Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions storage/blockchain/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ thiserror = { workspace = true, features = ["std"] }
cuprate-constants = { workspace = true }

proptest = { workspace = true }
tempfile = { workspace = true }

[lints]
workspace = true
135 changes: 135 additions & 0 deletions storage/blockchain/src/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use cuprate_helper::cast::{u64_to_usize, usize_to_u64};

use crate::{
config::Config,
constants::DATABASE_VERSION,
types::{Amount, BlockInfo, RctOutput, TxInfo},
BlockchainError,
};
Expand Down Expand Up @@ -142,6 +143,11 @@ impl BlockchainDatabase {
config: &Config,
fjall: fjall::Database,
) -> Result<Self, BlockchainError> {
// Verify (or initialize) the on-disk format version BEFORE opening the tapes, so an
// incompatible database is refused without creating any tape files.
let metadata = fjall.keyspace("metadata", KeyspaceCreateOptions::default)?;
Self::check_or_init_format_version(&metadata)?;

let block_heights = fjall.keyspace("block_heights", KeyspaceCreateOptions::default)?;
let key_images = fjall.keyspace("key_images", KeyspaceCreateOptions::default)?;
let pre_rct_outputs = fjall.keyspace("pre_rct_outputs", KeyspaceCreateOptions::default)?;
Expand Down Expand Up @@ -252,6 +258,32 @@ impl BlockchainDatabase {
})
}

/// Reads `b"format_version"` from `metadata`. If absent, stamps `DATABASE_VERSION`.
/// If present and equal, ok. Otherwise returns `DbFormatVersionMismatch` (never panics).
fn check_or_init_format_version(metadata: &fjall::Keyspace) -> Result<(), BlockchainError> {
match metadata.get(b"format_version")? {
None => {
metadata.insert(b"format_version", DATABASE_VERSION.to_le_bytes())?;
Ok(())
}
Some(bytes) => {
let mut found_bytes = [0_u8; 8];
let len = bytes.len().min(found_bytes.len());
found_bytes[..len].copy_from_slice(&bytes[..len]);
let found = u64::from_le_bytes(found_bytes);

if found == DATABASE_VERSION && bytes.len() == found_bytes.len() {
Ok(())
} else {
Err(BlockchainError::DbFormatVersionMismatch {
expected: DATABASE_VERSION,
found,
})
}
}
}
}

/// Checks if the fjall and tapes database are in sync and rebuilds the fjall database if it
/// is not.
pub fn make_consistent(&self) -> Result<(), BlockchainError> {
Expand Down Expand Up @@ -353,3 +385,106 @@ impl Drop for BlockchainDatabase {
let _ = self.linear_tapes.append().commit(Persistence::SyncAll);
}
}

