use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use object_store::ObjectStore;
use slatedb::DbTransaction;
use crate::{
catalog::{
CatalogSnapshot, ColumnInfo, IndexInfo, SnapshotId, TableId,
inline::{InlineScanKind, materialize_inline_rows},
projection::{ProjectionCache, fold_committed_batch, invalidate_head_view},
scoped_read::{self, ScopedReadEntry},
},
error::{Error, Result},
store::{
handle::ReadHandle,
index_encoding::encode_ordered_values,
inline as store_inline,
key::{EntityKey, InlineKey, InlineOperation, Key, SysKey},
proto, read, value,
},
transaction::{
commit,
index_maintenance::{StagedIndexEntry, stage_index_entries},
},
};
mod apply;
mod decode;
mod index_upkeep;
mod inline;
#[cfg(test)]
mod tests;
use apply::{ChildRows, apply_op, build_snapshot_value, collect_child_rows, is_inline_op};
use decode::Cursor;
use index_upkeep::stage_index_maintenance;
use inline::translate_inline;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum TableKind {
Snapshot,
SnapshotChanges,
Schema,
Table,
View,
Column,
DataFile,
DeleteFile,
TableStats,
TableColumnStats,
FileColumnStats,
SchemaVersions,
PartitionInfo,
PartitionColumn,
FilePartitionValue,
SortInfo,
SortExpression,
Tag,
ColumnTag,
FilesScheduledForDeletion,
Macro,
MacroImpl,
MacroParameters,
ColumnMapping,
NameMapping,
}
impl TableKind {
pub const ALL: [Self; 25] = [
Self::Snapshot,
Self::SnapshotChanges,
Self::Schema,
Self::Table,
Self::View,
Self::Column,
Self::DataFile,
Self::DeleteFile,
Self::TableStats,
Self::TableColumnStats,
Self::FileColumnStats,
Self::SchemaVersions,
Self::PartitionInfo,
Self::PartitionColumn,
Self::FilePartitionValue,
Self::SortInfo,
Self::SortExpression,
Self::Tag,
Self::ColumnTag,
Self::FilesScheduledForDeletion,
Self::Macro,
Self::MacroImpl,
Self::MacroParameters,
Self::ColumnMapping,
Self::NameMapping,
];
}
impl TryFrom<i32> for TableKind {
type Error = i32;
fn try_from(value: i32) -> std::result::Result<Self, i32> {
usize::try_from(value)
.ok()
.and_then(|index| Self::ALL.get(index).copied())
.ok_or(value)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Cell {
Null,
U64(u64),
I64(i64),
Bool(bool),
Str(String),
}
#[derive(Debug, Clone)]
pub enum RowOperation {
Insert {
table: TableKind,
cells: Vec<Cell>,
},
Delete {
table: TableKind,
cells: Vec<Cell>,
},
UpdateSetEnd {
table: TableKind,
cells: Vec<Cell>,
},
UpdateSetBegin {
table: TableKind,
cells: Vec<Cell>,
},
InlineSchema {
table_id: u64,
schema_version: u64,
arrow_schema: Vec<u8>,
},
InlineInsert {
table_id: u64,
schema_version: u64,
begin_snapshot: u64,
row_id_start: u64,
row_count: u64,
arrow_body: Vec<u8>,
},
InlineInlineDelete {
table_id: u64,
row_id: u64,
end_snapshot: u64,
},
InlineFileDelete {
table_id: u64,
data_file_id: u64,
row_id: u64,
begin_snapshot: u64,
},
InlineFlushDelete {
table_id: u64,
schema_version: u64,
flush_snapshot: u64,
},
InlineDrop {
table_id: u64,
},
InlineSchemaDrop {
table_id: u64,
schema_version: u64,
},
}
fn corrupt_row(table: TableKind, detail: impl std::fmt::Display) -> Error {
Error::Corruption(format!("staged row for {table:?}: {detail}"))
}
pub struct StagedTransaction {
db_tx: DbTransaction,
ops: Vec<RowOperation>,
projections: Arc<std::sync::RwLock<ProjectionCache>>,
data_store: Option<Arc<dyn ObjectStore>>,
data_prefix: String,
}
impl StagedTransaction {
pub(crate) fn begin(
db_tx: DbTransaction,
projections: Arc<std::sync::RwLock<ProjectionCache>>,
data_store: Option<Arc<dyn ObjectStore>>,
data_prefix: String,
) -> Self {
Self {
db_tx,
ops: Vec::new(),
projections,
data_store,
data_prefix,
}
}
#[cfg(test)]
pub(crate) fn begin_detached(db_tx: DbTransaction) -> Self {
Self::begin(
db_tx,
Arc::new(std::sync::RwLock::new(ProjectionCache::empty())),
None,
String::new(),
)
}
#[cfg(test)]
pub(crate) fn begin_detached_with_store(
db_tx: DbTransaction,
data_store: Arc<dyn ObjectStore>,
) -> Self {
Self::begin(
db_tx,
Arc::new(std::sync::RwLock::new(ProjectionCache::empty())),
Some(data_store),
String::new(),
)
}
pub fn stage(&mut self, op: RowOperation) {
self.ops.push(op);
}
pub fn rollback(self) {
self.db_tx.rollback();
}
pub async fn visible_snapshots(&self) -> Result<Vec<proto::SnapshotValue>> {
let committed = read::scan_snapshots(ReadHandle::Tx(&self.db_tx)).await?;
let mut deleted = std::collections::BTreeSet::new();
for op in &self.ops {
if let RowOperation::Delete {
table: TableKind::Snapshot,
cells,
} = op
{
let mut c = Cursor::new(TableKind::Snapshot, cells);
deleted.insert(c.u64()?);
c.finish()?;
}
}
Ok(committed
.into_iter()
.filter(|s| !deleted.contains(&s.snapshot_id))
.collect())
}
pub async fn commit(self) -> Result<SnapshotId> {
let Self {
db_tx,
ops,
projections,
data_store,
data_prefix,
} = self;
let base = match commit::head_view_for(&db_tx, &projections).await {
Ok(base) => base,
Err(err) => {
db_tx.rollback();
return Err(err);
}
};
let base_ref: &CatalogSnapshot = &base;
let inline_writes = match translate_inline(&db_tx, &ops).await {
Ok(writes) => writes,
Err(err) => {
db_tx.rollback();
return Err(err);
}
};
let mints_snapshot = ops.iter().any(|op| {
matches!(
op,
RowOperation::Insert {
table: TableKind::Snapshot,
..
}
)
});
let translated = if mints_snapshot {
translate(base_ref, &ops).map(|(new_id, mut writes, snap)| {
writes.push((
Key::Snapshot {
snapshot_id: new_id,
}
.encode(),
Some(value::encode_value(&snap)),
));
writes.push((
Key::Sys(SysKey::Head).encode(),
Some(value::encode_value(&proto::HeadValue {
snapshot_id: new_id,
})),
));
(new_id, writes)
})
} else {
translate_maintenance(base_ref, &ops)
.map(|writes| (base_ref.snapshot.snapshot_id, writes))
};
match translated {
Ok((result_id, mut writes)) => {
writes.extend(inline_writes);
if let Err(err) = stage_index_maintenance(
&db_tx,
base_ref,
&ops,
data_store.as_ref(),
&data_prefix,
&mut writes,
)
.await
{
db_tx.rollback();
return Err(err);
}
if let Err(err) = commit::stage_writes(&db_tx, &writes) {
db_tx.rollback();
return Err(err);
}
if !mints_snapshot {
invalidate_head_view(&projections);
}
match db_tx.commit_with_options(&commit::durable()).await {
Ok(_) => {
fold_committed_batch(&projections, &writes, result_id);
if mints_snapshot {
commit::refresh_head_view(&projections, base_ref, &writes);
}
Ok(SnapshotId::new(result_id))
}
Err(err) if err.kind() == slatedb::ErrorKind::Transaction => {
Err(Error::CommitConflict(format!(
"a concurrent commit changed state this one read or wrote \
(attempted snapshot {result_id}); staged-row commits are never \
retried internally"
)))
}
Err(err) => Err(err.into()),
}
}
Err(err) => {
db_tx.rollback();
Err(err)
}
}
}
}
fn translate(
base: &CatalogSnapshot,
ops: &[RowOperation],
) -> Result<(u64, Vec<commit::StagedWrite>, proto::SnapshotValue)> {
let snapshot = build_snapshot_value(ops)?;
let new_id = snapshot.snapshot_id;
let mut state = base.clone();
let mut children = collect_child_rows(ops)?;
let mut direct = Vec::new();
for op in ops {
if !is_inline_op(op)
&& !matches!(
op,
RowOperation::Insert { .. } | RowOperation::UpdateSetBegin { .. }
)
{
apply_op(base, &mut state, op, new_id, &mut children, &mut direct)?;
}
}
for op in ops {
if matches!(op, RowOperation::Insert { .. }) {
apply_op(base, &mut state, op, new_id, &mut children, &mut direct)?;
}
}
for op in ops {
if matches!(op, RowOperation::UpdateSetBegin { .. }) {
apply_op(base, &mut state, op, new_id, &mut children, &mut direct)?;
}
}
if !children.partition_columns.is_empty() {
return Err(corrupt_row(
TableKind::PartitionColumn,
"partition_column rows without a matching partition_info insert in this commit",
));
}
if !children.sort_expressions.is_empty() {
return Err(corrupt_row(
TableKind::SortExpression,
"sort_expression rows without a matching sort_info insert in this commit",
));
}
if !children.file_partition_values.is_empty() {
return Err(corrupt_row(
TableKind::FilePartitionValue,
"file_partition_value rows without a matching data_file insert in this commit",
));
}
if !children.macro_implementations.is_empty() {
return Err(corrupt_row(
TableKind::MacroImpl,
"macro_impl rows without a matching macro insert in this commit",
));
}
if !children.macro_parameters.is_empty() {
return Err(corrupt_row(
TableKind::MacroParameters,
"macro_parameters rows without a matching macro_impl in this commit",
));
}
if !children.name_mappings.is_empty() {
return Err(corrupt_row(
TableKind::NameMapping,
"name_mapping rows without a matching column_mapping insert in this commit",
));
}
for (table_id, columns) in &state.columns {
let Some(max_id) = columns.keys().max() else {
continue;
};
if let Some(table) = state.tables.get_mut(table_id)
&& table.next_column_id <= *max_id
{
table.next_column_id = max_id + 1;
}
}
let mut writes = commit::diff_writes(base, &state, new_id);
writes.extend(direct);
Ok((new_id, writes, snapshot))
}
fn translate_maintenance(
base: &CatalogSnapshot,
ops: &[RowOperation],
) -> Result<Vec<commit::StagedWrite>> {
let head = base.snapshot.snapshot_id;
let mut state = base.clone();
let mut children = ChildRows::default();
let mut direct = Vec::new();
for op in ops {
let allowed = matches!(
op,
RowOperation::Delete { .. }
| RowOperation::Insert {
table: TableKind::FilesScheduledForDeletion,
..
}
) || is_inline_op(op);
if !allowed {
return Err(Error::Constraint(
"a staged commit without a ducklake_snapshot insert may only reclaim state \
(maintenance deletes and deletion-schedule inserts)"
.to_string(),
));
}
apply_op(base, &mut state, op, head, &mut children, &mut direct)?;
}
let mut writes = commit::diff_writes(base, &state, head);
writes.extend(direct);
Ok(writes)
}