use std::sync::Arc;
use futures::FutureExt;
use object_store::memory::InMemory;
use super::*;
use crate::{
catalog::{Catalog, CatalogOptions, MigrationRequest},
fault::inject_crash,
store::{
key::EntityKey,
open::StoreBuilder,
read::{EntityRecord, scan_current_entities},
},
transaction::commit::{FORMAT_VERSION, MAX_FORMAT_VERSION, MIN_FORMAT_VERSION, durable},
};
const TEST_TO_FORMAT: u64 = MAX_FORMAT_VERSION + 1;
const SOURCE_SCOPE: u64 = 1;
const TARGET_SCOPE: u64 = 2;
const SEEDED_RECORDS: u64 = 3;
fn move_scope_step<'a>(
tx: &'a DbTransaction,
cursor: &'a [u8],
) -> BoxFuture<'a, Result<StepOutcome>> {
async move {
let start = decode_cursor(cursor)?;
let Some((scope_id, value)) = next_source_record(tx, start).await? else {
return Ok(None);
};
tx.put(
Key::current(EntityKey::Option {
scope_kind: TARGET_SCOPE,
scope_id,
})
.encode(),
value::encode_value(&value),
)
.map_err(Error::from)?;
tx.delete(
Key::current(EntityKey::Option {
scope_kind: SOURCE_SCOPE,
scope_id,
})
.encode(),
)
.map_err(Error::from)?;
Ok(Some(scope_id.to_be_bytes().to_vec()))
}
.boxed()
}
fn decode_cursor(cursor: &[u8]) -> Result<Option<u64>> {
if cursor.is_empty() {
return Ok(None);
}
let bytes: [u8; 8] = cursor
.try_into()
.map_err(|_| Error::Corruption("migration cursor is not a scope id".to_string()))?;
Ok(Some(u64::from_be_bytes(bytes)))
}
async fn next_source_record(
tx: &DbTransaction,
start: Option<u64>,
) -> Result<Option<(u64, proto::OptionScopeValue)>> {
let mut found: Vec<(u64, proto::OptionScopeValue)> = scan_current_entities(ReadHandle::Tx(tx))
.await?
.into_iter()
.filter_map(|record| match record {
EntityRecord::Option {
scope_kind,
scope_id,
value,
} if scope_kind == SOURCE_SCOPE => Some((scope_id, value)),
_ => None,
})
.filter(|(scope_id, _)| start.is_none_or(|start| *scope_id > start))
.collect();
found.sort_by_key(|(scope_id, _)| *scope_id);
Ok(found.into_iter().next())
}
const MOVE_SCOPE: MigrationUnit = MigrationUnit {
name: "move-option-scope",
from_format: FORMAT_VERSION,
to_format: TEST_TO_FORMAT,
step: move_scope_step,
};
fn no_work_step<'a>(
_tx: &'a DbTransaction,
_cursor: &'a [u8],
) -> BoxFuture<'a, Result<StepOutcome>> {
async move { Ok(None) }.boxed()
}
const SECOND_LINK: MigrationUnit = MigrationUnit {
name: "second-link",
from_format: TEST_TO_FORMAT,
to_format: TEST_TO_FORMAT + 1,
step: no_work_step,
};
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()
}
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: &[&'static MigrationUnit],
) -> Result<()> {
let db = open_migrator(object_store).await;
let outcome = drive(&db, units).await;
db.close().await.unwrap();
outcome
}
async fn drive(db: &Db, units: &[&'static MigrationUnit]) -> Result<()> {
let tx = db
.begin(IsolationLevel::Snapshot)
.await
.map_err(Error::from)?;
let format = read::read_format(ReadHandle::Tx(&tx)).await?;
let marker = read::read_migration(ReadHandle::Tx(&tx)).await?;
tx.rollback();
let mut current = format
.ok_or_else(|| Error::Corruption("uninitialized store".to_string()))?
.format_version;
let mut resume = marker.map(|marker| marker.cursor);
for unit in units {
if unit.to_format <= current {
continue;
}
run_unit(db, unit, resume.take()).await?;
current = unit.to_format;
}
Ok(())
}
#[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, &[&MOVE_SCOPE])
.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, TEST_TO_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, &[&MOVE_SCOPE]).await;
let (format, marker) = durable_state(&object_store).await;
assert!(
!(format == TEST_TO_FORMAT && marker.is_some()),
"new format with the marker still present at {point:?}"
);
if matches!(point, CrashPoint::AfterFinish) {
assert_eq!(format, TEST_TO_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, &[&MOVE_SCOPE])
.await
.unwrap_or_else(|error| panic!("resume after {point:?}: {error:?}"));
let (format, marker) = durable_state(&object_store).await;
assert_eq!(format, TEST_TO_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, &[&MOVE_SCOPE]).await.unwrap();
migrate_with(&object_store, &[&MOVE_SCOPE]).await.unwrap();
assert_eq!(option_scopes(&object_store).await, migrated_scopes());
let (format, marker) = durable_state(&object_store).await;
assert_eq!(format, TEST_TO_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, &[&MOVE_SCOPE, &SECOND_LINK])
.await
.unwrap();
let (format, marker) = durable_state(&object_store).await;
assert_eq!(format, SECOND_LINK.to_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, &[&MOVE_SCOPE]).await.unwrap();
let db = open_migrator(&object_store).await;
let tx = db.begin(IsolationLevel::Snapshot).await.unwrap();
let past = crate::transaction::commit::materialize(ReadHandle::Tx(&tx), Some(first.get()))
.await
.unwrap();
tx.rollback();
db.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() {
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() {
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() {
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() {
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"
);
}