use std::sync::Arc;
use futures::TryStreamExt;
use object_store::{ObjectStore, ObjectStoreExt, memory::InMemory};
use super::*;
mod allocation;
mod durability;
#[test]
fn a_prepared_head_view_is_installed_without_rebuilding() {
let projections = std::sync::RwLock::new(ProjectionCache::empty());
let prepared = Arc::new(CatalogSnapshot {
snapshot: proto::SnapshotValue {
snapshot_id: 7,
..proto::SnapshotValue::default()
},
batch_seq: 11,
..CatalogSnapshot::default()
});
install_committed_head_view(
&projections,
HeadViewUpdate::Prepared(Arc::clone(&prepared)),
&[],
);
let installed = held_head_view(&projections).unwrap();
assert!(Arc::ptr_eq(&installed, &prepared));
}
#[tokio::test]
async fn a_durable_commit_returns_its_own_outcome() {
let object_store: Arc<InMemory> = Arc::new(InMemory::new());
let (db, _) = StoreBuilder::new("", object_store)
.open_writer()
.await
.unwrap();
let tx = db.begin(IsolationLevel::Snapshot).await.unwrap();
tx.put(Key::Sys(SysKey::Head).encode(), b"head").unwrap();
assert!(
commit_durable(
tx,
"test",
StagedBytes::default(),
&CommitDurability::OnFlushInterval,
)
.await
.is_ok()
);
assert_eq!(
db.get(&Key::Sys(SysKey::Head).encode())
.await
.unwrap()
.unwrap()
.as_ref(),
b"head"
);
db.close().await.unwrap();
}
#[tokio::test]
async fn a_store_with_an_unreadable_manifest_refuses_to_open() {
let object_store: Arc<InMemory> = Arc::new(InMemory::new());
StoreBuilder::new("", object_store.clone())
.open_writer()
.await
.unwrap()
.0
.close()
.await
.unwrap();
let manifests: Vec<_> = object_store
.list(None)
.map_ok(|meta| meta.location)
.try_filter(|location| futures::future::ready(location.to_string().contains("manifest")))
.try_collect()
.await
.unwrap();
assert!(!manifests.is_empty(), "the store must have a manifest");
for manifest in manifests {
object_store
.put(&manifest, vec![0xff; 64].into())
.await
.unwrap();
}
let err = open_initialized(
StoreBuilder::new("", object_store),
false,
None,
std::time::Duration::ZERO,
)
.await
.err()
.unwrap();
assert!(matches!(err, Error::Store(_)), "{err:?}");
}
#[tokio::test]
async fn unknown_format_is_refused() {
let object_store: Arc<InMemory> = Arc::new(InMemory::new());
let (db, _) = StoreBuilder::new("", object_store.clone())
.open_writer()
.await
.unwrap();
db.put(
&Key::Sys(SysKey::Format).encode(),
&value::encode_value(&proto::FormatValue {
format_version: MAX_FORMAT_VERSION + 1,
writer_version: "future".into(),
}),
)
.await
.unwrap();
db.close().await.unwrap();
let err = open_initialized(
StoreBuilder::new("", object_store),
false,
None,
std::time::Duration::ZERO,
)
.await
.err()
.unwrap();
assert!(matches!(err, Error::Migration(_)), "{err:?}");
}
#[tokio::test]
async fn migration_marker_is_refused() {
let object_store: Arc<InMemory> = Arc::new(InMemory::new());
let (db, _) = StoreBuilder::new("", object_store.clone())
.open_writer()
.await
.unwrap();
db.put(
&Key::Sys(SysKey::Migration).encode(),
&value::encode_value(&proto::MigrationValue {
from_format: 1,
to_format: 2,
cursor: vec![],
}),
)
.await
.unwrap();
db.close().await.unwrap();
let err = open_initialized(
StoreBuilder::new("", object_store),
false,
None,
std::time::Duration::ZERO,
)
.await
.err()
.unwrap();
match err {
Error::Migration(msg) => assert!(
msg.contains("Catalog::migrate"),
"mid-migration message names no verb: {msg}"
),
other => panic!("expected Migration, got {other:?}"),
}
}
#[tokio::test]
async fn older_format_refuses_toward_migrate() {
let object_store: Arc<InMemory> = Arc::new(InMemory::new());
let (db, _) = StoreBuilder::new("", object_store.clone())
.open_writer()
.await
.unwrap();
db.put(
&Key::Sys(SysKey::Format).encode(),
&value::encode_value(&proto::FormatValue {
format_version: 0,
writer_version: "t".into(),
}),
)
.await
.unwrap();
db.close().await.unwrap();
let err = open_initialized(
StoreBuilder::new("", object_store),
false,
None,
std::time::Duration::ZERO,
)
.await
.err()
.unwrap();
match err {
Error::Migration(msg) => {
assert!(
msg.contains("Catalog::migrate"),
"older-store message names no verb: {msg}"
);
assert!(
msg.contains("this same binary"),
"older-store message does not say who can run it: {msg}"
);
}
other => panic!("expected Migration, got {other:?}"),
}
}
#[tokio::test]
async fn materialize_gate_refuses_on_marker() {
let object_store: Arc<InMemory> = Arc::new(InMemory::new());
let (db, _) = StoreBuilder::new("", object_store)
.open_writer()
.await
.unwrap();
let tx = db.begin(IsolationLevel::Snapshot).await.unwrap();
tx.put(
Key::Sys(SysKey::Head).encode(),
value::encode_value(&proto::HeadValue {
snapshot_id: 0,
batch_seq: 0,
}),
)
.unwrap();
tx.put(
Key::Sys(SysKey::Migration).encode(),
value::encode_value(&proto::MigrationValue {
from_format: 1,
to_format: 2,
cursor: vec![],
}),
)
.unwrap();
tx.commit()
.await
.unwrap()
.unwrap()
.await_durable()
.await
.unwrap();
let read = db.begin(IsolationLevel::Snapshot).await.unwrap();
let err = materialize(ReadHandle::Tx(&read), None)
.await
.err()
.unwrap();
assert!(matches!(err, Error::Migration(_)), "{err:?}");
read.rollback();
}
#[test]
fn renaming_one_column_stages_no_write_for_any_sibling() {
use crate::{
catalog::ColumnDef,
store::key::{CurrentKey, HistoryKey},
};
let column_def = |name: &str| ColumnDef {
name: name.into(),
column_type: "BIGINT".into(),
nulls_allowed: true,
default_value: None,
children: Vec::new(),
};
let snap0 = proto::SnapshotValue {
snapshot_id: 0,
snapshot_time_micros: 0,
schema_version: 0,
next_catalog_id: 1,
next_file_id: 0,
changes_made: String::new(),
author: None,
commit_message: None,
commit_extra_info: None,
schema_changed_table_ids: Vec::new(),
deleted_data_file_ids: Vec::new(),
};
let mut setup = Transaction::new(CatalogSnapshot::build(snap0, &[], &[], None), 1);
let schema = setup.create_schema("s").unwrap();
let table = setup
.create_table(
schema,
"t",
&[
column_def("a"),
column_def("b"),
column_def("c"),
column_def("d"),
],
)
.unwrap();
let renamed = setup.columns_of(table)[1].id;
let base = setup.into_parts().state;
let mut tx = Transaction::new(base.clone(), 2);
tx.rename_column(table, renamed, "b2").unwrap();
let state = tx.into_parts().state;
let touched: Vec<u64> = diff_writes(&base, &state, 2)
.iter()
.filter_map(|(key_bytes, _)| match Key::decode(key_bytes).unwrap() {
Key::Current(CurrentKey::Entity(EntityKey::Column { column_id, .. }))
| Key::History(HistoryKey {
entity: EntityKey::Column { column_id, .. },
..
}) => Some(column_id),
_ => None,
})
.collect();
assert!(
touched.iter().all(|id| *id == renamed.get()),
"a rename of column {} staged writes for siblings: {touched:?}",
renamed.get()
);
assert!(
!touched.is_empty(),
"the renamed column itself must be written"
);
}
#[test]
fn register_then_expire_in_one_commit_stages_no_orphaned_file_column_stats() {
use crate::{
catalog::{ColumnDef, DataFile, FileColumnStats},
store::key::{CurrentKey, HistoryKey},
};
let snap0 = proto::SnapshotValue {
snapshot_id: 0,
snapshot_time_micros: 1,
schema_version: 0,
next_catalog_id: 0,
next_file_id: 0,
changes_made: String::new(),
author: None,
commit_message: None,
commit_extra_info: None,
schema_changed_table_ids: Vec::new(),
deleted_data_file_ids: Vec::new(),
};
let empty = CatalogSnapshot::build(snap0, &[], &[], None);
let mut setup = Transaction::new(empty, 1);
let schema = setup.create_schema("s").unwrap();
let table = setup
.create_table(
schema,
"t",
&[ColumnDef {
name: "a".into(),
column_type: "BIGINT".into(),
nulls_allowed: true,
default_value: None,
children: Vec::new(),
}],
)
.unwrap();
let column = setup.columns_of(table)[0].id;
let base = setup.into_parts().state;
let mut tx = Transaction::new(base.clone(), 2);
let file = tx
.register_data_file(
table,
DataFile {
path: "f.parquet".into(),
path_is_relative: true,
file_format: "parquet".into(),
record_count: 10,
file_size_bytes: 100,
footer_size: 4,
encryption_key: None,
partition_values: vec![],
column_stats: vec![FileColumnStats {
column_id: column,
column_size_bytes: 10,
value_count: 10,
null_count: 0,
min_value: Some("1".into()),
max_value: Some("2".into()),
contains_nan: None,
extra_stats: None,
}],
},
&[],
)
.unwrap();
tx.expire_data_file(table, file).unwrap();
let state = tx.into_parts().state;
let writes = diff_writes(&base, &state, 2);
for (key_bytes, _) in &writes {
let key = Key::decode(key_bytes).unwrap();
let is_file_column_stats = matches!(
key,
Key::Current(CurrentKey::Entity(EntityKey::FileColumnStats { .. }))
| Key::History(HistoryKey {
entity: EntityKey::FileColumnStats { .. },
..
})
);
assert!(
!is_file_column_stats,
"orphaned file_column_stats write staged: {key:?}"
);
}
}
#[tokio::test]
async fn fresh_reader_sees_committed_head() {
use slatedb::DbReader;
use crate::catalog::{Catalog, CatalogOptions};
let object_store: Arc<InMemory> = Arc::new(InMemory::new());
let catalog = Catalog::open(object_store.clone(), CatalogOptions::default())
.await
.unwrap();
catalog
.commit(|tx| tx.create_schema("visible").map(|_| ()))
.await
.unwrap();
let reader = DbReader::builder("", object_store)
.with_segment_extractor(Arc::new(crate::store::segment::TagSegmentExtractor))
.build()
.await
.unwrap();
let head_bytes = reader
.get(Key::Sys(SysKey::Head).encode())
.await
.unwrap()
.expect("fresh reader must see the head");
let head: proto::HeadValue = value::decode_value(&head_bytes).unwrap();
assert_eq!(head.snapshot_id, 1);
reader.close().await.unwrap();
catalog.close().await.unwrap();
}
#[tokio::test]
async fn flush_on_commit_lands_the_write_without_waiting_for_the_interval() {
use std::time::{Duration, Instant};
use crate::catalog::{Catalog, CatalogOptions};
let object_store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
let catalog = Catalog::open(
Arc::clone(&object_store),
CatalogOptions {
flush_interval: Duration::from_secs(10),
flush_on_commit: true,
..CatalogOptions::default()
},
)
.await
.unwrap();
let wal_objects = || {
let object_store = Arc::clone(&object_store);
async move {
object_store
.list(None)
.try_collect::<Vec<_>>()
.await
.unwrap()
.into_iter()
.filter(|object| object.location.as_ref().starts_with("wal/"))
.count()
}
};
let before = wal_objects().await;
let started = Instant::now();
catalog
.commit(|tx| {
tx.create_schema("s")?;
Ok(())
})
.await
.unwrap();
let waited = started.elapsed();
assert!(
waited < Duration::from_secs(5),
"commit waited {waited:?}, so it sat on the flush interval"
);
assert!(
wal_objects().await > before,
"the commit returned before its write reached object storage"
);
}
#[tokio::test]
async fn verb_ddl_records_schema_changed_table_ids() {
use crate::catalog::{Catalog, CatalogOptions, ColumnDef, DataFile};
let catalog = Catalog::open(Arc::new(InMemory::new()), CatalogOptions::default())
.await
.unwrap();
let created = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let schema = tx.create_schema("s")?;
let table = tx.create_table(
schema,
"t",
&[ColumnDef {
name: "a".into(),
column_type: "BIGINT".into(),
nulls_allowed: true,
default_value: None,
children: Vec::new(),
}],
)?;
tx.rename_table(table, "t2")?;
created.set(Some(table));
Ok(())
})
.await
.unwrap();
let table = created.get().unwrap();
catalog
.commit(|tx| {
tx.register_data_file(
table,
DataFile {
path: "f.parquet".into(),
path_is_relative: true,
file_format: "parquet".into(),
record_count: 1,
file_size_bytes: 10,
footer_size: 4,
encryption_key: None,
partition_values: vec![],
column_stats: vec![],
},
&[],
)
.map(|_| ())
})
.await
.unwrap();
let tx = catalog.begin_write_tx().await.unwrap();
let ddl = read::read_snapshot(ReadHandle::Tx(&tx), 1)
.await
.unwrap()
.unwrap();
assert_eq!(ddl.schema_changed_table_ids, vec![table.get()]);
let data_only = read::read_snapshot(ReadHandle::Tx(&tx), 2)
.await
.unwrap()
.unwrap();
assert_eq!(data_only.schema_changed_table_ids, Vec::<u64>::new());
tx.rollback();
catalog.close().await.unwrap();
}
async fn catalog_with_two_column_table() -> (crate::catalog::Catalog, crate::catalog::TableId) {
use crate::catalog::{Catalog, CatalogOptions, ColumnDef};
let catalog = Catalog::open(Arc::new(InMemory::new()), CatalogOptions::default())
.await
.unwrap();
let table = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let schema = tx.create_schema("s")?;
let column = |name: &str| ColumnDef {
name: name.into(),
column_type: "BIGINT".into(),
nulls_allowed: true,
default_value: None,
children: Vec::new(),
};
let created = tx.create_table(schema, "t", &[column("a"), column("b")])?;
table.set(Some(created));
Ok(())
})
.await
.unwrap();
(catalog, table.get().unwrap())
}
async fn plant_migration_marker(catalog: &crate::catalog::Catalog) {
let tx = catalog.begin_write_tx().await.unwrap();
let head = read::read_head(ReadHandle::Tx(&tx)).await.unwrap().unwrap();
tx.put(
Key::Sys(SysKey::Migration).encode(),
value::encode_value(&proto::MigrationValue {
from_format: 1,
to_format: 2,
cursor: Vec::new(),
}),
)
.unwrap();
let (key, stamp) = head_stamp(head.snapshot_id, head.batch_seq);
tx.put(key, stamp.unwrap()).unwrap();
catalog.commit_write_tx(tx).await.unwrap();
}
fn int_value(value: i128) -> crate::store::index_encoding::IndexKeyValue {
crate::store::index_encoding::IndexKeyValue::Int {
value,
width: crate::store::index_encoding::IntWidth::I64,
}
}
fn int_key(value: i128) -> Vec<crate::store::index_encoding::IndexKeyValue> {
vec![int_value(value)]
}
fn entry(row_id: u64, value: i128) -> crate::catalog::IndexEntry {
crate::catalog::IndexEntry {
row_id,
values: vec![Some(int_value(value))],
}
}
fn pair_entry(row_id: u64, first: i128, second: i128) -> crate::catalog::IndexEntry {
crate::catalog::IndexEntry {
row_id,
values: vec![Some(int_value(first)), Some(int_value(second))],
}
}
fn sorted_row_ids(mut ids: Vec<u64>) -> Vec<u64> {
ids.sort_unstable();
ids
}
fn null_entry(row_id: u64) -> crate::catalog::IndexEntry {
crate::catalog::IndexEntry {
row_id,
values: vec![None],
}
}
async fn read_format_version(catalog: &crate::catalog::Catalog) -> u64 {
let tx = catalog.begin_write_tx().await.unwrap();
let format = read::read_format(ReadHandle::Tx(&tx)).await.unwrap();
tx.rollback();
format.map_or(FORMAT_VERSION, |f| f.format_version)
}
#[tokio::test]
async fn create_index_persists_definition_stamps_format_and_lands_entries() {
use crate::{
catalog::{ColumnId, IndexDef, IndexState},
store::key::{IndexKind, index_index_prefix},
};
let (catalog, table) = catalog_with_two_column_table().await;
assert_eq!(
read_format_version(&catalog).await,
FORMAT_VERSION.max(MIN_FORMAT_VERSION)
);
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
&[entry(0, 10), entry(1, 20)],
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
let index_id = index.get().unwrap();
let snapshot = catalog.snapshot().await.unwrap();
let infos = snapshot.indexes_of(table);
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].id, index_id);
assert_eq!(infos[0].columns, vec![ColumnId::new(1)]);
assert!(infos[0].unique);
assert_eq!(infos[0].state, IndexState::Ready);
assert_eq!(
read_format_version(&catalog).await,
FORMAT_WITH_INDEX.max(MIN_FORMAT_VERSION)
);
let tx = catalog.begin_write_tx().await.unwrap();
let mut iter = ReadHandle::Tx(&tx)
.scan_prefix(
index_index_prefix(IndexKind::Unique, index_id.get()),
..,
crate::store::handle::ScanShape::Bulk,
)
.await
.unwrap();
let mut count = 0;
while iter.next().await.unwrap().is_some() {
count += 1;
}
assert_eq!(count, 2);
tx.rollback();
catalog.close().await.unwrap();
}
#[tokio::test]
async fn deferred_index_is_non_unique_and_stamps_its_format() {
use crate::catalog::{ColumnId, IndexDef, IndexMaintenance};
let (catalog, table) = catalog_with_two_column_table().await;
catalog
.commit(|tx| {
tx.create_index_ordered_with_maintenance(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: false,
},
&[],
IndexMaintenance::Deferred,
&[],
)
.map(|_| ())
})
.await
.unwrap();
let snapshot = catalog.snapshot().await.unwrap();
assert_eq!(
snapshot.indexes_of(table)[0].maintenance,
IndexMaintenance::Deferred
);
assert_eq!(
read_format_version(&catalog).await,
FORMAT_WITH_DEFERRED_INDEX.max(MIN_FORMAT_VERSION)
);
let error = catalog
.commit(|tx| {
tx.create_index_ordered_with_maintenance(
table,
&IndexDef {
name: "unique_by_b".into(),
columns: vec![ColumnId::new(2)],
unique: true,
},
&[],
IndexMaintenance::Deferred,
&[],
)
.map(|_| ())
})
.await
.unwrap_err();
assert!(error.to_string().contains("non-unique"), "{error}");
catalog.close().await.unwrap();
}
#[tokio::test]
async fn duplicate_unique_value_in_backfill_aborts_create() {
use crate::catalog::{ColumnId, IndexDef};
let (catalog, table) = catalog_with_two_column_table().await;
let err = catalog
.commit(|tx| {
tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
&[entry(0, 10), entry(1, 10)],
)
.map(|_| ())
})
.await
.unwrap_err();
assert!(matches!(err, Error::Constraint(_)), "{err}");
assert!(
catalog
.snapshot()
.await
.unwrap()
.indexes_of(table)
.is_empty()
);
assert_eq!(
read_format_version(&catalog).await,
FORMAT_VERSION.max(MIN_FORMAT_VERSION)
);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn non_unique_index_accepts_duplicate_values() {
use crate::catalog::{ColumnId, IndexDef};
let (catalog, table) = catalog_with_two_column_table().await;
catalog
.commit(|tx| {
tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: false,
},
&[entry(0, 10), entry(1, 10)],
)
.map(|_| ())
})
.await
.unwrap();
assert_eq!(catalog.snapshot().await.unwrap().indexes_of(table).len(), 1);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn null_indexed_value_gets_no_entry_so_unique_admits_many() {
use crate::catalog::{ColumnId, IndexDef, IndexEntry};
let (catalog, table) = catalog_with_two_column_table().await;
let null_entry = |row_id| IndexEntry {
row_id,
values: vec![None],
};
catalog
.commit(|tx| {
tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
&[null_entry(0), null_entry(1)],
)
.map(|_| ())
})
.await
.unwrap();
assert_eq!(catalog.snapshot().await.unwrap().indexes_of(table).len(), 1);
catalog.close().await.unwrap();
}
async fn register_three_row_file(
catalog: &crate::catalog::Catalog,
table: crate::catalog::TableId,
) -> crate::catalog::DataFileId {
use crate::catalog::DataFile;
let file = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.register_data_file(
table,
DataFile {
path: "f.parquet".into(),
path_is_relative: true,
file_format: "parquet".into(),
record_count: 3,
file_size_bytes: 30,
footer_size: 4,
encryption_key: None,
partition_values: vec![],
column_stats: vec![],
},
&[],
)?;
file.set(Some(id));
Ok(())
})
.await
.unwrap();
file.get().unwrap()
}
#[tokio::test]
async fn index_lookup_resolves_unique_value_to_its_data_file_row() {
use crate::catalog::{ColumnId, IndexDef};
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
&[entry(0, 10), entry(1, 20), entry(2, 30)],
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
let index = index.get().unwrap();
let hits = catalog
.index_lookup(table, index, &[int_value(20)])
.await
.unwrap();
assert_eq!(hits, vec![1]);
assert!(
catalog
.index_lookup(table, index, &[int_value(99)])
.await
.unwrap()
.is_empty()
);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn index_lookup_returns_all_rows_for_a_non_unique_value() {
use crate::catalog::{ColumnId, IndexDef};
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: false,
},
&[entry(0, 10), entry(1, 20), entry(2, 10)],
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
let value = crate::store::index_encoding::IndexKeyValue::Int {
value: 10,
width: crate::store::index_encoding::IntWidth::I64,
};
let mut rows: Vec<u64> = catalog
.index_lookup(table, index.get().unwrap(), &[value])
.await
.unwrap()
.into_iter()
.collect();
rows.sort_unstable();
assert_eq!(rows, vec![0, 2]);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn index_range_selects_unique_values_in_a_bounded_interval() {
use std::ops::Bound;
use crate::catalog::{ColumnId, IndexDef};
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
&[entry(0, 10), entry(1, 20), entry(2, 30)],
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
let index = index.get().unwrap();
let between = catalog
.index_range(
table,
index,
Bound::Included(int_key(15)),
Bound::Included(int_key(25)),
false,
)
.await
.unwrap();
assert_eq!(between, vec![1]);
let above = catalog
.index_range(
table,
index,
Bound::Excluded(int_key(20)),
Bound::Unbounded,
false,
)
.await
.unwrap();
assert_eq!(sorted_row_ids(above), vec![2]);
let below = catalog
.index_range(
table,
index,
Bound::Unbounded,
Bound::Included(int_key(20)),
false,
)
.await
.unwrap();
assert_eq!(sorted_row_ids(below), vec![0, 1]);
let all = catalog
.index_range(
table,
index,
Bound::Included(int_key(10)),
Bound::Included(int_key(30)),
false,
)
.await
.unwrap();
assert_eq!(sorted_row_ids(all), vec![0, 1, 2]);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn index_range_reverse_serves_the_opposite_order() {
use std::ops::Bound;
use crate::catalog::{ColumnId, IndexDef};
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
&[entry(0, 10), entry(1, 20), entry(2, 30)],
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
let index = index.get().unwrap();
let order = |query: Vec<u64>| query;
let ascending = catalog
.index_range(
table,
index,
Bound::Included(int_key(10)),
Bound::Included(int_key(30)),
false,
)
.await
.unwrap();
assert_eq!(order(ascending), vec![0, 1, 2]);
let reversed = catalog
.index_range(
table,
index,
Bound::Included(int_key(10)),
Bound::Included(int_key(30)),
true,
)
.await
.unwrap();
assert_eq!(
order(reversed),
vec![2, 1, 0],
"reverse serves the exact opposite order"
);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn index_range_refuses_a_bound_wider_than_the_index() {
use std::ops::Bound;
use crate::catalog::{ColumnId, IndexDef};
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
&[entry(0, 10), entry(1, 20), entry(2, 30)],
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
let index = index.get().unwrap();
let err = catalog
.index_range(
table,
index,
Bound::Included(vec![int_value(10), int_value(20)]),
Bound::Unbounded,
false,
)
.await
.unwrap_err();
assert!(matches!(err, Error::Constraint(_)), "{err}");
catalog.close().await.unwrap();
}
#[tokio::test]
async fn index_range_over_a_non_unique_index_returns_every_matching_row() {
use std::ops::Bound;
use crate::catalog::{ColumnId, IndexDef};
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: false,
},
&[entry(0, 10), entry(1, 20), entry(2, 10)],
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
let index = index.get().unwrap();
let low = catalog
.index_range(
table,
index,
Bound::Unbounded,
Bound::Excluded(int_key(20)),
false,
)
.await
.unwrap();
assert_eq!(sorted_row_ids(low), vec![0, 2]);
let high = catalog
.index_range(
table,
index,
Bound::Included(int_key(20)),
Bound::Unbounded,
false,
)
.await
.unwrap();
assert_eq!(sorted_row_ids(high), vec![1]);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn index_range_excludes_nulls_from_an_open_side() {
use std::ops::Bound;
use crate::{
catalog::{ColumnId, ColumnOrder, IndexDef},
store::index_encoding::{Direction, NullOrder},
};
for (nulls, lower, upper) in [
(
NullOrder::Last,
Bound::Included(int_key(10)),
Bound::Unbounded,
),
(
NullOrder::First,
Bound::Unbounded,
Bound::Included(int_key(20)),
),
] {
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index_ordered(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: false,
},
&[ColumnOrder {
direction: Direction::Ascending,
nulls,
}],
&[entry(0, 10), entry(1, 20), null_entry(2)],
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
let index = index.get().unwrap();
let hits = catalog
.index_range(table, index, lower.clone(), upper.clone(), false)
.await
.unwrap();
assert_eq!(
sorted_row_ids(hits),
vec![0, 1],
"{nulls:?} leaked the NULL row"
);
catalog.close().await.unwrap();
}
}
#[tokio::test]
async fn descending_index_scans_high_value_first() {
use std::ops::Bound;
use crate::{
catalog::{ColumnId, ColumnOrder, IndexDef},
store::index_encoding::{Direction, NullOrder},
};
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index_ordered(
table,
&IndexDef {
name: "by_a_desc".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
&[ColumnOrder {
direction: Direction::Descending,
nulls: NullOrder::Last,
}],
&[entry(0, 10), entry(1, 20), entry(2, 30)],
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
let index = index.get().unwrap();
let order = |hits: Vec<u64>| hits;
let all = catalog
.index_range(
table,
index,
Bound::Included(int_key(10)),
Bound::Included(int_key(30)),
false,
)
.await
.unwrap();
assert_eq!(
order(all),
vec![2, 1, 0],
"descending index scans high first"
);
let above = catalog
.index_range(
table,
index,
Bound::Excluded(int_key(15)),
Bound::Unbounded,
false,
)
.await
.unwrap();
assert_eq!(order(above), vec![2, 1]);
let below = catalog
.index_range(
table,
index,
Bound::Unbounded,
Bound::Included(int_key(20)),
false,
)
.await
.unwrap();
assert_eq!(order(below), vec![1, 0]);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn unique_index_admits_null_rows_and_index_nulls_finds_them() {
use crate::catalog::{ColumnId, IndexDef};
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
&[entry(0, 10), null_entry(1), null_entry(2)],
)?;
index.set(Some(id));
Ok(())
})
.await
.expect("a unique index must admit multiple NULL rows");
let index = index.get().unwrap();
let mut nulls: Vec<u64> = catalog
.index_nulls(table, index, vec![None], false)
.await
.unwrap()
.into_iter()
.collect();
nulls.sort_unstable();
assert_eq!(nulls, vec![1, 2], "IS NULL finds both NULL rows");
let reversed_nulls = catalog
.index_nulls(table, index, vec![None], true)
.await
.unwrap()
.into_iter()
.collect::<Vec<_>>();
assert_eq!(
reversed_nulls,
vec![2, 1],
"a reverse NULL-prefix scan streams the opposite order"
);
let value = vec![crate::store::index_encoding::IndexKeyValue::Int {
value: 10,
width: crate::store::index_encoding::IntWidth::I64,
}];
assert_eq!(
catalog.index_lookup(table, index, &value).await.unwrap(),
vec![0]
);
let err = catalog
.index_nulls(
table,
index,
vec![Some(crate::store::index_encoding::IndexKeyValue::Int {
value: 10,
width: crate::store::index_encoding::IntWidth::I64,
})],
false,
)
.await
.unwrap_err();
assert!(matches!(err, Error::Constraint(_)), "{err}");
catalog.close().await.unwrap();
}
#[tokio::test]
async fn composite_index_nulls_matches_a_leading_prefix() {
use crate::catalog::{ColumnId, IndexDef, IndexEntry};
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let entry = |row_id, a, b| IndexEntry {
row_id,
values: vec![a, b],
};
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index(
table,
&IndexDef {
name: "by_ab".into(),
columns: vec![ColumnId::new(1), ColumnId::new(2)],
unique: false,
},
&[
entry(0, Some(int_value(5)), None),
entry(1, Some(int_value(5)), Some(int_value(3))),
entry(2, Some(int_value(7)), None),
],
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
let index = index.get().unwrap();
assert_eq!(
sorted_row_ids(
catalog
.index_nulls(table, index, vec![Some(int_value(5)), None], false)
.await
.unwrap()
),
vec![0]
);
assert!(
catalog
.index_nulls(table, index, vec![None], false)
.await
.unwrap()
.is_empty()
);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn index_range_spans_a_composite_prefix_and_window() {
use std::ops::Bound;
use crate::catalog::{ColumnId, IndexDef};
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index(
table,
&IndexDef {
name: "by_ab".into(),
columns: vec![ColumnId::new(1), ColumnId::new(2)],
unique: true,
},
&[
pair_entry(0, 5, 10),
pair_entry(1, 5, 20),
pair_entry(2, 7, 10),
],
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
let index = index.get().unwrap();
let equal_a = catalog
.index_range(
table,
index,
Bound::Included(vec![int_value(5)]),
Bound::Included(vec![int_value(5)]),
false,
)
.await
.unwrap();
assert_eq!(
sorted_row_ids(equal_a),
vec![0, 1],
"a = 5 spans rows 0 and 1"
);
let window = catalog
.index_range(
table,
index,
Bound::Included(vec![int_value(5), int_value(20)]),
Bound::Included(vec![int_value(7), int_value(10)]),
false,
)
.await
.unwrap();
assert_eq!(
sorted_row_ids(window),
vec![1, 2],
"(5,20)..=(7,10) spans rows 1 and 2"
);
let above_a = catalog
.index_range(
table,
index,
Bound::Excluded(vec![int_value(5)]),
Bound::Unbounded,
false,
)
.await
.unwrap();
assert_eq!(sorted_row_ids(above_a), vec![2], "a > 5 spans only row 2");
let forward = catalog
.index_range(table, index, Bound::Unbounded, Bound::Unbounded, false)
.await
.unwrap();
let reverse = catalog
.index_range(table, index, Bound::Unbounded, Bound::Unbounded, true)
.await
.unwrap();
assert_eq!(forward, vec![0, 1, 2]);
assert_eq!(reverse, vec![2, 1, 0]);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn index_range_prefix_bound_carries_over_a_descending_value() {
use std::ops::Bound;
use crate::{
catalog::{ColumnId, ColumnOrder, IndexDef},
store::index_encoding::{Direction, NullOrder},
};
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let descending = ColumnOrder {
direction: Direction::Descending,
nulls: NullOrder::Last,
};
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index_ordered(
table,
&IndexDef {
name: "by_ab_desc".into(),
columns: vec![ColumnId::new(1), ColumnId::new(2)],
unique: false,
},
&[descending, descending],
&[
pair_entry(0, 256, 1),
pair_entry(1, 256, 2),
pair_entry(2, 512, 1),
],
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
let index = index.get().unwrap();
let equal_a = catalog
.index_range(
table,
index,
Bound::Included(vec![int_value(256)]),
Bound::Included(vec![int_value(256)]),
false,
)
.await
.unwrap();
assert_eq!(
sorted_row_ids(equal_a),
vec![0, 1],
"a = 256 spans rows 0 and 1"
);
let above_a = catalog
.index_range(
table,
index,
Bound::Excluded(vec![int_value(256)]),
Bound::Unbounded,
false,
)
.await
.unwrap();
assert_eq!(sorted_row_ids(above_a), vec![2], "a > 256 spans only row 2");
catalog.close().await.unwrap();
}
#[tokio::test]
async fn index_range_over_mixed_directions_requires_a_pinned_prefix() {
use std::ops::Bound;
use crate::{
catalog::{ColumnId, ColumnOrder, IndexDef},
error::Error,
store::index_encoding::{Direction, NullOrder},
};
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let ascending = ColumnOrder {
direction: Direction::Ascending,
nulls: NullOrder::Last,
};
let descending = ColumnOrder {
direction: Direction::Descending,
nulls: NullOrder::Last,
};
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index_ordered(
table,
&IndexDef {
name: "by_a_asc_b_desc".into(),
columns: vec![ColumnId::new(1), ColumnId::new(2)],
unique: true,
},
&[ascending, descending],
&[
pair_entry(0, 5, 10),
pair_entry(1, 5, 20),
pair_entry(2, 7, 10),
],
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
let index = index.get().unwrap();
let refused = catalog
.index_range(
table,
index,
Bound::Included(vec![int_value(5), int_value(20)]),
Bound::Included(vec![int_value(7), int_value(10)]),
false,
)
.await;
assert!(
matches!(refused, Err(Error::Constraint(_))),
"a free tuple window over mixed directions is refused, got {refused:?}"
);
let pinned = catalog
.index_range(
table,
index,
Bound::Included(vec![int_value(5), int_value(10)]),
Bound::Included(vec![int_value(5), int_value(20)]),
false,
)
.await
.unwrap();
assert_eq!(
sorted_row_ids(pinned),
vec![0, 1],
"a = 5 and 10 <= b <= 20"
);
let leading = catalog
.index_range(
table,
index,
Bound::Included(vec![int_value(7)]),
Bound::Unbounded,
false,
)
.await
.unwrap();
assert_eq!(sorted_row_ids(leading), vec![2], "a >= 7 spans only row 2");
catalog.close().await.unwrap();
}
#[tokio::test]
async fn index_lookup_on_missing_index_is_not_found() {
use crate::catalog::IndexId;
let (catalog, table) = catalog_with_two_column_table().await;
let value = crate::store::index_encoding::IndexKeyValue::Int {
value: 1,
width: crate::store::index_encoding::IntWidth::I64,
};
let err = catalog
.index_lookup(table, IndexId::new(999), &[value])
.await
.unwrap_err();
assert!(matches!(err, Error::NotFound(_)), "{err}");
catalog.close().await.unwrap();
}
#[tokio::test]
async fn register_data_file_must_supply_index_entries_and_they_are_looked_up() {
use crate::catalog::{ColumnId, DataFile, FileIndexEntry, IndexDef};
let (catalog, table) = catalog_with_two_column_table().await;
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
&[],
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
let index = index.get().unwrap();
let file = || DataFile {
path: "f.parquet".into(),
path_is_relative: true,
file_format: "parquet".into(),
record_count: 2,
file_size_bytes: 20,
footer_size: 4,
encryption_key: None,
partition_values: vec![],
column_stats: vec![],
};
let refused = catalog
.commit(|tx| tx.register_data_file(table, file(), &[]).map(|_| ()))
.await;
assert!(matches!(refused, Err(Error::Constraint(_))), "{refused:?}");
catalog
.commit(|tx| {
tx.register_data_file(
table,
file(),
&[
FileIndexEntry {
index,
ordinal: 0,
values: vec![Some(int_value(10))],
},
FileIndexEntry {
index,
ordinal: 1,
values: vec![Some(int_value(20))],
},
],
)
.map(|_| ())
})
.await
.unwrap();
let hits = catalog
.index_lookup(table, index, &[int_value(20)])
.await
.unwrap();
assert_eq!(hits, vec![1]);
catalog.close().await.unwrap();
}
fn bulk_file_entries(
index: crate::catalog::IndexId,
first: i128,
count: u64,
) -> Vec<crate::catalog::FileIndexEntry> {
(0..count)
.map(|ordinal| crate::catalog::FileIndexEntry {
index,
ordinal,
values: vec![Some(int_value(first + i128::from(ordinal)))],
})
.collect()
}
fn bulk_file(path: &str, count: u64) -> crate::catalog::DataFile {
crate::catalog::DataFile {
path: path.into(),
path_is_relative: true,
file_format: "parquet".into(),
record_count: count,
file_size_bytes: count * 10,
footer_size: 4,
encryption_key: None,
partition_values: vec![],
column_stats: vec![],
}
}
#[tokio::test]
async fn bulk_unique_commit_lands_and_committed_duplicate_aborts() {
use crate::catalog::{ColumnId, IndexDef};
const COUNT: u64 = 1500;
let (catalog, table) = catalog_with_two_column_table().await;
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
&[],
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
let index = index.get().unwrap();
catalog
.commit(|tx| {
tx.register_data_file(table, bulk_file("a.parquet", COUNT), &{
bulk_file_entries(index, 0, COUNT)
})
.map(|_| ())
})
.await
.unwrap();
let mut duplicated = bulk_file_entries(index, i128::from(COUNT), COUNT);
duplicated[700].values = vec![Some(int_value(42))];
let err = catalog
.commit(|tx| {
tx.register_data_file(table, bulk_file("b.parquet", COUNT), &duplicated)
.map(|_| ())
})
.await
.unwrap_err();
assert!(matches!(err, Error::Constraint(_)), "{err}");
let hits = catalog
.index_lookup(table, index, &[int_value(42)])
.await
.unwrap();
assert_eq!(sorted_row_ids(hits), vec![42]);
assert!(
catalog
.index_lookup(table, index, &[int_value(i128::from(COUNT) + 10)])
.await
.unwrap()
.is_empty()
);
catalog
.commit(|tx| {
tx.register_data_file(table, bulk_file("c.parquet", COUNT), &{
bulk_file_entries(index, i128::from(COUNT), COUNT)
})
.map(|_| ())
})
.await
.unwrap();
for probe in [0, 1499, 1500, 2999] {
assert_eq!(
catalog
.index_lookup(table, index, &[int_value(probe)])
.await
.unwrap()
.len(),
1,
"value {probe} resolves after the fresh bulk commit"
);
}
catalog.close().await.unwrap();
}
#[tokio::test]
async fn bulk_unique_commit_frees_values_deleted_in_the_same_commit() {
use crate::catalog::{ColumnId, FileIndexRemoval, IndexDef};
const COUNT: u64 = 1500;
let (catalog, table) = catalog_with_two_column_table().await;
let index = std::cell::Cell::new(None);
let file = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
&[],
)?;
index.set(Some(id));
let registered = tx.register_data_file(table, bulk_file("a.parquet", COUNT), &{
bulk_file_entries(id, 0, COUNT)
})?;
file.set(Some(registered));
Ok(())
})
.await
.unwrap();
let index = index.get().unwrap();
let file = file.get().unwrap();
catalog
.commit(|tx| {
let removals: Vec<FileIndexRemoval> = (0..COUNT)
.map(|row_id| FileIndexRemoval {
index,
row_id,
values: vec![Some(int_value(i128::from(row_id)))],
})
.collect();
let mut killer = delete_file(file);
killer.delete_count = COUNT;
tx.register_delete_file(table, killer, &removals)?;
tx.register_data_file(table, bulk_file("b.parquet", COUNT), &{
bulk_file_entries(index, 0, COUNT)
})
.map(|_| ())
})
.await
.unwrap();
let hits = catalog
.index_lookup(table, index, &[int_value(700)])
.await
.unwrap();
assert_eq!(sorted_row_ids(hits), vec![COUNT + 700]);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn bulk_commit_over_two_unique_indexes_names_the_violated_index() {
use crate::catalog::{ColumnId, IndexDef};
const COUNT: u64 = 1500;
let (catalog, table) = catalog_with_two_column_table().await;
let by_a = std::cell::Cell::new(None);
let by_b = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let a = tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
&[],
)?;
let b = tx.create_index(
table,
&IndexDef {
name: "by_b".into(),
columns: vec![ColumnId::new(2)],
unique: true,
},
&[],
)?;
by_a.set(Some(a));
by_b.set(Some(b));
Ok(())
})
.await
.unwrap();
let by_a = by_a.get().unwrap();
let by_b = by_b.get().unwrap();
let both = |first_a: i128, first_b: i128| {
let mut entries = bulk_file_entries(by_a, first_a, COUNT);
entries.extend(bulk_file_entries(by_b, first_b, COUNT));
entries
};
catalog
.commit(|tx| {
tx.register_data_file(table, bulk_file("a.parquet", COUNT), &both(0, 100_000))
.map(|_| ())
})
.await
.unwrap();
let mut entries = both(i128::from(COUNT), 100_000 + i128::from(COUNT));
entries[usize::try_from(COUNT).unwrap() + 900].values = vec![Some(int_value(100_042))];
let err = catalog
.commit(|tx| {
tx.register_data_file(table, bulk_file("b.parquet", COUNT), &entries)
.map(|_| ())
})
.await
.unwrap_err();
let text = err.to_string();
assert!(
text.contains(&by_b.get().to_string()) && !text.contains(&by_a.get().to_string()),
"the violation names the violated index: {text}"
);
catalog.close().await.unwrap();
}
async fn catalog_with_indexed_data_file() -> (
crate::catalog::Catalog,
crate::catalog::TableId,
crate::catalog::IndexId,
crate::catalog::DataFileId,
) {
use crate::catalog::{ColumnId, DataFile, FileIndexEntry, IndexDef};
let (catalog, table) = catalog_with_two_column_table().await;
let index = std::cell::Cell::new(None);
let file = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
&[],
)?;
index.set(Some(id));
let registered = tx.register_data_file(
table,
DataFile {
path: "f.parquet".into(),
path_is_relative: true,
file_format: "parquet".into(),
record_count: 2,
file_size_bytes: 20,
footer_size: 4,
encryption_key: None,
partition_values: vec![],
column_stats: vec![],
},
&[
FileIndexEntry {
index: id,
ordinal: 0,
values: vec![Some(int_value(10))],
},
FileIndexEntry {
index: id,
ordinal: 1,
values: vec![Some(int_value(20))],
},
],
)?;
file.set(Some(registered));
Ok(())
})
.await
.unwrap();
(catalog, table, index.get().unwrap(), file.get().unwrap())
}
fn delete_file(data_file: crate::catalog::DataFileId) -> crate::catalog::DeleteFile {
crate::catalog::DeleteFile {
data_file_id: data_file,
path: "d.parquet".into(),
path_is_relative: true,
format: "parquet".into(),
delete_count: 1,
file_size_bytes: 10,
footer_size: 4,
encryption_key: None,
}
}
#[tokio::test]
async fn register_delete_file_removes_the_entries_it_names() {
use crate::catalog::FileIndexRemoval;
let (catalog, table, index, file) = catalog_with_indexed_data_file().await;
catalog
.commit(|tx| {
tx.register_delete_file(
table,
delete_file(file),
&[FileIndexRemoval {
index,
row_id: 1,
values: vec![Some(int_value(20))],
}],
)
.map(|_| ())
})
.await
.unwrap();
assert!(
catalog
.index_lookup(table, index, &[int_value(20)])
.await
.unwrap()
.is_empty(),
"the killed row's entry is gone"
);
assert_eq!(
catalog
.index_lookup(table, index, &[int_value(10)])
.await
.unwrap()
.len(),
1,
"the surviving row is still indexed"
);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn register_delete_file_must_supply_index_entries() {
let (catalog, table, _, file) = catalog_with_indexed_data_file().await;
let refused = catalog
.commit(|tx| {
tx.register_delete_file(table, delete_file(file), &[])
.map(|_| ())
})
.await;
assert!(matches!(refused, Err(Error::Constraint(_))), "{refused:?}");
catalog.close().await.unwrap();
}
#[tokio::test]
async fn register_delete_file_rejects_index_entries_without_deletes() {
use crate::catalog::FileIndexRemoval;
let (catalog, table, index, file) = catalog_with_indexed_data_file().await;
let refused = catalog
.commit(|tx| {
tx.register_delete_file(
table,
crate::catalog::DeleteFile {
delete_count: 0,
..delete_file(file)
},
&[FileIndexRemoval {
index,
row_id: 1,
values: vec![Some(int_value(20))],
}],
)
.map(|_| ())
})
.await;
assert!(matches!(refused, Err(Error::Constraint(_))), "{refused:?}");
catalog.close().await.unwrap();
}
#[tokio::test]
async fn register_delete_file_rejects_an_out_of_range_row_id() {
use crate::catalog::FileIndexRemoval;
let (catalog, table, index, file) = catalog_with_indexed_data_file().await;
let refused = catalog
.commit(|tx| {
tx.register_delete_file(
table,
delete_file(file),
&[FileIndexRemoval {
index,
row_id: 2,
values: vec![Some(int_value(30))],
}],
)
.map(|_| ())
})
.await;
assert!(matches!(refused, Err(Error::Constraint(_))), "{refused:?}");
catalog.close().await.unwrap();
}
#[tokio::test]
async fn unique_index_rejects_a_duplicate_value_across_commits() {
use crate::catalog::{ColumnId, DataFile, FileIndexEntry, IndexDef};
let (catalog, table) = catalog_with_two_column_table().await;
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
&[],
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
let index = index.get().unwrap();
let one_row_with = |value: i128| {
let file = DataFile {
path: "f.parquet".into(),
path_is_relative: true,
file_format: "parquet".into(),
record_count: 1,
file_size_bytes: 10,
footer_size: 4,
encryption_key: None,
partition_values: vec![],
column_stats: vec![],
};
(
file,
FileIndexEntry {
index,
ordinal: 0,
values: vec![Some(int_value(value))],
},
)
};
catalog
.commit(|tx| {
let (file, entry) = one_row_with(10);
tx.register_data_file(table, file, &[entry]).map(|_| ())
})
.await
.unwrap();
let dup = catalog
.commit(|tx| {
let (file, entry) = one_row_with(10);
tx.register_data_file(table, file, &[entry]).map(|_| ())
})
.await;
assert!(matches!(dup, Err(Error::Constraint(_))), "{dup:?}");
catalog.close().await.unwrap();
}
#[tokio::test]
async fn scoped_read_covers_a_registration_end_to_end() {
use std::sync::Arc;
use arrow::{
array::{Int64Array, RecordBatch},
datatypes::{DataType, Field, Schema},
};
use object_store::{ObjectStoreExt, memory::InMemory, path::Path};
use parquet::arrow::ArrowWriter;
use crate::{
catalog::{ColumnId, DataFile, IndexDef},
store::index_encoding::{IndexKeyValue, IntWidth},
};
let (catalog, table) = catalog_with_two_column_table().await;
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
&[],
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
let index = index.get().unwrap();
let data = Arc::new(InMemory::new());
let path = Path::from("t/data-1.parquet");
let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int64, false)]));
let batch =
RecordBatch::try_new(schema, vec![Arc::new(Int64Array::from(vec![10, 20, 30]))]).unwrap();
let mut buffer = Vec::new();
{
let mut writer = ArrowWriter::try_new(&mut buffer, batch.schema(), None).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
}
let file_size = buffer.len() as u64;
let footer_offset = buffer.len() - 8;
let footer_size = u64::from(u32::from_le_bytes(
buffer[footer_offset..footer_offset + 4].try_into().unwrap(),
));
data.put(&path, buffer.into()).await.unwrap();
let entries = catalog
.scoped_file_index_entries(
crate::DataStore::new(data.clone()),
&path,
file_size,
footer_size,
index,
&[0],
)
.await
.unwrap();
assert_eq!(entries.len(), 3);
catalog
.commit(|tx| {
tx.register_data_file(
table,
DataFile {
path: "t/data-1.parquet".into(),
path_is_relative: true,
file_format: "parquet".into(),
record_count: 3,
file_size_bytes: file_size,
footer_size,
encryption_key: None,
partition_values: vec![],
column_stats: vec![],
},
&entries,
)
.map(|_| ())
})
.await
.unwrap();
let value = IndexKeyValue::Int {
value: 20,
width: IntWidth::I64,
};
let hits = catalog.index_lookup(table, index, &[value]).await.unwrap();
assert_eq!(hits, vec![1]);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn ddl_on_an_indexed_column_is_guarded() {
use crate::catalog::{ColumnAlteration, ColumnId, IndexDef};
let (catalog, table) = catalog_with_two_column_table().await;
catalog
.commit(|tx| {
tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
&[entry(0, 10)],
)
.map(|_| ())
})
.await
.unwrap();
let dropped = catalog
.commit(|tx| tx.drop_column(table, ColumnId::new(1)))
.await;
assert!(matches!(dropped, Err(Error::Constraint(_))), "{dropped:?}");
let retyped = catalog
.commit(|tx| {
tx.alter_column(
table,
ColumnId::new(1),
ColumnAlteration {
column_type: Some("INTEGER".into()),
..ColumnAlteration::default()
},
)
})
.await;
assert!(matches!(retyped, Err(Error::Constraint(_))), "{retyped:?}");
catalog
.commit(|tx| tx.rename_column(table, ColumnId::new(1), "a2"))
.await
.unwrap();
catalog
.commit(|tx| {
tx.alter_column(
table,
ColumnId::new(2),
ColumnAlteration {
column_type: Some("INTEGER".into()),
..ColumnAlteration::default()
},
)
})
.await
.unwrap();
catalog.close().await.unwrap();
}
async fn scan_index_entries(
catalog: &crate::catalog::Catalog,
index: crate::catalog::IndexId,
) -> Vec<(Vec<u8>, Vec<u8>)> {
use crate::store::key::{IndexKind, index_index_prefix};
let tx = catalog.begin_write_tx().await.unwrap();
let mut entries = Vec::new();
for kind in [IndexKind::Unique, IndexKind::Multi] {
let mut iter = ReadHandle::Tx(&tx)
.scan_prefix(
index_index_prefix(kind, index.get()),
..,
crate::store::handle::ScanShape::Bulk,
)
.await
.unwrap();
while let Some(entry) = iter.next().await.unwrap() {
entries.push((entry.key.to_vec(), entry.value.to_vec()));
}
}
tx.rollback();
entries.sort();
entries
}
#[tokio::test]
async fn staged_build_gates_lookups_flips_ready_and_matches_single_commit() {
use crate::catalog::{ColumnId, IndexDef, IndexState};
let def = || IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
};
let (single, table_single) = catalog_with_two_column_table().await;
register_three_row_file(&single, table_single).await;
let single_index = std::cell::Cell::new(None);
single
.commit(|tx| {
let id = tx.create_index(
table_single,
&def(),
&[entry(0, 10), entry(1, 20), entry(2, 30)],
)?;
single_index.set(Some(id));
Ok(())
})
.await
.unwrap();
let single_index = single_index.get().unwrap();
let (staged, table_staged) = catalog_with_two_column_table().await;
register_three_row_file(&staged, table_staged).await;
let staged_index = std::cell::Cell::new(None);
staged
.commit(|tx| {
let id = tx.create_index_staged(table_staged, &def())?;
staged_index.set(Some(id));
Ok(())
})
.await
.unwrap();
let staged_index = staged_index.get().unwrap();
assert_eq!(single_index, staged_index);
assert_eq!(
read_format_version(&staged).await,
FORMAT_WITH_STAGED_INDEX.max(MIN_FORMAT_VERSION)
);
assert!(matches!(
staged
.index_lookup(table_staged, staged_index, &[int_value(20)])
.await,
Err(Error::IndexBuilding(_))
));
staged
.commit(|tx| {
tx.build_index_step(staged_index, &[entry(0, 10), entry(1, 20)], false)
.map(|_| ())
})
.await
.unwrap();
let final_state = std::cell::Cell::new(None);
staged
.commit(|tx| {
let state = tx.build_index_step(staged_index, &[entry(2, 30)], true)?;
final_state.set(Some(state));
Ok(())
})
.await
.unwrap();
assert_eq!(final_state.get().unwrap(), IndexState::Ready);
let hits = staged
.index_lookup(table_staged, staged_index, &[int_value(20)])
.await
.unwrap();
assert_eq!(hits, vec![1]);
assert_eq!(
scan_index_entries(&single, single_index).await,
scan_index_entries(&staged, staged_index).await
);
single.close().await.unwrap();
staged.close().await.unwrap();
}
#[tokio::test]
async fn staged_ordered_create_records_orders_and_matches_single_commit() {
use crate::{
catalog::{ColumnId, ColumnOrder, IndexDef},
store::index_encoding::{Direction, NullOrder},
};
let def = || IndexDef {
name: "by_a_desc".into(),
columns: vec![ColumnId::new(1)],
unique: true,
};
let orders = || {
vec![ColumnOrder {
direction: Direction::Descending,
nulls: NullOrder::First,
}]
};
let (single, table_single) = catalog_with_two_column_table().await;
register_three_row_file(&single, table_single).await;
let single_index = std::cell::Cell::new(None);
single
.commit(|tx| {
let id = tx.create_index_ordered(
table_single,
&def(),
&orders(),
&[entry(0, 10), entry(1, 20), entry(2, 30)],
)?;
single_index.set(Some(id));
Ok(())
})
.await
.unwrap();
let (staged, table_staged) = catalog_with_two_column_table().await;
register_three_row_file(&staged, table_staged).await;
let staged_index = std::cell::Cell::new(None);
staged
.commit(|tx| {
let id = tx.create_index_staged_ordered(table_staged, &def(), &orders())?;
staged_index.set(Some(id));
Ok(())
})
.await
.unwrap();
let staged_index = staged_index.get().unwrap();
let info = staged
.snapshot()
.await
.unwrap()
.indexes_of(table_staged)
.remove(0);
assert_eq!(info.directions, vec![Direction::Descending]);
assert_eq!(info.nulls, vec![NullOrder::First]);
staged
.commit(|tx| {
tx.build_index_step(staged_index, &[entry(0, 10), entry(1, 20)], false)
.map(|_| ())
})
.await
.unwrap();
staged
.commit(|tx| {
tx.build_index_step(staged_index, &[entry(2, 30)], true)
.map(|_| ())
})
.await
.unwrap();
assert_eq!(
scan_index_entries(&single, single_index.get().unwrap()).await,
scan_index_entries(&staged, staged_index).await
);
single.close().await.unwrap();
staged.close().await.unwrap();
}
#[tokio::test]
async fn a_writer_duplicating_a_value_mid_build_poisons_the_index() {
use crate::catalog::{ColumnId, DataFile, FileIndexEntry, IndexDef, IndexState};
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index_staged(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
let index = index.get().unwrap();
catalog
.commit(|tx| {
tx.build_index_step(index, &[entry(0, 10)], false)
.map(|_| ())
})
.await
.unwrap();
catalog
.commit(|tx| {
tx.register_data_file(
table,
DataFile {
path: "w.parquet".into(),
path_is_relative: true,
file_format: "parquet".into(),
record_count: 1,
file_size_bytes: 10,
footer_size: 4,
encryption_key: None,
partition_values: vec![],
column_stats: vec![],
},
&[FileIndexEntry {
index,
ordinal: 0,
values: vec![Some(int_value(10))],
}],
)
.map(|_| ())
})
.await
.expect("the writer's commit lands rather than failing on the building index");
let snapshot = catalog.snapshot().await.unwrap();
assert_eq!(
snapshot.indexes_of(table).remove(0).state,
IndexState::Poisoned,
"the duplicate poisoned the build"
);
assert_eq!(snapshot.data_files_of(table).len(), 2);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn a_writer_duplicating_a_value_on_a_ready_index_still_fails() {
use crate::catalog::FileIndexEntry;
let (catalog, table, index, _) = catalog_with_indexed_data_file().await;
let refused = catalog
.commit(|tx| {
tx.register_data_file(
table,
crate::catalog::DataFile {
path: "w.parquet".into(),
path_is_relative: true,
file_format: "parquet".into(),
record_count: 1,
file_size_bytes: 10,
footer_size: 4,
encryption_key: None,
partition_values: vec![],
column_stats: vec![],
},
&[FileIndexEntry {
index,
ordinal: 0,
values: vec![Some(int_value(20))],
}],
)
.map(|_| ())
})
.await;
assert!(matches!(refused, Err(Error::Constraint(_))), "{refused:?}");
catalog.close().await.unwrap();
}
#[tokio::test]
async fn staged_build_step_rejects_a_duplicate_and_a_ready_index() {
use crate::catalog::{ColumnId, IndexDef};
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index_staged(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
let index = index.get().unwrap();
catalog
.commit(|tx| {
tx.build_index_step(index, &[entry(0, 10), entry(1, 10)], false)
.map(|_| ())
})
.await
.expect("the step lands; the duplicate poisons the definition");
assert_eq!(
catalog
.snapshot()
.await
.unwrap()
.indexes_of(table)
.remove(0)
.state,
crate::catalog::IndexState::Poisoned
);
catalog
.commit(|tx| {
tx.build_index_step(index, &[entry(0, 10)], true)
.map(|_| ())
})
.await
.unwrap();
let after_ready = catalog
.commit(|tx| {
tx.build_index_step(index, &[entry(1, 20)], false)
.map(|_| ())
})
.await;
assert!(
matches!(after_ready, Err(Error::Constraint(_))),
"{after_ready:?}"
);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn reclaiming_a_dropped_index_deletes_its_orphaned_entries() {
use crate::{
catalog::{ColumnId, IndexDef},
store::key::{IndexKind, index_index_prefix},
};
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
&[entry(0, 10), entry(1, 20), entry(2, 30)],
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
let index = index.get().unwrap();
assert!(matches!(
catalog.reclaim_index_entries(index, 100).await,
Err(Error::Constraint(_))
));
catalog.commit(|tx| tx.drop_index(index)).await.unwrap();
let first = catalog.reclaim_index_entries(index, 2).await.unwrap();
assert_eq!(first, 2);
let second = catalog.reclaim_index_entries(index, 100).await.unwrap();
assert_eq!(second, 1);
assert_eq!(catalog.reclaim_index_entries(index, 100).await.unwrap(), 0);
let tx = catalog.begin_write_tx().await.unwrap();
let mut iter = ReadHandle::Tx(&tx)
.scan_prefix(
index_index_prefix(IndexKind::Unique, index.get()),
..,
crate::store::handle::ScanShape::Bulk,
)
.await
.unwrap();
assert!(iter.next().await.unwrap().is_none());
tx.rollback();
catalog.close().await.unwrap();
}
async fn indexed(
catalog: &crate::catalog::Catalog,
table: crate::catalog::TableId,
name: &str,
count: u64,
) -> crate::catalog::IndexId {
use crate::catalog::{ColumnId, IndexDef};
let entries: Vec<_> = (0..count).map(|i| entry(i, i128::from(i) * 10)).collect();
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index(
table,
&IndexDef {
name: name.into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
&entries,
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
index.get().unwrap()
}
async fn index_entry_count(catalog: &crate::catalog::Catalog) -> usize {
use crate::store::key::{IndexKind, index_kind_prefix};
let tx = catalog.begin_write_tx().await.unwrap();
let mut total = 0;
for kind in [IndexKind::Unique, IndexKind::Multi] {
let mut iter = ReadHandle::Tx(&tx)
.scan_prefix(
index_kind_prefix(kind),
..,
crate::store::handle::ScanShape::Bulk,
)
.await
.unwrap();
while iter.next().await.unwrap().is_some() {
total += 1;
}
}
tx.rollback();
total
}
#[tokio::test]
async fn maintain_sweeps_a_dropped_index_and_is_idempotent() {
use crate::catalog::MaintenanceRequest;
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let index = indexed(&catalog, table, "by_a", 3).await;
let untouched = catalog
.maintain(MaintenanceRequest::default())
.await
.unwrap();
assert_eq!(untouched.indexes_swept, 0);
assert_eq!(untouched.index_entries_reclaimed, 0);
assert_eq!(index_entry_count(&catalog).await, 3);
catalog.commit(|tx| tx.drop_index(index)).await.unwrap();
let swept = catalog
.maintain(MaintenanceRequest::default())
.await
.unwrap();
assert_eq!(swept.indexes_swept, 1);
assert_eq!(swept.index_entries_reclaimed, 3);
assert_eq!(index_entry_count(&catalog).await, 0);
let again = catalog
.maintain(MaintenanceRequest::default())
.await
.unwrap();
assert_eq!(again.indexes_swept, 0);
assert_eq!(again.index_entries_reclaimed, 0);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn maintain_spares_live_indexes_interleaved_by_id() {
use crate::{
catalog::MaintenanceRequest,
store::index_encoding::{IndexKeyValue, IntWidth},
};
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let first = indexed(&catalog, table, "one", 3).await;
let live = indexed(&catalog, table, "two", 3).await;
let last = indexed(&catalog, table, "three", 3).await;
assert!(first.get() < live.get() && live.get() < last.get());
catalog.commit(|tx| tx.drop_index(first)).await.unwrap();
catalog.commit(|tx| tx.drop_index(last)).await.unwrap();
let report = catalog
.maintain(MaintenanceRequest::default())
.await
.unwrap();
assert_eq!(report.indexes_swept, 2);
assert_eq!(report.index_entries_reclaimed, 6);
assert_eq!(index_entry_count(&catalog).await, 3);
for row_id in 0..3u64 {
let found = catalog
.index_lookup(
table,
live,
&[IndexKeyValue::Int {
value: i128::from(row_id) * 10,
width: IntWidth::I64,
}],
)
.await
.unwrap();
assert_eq!(found, vec![row_id]);
}
catalog.close().await.unwrap();
}
#[tokio::test]
async fn maintain_reclaims_both_indexes_of_a_dropped_table() {
use crate::catalog::MaintenanceRequest;
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
indexed(&catalog, table, "by_a", 3).await;
indexed(&catalog, table, "also_a", 2).await;
assert_eq!(index_entry_count(&catalog).await, 5);
catalog.commit(|tx| tx.drop_table(table)).await.unwrap();
let report = catalog
.maintain(MaintenanceRequest::default())
.await
.unwrap();
assert_eq!(report.indexes_swept, 2);
assert_eq!(report.index_entries_reclaimed, 5);
assert_eq!(index_entry_count(&catalog).await, 0);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn maintain_batches_without_advancing_head() {
use crate::catalog::MaintenanceRequest;
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let index = indexed(&catalog, table, "by_a", 10).await;
catalog.commit(|tx| tx.drop_index(index)).await.unwrap();
let before = catalog.snapshot().await.unwrap().current_snapshot().id;
let report = catalog
.maintain(MaintenanceRequest {
batch_size: 3,
..MaintenanceRequest::default()
})
.await
.unwrap();
assert_eq!(report.indexes_swept, 1);
assert_eq!(report.index_entries_reclaimed, 10);
assert_eq!(index_entry_count(&catalog).await, 0);
let after = catalog.snapshot().await.unwrap().current_snapshot().id;
assert_eq!(before, after, "a maintenance pass must not advance head");
catalog.close().await.unwrap();
}
#[tokio::test]
async fn maintain_resumes_each_batch_where_the_last_one_stopped() {
use crate::catalog::MaintenanceRequest;
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let index = indexed(&catalog, table, "by_a", 25).await;
catalog.commit(|tx| tx.drop_index(index)).await.unwrap();
let report = catalog
.maintain(MaintenanceRequest {
batch_size: 1,
..MaintenanceRequest::default()
})
.await
.unwrap();
assert_eq!(report.indexes_swept, 1);
assert_eq!(
report.index_entries_reclaimed, 25,
"every entry must be reclaimed exactly once across 25 batches"
);
assert_eq!(index_entry_count(&catalog).await, 0);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn maintain_refuses_a_zero_batch_size() {
use crate::catalog::MaintenanceRequest;
let (catalog, _) = catalog_with_two_column_table().await;
assert!(matches!(
catalog
.maintain(MaintenanceRequest {
batch_size: 0,
..MaintenanceRequest::default()
})
.await,
Err(Error::Configuration(_))
));
catalog.close().await.unwrap();
}
#[tokio::test]
async fn maintain_refuses_a_read_only_catalog() {
use crate::catalog::{Catalog, CatalogOptions};
let object_store: Arc<InMemory> = Arc::new(InMemory::new());
let writer = Catalog::open(object_store.clone(), CatalogOptions::default())
.await
.unwrap();
writer.close().await.unwrap();
let reader = Catalog::open_read_only(object_store, CatalogOptions::default())
.await
.unwrap();
assert_eq!(
reader.snapshot().await.unwrap().current_snapshot().id.get(),
0
);
reader.close().await.unwrap();
}
#[tokio::test]
async fn discovery_seeks_to_the_next_index_holding_entries() {
use crate::store::key::IndexKind;
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let first = indexed(&catalog, table, "one", 3).await;
let second = indexed(&catalog, table, "two", 3).await;
let (low, high) = (first.get(), second.get());
assert!(low < high);
assert_eq!(
catalog
.first_index_id_from(IndexKind::Unique, 0)
.await
.unwrap(),
Some(low)
);
assert_eq!(
catalog
.first_index_id_from(IndexKind::Unique, low)
.await
.unwrap(),
Some(low)
);
assert_eq!(
catalog
.first_index_id_from(IndexKind::Unique, low + 1)
.await
.unwrap(),
Some(high)
);
assert_eq!(
catalog
.first_index_id_from(IndexKind::Unique, high + 1)
.await
.unwrap(),
None
);
assert_eq!(
catalog
.first_index_id_from(IndexKind::Multi, 0)
.await
.unwrap(),
None
);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn maintain_does_not_conflict_with_a_live_writer() {
use crate::{
catalog::{DataFile, FileIndexEntry, MaintenanceRequest},
store::index_encoding::{IndexKeyValue, IntWidth},
};
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let dead = indexed(&catalog, table, "dead", 6).await;
let live = indexed(&catalog, table, "live", 3).await;
catalog.commit(|tx| tx.drop_index(dead)).await.unwrap();
let mut reclaimed = 0;
for round in 0..3u64 {
let report = catalog
.maintain(MaintenanceRequest {
batch_size: 2,
..MaintenanceRequest::default()
})
.await
.unwrap();
reclaimed += report.index_entries_reclaimed;
let path = format!("live-{round}.parquet");
catalog
.commit(move |tx| {
tx.register_data_file(
table,
DataFile {
path: path.clone(),
path_is_relative: true,
file_format: "parquet".into(),
record_count: 1,
file_size_bytes: 10,
footer_size: 4,
encryption_key: None,
partition_values: vec![],
column_stats: vec![],
},
&[FileIndexEntry {
index: live,
ordinal: 0,
values: vec![Some(IndexKeyValue::Int {
value: i128::from(1_000 + round),
width: IntWidth::I64,
})],
}],
)?;
Ok(())
})
.await
.expect("a commit into a live index must not conflict with the sweep");
}
assert_eq!(reclaimed, 6, "the dead range is fully reclaimed");
assert_eq!(index_entry_count(&catalog).await, 6);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn maintain_skips_the_sweep_when_disabled() {
use crate::catalog::MaintenanceRequest;
let (catalog, table) = catalog_with_two_column_table().await;
register_three_row_file(&catalog, table).await;
let index = indexed(&catalog, table, "by_a", 3).await;
catalog.commit(|tx| tx.drop_index(index)).await.unwrap();
let report = catalog
.maintain(MaintenanceRequest {
sweep_orphaned_index_entries: false,
..MaintenanceRequest::default()
})
.await
.unwrap();
assert_eq!(report.index_entries_reclaimed, 0);
assert_eq!(index_entry_count(&catalog).await, 3);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn drop_index_ends_definition_and_keeps_format() {
use crate::catalog::{ColumnId, IndexDef};
let (catalog, table) = catalog_with_two_column_table().await;
let index = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let id = tx.create_index(
table,
&IndexDef {
name: "by_a".into(),
columns: vec![ColumnId::new(1)],
unique: true,
},
&[entry(0, 10)],
)?;
index.set(Some(id));
Ok(())
})
.await
.unwrap();
catalog
.commit(|tx| tx.drop_index(index.get().unwrap()))
.await
.unwrap();
assert!(
catalog
.snapshot()
.await
.unwrap()
.indexes_of(table)
.is_empty()
);
assert_eq!(
read_format_version(&catalog).await,
FORMAT_WITH_INDEX.max(MIN_FORMAT_VERSION)
);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn folded_head_view_matches_a_fresh_scan() {
use crate::catalog::{Catalog, CatalogOptions, ColumnDef, DataFile, OptionScope};
let catalog = Catalog::open(Arc::new(InMemory::new()), CatalogOptions::default())
.await
.unwrap();
let column = |name: &str| ColumnDef {
name: name.into(),
column_type: "BIGINT".into(),
nulls_allowed: true,
default_value: None,
children: Vec::new(),
};
let keep = std::cell::Cell::new(None);
let doomed = std::cell::Cell::new(None);
catalog
.commit(|tx| {
let schema = tx.create_schema("s")?;
let k = tx.create_table(schema, "keep", &[column("a"), column("b")])?;
let d = tx.create_table(schema, "doomed", &[column("x")])?;
tx.create_view(schema, "v", "duckdb", "SELECT 1")?;
keep.set(Some(k));
doomed.set(Some(d));
Ok(())
})
.await
.unwrap();
let keep = keep.get().unwrap();
let doomed = doomed.get().unwrap();
catalog
.commit(|tx| {
tx.register_data_file(
keep,
DataFile {
path: "f.parquet".into(),
path_is_relative: true,
file_format: "parquet".into(),
record_count: 3,
file_size_bytes: 100,
footer_size: 8,
encryption_key: None,
partition_values: vec![],
column_stats: vec![],
},
&[],
)?;
tx.set_option(OptionScope::Global, "answer", "42")?;
Ok(())
})
.await
.unwrap();
catalog
.commit(|tx| {
tx.rename_table(keep, "keep2")?;
let second = tx.columns_of(keep)[1].id;
tx.drop_column(keep, second)
})
.await
.unwrap();
catalog.commit(|tx| tx.drop_table(doomed)).await.unwrap();
let tx = catalog.begin_write_tx().await.unwrap();
let stamp = read::read_head(ReadHandle::Tx(&tx)).await.unwrap().unwrap();
let head = stamp.snapshot_id;
let fresh = materialize(ReadHandle::Tx(&tx), None).await.unwrap();
tx.rollback();
let cached = catalog
.projections()
.read()
.unwrap()
.head_view(&stamp)
.expect("the writer caches its head view");
assert_eq!(cached.snapshot.snapshot_id, fresh.snapshot.snapshot_id);
assert!(
diff_writes(&fresh, &cached, head + 1).is_empty(),
"folded head view diverged from a fresh scan"
);
assert!(
diff_writes(&cached, &fresh, head + 1).is_empty(),
"folded head view diverged from a fresh scan"
);
catalog.close().await.unwrap();
}
#[allow(clippy::unwrap_used)]
async fn seeded_catalog(tables: usize) -> (crate::catalog::Catalog, Vec<crate::catalog::TableId>) {
use crate::catalog::{Catalog, CatalogOptions, ColumnDef};
let catalog = Catalog::open(Arc::new(InMemory::new()), CatalogOptions::default())
.await
.unwrap();
let column = |name: &str| ColumnDef {
name: name.into(),
column_type: "BIGINT".into(),
nulls_allowed: true,
default_value: None,
children: Vec::new(),
};
let ids = std::cell::RefCell::new(Vec::new());
catalog
.commit(|tx| {
let schema = tx.create_schema("s")?;
for index in 0..tables {
let id =
tx.create_table(schema, &format!("t{index}"), &[column("a"), column("b")])?;
ids.borrow_mut().push(id);
}
Ok(())
})
.await
.unwrap();
let ids = ids.into_inner();
(catalog, ids)
}
#[tokio::test]
async fn the_migration_marker_refuses_reads_even_with_a_warm_cache() {
use crate::catalog::{Catalog, CatalogOptions};
let store: Arc<InMemory> = Arc::new(InMemory::new());
let writer = Catalog::open(store.clone(), CatalogOptions::default())
.await
.unwrap();
writer
.commit(|tx| tx.create_schema("before").map(|_| ()))
.await
.unwrap();
let options = CatalogOptions {
reader_poll_interval: std::time::Duration::from_millis(20),
..CatalogOptions::default()
};
let reader = Catalog::open_read_only(store.clone(), options)
.await
.unwrap();
reader.snapshot().await.unwrap();
plant_migration_marker(&writer).await;
let mut refused = None;
for _ in 0..100 {
if let Err(err) = reader.snapshot().await {
refused = Some(err);
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
let refused = refused.expect("a warm reader served every read through a planted marker");
assert!(matches!(refused, Error::Migration(_)), "{refused:?}");
writer.close().await.unwrap();
}
#[tokio::test]
async fn the_migration_marker_refuses_a_commit_with_a_warm_cache() {
let (catalog, _) = seeded_catalog(3).await;
catalog.snapshot().await.unwrap();
plant_migration_marker(&catalog).await;
let err = catalog
.commit(|tx| {
tx.create_schema("late")?;
Ok(())
})
.await
.err()
.unwrap();
assert!(matches!(err, Error::Migration(_)), "{err:?}");
catalog.close().await.unwrap();
}
#[tokio::test]
async fn a_format_target_under_the_observed_floor_costs_no_read() {
let (catalog, _) = seeded_catalog(1).await;
let projections = catalog.projections();
raise_format_floor(projections, MAX_FORMAT_VERSION + 1);
let db_tx = catalog.begin_write_tx().await.unwrap();
let stamp = format_stamp_to(&db_tx, projections, MAX_FORMAT_VERSION + 1)
.await
.unwrap();
assert!(stamp.is_none(), "the floor already covers the target");
db_tx.rollback();
catalog.close().await.unwrap();
}
#[tokio::test]
async fn a_format_target_above_the_floor_reads_and_raises_it() {
let (catalog, _) = seeded_catalog(1).await;
let projections = catalog.projections();
let db_tx = catalog.begin_write_tx().await.unwrap();
let stamp = format_stamp_to(&db_tx, projections, MIN_FORMAT_VERSION + 1)
.await
.unwrap();
assert!(stamp.is_some(), "a higher target requires a stamp");
assert_eq!(format_floor(projections), MIN_FORMAT_VERSION);
db_tx.rollback();
catalog.close().await.unwrap();
}
#[tokio::test]
async fn the_migration_check_is_skipped_at_a_head_already_found_clear() {
let (catalog, _) = seeded_catalog(1).await;
let projections = catalog.projections();
let db_tx = catalog.begin_write_tx().await.unwrap();
let head = read_head_value(ReadHandle::Tx(&db_tx)).await.unwrap();
refuse_mid_migration_at(ReadHandle::Tx(&db_tx), projections, &head)
.await
.unwrap();
assert!(migration_clear_at(projections, &head));
db_tx.rollback();
let tx = catalog.begin_write_tx().await.unwrap();
tx.put(
Key::Sys(SysKey::Migration).encode(),
value::encode_value(&proto::MigrationValue {
from_format: 1,
to_format: 2,
cursor: Vec::new(),
}),
)
.unwrap();
catalog.commit_write_tx(tx).await.unwrap();
let db_tx = catalog.begin_write_tx().await.unwrap();
let served = refuse_mid_migration_at(ReadHandle::Tx(&db_tx), projections, &head).await;
assert!(served.is_ok(), "the stamp answered without reading");
let moved = proto::HeadValue {
snapshot_id: head.snapshot_id,
batch_seq: head.batch_seq + 1,
};
let err = refuse_mid_migration_at(ReadHandle::Tx(&db_tx), projections, &moved)
.await
.err()
.unwrap();
assert!(matches!(err, Error::Migration(_)), "{err:?}");
db_tx.rollback();
catalog.close().await.unwrap();
}
#[tokio::test]
async fn a_read_session_skips_the_migration_check_at_a_stamped_head() {
let (catalog, _) = seeded_catalog(1).await;
let session = catalog.begin_read().await.unwrap();
session.finish();
let tx = catalog.begin_write_tx().await.unwrap();
tx.put(
Key::Sys(SysKey::Migration).encode(),
value::encode_value(&proto::MigrationValue {
from_format: 1,
to_format: 2,
cursor: Vec::new(),
}),
)
.unwrap();
catalog.commit_write_tx(tx).await.unwrap();
let session = catalog.begin_read().await.unwrap();
session.finish();
plant_migration_marker(&catalog).await;
let err = catalog.begin_read().await.err().unwrap();
assert!(matches!(err, Error::Migration(_)), "{err:?}");
catalog.close().await.unwrap();
}
#[tokio::test]
async fn a_migration_start_moves_the_head() {
let (catalog, _) = seeded_catalog(2).await;
let db_tx = catalog.begin_write_tx().await.unwrap();
let before = read_head_value(ReadHandle::Tx(&db_tx)).await.unwrap();
db_tx.rollback();
plant_migration_marker(&catalog).await;
let db_tx = catalog.begin_write_tx().await.unwrap();
let after = read_head_value(ReadHandle::Tx(&db_tx)).await.unwrap();
db_tx.rollback();
assert_ne!(before, after, "the marker must not land at a still head");
assert_eq!(after.batch_seq, before.batch_seq + 1);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn a_landed_commit_stamps_its_own_head_clear() {
let (catalog, _) = seeded_catalog(1).await;
let projections = catalog.projections();
catalog
.commit(|tx| tx.create_schema("after").map(|_| ()))
.await
.unwrap();
let db_tx = catalog.begin_write_tx().await.unwrap();
let head = read_head_value(ReadHandle::Tx(&db_tx)).await.unwrap();
db_tx.rollback();
assert!(
migration_clear_at(projections, &head),
"the commit's own head should need no re-read"
);
catalog.close().await.unwrap();
}
#[test]
fn a_bulk_batch_still_records_a_changelog() {
let writes: Vec<StagedWrite> = (0..1_000)
.map(|id| {
(
Key::Current(crate::store::key::CurrentKey::Entity(EntityKey::Table {
table_id: id,
}))
.encode(),
Some(Vec::new()),
)
})
.collect();
let (keys, complete) = refresh_keys_of(&writes);
assert!(complete, "a thousand-key batch must stay replayable");
assert_eq!(keys.len(), 1_000);
}
#[test]
fn retry_backoff_starts_at_zero_grows_and_caps() {
use super::{RETRY_BACKOFF_BASE_MICROS, RETRY_BACKOFF_MAX_MICROS, retry_backoff};
assert_eq!(retry_backoff(0), std::time::Duration::ZERO);
let ceiling = u128::from(RETRY_BACKOFF_MAX_MICROS + RETRY_BACKOFF_BASE_MICROS);
let mut previous = 0_u128;
for attempt in 1..MAX_COMMIT_ATTEMPTS {
let waited = retry_backoff(attempt).as_micros();
assert!(
waited >= u128::from(RETRY_BACKOFF_BASE_MICROS),
"attempt {attempt} waited {waited}µs, below the base delay"
);
assert!(
waited <= ceiling,
"attempt {attempt} waited {waited}µs, above the cap plus jitter"
);
if previous > 0 && previous < u128::from(RETRY_BACKOFF_MAX_MICROS) {
assert!(
waited > previous,
"attempt {attempt} waited {waited}µs, not more than {previous}µs"
);
}
previous = waited;
}
assert!(
retry_backoff(MAX_COMMIT_ATTEMPTS - 1).as_micros() >= u128::from(RETRY_BACKOFF_MAX_MICROS)
);
}
#[test]
fn retry_backoff_budget_stays_in_a_sane_band() {
use super::retry_backoff;
let total: std::time::Duration = (0..MAX_COMMIT_ATTEMPTS).map(retry_backoff).sum();
assert!(
total >= std::time::Duration::from_millis(100),
"whole retry budget waits only {total:?}"
);
assert!(
total <= std::time::Duration::from_millis(600),
"whole retry budget waits {total:?}"
);
}
#[tokio::test]
async fn every_batch_moves_the_head_stamp() {
use crate::catalog::OptionScope;
let (catalog, _) = seeded_catalog(2).await;
let stamp = |catalog: &crate::catalog::Catalog| {
let catalog = catalog.clone();
async move {
let tx = catalog.begin_write_tx().await.unwrap();
let head = read_head_value(ReadHandle::Tx(&tx)).await.unwrap();
tx.rollback();
head
}
};
let seeded = stamp(&catalog).await;
catalog
.commit(|tx| tx.create_schema("more").map(|_| ()))
.await
.unwrap();
let minted = stamp(&catalog).await;
assert_eq!(minted.snapshot_id, seeded.snapshot_id + 1);
assert_eq!(minted.batch_seq, seeded.batch_seq + 1);
catalog
.commit(|tx| tx.set_option(OptionScope::Global, "answer", "42"))
.await
.unwrap();
let maintained = stamp(&catalog).await;
assert_eq!(maintained.snapshot_id, minted.snapshot_id);
assert_eq!(maintained.batch_seq, minted.batch_seq + 1);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn an_incremental_refresh_matches_a_full_rematerialization() {
let (catalog, tables) = seeded_catalog(12).await;
let tx = catalog.begin_write_tx().await.unwrap();
let base = materialize(ReadHandle::Tx(&tx), None).await.unwrap();
tx.rollback();
catalog
.commit(|tx| {
tx.register_data_file(tables[0], bulk_file("f0.parquet", 3), &[])
.map(|_| ())
})
.await
.unwrap();
catalog
.commit(|tx| tx.rename_table(tables[1], "renamed"))
.await
.unwrap();
catalog
.commit(|tx| {
let schema = tx.schema_by_name("s").unwrap().id;
tx.create_table(
schema,
"late",
&[crate::catalog::ColumnDef {
name: "x".into(),
column_type: "BIGINT".into(),
nulls_allowed: true,
default_value: None,
children: Vec::new(),
}],
)
.map(|_| ())
})
.await
.unwrap();
catalog
.commit(|tx| {
let second = tx.columns_of(tables[3])[1].id;
tx.drop_column(tables[3], second)
})
.await
.unwrap();
catalog.commit(|tx| tx.drop_table(tables[2])).await.unwrap();
let tx = catalog.begin_write_tx().await.unwrap();
let handle = ReadHandle::Tx(&tx);
let head = read_head_value(handle).await.unwrap();
let refreshed = refresh(handle, &base)
.await
.unwrap()
.expect("a five-commit gap over a twelve-table catalog must replay");
let full = materialize(handle, None).await.unwrap();
tx.rollback();
assert_eq!(refreshed.snapshot, full.snapshot);
assert_eq!(refreshed.batch_seq, full.batch_seq);
let next = head.snapshot_id + 1;
assert!(
diff_writes(&full, &refreshed, next).is_empty(),
"a replayed view diverged from a fresh scan"
);
assert!(
diff_writes(&refreshed, &full, next).is_empty(),
"a replayed view diverged from a fresh scan"
);
let schema = refreshed.schema_by_name("s").unwrap().id;
assert!(refreshed.table_by_name(schema, "renamed").is_some());
assert!(refreshed.table_by_name(schema, "t1").is_none());
assert!(refreshed.table_by_name(schema, "late").is_some());
assert!(refreshed.table_by_name(schema, "t2").is_none());
catalog.close().await.unwrap();
}
#[tokio::test]
async fn a_refresh_declines_a_gap_a_maintenance_batch_landed_in() {
use crate::catalog::OptionScope;
let (catalog, tables) = seeded_catalog(8).await;
let tx = catalog.begin_write_tx().await.unwrap();
let base = materialize(ReadHandle::Tx(&tx), None).await.unwrap();
tx.rollback();
catalog
.commit(|tx| tx.set_option(OptionScope::Global, "answer", "42"))
.await
.unwrap();
catalog
.commit(|tx| {
tx.register_data_file(tables[0], bulk_file("f0.parquet", 3), &[])
.map(|_| ())
})
.await
.unwrap();
let tx = catalog.begin_write_tx().await.unwrap();
let handle = ReadHandle::Tx(&tx);
let refreshed = refresh(handle, &base).await.unwrap();
tx.rollback();
assert!(
refreshed.is_none(),
"a gap holding a batch that recorded no changelog must not be replayed"
);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn a_refresh_declines_a_gap_swept_out_of_the_changelog_window() {
let (catalog, tables) = seeded_wide_catalog(4, 2).await;
let tx = catalog.begin_write_tx().await.unwrap();
let base = materialize(ReadHandle::Tx(&tx), None).await.unwrap();
tx.rollback();
let commits = usize::try_from(CHANGELOG_WINDOW).unwrap() + 2;
for round in 0..commits {
catalog
.commit(|tx| {
tx.register_data_file(tables[0], bulk_file(&format!("f{round}.parquet"), 3), &[])
.map(|_| ())
})
.await
.unwrap();
}
let tx = catalog.begin_write_tx().await.unwrap();
let handle = ReadHandle::Tx(&tx);
let head = read_head_value(handle).await.unwrap();
let refreshed = replay(handle, &base, &head, usize::MAX).await.unwrap();
let mut retained = 0u64;
for snapshot_id in 0..=head.snapshot_id {
if read::read_changelog(handle, snapshot_id)
.await
.unwrap()
.is_some()
{
retained += 1;
}
}
tx.rollback();
assert!(
refreshed.is_none(),
"a gap whose changelogs were swept must not be replayed"
);
assert!(
retained <= CHANGELOG_WINDOW,
"{retained} changelogs retained, above the window of {CHANGELOG_WINDOW}"
);
catalog.close().await.unwrap();
}
#[tokio::test]
async fn a_refresh_declines_churn_that_outruns_the_catalog() {
let (catalog, tables) = seeded_catalog(1).await;
let tx = catalog.begin_write_tx().await.unwrap();
let base = materialize(ReadHandle::Tx(&tx), None).await.unwrap();
tx.rollback();
for index in 0..6u64 {
catalog
.commit(|tx| {
tx.register_data_file(tables[0], bulk_file(&format!("f{index}.parquet"), 3), &[])
.map(|_| ())
})
.await
.unwrap();
}
let tx = catalog.begin_write_tx().await.unwrap();
let handle = ReadHandle::Tx(&tx);
let refreshed = refresh(handle, &base).await.unwrap();
tx.rollback();
assert!(
base.live_entity_count() / REFRESH_CHURN_SHARE < 6,
"the fixture must churn past the backstop for this to test it"
);
assert!(
refreshed.is_none(),
"churn past the backstop must fall back to a full rescan"
);
catalog.close().await.unwrap();
}
#[tokio::test]
#[ignore = "measurement, not a test: run with --ignored --nocapture"]
#[allow(clippy::cast_precision_loss)]
async fn measure_refresh_versus_rematerialization() {
const COLUMNS: usize = 8;
const FILES_PER_COMMIT: usize = 4;
const REPEATS: usize = 9;
println!("\n# 0009 changelog replay vs. rematerialization (in-memory object_store)");
println!(
"# {COLUMNS} columns per table, {FILES_PER_COMMIT} files per gap commit, \
median of {REPEATS}"
);
println!("# gaps stop at the {CHANGELOG_WINDOW}-commit changelog window\n");
println!(
"{:>7} {:>8} {:>7} {:>7} {:>7} {:>10} {:>10} {:>7}",
"tables", "entities", "commits", "churn", "share", "full_ms", "replay_ms", "ratio"
);
for tables in [50usize, 200, 800] {
for commits in [1usize, 4, 16, 32, 64] {
let (catalog, table_ids) = seeded_wide_catalog(tables, COLUMNS).await;
let tx = catalog.begin_write_tx().await.unwrap();
let base = materialize(ReadHandle::Tx(&tx), None).await.unwrap();
tx.rollback();
let entities = base.live_entity_count();
for round in 0..commits {
let target = table_ids[round % table_ids.len()];
catalog
.commit(|tx| {
for file in 0..FILES_PER_COMMIT {
tx.register_data_file(
target,
bulk_file(&format!("g{round}-{file}.parquet"), 10),
&[],
)?;
}
Ok(())
})
.await
.unwrap();
}
let tx = catalog.begin_write_tx().await.unwrap();
let handle = ReadHandle::Tx(&tx);
let head = read_head_value(handle).await.unwrap();
let mut full = Vec::with_capacity(REPEATS);
let mut replayed = Vec::with_capacity(REPEATS);
for _ in 0..REPEATS {
let started = std::time::Instant::now();
let view = materialize(handle, None).await.unwrap();
full.push(started.elapsed());
std::hint::black_box(&view);
let started = std::time::Instant::now();
let view = replay(handle, &base, &head, usize::MAX)
.await
.unwrap()
.expect("an unbounded replay must reach head");
replayed.push(started.elapsed());
std::hint::black_box(&view);
}
tx.rollback();
let churn = churn_above(&catalog, base.snapshot.snapshot_id, head.snapshot_id).await;
let median = |mut samples: Vec<std::time::Duration>| {
samples.sort_unstable();
samples[samples.len() / 2].as_secs_f64() * 1_000.0
};
let (full_ms, replay_ms) = (median(full), median(replayed));
println!(
"{tables:>7} {entities:>8} {commits:>7} {churn:>7} {:>7.3} {full_ms:>10.3} \
{replay_ms:>10.3} {:>7.2}",
churn as f64 / entities as f64,
full_ms / replay_ms
);
catalog.close().await.unwrap();
}
}
println!();
}
#[tokio::test]
#[ignore = "measurement, not a test: run with --ignored --nocapture"]
#[allow(clippy::cast_precision_loss)]
async fn measure_changelog_read_cost() {
const COLUMNS: usize = 8;
const REPEATS: usize = 9;
println!("\n# 0009 changelog cost on the snapshot read path (in-memory object_store)");
println!(
"# one data file per commit over a {COLUMNS}-column table, stats for every \
column, median of {REPEATS}\n"
);
println!(
"{:>8} {:>13} {:>10} {:>14} {:>10} {:>13}",
"commits", "snapshot_bytes", "log_records", "log_bytes", "log_share", "snap_scan_ms"
);
for commits in [64usize, 256, 1024] {
let (catalog, tables) = seeded_wide_catalog(1, COLUMNS).await;
for round in 0..commits {
catalog
.commit(|tx| {
let stats = tx
.columns_of(tables[0])
.iter()
.map(|column| crate::catalog::FileColumnStats {
column_id: column.id,
column_size_bytes: 128,
value_count: 10,
null_count: 0,
min_value: Some("0".into()),
max_value: Some("9".into()),
contains_nan: None,
extra_stats: None,
})
.collect();
let file = crate::catalog::DataFile {
column_stats: stats,
..bulk_file(&format!("f{round}.parquet"), 10)
};
tx.register_data_file(tables[0], file, &[]).map(|_| ())
})
.await
.unwrap();
}
let tx = catalog.begin_write_tx().await.unwrap();
let handle = ReadHandle::Tx(&tx);
let snapshots = read::scan_snapshots(handle).await.unwrap();
let snapshot_bytes: usize = snapshots.iter().map(|r| value::encode_value(r).len()).sum();
let mut log_records = 0usize;
let mut log_bytes = 0usize;
let head = read_head_value(handle).await.unwrap().snapshot_id;
for snapshot_id in 0..=head {
if let Some(log) = read::read_changelog(handle, snapshot_id).await.unwrap() {
log_records += 1;
log_bytes += value::encode_value(&log).len();
}
}
let mut samples = Vec::with_capacity(REPEATS);
for _ in 0..REPEATS {
let started = std::time::Instant::now();
let rows = read::scan_snapshots(handle).await.unwrap();
samples.push(started.elapsed());
std::hint::black_box(&rows);
}
tx.rollback();
samples.sort_unstable();
let snap_scan_ms = samples[samples.len() / 2].as_secs_f64() * 1_000.0;
println!(
"{commits:>8} {snapshot_bytes:>13} {log_records:>10} {log_bytes:>14} \
{:>10.2} {snap_scan_ms:>13.3}",
log_bytes as f64 / snapshot_bytes as f64,
);
catalog.close().await.unwrap();
}
println!();
}
#[allow(clippy::unwrap_used)]
async fn seeded_wide_catalog(
tables: usize,
columns: usize,
) -> (crate::catalog::Catalog, Vec<crate::catalog::TableId>) {
use crate::catalog::{Catalog, CatalogOptions, ColumnDef};
let options = CatalogOptions {
flush_interval: std::time::Duration::from_millis(1),
..CatalogOptions::default()
};
let catalog = Catalog::open(Arc::new(InMemory::new()), options)
.await
.unwrap();
let defs: Vec<ColumnDef> = (0..columns)
.map(|index| ColumnDef {
name: format!("c{index}"),
column_type: "BIGINT".into(),
nulls_allowed: true,
default_value: None,
children: Vec::new(),
})
.collect();
let ids = std::cell::RefCell::new(Vec::new());
catalog
.commit(|tx| {
let schema = tx.create_schema("s")?;
for index in 0..tables {
ids.borrow_mut()
.push(tx.create_table(schema, &format!("t{index}"), &defs)?);
}
Ok(())
})
.await
.unwrap();
let ids = ids.into_inner();
(catalog, ids)
}
#[allow(clippy::unwrap_used)]
async fn churn_above(catalog: &crate::catalog::Catalog, from: u64, head: u64) -> usize {
let tx = catalog.begin_write_tx().await.unwrap();
let handle = ReadHandle::Tx(&tx);
let mut keys = BTreeSet::new();
for snapshot_id in (from + 1)..=head {
if let Some(log) = read::read_changelog(handle, snapshot_id).await.unwrap() {
keys.extend(log.keys);
}
}
tx.rollback();
keys.len()
}
#[tokio::test]
async fn a_commit_landing_after_an_attempts_materialization_is_always_detected() {
let (catalog, tables) = seeded_wide_catalog(3, 2).await;
let attempt = catalog.begin_write_tx().await.unwrap();
let base = head_view_for(&attempt, catalog.projections())
.await
.unwrap();
let head_before = base.snapshot.snapshot_id;
catalog
.commit(|tx| {
tx.register_data_file(tables[0], bulk_file("winner.parquet", 3), &[])
.map(|_| ())
})
.await
.unwrap();
let reread = head_view_for(&attempt, catalog.projections())
.await
.unwrap();
assert_eq!(
reread.snapshot.snapshot_id, head_before,
"an open transaction must not observe a commit that landed after it began"
);
assert!(
reread
.data_files_of(crate::catalog::TableId::new(tables[0].get()))
.is_empty(),
"the attempt's premises must not include the winner's file"
);
let mut staged = diff_writes(&base, &base, head_before + 1);
staged.push(head_stamp(head_before + 1, base.batch_seq));
let staged_bytes = stage_writes(&attempt, &staged).unwrap();
let landed = commit_batch(
attempt,
HeadTransition {
before: head_before,
after: head_before + 1,
},
&staged,
staged_bytes,
HeadViewUpdate::Rebuild(Arc::clone(&base)),
catalog.projections(),
&CommitDurability::OnFlushInterval,
)
.await
.unwrap();
assert_eq!(
landed,
Landed::LostRace,
"an attempt whose premises predate a landed commit must not commit"
);
let retry = catalog.begin_write_tx().await.unwrap();
let fresh = head_view_for(&retry, catalog.projections()).await.unwrap();
assert_eq!(fresh.snapshot.snapshot_id, head_before + 1);
assert_eq!(
fresh
.data_files_of(crate::catalog::TableId::new(tables[0].get()))
.len(),
1
);
retry.rollback();
catalog.close().await.unwrap();
}
#[tokio::test]
async fn a_commit_landing_mid_materialization_is_invisible_to_the_whole_pass() {
let (catalog, tables) = seeded_wide_catalog(3, 2).await;
let pass = catalog.begin_write_tx().await.unwrap();
let handle = ReadHandle::Tx(&pass);
let head = read_head_value(handle).await.unwrap();
catalog
.commit(|tx| {
let schema = tx.schema_by_name("s").unwrap().id;
tx.create_table(
schema,
"mid",
&[crate::catalog::ColumnDef {
name: "a".into(),
column_type: "BIGINT".into(),
nulls_allowed: true,
default_value: None,
children: Vec::new(),
}],
)?;
tx.register_data_file(tables[0], bulk_file("mid.parquet", 3), &[])
.map(|_| ())
})
.await
.unwrap();
let current = read::scan_current_entities(handle).await.unwrap();
let after = read_head_value(handle).await.unwrap();
let view = materialize(handle, None).await.unwrap();
pass.rollback();
assert_eq!(
after, head,
"the pass's second head read must agree with its first"
);
let schema = view.schema_by_name("s").unwrap().id;
assert!(
view.table_by_name(schema, "mid").is_none(),
"the pass must be entirely pre-commit, not a mix"
);
assert!(
view.data_files_of(crate::catalog::TableId::new(tables[0].get()))
.is_empty(),
"the pass must be entirely pre-commit, not a mix"
);
assert_eq!(
current
.iter()
.filter(|record| matches!(record, crate::store::read::EntityRecord::File(_)))
.count(),
0,
"the scan half of the pass must be pre-commit too"
);
let next = catalog.begin_write_tx().await.unwrap();
let fresh = materialize(ReadHandle::Tx(&next), None).await.unwrap();
next.rollback();
assert!(fresh.table_by_name(schema, "mid").is_some());
assert_eq!(
fresh
.data_files_of(crate::catalog::TableId::new(tables[0].get()))
.len(),
1
);
catalog.close().await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn a_read_only_pass_that_straddles_a_commit_is_discarded_and_re_run() {
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::{
catalog::{Catalog, CatalogOptions},
store::{handle::ReadHandle as Handle, open::StoreBuilder},
};
let object_store: Arc<InMemory> = Arc::new(InMemory::new());
let writer = Catalog::open(
object_store.clone(),
CatalogOptions {
flush_interval: std::time::Duration::from_millis(1),
..CatalogOptions::default()
},
)
.await
.unwrap();
writer
.commit(|tx| tx.create_schema("s").map(|_| ()))
.await
.unwrap();
let (reader, _) = StoreBuilder::new("", object_store)
.poll_interval(std::time::Duration::from_millis(20))
.open_reader()
.await
.unwrap();
let handle = Handle::Reader(&reader);
let passes = AtomicUsize::new(0);
let view = read::consistent(handle, || async {
let pass = passes.fetch_add(1, Ordering::SeqCst);
if pass == 0 {
writer
.commit(|tx| tx.create_schema("late").map(|_| ()))
.await
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while std::time::Instant::now() < deadline {
if read_head_value(handle).await.unwrap().snapshot_id > 1 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
}
materialize(handle, None).await
})
.await
.unwrap();
assert!(
passes.load(Ordering::SeqCst) > 1,
"a pass the store moved under must be discarded and re-run"
);
assert!(view.schema_by_name("s").is_some());
assert!(view.schema_by_name("late").is_some());
writer.close().await.unwrap();
}
async fn catalog_with_a_reclaimed_snapshot()
-> (Db, Arc<std::sync::RwLock<ProjectionCache>>, Arc<Coalescer>) {
let object_store: Arc<InMemory> = Arc::new(InMemory::new());
let (db, _, _, _) = open_initialized(
StoreBuilder::new("", object_store),
false,
None,
std::time::Duration::ZERO,
)
.await
.unwrap();
let projections = Arc::new(std::sync::RwLock::new(ProjectionCache::empty()));
let coalescer = Arc::new(Coalescer::new(
Arc::clone(&projections),
CommitDurability::OnFlushInterval,
));
for name in ["a", "b", "c"] {
commit_cycle(
&db,
&[|tx: &mut Transaction| tx.create_schema(name).map(|_| ())],
&coalescer,
)
.await
.unwrap();
}
let tx = db.begin(IsolationLevel::Snapshot).await.unwrap();
tx.delete(Key::Snapshot { snapshot_id: 2 }.encode())
.unwrap();
tx.commit().await.unwrap();
(db, projections, coalescer)
}
#[tokio::test]
async fn a_retry_across_a_reclaimed_snapshot_conflicts_rather_than_corrupting() {
let (db, _projections, _coalescer) = catalog_with_a_reclaimed_snapshot().await;
let intervening = intervening_changes(&db, 1).await.unwrap();
let ids: Vec<u64> = intervening.iter().map(|(id, _)| *id).collect();
assert_eq!(ids, vec![2, 3]);
let reclaimed = &intervening[0].1;
let surviving = &intervening[1].1;
assert!(
reclaimed.has_unknown,
"a reclaimed record leaves its commit's changes unknowable"
);
assert!(!surviving.has_unknown);
let ours = ChangeSet::parse("inserted_into_table:7");
assert!(!crate::transaction::operations::conflicts(&ours, surviving));
assert!(crate::transaction::operations::conflicts(&ours, reclaimed));
db.close().await.unwrap();
}
#[tokio::test]
async fn a_verb_commit_rematerializes_over_a_reclaimed_snapshot() {
let (db, projections, coalescer) = catalog_with_a_reclaimed_snapshot().await;
invalidate_head_view(&projections);
commit_cycle(
&db,
&[|tx: &mut Transaction| tx.create_schema("d").map(|_| ())],
&coalescer,
)
.await
.unwrap();
let tx = db.begin(IsolationLevel::Snapshot).await.unwrap();
let handle = ReadHandle::Tx(&tx);
let view = materialize(handle, None).await.unwrap();
assert_eq!(view.snapshot.snapshot_id, 4);
for name in ["main", "a", "b", "c", "d"] {
assert!(
view.schema_by_name(name).is_some(),
"{name} must survive the reclaimed record"
);
}
let expired = materialize(handle, Some(2)).await.unwrap_err();
assert!(matches!(expired, Error::SnapshotExpired(_)), "{expired}");
tx.rollback();
db.close().await.unwrap();
}
#[tokio::test]
async fn a_marker_from_the_writer_that_fenced_us_never_reads_as_a_migration() {
use crate::catalog::{Catalog, CatalogOptions};
let store: Arc<InMemory> = Arc::new(InMemory::new());
let displaced = Catalog::open(store.clone(), CatalogOptions::default())
.await
.unwrap();
displaced
.commit(|tx| tx.create_schema("before").map(|_| ()))
.await
.unwrap();
displaced.snapshot().await.unwrap();
let migrator = Catalog::open(store.clone(), CatalogOptions::default())
.await
.unwrap();
let tx = migrator.begin_write_tx().await.unwrap();
tx.put(
Key::Sys(SysKey::Migration).encode(),
value::encode_value(&proto::MigrationValue {
from_format: 1,
to_format: 2,
cursor: Vec::new(),
}),
)
.unwrap();
migrator.commit_write_tx(tx).await.unwrap();
let mut noticed = None;
for _ in 0..100 {
match displaced.begin_read().await {
Ok(session) => {
assert_eq!(
crate::store::read::read_migration(session.handle())
.await
.unwrap(),
None,
"the displaced handle read the marker of the writer that fenced it"
);
session.finish();
}
Err(err) => {
noticed = Some(err);
break;
}
}
if let Err(err) = displaced.snapshot().await {
noticed = Some(err);
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
let noticed = noticed.expect("the displaced handle never noticed it had been fenced");
assert!(matches!(noticed, Error::Fenced(_)), "{noticed:?}");
}
#[tokio::test]
async fn a_staged_batch_reports_the_bytes_it_holds() {
let (catalog, table) = catalog_with_two_column_table().await;
let entries: Vec<crate::catalog::IndexEntry> = (0..4)
.map(|row_id| crate::catalog::IndexEntry {
row_id,
values: vec![Some(int_value(i128::from(row_id)))],
})
.collect();
let def = crate::catalog::IndexDef {
name: "by_a".into(),
columns: vec![crate::catalog::ColumnId::new(1)],
unique: false,
};
let base = catalog.snapshot().await.unwrap();
let db_tx = catalog.begin_write_tx().await.unwrap();
let prepared = prepare_and_stage(
&db_tx,
catalog.projections(),
&|tx: &mut Transaction| tx.create_index(table, &def, &entries).map(|_| ()),
&base,
true,
)
.await
.unwrap();
let Prepared::Staged {
writes,
staged_bytes,
..
} = prepared
else {
panic!("the closure staged writes");
};
let catalog_bytes: u64 = writes
.iter()
.map(|(key, value)| (key.len() + value.as_ref().map_or(0, Vec::len)) as u64)
.sum();
let nominal: u64 = entries
.iter()
.map(crate::catalog::IndexEntry::nominal_bytes)
.sum();
assert!(nominal > 0, "the entries weigh something");
assert!(
staged_bytes.0 >= catalog_bytes + nominal,
"{} < {catalog_bytes} + {nominal}",
staged_bytes.0
);
assert!(
staged_bytes.0 <= catalog_bytes + 2 * nominal,
"{} > {catalog_bytes} + 2 * {nominal}",
staged_bytes.0
);
db_tx.rollback();
catalog.close().await.unwrap();
}
#[test]
fn the_format_floor_takes_the_highest_term_that_applies() {
let index_at = |value: proto::IndexValue| {
let mut state = CatalogSnapshot::default();
state.put_index(value);
state
};
let building = index_at(proto::IndexValue {
build_state: Some("maintaining".to_owned()),
..proto::IndexValue::default()
});
let deferred = index_at(proto::IndexValue {
deferred_maintenance: Some(true),
..proto::IndexValue::default()
});
assert_eq!(index_format(&CatalogSnapshot::default()), FORMAT_VERSION);
assert_eq!(index_format(&building), FORMAT_WITH_STAGED_INDEX);
assert_eq!(index_format(&deferred), FORMAT_WITH_DEFERRED_INDEX);
let both = InlineShapes {
chunk_directory: true,
schema_reference: true,
};
assert_eq!(inline_format(InlineShapes::default()), FORMAT_VERSION);
assert_eq!(
inline_format(InlineShapes {
schema_reference: true,
..InlineShapes::default()
}),
FORMAT_WITH_INLINE_SCHEMA_REFERENCE
);
assert_eq!(
inline_format(both),
FORMAT_WITH_INLINE_CHUNK_IDENTITY,
"a locator outranks a schema reference written beside it"
);
assert_eq!(
target_format(&deferred, both),
FORMAT_WITH_INLINE_CHUNK_IDENTITY,
"an inline shape outranks the index state it commits beside"
);
assert_eq!(
target_format(&deferred, InlineShapes::default()),
FORMAT_WITH_DEFERRED_INDEX
);
}