use std::{
collections::{BTreeSet, HashMap, HashSet},
sync::Arc,
};
use object_store::ObjectStore;
use slatedb::DbTransaction;
use tracing::debug;
use crate::{
catalog::{
CatalogSnapshot, ColumnInfo, IndexInfo, SnapshotId, TableId,
inline::{InlineScanKind, materialize_inline_rows},
projection::ProjectionCache,
scoped_read::{self, ScopedReadEntry},
},
error::{Error, Result},
store::{
handle::ReadHandle,
index_encoding::encode_ordered_values,
inline as store_inline,
key::{EntityKey, InlineKey, InlineOperation, Key},
proto, read, value,
},
transaction::{
commit,
index_maintenance::{StagedIndexEntry, stage_index_entries},
},
};
mod apply;
mod decode;
mod index_upkeep;
pub(crate) mod inline;
mod overlay;
#[cfg(test)]
mod tests;
use apply::{
ChildRows, apply_op, build_snapshot_value, collect_child_rows, collect_hard_deletes,
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,
Metadata,
}
impl TableKind {
pub const ALL: [Self; 26] = [
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,
Self::Metadata,
];
}
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,
},
InlineFileDeleteRemove {
table_id: u64,
data_file_id: u64,
row_id: 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>,
committed: tokio::sync::OnceCell<Arc<Vec<read::EntityRecord>>>,
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(),
committed: tokio::sync::OnceCell::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 = 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())
}
async fn committed_entities(&self) -> Result<&Arc<Vec<read::EntityRecord>>> {
self.committed
.get_or_try_init(|| async {
let handle = ReadHandle::Tx(&self.db_tx);
let mut records = read::scan_current_entities(handle).await?;
records.extend(read::scan_history_entities(handle).await?);
Ok(Arc::new(records))
})
.await
}
async fn committed_rows<T>(
&self,
extract: impl Fn(&read::EntityRecord) -> Option<T>,
) -> Result<Vec<T>> {
Ok(self
.committed_entities()
.await?
.iter()
.filter_map(extract)
.collect())
}
pub async fn visible_data_files(&self) -> Result<Vec<proto::DataFileValue>> {
let committed = self
.committed_rows(|r| match r {
read::EntityRecord::File(v) => Some(v.clone()),
_ => None,
})
.await?;
overlay::overlay_versioned(&self.ops, committed)
}
pub async fn visible_delete_files(&self) -> Result<Vec<proto::DeleteFileValue>> {
let committed = self
.committed_rows(|r| match r {
read::EntityRecord::DeleteFile(v) => Some(v.clone()),
_ => None,
})
.await?;
overlay::overlay_versioned(&self.ops, committed)
}
pub async fn visible_columns(&self) -> Result<Vec<proto::ColumnValue>> {
let committed = self
.committed_rows(|r| match r {
read::EntityRecord::Column(v) => Some(v.clone()),
_ => None,
})
.await?;
overlay::overlay_versioned(&self.ops, committed)
}
pub async fn visible_tables(&self) -> Result<Vec<proto::TableValue>> {
let committed = self
.committed_rows(|r| match r {
read::EntityRecord::Table(v) => Some(v.clone()),
_ => None,
})
.await?;
overlay::overlay_versioned(&self.ops, committed)
}
pub async fn visible_file_column_stats(&self) -> Result<Vec<proto::FileColumnStatsValue>> {
let committed = self
.committed_rows(|r| match r {
read::EntityRecord::FileColumnStats(v) => Some(v.clone()),
_ => None,
})
.await?;
overlay::overlay_unversioned(&self.ops, committed)
}
pub async fn visible_schemas(&self) -> Result<Vec<proto::SchemaValue>> {
let committed = self
.committed_rows(|r| match r {
read::EntityRecord::Schema(v) => Some(v.clone()),
_ => None,
})
.await?;
overlay::overlay_versioned(&self.ops, committed)
}
pub async fn visible_views(&self) -> Result<Vec<proto::ViewValue>> {
let committed = self
.committed_rows(|r| match r {
read::EntityRecord::View(v) => Some(v.clone()),
_ => None,
})
.await?;
overlay::overlay_versioned(&self.ops, committed)
}
pub async fn visible_partition_info(&self) -> Result<Vec<proto::PartitionValue>> {
let committed = self
.committed_rows(|r| match r {
read::EntityRecord::Partition(v) => Some(v.clone()),
_ => None,
})
.await?;
overlay::overlay_versioned(&self.ops, committed)
}
pub async fn visible_sort_info(&self) -> Result<Vec<proto::SortValue>> {
let committed = self
.committed_rows(|r| match r {
read::EntityRecord::Sort(v) => Some(v.clone()),
_ => None,
})
.await?;
overlay::overlay_versioned(&self.ops, committed)
}
pub async fn visible_macros(&self) -> Result<Vec<proto::MacroValue>> {
let committed = self
.committed_rows(|r| match r {
read::EntityRecord::Macro(v) => Some(v.clone()),
_ => None,
})
.await?;
overlay::overlay_versioned(&self.ops, committed)
}
pub async fn visible_table_stats(&self) -> Result<Vec<proto::TableStatsValue>> {
let committed = self
.committed_rows(|r| match r {
read::EntityRecord::TableStats(v) => Some(*v),
_ => None,
})
.await?;
overlay::overlay_unversioned(&self.ops, committed)
}
pub async fn visible_table_column_stats(&self) -> Result<Vec<proto::TableColumnStatsValue>> {
let committed = self
.committed_rows(|r| match r {
read::EntityRecord::TableColumnStats(v) => Some(v.clone()),
_ => None,
})
.await?;
overlay::overlay_unversioned(&self.ops, committed)
}
pub async fn visible_mappings(&self) -> Result<Vec<proto::MappingValue>> {
let committed = self
.committed_rows(|r| match r {
read::EntityRecord::Mapping(v) => Some(v.clone()),
_ => None,
})
.await?;
overlay::overlay_unversioned(&self.ops, committed)
}
pub async fn visible_tag_containers(&self) -> Result<Vec<proto::TagValue>> {
let committed = self
.committed_rows(|r| match r {
read::EntityRecord::Tag(v) => Some(v.clone()),
_ => None,
})
.await?;
overlay::overlay_tag_containers(&self.ops, committed)
}
pub async fn visible_option_scopes(&self) -> Result<Vec<(u64, u64, proto::OptionScopeValue)>> {
let committed = self
.committed_rows(|r| match r {
read::EntityRecord::Option {
scope_kind,
scope_id,
value,
} => Some((*scope_kind, *scope_id, value.clone())),
_ => None,
})
.await?;
overlay::overlay_option_scopes(&self.ops, committed)
}
fn staged_children<T>(
&self,
kind: TableKind,
decode: impl Fn(&[Cell]) -> Result<T>,
) -> Result<Vec<T>> {
self.ops
.iter()
.filter_map(|op| match op {
RowOperation::Insert { table, cells } if *table == kind => Some(decode(cells)),
_ => None,
})
.collect()
}
pub fn staged_partition_columns(&self) -> Result<Vec<(u64, proto::PartitionColumn)>> {
self.staged_children(TableKind::PartitionColumn, decode::decode_partition_column)
}
pub fn staged_file_partition_values(
&self,
) -> Result<Vec<((u64, u64), proto::FilePartitionValue)>> {
self.staged_children(
TableKind::FilePartitionValue,
decode::decode_file_partition_value,
)
}
pub fn staged_sort_expressions(&self) -> Result<Vec<(u64, proto::SortExpression)>> {
self.staged_children(TableKind::SortExpression, decode::decode_sort_expression)
}
pub fn staged_column_tags(&self) -> Result<Vec<((u64, u64), proto::ColumnTag)>> {
self.staged_children(TableKind::ColumnTag, decode::decode_column_tag_row)
}
pub fn staged_macro_impls(&self) -> Result<Vec<(u64, proto::MacroImplementation)>> {
self.staged_children(TableKind::MacroImpl, decode::decode_macro_impl)
}
pub fn staged_macro_parameters(&self) -> Result<Vec<((u64, u64), proto::MacroParameter)>> {
self.staged_children(TableKind::MacroParameters, decode::decode_macro_parameter)
}
pub fn staged_name_mappings(&self) -> Result<Vec<(u64, proto::NameMapping)>> {
self.staged_children(TableKind::NameMapping, decode::decode_name_mapping)
}
pub async fn visible_schema_version_records(&self) -> Result<Vec<(u64, u64, u64)>> {
let mut records = read::scan_schema_versions(ReadHandle::Tx(&self.db_tx)).await?;
for op in &self.ops {
match op {
RowOperation::Insert { table, cells } if *table == TableKind::SchemaVersions => {
records.push(decode::decode_schema_version_row(cells)?);
}
RowOperation::Delete { table, cells } if *table == TableKind::SchemaVersions => {
let (table_id, begin_snapshot, _) = decode::decode_schema_version_row(cells)?;
records.retain(|(t, b, _)| !(*t == table_id && *b == begin_snapshot));
}
_ => {}
}
}
Ok(records)
}
pub async fn visible_scheduled_deletions(&self) -> Result<Vec<proto::GcFileValue>> {
let committed = self
.committed_rows(|r| match r {
read::EntityRecord::GcFile(v) => Some(v.clone()),
_ => None,
})
.await?;
overlay::overlay_unversioned(&self.ops, committed)
}
pub async fn commit(self) -> Result<SnapshotId> {
let started = std::time::Instant::now();
let Self {
db_tx,
ops,
committed: _,
projections,
data_store,
data_prefix,
} = self;
let staged_rows = ops.len();
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 maintained =
stage_index_maintenance(&db_tx, base_ref, &ops, data_store.as_ref(), &data_prefix)
.await;
let poisoned = match maintained {
Ok(poisoned) => poisoned,
Err(err) => {
db_tx.rollback();
return Err(err);
}
};
let translated = if mints_snapshot {
translate(base_ref, &ops, &poisoned).map(|(new_id, mut writes, snap)| {
let changelog = commit::changelog_writes(new_id, &writes);
writes.push((
Key::Snapshot {
snapshot_id: new_id,
}
.encode(),
Some(value::encode_value(&snap)),
));
writes.extend(changelog);
(new_id, writes)
})
} else {
translate_maintenance(base_ref, &ops)
.map(|writes| (base_ref.snapshot.snapshot_id, writes))
};
match translated {
Ok((result_id, mut writes)) => {
match commit::head_write(&db_tx, result_id).await {
Ok(head) => writes.push(head),
Err(err) => {
db_tx.rollback();
return Err(err);
}
}
writes.extend(inline_writes);
if let Err(err) = commit::stage_writes(&db_tx, &writes) {
db_tx.rollback();
return Err(err);
}
let head_before = base_ref.snapshot.snapshot_id;
let landed = commit::join_commit_batch(commit::spawn_commit_batch(
db_tx,
head_before,
result_id,
writes,
Arc::clone(&base),
projections,
))
.await?;
match landed {
commit::Landed::Committed => {
staged_landed(result_id, staged_rows, started);
Ok(SnapshotId::new(result_id))
}
commit::Landed::LostRace => Err(staged_lost_race(result_id, staged_rows)),
}
}
Err(err) => {
db_tx.rollback();
Err(err)
}
}
}
}
fn staged_landed(result_id: u64, staged_rows: usize, started: std::time::Instant) {
debug!(
snapshot = result_id,
staged_rows,
elapsed_ms = started.elapsed().as_millis(),
"staged commit landed"
);
}
fn staged_lost_race(result_id: u64, staged_rows: usize) -> Error {
debug!(
attempted_snapshot = result_id,
staged_rows, "staged commit lost a write-write race; DuckLake re-drives"
);
Error::CommitConflict(format!(
"a concurrent commit changed state this one read or wrote \
(attempted snapshot {result_id}); staged-row commits are never \
retried internally"
))
}
fn translate(
base: &CatalogSnapshot,
ops: &[RowOperation],
poisoned: &[u64],
) -> Result<(u64, Vec<commit::StagedWrite>, proto::SnapshotValue)> {
let snapshot = build_snapshot_value(ops)?;
let new_id = snapshot.snapshot_id;
if new_id <= base.snapshot.snapshot_id {
return Err(staged_lost_race(new_id, ops.len()));
}
let mut state = base.clone();
let mut children = collect_child_rows(ops)?;
let mut direct = Vec::new();
let hard_deleted = collect_hard_deletes(ops)?;
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,
&hard_deleted,
)?;
}
}
for op in ops {
if matches!(op, RowOperation::Insert { .. }) {
apply_op(
base,
&mut state,
op,
new_id,
&mut children,
&mut direct,
&hard_deleted,
)?;
}
}
for op in ops {
if matches!(op, RowOperation::UpdateSetBegin { .. }) {
apply_op(
base,
&mut state,
op,
new_id,
&mut children,
&mut direct,
&hard_deleted,
)?;
}
}
refuse_orphaned_children(&children)?;
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;
}
}
crate::transaction::index_maintenance::apply_poison(&mut state, poisoned);
let mut writes = commit::diff_writes(base, &state, new_id);
writes.extend(direct);
for table_id in &snapshot.schema_changed_table_ids {
writes.push(commit::schema_version_write(
*table_id,
new_id,
snapshot.schema_version,
));
}
Ok((new_id, writes, snapshot))
}
fn refuse_orphaned_children(children: &ChildRows) -> Result<()> {
let orphans = [
(
children.partition_columns.is_empty(),
TableKind::PartitionColumn,
"partition_column rows without a matching partition_info insert in this commit",
),
(
children.sort_expressions.is_empty(),
TableKind::SortExpression,
"sort_expression rows without a matching sort_info insert in this commit",
),
(
children.file_partition_values.is_empty(),
TableKind::FilePartitionValue,
"file_partition_value rows without a matching data_file insert in this commit",
),
(
children.macro_implementations.is_empty(),
TableKind::MacroImpl,
"macro_impl rows without a matching macro insert in this commit",
),
(
children.macro_parameters.is_empty(),
TableKind::MacroParameters,
"macro_parameters rows without a matching macro_impl in this commit",
),
(
children.name_mappings.is_empty(),
TableKind::NameMapping,
"name_mapping rows without a matching column_mapping insert in this commit",
),
];
for (applied, table, detail) in orphans {
if !applied {
return Err(corrupt_row(table, detail));
}
}
Ok(())
}
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();
let hard_deleted = collect_hard_deletes(ops)?;
for op in ops {
let allowed = matches!(
op,
RowOperation::Delete { .. }
| RowOperation::Insert {
table: TableKind::FilesScheduledForDeletion
| TableKind::Metadata,
..
}
) || is_inline_op(op);
if !allowed {
return Err(Error::Constraint(
"a staged commit without a ducklake_snapshot insert may only reclaim state \
or set options (maintenance deletes, deletion-schedule inserts, and \
ducklake_metadata writes)"
.to_string(),
));
}
apply_op(
base,
&mut state,
op,
head,
&mut children,
&mut direct,
&hard_deleted,
)?;
}
let mut writes = commit::diff_writes(base, &state, head);
writes.extend(direct);
Ok(writes)
}