use std::sync::Arc;
use object_store::memory::InMemory;
use super::{
synthetic::{SOURCE_SCOPE, TARGET_SCOPE},
*,
};
use crate::{
catalog::{Catalog, CatalogOptions, MigrationRequest},
fault::{SyntheticMigration, inject_crash, install_migration},
store::{
key::EntityKey,
open::StoreBuilder,
read::{EntityRecord, scan_current_entities},
},
transaction::commit::{FORMAT_VERSION, MAX_FORMAT_VERSION, MIN_FORMAT_VERSION, durable},
};
const REWRITTEN_FORMAT: u64 = MAX_FORMAT_VERSION;
const CHAINED_FORMAT: u64 = MAX_FORMAT_VERSION + 1;
const SEEDED_RECORDS: u64 = 3;
async fn seeded_store() -> Arc<InMemory> {
let object_store: Arc<InMemory> = Arc::new(InMemory::new());
let catalog = Catalog::open(object_store.clone(), CatalogOptions::default())
.await
.unwrap();
catalog.close().await.unwrap();
let db = open_migrator(&object_store).await;
let tx = db.begin(IsolationLevel::Snapshot).await.unwrap();
for scope_id in 1..=SEEDED_RECORDS {
tx.put(
Key::current(EntityKey::Option {
scope_kind: SOURCE_SCOPE,
scope_id,
})
.encode(),
value::encode_value(&proto::OptionScopeValue {
options: std::collections::HashMap::from([(
"planted".to_string(),
scope_id.to_string(),
)]),
}),
)
.unwrap();
}
tx.commit_with_options(&durable()).await.unwrap();
db.close().await.unwrap();
object_store
}
async fn open_migrator(object_store: &Arc<InMemory>) -> Db {
StoreBuilder::new("", object_store.clone())
.open_writer()
.await
.unwrap()
.0
}
async fn durable_state(object_store: &Arc<InMemory>) -> (u64, Option<proto::MigrationValue>) {
let db = open_migrator(object_store).await;
let tx = db.begin(IsolationLevel::Snapshot).await.unwrap();
let format = read::read_format(ReadHandle::Tx(&tx))
.await
.unwrap()
.unwrap()
.format_version;
let marker = read::read_migration(ReadHandle::Tx(&tx)).await.unwrap();
tx.rollback();
db.close().await.unwrap();
(format, marker)
}
async fn option_scopes(object_store: &Arc<InMemory>) -> Vec<(u64, u64)> {
let db = open_migrator(object_store).await;
let tx = db.begin(IsolationLevel::Snapshot).await.unwrap();
let records = scan_current_entities(ReadHandle::Tx(&tx)).await.unwrap();
tx.rollback();
db.close().await.unwrap();
let mut scopes: Vec<(u64, u64)> = records
.into_iter()
.filter_map(|record| match record {
EntityRecord::Option {
scope_kind,
scope_id,
..
} => Some((scope_kind, scope_id)),
_ => None,
})
.collect();
scopes.sort_unstable();
scopes
}
fn migrated_scopes() -> Vec<(u64, u64)> {
let mut expected = vec![(0, 0)];
expected.extend((1..=SEEDED_RECORDS).map(|scope_id| (TARGET_SCOPE, scope_id)));
expected
}
async fn migrate_with(
object_store: &Arc<InMemory>,
units: SyntheticMigration,
) -> Result<MigrationReport> {
install_migration(units);
let db = open_migrator(object_store).await;
let outcome = run(&db).await;
db.close().await.unwrap();
outcome
}
#[tokio::test]
async fn a_crash_mid_rewrite_leaves_the_store_old_and_unreadable() {
let object_store = seeded_store().await;
inject_crash(Some(CrashPoint::AfterStep));
let error = migrate_with(&object_store, SyntheticMigration::MoveOptionScope)
.await
.err()
.unwrap();
assert!(matches!(error, Error::Migration(_)), "{error:?}");
let (format, marker) = durable_state(&object_store).await;
assert_eq!(format, FORMAT_VERSION, "the flip has not happened");
let marker = marker.expect("a crashed migration leaves its marker");
assert_eq!(marker.from_format, FORMAT_VERSION);
assert_eq!(marker.to_format, REWRITTEN_FORMAT);
assert_eq!(marker.cursor, 1_u64.to_be_bytes().to_vec());
let error = Catalog::open(object_store.clone(), CatalogOptions::default())
.await
.err()
.unwrap();
assert!(matches!(error, Error::Migration(_)), "{error:?}");
}
#[tokio::test]
async fn a_crash_at_every_seam_resumes_to_a_coherent_store() {
for point in [
CrashPoint::AfterStart,
CrashPoint::AfterStep,
CrashPoint::BeforeFinish,
CrashPoint::AfterFinish,
] {
let object_store = seeded_store().await;
inject_crash(Some(point));
let crashed = migrate_with(&object_store, SyntheticMigration::MoveOptionScope).await;
let (format, marker) = durable_state(&object_store).await;
assert!(
!(format == REWRITTEN_FORMAT && marker.is_some()),
"new format with the marker still present at {point:?}"
);
if matches!(point, CrashPoint::AfterFinish) {
assert_eq!(format, REWRITTEN_FORMAT, "{point:?}");
assert!(marker.is_none(), "{point:?}");
} else {
assert!(crashed.is_err(), "{point:?}");
assert_eq!(format, FORMAT_VERSION, "{point:?}");
assert!(marker.is_some(), "{point:?}");
}
inject_crash(None);
migrate_with(&object_store, SyntheticMigration::MoveOptionScope)
.await
.unwrap_or_else(|error| panic!("resume after {point:?}: {error:?}"));
let (format, marker) = durable_state(&object_store).await;
assert_eq!(format, REWRITTEN_FORMAT, "{point:?}");
assert!(marker.is_none(), "{point:?}");
assert_eq!(
option_scopes(&object_store).await,
migrated_scopes(),
"every record moved exactly once after {point:?}"
);
}
}
#[tokio::test]
async fn a_completed_migration_reruns_as_a_noop() {
let object_store = seeded_store().await;
migrate_with(&object_store, SyntheticMigration::MoveOptionScope)
.await
.unwrap();
migrate_with(&object_store, SyntheticMigration::MoveOptionScope)
.await
.unwrap();
assert_eq!(option_scopes(&object_store).await, migrated_scopes());
let (format, marker) = durable_state(&object_store).await;
assert_eq!(format, REWRITTEN_FORMAT);
assert!(marker.is_none());
}
#[tokio::test]
async fn a_multi_version_jump_composes_its_links() {
let object_store = seeded_store().await;
migrate_with(&object_store, SyntheticMigration::MoveOptionScopeThenLink)
.await
.unwrap();
let (format, marker) = durable_state(&object_store).await;
assert_eq!(format, CHAINED_FORMAT);
assert!(marker.is_none());
assert_eq!(option_scopes(&object_store).await, migrated_scopes());
}
#[tokio::test]
async fn a_migrated_store_still_time_travels() {
let object_store: Arc<InMemory> = Arc::new(InMemory::new());
let catalog = Catalog::open(object_store.clone(), CatalogOptions::default())
.await
.unwrap();
let first = catalog
.commit(|tx| {
tx.create_schema("sales")?;
Ok(())
})
.await
.unwrap();
catalog
.commit(|tx| {
tx.create_schema("ops")?;
Ok(())
})
.await
.unwrap();
catalog.close().await.unwrap();
migrate_with(&object_store, SyntheticMigration::MoveOptionScope)
.await
.unwrap();
let catalog = Catalog::open(object_store.clone(), CatalogOptions::default())
.await
.unwrap();
let past = catalog.snapshot_at(first).await.unwrap();
catalog.close().await.unwrap();
let mut names: Vec<String> = past
.schemas()
.into_iter()
.map(|schema| schema.name)
.collect();
names.sort();
assert_eq!(names, vec!["main".to_string(), "sales".to_string()]);
}
#[test]
fn a_marker_the_format_contradicts_is_corruption() {
install_migration(SyntheticMigration::None);
let error = plan(
FORMAT_VERSION,
Some(&proto::MigrationValue {
from_format: FORMAT_VERSION + 1,
to_format: FORMAT_VERSION + 2,
cursor: Vec::new(),
}),
)
.err()
.unwrap();
assert!(matches!(error, Error::Corruption(_)), "{error:?}");
}
#[test]
fn an_unknown_migration_in_flight_is_refused() {
install_migration(SyntheticMigration::None);
let error = plan(
FORMAT_VERSION,
Some(&proto::MigrationValue {
from_format: FORMAT_VERSION,
to_format: FORMAT_VERSION + 1,
cursor: Vec::new(),
}),
)
.err()
.unwrap();
assert!(matches!(error, Error::Migration(_)), "{error:?}");
}
#[tokio::test]
async fn the_migrate_verb_is_a_noop_against_a_current_store() {
install_migration(SyntheticMigration::None);
let object_store: Arc<InMemory> = Arc::new(InMemory::new());
let catalog = Catalog::open(object_store.clone(), CatalogOptions::default())
.await
.unwrap();
catalog.close().await.unwrap();
let report = Catalog::migrate(
object_store,
CatalogOptions::default(),
MigrationRequest::default(),
)
.await
.unwrap();
assert_eq!(report.from_format, FORMAT_VERSION);
assert_eq!(report.to_format, FORMAT_VERSION);
assert!(report.units_run.is_empty());
assert!(!report.resumed);
}
#[tokio::test]
async fn the_checkpoint_flag_takes_and_releases_one() {
install_migration(SyntheticMigration::None);
let object_store: Arc<InMemory> = Arc::new(InMemory::new());
let catalog = Catalog::open(object_store.clone(), CatalogOptions::default())
.await
.unwrap();
catalog.close().await.unwrap();
Catalog::migrate(
object_store.clone(),
CatalogOptions::default(),
MigrationRequest { checkpoint: true },
)
.await
.unwrap();
let catalog = Catalog::open(object_store, CatalogOptions::default())
.await
.unwrap();
assert_eq!(catalog.snapshot().await.unwrap().schemas().len(), 1);
catalog.close().await.unwrap();
}
#[test]
fn the_registry_and_the_readable_floor_agree() {
for pair in MIGRATIONS.windows(2) {
assert_eq!(
pair[0].to_format, pair[1].from_format,
"{} and {} leave a gap the chain cannot cross",
pair[0].name, pair[1].name
);
}
let mut sources: Vec<u64> = MIGRATIONS.iter().map(|unit| unit.from_format).collect();
let before = sources.len();
sources.sort_unstable();
sources.dedup();
assert_eq!(
sources.len(),
before,
"two units read the same format; which one runs would depend on registry order"
);
for unit in MIGRATIONS {
assert!(
unit.from_format < unit.to_format,
"{} does not move the store forward",
unit.name
);
assert!(
unit.to_format <= MAX_FORMAT_VERSION,
"{} migrates to {}, past the newest format this binary names ({MAX_FORMAT_VERSION})",
unit.name,
unit.to_format
);
}
let expected_floor = MIGRATIONS
.last()
.map_or(FORMAT_VERSION, |unit| unit.to_format);
assert_eq!(
MIN_FORMAT_VERSION, expected_floor,
"the floor must sit at the newest rewritten layout: below it the keys are somewhere else"
);
}