use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
time::{Duration, 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,
install_head_view, install_head_view_at, invalidate_head_view,
},
},
error::{Error, Result},
store::{
handle::ReadHandle,
key::{EntityKey, Key, SysKey},
open::StoreBuilder,
proto, read, 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".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}); \
it must be migrated up before this binary can open it",
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 }),
)
}
pub(crate) async fn open_initialized(
store: StoreBuilder<'_>,
encrypted: bool,
data_path: Option<&str>,
) -> Result<Db> {
let db = store.open_writer().await?;
let tx = db
.begin(IsolationLevel::Snapshot)
.await
.map_err(Error::from)?;
match validate_format(ReadHandle::Tx(&tx)).await {
Ok(Some(_)) => {
tx.rollback();
return Ok(db);
}
Ok(None) => {}
Err(err) => {
tx.rollback();
return Err(err);
}
}
if let Err(err) = stage_bootstrap(&tx, encrypted, data_path) {
tx.rollback();
return Err(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 = db
.begin(IsolationLevel::Snapshot)
.await
.map_err(Error::from)?;
let validated = validate_format(ReadHandle::Tx(&tx)).await;
tx.rollback();
if validated?.is_some() {
Ok(db)
} else {
Err(Error::Corruption(
"bootstrap race left the store uninitialized".to_string(),
))
}
}
Err(err) => Err(err.into()),
}
}
pub(crate) async fn open_reader_initialized(store: StoreBuilder<'_>) -> Result<DbReader> {
let reader = store.open_reader().await?;
match validate_format(ReadHandle::Reader(&reader)).await? {
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_id(tx: ReadHandle<'_>) -> Result<u64> {
Ok(read::read_head(tx)
.await?
.ok_or_else(|| Error::Corruption("store has no head pointer".to_string()))?
.snapshot_id)
}
pub(crate) async fn materialize(tx: ReadHandle<'_>, at: Option<u64>) -> Result<CatalogSnapshot> {
refuse_mid_migration(tx).await?;
let head = read_head_id(tx).await?;
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"
))
})?;
let current = read::scan_current_entities(tx).await?;
let history = match at {
Some(_) => read::scan_history_entities(tx).await?,
None => Vec::new(),
};
Ok(CatalogSnapshot::build(
snapshot,
current,
history,
at.map(|_| target),
))
}
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_id(handle).await?;
if let Some(view) = cached_head_view(projections, head) {
return Ok(view);
}
let view = 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((
Key::Sys(SysKey::Head).encode(),
Some(value::encode_value(&proto::HeadValue { snapshot_id: head })),
));
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 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.push((
Key::Snapshot {
snapshot_id: new_id,
}
.encode(),
Some(value::encode_value(&snapshot)),
));
writes.push((
Key::Sys(SysKey::Head).encode(),
Some(value::encode_value(&proto::HeadValue {
snapshot_id: new_id,
})),
));
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) => Err(err.into()),
}
}
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;