From 6a4334a603e78ffd594026afabb85060c54778e6 Mon Sep 17 00:00:00 2001 From: Esteve Soler Arderiu Date: Thu, 23 Apr 2026 15:49:07 +0200 Subject: [PATCH 1/5] feat(storage): add automatic DB migration framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of forcing a full resync on every schema version bump, the storage layer now runs migration functions automatically. The MIGRATIONS array in migrations.rs maps each version upgrade (v1→v2 is index 0, v2→v3 is index 1, etc.), with a compile-time assert ensuring the array length stays in sync with STORE_SCHEMA_VERSION. Store::new detects the on-disk version and runs pending migrations before proceeding, writing metadata with fsync after each step for crash safety. has_valid_db now accepts any migratable version (1..=STORE_SCHEMA_VERSION). Currently empty (version stays at 1) — this is the framework only. --- cmd/ethrex/initializers.rs | 5 ++ crates/storage/error.rs | 2 + crates/storage/lib.rs | 6 +- crates/storage/migrations.rs | 133 +++++++++++++++++++++++++++++++++++ crates/storage/store.rs | 91 ++++++++++++++++-------- 5 files changed, 206 insertions(+), 31 deletions(-) create mode 100644 crates/storage/migrations.rs diff --git a/cmd/ethrex/initializers.rs b/cmd/ethrex/initializers.rs index 3c284e15538..65b10f64984 100644 --- a/cmd/ethrex/initializers.rs +++ b/cmd/ethrex/initializers.rs @@ -501,6 +501,11 @@ pub async fn init_l1( "{err}. Please erase your DB by running `ethrex removedb` and restart node to resync. Note that this will take a while." )); } + Err(err @ StoreError::MigrationFailed { .. }) => { + return Err(eyre::eyre!( + "{err}. The database may be in an inconsistent state. Please erase your DB by running `ethrex removedb` and restart node to resync." + )); + } Err(error) => return Err(eyre::eyre!("Failed to create Store: {error}")), }; diff --git a/crates/storage/error.rs b/crates/storage/error.rs index 318115fb1b9..93dd1f02211 100644 --- a/crates/storage/error.rs +++ b/crates/storage/error.rs @@ -48,4 +48,6 @@ pub enum StoreError { NotFoundDBVersion { expected: u64 }, #[error("Incompatible DB Version: found v{found}, expected v{expected}")] IncompatibleDBVersion { found: u64, expected: u64 }, + #[error("Migration from v{from} to v{to} failed: {reason}")] + MigrationFailed { from: u64, to: u64, reason: String }, } diff --git a/crates/storage/lib.rs b/crates/storage/lib.rs index c2868f6049f..876911539e5 100644 --- a/crates/storage/lib.rs +++ b/crates/storage/lib.rs @@ -68,6 +68,7 @@ pub mod api; pub mod backend; pub mod error; mod layering; +pub mod migrations; pub mod rlp; pub mod store; pub mod trie; @@ -81,8 +82,9 @@ pub use store::{ /// Store Schema Version, must be updated on any breaking change. /// -/// An upgrade to a newer schema version invalidates currently stored data, -/// requiring a re-sync from genesis or a snapshot. +/// When bumping this version, add a corresponding migration function to +/// `migrations::MIGRATIONS`. The migration framework will automatically +/// upgrade existing databases instead of requiring a full resync. pub const STORE_SCHEMA_VERSION: u64 = 1; /// Name of the file storing the metadata about the database. diff --git a/crates/storage/migrations.rs b/crates/storage/migrations.rs new file mode 100644 index 00000000000..bf87d521d2b --- /dev/null +++ b/crates/storage/migrations.rs @@ -0,0 +1,133 @@ +use std::io::Write; +use std::path::Path; + +use crate::api::StorageBackend; +use crate::error::StoreError; +use crate::{STORE_METADATA_FILENAME, STORE_SCHEMA_VERSION}; + +use super::store::StoreMetadata; + +/// A migration function that upgrades the database schema by one version. +/// +/// Receives a reference to the storage backend so it can read/write data +/// as needed for the migration. +pub type MigrationFn = fn(backend: &dyn StorageBackend) -> Result<(), StoreError>; + +/// Migration functions indexed by source version. +/// +/// `MIGRATIONS[i]` upgrades the schema from version `(i + 1)` to `(i + 2)`. +/// For example: +/// - `MIGRATIONS[0]` upgrades v1 → v2 +/// - `MIGRATIONS[1]` upgrades v2 → v3 +/// +/// **Invariant**: `MIGRATIONS.len() == (STORE_SCHEMA_VERSION - 1) as usize` +/// (empty when `STORE_SCHEMA_VERSION == 1`, one entry when it's 2, etc.) +pub const MIGRATIONS: &[MigrationFn] = &[ + // Currently empty — no migrations exist yet. + // When STORE_SCHEMA_VERSION is bumped to 2, add migrate_1_to_2 here. +]; + +// Compile-time check: the number of migration functions must match the number +// of version gaps (i.e. STORE_SCHEMA_VERSION - 1). +const _: () = assert!( + MIGRATIONS.len() == (STORE_SCHEMA_VERSION - 1) as usize, + "MIGRATIONS length must equal STORE_SCHEMA_VERSION - 1" +); + +/// Runs all pending migrations from `current_version` up to `STORE_SCHEMA_VERSION`. +/// +/// Each migration is applied one version at a time, and the metadata file is +/// updated (with fsync) after each successful step for crash safety. +/// +/// Returns `Ok(())` if `current_version == STORE_SCHEMA_VERSION` (no-op). +pub fn run_pending_migrations( + backend: &dyn StorageBackend, + db_path: &Path, + current_version: u64, +) -> Result<(), StoreError> { + assert!( + MIGRATIONS.len() == (STORE_SCHEMA_VERSION - 1) as usize, + "MIGRATIONS length must equal STORE_SCHEMA_VERSION - 1" + ); + + for version in current_version..STORE_SCHEMA_VERSION { + let idx = (version - 1) as usize; // v1→v2 is index 0 + let target = version + 1; + + tracing::info!("Running migration v{version} → v{target}"); + + MIGRATIONS[idx](backend).map_err(|e| StoreError::MigrationFailed { + from: version, + to: target, + reason: e.to_string(), + })?; + + // Persist the new version to metadata.json after each migration step + write_metadata_version(db_path, target)?; + + tracing::info!("Migration v{version} → v{target} completed"); + } + + Ok(()) +} + +/// Atomically writes the schema version to metadata.json with fsync for crash safety. +fn write_metadata_version(db_path: &Path, version: u64) -> Result<(), StoreError> { + let metadata_path = db_path.join(STORE_METADATA_FILENAME); + let metadata = StoreMetadata::new(version); + let serialized = serde_json::to_string_pretty(&metadata)?; + + let mut file = std::fs::File::create(&metadata_path)?; + file.write_all(serialized.as_bytes())?; + file.sync_all()?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn migrations_length_matches_schema_version() { + assert_eq!( + MIGRATIONS.len(), + (STORE_SCHEMA_VERSION - 1) as usize, + "MIGRATIONS array length must be STORE_SCHEMA_VERSION - 1" + ); + } + + #[test] + fn run_pending_migrations_noop_when_current() { + // When current_version == STORE_SCHEMA_VERSION, nothing should happen. + // We use a dummy in-memory backend since no migrations will actually run. + let backend = crate::backend::in_memory::InMemoryBackend::open().unwrap(); + let temp_dir = std::env::temp_dir().join("ethrex_migration_test_noop"); + std::fs::create_dir_all(&temp_dir).ok(); + + // Write initial metadata + write_metadata_version(&temp_dir, STORE_SCHEMA_VERSION).unwrap(); + + let result = run_pending_migrations(&backend, &temp_dir, STORE_SCHEMA_VERSION); + assert!(result.is_ok()); + + // Clean up + std::fs::remove_dir_all(&temp_dir).ok(); + } + + #[test] + fn fresh_store_creates_correct_metadata() { + let temp_dir = std::env::temp_dir().join("ethrex_migration_test_fresh"); + std::fs::create_dir_all(&temp_dir).ok(); + + write_metadata_version(&temp_dir, STORE_SCHEMA_VERSION).unwrap(); + + let metadata_path = temp_dir.join(STORE_METADATA_FILENAME); + let contents = std::fs::read_to_string(&metadata_path).unwrap(); + let metadata: StoreMetadata = serde_json::from_str(&contents).unwrap(); + assert_eq!(metadata.schema_version, STORE_SCHEMA_VERSION); + + // Clean up + std::fs::remove_dir_all(&temp_dir).ok(); + } +} diff --git a/crates/storage/store.rs b/crates/storage/store.rs index 9b16879b105..2dde1f11b6e 100644 --- a/crates/storage/store.rs +++ b/crates/storage/store.rs @@ -1451,12 +1451,57 @@ impl Store { } pub fn new(path: impl AsRef, engine_type: EngineType) -> Result { - // Ignore unused variable warning when compiling without DB features let db_path = path.as_ref().to_path_buf(); if engine_type != EngineType::InMemory { - // Check that the last used DB version matches the current version - validate_store_schema_version(&db_path)?; + let version = read_store_schema_version(&db_path)?; + + match version { + None if db_path.exists() && !dir_is_empty(&db_path)? => { + // Pre-metadata DB — cannot migrate safely + return Err(StoreError::NotFoundDBVersion { + expected: STORE_SCHEMA_VERSION, + }); + } + None => { + // Fresh / empty directory — write initial metadata + init_metadata_file(&db_path)?; + } + Some(v) if v < 1 => { + return Err(StoreError::IncompatibleDBVersion { + found: v, + expected: STORE_SCHEMA_VERSION, + }); + } + Some(v) if v > STORE_SCHEMA_VERSION => { + return Err(StoreError::IncompatibleDBVersion { + found: v, + expected: STORE_SCHEMA_VERSION, + }); + } + #[cfg(feature = "rocksdb")] + Some(v) if v < STORE_SCHEMA_VERSION => { + // Open backend, run migrations, then proceed with the same Arc + let backend: Arc = + Arc::new(RocksDBBackend::open(&path)?); + crate::migrations::run_pending_migrations( + backend.as_ref(), + &db_path, + v, + )?; + return Self::from_backend(backend, db_path, DB_COMMIT_THRESHOLD); + } + #[cfg(not(feature = "rocksdb"))] + Some(v) if v < STORE_SCHEMA_VERSION => { + return Err(StoreError::IncompatibleDBVersion { + found: v, + expected: STORE_SCHEMA_VERSION, + }); + } + Some(_) => { + // version == STORE_SCHEMA_VERSION, proceed normally + } + } } match engine_type { @@ -3186,29 +3231,24 @@ impl LatestBlockHeaderCache { } #[derive(Debug, Serialize, Deserialize)] -struct StoreMetadata { - schema_version: u64, +pub struct StoreMetadata { + pub schema_version: u64, } impl StoreMetadata { - fn new(schema_version: u64) -> Self { + pub fn new(schema_version: u64) -> Self { Self { schema_version } } } -fn validate_store_schema_version(path: &Path) -> Result<(), StoreError> { +/// Reads the schema version from the metadata file, if it exists. +/// +/// Returns `Some(version)` when metadata.json is present and valid, +/// or `None` when the file does not exist. +fn read_store_schema_version(path: &Path) -> Result, StoreError> { let metadata_path = path.join(STORE_METADATA_FILENAME); - // If metadata file does not exist, try to create it if !metadata_path.exists() { - // If datadir exists but is not empty, this is probably a DB for an - // old ethrex version and we should return an error - if path.exists() && !dir_is_empty(path)? { - return Err(StoreError::NotFoundDBVersion { - expected: STORE_SCHEMA_VERSION, - }); - } - init_metadata_file(path)?; - return Ok(()); + return Ok(None); } if !metadata_path.is_file() { return Err(StoreError::Custom( @@ -3217,15 +3257,7 @@ fn validate_store_schema_version(path: &Path) -> Result<(), StoreError> { } let file_contents = std::fs::read_to_string(metadata_path)?; let metadata: StoreMetadata = serde_json::from_str(&file_contents)?; - - // Check schema version matches the expected one - if metadata.schema_version != STORE_SCHEMA_VERSION { - return Err(StoreError::IncompatibleDBVersion { - found: metadata.schema_version, - expected: STORE_SCHEMA_VERSION, - }); - } - Ok(()) + Ok(Some(metadata.schema_version)) } fn init_metadata_file(parent_path: &Path) -> Result<(), StoreError> { @@ -3244,8 +3276,9 @@ fn dir_is_empty(path: &Path) -> Result { Ok(is_empty) } -/// Checks whether a valid database exists at the given path by looking for -/// a metadata.json file with a matching schema version. +/// Checks whether a valid (or migratable) database exists at the given path +/// by looking for a metadata.json file with a schema version between 1 and +/// `STORE_SCHEMA_VERSION` (inclusive). pub fn has_valid_db(path: &Path) -> bool { let metadata_path = path.join(STORE_METADATA_FILENAME); if !metadata_path.is_file() { @@ -3257,7 +3290,7 @@ pub fn has_valid_db(path: &Path) -> bool { let Ok(metadata) = serde_json::from_str::(&contents) else { return false; }; - metadata.schema_version == STORE_SCHEMA_VERSION + metadata.schema_version >= 1 && metadata.schema_version <= STORE_SCHEMA_VERSION } /// Reads the chain ID from an existing database without performing a full From 018163dfd39110db26f712986044f03bfe67594c Mon Sep 17 00:00:00 2001 From: Esteve Soler Arderiu Date: Thu, 23 Apr 2026 16:00:59 +0200 Subject: [PATCH 2/5] style(l1): fix formatting in store.rs --- crates/storage/store.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/storage/store.rs b/crates/storage/store.rs index 2dde1f11b6e..ccd03a7df42 100644 --- a/crates/storage/store.rs +++ b/crates/storage/store.rs @@ -1484,11 +1484,7 @@ impl Store { // Open backend, run migrations, then proceed with the same Arc let backend: Arc = Arc::new(RocksDBBackend::open(&path)?); - crate::migrations::run_pending_migrations( - backend.as_ref(), - &db_path, - v, - )?; + crate::migrations::run_pending_migrations(backend.as_ref(), &db_path, v)?; return Self::from_backend(backend, db_path, DB_COMMIT_THRESHOLD); } #[cfg(not(feature = "rocksdb"))] From 26a2877d44b1fa27fd615a62411874b2c72c9aae Mon Sep 17 00:00:00 2001 From: Esteve Soler Arderiu Date: Thu, 23 Apr 2026 17:24:45 +0200 Subject: [PATCH 3/5] fix(l1): address review feedback on migration framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use write-to-temp-then-rename pattern in write_metadata_version for crash-safe metadata updates (atomic on POSIX filesystems) - Remove unreachable #[cfg(not(feature = "rocksdb"))] match arm in Store::new — without rocksdb the only engine is InMemory, so the outer guard already excludes this path - Use tempfile::tempdir() in tests instead of fixed paths to avoid flakiness under parallel test runners --- Cargo.lock | 1 + crates/storage/Cargo.toml | 1 + crates/storage/migrations.rs | 28 ++++++++++++---------------- crates/storage/store.rs | 12 ++++-------- 4 files changed, 18 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 334280fb331..23870bdc5c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4403,6 +4403,7 @@ dependencies = [ "rustc-hash 2.1.2", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", "tokio", "tracing", diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 9771eabb964..faaeabf5048 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -32,6 +32,7 @@ default = [] rocksdb = ["dep:rocksdb"] [dev-dependencies] +tempfile.workspace = true tokio = { workspace = true, features = ["full"] } [lib] diff --git a/crates/storage/migrations.rs b/crates/storage/migrations.rs index bf87d521d2b..1fe26522fb2 100644 --- a/crates/storage/migrations.rs +++ b/crates/storage/migrations.rs @@ -71,15 +71,19 @@ pub fn run_pending_migrations( Ok(()) } -/// Atomically writes the schema version to metadata.json with fsync for crash safety. +/// Writes the schema version to metadata.json using write-to-temp-then-rename +/// for crash safety. On POSIX filesystems `rename` is atomic, so the metadata +/// file is never left in a partial/truncated state. fn write_metadata_version(db_path: &Path, version: u64) -> Result<(), StoreError> { let metadata_path = db_path.join(STORE_METADATA_FILENAME); + let tmp_path = db_path.join(format!("{}.tmp", STORE_METADATA_FILENAME)); let metadata = StoreMetadata::new(version); let serialized = serde_json::to_string_pretty(&metadata)?; - let mut file = std::fs::File::create(&metadata_path)?; + let mut file = std::fs::File::create(&tmp_path)?; file.write_all(serialized.as_bytes())?; file.sync_all()?; + std::fs::rename(&tmp_path, &metadata_path)?; Ok(()) } @@ -102,32 +106,24 @@ mod tests { // When current_version == STORE_SCHEMA_VERSION, nothing should happen. // We use a dummy in-memory backend since no migrations will actually run. let backend = crate::backend::in_memory::InMemoryBackend::open().unwrap(); - let temp_dir = std::env::temp_dir().join("ethrex_migration_test_noop"); - std::fs::create_dir_all(&temp_dir).ok(); + let temp_dir = tempfile::tempdir().unwrap(); // Write initial metadata - write_metadata_version(&temp_dir, STORE_SCHEMA_VERSION).unwrap(); + write_metadata_version(temp_dir.path(), STORE_SCHEMA_VERSION).unwrap(); - let result = run_pending_migrations(&backend, &temp_dir, STORE_SCHEMA_VERSION); + let result = run_pending_migrations(&backend, temp_dir.path(), STORE_SCHEMA_VERSION); assert!(result.is_ok()); - - // Clean up - std::fs::remove_dir_all(&temp_dir).ok(); } #[test] fn fresh_store_creates_correct_metadata() { - let temp_dir = std::env::temp_dir().join("ethrex_migration_test_fresh"); - std::fs::create_dir_all(&temp_dir).ok(); + let temp_dir = tempfile::tempdir().unwrap(); - write_metadata_version(&temp_dir, STORE_SCHEMA_VERSION).unwrap(); + write_metadata_version(temp_dir.path(), STORE_SCHEMA_VERSION).unwrap(); - let metadata_path = temp_dir.join(STORE_METADATA_FILENAME); + let metadata_path = temp_dir.path().join(STORE_METADATA_FILENAME); let contents = std::fs::read_to_string(&metadata_path).unwrap(); let metadata: StoreMetadata = serde_json::from_str(&contents).unwrap(); assert_eq!(metadata.schema_version, STORE_SCHEMA_VERSION); - - // Clean up - std::fs::remove_dir_all(&temp_dir).ok(); } } diff --git a/crates/storage/store.rs b/crates/storage/store.rs index ccd03a7df42..a655ba2d64c 100644 --- a/crates/storage/store.rs +++ b/crates/storage/store.rs @@ -1487,15 +1487,11 @@ impl Store { crate::migrations::run_pending_migrations(backend.as_ref(), &db_path, v)?; return Self::from_backend(backend, db_path, DB_COMMIT_THRESHOLD); } - #[cfg(not(feature = "rocksdb"))] - Some(v) if v < STORE_SCHEMA_VERSION => { - return Err(StoreError::IncompatibleDBVersion { - found: v, - expected: STORE_SCHEMA_VERSION, - }); - } Some(_) => { - // version == STORE_SCHEMA_VERSION, proceed normally + // version == STORE_SCHEMA_VERSION, proceed normally. + // Without the `rocksdb` feature this also covers v < target, + // but that path is unreachable since InMemory is the only + // engine type and the outer guard excludes it. } } } From 0507639e97c2fa0596e5e51c8274a031dca23afd Mon Sep 17 00:00:00 2001 From: Esteve Soler Arderiu Date: Mon, 27 Apr 2026 12:38:43 +0200 Subject: [PATCH 4/5] fix(l1): wrap metadata write error in MigrationFailed If write_metadata_version fails during a migration, the error is now wrapped in MigrationFailed with the version context, matching the handling for the migration function itself. --- crates/storage/migrations.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/storage/migrations.rs b/crates/storage/migrations.rs index 1fe26522fb2..d32ced73b93 100644 --- a/crates/storage/migrations.rs +++ b/crates/storage/migrations.rs @@ -63,7 +63,11 @@ pub fn run_pending_migrations( })?; // Persist the new version to metadata.json after each migration step - write_metadata_version(db_path, target)?; + write_metadata_version(db_path, target).map_err(|e| StoreError::MigrationFailed { + from: version, + to: target, + reason: format!("failed to write metadata: {e}"), + })?; tracing::info!("Migration v{version} → v{target} completed"); } From 2f9bd1a4108dbd6817370503713f7f9f9194ee5c Mon Sep 17 00:00:00 2001 From: Esteve Soler Arderiu Date: Wed, 29 Apr 2026 12:18:48 +0200 Subject: [PATCH 5/5] fix(l1): address second round of review feedback on migrations - Make NotFoundDBVersion fieldless with descriptive error message - Use MigrationFailed for invalid version (v < 1) and future version (v > STORE_SCHEMA_VERSION) instead of IncompatibleDBVersion - Remove redundant runtime assert (compile-time const assert suffices) - Extract migration_for_version() helper for clarity - Add TODO for moving metadata persistence into StorageBackend --- cmd/ethrex/initializers.rs | 2 +- crates/storage/error.rs | 6 ++++-- crates/storage/migrations.rs | 15 ++++++++------- crates/storage/store.rs | 20 +++++++++++--------- 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/cmd/ethrex/initializers.rs b/cmd/ethrex/initializers.rs index 6e54fdde9fa..e52546325c8 100644 --- a/cmd/ethrex/initializers.rs +++ b/cmd/ethrex/initializers.rs @@ -496,7 +496,7 @@ pub async fn init_l1( let store = match init_store(&datadir, genesis).await { Ok(store) => store, Err(err @ StoreError::IncompatibleDBVersion { .. }) - | Err(err @ StoreError::NotFoundDBVersion { .. }) => { + | Err(err @ StoreError::NotFoundDBVersion) => { return Err(eyre::eyre!( "{err}. Please erase your DB by running `ethrex removedb` and restart node to resync. Note that this will take a while." )); diff --git a/crates/storage/error.rs b/crates/storage/error.rs index 93dd1f02211..5d620f735fd 100644 --- a/crates/storage/error.rs +++ b/crates/storage/error.rs @@ -44,8 +44,10 @@ pub enum StoreError { IoError(#[from] std::io::Error), #[error("Error serializing metadata: {0}")] DbMetadataError(#[from] serde_json::Error), - #[error("Incompatible DB Version: not found, expected v{expected}")] - NotFoundDBVersion { expected: u64 }, + #[error( + "Cannot migrate the database: its version is unavailable, which means it predates versioning and migrations. A full resync (removedb) is required." + )] + NotFoundDBVersion, #[error("Incompatible DB Version: found v{found}, expected v{expected}")] IncompatibleDBVersion { found: u64, expected: u64 }, #[error("Migration from v{from} to v{to} failed: {reason}")] diff --git a/crates/storage/migrations.rs b/crates/storage/migrations.rs index d32ced73b93..2f224601472 100644 --- a/crates/storage/migrations.rs +++ b/crates/storage/migrations.rs @@ -34,6 +34,11 @@ const _: () = assert!( "MIGRATIONS length must equal STORE_SCHEMA_VERSION - 1" ); +/// Returns the migration function that upgrades from `version` to `version + 1`. +fn migration_for_version(version: u64) -> MigrationFn { + MIGRATIONS[(version - 1) as usize] +} + /// Runs all pending migrations from `current_version` up to `STORE_SCHEMA_VERSION`. /// /// Each migration is applied one version at a time, and the metadata file is @@ -45,18 +50,12 @@ pub fn run_pending_migrations( db_path: &Path, current_version: u64, ) -> Result<(), StoreError> { - assert!( - MIGRATIONS.len() == (STORE_SCHEMA_VERSION - 1) as usize, - "MIGRATIONS length must equal STORE_SCHEMA_VERSION - 1" - ); - for version in current_version..STORE_SCHEMA_VERSION { - let idx = (version - 1) as usize; // v1→v2 is index 0 let target = version + 1; tracing::info!("Running migration v{version} → v{target}"); - MIGRATIONS[idx](backend).map_err(|e| StoreError::MigrationFailed { + migration_for_version(version)(backend).map_err(|e| StoreError::MigrationFailed { from: version, to: target, reason: e.to_string(), @@ -78,6 +77,8 @@ pub fn run_pending_migrations( /// Writes the schema version to metadata.json using write-to-temp-then-rename /// for crash safety. On POSIX filesystems `rename` is atomic, so the metadata /// file is never left in a partial/truncated state. +// TODO: move metadata persistence into the StorageBackend abstraction so we +// don't need to pass `db_path` around. fn write_metadata_version(db_path: &Path, version: u64) -> Result<(), StoreError> { let metadata_path = db_path.join(STORE_METADATA_FILENAME); let tmp_path = db_path.join(format!("{}.tmp", STORE_METADATA_FILENAME)); diff --git a/crates/storage/store.rs b/crates/storage/store.rs index ee9f41e877f..56158c8ce6a 100644 --- a/crates/storage/store.rs +++ b/crates/storage/store.rs @@ -1461,24 +1461,26 @@ impl Store { match version { None if db_path.exists() && !dir_is_empty(&db_path)? => { // Pre-metadata DB — cannot migrate safely - return Err(StoreError::NotFoundDBVersion { - expected: STORE_SCHEMA_VERSION, - }); + return Err(StoreError::NotFoundDBVersion); } None => { // Fresh / empty directory — write initial metadata init_metadata_file(&db_path)?; } Some(v) if v < 1 => { - return Err(StoreError::IncompatibleDBVersion { - found: v, - expected: STORE_SCHEMA_VERSION, + return Err(StoreError::MigrationFailed { + from: v, + to: STORE_SCHEMA_VERSION, + reason: format!("DB version v{v} is invalid (predates migrations)"), }); } Some(v) if v > STORE_SCHEMA_VERSION => { - return Err(StoreError::IncompatibleDBVersion { - found: v, - expected: STORE_SCHEMA_VERSION, + return Err(StoreError::MigrationFailed { + from: v, + to: STORE_SCHEMA_VERSION, + reason: format!( + "DB version v{v} is more recent than the client expects (v{STORE_SCHEMA_VERSION}). Rolling back is not supported" + ), }); } #[cfg(feature = "rocksdb")]