use std::ops::Bound; use fjall::{Keyspace, OwnedWriteBatch, Readable}; use miette::{Context, IntoDiagnostic, Result}; use crate::db::Db; use crate::db::keys::{SEP, VERSIONING_KEY}; #[cfg(feature = "indexer")] const LEGACY_BLOCKS_KEYSPACE: &str = "blocks"; /// open the pre-v10 CAS only when it actually exists. keeping this out of the /// live keyspace registry means a fresh database never recreates the retired /// layout merely so a migration can observe its absence. #[cfg(feature = "indexer")] fn legacy_blocks(db: &Db) -> Result> { if !db.inner.keyspace_exists(LEGACY_BLOCKS_KEYSPACE) { return Ok(None); } db.inner .keyspace(LEGACY_BLOCKS_KEYSPACE, Default::default) .into_diagnostic() .map(Some) } #[cfg(feature = "indexer")] fn legacy_block(db: &Db, key: &[u8]) -> Result> { legacy_blocks(db)? .map(|blocks| blocks.get(key).into_diagnostic()) .transpose() .map(Option::flatten) } #[cfg(all(test, feature = "indexer"))] fn legacy_blocks_for_test(db: &Db) -> Result { db.inner .keyspace(LEGACY_BLOCKS_KEYSPACE, Default::default) .into_diagnostic() } /// the pre-v12 record store. v12's schema drops `records` from the registry, /// but its on-disk tables persist until the `recompress_record_heads` /// migration copies them into `heads` and deletes the keyspace. #[cfg(feature = "indexer")] const LEGACY_RECORDS_KEYSPACE: &str = "records"; #[cfg(all(test, feature = "indexer"))] fn legacy_records_for_test(db: &Db) -> Result { db.inner .keyspace(LEGACY_RECORDS_KEYSPACE, Default::default) .into_diagnostic() } #[cfg(feature = "indexer")] fn legacy_records(db: &Db) -> Result> { if !db.inner.keyspace_exists(LEGACY_RECORDS_KEYSPACE) { return Ok(None); } db.inner .keyspace(LEGACY_RECORDS_KEYSPACE, Default::default) .into_diagnostic() .map(Some) } mod v1; mod v10; mod v11; mod v2; mod v3; mod v4; mod v5; mod v6; mod v7; mod v8; mod v9; /// a migration whose entire write set is buffered into one batch. /// returns `Ok(true)` if applied, or `Ok(false)` if skipped due to missing keyspaces in this build. type AtomicFn = fn(&Db, &mut OwnedWriteBatch) -> Result; /// transform one scanned entry. staged writes may target any keyspace. /// returns the number of output bytes staged into the write batch for this entry. /// visit one scanned entry, staging its rewrite into the chunk batch, and /// return the staged byte count for the chunk budget. /// /// the third argument is the keyspace being scanned: in-place passes stage /// into it, so a pass keeps writing the same store it reads even when that /// store is a legacy keyspace the registry no longer knows. type VisitFn = fn(&Db, &mut OwnedWriteBatch, &Keyspace, &[u8], &[u8]) -> Result; /// destructive or otherwise non-batched work that runs only after every /// chunked pass is durable. returning false means this build does not contain /// the keyspaces needed by the migration. type FinalizeFn = fn(&Db) -> Result; /// how a migration is applied. enum Migration { /// the whole write set is buffered into one batch, committed atomically with /// the version bump. /// /// a batch is held entirely in memory, so this is only for migrations whose /// write set is small and bounded. it is *not* bounded by how much the /// migration reads: streaming a large keyspace to accumulate a handful of /// counters is fine, staging one write per entry of a large keyspace is not. Atomic(AtomicFn), /// ordered resumable passes, each committed in chunks with bounded memory. /// /// the only way to rewrite a keyspace larger than memory. passes run in /// order, and each runs to completion before the next starts. Chunked { passes: &'static [Pass], finalize: Option, }, } /// per-chunk limits. both bound peak batch memory; whichever is hit first ends /// the chunk. they also bound how much work a crash re-does. #[derive(Clone, Copy)] struct ChunkBudget { /// entries scanned per chunk. entries: usize, /// staged output bytes per chunk. /// /// bounds peak write batch memory when converting small input keys/references /// into large staged bodies (e.g. v10 record body expansion). bytes: usize, } impl ChunkBudget { /// what a pass should use unless it has a measured reason not to. #[allow(dead_code)] const DEFAULT: Self = Self { entries: 100_000, bytes: 64 * 1024 * 1024, }; } /// one resumable key-ordered scan over a single keyspace. /// /// the framework owns iteration, chunk commits, and the resume cursor; a pass /// supplies only [`Pass::visit`]. /// /// # visit must be idempotent /// /// the resume cursor advances only on a committed chunk, so a crash re-visits /// every entry of the in-flight chunk. /// /// more subtly, a pass that writes into the keyspace it scans can observe its /// own output. each chunk reads a fresh snapshot, so output that sorts *after* /// the resume cursor is scanned again in a later chunk. `visit` must therefore /// be safe to apply twice, and re-visiting its own output must reach a fixpoint /// — normally by making the migrated form recognizable and doing nothing when it /// is seen. a transform whose output sorts before its input (as key-shortening /// rewrites usually do) is never rescanned, but that is a property of the /// encoding, not a guarantee of the framework. struct Pass { /// stable cursor identity. THIS SHOULD ALWAYS BE STABLE. DO NOT CHANGE. name: &'static str, /// on-disk name of the keyspace to scan. /// /// the registry resolves names from this build first; a name it no longer /// knows falls back to opening an existing on-disk keyspace, so a pass can /// still finish migrating data out of a store a later schema dropped. /// /// a pass whose keyspace exists nowhere is skipped, so a migration can /// name mode-specific keyspaces unconditionally — unless /// `optional_on_absent` is set, in which case absence means the database /// never had the source layout and the pass completes as a no-op. /// /// a pass scanning `counts` also sees its own resume cursor, which sorts /// between the `k|` and `r|` prefixes; `visit` must ignore keys under /// [`MIGRATION_CURSOR_PREFIX`]. scan: &'static str, visit: VisitFn, budget: ChunkBudget, optional_on_absent: bool, } /// ordered list of schema migrations. /// /// invariants: /// - migration `vN` upgrades on-disk data from schema version `N-1` to `N` /// - once a migration ships, its input/output wire types are frozen /// - never "fix up" an older migration to match a newer live type; add a new /// schema version and a new migration instead /// - if a stored type changes shape, define a new versioned type in /// `src/types.rs`, export the newest one for live code, and have the new /// migration explicitly deserialize the previous version and write the new one /// - a migration that stages one write per entry of a high-cardinality keyspace /// must be [`Migration::Chunked`]; [`Migration::Atomic`] would buffer the /// whole rewrite in memory /// /// example: if `RepoState` changes again after `types::v7::RepoState`, add a new /// `types::v8::RepoState`, keep `v7` frozen, and add a migration that rewrites /// `v7 -> v8`. /// ordered list of migrations. migration at index `i` upgrades the schema from version `i` to `i+1`. const MIGRATIONS: &[(&str, Migration)] = &[ ( "stable_firehose_cursors", Migration::Atomic(v1::stable_firehose_cursors), ), ( "repo_state_root_commit", Migration::Atomic(v2::repo_state_root_commit), ), ( "firehose_source_is_pds", Migration::Atomic(v3::firehose_source_is_pds), ), ( "repo_state_active", Migration::Atomic(v4::repo_state_active), ), ("pds_meta_layout", Migration::Atomic(v5::pds_meta_layout)), ( "rebuild_pds_account_counts", Migration::Atomic(v6::rebuild_pds_account_counts), ), ( "repo_state_event_clocks", Migration::Atomic(v7::repo_state_event_clocks), ), ( "rebuild_lifecycle_counts", Migration::Atomic(v8::rebuild_lifecycle_counts), ), ( "migrate_excludes_and_pds_keys", Migration::Atomic(v9::migrate_v9), ), ( "inline_record_bodies", Migration::Chunked { passes: v10::PASSES, finalize: Some(v10::retire_legacy_stores), }, ), ( "canonicalize_pds_status_hosts", Migration::Chunked { passes: v11::PASSES, finalize: None, }, ), ]; pub(crate) const LATEST_VERSION: u64 = MIGRATIONS.len() as u64; /// resume cursors for chunked passes, in the `counts` keyspace: /// `mig|{version}|{pass}` -> last committed key. /// /// the `counts` compaction filter only drops `r|` and old `d|` entries, so these /// survive compaction. const MIGRATION_CURSOR_PREFIX: &[u8] = b"mig|"; /// applied markers for migrations, in the `counts` keyspace: /// `mig_applied|{name}` -> `b"1"`. const MIGRATION_APPLIED_PREFIX: &[u8] = b"mig_applied|"; fn pass_cursor_key(version: usize, pass: &str) -> Vec { let mut key = Vec::with_capacity(MIGRATION_CURSOR_PREFIX.len() + 8 + 1 + pass.len()); key.extend_from_slice(MIGRATION_CURSOR_PREFIX); key.extend_from_slice(&(version as u64).to_be_bytes()); key.push(SEP); key.extend_from_slice(pass.as_bytes()); key } fn migration_applied_key(name: &str) -> Vec { let mut key = Vec::with_capacity(MIGRATION_APPLIED_PREFIX.len() + name.len()); key.extend_from_slice(MIGRATION_APPLIED_PREFIX); key.extend_from_slice(name.as_bytes()); key } /// test helper: rewind the db to `version` as if later migrations never ran. /// opening a fresh test db already runs (and marks) every migration, so /// simulating an old database means clearing the applied markers too. #[cfg(test)] pub(crate) fn rewind_version_for_test(db: &Db, version: u64) -> Result<()> { let mut batch = db.inner.batch(); for (name, _) in MIGRATIONS { batch.remove(&db.counts, migration_applied_key(name)); } batch.insert(&db.counts, VERSIONING_KEY, version.to_be_bytes()); batch.commit().into_diagnostic() } fn is_migration_applied(db: &Db, index: usize, name: &str, stored_version: u64) -> Result { let key = migration_applied_key(name); if db.counts.contains_key(&key).into_diagnostic()? { return Ok(true); } // Legacy versions predate per-migration applied keys. Their global version // proves ordinary migrations ran, while feature-gated migrations need a // cheap migration-specific proof: reduced-feature builds advanced the old // version even when they skipped the work. if (index as u64) < stored_version { return match name { "rebuild_lifecycle_counts" => v8::legacy_migration_applied(db), // The old v9 marker is trusted; v10 safely and boundedly finishes // any work a reduced-feature open skipped. "migrate_excludes_and_pds_keys" => Ok(true), // no intermediate v10 layout shipped. accepting a v10 global // version without this migration's marker would silently bless a // branch-local draft as the final schema. "inline_record_bodies" => { miette::bail!("unsupported pre-release v10 database; rebuild it from v9") } _ => Ok(true), }; } Ok(false) } fn read_version(db: &Db) -> Result { db.counts .get(VERSIONING_KEY) .into_diagnostic()? .map(|r| { r.as_ref() .try_into() .into_diagnostic() .wrap_err("db version key expected to be 8 bytes") .map(u64::from_be_bytes) }) .transpose() .map(|v| v.unwrap_or(0)) } struct ChunkOutcome { /// entries visited and committed. seen: usize, /// the scan reached the end of the keyspace. exhausted: bool, } /// visit one chunk of `pass`, committing the transformed entries and the /// advanced resume cursor as one atomic batch. fn run_chunk(db: &Db, cursor_key: &[u8], ks: &Keyspace, pass: &Pass) -> Result { let resume = db.counts.get(cursor_key).into_diagnostic()?; let start = resume .as_ref() .map_or(Bound::Unbounded, |k| Bound::Excluded(k.to_vec())); let mut batch = db.inner.batch(); let mut seen = 0usize; let mut bytes = 0usize; let mut last = None; let mut exhausted = true; { // a fresh snapshot per chunk keeps the scan consistent while the batch is // staged, without pinning superseded versions for the whole pass. let snapshot = db.inner.snapshot(); for guard in snapshot.range(ks, (start, Bound::>::Unbounded)) { // checked before visiting, so breaking here proves an entry remains. // a chunk always visits at least one entry, so neither a degenerate // budget nor an entry larger than the byte budget can stall the pass // or make an unfinished scan look exhausted. if seen > 0 && (seen >= pass.budget.entries || bytes >= pass.budget.bytes) { exhausted = false; break; } let (key, value) = guard.into_inner().into_diagnostic()?; let staged_bytes = (pass.visit)(db, &mut batch, ks, &key, &value) .wrap_err_with(|| format!("migration pass {} failed on an entry", pass.name))?; bytes += staged_bytes; last = Some(key); seen += 1; } } let Some(last) = last else { // nothing left to do; leave the cursor for `run` to clear with the // version bump return Ok(ChunkOutcome { seen: 0, exhausted: true, }); }; batch.insert(&db.counts, cursor_key, last); batch.commit().into_diagnostic()?; Ok(ChunkOutcome { seen, exhausted }) } /// run one pass to completion, resuming from its stored cursor if present. fn run_pass(db: &Db, version: usize, pass: &Pass) -> Result { let ks = match db.keyspace_by_name(pass.scan) { Some(ks) => Some(ks), // a keyspace dropped from the registry by a later schema may still // exist on disk; open it so the pass can finish migrating its data None if db.inner.keyspace_exists(pass.scan) => Some( db.inner .keyspace(pass.scan, Default::default) .into_diagnostic()?, ), None => None, }; let Some(ks) = ks else { if pass.optional_on_absent { // the source layout never existed on this database, so there is // genuinely nothing to migrate tracing::debug!( "db: migration pass {} found no {} keyspace, nothing to migrate", pass.name, pass.scan ); return Ok(true); } // keyspace is not compiled into this build, so there is nothing to migrate tracing::debug!( "db: migration pass {} skipped, keyspace {} not in this build", pass.name, pass.scan ); return Ok(false); }; let cursor_key = pass_cursor_key(version, pass.name); if db.counts.contains_key(&cursor_key).into_diagnostic()? { tracing::info!("db: resuming migration pass {} from its cursor", pass.name); } let mut total: u64 = 0; loop { let outcome = run_chunk(db, &cursor_key, &ks, pass)?; total += outcome.seen as u64; if outcome.seen > 0 { tracing::info!( "db: migration pass {} committed {total} entries so far", pass.name ); } if outcome.exhausted { break; } } tracing::info!( "db: migration pass {} complete ({total} entries)", pass.name ); Ok(true) } /// run all pending database migrations in order. /// /// an atomic migration commits its write set and applied marker together. a /// chunked migration commits each chunk with its resume cursor, durably runs /// any finalizer, then commits cursor cleanup with the applied marker. the /// global version is derived from the contiguous markers afterward. a crash /// before the marker therefore resumes from the durable pass cursors. /// /// safe to run on a fresh database (all migrations are no-ops when no relevant /// data exists). called during [`Db::open`]. pub(super) fn run(db: &Db) -> Result<()> { let stored_version = read_version(db)?; if stored_version > LATEST_VERSION { miette::bail!( "database schema version {stored_version} is newer than supported version {LATEST_VERSION}" ); } for (i, (name, migration)) in MIGRATIONS.iter().enumerate() { if is_migration_applied(db, i, name, stored_version)? { let key = migration_applied_key(name); if !db.counts.contains_key(&key).into_diagnostic()? { db.counts.insert(key, b"1").into_diagnostic()?; tracing::info!("db: adopted legacy applied marker for {name}"); } tracing::debug!("db: migration {name} already applied"); continue; } tracing::info!("db: running migration {name} (v{i} -> v{})", i + 1); match migration { Migration::Atomic(f) => { let mut batch = db.inner.batch(); if f(db, &mut batch)? { batch.insert(&db.counts, migration_applied_key(name), b"1"); batch.commit().into_diagnostic()?; tracing::info!("db: migration {name} complete"); } else { tracing::info!( "db: migration {name} skipped (required keyspace not in this build)" ); } } Migration::Chunked { passes, finalize } => { let mut all_passed = !(passes.is_empty() && finalize.is_none()); for pass in *passes { if !run_pass(db, i, pass)? { all_passed = false; break; } } if all_passed && let Some(finalize) = finalize && !finalize(db)? { all_passed = false; } if all_passed { let mut batch = db.inner.batch(); for pass in *passes { batch.remove(&db.counts, pass_cursor_key(i, pass.name)); } batch.insert(&db.counts, migration_applied_key(name), b"1"); batch.commit().into_diagnostic()?; tracing::info!("db: migration {name} complete"); } else { tracing::info!( "db: migration {name} skipped (required keyspace not in this build)" ); } } } } // Advance VERSIONING_KEY to highest contiguous applied migration version let mut max_contiguous_version = 0u64; for (i, (name, _)) in MIGRATIONS.iter().enumerate() { if is_migration_applied(db, i, name, stored_version)? { max_contiguous_version = (i + 1) as u64; } else { break; } } if max_contiguous_version != stored_version { let mut batch = db.inner.batch(); batch.insert( &db.counts, VERSIONING_KEY, max_contiguous_version.to_be_bytes(), ); batch.commit().into_diagnostic()?; } Ok(()) } #[cfg(test)] mod tests { use super::*; const TEST_PREFIX: &[u8] = b"chunktest|"; const MIGRATED_PREFIX: &[u8] = b"chunkdone|"; /// rewrites `chunktest|{n}` to `chunkdone|{n}`, keeping the value. /// /// idempotent: already-migrated keys carry the new prefix and are ignored. /// note the output sorts *before* the input (`chunkd` < `chunkt`), which is /// the usual shape for a key rewrite and means output is never rescanned. fn rewrite_prefix( db: &Db, batch: &mut OwnedWriteBatch, scanned: &Keyspace, key: &[u8], value: &[u8], ) -> Result { let Some(suffix) = key.strip_prefix(TEST_PREFIX) else { return Ok(0); }; let mut new_key = MIGRATED_PREFIX.to_vec(); new_key.extend_from_slice(suffix); let _ = db; let staged_bytes = new_key.len() + value.len(); batch.insert(scanned, new_key, value); batch.remove(scanned, key); Ok(staged_bytes) } fn test_pass(entries: usize) -> Pass { Pass { name: "rewrite_prefix", scan: "cursors", visit: rewrite_prefix, budget: ChunkBudget { entries, bytes: usize::MAX, }, optional_on_absent: false, } } fn open_db() -> Result<(tempfile::TempDir, Db)> { let tmp = tempfile::tempdir().into_diagnostic()?; let cfg = crate::config::Config { database_path: tmp.path().to_path_buf(), ..Default::default() }; let db = Db::open(&cfg)?; Ok((tmp, db)) } fn seed(db: &Db, count: usize) -> Result<()> { let mut batch = db.inner.batch(); for i in 0..count { let mut key = TEST_PREFIX.to_vec(); key.extend_from_slice(&(i as u64).to_be_bytes()); batch.insert(&db.cursors, key, (i as u64).to_be_bytes()); } batch.commit().into_diagnostic()?; Ok(()) } fn migrated(db: &Db) -> Result> { db.cursors .prefix(MIGRATED_PREFIX) .map(|guard| { let (k, v) = guard.into_inner().into_diagnostic()?; let n = u64::from_be_bytes(k[MIGRATED_PREFIX.len()..].try_into().into_diagnostic()?); let v = u64::from_be_bytes(v.as_ref().try_into().into_diagnostic()?); Ok((n, v)) }) .collect() } fn expected(count: usize) -> Vec<(u64, u64)> { (0..count as u64).map(|i| (i, i)).collect() } #[cfg(feature = "indexer")] #[test] fn v0_database_migrates_all_early_representation_boundaries() -> Result<()> { use crate::config::Config; use crate::db::{ keys, types::{DbTid, DidKey}, }; use crate::types::v2::RepoStatus as V2RepoStatus; use jacquard_common::CowStr; use jacquard_common::types::{ string::{Did, Handle}, tid::Tid, }; let tmp = tempfile::tempdir().into_diagnostic()?; let cfg = Config { database_path: tmp.path().to_path_buf(), ..Default::default() }; let did = Did::new_static("did:web:migration.test").into_diagnostic()?; let repo_key = keys::repo_key(&did); let old_cursor_url = url::Url::parse("wss://relay.example:7443/x").into_diagnostic()?; let old_cursor_key = [ keys::FIREHOSE_CURSOR_PREFIX, old_cursor_url.as_str().as_bytes(), ] .concat(); let new_cursor_key = keys::v1::firehose_cursor_key_from_url(&old_cursor_url); let relay_source = "wss://bsky.network"; let pds_source = "wss://pds.example"; { let db = Db::open(&cfg)?; let old_state = v2::OldRepoState { status: V2RepoStatus::Error("stinkpot".into()), rev: Some(DbTid::from(&Tid::new("3jzfcijpj2z2a").unwrap())), data: Some(jacquard_repo::mst::util::compute_cid(b"root")?), last_message_time: Some(-1), last_updated_at: 42, tracked: true, index_id: 7, signing_key: Some(DidKey(std::borrow::Cow::Borrowed(&[0xed, 1, 2, 3]))), pds: Some(CowStr::Borrowed("https://pds.example")), handle: Some(Handle::new_static("alice.test").into_diagnostic()?), }; let mut batch = db.inner.batch(); batch.insert( &db.repos, &repo_key, rmp_serde::to_vec(&old_state).into_diagnostic()?, ); batch.insert(&db.cursors, &old_cursor_key, 42i64.to_be_bytes()); batch.insert(&db.crawler, keys::firehose_source_key(relay_source), []); batch.insert(&db.crawler, keys::firehose_source_key(pds_source), []); batch.insert(&db.filter, b"pt|pds.example", b"trusted"); batch.insert(&db.filter, b"pb|banned.example", []); batch.insert( &db.counts, keys::count_keyspace_key("p|stale.example"), 99u64.to_be_bytes(), ); batch.commit().into_diagnostic()?; rewind_version_for_test(&db, 0)?; db.persist()?; } let db = Db::open(&cfg)?; assert!(!db.cursors.contains_key(&old_cursor_key).into_diagnostic()?); let cursor = db .cursors .get(&new_cursor_key) .into_diagnostic()? .expect("v1 cursor"); assert_eq!( i64::from_be_bytes(cursor.as_ref().try_into().into_diagnostic()?), 42 ); for (source, is_pds) in [(relay_source, false), (pds_source, true)] { let encoded = db .crawler .get(keys::firehose_source_key(source)) .into_diagnostic()? .expect("v3 source metadata"); let metadata: crate::db::FirehoseSourceMeta = rmp_serde::from_slice(&encoded).into_diagnostic()?; assert_eq!(metadata.is_pds, is_pds); } let state_bytes = db .repos .get(&repo_key) .into_diagnostic()? .expect("migrated repo state"); let state = crate::db::deser_repo_state(&state_bytes)?; assert!(state.active); assert!(matches!( &state.status, crate::types::RepoStatus::Error(error) if error == "stinkpot" )); let root = state.root.as_ref().expect("migrated commit"); assert_eq!(root.version, -1); assert_eq!(root.rev, DbTid::from(&Tid::new("3jzfcijpj2z2a").unwrap())); assert_eq!(root.data, jacquard_repo::mst::util::compute_cid(b"root")?); assert!(root.prev.is_none()); assert!(root.sig.is_empty()); assert_eq!(state.last_message_time, Some(-1)); assert_eq!(state.last_identity_time, None); assert_eq!(state.last_account_time, None); assert_eq!(state.last_updated_at, 42); assert_eq!( state.signing_key.as_ref().map(|key| key.0.as_ref()), Some([0xed, 1, 2, 3].as_slice()) ); assert_eq!(state.pds.as_deref(), Some("https://pds.example")); assert_eq!( state.handle.as_ref().map(Handle::as_str), Some("alice.test") ); let metadata = crate::db::deser_repo_meta( &db.repo_metadata .get(keys::repo_metadata_key(&did)) .into_diagnostic()? .expect("v4 repo metadata"), )?; assert!(metadata.tracked); assert_eq!(metadata.index_id, 7); assert_eq!( db.filter .get(crate::db::pds_meta::pds_tier_key("pds.example")) .into_diagnostic()? .as_deref(), Some(b"trusted".as_slice()) ); assert!( !db.filter .contains_key(crate::db::pds_meta::pds_status_key("banned.example")) .into_diagnostic()? ); assert!( db.filter .contains_key(crate::db::pds_meta::pds_ban_key("banned.example")) .into_diagnostic()? ); assert!( !db.counts .contains_key(keys::count_keyspace_key("p|stale.example")) .into_diagnostic()? ); let account_count = db .counts .get(keys::count_keyspace_key("p|pds.example")) .into_diagnostic()? .expect("v6 PDS account count"); assert_eq!( u64::from_be_bytes(account_count.as_ref().try_into().into_diagnostic()?), 1 ); let version = db .counts .get(VERSIONING_KEY) .into_diagnostic()? .expect("schema version"); assert_eq!( u64::from_be_bytes(version.as_ref().try_into().into_diagnostic()?), LATEST_VERSION ); Ok(()) } #[test] fn chunked_pass_rewrites_every_entry_across_many_chunks() -> Result<()> { let (_tmp, db) = open_db()?; seed(&db, 50)?; let pass = test_pass(7); run_pass(&db, 99, &pass)?; assert_eq!(migrated(&db)?, expected(50)); assert_eq!(db.cursors.prefix(TEST_PREFIX).count(), 0); Ok(()) } #[test] fn interrupted_pass_resumes_and_matches_an_uninterrupted_run() -> Result<()> { let (_tmp, db) = open_db()?; seed(&db, 50)?; let pass = test_pass(7); let cursor_key = pass_cursor_key(99, pass.name); let ks = db.keyspace_by_name("cursors").expect("cursors keyspace"); // one chunk only, standing in for a crash mid-pass let first = run_chunk(&db, &cursor_key, &ks, &pass)?; assert_eq!(first.seen, 7); assert!(!first.exhausted); assert_eq!(migrated(&db)?, expected(7)); assert!(db.counts.contains_key(&cursor_key).into_diagnostic()?); // reopening resumes from the cursor rather than restarting run_pass(&db, 99, &pass)?; assert_eq!(migrated(&db)?, expected(50)); assert_eq!(db.cursors.prefix(TEST_PREFIX).count(), 0); Ok(()) } #[test] fn completed_pass_is_a_cheap_no_op_when_rerun() -> Result<()> { let (_tmp, db) = open_db()?; seed(&db, 20)?; let pass = test_pass(7); run_pass(&db, 99, &pass)?; let after_first = migrated(&db)?; // the cursor survives until the version bump, so a rerun scans nothing let cursor_key = pass_cursor_key(99, pass.name); let ks = db.keyspace_by_name("cursors").expect("cursors keyspace"); let outcome = run_chunk(&db, &cursor_key, &ks, &pass)?; assert_eq!(outcome.seen, 0); assert!(outcome.exhausted); assert_eq!(migrated(&db)?, after_first); Ok(()) } #[test] fn visiting_migrated_entries_again_is_a_fixpoint() -> Result<()> { let (_tmp, db) = open_db()?; seed(&db, 20)?; let pass = test_pass(7); run_pass(&db, 99, &pass)?; let after_first = migrated(&db)?; // drop the cursor to force a full rescan over already-migrated entries, // the worst case the idempotency contract has to survive db.counts .remove(pass_cursor_key(99, pass.name)) .into_diagnostic()?; run_pass(&db, 99, &pass)?; assert_eq!(migrated(&db)?, after_first); Ok(()) } /// fails partway through a chunk, standing in for a crash mid-chunk. fn fail_partway( db: &Db, batch: &mut OwnedWriteBatch, scanned: &Keyspace, key: &[u8], value: &[u8], ) -> Result { let Some(suffix) = key.strip_prefix(TEST_PREFIX) else { return Ok(0); }; let n = u64::from_be_bytes(suffix.try_into().into_diagnostic()?); if n == 4 { miette::bail!("simulated failure at entry 4"); } rewrite_prefix(db, batch, scanned, key, value) } #[test] fn a_chunk_that_fails_partway_commits_nothing() -> Result<()> { let (_tmp, db) = open_db()?; seed(&db, 50)?; let pass = Pass { visit: fail_partway, ..test_pass(7) }; let cursor_key = pass_cursor_key(99, pass.name); let ks = db.keyspace_by_name("cursors").expect("cursors keyspace"); assert!(run_chunk(&db, &cursor_key, &ks, &pass).is_err()); // entries 0..=3 were staged before the failure, but the batch is dropped // uncommitted, so neither they nor the cursor survive assert_eq!(migrated(&db)?, vec![]); assert!(!db.counts.contains_key(&cursor_key).into_diagnostic()?); assert_eq!(db.cursors.prefix(TEST_PREFIX).count(), 50); // retrying with a working visit starts over from the beginning run_pass(&db, 99, &test_pass(7))?; assert_eq!(migrated(&db)?, expected(50)); Ok(()) } #[test] fn byte_budget_ends_a_chunk_before_the_entry_budget() -> Result<()> { let (_tmp, db) = open_db()?; seed(&db, 50)?; // each entry is 26 scanned bytes (10-byte prefix + 8-byte key + 8-byte // value), and the budget is checked before visiting, so a 60-byte budget // admits 3 entries (26, 52, 78) before it trips let pass = Pass { budget: ChunkBudget { entries: usize::MAX, bytes: 60, }, ..test_pass(0) }; let cursor_key = pass_cursor_key(99, pass.name); let ks = db.keyspace_by_name("cursors").expect("cursors keyspace"); let outcome = run_chunk(&db, &cursor_key, &ks, &pass)?; assert_eq!(outcome.seen, 3); assert!(!outcome.exhausted); run_pass(&db, 99, &pass)?; assert_eq!(migrated(&db)?, expected(50)); Ok(()) } #[test] fn a_degenerate_budget_still_makes_progress() -> Result<()> { let (_tmp, db) = open_db()?; seed(&db, 5)?; // a zero budget would otherwise trip before visiting anything, leaving // the chunk with no cursor to advance and reporting itself exhausted let pass = Pass { budget: ChunkBudget { entries: 0, bytes: 0, }, ..test_pass(0) }; let cursor_key = pass_cursor_key(99, pass.name); let ks = db.keyspace_by_name("cursors").expect("cursors keyspace"); let outcome = run_chunk(&db, &cursor_key, &ks, &pass)?; assert_eq!(outcome.seen, 1); assert!(!outcome.exhausted); run_pass(&db, 99, &pass)?; assert_eq!(migrated(&db)?, expected(5)); Ok(()) } #[test] fn pass_on_a_keyspace_missing_from_this_build_is_skipped() -> Result<()> { let (_tmp, db) = open_db()?; let pass = Pass { scan: "not_a_keyspace", ..test_pass(7) }; run_pass(&db, 99, &pass)?; let cursor_key = pass_cursor_key(99, pass.name); assert!(!db.counts.contains_key(&cursor_key).into_diagnostic()?); Ok(()) } #[test] fn cursor_keys_are_distinct_per_version_and_pass() { assert_ne!(pass_cursor_key(1, "a"), pass_cursor_key(2, "a")); assert_ne!(pass_cursor_key(1, "a"), pass_cursor_key(1, "b")); assert!(pass_cursor_key(1, "a").starts_with(MIGRATION_CURSOR_PREFIX)); } #[test] fn staged_byte_budget_bounds_chunk_write_size() -> Result<()> { let (_tmp, db) = open_db()?; let mut batch = db.inner.batch(); // Insert small input keys/values that expand into large staged outputs for i in 0..10 { let mut key = TEST_PREFIX.to_vec(); key.extend_from_slice(&(i as u64).to_be_bytes()); batch.insert(&db.cursors, key, vec![0u8; 1000]); // 1 KB payload } batch.commit().into_diagnostic()?; // Set byte budget to 2500 bytes. Each output staged is ~1018 bytes. // First entry: 1018 bytes. Second entry: 2036 bytes. Third entry: 3054 bytes (>= 2500). // 4th check trips because 3054 >= 2500. So outcome.seen == 3. let pass = Pass { budget: ChunkBudget { entries: usize::MAX, bytes: 2500, }, ..test_pass(0) }; let cursor_key = pass_cursor_key(99, pass.name); let ks = db.keyspace_by_name("cursors").expect("cursors keyspace"); let outcome = run_chunk(&db, &cursor_key, &ks, &pass)?; assert_eq!(outcome.seen, 3); assert!(!outcome.exhausted); Ok(()) } #[test] fn applied_migration_keys_track_per_migration_status() -> Result<()> { let (_tmp, db) = open_db()?; assert!( db.counts .contains_key(&migration_applied_key("stable_firehose_cursors")) .into_diagnostic()? ); Ok(()) } #[test] fn branch_local_v10_without_the_final_marker_is_rejected() -> Result<()> { let tmp = tempfile::tempdir().into_diagnostic()?; let cfg = crate::config::Config { database_path: tmp.path().to_path_buf(), ..Default::default() }; { let db = Db::open(&cfg)?; rewind_version_for_test(&db, 10)?; db.persist()?; } let err = match Db::open(&cfg) { Ok(_) => panic!("an unmarked branch-local v10 must not be adopted"), Err(err) => err, }; assert!(format!("{err:?}").contains("unsupported pre-release v10")); Ok(()) } #[cfg(feature = "indexer")] #[test] fn legacy_marker_adoption_does_not_rerun_lifecycle_rebuild() -> Result<()> { use jacquard_common::types::string::Did; let tmp = tempfile::tempdir().into_diagnostic()?; let cfg = crate::config::Config { database_path: tmp.path().to_path_buf(), ..Default::default() }; let repo = Did::new_static("did:plc:ewvi7nxzyoun6zhxrhs64oiz").into_diagnostic()?; let stale = Did::new_static("did:plc:yk4q3id7id6p5z3bypvshc64").into_diagnostic()?; let mut stale_gauge = crate::db::keys::COUNT_GAUGE_PREFIX.to_vec(); stale_gauge.extend_from_slice(&crate::db::keys::repo_key(&stale)); { let db = Db::open(&cfg)?; let mut batch = db.inner.batch(); batch.insert( &db.repos, crate::db::keys::repo_key(&repo), crate::db::ser_repo_state(&crate::types::RepoState::synced())?, ); let mut gauge = crate::db::keys::COUNT_GAUGE_PREFIX.to_vec(); gauge.extend_from_slice(&crate::db::keys::repo_key(&repo)); let synced = crate::db::lifecycle_counts::encode_gauge(crate::types::GaugeState::Synced); batch.insert(&db.counts, gauge, synced); // v8 would clear this stale-but-valid row. retaining it proves the // legacy global version was adopted rather than rebuilt. batch.insert(&db.counts, &stale_gauge, synced); batch.commit().into_diagnostic()?; // v9 is the last schema that existed before this change. rewind_version_for_test(&db, 9)?; db.persist()?; } let db = Db::open(&cfg)?; assert!(db.counts.contains_key(stale_gauge).into_diagnostic()?); assert!( db.counts .contains_key(migration_applied_key("rebuild_lifecycle_counts")) .into_diagnostic()? ); Ok(()) } }