#[cfg(test)]
mod tests {
use fjall::KeyspaceCreateOptions;

use super::BlockchainDatabase;
use crate::{config::Config, constants::DATABASE_VERSION, error::BlockchainError};

#[test]
fn fresh_db_open_stamps_format_version_in_metadata() {
let dir = tempfile::tempdir().unwrap();
let config = Config {
blob_dir: dir.path().into(),
index_dir: dir.path().into(),
..Default::default()
};
let fjall = fjall::Database::builder(dir.path()).open().unwrap();

let db = BlockchainDatabase::open_with_fjall_database(&config, fjall).unwrap();

let metadata = db
.fjall
.keyspace("metadata", KeyspaceCreateOptions::default)
.unwrap();
assert_eq!(
metadata.get(b"format_version").unwrap().as_deref(),
Some(DATABASE_VERSION.to_le_bytes().as_slice())
);
}

#[test]
fn reopening_db_with_matching_stored_version_succeeds() {
let dir = tempfile::tempdir().unwrap();
let config = Config {
blob_dir: dir.path().into(),
index_dir: dir.path().into(),
..Default::default()
};

let fjall = fjall::Database::builder(dir.path()).open().unwrap();
let db = BlockchainDatabase::open_with_fjall_database(&config, fjall).unwrap();
drop(db);

let fjall = fjall::Database::builder(dir.path()).open().unwrap();
let reopened = BlockchainDatabase::open_with_fjall_database(&config, fjall);

assert!(reopened.is_ok());
}

#[test]
fn opening_db_with_mismatched_stored_version_returns_error() {
let dir = tempfile::tempdir().unwrap();
let config = Config {
blob_dir: dir.path().into(),
index_dir: dir.path().into(),
..Default::default()
};
let fjall = fjall::Database::builder(dir.path()).open().unwrap();
let metadata = fjall
.keyspace("metadata", KeyspaceCreateOptions::default)
.unwrap();
metadata
.insert(b"format_version", 999_u64.to_le_bytes())
.unwrap();

let result = BlockchainDatabase::open_with_fjall_database(&config, fjall);

assert!(matches!(
result,
Err(BlockchainError::DbFormatVersionMismatch {
expected,
found
}) if expected == DATABASE_VERSION && found == 999
));
}

#[test]
fn opening_db_with_malformed_stored_version_returns_error() {
let dir = tempfile::tempdir().unwrap();
let config = Config {
blob_dir: dir.path().into(),
index_dir: dir.path().into(),
..Default::default()
};
let fjall = fjall::Database::builder(dir.path()).open().unwrap();
let metadata = fjall
.keyspace("metadata", KeyspaceCreateOptions::default)
.unwrap();
metadata.insert(b"format_version", b"bad").unwrap();

let result = BlockchainDatabase::open_with_fjall_database(&config, fjall);

assert!(result.is_err());
}

#[test]
fn db_version_property_matches_database_version_constant() {
assert_eq!(
crate::ops::property::db_version().unwrap(),
DATABASE_VERSION
);
}
}
2 changes: 2 additions & 0 deletions storage/blockchain/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ pub enum BlockchainError {
IO(#[from] std::io::Error),
#[error(transparent)]
Fjall(#[from] fjall::Error),
#[error("database format version mismatch: this binary supports format version {expected} but the on-disk database is version {found}; refusing to start (a future release may provide a migration)")]
DbFormatVersionMismatch { expected: u64, found: u64 },
#[error("not found")]
NotFound,
}
2 changes: 2 additions & 0 deletions storage/txpool/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
pub enum TxPoolError {
#[error("{0}")]
Fjall(#[from] fjall::Error),
#[error("database format version mismatch: this binary supports format version {expected} but the on-disk database is version {found}; refusing to start (a future release may provide a migration)")]
DbFormatVersionMismatch { expected: u64, found: u64 },
#[error("Required key not found")]
NotFound,
}
112 changes: 110 additions & 2 deletions storage/txpool/src/txpool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ use std::{collections::HashMap, sync::Mutex};

use fjall::{KeyspaceCreateOptions, KvSeparationOptions};

use crate::error::TxPoolError;

/// Current on-disk format version of the txpool database.
///
/// Incremented by 1 whenever the txpool database structure/schema changes.
/// Persisted under `b"format_version"` in the `metadata` keyspace and checked on open.
const DATABASE_FORMAT_VERSION: u64 = 0;

/// The txpool database.
pub struct TxpoolDatabase {
pub(crate) fjall_database: fjall::Database,
Expand All @@ -10,15 +18,14 @@ pub struct TxpoolDatabase {
pub(crate) tx_infos: fjall::Keyspace,
pub(crate) spent_key_images: fjall::Keyspace,
pub(crate) known_blob_hashes: fjall::Keyspace,
#[expect(dead_code)] // TODO: version?
pub(crate) metadata: fjall::Keyspace,

pub(crate) in_progress_key_images: Mutex<HashMap<[u8; 32], [u8; 32]>>,
}

impl TxpoolDatabase {
/// Open a txpool database with the given fjall backing database.
pub fn open_with_database(fjall_database: fjall::Database) -> fjall::Result<Self> {
pub fn open_with_database(fjall_database: fjall::Database) -> Result<Self, TxPoolError> {
let s = Self {
tx_blobs: fjall_database.keyspace("tx_blobs", || {
KeyspaceCreateOptions::default().with_kv_separation(Some(
Expand All @@ -35,6 +42,107 @@ impl TxpoolDatabase {
in_progress_key_images: Mutex::new(HashMap::new()),
};

Self::check_or_init_format_version(&s.metadata)?;

Ok(s)
}

/// Reads `b"format_version"` from `metadata`. If absent, stamps `DATABASE_FORMAT_VERSION`.
/// If present and equal, ok. Otherwise returns `DbFormatVersionMismatch` (never panics).
fn check_or_init_format_version(metadata: &fjall::Keyspace) -> Result<(), TxPoolError> {
match metadata.get(b"format_version")? {
None => {
metadata.insert(b"format_version", DATABASE_FORMAT_VERSION.to_le_bytes())?;
Ok(())
}
Some(bytes) => {
let mut version_bytes = [0_u8; 8];
let copy_len = bytes.len().min(version_bytes.len());
version_bytes[..copy_len].copy_from_slice(&bytes[..copy_len]);

let found = u64::from_le_bytes(version_bytes);

if found == DATABASE_FORMAT_VERSION && bytes.len() == version_bytes.len() {
Ok(())
} else {
Err(TxPoolError::DbFormatVersionMismatch {
expected: DATABASE_FORMAT_VERSION,
found,
})
}
}
}
}
}

#[cfg(test)]
mod tests {
use super::{TxpoolDatabase, DATABASE_FORMAT_VERSION};
use crate::error::TxPoolError;

#[test]
fn open_with_database_stamps_format_version_on_fresh_db() {
let dir = tempfile::tempdir().unwrap();
let fjall = fjall::Database::builder(dir.path()).open().unwrap();
let db = TxpoolDatabase::open_with_database(fjall).unwrap();

let stored = db.metadata.get(b"format_version").unwrap().unwrap();

assert_eq!(stored.as_ref(), DATABASE_FORMAT_VERSION.to_le_bytes());
}

#[test]
fn open_with_database_allows_reopen_with_matching_format_version() {
let dir = tempfile::tempdir().unwrap();
let fjall = fjall::Database::builder(dir.path()).open().unwrap();
let db = TxpoolDatabase::open_with_database(fjall).unwrap();

drop(db);

let fjall = fjall::Database::builder(dir.path()).open().unwrap();
let reopened = TxpoolDatabase::open_with_database(fjall);

assert!(reopened.is_ok());
}

#[test]
fn open_with_database_rejects_mismatched_format_version() {
let dir = tempfile::tempdir().unwrap();
let fjall = fjall::Database::builder(dir.path()).open().unwrap();
let metadata = fjall
.keyspace("metadata", fjall::KeyspaceCreateOptions::default)
.unwrap();
metadata
.insert(b"format_version", 999_u64.to_le_bytes())
.unwrap();

match TxpoolDatabase::open_with_database(fjall) {
Err(TxPoolError::DbFormatVersionMismatch { expected, found }) => {
assert_eq!(expected, DATABASE_FORMAT_VERSION);
assert_eq!(found, 999);
}
Err(other) => panic!("unexpected error: {other:?}"),
Ok(_) => panic!("expected format version mismatch error"),
}
}

#[test]
fn open_with_database_rejects_malformed_format_version_value() {
let dir = tempfile::tempdir().unwrap();
let fjall = fjall::Database::builder(dir.path()).open().unwrap();
let metadata = fjall
.keyspace("metadata", fjall::KeyspaceCreateOptions::default)
.unwrap();
metadata.insert(b"format_version", [1_u8, 2, 3]).unwrap();

let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
TxpoolDatabase::open_with_database(fjall)
}));

assert!(
result.is_ok(),
"open_with_database panicked on malformed version"
);
assert!(result.unwrap().is_err());
}
}
Loading