use std::{
collections::{BTreeSet, HashMap, HashSet},
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
time::{Duration, Instant},
};
use slatedb::DbTransaction;
use tracing::debug;
use crate::{
catalog::{
CatalogSnapshot, ColumnInfo, IndexId, IndexInfo, SnapshotId, Store, TableId,
inline::materialize_inline_rows, projection::ProjectionCache,
},
data_file::{self, DataStore},
error::{Error, Result},
store::{
StagedBytes,
handle::ReadHandle,
inline as store_inline,
key::{EntityKey, InlineKey, InlineOperation, Key},
proto, read, value,
},
transaction::{
commit,
index_maintenance::{
IndexMaintenanceMetrics, StagedEntries, StagedIndexEntry, apply_deferred_maintenance,
apply_poison, stage_index_entry_stream,
},
},
};
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;
use crate::telemetry::milliseconds;
fn next_diagnostic_id() -> u64 {
static NEXT: AtomicU64 = AtomicU64::new(1);
NEXT.fetch_add(1, Ordering::Relaxed)
}
#[derive(Default)]
struct CommitPhases {
head_view: Duration,
inline: Duration,
index_maintenance: Duration,
translate: Duration,
stage: Duration,
land: Duration,
index_metrics: IndexMaintenanceMetrics,
}
fn mints_snapshot(operations: &[RowOperation]) -> bool {
operations.iter().any(|operation| {
matches!(
operation,
RowOperation::Insert {
table: TableKind::Snapshot,
..
}
)
})
}
#[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,
},
}
type CommittedRecords = Arc<Vec<read::EntityRecord>>;
type CommittedRecordCache = tokio::sync::Mutex<
HashMap<(read::EntityRecordKind, read::Versions), Arc<tokio::sync::OnceCell<CommittedRecords>>>,
>;
type CommittedSnapshots = tokio::sync::OnceCell<Arc<Vec<proto::SnapshotValue>>>;
type CommittedSchemaVersions = tokio::sync::OnceCell<Arc<Vec<(u64, u64, u64)>>>;
fn corrupt_row(table: TableKind, detail: impl std::fmt::Display) -> Error {
Error::Corruption(format!("staged row for {table:?}: {detail}"))
}
pub struct StagedTransaction {
diagnostic_id: u64,
db_tx: DbTransaction,
ops: Vec<RowOperation>,
committed: CommittedRecordCache,
committed_snapshots: CommittedSnapshots,
committed_schema_versions: CommittedSchemaVersions,
projections: Arc<std::sync::RwLock<ProjectionCache>>,
store: Option<Arc<Store>>,
data_store: Option<DataStore>,
data_prefix: String,
data_reads: Arc<data_file::DataStoreCounters>,
memory: Arc<crate::store::cache::CacheCounters>,
}
impl StagedTransaction {
pub(crate) fn begin(
db_tx: DbTransaction,
projections: Arc<std::sync::RwLock<ProjectionCache>>,
store: Option<Arc<Store>>,
data_store: Option<DataStore>,
data_prefix: String,
data_reads: Arc<data_file::DataStoreCounters>,
memory: Arc<crate::store::cache::CacheCounters>,
) -> Self {
Self {
diagnostic_id: next_diagnostic_id(),
db_tx,
ops: Vec::new(),
committed: tokio::sync::Mutex::new(HashMap::new()),
committed_snapshots: tokio::sync::OnceCell::new(),
committed_schema_versions: tokio::sync::OnceCell::new(),
projections,
store,
data_store,
data_prefix,
data_reads,
memory,
}
}
#[cfg(test)]
pub(crate) fn begin_detached(catalog: &crate::Catalog, db_tx: DbTransaction) -> Self {
Self::begin(
db_tx,
Arc::new(std::sync::RwLock::new(ProjectionCache::empty())),
Some(catalog.store()),
None,
String::new(),
Arc::default(),
Arc::default(),
)
}
#[cfg(test)]
pub(crate) fn begin_detached_on(catalog: &crate::Catalog, db_tx: DbTransaction) -> Self {
Self::begin(
db_tx,
Arc::clone(catalog.projections()),
Some(catalog.store()),
None,
String::new(),
Arc::default(),
catalog.cache_counters(),
)
}
#[cfg(test)]
pub(crate) fn begin_detached_with_store(
catalog: &crate::Catalog,
db_tx: DbTransaction,
data_store: DataStore,
) -> Self {
Self::begin(
db_tx,
Arc::new(std::sync::RwLock::new(ProjectionCache::empty())),
Some(catalog.store()),
Some(data_store),
String::new(),
Arc::default(),
Arc::default(),
)
}
pub fn stage(&mut self, op: RowOperation) {
self.ops.push(op);
}
pub fn tables_with_staged_data_files(&self) -> Vec<TableId> {
let mut tables: Vec<TableId> = self
.ops
.iter()
.filter_map(|op| match op {
RowOperation::Insert {
table: TableKind::DataFile,
cells,
} => decode::decode_data_file(cells).ok(),
_ => None,
})
.map(|file| TableId::new(file.table_id))
.collect();
tables.sort_unstable();
tables.dedup();
tables
}
pub fn rollback(self) {
self.db_tx.rollback();
}
pub async fn visible_snapshots(&self) -> Result<Vec<proto::SnapshotValue>> {
let committed = self.committed_snapshots().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
.iter()
.filter(|s| !deleted.contains(&s.snapshot_id))
.cloned()
.collect())
}
async fn committed_snapshots(&self) -> Result<Arc<Vec<proto::SnapshotValue>>> {
self.committed_snapshots
.get_or_try_init(|| async {
let started = Instant::now();
let records = read::scan_snapshots(ReadHandle::Tx(&self.db_tx)).await?;
debug!(
transaction_id = self.diagnostic_id,
records = records.len(),
elapsed_ms = milliseconds(started.elapsed()),
"scanned committed snapshots for staged transaction"
);
Ok::<_, Error>(Arc::new(records))
})
.await
.cloned()
}
async fn committed_entities(
&self,
kind: read::EntityRecordKind,
versions: read::Versions,
) -> Result<CommittedRecords> {
let cell = {
let mut committed = self.committed.lock().await;
Arc::clone(
committed
.entry((kind, versions))
.or_insert_with(|| Arc::new(tokio::sync::OnceCell::new())),
)
};
let records = cell
.get_or_try_init(|| async {
let started = Instant::now();
let records =
read::scan_entity_kind(ReadHandle::Tx(&self.db_tx), kind, versions).await?;
let history_records = records
.iter()
.filter(|record| {
record
.lifecycle()
.is_some_and(|(_, end_snapshot)| end_snapshot.is_some())
})
.count();
let current_records = records.len().saturating_sub(history_records);
debug!(
transaction_id = self.diagnostic_id,
?kind,
?versions,
current_records,
history_records,
records = records.len(),
elapsed_ms = milliseconds(started.elapsed()),
"scanned committed entities for staged transaction"
);
Ok::<_, Error>(Arc::new(records))
})
.await?;
Ok(Arc::clone(records))
}
async fn versions_for(&self, filter_snapshot: Option<u64>) -> Result<read::Versions> {
if filter_snapshot.is_none() {
return Ok(read::Versions::LiveAndEnded);
}
let head = read::read_head(ReadHandle::Tx(&self.db_tx))
.await?
.map(|head| head.snapshot_id);
Ok(read::versions_for(filter_snapshot, head))
}
async fn committed_rows<T>(
&self,
kind: read::EntityRecordKind,
versions: read::Versions,
extract: impl Fn(&read::EntityRecord) -> Option<T>,
) -> Result<Vec<T>> {
Ok(self
.committed_entities(kind, versions)
.await?
.iter()
.filter_map(extract)
.collect())
}
pub async fn visible_data_files(&self) -> Result<Vec<proto::DataFileValue>> {
self.visible_data_files_live_at(None).await
}
pub async fn visible_data_files_live_at(
&self,
filter_snapshot: Option<u64>,
) -> Result<Vec<proto::DataFileValue>> {
let versions = self.versions_for(filter_snapshot).await?;
let committed = self
.committed_rows(read::EntityRecordKind::File, versions, |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(
read::EntityRecordKind::DeleteFile,
read::Versions::LiveAndEnded,
|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(
read::EntityRecordKind::Column,
read::Versions::LiveAndEnded,
|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(
read::EntityRecordKind::Table,
read::Versions::LiveAndEnded,
|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(
read::EntityRecordKind::FileColumnStats,
read::Versions::LiveAndEnded,
|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(
read::EntityRecordKind::Schema,
read::Versions::LiveAndEnded,
|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(
read::EntityRecordKind::View,
read::Versions::LiveAndEnded,
|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(
read::EntityRecordKind::Partition,
read::Versions::LiveAndEnded,
|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(
read::EntityRecordKind::Sort,
read::Versions::LiveAndEnded,
|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(
read::EntityRecordKind::Macro,
read::Versions::LiveAndEnded,
|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(
read::EntityRecordKind::TableStats,
read::Versions::LiveAndEnded,
|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(
read::EntityRecordKind::TableColumnStats,
read::Versions::LiveAndEnded,
|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(
read::EntityRecordKind::Mapping,
read::Versions::LiveAndEnded,
|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(
read::EntityRecordKind::Tag,
read::Versions::LiveAndEnded,
|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(
read::EntityRecordKind::Option,
read::Versions::LiveAndEnded,
|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 = self.committed_schema_versions().await?.as_ref().clone();
let mut deleted = HashSet::new();
let mut inserted = Vec::new();
for op in &self.ops {
match op {
RowOperation::Insert { table, cells } if *table == TableKind::SchemaVersions => {
inserted.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)?;
deleted.insert((table_id, begin_snapshot));
inserted.retain(|(t, b, _)| !(*t == table_id && *b == begin_snapshot));
}
_ => {}
}
}
records.retain(|(t, b, _)| !deleted.contains(&(*t, *b)));
records.extend(inserted);
Ok(records)
}
async fn committed_schema_versions(&self) -> Result<Arc<Vec<(u64, u64, u64)>>> {
self.committed_schema_versions
.get_or_try_init(|| async {
let started = Instant::now();
let records = read::scan_schema_versions(ReadHandle::Tx(&self.db_tx)).await?;
debug!(
transaction_id = self.diagnostic_id,
records = records.len(),
elapsed_ms = milliseconds(started.elapsed()),
"scanned committed schema versions for staged transaction"
);
Ok::<_, Error>(Arc::new(records))
})
.await
.cloned()
}
pub async fn visible_scheduled_deletions(&self) -> Result<Vec<proto::GcFileValue>> {
let committed = self
.committed_rows(
read::EntityRecordKind::GcFile,
read::Versions::LiveAndEnded,
|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> {
self.commit_reporting()
.await
.map(|report| report.snapshot_id)
}
#[allow(clippy::too_many_lines)]
pub async fn commit_reporting(self) -> Result<CommitReport> {
let started = Instant::now();
let Self {
diagnostic_id,
db_tx,
ops,
committed: _,
committed_snapshots: _,
committed_schema_versions: _,
projections,
store: catalog_store,
data_store,
data_prefix,
data_reads,
memory,
} = self;
let durability = catalog_store
.as_deref()
.map(Store::commit_durability)
.unwrap_or_default();
let staged_rows = ops.len();
let mut uses_inline_chunk_directory = ops
.iter()
.any(|operation| matches!(operation, RowOperation::InlineInsert { .. }));
let mut phases = CommitPhases::default();
let phase_started = Instant::now();
let base = match commit::head_view_for(&db_tx, &projections).await {
Ok(base) => base,
Err(err) => {
db_tx.rollback();
return Err(err);
}
};
phases.head_view = phase_started.elapsed();
let base_ref: &CatalogSnapshot = &base;
let mints_snapshot = mints_snapshot(&ops);
let store = data_store.as_ref();
let read_metrics = Arc::new(data_file::ScopedReadMetrics::reporting_to(data_reads));
let inline = async {
let phase_started = Instant::now();
let (writes, uses_schema_reference) =
translate_inline(&db_tx, &projections, &ops).await?;
Ok::<_, Error>((writes, uses_schema_reference, phase_started.elapsed()))
};
let maintenance = async {
let phase_started = Instant::now();
let entries = stage_index_maintenance(
&db_tx,
base_ref,
&ops,
store,
&data_prefix,
read_metrics,
&projections,
)
.await?;
Ok::<_, Error>((entries, phase_started.elapsed()))
};
let (inline_writes, uses_inline_schema_reference, entries) =
match futures::try_join!(inline, maintenance) {
Ok((
(inline_writes, uses_schema_reference, inline_elapsed),
(entries, maintenance_elapsed),
)) => {
phases.inline = inline_elapsed;
phases.index_maintenance = maintenance_elapsed;
(inline_writes, uses_schema_reference, entries)
}
Err(err) => {
db_tx.rollback();
return Err(err);
}
};
let StagedEntries {
poisoned,
deferred,
bytes: entry_bytes,
locator_writes,
metrics: index_metrics,
} = entries;
phases.index_metrics = index_metrics;
uses_inline_chunk_directory |= !locator_writes.is_empty();
let deferred_indexes = if mints_snapshot {
deferred.iter().copied().map(IndexId::new).collect()
} else {
Vec::new()
};
let phase_started = Instant::now();
let wrote_inline = commit::InlineShapes {
chunk_directory: uses_inline_chunk_directory,
schema_reference: uses_inline_schema_reference,
};
match translate_batch(
base_ref,
&ops,
&poisoned,
&deferred,
mints_snapshot,
wrote_inline,
) {
Ok((result_id, mut writes, translated_head_view, target_format)) => {
phases.translate = phase_started.elapsed();
let phase_started = Instant::now();
writes.extend(locator_writes);
writes.push(commit::head_stamp(result_id, base_ref.batch_seq));
let staged_bytes = match stage_batch(
&db_tx,
&projections,
&mut writes,
inline_writes,
entry_bytes,
target_format,
)
.await
{
Ok(staged) => staged,
Err(err) => {
db_tx.rollback();
return Err(err);
}
};
phases.stage = phase_started.elapsed();
let head_before = proto::HeadValue {
snapshot_id: base_ref.snapshot.snapshot_id,
batch_seq: base_ref.batch_seq,
};
let head_view_update = translated_head_view.map_or_else(
|| commit::HeadViewUpdate::Rebuild(Arc::clone(&base)),
|view| commit::HeadViewUpdate::Prepared(Arc::new(view)),
);
let phase_started = Instant::now();
let landed = commit::commit_batch_off_task(
db_tx,
commit::HeadTransition {
before: head_before.snapshot_id,
after: result_id,
},
writes,
staged_bytes,
head_view_update,
projections,
durability,
)
.await?;
phases.land = phase_started.elapsed();
match landed {
commit::Landed::Committed(commit_timings) => {
let index_entries = phases
.index_metrics
.additions
.saturating_add(phases.index_metrics.deletions);
memory.record_commit(index_entries, staged_bytes.0);
staged_landed(
diagnostic_id,
result_id,
staged_rows,
staged_bytes,
&phases,
commit_timings,
started,
);
Ok(CommitReport {
snapshot_id: SnapshotId::new(result_id),
deferred_indexes,
index_entries,
staged_bytes: staged_bytes.0,
})
}
commit::Landed::LostRace => {
let snapshot = build_snapshot_value(&ops).ok();
Err(staged_lost_race_against(
catalog_store.as_deref(),
head_before,
snapshot.as_ref().map(|s| s.changes_made.as_str()),
result_id,
staged_rows,
)
.await)
}
}
}
Err(err) => {
db_tx.rollback();
Err(err)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CommitReport {
pub snapshot_id: SnapshotId,
pub deferred_indexes: Vec<IndexId>,
pub index_entries: u64,
pub staged_bytes: u64,
}
fn translate_batch(
base: &CatalogSnapshot,
ops: &[RowOperation],
poisoned: &[u64],
deferred: &[u64],
mints_snapshot: bool,
wrote: commit::InlineShapes,
) -> Result<(u64, Vec<commit::StagedWrite>, Option<CatalogSnapshot>, u64)> {
if !mints_snapshot {
return translate_maintenance(base, ops).map(|writes| {
(
base.snapshot.snapshot_id,
writes,
None,
commit::index_format(base).max(commit::inline_format(wrote)),
)
});
}
let Translated {
new_id,
mut writes,
snapshot: snap,
head_view: translated_head_view,
index_format,
} = translate(base, ops, poisoned, deferred)?;
let target_format = index_format.max(commit::inline_format(wrote));
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);
let translated_head_view = translated_head_view.map(|mut view| {
view.batch_seq = base.batch_seq.saturating_add(1);
view
});
Ok((new_id, writes, translated_head_view, target_format))
}
async fn stage_batch(
db_tx: &DbTransaction,
projections: &std::sync::RwLock<ProjectionCache>,
writes: &mut Vec<commit::StagedWrite>,
inline_writes: Vec<commit::StagedWrite>,
entry_bytes: u64,
target_format: u64,
) -> Result<StagedBytes> {
writes.extend(inline_writes);
if let Some(stamp) = commit::format_stamp_to(db_tx, projections, target_format).await? {
writes.push(stamp);
}
let mut staged = commit::stage_writes(db_tx, writes)?;
staged.0 = staged.0.saturating_add(entry_bytes);
Ok(staged)
}
fn staged_landed(
transaction_id: u64,
result_id: u64,
staged_rows: usize,
staged_bytes: StagedBytes,
phases: &CommitPhases,
commit_timings: commit::CommitTimings,
started: Instant,
) {
debug!(
transaction_id,
snapshot = result_id,
staged_rows,
staged_bytes = staged_bytes.0,
head_view_ms = milliseconds(phases.head_view),
inline_ms = milliseconds(phases.inline),
index_maintenance_ms = milliseconds(phases.index_maintenance),
index_delete_derivation_ms = milliseconds(phases.index_metrics.deletion_derivation),
index_add_derivation_ms = milliseconds(phases.index_metrics.addition_derivation),
index_probe_window_ms = milliseconds(phases.index_metrics.probe_window),
index_probe_service_ms = milliseconds(phases.index_metrics.probe_service),
index_stage_ms = milliseconds(phases.index_metrics.staging),
index_encode_ms = milliseconds(phases.index_metrics.scoped_read.encode_duration),
index_parquet_read_ms = milliseconds(phases.index_metrics.scoped_read.range_duration),
index_additions = phases.index_metrics.additions,
index_deletions = phases.index_metrics.deletions,
index_unique_probes = phases.index_metrics.unique_probes,
index_probe_hits = phases.index_metrics.probe_hits,
index_probe_misses = phases.index_metrics.probe_misses,
index_probe_peak_in_flight = phases.index_metrics.probe_peak_in_flight,
index_probes_completed_during_deletions =
phases.index_metrics.probes_completed_during_deletions,
index_metadata_hits = phases.index_metrics.scoped_read.metadata_hits,
index_metadata_misses = phases.index_metrics.scoped_read.metadata_misses,
index_range_fetches = phases.index_metrics.scoped_read.range_fetches,
index_ranges = phases.index_metrics.scoped_read.ranges,
index_range_bytes = phases.index_metrics.scoped_read.range_bytes,
index_files = phases.index_metrics.scoped_read.parquet_files,
index_inline_chunks = phases.index_metrics.scoped_read.inline_chunks,
index_arrow_batches = phases.index_metrics.scoped_read.arrow_batches,
translate_ms = milliseconds(phases.translate),
stage_ms = milliseconds(phases.stage),
land_ms = milliseconds(phases.land),
durable_ms = milliseconds(commit_timings.durable),
projection_ms = milliseconds(commit_timings.projection),
elapsed_ms = milliseconds(started.elapsed()),
"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"
))
}
async fn staged_lost_race_against(
store: Option<&Store>,
head_before: proto::HeadValue,
changes_made: Option<&str>,
result_id: u64,
staged_rows: usize,
) -> Error {
let bare = || staged_lost_race(result_id, staged_rows);
let (Some(db), Some(changes_made)) = (store.and_then(Store::writer_db), changes_made) else {
return bare();
};
let ours = crate::transaction::operations::ChangeSet::parse(changes_made);
let Ok((conflict, snapshot_ids, accounted)) =
commit::conflicting_intervening_change(db, &head_before, &ours).await
else {
return bare();
};
if snapshot_ids.is_empty() || (conflict.is_none() && !accounted) {
return bare();
}
let landed = snapshot_ids
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join(", ");
debug!(
attempted_snapshot = result_id,
staged_rows,
head_before = head_before.snapshot_id,
conflicting_snapshot = conflict,
intervening = landed.as_str(),
"staged commit lost a write-write race; DuckLake re-drives"
);
let detail = match conflict {
Some(snapshot_id) => format!("snapshot {snapshot_id} changed state this one read or wrote"),
None => format!(
"snapshots {landed} landed in between, none of them touching what this one \
read or wrote"
),
};
Error::CommitConflict(format!(
"a concurrent commit changed state this one read or wrote \
(attempted snapshot {result_id}, head was {}): {detail}; \
staged-row commits are never retried internally",
head_before.snapshot_id
))
}
struct Translated {
new_id: u64,
writes: Vec<commit::StagedWrite>,
snapshot: proto::SnapshotValue,
head_view: Option<CatalogSnapshot>,
index_format: u64,
}
fn translate(
base: &CatalogSnapshot,
ops: &[RowOperation],
poisoned: &[u64],
deferred: &[u64],
) -> Result<Translated> {
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)?;
let mut touched = commit::Touched::default();
let phases: [fn(&RowOperation) -> bool; 3] = [
|op| {
!is_inline_op(op)
&& !matches!(
op,
RowOperation::Insert { .. } | RowOperation::UpdateSetBegin { .. }
)
},
|op| matches!(op, RowOperation::Insert { .. }),
|op| matches!(op, RowOperation::UpdateSetBegin { .. }),
];
for phase in phases {
for op in ops.iter().filter(|op| phase(op)) {
apply_op(
base,
&mut state,
op,
new_id,
&mut children,
&mut direct,
&hard_deleted,
&mut touched,
)?;
}
}
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;
touched.touch(EntityKey::Table {
table_id: *table_id,
});
}
}
apply_poison(&mut state, poisoned);
apply_deferred_maintenance(base, &mut state, deferred, new_id);
for index_id in poisoned.iter().chain(deferred) {
for table_id in base.indexes.keys().chain(state.indexes.keys()) {
touched.touch(EntityKey::Index {
table_id: *table_id,
index_id: *index_id,
});
}
}
state.snapshot = snapshot.clone();
let index_format = commit::index_format(&state);
let mut writes = commit::diff_touched(base, &state, new_id, &touched);
let translated_head_view = match commit::finish_translated_head_view(&mut state, &direct) {
Ok(()) => Some(state),
Err(err) => {
debug!(error = %err, "translated head view could not be completed; rebuilding after commit");
None
}
};
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(Translated {
new_id,
writes,
snapshot,
head_view: translated_head_view,
index_format,
})
}
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)?;
let mut touched = commit::Touched::default();
for op in ops {
let row_id_statistics_insert = match op {
RowOperation::Insert {
table: TableKind::FileColumnStats,
cells,
} => file_column_stats_column_id(cells)? == data_file::ROW_ID_FIELD_ID,
_ => false,
};
let allowed = matches!(
op,
RowOperation::Delete { .. }
| RowOperation::Insert {
table: TableKind::FilesScheduledForDeletion | TableKind::Metadata,
..
}
) || row_id_statistics_insert
|| is_inline_op(op);
if !allowed {
return Err(Error::Constraint(
"a staged commit without a ducklake_snapshot insert may only maintain \
unversioned state (maintenance deletes, deletion-schedule inserts, \
reserved row-ID file-stat inserts, and ducklake_metadata writes)"
.to_string(),
));
}
apply_op(
base,
&mut state,
op,
head,
&mut children,
&mut direct,
&hard_deleted,
&mut touched,
)?;
}
let mut writes = commit::diff_touched(base, &state, head, &touched);
writes.extend(direct);
Ok(writes)
}
fn file_column_stats_column_id(cells: &[Cell]) -> Result<u64> {
let mut cursor = Cursor::new(TableKind::FileColumnStats, cells);
let _data_file_id = cursor.u64()?;
let _table_id = cursor.u64()?;
cursor.u64()
}