From 7cf5269c05b1e3f7cc29ce48d27979ed056e46a0 Mon Sep 17 00:00:00 2001 From: Ben Hagen Date: Thu, 28 May 2026 09:45:08 +0200 Subject: [PATCH] fix(store): migrate redb 2.x tuple tables on open Stores written by iroh-docs 0.94..=0.98 (redb 2.6) carry the old on-disk type tag for variable-width tuples on records-1, records-by-key-1, and latest-by-author-1. Opening them under 0.99+ (redb 4) fails with TableTypeMismatch because the 2 to 4 jump in #100 skipped the redb 3.x Legacy<...> migration window. When Store::persistent hits TableTypeMismatch, open the file with redb 3 via Legacy wrappers on the three affected tables, copy everything into a fresh redb 3 file with plain tuple types, and swap files. The original is preserved at .backup-redb-v2-tuples. redb 4 then opens the result. Closes #104 --- Cargo.lock | 14 +- Cargo.toml | 4 +- src/store/fs.rs | 161 +++++++++++++++++++++-- src/store/fs/migrate_redb_v2_tuples.rs | 169 +++++++++++++++++++++++++ 4 files changed, 336 insertions(+), 12 deletions(-) create mode 100644 src/store/fs/migrate_redb_v2_tuples.rs diff --git a/Cargo.lock b/Cargo.lock index 767ba2f0..88c3e380 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2056,7 +2056,7 @@ dependencies = [ "postcard", "rand 0.10.1", "range-collections", - "redb", + "redb 4.1.0", "ref-cast", "reflink-copy", "self_cell", @@ -2119,7 +2119,8 @@ dependencies = [ "postcard", "proptest", "rand 0.10.1", - "redb", + "redb 3.1.3", + "redb 4.1.0", "self_cell", "serde", "serde-error", @@ -3522,6 +3523,15 @@ dependencies = [ "yasna", ] +[[package]] +name = "redb" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba239c1c1693315d3cc0e601db3b3965543afbf48c41730fdca2f069f510f4a" +dependencies = [ + "libc", +] + [[package]] name = "redb" version = "4.1.0" diff --git a/Cargo.toml b/Cargo.toml index 875eace3..61605bc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ postcard = { version = "1", default-features = false, features = [ noq = { version = "=1.0.0-rc.1", optional = true } rand = "0.10" redb = { version = "4.1" } +redb_v3 = { package = "redb", version = "3.1", optional = true } self_cell = "1.0.3" serde = { version = "1.0.164", features = ["derive"] } serde-error = "0.1.3" @@ -73,10 +74,11 @@ tracing-test = "0.2.5" tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } [features] -default = ["metrics", "rpc", "fs-store"] +default = ["metrics", "rpc", "fs-store", "redb-v2-migration"] metrics = ["iroh-metrics/metrics", "iroh/metrics"] rpc = ["dep:noq", "irpc/rpc", "iroh-blobs/rpc"] fs-store = ["iroh-blobs/fs-store"] +redb-v2-migration = ["dep:redb_v3"] [package.metadata.docs.rs] all-features = true diff --git a/src/store/fs.rs b/src/store/fs.rs index f72f1e6a..b9143f96 100644 --- a/src/store/fs.rs +++ b/src/store/fs.rs @@ -14,6 +14,8 @@ use iroh_blobs::Hash; use n0_future::time::SystemTime; use rand::CryptoRng; use redb::{Database, ReadableDatabase, ReadableMultimapTable, ReadableTable}; +#[cfg(all(feature = "fs-store", feature = "redb-v2-migration"))] +use tracing::info; use tracing::warn; use super::{ @@ -30,6 +32,8 @@ use crate::{ }; mod bounds; +#[cfg(all(feature = "fs-store", feature = "redb-v2-migration"))] +mod migrate_redb_v2_tuples; mod migrations; mod query; mod ranges; @@ -84,6 +88,27 @@ enum CurrentTransaction { Write(TransactionAndTables), } +#[cfg(feature = "fs-store")] +fn open_database(path: &std::path::Path) -> Result { + match Database::create(path) { + Ok(db) => Ok(db), + Err(redb::DatabaseError::UpgradeRequired(v)) => Err(anyhow!( + "Opening the database failed: Upgrading from redb {v} no longer supported. Use an older redb version first." + )), + Err(err) => Err(err.into()), + } +} + +#[cfg(feature = "fs-store")] +fn is_redb_v2_tuple_mismatch(err: &anyhow::Error) -> bool { + err.chain().any(|e| { + matches!( + e.downcast_ref::(), + Some(redb::TableError::TableTypeMismatch { .. }) + ) + }) +} + impl Store { /// Create a new store in memory. pub fn memory() -> Self { @@ -100,16 +125,27 @@ impl Store { /// The file will be created if it does not exist, otherwise it will be opened. #[cfg(feature = "fs-store")] pub fn persistent(path: impl AsRef) -> Result { - let db = match Database::create(&path) { - Ok(db) => db, - Err(redb::DatabaseError::UpgradeRequired(v)) => { - return Err(anyhow!( - "Opening the database failed: Upgrading from redb {v} longer supported. Use and older redb version first." - )); + let path = path.as_ref(); + let db = open_database(path)?; + match Self::new_impl(db) { + Ok(store) => Ok(store), + Err(err) if is_redb_v2_tuple_mismatch(&err) => { + #[cfg(feature = "redb-v2-migration")] + { + info!("redb 2.x tuple format detected, running migration"); + migrate_redb_v2_tuples::run(path)?; + Self::new_impl(open_database(path)?) + } + #[cfg(not(feature = "redb-v2-migration"))] + { + let _ = err; + Err(anyhow!( + "Opening the database failed: this store was written by iroh-docs 0.94..=0.98 (redb 2.x) and needs migration. Enable the `redb-v2-migration` feature on iroh-docs and re-open to migrate." + )) + } } - Err(err) => return Err(err.into()), - }; - Self::new_impl(db) + Err(err) => Err(err), + } } fn new_impl(db: redb::Database) -> Result { @@ -1167,4 +1203,111 @@ mod tests { // TODO: write test checking that the indexing is done correctly Ok(()) } + + #[test] + #[cfg(all(feature = "fs-store", feature = "redb-v2-migration"))] + fn test_migration_redb_v2_tuples() -> Result<()> { + use migrate_redb_v2_tuples::old; + + let dbfile = tempfile::NamedTempFile::new()?; + let path = dbfile.path().to_path_buf(); + + let ns = [1u8; 32]; + let author = [2u8; 32]; + let key: &[u8] = b"hello"; + let ns_sig = [3u8; 64]; + let auth_sig = [4u8; 64]; + let hash = [5u8; 32]; + + { + let db = redb_v3::Database::create(&path)?; + let tx = db.begin_write()?; + { + let mut records = tx.open_table(old::RECORDS_TABLE)?; + let mut latest = tx.open_table(old::LATEST_PER_AUTHOR_TABLE)?; + let mut by_key = tx.open_table(old::RECORDS_BY_KEY_TABLE)?; + records.insert( + (&ns, &author, key), + (42u64, &ns_sig, &auth_sig, 7u64, &hash), + )?; + latest.insert((&ns, &author), (42u64, key))?; + by_key.insert((&ns, key, &author), ())?; + } + tx.commit()?; + } + + // Confirm redb 4 rejects the file before migration. + { + let db = redb::Database::create(&path)?; + let tx = db.begin_write()?; + let err = Tables::new(&tx).unwrap_err(); + assert!( + matches!(err, redb::TableError::TableTypeMismatch { .. }), + "expected TableTypeMismatch, got {err:?}", + ); + } + + let store = Store::persistent(&path)?; + drop(store); + + let backup: std::path::PathBuf = { + let mut p = path.clone().into_os_string(); + p.push(".backup-redb-v2-tuples"); + p.into() + }; + assert!( + backup.exists(), + "missing backup file at {}", + backup.display() + ); + + // After migration redb 4 can open the affected tables and the rows survive. + { + let db = redb::Database::create(&path)?; + let tx = db.begin_read()?; + let records = tx.open_table(tables::RECORDS_TABLE)?; + let entries: Vec<_> = records.iter()?.collect::>()?; + assert_eq!(entries.len(), 1); + let (k, v) = &entries[0]; + assert_eq!(k.value(), (&ns, &author, key)); + assert_eq!(v.value(), (42u64, &ns_sig, &auth_sig, 7u64, &hash)); + + let latest = tx.open_table(tables::LATEST_PER_AUTHOR_TABLE)?; + let entries: Vec<_> = latest.iter()?.collect::>()?; + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].0.value(), (&ns, &author)); + assert_eq!(entries[0].1.value(), (42u64, key)); + + let by_key = tx.open_table(tables::RECORDS_BY_KEY_TABLE)?; + let entries: Vec<_> = by_key.iter()?.collect::>()?; + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].0.value(), (&ns, key, &author)); + } + + Ok(()) + } + + #[test] + #[cfg(feature = "fs-store")] + fn test_no_migration_on_fresh_store() -> Result<()> { + let dbfile = tempfile::NamedTempFile::new()?; + let path = dbfile.path().to_path_buf(); + let store = Store::persistent(&path)?; + drop(store); + + let backup: std::path::PathBuf = { + let mut p = path.clone().into_os_string(); + p.push(".backup-redb-v2-tuples"); + p.into() + }; + assert!( + !backup.exists(), + "unexpected backup file at {}", + backup.display() + ); + + let _store = Store::persistent(&path)?; + assert!(!backup.exists()); + Ok(()) + } } diff --git a/src/store/fs/migrate_redb_v2_tuples.rs b/src/store/fs/migrate_redb_v2_tuples.rs new file mode 100644 index 00000000..0e56c2c4 --- /dev/null +++ b/src/store/fs/migrate_redb_v2_tuples.rs @@ -0,0 +1,169 @@ +//! Migrate stores written by iroh-docs 0.94..=0.98 (redb 2.x) so they open under redb 4. +//! +//! redb 3.0 changed the on-disk type tag for variable-width tuples. Stores written by +//! redb 2.x carry the old tag for `records-1`, `records-by-key-1`, and `latest-by-author-1`, +//! so redb 4 rejects them with `TableTypeMismatch`. We open the file with redb 3 using +//! `Legacy<_>` wrappers on those tables, copy everything into a fresh redb 3 file with +//! plain types (re-stamping the metadata), and swap files. + +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use redb_v3::{ + MultimapTableHandle, ReadableDatabase, ReadableMultimapTable, ReadableTable, TableHandle, +}; +use tempfile::NamedTempFile; +use tracing::info; + +type RecordsKey<'a> = (&'a [u8; 32], &'a [u8; 32], &'a [u8]); +type RecordsValue<'a> = (u64, &'a [u8; 64], &'a [u8; 64], u64, &'a [u8; 32]); +type LatestKey<'a> = (&'a [u8; 32], &'a [u8; 32]); +type LatestValue<'a> = (u64, &'a [u8]); +type RecordsByKeyKey<'a> = (&'a [u8; 32], &'a [u8], &'a [u8; 32]); +type NamespacesValue<'a> = (u8, &'a [u8; 32]); +type NamespacePeersValue<'a> = (u64, &'a crate::PeerIdBytes); + +pub(super) mod old { + use redb_v3::{Legacy, TableDefinition}; + + use super::{LatestKey, LatestValue, RecordsByKeyKey, RecordsKey, RecordsValue}; + + pub const RECORDS_TABLE: TableDefinition, RecordsValue> = + TableDefinition::new("records-1"); + pub const LATEST_PER_AUTHOR_TABLE: TableDefinition> = + TableDefinition::new("latest-by-author-1"); + pub const RECORDS_BY_KEY_TABLE: TableDefinition, ()> = + TableDefinition::new("records-by-key-1"); +} + +mod new { + use redb_v3::{MultimapTableDefinition, TableDefinition}; + + use super::{ + LatestKey, LatestValue, NamespacePeersValue, NamespacesValue, RecordsByKeyKey, RecordsKey, + RecordsValue, + }; + + pub const AUTHORS_TABLE: TableDefinition<&[u8; 32], &[u8; 32]> = + TableDefinition::new("authors-1"); + pub const NAMESPACES_TABLE_V1: TableDefinition<&[u8; 32], &[u8; 32]> = + TableDefinition::new("namespaces-1"); + pub const NAMESPACES_TABLE: TableDefinition<&[u8; 32], NamespacesValue> = + TableDefinition::new("namespaces-2"); + pub const RECORDS_TABLE: TableDefinition = + TableDefinition::new("records-1"); + pub const LATEST_PER_AUTHOR_TABLE: TableDefinition = + TableDefinition::new("latest-by-author-1"); + pub const RECORDS_BY_KEY_TABLE: TableDefinition = + TableDefinition::new("records-by-key-1"); + pub const NAMESPACE_PEERS_TABLE: MultimapTableDefinition<&[u8; 32], NamespacePeersValue> = + MultimapTableDefinition::new("sync-peers-1"); + pub const DOWNLOAD_POLICY_TABLE: TableDefinition<&[u8; 32], &[u8]> = + TableDefinition::new("download-policy-1"); +} + +macro_rules! migrate_table { + ($existing:expr, $rtx:expr, $wtx:expr, $old:expr, $new:expr) => {{ + if $existing.contains($new.name()) { + let old_t = $rtx.open_table($old)?; + let mut new_t = $wtx.open_table($new)?; + for entry in old_t.iter()? { + let (k, v) = entry?; + new_t.insert(k.value(), v.value())?; + } + } + }}; +} + +macro_rules! migrate_multimap_table { + ($existing:expr, $rtx:expr, $wtx:expr, $def:expr) => {{ + if $existing.contains($def.name()) { + let old_t = $rtx.open_multimap_table($def)?; + let mut new_t = $wtx.open_multimap_table($def)?; + for entry in old_t.iter()? { + let (k, values) = entry?; + let key = k.value(); + for value in values { + let value = value?; + new_t.insert(key, value.value())?; + } + } + } + }}; +} + +pub fn run(source: &Path) -> Result<()> { + let dir = source + .parent() + .ok_or_else(|| anyhow::anyhow!("database path has no parent directory"))?; + let target = NamedTempFile::with_prefix_in("docs.db.migrate", dir)?.into_temp_path(); + info!( + "migrating redb 2.x docs store {} -> {}", + source.display(), + target.display() + ); + + { + let old_db = redb_v3::Database::open(source)?; + let new_db = redb_v3::Database::create(&target)?; + let rtx = old_db.begin_read()?; + let wtx = new_db.begin_write()?; + + let existing: std::collections::HashSet = + rtx.list_tables()?.map(|h| h.name().to_string()).collect(); + + migrate_table!(existing, rtx, wtx, new::AUTHORS_TABLE, new::AUTHORS_TABLE); + migrate_table!( + existing, + rtx, + wtx, + new::NAMESPACES_TABLE_V1, + new::NAMESPACES_TABLE_V1 + ); + migrate_table!( + existing, + rtx, + wtx, + new::NAMESPACES_TABLE, + new::NAMESPACES_TABLE + ); + migrate_table!( + existing, + rtx, + wtx, + new::DOWNLOAD_POLICY_TABLE, + new::DOWNLOAD_POLICY_TABLE + ); + migrate_multimap_table!(existing, rtx, wtx, new::NAMESPACE_PEERS_TABLE); + migrate_table!(existing, rtx, wtx, old::RECORDS_TABLE, new::RECORDS_TABLE); + migrate_table!( + existing, + rtx, + wtx, + old::LATEST_PER_AUTHOR_TABLE, + new::LATEST_PER_AUTHOR_TABLE + ); + migrate_table!( + existing, + rtx, + wtx, + old::RECORDS_BY_KEY_TABLE, + new::RECORDS_BY_KEY_TABLE + ); + + wtx.commit()?; + drop(rtx); + drop(old_db); + drop(new_db); + } + + let backup: PathBuf = { + let mut p = source.to_owned().into_os_string(); + p.push(".backup-redb-v2-tuples"); + p.into() + }; + info!("rename {} -> {}", source.display(), backup.display()); + std::fs::rename(source, &backup)?; + target.persist_noclobber(source)?; + Ok(()) +}