use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
time::{Duration, Instant},
};
use futures::{StreamExt, TryStreamExt, stream};
use slatedb::{Db, DbReader, DbTransaction, IsolationLevel, config::WriteOptions};
use tracing::{debug, info, warn};
use uuid::Uuid;
use crate::{
catalog::{
CatalogSnapshot, SnapshotId, Timestamp,
projection::{
ProjectionCache, cache_epoch, cached_head_view, fold_committed_batch, held_head_view,
install_head_view, install_head_view_at, invalidate_head_view,
},
},
error::{Error, Result},
store::{
StagedBytes,
cache::CacheCounters,
handle::ReadHandle,
key::{EntityKey, Key, SysKey},
open::StoreBuilder,
proto,
read::{self, EntityRecord},
value,
},
transaction::{
index_maintenance, inline,
operations::{ChangeSet, Operation},
verbs::{Transaction, TransactionParts},
},
};
pub(crate) const FORMAT_VERSION: u64 = 1;
pub(crate) const FORMAT_WITH_INDEX: u64 = 2;
pub(crate) const FORMAT_WITH_STAGED_INDEX: u64 = 3;
pub(crate) const FORMAT_WITH_DEFERRED_INDEX: u64 = 4;
pub(crate) const FORMAT_WITH_MAINTENANCE_STATUS: u64 = 5;
pub(crate) const FORMAT_WITH_INLINE_CHUNK_DIRECTORY: u64 = 6;
pub(crate) const MAX_FORMAT_VERSION: u64 = FORMAT_WITH_INLINE_CHUNK_DIRECTORY;
pub(crate) const MIN_FORMAT_VERSION: u64 = FORMAT_VERSION;
pub(crate) const MAX_COMMIT_ATTEMPTS: usize = 10;
const RETRY_BACKOFF_BASE_MICROS: u64 = 2_000;
const RETRY_BACKOFF_MAX_MICROS: u64 = 50_000;
const INTERVENING_READ_CONCURRENCY: usize = 64;
const REFRESH_READ_CONCURRENCY: usize = 64;
pub(crate) fn retry_backoff(attempt: usize) -> Duration {
if attempt == 0 {
return Duration::ZERO;
}
let doublings = u32::try_from(attempt - 1).unwrap_or(u32::MAX).min(31);
let step = RETRY_BACKOFF_BASE_MICROS
.saturating_mul(1_u64 << doublings)
.min(RETRY_BACKOFF_MAX_MICROS);
let jitter = now_micros().unsigned_abs() % RETRY_BACKOFF_BASE_MICROS;
Duration::from_micros(step.saturating_add(jitter))
}
pub(crate) fn now_micros() -> i64 {
Timestamp::now().as_micros()
}
pub(crate) fn durable() -> WriteOptions {
WriteOptions {
await_durable: true,
..Default::default()
}
}
const STALL_INTERVAL: Duration = Duration::from_secs(10);
pub(crate) async fn commit_durable(
tx: DbTransaction,
operation: &'static str,
staged: StagedBytes,
) -> std::result::Result<Option<slatedb::WriteHandle>, slatedb::Error> {
let options = durable();
let mut commit = Box::pin(tx.commit_with_options(&options));
let mut waited = Duration::ZERO;
loop {
if let Ok(outcome) = tokio::time::timeout(STALL_INTERVAL, &mut commit).await {
return outcome;
}
waited = waited.saturating_add(STALL_INTERVAL);
warn!(
operation,
waited_seconds = waited.as_secs(),
staged_bytes = staged.0,
"still waiting for object storage to accept a durable write; the batch goes as one \
request and is retried indefinitely, so it will not fail on its own — check that a \
batch this size fits the store client's request timeout, then credentials and \
bucket policy"
);
}
}
pub(crate) fn non_durable() -> WriteOptions {
WriteOptions {
await_durable: false,
..Default::default()
}
}
async fn validate_format(tx: ReadHandle<'_>) -> Result<Option<proto::FormatValue>> {
if read::read_migration(tx).await?.is_some() {
return Err(Error::Migration(
"store is mid-migration; refusing to open — Catalog::migrate resumes it from the \
durable cursor, and takes a store path rather than an open catalog, so it runs \
against a store no attach will touch"
.to_string(),
));
}
match read::read_format(tx).await? {
Some(format) if format.format_version > MAX_FORMAT_VERSION => {
Err(Error::Migration(format!(
"store format {} is newer than this binary understands (max {MAX_FORMAT_VERSION}); \
upgrade the binary",
format.format_version
)))
}
Some(format) if format.format_version < MIN_FORMAT_VERSION => {
Err(Error::Migration(format!(
"store format {} predates this binary's minimum ({MIN_FORMAT_VERSION}); \
migrate it up with Catalog::migrate, which takes a store path rather than an \
open catalog and so runs on this same binary against the store it refuses to \
open",
format.format_version
)))
}
Some(format) => Ok(Some(format)),
None => Ok(None),
}
}
fn stage_bootstrap(
tx: &DbTransaction,
encrypted: bool,
data_path: Option<&str>,
) -> Result<StagedBytes> {
let staged = std::cell::Cell::new(StagedBytes::default());
let stage = |key: Key, bytes: Vec<u8>| {
let key = key.encode();
let mut running = staged.get();
running.add(key.len(), bytes.len());
staged.set(running);
tx.put(key, bytes).map_err(Error::from)
};
stage(
Key::Sys(SysKey::Format),
value::encode_value(&proto::FormatValue {
format_version: FORMAT_VERSION,
writer_version: env!("CARGO_PKG_VERSION").to_string(),
}),
)?;
let mut bootstrap_changes = ChangeSet::default();
bootstrap_changes.created_schemas.insert("main".to_string());
stage(
Key::Snapshot { snapshot_id: 0 },
value::encode_value(&proto::SnapshotValue {
snapshot_id: 0,
snapshot_time_micros: now_micros(),
schema_version: 0,
next_catalog_id: 1,
next_file_id: 0,
changes_made: bootstrap_changes.to_changes_made(),
author: None,
commit_message: None,
commit_extra_info: None,
schema_changed_table_ids: Vec::new(),
deleted_data_file_ids: Vec::new(),
}),
)?;
stage(
Key::current(EntityKey::Schema { schema_id: 0 }),
value::encode_value(&proto::SchemaValue {
schema_id: 0,
schema_uuid: Uuid::new_v4().to_string(),
begin_snapshot: 0,
end_snapshot: None,
schema_name: "main".to_string(),
path: "main/".to_string(),
path_is_relative: true,
}),
)?;
let mut options = std::collections::HashMap::from([(
"encrypted".to_string(),
if encrypted { "true" } else { "false" }.to_string(),
)]);
if let Some(path) = data_path {
options.insert("data_path".to_string(), path.to_string());
}
stage(
Key::current(EntityKey::Option {
scope_kind: 0,
scope_id: 0,
}),
value::encode_value(&proto::OptionScopeValue { options }),
)?;
stage(
Key::Sys(SysKey::Head),
value::encode_value(&proto::HeadValue {
snapshot_id: 0,
batch_seq: 0,
}),
)?;
Ok(staged.get())
}
const GENESIS_ATTEMPTS: u32 = 3;
enum OpenFailure {
FencedAtGenesis(Error),
Fatal(Error),
}
pub(crate) async fn open_initialized(
store: StoreBuilder<'_>,
encrypted: bool,
data_path: Option<&str>,
) -> Result<(Db, Arc<CacheCounters>)> {
let mut attempt = 1;
loop {
match open_attempt(&store, encrypted, data_path).await {
Ok(opened) => return Ok(opened),
Err(OpenFailure::Fatal(err)) => return Err(err),
Err(OpenFailure::FencedAtGenesis(err)) => {
if attempt == GENESIS_ATTEMPTS {
return Err(err);
}
warn!(
attempt,
"another writer took the store over while this open was creating the \
catalog; nothing was written, so this open re-attempts"
);
attempt += 1;
}
}
}
}
async fn open_attempt(
store: &StoreBuilder<'_>,
encrypted: bool,
data_path: Option<&str>,
) -> std::result::Result<(Db, Arc<CacheCounters>), OpenFailure> {
let started = Instant::now();
let (db, counters) = store.open_writer().await.map_err(OpenFailure::Fatal)?;
info!(
writer_open_ms = started.elapsed().as_secs_f64() * 1_000.0,
"opened the store read-write"
);
let tx = begin_snapshot(&db).await?;
match validate_format(ReadHandle::Tx(&tx)).await {
Ok(Some(_)) => {
tx.rollback();
return Ok((db, counters));
}
Ok(None) => {}
Err(err) => {
tx.rollback();
return Err(OpenFailure::Fatal(err));
}
}
let staged = match stage_bootstrap(&tx, encrypted, data_path) {
Ok(staged) => staged,
Err(err) => {
tx.rollback();
return Err(OpenFailure::Fatal(err));
}
};
match commit_durable(tx, "bootstrap", staged).await {
Ok(_) => {
info!(encrypted, data_path, "bootstrapped a fresh catalog store");
Ok((db, counters))
}
Err(err) if err.kind() == slatedb::ErrorKind::Transaction => {
let tx = begin_snapshot(&db).await?;
let validated = validate_format(ReadHandle::Tx(&tx)).await;
tx.rollback();
match validated {
Ok(Some(_)) => Ok((db, counters)),
Ok(None) => Err(OpenFailure::Fatal(Error::Corruption(
"bootstrap race left the store uninitialized".to_string(),
))),
Err(err) => Err(OpenFailure::Fatal(err)),
}
}
Err(err) if err.kind() == slatedb::ErrorKind::Closed(slatedb::CloseReason::Fenced) => {
Err(OpenFailure::FencedAtGenesis(err.into()))
}
Err(err) => Err(OpenFailure::Fatal(err.into())),
}
}
async fn begin_snapshot(db: &Db) -> std::result::Result<DbTransaction, OpenFailure> {
db.begin(IsolationLevel::Snapshot)
.await
.map_err(|err| OpenFailure::Fatal(Error::from(err)))
}
pub(crate) async fn open_reader_initialized(
store: StoreBuilder<'_>,
) -> Result<(DbReader, Arc<CacheCounters>)> {
let started = Instant::now();
let (reader, counters) = store.open_reader().await?;
let opened = started.elapsed();
let started = Instant::now();
let format = validate_format(ReadHandle::Reader(&reader)).await?;
info!(
reader_open_ms = opened.as_secs_f64() * 1_000.0,
validate_ms = started.elapsed().as_secs_f64() * 1_000.0,
"opened the store read-only"
);
match format {
Some(_) => Ok((reader, counters)),
None => Err(Error::Corruption(
"store is not an initialized moraine catalog; a read-only attach \
needs a writer to have created it first"
.to_string(),
)),
}
}
pub(crate) async fn refuse_mid_migration(tx: ReadHandle<'_>) -> Result<()> {
match read::read_migration(tx).await? {
Some(marker) => Err(Error::Migration(format!(
"store is migrating from format {} to {}; reads are unavailable until it completes",
marker.from_format, marker.to_format
))),
None => Ok(()),
}
}
pub(crate) async fn read_head_value(tx: ReadHandle<'_>) -> Result<proto::HeadValue> {
read::read_head(tx)
.await?
.ok_or_else(|| Error::Corruption("store has no head pointer".to_string()))
}
pub(crate) async fn read_head_id(tx: ReadHandle<'_>) -> Result<u64> {
Ok(read_head_value(tx).await?.snapshot_id)
}
pub(crate) async fn head_write(db_tx: &DbTransaction, snapshot_id: u64) -> Result<StagedWrite> {
let standing = read_head_value(ReadHandle::Tx(db_tx)).await?;
Ok((
Key::Sys(SysKey::Head).encode(),
Some(value::encode_value(&proto::HeadValue {
snapshot_id,
batch_seq: standing.batch_seq.saturating_add(1),
})),
))
}
pub(crate) async fn resolve_read_snapshot(
tx: ReadHandle<'_>,
at: Option<u64>,
) -> Result<(u64, proto::SnapshotValue)> {
let head = read_head_id(tx).await?;
resolve_below(tx, at, head).await
}
async fn resolve_below(
tx: ReadHandle<'_>,
at: Option<u64>,
head: u64,
) -> Result<(u64, proto::SnapshotValue)> {
let target = match at {
Some(requested) if requested > head => {
return Err(Error::NotFound(format!(
"snapshot {requested} (head is {head})"
)));
}
Some(requested) => requested,
None => head,
};
let snapshot = read::read_snapshot(tx, target).await?.ok_or_else(|| {
Error::SnapshotExpired(format!(
"snapshot {target} is below the retention horizon (head is {head}); \
re-resolve from head"
))
})?;
Ok((target, snapshot))
}
pub(crate) async fn materialize(tx: ReadHandle<'_>, at: Option<u64>) -> Result<CatalogSnapshot> {
match at {
None => Ok(materialize_capturing(tx).await?.0),
Some(_) => {
read::consistent(tx, || async move {
refuse_mid_migration(tx).await?;
let head = read_head_value(tx).await?;
let (target, snapshot) = resolve_below(tx, at, head.snapshot_id).await?;
let current = read::scan_current_entities(tx).await?;
let history = read::scan_history_entities(tx).await?;
Ok(CatalogSnapshot::build(
snapshot,
¤t,
&history,
Some(target),
))
})
.await
}
}
}
pub(crate) async fn materialize_capturing(
tx: ReadHandle<'_>,
) -> Result<(CatalogSnapshot, Arc<Vec<EntityRecord>>)> {
read::consistent(tx, || async move {
refuse_mid_migration(tx).await?;
let head = read_head_value(tx).await?;
let (_, snapshot) = resolve_below(tx, None, head.snapshot_id).await?;
let started = Instant::now();
let current = read::scan_current_entities(tx).await?;
let scanned = started.elapsed();
info!(
records = current.len(),
scan_ms = scanned.as_secs_f64() * 1_000.0,
"scanned `current`"
);
let mut view = CatalogSnapshot::build(snapshot, ¤t, &[], None);
view.batch_seq = head.batch_seq;
Ok((view, Arc::new(current)))
})
.await
}
pub(crate) async fn materialize_from(
tx: ReadHandle<'_>,
expected: &proto::HeadValue,
current: &[EntityRecord],
) -> Result<Option<CatalogSnapshot>> {
read::consistent(tx, || async move {
refuse_mid_migration(tx).await?;
let head = read_head_value(tx).await?;
if head.snapshot_id != expected.snapshot_id || head.batch_seq != expected.batch_seq {
return Ok(None);
}
let (_, snapshot) = resolve_below(tx, None, head.snapshot_id).await?;
let mut view = CatalogSnapshot::build(snapshot, current, &[], None);
view.batch_seq = head.batch_seq;
Ok(Some(view))
})
.await
}
const MAX_REFRESH_KEYS: usize = 256;
const REFRESH_CHURN_SHARE: usize = 2;
fn refresh_keys_of(writes: &[StagedWrite]) -> (Vec<Vec<u8>>, bool) {
let mut keys = Vec::new();
for (encoded, _) in writes {
match Key::decode(encoded) {
Ok(Key::Current(_)) => keys.push(encoded.clone()),
Ok(_) => {}
Err(_) => return (Vec::new(), false),
}
}
keys.sort_unstable();
keys.dedup();
if keys.len() > MAX_REFRESH_KEYS {
return (Vec::new(), false);
}
(keys, true)
}
const CHANGELOG_WINDOW: u64 = 64;
pub(crate) fn changelog_writes(snapshot_id: u64, writes: &[StagedWrite]) -> Vec<StagedWrite> {
let (keys, complete) = refresh_keys_of(writes);
let mut out = Vec::with_capacity(2);
if complete {
out.push((
Key::Changelog { snapshot_id }.encode(),
Some(value::encode_value(&proto::ChangelogValue { keys })),
));
}
if let Some(expired) = snapshot_id.checked_sub(CHANGELOG_WINDOW) {
out.push((
Key::Changelog {
snapshot_id: expired,
}
.encode(),
None,
));
}
out
}
pub(crate) async fn refresh(
tx: ReadHandle<'_>,
base: &CatalogSnapshot,
) -> Result<Option<CatalogSnapshot>> {
read::consistent(tx, || async move {
let head = read_head_value(tx).await?;
let churn_limit = base.live_entity_count() / REFRESH_CHURN_SHARE;
replay(tx, base, &head, churn_limit).await
})
.await
}
async fn replay(
tx: ReadHandle<'_>,
base: &CatalogSnapshot,
head: &proto::HeadValue,
churn_limit: usize,
) -> Result<Option<CatalogSnapshot>> {
let from = base.snapshot.snapshot_id;
let Some(minted) = head.snapshot_id.checked_sub(from).filter(|gap| *gap > 0) else {
return Ok(None);
};
if head.batch_seq.saturating_sub(base.batch_seq) != minted {
return Ok(None);
}
let changelogs = stream::iter(
((from + 1)..=head.snapshot_id).map(|snapshot_id| read::read_changelog(tx, snapshot_id)),
)
.buffer_unordered(REFRESH_READ_CONCURRENCY);
let mut changelogs = std::pin::pin!(changelogs);
let mut keys: BTreeSet<Vec<u8>> = BTreeSet::new();
while let Some(changelog) = changelogs.try_next().await? {
let Some(changelog) = changelog else {
return Ok(None);
};
keys.extend(changelog.keys);
if keys.len() > churn_limit {
return Ok(None);
}
}
let Some(latest) = read::read_snapshot(tx, head.snapshot_id).await? else {
return Ok(None);
};
let mut writes: Vec<StagedWrite> = stream::iter(keys.into_iter().map(|key| async move {
let value = tx.get(&key).await.map_err(Error::from)?;
Ok::<_, Error>((key, value.map(|bytes| bytes.to_vec())))
}))
.buffer_unordered(REFRESH_READ_CONCURRENCY)
.try_collect()
.await?;
writes.sort_unstable_by(|left, right| left.0.cmp(&right.0));
let mut view = base.clone();
fold::fold_batch(&mut view, &writes)?;
view.snapshot = latest;
view.batch_seq = head.batch_seq;
Ok(Some(view))
}
pub(crate) type StagedWrite = (Vec<u8>, Option<Vec<u8>>);
mod diff;
mod fold;
mod group;
use diff::diff_options;
pub(crate) use diff::diff_writes;
pub(crate) use group::Coalescer;
use group::Outcome;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Landed {
Committed(CommitTimings),
LostRace,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct CommitTimings {
pub(crate) durable: Duration,
pub(crate) projection: Duration,
}
enum CommitOutcome {
Committed(Vec<SnapshotId>),
LostRace {
ours: Vec<ChangeSet>,
head_before: u64,
},
Nothing,
}
async fn attempt_group<F>(
db: &Db,
members: &[F],
coalescer: &Arc<Coalescer>,
) -> Result<CommitOutcome>
where
F: Fn(&mut Transaction) -> Result<()>,
{
let staged = coalescer.stage(db, members).await?;
if !staged.contributed {
return Ok(CommitOutcome::Committed(staged.ids));
}
match group::await_outcome(staged.outcome).await {
Outcome::Committed => Ok(CommitOutcome::Committed(staged.ids)),
Outcome::LostRace => Ok(CommitOutcome::LostRace {
ours: staged.ours,
head_before: staged.head_before,
}),
Outcome::Nothing => Ok(CommitOutcome::Nothing),
}
}
pub(crate) async fn head_view_for(
db_tx: &DbTransaction,
projections: &std::sync::RwLock<ProjectionCache>,
) -> Result<Arc<CatalogSnapshot>> {
let epoch = cache_epoch(projections);
let handle = ReadHandle::Tx(db_tx);
refuse_mid_migration(handle).await?;
let head = read_head_value(handle).await?;
if let Some(view) = cached_head_view(projections, &head) {
return Ok(view);
}
let view = match held_head_view(projections) {
Some(behind) => refresh(handle, &behind).await?,
None => None,
};
let view = match view {
Some(refreshed) => Arc::new(refreshed),
None => Arc::new(materialize(handle, None).await?),
};
install_head_view_at(projections, epoch, Arc::clone(&view));
Ok(view)
}
pub(crate) fn refresh_head_view(
projections: &std::sync::RwLock<ProjectionCache>,
base: &CatalogSnapshot,
writes: &[StagedWrite],
) {
let mut view = base.clone();
match fold::fold_batch(&mut view, writes) {
Ok(()) => install_head_view(projections, Arc::new(view)),
Err(err) => {
debug!(error = %err, "head view fold failed; clearing the cached view");
invalidate_head_view(projections);
}
}
}
enum Prepared {
Nothing {
head: u64,
},
Staged {
ours: Box<ChangeSet>,
commits: u64,
writes: Vec<StagedWrite>,
staged_bytes: StagedBytes,
},
}
fn target_format(state: &CatalogSnapshot, uses_inline_chunk_directory: bool) -> u64 {
let index_format = if state
.indexes
.values()
.flat_map(BTreeMap::values)
.any(|index| index.deferred_maintenance == Some(true))
{
FORMAT_WITH_DEFERRED_INDEX
} else if state
.indexes
.values()
.flat_map(BTreeMap::values)
.any(|index| index.build_state.is_some())
{
FORMAT_WITH_STAGED_INDEX
} else if state
.indexes
.values()
.any(|per_table| !per_table.is_empty())
{
FORMAT_WITH_INDEX
} else {
FORMAT_VERSION
};
if uses_inline_chunk_directory {
index_format.max(FORMAT_WITH_INLINE_CHUNK_DIRECTORY)
} else {
index_format
}
}
async fn format_stamp(
db_tx: &DbTransaction,
state: &CatalogSnapshot,
uses_inline_chunk_directory: bool,
) -> Result<Option<StagedWrite>> {
format_stamp_to(db_tx, target_format(state, uses_inline_chunk_directory)).await
}
pub(crate) async fn format_stamp_to(
db_tx: &DbTransaction,
target_format: u64,
) -> Result<Option<StagedWrite>> {
if target_format <= FORMAT_VERSION {
return Ok(None);
}
let current = read::read_format(ReadHandle::Tx(db_tx))
.await?
.map_or(FORMAT_VERSION, |format| format.format_version);
if current >= target_format {
return Ok(None);
}
info!(
from = current,
to = target_format,
"upgrading the store format stamp"
);
Ok(Some((
Key::Sys(SysKey::Format).encode(),
Some(value::encode_value(&proto::FormatValue {
format_version: target_format,
writer_version: env!("CARGO_PKG_VERSION").to_string(),
})),
)))
}
pub(crate) fn schema_version_write(
table_id: u64,
begin_snapshot: u64,
schema_version: u64,
) -> StagedWrite {
(
Key::SchemaVersion {
table_id,
begin_snapshot,
}
.encode(),
Some(value::encode_value(&proto::SchemaVersionValue {
schema_version,
})),
)
}
async fn prepare_and_stage<F>(
db_tx: &DbTransaction,
f: &F,
base: &CatalogSnapshot,
) -> Result<Prepared>
where
F: Fn(&mut Transaction) -> Result<()>,
{
let head = base.snapshot.snapshot_id;
let new_id = head + 1;
let mut tx = Transaction::new(base.clone(), new_id);
f(&mut tx)?;
let TransactionParts {
operations,
index_entries,
inline_ops,
mut state,
next_catalog_id,
next_file_id,
} = tx.into_parts();
if operations.is_empty() {
let mut writes = Vec::new();
diff_options(&mut writes, base, &state);
if writes.is_empty() {
return Ok(Prepared::Nothing { head });
}
writes.push(head_write(db_tx, head).await?);
let staged_bytes = stage_writes(db_tx, &writes)?;
return Ok(Prepared::Staged {
ours: Box::default(),
commits: head,
writes,
staged_bytes,
});
}
let index_entry_count = index_entries.len();
let entries = index_maintenance::stage_index_entries(db_tx, index_entries).await?;
let poisoned = entries.poisoned;
index_maintenance::apply_poison(&mut state, &poisoned);
let mut writes = diff_writes(base, &state, new_id);
writes.extend(inline::stage_inline_writes(db_tx, &inline_ops).await?);
let uses_inline_chunk_directory = inline_ops
.iter()
.any(|operation| matches!(operation, inline::InlineStage::Insert { .. }));
writes.extend(format_stamp(db_tx, &state, uses_inline_chunk_directory).await?);
let schema_changed = operations.iter().any(Operation::is_schema_changing);
let schema_changed_table_ids: Vec<u64> = operations
.iter()
.filter_map(Operation::schema_changed_table_id)
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let schema_version = base.snapshot.schema_version + u64::from(schema_changed);
for table_id in &schema_changed_table_ids {
writes.push(schema_version_write(*table_id, new_id, schema_version));
}
let ours = ChangeSet::from_operations(&operations);
let changelog = changelog_writes(new_id, &writes);
let snapshot = proto::SnapshotValue {
snapshot_id: new_id,
snapshot_time_micros: now_micros(),
schema_version,
next_catalog_id,
next_file_id,
changes_made: ours.to_changes_made(),
author: None,
commit_message: None,
commit_extra_info: None,
schema_changed_table_ids,
deleted_data_file_ids: ours.deleted_data_file_ids.iter().copied().collect(),
};
writes.extend(changelog);
writes.push((
Key::Snapshot {
snapshot_id: new_id,
}
.encode(),
Some(value::encode_value(&snapshot)),
));
writes.push(head_write(db_tx, new_id).await?);
let mut staged_bytes = stage_writes(db_tx, &writes)?;
staged_bytes.0 = staged_bytes.0.saturating_add(entries.bytes);
tracing::debug!(
snapshot = new_id,
index_entries = index_entry_count,
inline_ops = inline_ops.len(),
catalog_writes = writes.len(),
poisoned_indexes = poisoned.len(),
staged_bytes = staged_bytes.0,
"commit staged"
);
Ok(Prepared::Staged {
ours: Box::new(ours),
commits: new_id,
writes,
staged_bytes,
})
}
pub(crate) fn stage_writes(db_tx: &DbTransaction, writes: &[StagedWrite]) -> Result<StagedBytes> {
let mut staged = StagedBytes::default();
for (key, write) in writes {
staged.add(key.len(), write.as_ref().map_or(0, Vec::len));
match write {
Some(bytes) => db_tx.put(key.clone(), bytes.clone()),
None => db_tx.delete(key.clone()),
}
.map_err(Error::from)?;
}
Ok(staged)
}
pub(crate) async fn commit_batch(
db_tx: DbTransaction,
head_before: u64,
head: u64,
writes: &[StagedWrite],
staged_bytes: StagedBytes,
base: &CatalogSnapshot,
projections: &std::sync::RwLock<ProjectionCache>,
) -> Result<Landed> {
let head_advanced = head > head_before;
if !head_advanced {
invalidate_head_view(projections);
}
let durable_started = Instant::now();
match commit_durable(db_tx, "commit", staged_bytes).await {
Ok(_) => {
let durable = durable_started.elapsed();
let projection_started = Instant::now();
fold_committed_batch(projections, writes, head);
if head_advanced {
refresh_head_view(projections, base, writes);
}
let projection = projection_started.elapsed();
debug!(
operation = "commit",
snapshot = head,
staged_bytes = staged_bytes.0,
elapsed_ms = durable.as_secs_f64() * 1_000.0,
projection_ms = projection.as_secs_f64() * 1_000.0,
"durable commit landed"
);
Ok(Landed::Committed(CommitTimings {
durable,
projection,
}))
}
Err(err) if err.kind() == slatedb::ErrorKind::Transaction => Ok(Landed::LostRace),
Err(err) => {
invalidate_head_view(projections);
Err(err.into())
}
}
}
pub(crate) async fn commit_batch_off_task(
db_tx: DbTransaction,
head_before: u64,
head: u64,
writes: Vec<StagedWrite>,
staged_bytes: StagedBytes,
base: Arc<CatalogSnapshot>,
projections: Arc<std::sync::RwLock<ProjectionCache>>,
) -> Result<Landed> {
let task = {
let projections = Arc::clone(&projections);
tokio::spawn(async move {
commit_batch(
db_tx,
head_before,
head,
&writes,
staged_bytes,
&base,
&projections,
)
.await
})
};
match task.await {
Ok(landed) => landed,
Err(err) => {
invalidate_head_view(&projections);
warn!(error = %err, "the durable write did not report back; its outcome is unknown");
Err(Error::Interrupted(format!(
"the durable write did not report back ({err}); it may or may not have \
landed — re-resolve head before re-driving"
)))
}
}
}
pub(crate) async fn commit_cycle<F>(
db: &Db,
members: &[F],
coalescer: &Arc<Coalescer>,
) -> Result<Vec<SnapshotId>>
where
F: Fn(&mut Transaction) -> Result<()>,
{
let started = std::time::Instant::now();
let mut first_head = None;
let mut last_intervening = Vec::new();
for attempt in 0..MAX_COMMIT_ATTEMPTS {
if attempt > 0 {
tokio::time::sleep(retry_backoff(attempt)).await;
}
match attempt_group(db, members, coalescer).await? {
CommitOutcome::Nothing => {
tracing::debug!(attempt, "commit's batch wrote nothing; retrying");
}
CommitOutcome::Committed(ids) => {
tracing::debug!(
snapshot = ids.last().map(|id| id.get()),
members = members.len(),
attempt,
elapsed_ms = started.elapsed().as_millis(),
"commit landed"
);
return Ok(ids);
}
CommitOutcome::LostRace { ours, head_before } => {
first_head.get_or_insert(head_before);
if ours.iter().all(ChangeSet::is_empty) {
tracing::debug!(
attempt,
head_before,
"commit lost the head race with nothing to conflict over; retrying"
);
continue;
}
let intervening = classify_intervening_changes(db, head_before, &ours).await?;
if let Some(snapshot_id) = intervening.conflict {
tracing::debug!(
attempt,
head_before,
winner = snapshot_id,
"commit conflicts with an intervening commit; surfacing"
);
return Err(Error::CommitConflict(format!(
"concurrent commit {snapshot_id} touched the same state"
)));
}
last_intervening = intervening.snapshot_ids;
tracing::debug!(
attempt,
head_before,
intervening = ?last_intervening,
"commit lost the head race to disjoint commits; retrying"
);
}
}
}
let head_before = first_head.unwrap_or_default();
tracing::warn!(
attempts = MAX_COMMIT_ATTEMPTS,
head_before,
intervening = ?last_intervening,
"commit exhausted its retry budget; reporting a terminal error"
);
Err(Error::RetryBudgetExhausted(format!(
"spent {MAX_COMMIT_ATTEMPTS} attempts from head snapshot {head_before} without \
settling; commits above it: {last_intervening:?}"
)))
}
struct InterveningClassification {
conflict: Option<u64>,
snapshot_ids: Vec<u64>,
}
async fn read_head_snapshot_id(db: &Db) -> Result<u64> {
let head_bytes = db
.get(Key::Sys(SysKey::Head).encode())
.await
.map_err(Error::from)?
.ok_or_else(|| Error::Corruption("store has no head pointer".to_string()))?;
let head: proto::HeadValue = value::decode_value(&head_bytes)?;
Ok(head.snapshot_id)
}
async fn read_intervening_change(db: &Db, snapshot_id: u64) -> Result<(u64, ChangeSet)> {
let change_set = match db
.get(Key::Snapshot { snapshot_id }.encode())
.await
.map_err(Error::from)?
{
Some(bytes) => {
let snapshot: proto::SnapshotValue = value::decode_value(&bytes)?;
let mut change_set = ChangeSet::parse(&snapshot.changes_made);
if !snapshot.deleted_data_file_ids.is_empty() {
change_set.deleted_data_file_ids =
snapshot.deleted_data_file_ids.iter().copied().collect();
change_set.deletes_untargeted_files = false;
}
change_set
}
None => ChangeSet {
has_unknown: true,
..ChangeSet::default()
},
};
Ok((snapshot_id, change_set))
}
fn intervening_change_stream(
db: &Db,
first: u64,
last: u64,
) -> impl futures::Stream<Item = Result<(u64, ChangeSet)>> + '_ {
stream::iter(
(first..=last)
.map(move |snapshot_id| async move { read_intervening_change(db, snapshot_id).await }),
)
.buffer_unordered(INTERVENING_READ_CONCURRENCY)
}
async fn classify_intervening_changes(
db: &Db,
head_before: u64,
ours: &[ChangeSet],
) -> Result<InterveningClassification> {
let head = read_head_snapshot_id(db).await?;
if head_before >= head {
return Ok(InterveningClassification {
conflict: None,
snapshot_ids: Vec::new(),
});
}
let mut changes = std::pin::pin!(intervening_change_stream(
db,
head_before.saturating_add(1),
head,
));
let mut snapshot_ids = Vec::new();
while let Some((snapshot_id, theirs)) = changes.try_next().await? {
snapshot_ids.push(snapshot_id);
if ours
.iter()
.any(|mine| crate::transaction::operations::conflicts(mine, &theirs))
{
return Ok(InterveningClassification {
conflict: Some(snapshot_id),
snapshot_ids,
});
}
}
snapshot_ids.sort_unstable();
Ok(InterveningClassification {
conflict: None,
snapshot_ids,
})
}
#[cfg(test)]
async fn intervening_changes(db: &Db, head_before: u64) -> Result<Vec<(u64, ChangeSet)>> {
let head = read_head_snapshot_id(db).await?;
if head_before >= head {
return Ok(Vec::new());
}
let mut changes: Vec<_> = intervening_change_stream(db, head_before.saturating_add(1), head)
.try_collect()
.await?;
changes.sort_unstable_by_key(|(snapshot_id, _)| *snapshot_id);
Ok(changes)
}
#[cfg(test)]
mod tests;