use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use slatedb::{Db, DbReader, DbTransaction, IsolationLevel, config::WriteOptions};
use tracing::{debug, info, warn};
use uuid::Uuid;
use crate::{
catalog::{
CatalogSnapshot, SnapshotId,
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::{
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 MAX_FORMAT_VERSION: u64 = FORMAT_WITH_STAGED_INDEX;
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;
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 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_micros()).unwrap_or(i64::MAX))
}
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,
) -> 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(),
"still waiting for object storage to accept a durable write; writes are retried \
indefinitely, so check 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<()> {
let stage = |key: Key, bytes: Vec<u8>| tx.put(key.encode(), 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,
}),
)
}
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> {
let mut attempt = 1;
loop {
match open_attempt(&store, encrypted, data_path).await {
Ok(db) => return Ok(db),
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, OpenFailure> {
let started = Instant::now();
let db = 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);
}
Ok(None) => {}
Err(err) => {
tx.rollback();
return Err(OpenFailure::Fatal(err));
}
}
if let Err(err) = stage_bootstrap(&tx, encrypted, data_path) {
tx.rollback();
return Err(OpenFailure::Fatal(err));
}
match commit_durable(tx, "bootstrap").await {
Ok(_) => {
info!(encrypted, data_path, "bootstrapped a fresh catalog store");
Ok(db)
}
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),
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> {
let started = Instant::now();
let reader = 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),
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 mut keys: BTreeSet<Vec<u8>> = BTreeSet::new();
for snapshot_id in (from + 1)..=head.snapshot_id {
let Some(changelog) = read::read_changelog(tx, snapshot_id).await? 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> = Vec::with_capacity(keys.len());
for key in keys {
let value = tx.get(&key).await.map_err(Error::from)?;
writes.push((key, value.map(|bytes| bytes.to_vec())));
}
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,
LostRace,
}
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>,
},
}
fn target_format(state: &CatalogSnapshot) -> u64 {
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
}
}
async fn format_stamp(
db_tx: &DbTransaction,
state: &CatalogSnapshot,
) -> Result<Option<StagedWrite>> {
let target_format = target_format(state);
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?);
stage_writes(db_tx, &writes)?;
return Ok(Prepared::Staged {
ours: Box::default(),
commits: head,
writes,
});
}
let poisoned = index_maintenance::stage_index_entries(db_tx, &index_entries).await?;
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?);
writes.extend(format_stamp(db_tx, &state).await?);
tracing::debug!(
snapshot = new_id,
index_entries = index_entries.len(),
inline_ops = inline_ops.len(),
catalog_writes = writes.len(),
poisoned_indexes = poisoned.len(),
"commit staged"
);
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?);
stage_writes(db_tx, &writes)?;
Ok(Prepared::Staged {
ours: Box::new(ours),
commits: new_id,
writes,
})
}
pub(crate) fn stage_writes(db_tx: &DbTransaction, writes: &[StagedWrite]) -> Result<()> {
for (key, write) in writes {
match write {
Some(bytes) => db_tx.put(key.clone(), bytes.clone()),
None => db_tx.delete(key.clone()),
}
.map_err(Error::from)?;
}
Ok(())
}
pub(crate) async fn commit_batch(
db_tx: DbTransaction,
head_before: u64,
head: u64,
writes: &[StagedWrite],
base: &CatalogSnapshot,
projections: &std::sync::RwLock<ProjectionCache>,
) -> Result<Landed> {
let head_advanced = head > head_before;
if !head_advanced {
invalidate_head_view(projections);
}
match commit_durable(db_tx, "commit").await {
Ok(_) => {
fold_committed_batch(projections, writes, head);
if head_advanced {
refresh_head_view(projections, base, writes);
}
Ok(Landed::Committed)
}
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>,
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, &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 = intervening_changes(db, head_before).await?;
for (snapshot_id, theirs) in &intervening {
if ours
.iter()
.any(|mine| crate::transaction::operations::conflicts(mine, theirs))
{
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.iter().map(|(id, _)| *id).collect();
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:?}"
)))
}
async fn intervening_changes(db: &Db, head_before: u64) -> Result<Vec<(u64, ChangeSet)>> {
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)?;
let mut changes = Vec::new();
for snapshot_id in (head_before + 1)..=head.snapshot_id {
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()
},
};
changes.push((snapshot_id, change_set));
}
Ok(changes)
}
#[cfg(test)]
mod tests;