use std::collections::HashSet;
use bytes::Bytes;
#[doc(hidden)]
pub use crate::catalog::inline::InlineScanKind;
use crate::{
catalog::ReadOnlyCatalog,
error::{Error, Result},
store::{inline as store_inline, key::InlineOperation},
};
#[doc(hidden)]
pub struct InlineRowRecord {
pub row_id: u64,
pub schema_version: u64,
pub begin_snapshot: u64,
pub end_snapshot: Option<u64>,
pub chunk_index: u64,
pub offset_in_chunk: u64,
}
#[doc(hidden)]
pub struct InlineScanRecord {
pub rows: Vec<InlineRowRecord>,
pub chunk_bodies: Vec<Bytes>,
}
#[doc(hidden)]
pub async fn scan_inline(
catalog: &ReadOnlyCatalog,
table_id: u64,
kind: InlineScanKind,
snapshot: u64,
start: u64,
schema_version: Option<u64>,
) -> Result<InlineScanRecord> {
let (selected, chunks) = catalog
.select_inline_rows(table_id, kind, snapshot, start, schema_version)
.await?;
let chunk_bodies: Vec<Bytes> = chunks.iter().map(|(_, chunk)| chunk.body.clone()).collect();
let rows = selected
.into_iter()
.map(|row| {
let (operation, _) = &chunks[row.chunk];
let InlineOperation::Insert { schema_version, .. } = operation else {
return Err(Error::Corruption(format!(
"inline row {} references a non-insert chunk key: {operation:?}",
row.row_id
)));
};
Ok(InlineRowRecord {
row_id: row.row_id,
schema_version: *schema_version,
begin_snapshot: row.begin_snapshot,
end_snapshot: row.end_snapshot,
chunk_index: row.chunk as u64,
offset_in_chunk: row.offset_in_chunk,
})
})
.collect::<Result<Vec<_>>>()?;
Ok(InlineScanRecord { rows, chunk_bodies })
}
#[doc(hidden)]
pub async fn inline_schemas(catalog: &ReadOnlyCatalog, table_id: u64) -> Result<Vec<(u64, Bytes)>> {
let session = catalog.begin_read().await?;
let schemas = store_inline::scan_inline_schemas_resolved(session.handle(), table_id).await;
session.finish();
schemas
}
#[doc(hidden)]
pub async fn inline_registered_tables(catalog: &ReadOnlyCatalog) -> Result<Vec<(u64, u64)>> {
let session = catalog.begin_read().await?;
let scans = futures::try_join!(
store_inline::scan_all_inline_schema_keys(session.handle()),
store_inline::scan_all_inline_dropped_schemas(session.handle()),
);
session.finish();
let (registered, dropped) = scans?;
let dropped: HashSet<(u64, u64)> = dropped.into_iter().collect();
Ok(registered
.into_iter()
.filter(|pair| !dropped.contains(pair))
.collect())
}
#[doc(hidden)]
pub async fn inline_file_delete_table_exists(
catalog: &ReadOnlyCatalog,
table_id: u64,
) -> Result<bool> {
let session = catalog.begin_read().await?;
let marked = store_inline::read_inline_file_delete_table(session.handle(), table_id).await;
let exists = match marked {
Ok(true) => Ok(true),
Ok(false) => store_inline::scan_inline_file_deletes(session.handle(), table_id)
.await
.map(|records| !records.is_empty()),
Err(error) => Err(error),
};
session.finish();
exists
}
#[doc(hidden)]
pub async fn inline_file_deletes(
catalog: &ReadOnlyCatalog,
table_id: u64,
) -> Result<Vec<(u64, u64, u64)>> {
let session = catalog.begin_read().await?;
let file_deletes = store_inline::scan_inline_file_deletes(session.handle(), table_id).await;
session.finish();
Ok(file_deletes?
.into_iter()
.map(|(data_file_id, row_id, value)| (data_file_id, row_id, value.begin_snapshot))
.collect())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use object_store::memory::InMemory;
use super::*;
use crate::{
catalog::CatalogOptions,
transaction::staged::{Cell, RowOperation, StagedTransaction, TableKind},
};
fn snapshot_row(id: u64) -> Vec<Cell> {
vec![
Cell::U64(id),
Cell::I64(1),
Cell::U64(0),
Cell::U64(1),
Cell::U64(0),
]
}
fn snapshot_changes_row(id: u64) -> Vec<Cell> {
vec![
Cell::U64(id),
Cell::Str("inlined_insert:1".to_string()),
Cell::Null,
Cell::Null,
Cell::Null,
]
}
async fn open() -> crate::catalog::Catalog {
crate::catalog::Catalog::open(Arc::new(InMemory::new()), CatalogOptions::default())
.await
.unwrap()
}
#[tokio::test]
async fn scan_inline_materializes_rows_with_chunk_bodies() {
let catalog = open().await;
let db_tx = catalog.begin_write_tx().await.unwrap();
let mut tx = StagedTransaction::begin_detached(&catalog, db_tx);
tx.stage(RowOperation::InlineSchema {
table_id: 1,
schema_version: 0,
arrow_schema: b"schema".to_vec(),
});
tx.stage(RowOperation::InlineInsert {
table_id: 1,
schema_version: 0,
begin_snapshot: 1,
row_id_start: 0,
row_count: 2,
arrow_body: b"chunk-a".to_vec(),
});
tx.stage(RowOperation::InlineInsert {
table_id: 1,
schema_version: 0,
begin_snapshot: 1,
row_id_start: 2,
row_count: 1,
arrow_body: b"chunk-b".to_vec(),
});
tx.stage(RowOperation::Insert {
table: TableKind::Snapshot,
cells: snapshot_row(1),
});
tx.stage(RowOperation::Insert {
table: TableKind::SnapshotChanges,
cells: snapshot_changes_row(1),
});
tx.commit().await.unwrap();
let db_tx2 = catalog.begin_write_tx().await.unwrap();
let mut inline_delete = StagedTransaction::begin_detached(&catalog, db_tx2);
inline_delete.stage(RowOperation::InlineInlineDelete {
table_id: 1,
row_id: 1,
end_snapshot: 2,
});
inline_delete.stage(RowOperation::Insert {
table: TableKind::Snapshot,
cells: snapshot_row(2),
});
inline_delete.stage(RowOperation::Insert {
table: TableKind::SnapshotChanges,
cells: snapshot_changes_row(2),
});
inline_delete.commit().await.unwrap();
let record = scan_inline(&catalog, 1, InlineScanKind::Table, 2, 0, None)
.await
.unwrap();
let mut by_id: Vec<(u64, Bytes, u64)> = record
.rows
.iter()
.map(|r| {
(
r.row_id,
record.chunk_bodies[usize::try_from(r.chunk_index).unwrap()].clone(),
r.offset_in_chunk,
)
})
.collect();
by_id.sort_by_key(|(id, ..)| *id);
assert_eq!(
by_id,
vec![
(0, Bytes::from_static(b"chunk-a"), 0),
(2, Bytes::from_static(b"chunk-b"), 0)
]
);
assert_eq!(record.chunk_bodies.len(), 2);
let schemas = inline_schemas(&catalog, 1).await.unwrap();
assert_eq!(schemas, vec![(0, Bytes::from_static(b"schema"))]);
let registered = inline_registered_tables(&catalog).await.unwrap();
assert_eq!(registered, vec![(1, 0)]);
}
#[tokio::test]
async fn a_dropped_schema_version_deregisters_but_stays_resolvable() {
let catalog = open().await;
let db_tx = catalog.begin_write_tx().await.unwrap();
let mut tx = StagedTransaction::begin_detached(&catalog, db_tx);
for (schema_version, schema) in [(0u64, b"schema-v0"), (1, b"schema-v1")] {
tx.stage(RowOperation::InlineSchema {
table_id: 1,
schema_version,
arrow_schema: schema.to_vec(),
});
}
tx.stage(RowOperation::InlineInsert {
table_id: 1,
schema_version: 0,
begin_snapshot: 1,
row_id_start: 0,
row_count: 1,
arrow_body: b"chunk".to_vec(),
});
tx.stage(RowOperation::Insert {
table: TableKind::Snapshot,
cells: snapshot_row(1),
});
tx.stage(RowOperation::Insert {
table: TableKind::SnapshotChanges,
cells: snapshot_changes_row(1),
});
tx.commit().await.unwrap();
assert_eq!(
inline_registered_tables(&catalog).await.unwrap(),
vec![(1, 0), (1, 1)]
);
let db_tx2 = catalog.begin_write_tx().await.unwrap();
let mut flush = StagedTransaction::begin_detached(&catalog, db_tx2);
flush.stage(RowOperation::InlineFlushDelete {
table_id: 1,
schema_version: 0,
flush_snapshot: 2,
});
flush.stage(RowOperation::InlineSchemaDrop {
table_id: 1,
schema_version: 0,
});
flush.stage(RowOperation::Insert {
table: TableKind::Snapshot,
cells: snapshot_row(2),
});
flush.stage(RowOperation::Insert {
table: TableKind::SnapshotChanges,
cells: snapshot_changes_row(2),
});
flush.commit().await.unwrap();
assert_eq!(
inline_registered_tables(&catalog).await.unwrap(),
vec![(1, 1)],
"the flushed version leaves the registry"
);
assert_eq!(
inline_schemas(&catalog, 1).await.unwrap(),
vec![
(0, Bytes::from_static(b"schema-v0")),
(1, Bytes::from_static(b"schema-v1"))
],
"the flushed version still resolves its columns"
);
let record = scan_inline(&catalog, 1, InlineScanKind::Table, 2, 0, Some(0))
.await
.unwrap();
assert!(
record.rows.is_empty(),
"the retained version scans empty; its rows are in the flushed file"
);
}
#[tokio::test]
async fn scan_inline_serves_from_the_directory_once_verified() {
use crate::store::{
key::{InlineKey, InlineOperation, Key},
proto, value,
};
let catalog = open().await;
let db_tx = catalog.begin_write_tx().await.unwrap();
let mut tx = StagedTransaction::begin_detached_on(&catalog, db_tx);
tx.stage(RowOperation::InlineInsert {
table_id: 1,
schema_version: 0,
begin_snapshot: 1,
row_id_start: 0,
row_count: 2,
arrow_body: b"chunk-a".to_vec(),
});
tx.stage(RowOperation::Insert {
table: TableKind::Snapshot,
cells: snapshot_row(1),
});
tx.stage(RowOperation::Insert {
table: TableKind::SnapshotChanges,
cells: snapshot_changes_row(1),
});
tx.commit().await.unwrap();
let record = scan_inline(&catalog, 1, InlineScanKind::Table, 1, 0, None)
.await
.unwrap();
assert_eq!(record.rows.len(), 2);
assert!(
crate::catalog::projection::inline_directory_complete(catalog.projections(), 1),
"the first walk must verify and remember a complete directory"
);
let tx = catalog.begin_write_tx().await.unwrap();
tx.put(
Key::Inline(InlineKey::Live(InlineOperation::Insert {
table_id: 1,
schema_version: 0,
begin_snapshot: 1,
chunk_seq: 5,
}))
.encode(),
value::encode_value(&proto::InlineChunkValue {
body: b"rogue".to_vec().into(),
row_id_start: 10,
row_count: 2,
data_file_id: None,
}),
)
.unwrap();
tx.commit().await.unwrap();
let record = scan_inline(&catalog, 1, InlineScanKind::Table, 1, 0, None)
.await
.unwrap();
let row_ids: Vec<u64> = record.rows.iter().map(|row| row.row_id).collect();
assert_eq!(
row_ids,
[0, 1],
"a directory-served scan must not see a chunk only a body scan finds"
);
assert_eq!(record.chunk_bodies, vec![Bytes::from_static(b"chunk-a")]);
}
#[tokio::test]
async fn scan_inline_filtered_to_a_version_hauls_only_its_bodies() {
let catalog = open().await;
let db_tx = catalog.begin_write_tx().await.unwrap();
let mut tx = StagedTransaction::begin_detached(&catalog, db_tx);
tx.stage(RowOperation::InlineInsert {
table_id: 1,
schema_version: 0,
begin_snapshot: 1,
row_id_start: 0,
row_count: 2,
arrow_body: b"chunk-v0".to_vec(),
});
tx.stage(RowOperation::InlineInsert {
table_id: 1,
schema_version: 1,
begin_snapshot: 1,
row_id_start: 2,
row_count: 1,
arrow_body: b"chunk-v1".to_vec(),
});
tx.stage(RowOperation::Insert {
table: TableKind::Snapshot,
cells: snapshot_row(1),
});
tx.stage(RowOperation::Insert {
table: TableKind::SnapshotChanges,
cells: snapshot_changes_row(1),
});
tx.commit().await.unwrap();
for pass in ["chunk scan", "directory"] {
let record = scan_inline(&catalog, 1, InlineScanKind::Table, 1, 0, Some(1))
.await
.unwrap();
let rows: Vec<(u64, u64, u64)> = record
.rows
.iter()
.map(|row| (row.row_id, row.schema_version, row.chunk_index))
.collect();
assert_eq!(rows, vec![(2, 1, 0)], "one version-1 row ({pass})");
assert_eq!(
record.chunk_bodies,
vec![Bytes::from_static(b"chunk-v1")],
"the other version's body must not be hauled ({pass})"
);
}
}
#[tokio::test]
async fn inline_file_deletes_read_back_in_key_order() {
let catalog = open().await;
let db_tx = catalog.begin_write_tx().await.unwrap();
let mut tx = StagedTransaction::begin_detached(&catalog, db_tx);
tx.stage(RowOperation::InlineFileDelete {
table_id: 1,
data_file_id: 7,
row_id: 2,
begin_snapshot: 6,
});
tx.stage(RowOperation::InlineFileDelete {
table_id: 1,
data_file_id: 7,
row_id: 0,
begin_snapshot: 6,
});
tx.stage(RowOperation::Insert {
table: TableKind::Snapshot,
cells: snapshot_row(6),
});
tx.stage(RowOperation::Insert {
table: TableKind::SnapshotChanges,
cells: snapshot_changes_row(6),
});
tx.commit().await.unwrap();
let rows = inline_file_deletes(&catalog, 1).await.unwrap();
assert_eq!(rows, vec![(7, 0, 6), (7, 2, 6)]);
assert!(inline_file_delete_table_exists(&catalog, 1).await.unwrap());
assert_eq!(inline_file_deletes(&catalog, 9).await.unwrap(), vec![]);
}
}