use std::{
collections::{HashMap, HashSet},
ops::Bound,
};
use bytes::Bytes;
use futures::{StreamExt, TryStreamExt, stream};
use slatedb::DbTransaction;
use tracing::warn;
use crate::{
error::{Error, Result},
store::{
handle::{ReadHandle, ScanShape},
index_encoding::{CanonicalKey, NullOrder, non_null_flag_key},
key::{
IndexKey, IndexKind, Key, 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: CanonicalKey,
pub(crate) row_id: u64,
pub(crate) delete: bool,
pub(crate) building: bool,
}
fn entry_key(entry: &StagedIndexEntry) -> Key {
if entry.unique {
Key::Index(IndexKey::Unique {
index_id: entry.index_id,
key: entry.key.clone(),
})
} else {
Key::Index(IndexKey::Multi {
index_id: entry.index_id,
key: entry.key.clone(),
row_id: entry.row_id,
})
}
}
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 = 64;
const UNIQUENESS_PROBE_GROUP: usize = 1024;
const MAX_INDEX_ENTRIES_PER_COMMIT: usize = 8_000_000;
struct PendingProbe {
key: Vec<u8>,
row_id: u64,
index_id: u64,
building: bool,
}
fn collision(probe_index_id: u64, building: bool, poisoned: &mut Vec<u64>) -> Option<Error> {
if building {
poisoned.push(probe_index_id);
None
} else {
Some(unique_violation(probe_index_id))
}
}
async fn resolve_probes(
db_tx: &DbTransaction,
deleted_unique: &HashSet<Vec<u8>>,
pending: &mut Vec<PendingProbe>,
poisoned: &mut Vec<u64>,
) -> Result<()> {
let reader = ReadHandle::Tx(db_tx);
let held: Vec<Option<Bytes>> = stream::iter(0..pending.len())
.map(|position| {
let key_bytes = pending[position].key.clone();
let deleted = deleted_unique.contains(&key_bytes);
async move {
if deleted {
Ok(None)
} else {
reader.get(key_bytes).await
}
}
})
.buffered(UNIQUENESS_PROBE_CONCURRENCY)
.try_collect()
.await
.map_err(Error::from)?;
for (probe, present) in pending.drain(..).zip(held) {
if let Some(bytes) = present {
if decode_row_id(&bytes)? != probe.row_id {
match collision(probe.index_id, probe.building, poisoned) {
Some(error) => return Err(error),
None => continue,
}
}
continue;
}
db_tx.put(probe.key, probe.row_id.to_be_bytes())?;
}
Ok(())
}
pub(crate) async fn stage_index_entries(
db_tx: &DbTransaction,
entries: &[StagedIndexEntry],
) -> Result<Vec<u64>> {
if entries.len() > MAX_INDEX_ENTRIES_PER_COMMIT {
warn!(
staged = entries.len(),
limit = MAX_INDEX_ENTRIES_PER_COMMIT,
"refusing an oversized commit"
);
return Err(oversized_commit(entries.len()));
}
let mut deleted_unique: HashSet<Vec<u8>> = HashSet::new();
for entry in entries.iter().filter(|entry| entry.delete) {
let key_bytes = entry_key(entry).encode();
if entry.unique {
deleted_unique.insert(key_bytes.clone());
}
db_tx.delete(key_bytes)?;
}
let mut pending: Vec<PendingProbe> = Vec::new();
let mut claimed: HashMap<Vec<u8>, u64> = HashMap::new();
let mut poisoned: Vec<u64> = Vec::new();
for entry in entries.iter().filter(|entry| !entry.delete) {
let key_bytes = entry_key(entry).encode();
if !entry.unique {
db_tx.put(key_bytes, [])?;
continue;
}
match claimed.get(&key_bytes) {
Some(&holder) if holder != entry.row_id => {
match collision(entry.index_id, entry.building, &mut poisoned) {
Some(error) => return Err(error),
None => continue,
}
}
Some(_) => continue,
None => claimed.insert(key_bytes.clone(), entry.row_id),
};
pending.push(PendingProbe {
key: key_bytes,
row_id: entry.row_id,
index_id: entry.index_id,
building: entry.building,
});
if pending.len() >= UNIQUENESS_PROBE_GROUP {
resolve_probes(db_tx, &deleted_unique, &mut pending, &mut poisoned).await?;
}
}
resolve_probes(db_tx, &deleted_unique, &mut pending, &mut poisoned).await?;
poisoned.sort_unstable();
poisoned.dedup();
Ok(poisoned)
}
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);
}
}
}
}
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,
) -> 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).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]>,
) -> 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();
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 = Key::Index(IndexKey::Unique {
index_id,
key: key.clone(),
})
.encode();
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>,
) -> 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(prefix, (start, end), ScanShape::Probe)
.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,
) -> Result<Vec<u64>> {
let mut scan_prefix = index_multi_value_prefix(index_id, prefix);
scan_prefix.pop();
let mut iter = reader
.scan_prefix(scan_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 null-prefix scan: {other:?}"
)));
}
}
}
Ok(row_ids)
}
#[cfg(test)]
mod tests {
use super::*;
#[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}");
}
}