use std::{
collections::{HashMap, HashSet, VecDeque},
ops::Bound,
sync::{Arc, Mutex},
};
use bytes::Bytes;
use futures::{Stream, StreamExt, TryStreamExt, stream};
use slatedb::DbTransaction;
use tracing::warn;
use crate::{
error::{Error, Result},
store::{
StagedBytes,
handle::{ReadHandle, ScanOrder, ScanShape},
index_encoding::{CanonicalKey, NullOrder, non_null_flag_key},
key::{
IndexKey, IndexKind, Key, encode_index_entry, index_index_prefix,
index_multi_value_prefix, index_value_above, index_value_body, index_value_suffix,
},
},
};
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct StagedIndexEntry {
pub(crate) index_id: u64,
pub(crate) unique: bool,
pub(crate) key: Bytes,
pub(crate) row_id: u64,
pub(crate) delete: bool,
pub(crate) building: bool,
}
fn decode_row_id(bytes: &[u8]) -> Result<u64> {
let array: [u8; 8] = bytes.try_into().map_err(|_| {
Error::Corruption(format!(
"index entry value is {} bytes, expected 8",
bytes.len()
))
})?;
Ok(u64::from_be_bytes(array))
}
const UNIQUENESS_PROBE_CONCURRENCY: usize = 512;
const ADDITION_PREFETCH: usize = 512;
const MAX_INDEX_ENTRIES_PER_COMMIT: usize = 8_000_000;
pub(crate) struct StagedEntries {
pub(crate) poisoned: Vec<u64>,
pub(crate) deferred: Vec<u64>,
pub(crate) bytes: u64,
pub(crate) uses_inline_chunk_directory: bool,
}
struct PendingProbe {
key: Bytes,
row_id: u64,
index_id: u64,
building: bool,
}
enum ProbePlan {
Put { key: Bytes, row_id: Option<u64> },
Probe(PendingProbe),
Noop,
Collision { index_id: u64, building: bool },
Failure(Error),
}
enum ProbeResolution {
Put {
key: Bytes,
row_id: Option<u64>,
},
Probed {
probe: PendingProbe,
present: Option<Bytes>,
},
Noop,
Collision {
index_id: u64,
building: bool,
},
Failure(Error),
}
struct ProbePlanner<'a> {
deleted_unique: &'a HashSet<Bytes>,
claimed: HashMap<Bytes, u64>,
entry_count: usize,
}
impl ProbePlanner<'_> {
fn plan(&mut self, entry: Result<StagedIndexEntry>) -> ProbePlan {
let entry = match entry {
Ok(entry) => entry,
Err(error) => return ProbePlan::Failure(error),
};
self.entry_count = self.entry_count.saturating_add(1);
if self.entry_count > MAX_INDEX_ENTRIES_PER_COMMIT {
return ProbePlan::Failure(oversized_commit(self.entry_count));
}
if entry.delete {
return ProbePlan::Failure(Error::Corruption(
"index put stream contains a deletion".to_owned(),
));
}
if !entry.unique {
return ProbePlan::Put {
key: entry.key,
row_id: None,
};
}
if let Some(&holder) = self.claimed.get(&entry.key) {
return if holder == entry.row_id {
ProbePlan::Noop
} else {
ProbePlan::Collision {
index_id: entry.index_id,
building: entry.building,
}
};
}
self.claimed.insert(entry.key.clone(), entry.row_id);
let probe = PendingProbe {
key: entry.key,
row_id: entry.row_id,
index_id: entry.index_id,
building: entry.building,
};
if self.deleted_unique.contains(&probe.key) {
ProbePlan::Put {
key: probe.key,
row_id: Some(probe.row_id),
}
} else {
ProbePlan::Probe(probe)
}
}
}
fn collision(probe_index_id: u64, building: bool) -> Result<Option<u64>> {
if building {
Ok(Some(probe_index_id))
} else {
Err(unique_violation(probe_index_id))
}
}
async fn resolve_probe_plan(reader: ReadHandle<'_>, plan: ProbePlan) -> ProbeResolution {
match plan {
ProbePlan::Put { key, row_id } => ProbeResolution::Put { key, row_id },
ProbePlan::Probe(probe) => match reader.get(probe.key.clone()).await {
Ok(present) => ProbeResolution::Probed { probe, present },
Err(error) => ProbeResolution::Failure(Error::from(error)),
},
ProbePlan::Noop => ProbeResolution::Noop,
ProbePlan::Collision { index_id, building } => {
ProbeResolution::Collision { index_id, building }
}
ProbePlan::Failure(error) => ProbeResolution::Failure(error),
}
}
fn stage_probe_put(
db_tx: &DbTransaction,
staged: &mut StagedBytes,
key: Bytes,
row_id: Option<u64>,
) -> Result<()> {
let value_bytes = row_id.map_or(0, |_| size_of::<u64>());
staged.add(key.len(), value_bytes);
match row_id {
Some(row_id) => db_tx.put(key, row_id.to_be_bytes()),
None => db_tx.put(key, []),
}
.map_err(Error::from)
}
pub(crate) async fn stage_index_entries(
db_tx: &DbTransaction,
entries: Vec<StagedIndexEntry>,
) -> Result<StagedEntries> {
let entry_count = entries.len();
if entry_count > MAX_INDEX_ENTRIES_PER_COMMIT {
warn!(
staged = entry_count,
limit = MAX_INDEX_ENTRIES_PER_COMMIT,
"refusing an oversized commit"
);
return Err(oversized_commit(entry_count));
}
let (deletes, puts): (Vec<_>, Vec<_>) = entries.into_iter().partition(|entry| entry.delete);
let deletes = stream::iter(deletes.into_iter().map(Ok));
let puts = stream::iter(puts.into_iter().map(Ok));
stage_index_entry_stream(db_tx, deletes, puts, 0).await
}
#[allow(clippy::too_many_lines)]
pub(crate) async fn stage_index_entry_stream<D, S>(
db_tx: &DbTransaction,
deletes: D,
entries: S,
prior_entry_count: usize,
) -> Result<StagedEntries>
where
D: Stream<Item = Result<StagedIndexEntry>>,
S: Stream<Item = Result<StagedIndexEntry>>,
{
let mut staged = StagedBytes::default();
let mut deleted_unique = HashSet::new();
let mut entry_count = prior_entry_count;
let mut deletes = std::pin::pin!(deletes);
let mut entries = std::pin::pin!(entries);
let mut prefetched = VecDeque::with_capacity(ADDITION_PREFETCH);
let mut additions_done = false;
loop {
let deletion = if additions_done || prefetched.len() == ADDITION_PREFETCH {
deletes.next().await
} else {
tokio::select! {
biased;
deletion = deletes.next() => deletion,
addition = entries.next() => if let Some(addition) = addition {
prefetched.push_back(addition?);
continue;
} else {
additions_done = true;
continue;
}
}
};
let Some(entry) = deletion else {
break;
};
let entry = entry?;
entry_count = entry_count.saturating_add(1);
if entry_count > MAX_INDEX_ENTRIES_PER_COMMIT {
return Err(oversized_commit(entry_count));
}
if !entry.delete {
return Err(Error::Corruption(
"index deletion stream contains a put".to_owned(),
));
}
if entry.unique {
deleted_unique.insert(entry.key.clone());
}
staged.add(entry.key.len(), 0);
db_tx.delete(entry.key).map_err(Error::from)?;
}
let entries = stream::iter(prefetched.into_iter().map(Ok)).chain(entries);
let stream_budget = MAX_INDEX_ENTRIES_PER_COMMIT
.saturating_sub(entry_count)
.saturating_add(1);
let planner = Arc::new(Mutex::new(ProbePlanner {
deleted_unique: &deleted_unique,
claimed: HashMap::new(),
entry_count,
}));
let reader = ReadHandle::Tx(db_tx);
let (poisoned, addition_bytes): (Vec<_>, Vec<_>) = entries
.take(stream_budget)
.map(|entry| {
let planner = planner.clone();
async move {
let plan = {
let mut planner_guard =
planner.lock().map_err(|_| Error::ConcurrentModification)?;
planner_guard.plan(entry)
};
let mut addition_bytes = StagedBytes::default();
let poisoned = match resolve_probe_plan(reader, plan).await {
ProbeResolution::Put { key, row_id } => {
stage_probe_put(db_tx, &mut addition_bytes, key, row_id)?;
None
}
ProbeResolution::Probed { probe, present } => {
if let Some(bytes) = present {
match decode_row_id(&bytes) {
Ok(holder) if holder == probe.row_id => None,
Ok(_) => {
return collision(probe.index_id, probe.building)
.map(|index_id| (index_id, addition_bytes));
}
Err(error) => return Err(error),
}
} else {
stage_probe_put(
db_tx,
&mut addition_bytes,
probe.key,
Some(probe.row_id),
)?;
None
}
}
ProbeResolution::Noop => None,
ProbeResolution::Collision { index_id, building } => {
return collision(index_id, building)
.map(|index_id| (index_id, addition_bytes));
}
ProbeResolution::Failure(error) => return Err(error),
};
Ok((poisoned, addition_bytes))
}
})
.buffer_unordered(UNIQUENESS_PROBE_CONCURRENCY)
.try_collect::<Vec<_>>()
.await?
.into_iter()
.unzip();
for bytes in addition_bytes {
staged.merge(bytes);
}
let mut poisoned = poisoned.into_iter().flatten().collect::<Vec<_>>();
poisoned.sort_unstable();
poisoned.dedup();
Ok(StagedEntries {
poisoned,
deferred: Vec::new(),
bytes: staged.0,
uses_inline_chunk_directory: false,
})
}
pub(crate) fn apply_poison(state: &mut crate::catalog::CatalogSnapshot, poisoned: &[u64]) {
for index_id in poisoned {
for per_table in state.indexes.values_mut() {
if let Some(value) = per_table.get_mut(index_id) {
value.poisoned = Some(true);
}
}
}
}
pub(crate) fn apply_deferred_maintenance(
base: &crate::catalog::CatalogSnapshot,
state: &mut crate::catalog::CatalogSnapshot,
deferred: &[u64],
new_snapshot: u64,
) {
for index_id in deferred {
for (table_id, per_table) in &mut state.indexes {
let Some(value) = per_table.get_mut(index_id) else {
continue;
};
if value.build_state.is_none() {
value.begin_snapshot = new_snapshot;
value.build_state = Some("maintaining".to_owned());
value.build_cursor_row_id = base
.table_stats
.get(table_id)
.and_then(|stats| stats.next_row_id.checked_sub(1));
let tail = base
.data_files
.get(table_id)
.and_then(|files| files.values().max_by_key(|file| file.data_file_id));
value.build_cursor_file = Some(tail.map_or(0, |file| file.data_file_id));
value.build_cursor_position =
Some(tail.map_or(0, |file| file.record_count.saturating_sub(1)));
}
}
}
}
fn oversized_commit(staged: usize) -> Error {
let gib = (staged + 512 * 1024) / (1024 * 1024);
Error::Constraint(format!(
"commit stages {staged} equality-index entries, above the \
{MAX_INDEX_ENTRIES_PER_COMMIT} a single commit allows; at about a kilobyte \
apiece in the store's write path it would need roughly {gib} GiB of \
memory. Split the work into several smaller commits."
))
}
fn unique_violation(index_id: u64) -> Error {
Error::Constraint(format!(
"duplicate value violates equality index {index_id}"
))
}
pub(crate) async fn reclaim_entries(
tx: &slatedb::DbTransaction,
index_id: u64,
limit: usize,
staged: &mut StagedBytes,
) -> Result<usize> {
let mut deleted = 0;
for kind in [IndexKind::Unique, IndexKind::Multi] {
if deleted >= limit {
break;
}
let (batch, _) =
reclaim_entries_from(tx, kind, index_id, limit - deleted, None, staged).await?;
deleted += batch;
}
Ok(deleted)
}
pub(crate) async fn reclaim_entries_from(
tx: &slatedb::DbTransaction,
kind: IndexKind,
index_id: u64,
limit: usize,
start_from: Option<&[u8]>,
staged: &mut StagedBytes,
) -> Result<(usize, Option<Vec<u8>>)> {
let prefix = index_index_prefix(kind, index_id);
let suffix = match start_from {
Some(key) if key.len() >= prefix.len() => key[prefix.len()..].to_vec(),
_ => Vec::new(),
};
let mut iter = ReadHandle::Tx(tx)
.scan_prefix(prefix, suffix.., ScanShape::Bulk)
.await?;
let mut deleted = 0;
let mut last = None;
while deleted < limit {
match iter.next().await? {
Some(entry) => {
let key = entry.key.to_vec();
staged.add(key.len(), 0);
tx.delete(entry.key)?;
deleted += 1;
last = Some(key);
}
None => break,
}
}
Ok((deleted, last))
}
pub(crate) async fn lookup_row_ids(
reader: ReadHandle<'_>,
index_id: u64,
unique: bool,
key: &CanonicalKey,
) -> Result<Vec<u64>> {
if unique {
let entry_key = encode_index_entry(index_id, true, key, 0);
return match reader.get(entry_key).await.map_err(Error::from)? {
Some(bytes) => Ok(vec![decode_row_id(&bytes)?]),
None => Ok(Vec::new()),
};
}
let prefix = index_multi_value_prefix(index_id, key);
let mut iter = reader
.scan_prefix(prefix, .., ScanShape::Probe)
.await
.map_err(Error::from)?;
let mut row_ids = Vec::new();
while let Some(entry) = iter.next().await.map_err(Error::from)? {
match Key::decode(&entry.key)? {
Key::Index(IndexKey::Multi { row_id, .. }) => row_ids.push(row_id),
other => {
return Err(Error::Corruption(format!(
"non-multi key in index scan: {other:?}"
)));
}
}
}
Ok(row_ids)
}
pub(crate) async fn range_row_ids(
reader: ReadHandle<'_>,
index_id: u64,
unique: bool,
leading_nulls: NullOrder,
lower: Bound<CanonicalKey>,
upper: Bound<CanonicalKey>,
order: ScanOrder,
) -> Result<Vec<u64>> {
let kind = if unique {
IndexKind::Unique
} else {
IndexKind::Multi
};
let prefix = index_index_prefix(kind, index_id);
let suffix = |canon: &CanonicalKey| index_value_suffix(kind, index_id, canon);
let above = |canon: &CanonicalKey| index_value_above(kind, index_id, canon);
let non_null = non_null_flag_key(leading_nulls);
let past_non_null = above(&non_null).map_or(Bound::Unbounded, Bound::Excluded);
let start = match lower {
Bound::Included(canon) => Bound::Included(suffix(&canon)),
Bound::Excluded(canon) => match above(&canon) {
Some(above) => Bound::Included(above),
None => Bound::Excluded(suffix(&canon)),
},
Bound::Unbounded => Bound::Included(index_value_body(kind, index_id, &non_null)),
};
let end = match upper {
Bound::Included(canon) => match above(&canon) {
Some(above) => Bound::Excluded(above),
None => past_non_null,
},
Bound::Excluded(canon) => Bound::Excluded(suffix(&canon)),
Bound::Unbounded => past_non_null,
};
let mut iter = reader
.scan_prefix_ordered(prefix, (start, end), ScanShape::Probe, order)
.await
.map_err(Error::from)?;
let mut row_ids = Vec::new();
while let Some(entry) = iter.next().await.map_err(Error::from)? {
if unique {
row_ids.push(decode_row_id(entry.value.as_ref())?);
} else {
match Key::decode(&entry.key)? {
Key::Index(IndexKey::Multi { row_id, .. }) => row_ids.push(row_id),
other => {
return Err(Error::Corruption(format!(
"non-multi key in index range scan: {other:?}"
)));
}
}
}
}
Ok(row_ids)
}
pub(crate) async fn null_prefix_row_ids(
reader: ReadHandle<'_>,
index_id: u64,
prefix: &CanonicalKey,
order: ScanOrder,
) -> Result<Vec<u64>> {
let mut scan_prefix = index_multi_value_prefix(index_id, prefix);
scan_prefix.pop();
let mut iter = reader
.scan_prefix_ordered(scan_prefix, .., ScanShape::Probe, order)
.await
.map_err(Error::from)?;
let mut row_ids = Vec::new();
while let Some(entry) = iter.next().await.map_err(Error::from)? {
match Key::decode(&entry.key)? {
Key::Index(IndexKey::Multi { row_id, .. }) => row_ids.push(row_id),
other => {
return Err(Error::Corruption(format!(
"non-multi key in null-prefix scan: {other:?}"
)));
}
}
}
Ok(row_ids)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use object_store::memory::InMemory;
use slatedb::IsolationLevel;
use super::*;
use crate::store::open::StoreBuilder;
#[test]
fn normal_flush_unique_probes_share_one_window() {
const FACTS_AND_REFERENCES: usize = 258;
let concurrency = std::hint::black_box(UNIQUENESS_PROBE_CONCURRENCY);
assert!(
concurrency >= FACTS_AND_REFERENCES,
"{FACTS_AND_REFERENCES} probes exceed the concurrency window of \
{concurrency}"
);
}
#[tokio::test]
async fn unique_probe_stream_crosses_the_old_group_boundary() {
const ENTRIES: usize = 1_025;
let (db, _) = StoreBuilder::new("continuous-probes", Arc::new(InMemory::new()))
.open_writer()
.await
.unwrap();
let tx = db.begin(IsolationLevel::Snapshot).await.unwrap();
let entries = stream::iter((0..ENTRIES).map(|position| {
let row_id = u64::try_from(position).unwrap();
Ok(StagedIndexEntry {
index_id: 1,
unique: true,
key: Bytes::copy_from_slice(&row_id.to_be_bytes()),
row_id,
delete: false,
building: false,
})
}));
let staged = stage_index_entry_stream(&tx, stream::empty(), entries, 0)
.await
.unwrap();
assert_eq!(staged.bytes, u64::try_from(ENTRIES * 16).unwrap());
tx.rollback();
db.close().await.unwrap();
}
#[tokio::test]
async fn addition_prefetch_preserves_the_deletion_phase_boundary() {
let (db, _) = StoreBuilder::new("addition-prefetch", Arc::new(InMemory::new()))
.open_writer()
.await
.unwrap();
let tx = db.begin(IsolationLevel::Snapshot).await.unwrap();
let key = Bytes::from_static(b"delete-before-add");
let (release_deletion, deletion_released) = tokio::sync::oneshot::channel();
let deletion_key = key.clone();
let deletions = stream::once(async move {
deletion_released.await.unwrap();
Ok(StagedIndexEntry {
index_id: 1,
unique: true,
key: deletion_key,
row_id: 7,
delete: true,
building: false,
})
});
let (addition_polled, observed_addition) = tokio::sync::oneshot::channel();
let addition_key = key.clone();
let additions = stream::once(async move {
addition_polled.send(()).unwrap();
Ok(StagedIndexEntry {
index_id: 1,
unique: true,
key: addition_key,
row_id: 7,
delete: false,
building: false,
})
});
{
let staging = stage_index_entry_stream(&tx, deletions, additions, 0);
let mut staging = std::pin::pin!(staging);
tokio::select! {
_ = &mut staging => panic!("staging finished before deletion release"),
observed = observed_addition => observed.unwrap(),
}
release_deletion.send(()).unwrap();
staging.as_mut().await.unwrap();
}
assert_eq!(
tx.get(&key).await.unwrap(),
Some(Bytes::copy_from_slice(&7_u64.to_be_bytes()))
);
tx.rollback();
db.close().await.unwrap();
}
#[test]
fn oversized_commit_refusal_avoids_ducklake_retry_substrings() {
let text = oversized_commit(13_400_000).to_string();
for substring in ["conflict", "concurrent", "unique", "primary key"] {
assert!(
!text.contains(substring),
"{text:?} contains DuckLake's retry substring {substring:?}"
);
}
}
#[test]
fn oversized_commit_refusal_is_actionable() {
let text = oversized_commit(13_400_000).to_string();
assert!(text.contains("13400000"), "names the count: {text}");
assert!(text.contains("8000000"), "names the limit: {text}");
assert!(text.contains("13 GiB"), "names the memory: {text}");
assert!(text.contains("smaller commits"), "names the remedy: {text}");
}
}