use crate::database::{Database, ExternalTriggerBridge, TableHandle};
use crate::epoch::{Epoch, Snapshot};
use crate::error::{MongrelError, Result};
use crate::memtable::Value;
use crate::rowid::RowId;
use crate::wal::SharedWal;
use mongreldb_types::hlc::HlcTimestamp;
use parking_lot::{Condvar, Mutex as PlMutex};
use std::sync::Arc;
pub(crate) fn allocate_txn_id(allocator: &PlMutex<u64>) -> Result<u64> {
let mut next = allocator.lock();
let id = *next;
if id == crate::wal::SYSTEM_TXN_ID || id & u32::MAX as u64 == 0 {
return Err(MongrelError::Full(
"per-open transaction id namespace exhausted; reopen the database".into(),
));
}
*next = id.checked_add(1).ok_or_else(|| {
MongrelError::Full(
"per-open transaction id namespace exhausted; reopen the database".into(),
)
})?;
Ok(id)
}
pub(crate) enum Staged {
Put(Vec<(u16, Value)>),
Delete(RowId),
Update {
row_id: RowId,
new_row: Vec<(u16, Value)>,
changed_columns: Vec<u16>,
},
Truncate,
}
#[derive(Debug, Clone)]
pub struct OwnedRow {
pub columns: Vec<(u16, Value)>,
}
#[derive(Debug, Clone)]
pub struct PutResult {
pub auto_inc: Option<i64>,
pub row: OwnedRow,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpsertActionKind {
Inserted,
Updated,
Unchanged,
}
#[derive(Debug, Clone)]
pub enum UpsertAction {
DoNothing,
DoUpdate(Vec<(u16, Value)>),
}
#[derive(Debug, Clone)]
pub struct UpsertResult {
pub action: UpsertActionKind,
pub row: OwnedRow,
pub auto_inc: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AbortReason {
RolledBack,
Conflict(String),
Validation(String),
Cancelled(String),
Error(String),
}
#[derive(Debug, Clone)]
pub enum TransactionState {
Active,
Preparing,
CommitCritical,
Committed(mongreldb_log::CommitReceipt),
Aborted(AbortReason),
}
#[derive(Debug, Clone)]
pub struct TxnStateHandle {
inner: std::sync::Arc<PlMutex<TransactionState>>,
}
impl TxnStateHandle {
fn new() -> Self {
Self {
inner: std::sync::Arc::new(PlMutex::new(TransactionState::Active)),
}
}
pub fn state(&self) -> TransactionState {
self.inner.lock().clone()
}
fn transition(&self, next: TransactionState) -> bool {
let mut current = self.inner.lock();
let legal = matches!(
(&*current, &next),
(TransactionState::Active, TransactionState::Preparing)
| (TransactionState::Active, TransactionState::Aborted(_))
| (
TransactionState::Preparing,
TransactionState::CommitCritical
)
| (TransactionState::Preparing, TransactionState::Aborted(_))
| (TransactionState::Preparing, TransactionState::Committed(_))
| (
TransactionState::CommitCritical,
TransactionState::Committed(_)
)
);
if legal {
*current = next;
}
legal
}
pub(crate) fn begin_prepare(&self) -> bool {
self.transition(TransactionState::Preparing)
}
pub(crate) fn enter_commit_critical(&self) -> bool {
self.transition(TransactionState::CommitCritical)
}
pub(crate) fn committed(&self, receipt: mongreldb_log::CommitReceipt) -> bool {
self.transition(TransactionState::Committed(receipt))
}
pub fn abort(&self, reason: AbortReason) -> bool {
self.transition(TransactionState::Aborted(reason))
}
}
pub(crate) fn classify_commit_error(state: &TxnStateHandle, error: &MongrelError) {
let reason = match error {
MongrelError::Conflict(message) => AbortReason::Conflict(message.clone()),
MongrelError::SerializationFailure { message } => {
AbortReason::Conflict(format!("serialization failure: {message}"))
}
MongrelError::Cancelled => AbortReason::Cancelled("cancelled".into()),
MongrelError::DeadlineExceeded => AbortReason::Cancelled("deadline exceeded".into()),
MongrelError::CommitOutcomeUnknown { .. } | MongrelError::DurableCommit { .. } => return,
MongrelError::InvalidArgument(message)
| MongrelError::Schema(message)
| MongrelError::TriggerValidation(message) => AbortReason::Validation(message.clone()),
other => AbortReason::Error(other.to_string()),
};
state.abort(reason);
}
#[derive(Debug, Clone, Default)]
pub struct ReadSet {
rows: std::collections::BTreeSet<(u64, u64)>,
}
impl ReadSet {
pub(crate) fn record_row(&mut self, table_id: u64, row_id: RowId) {
self.rows.insert((table_id, row_id.0));
}
pub fn rows(&self) -> impl Iterator<Item = (u64, u64)> + '_ {
self.rows.iter().copied()
}
pub fn len(&self) -> usize {
self.rows.len()
}
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
}
#[derive(Debug, Clone, Default)]
pub struct PredicateSet {
tables: std::collections::BTreeSet<u64>,
}
impl PredicateSet {
pub(crate) fn record_table(&mut self, table_id: u64) {
self.tables.insert(table_id);
}
pub fn tables(&self) -> impl Iterator<Item = u64> + '_ {
self.tables.iter().copied()
}
pub fn len(&self) -> usize {
self.tables.len()
}
pub fn is_empty(&self) -> bool {
self.tables.is_empty()
}
}
pub(crate) fn ssi_validation_keys(
read_set: &ReadSet,
predicate_set: &PredicateSet,
) -> Vec<WriteKey> {
let mut keys = Vec::with_capacity(read_set.len() + predicate_set.len());
for (table_id, row_id) in read_set.rows() {
keys.push(WriteKey::Row { table_id, row_id });
}
for table_id in predicate_set.tables() {
keys.push(WriteKey::Table { table_id });
}
keys
}
pub(crate) struct TxnCommitContext {
pub isolation: IsolationLevel,
pub read_ts: Option<HlcTimestamp>,
pub read_set: ReadSet,
pub predicate_set: PredicateSet,
pub state: Option<TxnStateHandle>,
pub idempotency: Option<IdempotencyRequest>,
}
impl TxnCommitContext {
pub(crate) fn internal() -> Self {
Self {
isolation: IsolationLevel::RepeatableRead,
read_ts: None,
read_set: ReadSet::default(),
predicate_set: PredicateSet::default(),
state: None,
idempotency: None,
}
}
}
pub struct Transaction<'db> {
db: &'db Database,
txn_id: u64,
allocation_error: Option<String>,
isolation: IsolationLevel,
read: Snapshot,
read_ts: Option<HlcTimestamp>,
read_set: ReadSet,
predicate_set: PredicateSet,
state: TxnStateHandle,
staging: Vec<(u64 /*table_id*/, Staged)>,
external_states: Vec<(String, Vec<u8>)>,
materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
principal: Option<crate::auth::Principal>,
principal_catalog_bound: bool,
external_trigger_bridge: Option<&'db dyn ExternalTriggerBridge>,
_active: Option<ActiveTxnGuard<'db>>,
}
impl<'db> Transaction<'db> {
pub(crate) fn new(
db: &'db Database,
txn_id: Result<u64>,
read: Snapshot,
isolation: IsolationLevel,
) -> Self {
let guard = db.register_active(read.epoch);
let read_ts = db.hlc_clock().now().ok();
let (txn_id, allocation_error) = match txn_id {
Ok(txn_id) => (txn_id, None),
Err(MongrelError::Full(message)) => (crate::wal::SYSTEM_TXN_ID, Some(message)),
Err(error) => (crate::wal::SYSTEM_TXN_ID, Some(error.to_string())),
};
Self {
db,
txn_id,
allocation_error,
isolation,
read,
read_ts,
read_set: ReadSet::default(),
predicate_set: PredicateSet::default(),
state: TxnStateHandle::new(),
staging: Vec::new(),
external_states: Vec::new(),
materialized_view_updates: Vec::new(),
principal: None,
principal_catalog_bound: false,
external_trigger_bridge: None,
_active: Some(guard),
}
}
pub(crate) fn with_external_trigger_bridge(
mut self,
bridge: &'db dyn ExternalTriggerBridge,
) -> Self {
self.external_trigger_bridge = Some(bridge);
self
}
pub(crate) fn with_principal(
mut self,
principal: Option<crate::auth::Principal>,
catalog_bound: bool,
) -> Self {
self.principal = principal;
self.principal_catalog_bound = catalog_bound;
self
}
pub fn read_snapshot(&self) -> Snapshot {
self.read
}
pub fn txn_id(&self) -> u64 {
self.txn_id
}
pub fn isolation(&self) -> IsolationLevel {
self.isolation
}
pub fn read_ts(&self) -> Option<HlcTimestamp> {
self.read_ts
}
pub fn state(&self) -> TransactionState {
self.state.state()
}
pub fn state_handle(&self) -> TxnStateHandle {
self.state.clone()
}
pub fn read_set(&self) -> &ReadSet {
&self.read_set
}
pub fn predicate_set(&self) -> &PredicateSet {
&self.predicate_set
}
fn statement_snapshot(&self) -> Snapshot {
match self.isolation.canonical() {
IsolationLevel::ReadCommitted => Snapshot::at(self.db.visible_epoch()),
_ => self.read,
}
}
pub fn get(&mut self, table: &str, row_id: RowId) -> Result<Option<OwnedRow>> {
let snap = self.statement_snapshot();
let id = self.db.table_id(table)?;
let handle = self.db.table(table)?;
let row = handle.lock().get(row_id, snap);
Ok(row.map(|row| {
self.read_set.record_row(id, row_id);
owned_row_from_map(row.columns)
}))
}
pub fn track_predicate_read(&mut self, table: &str) -> Result<()> {
let id = self.db.table_id(table)?;
self.predicate_set.record_table(id);
Ok(())
}
fn lock_auto_inc_barrier(&self, table_id: u64, cells: &[(u16, Value)]) -> Result<()> {
if self.db.table_auto_inc_would_allocate(table_id, cells) {
self.db.acquire_txn_lock(
self.txn_id,
crate::locks::LockKey::sequence_barrier(format!("auto_inc:{table_id}").as_str()),
crate::locks::LockMode::Exclusive,
None,
)?;
}
Ok(())
}
pub fn put(&mut self, table: &str, mut cells: Vec<(u16, Value)>) -> Result<Option<i64>> {
self.require_columns(table, crate::auth::ColumnOperation::Insert, &cells)?;
let id = self.db.table_id(table)?;
self.lock_auto_inc_barrier(id, &cells)?;
let handle = self.db.table(table)?;
let mut t = handle.lock();
let assigned = t.fill_auto_inc(&mut cells)?;
t.apply_defaults(&mut cells)?;
drop(t);
self.staging.push((id, Staged::Put(cells)));
Ok(assigned)
}
#[doc(hidden)]
pub fn put_building(
&mut self,
table: &str,
mut cells: Vec<(u16, Value)>,
) -> Result<Option<i64>> {
self.db
.require_for(self.principal.as_ref(), &crate::auth::Permission::Ddl)?;
let id = self.db.building_table_id(table)?;
self.lock_auto_inc_barrier(id, &cells)?;
let handle = self.db.table_by_id(id)?;
let mut target = handle.lock();
let assigned = target.fill_auto_inc(&mut cells)?;
target.apply_defaults(&mut cells)?;
let primary_key_column = target
.schema()
.primary_key()
.map(|column| column.id)
.ok_or_else(|| MongrelError::Schema("CTAS build table has no primary key".into()))?;
let primary_key = cells
.iter()
.find(|(column, _)| *column == primary_key_column)
.map(|(_, value)| value)
.ok_or_else(|| MongrelError::InvalidArgument("CTAS primary key is missing".into()))?;
if matches!(primary_key, Value::Null) {
return Err(MongrelError::InvalidArgument(
"CTAS primary key cannot be NULL".into(),
));
}
let primary_key = primary_key.encode_key();
let replacing = self
.staging
.iter()
.any(|(table_id, staged)| *table_id == id && matches!(staged, Staged::Truncate));
if !replacing && target.lookup_pk(&primary_key).is_some() {
return Err(MongrelError::InvalidArgument(
"duplicate CTAS primary key".into(),
));
}
drop(target);
if self.staging.iter().any(|(staged_table, staged)| {
if *staged_table != id {
return false;
}
let Staged::Put(staged_cells) = staged else {
return false;
};
staged_cells
.iter()
.find(|(column, _)| *column == primary_key_column)
.is_some_and(|(_, value)| value.encode_key() == primary_key)
}) {
return Err(MongrelError::InvalidArgument(
"duplicate CTAS primary key".into(),
));
}
self.staging.push((id, Staged::Put(cells)));
Ok(assigned)
}
#[doc(hidden)]
pub fn truncate_building(&mut self, table: &str) -> Result<()> {
self.db
.require_for(self.principal.as_ref(), &crate::auth::Permission::Ddl)?;
let id = self.db.building_table_id(table)?;
if self.staging.iter().any(|(table_id, _)| *table_id == id) {
return Err(MongrelError::InvalidArgument(
"building-table truncate must be staged before replacement rows".into(),
));
}
self.staging.push((id, Staged::Truncate));
Ok(())
}
pub fn put_returning(
&mut self,
table: &str,
mut cells: Vec<(u16, Value)>,
) -> Result<PutResult> {
self.require_columns(table, crate::auth::ColumnOperation::Insert, &cells)?;
let id = self.db.table_id(table)?;
self.lock_auto_inc_barrier(id, &cells)?;
let handle = self.db.table(table)?;
let mut t = handle.lock();
let assigned = t.fill_auto_inc(&mut cells)?;
t.apply_defaults(&mut cells)?;
drop(t);
let row = owned_row_from_cells(&cells);
self.staging.push((id, Staged::Put(cells)));
Ok(PutResult {
auto_inc: assigned,
row,
})
}
#[doc(hidden)]
pub fn put_returning_bound(
&mut self,
table: &str,
expected_table_id: u64,
expected_schema_id: u64,
mut cells: Vec<(u16, Value)>,
) -> Result<PutResult> {
self.require_columns(table, crate::auth::ColumnOperation::Insert, &cells)?;
self.lock_auto_inc_barrier(expected_table_id, &cells)?;
let handle = self.bound_table(table, expected_table_id, expected_schema_id)?;
let mut target = handle.lock();
let assigned = target.fill_auto_inc(&mut cells)?;
target.apply_defaults(&mut cells)?;
drop(target);
let row = owned_row_from_cells(&cells);
self.staging.push((expected_table_id, Staged::Put(cells)));
Ok(PutResult {
auto_inc: assigned,
row,
})
}
pub fn put_batch(
&mut self,
table: &str,
rows: Vec<Vec<(u16, Value)>>,
) -> Result<Vec<Option<i64>>> {
if !rows.is_empty() {
let mut columns = rows
.iter()
.flat_map(|cells| cells.iter().map(|(column, _)| *column))
.collect::<Vec<_>>();
columns.sort_unstable();
columns.dedup();
self.db.require_columns_for(
table,
crate::auth::ColumnOperation::Insert,
&columns,
self.principal.as_ref(),
)?;
}
let id = self.db.table_id(table)?;
for cells in &rows {
self.lock_auto_inc_barrier(id, cells)?;
}
let handle = self.db.table(table)?;
let mut t = handle.lock();
let mut assigned = Vec::with_capacity(rows.len());
for mut cells in rows {
let a = t.fill_auto_inc(&mut cells)?;
t.apply_defaults(&mut cells)?;
assigned.push(a);
self.staging.push((id, Staged::Put(cells)));
}
drop(t);
Ok(assigned)
}
pub fn delete(&mut self, table: &str, row_id: RowId) -> Result<()> {
self.delete_batch(table, vec![row_id])
}
#[doc(hidden)]
pub fn delete_bound(
&mut self,
table: &str,
expected_table_id: u64,
expected_schema_id: u64,
row_id: RowId,
) -> Result<()> {
self.require_delete(table)?;
self.bound_table(table, expected_table_id, expected_schema_id)?;
self.reject_after_truncate(expected_table_id)?;
self.staging
.push((expected_table_id, Staged::Delete(row_id)));
Ok(())
}
#[doc(hidden)]
pub fn delete_by_pk_bound(
&mut self,
table: &str,
expected_table_id: u64,
expected_schema_id: u64,
key: &Value,
) -> Result<bool> {
self.require_delete(table)?;
let handle = self.bound_table(table, expected_table_id, expected_schema_id)?;
self.reject_after_truncate(expected_table_id)?;
let row_id = {
let mut target = handle.lock();
target.ensure_indexes_complete()?;
target.lookup_pk(&key.encode_key())
};
let Some(row_id) = row_id else {
return Ok(false);
};
self.read_set.record_row(expected_table_id, row_id);
self.staging
.push((expected_table_id, Staged::Delete(row_id)));
Ok(true)
}
pub fn delete_batch(&mut self, table: &str, row_ids: Vec<RowId>) -> Result<()> {
self.require_delete(table)?;
let id = self.db.table_id(table)?;
self.reject_after_truncate(id)?;
self.staging.extend(
row_ids
.into_iter()
.map(|row_id| (id, Staged::Delete(row_id))),
);
Ok(())
}
pub fn put_external_state(&mut self, table: &str, state: Vec<u8>) -> Result<()> {
if self.db.external_table(table).is_none() {
return Err(MongrelError::NotFound(format!(
"external table {table:?} not found"
)));
}
self.external_states.push((table.to_string(), state));
Ok(())
}
pub fn set_materialized_view_definition(
&mut self,
definition: crate::catalog::MaterializedViewEntry,
) -> Result<()> {
self.db
.require_for(self.principal.as_ref(), &crate::auth::Permission::Ddl)?;
if self.db.table_id(&definition.name).is_err() {
return Err(MongrelError::NotFound(format!(
"materialized view table {:?} not found",
definition.name
)));
}
self.materialized_view_updates
.retain(|current| current.name != definition.name);
self.materialized_view_updates.push(definition);
Ok(())
}
pub fn delete_many(&mut self, table: &str, row_ids: Vec<RowId>) -> Result<Vec<OwnedRow>> {
self.require_delete(table)?;
let id = self.db.table_id(table)?;
self.reject_after_truncate(id)?;
let snap = self.statement_snapshot();
let handle = self.db.table(table)?;
let t = handle.lock();
let mut pre_images = Vec::with_capacity(row_ids.len());
for row_id in &row_ids {
if let Some(row) = t.get(*row_id, snap) {
pre_images.push(owned_row_from_map(row.columns));
self.read_set.record_row(id, *row_id);
}
}
drop(t);
for row_id in row_ids {
self.staging.push((id, Staged::Delete(row_id)));
}
Ok(pre_images)
}
pub fn update_many(
&mut self,
table: &str,
updates: Vec<(RowId, Vec<(u16, Value)>)>,
) -> Result<Vec<OwnedRow>> {
if !updates.is_empty() {
let mut columns = updates
.iter()
.flat_map(|(_, cells)| cells.iter().map(|(column, _)| *column))
.collect::<Vec<_>>();
columns.sort_unstable();
columns.dedup();
self.db.require_columns_for(
table,
crate::auth::ColumnOperation::Update,
&columns,
self.principal.as_ref(),
)?;
}
let id = self.db.table_id(table)?;
self.reject_after_truncate(id)?;
let snap = self.statement_snapshot();
let handle = self.db.table(table)?;
let t = handle.lock();
let mut post_images = Vec::with_capacity(updates.len());
let mut staged = Vec::with_capacity(updates.len());
for (old_id, new_cells) in updates {
let changed_columns = changed_columns(&new_cells);
let old_row = t
.get(old_id, snap)
.ok_or_else(|| MongrelError::NotFound(format!("row {old_id:?} not found")))?;
self.read_set.record_row(id, old_id);
let merged = merge_cells(old_row.columns.into_iter().collect(), new_cells);
post_images.push(owned_row_from_cells(&merged));
staged.push((
id,
Staged::Update {
row_id: old_id,
new_row: merged,
changed_columns,
},
));
}
drop(t);
self.staging.extend(staged);
Ok(post_images)
}
pub fn upsert(
&mut self,
table: &str,
mut insert_cells: Vec<(u16, Value)>,
action: UpsertAction,
) -> Result<UpsertResult> {
self.require_columns(table, crate::auth::ColumnOperation::Insert, &insert_cells)?;
let id = self.db.table_id(table)?;
self.lock_auto_inc_barrier(id, &insert_cells)?;
self.reject_after_truncate(id)?;
match (self.existing_pk_row(table, &insert_cells)?, action) {
(None, _) => {
let handle = self.db.table(table)?;
let mut t = handle.lock();
let assigned = t.fill_auto_inc(&mut insert_cells)?;
t.apply_defaults(&mut insert_cells)?;
drop(t);
let row = owned_row_from_cells(&insert_cells);
self.staging.push((id, Staged::Put(insert_cells)));
Ok(UpsertResult {
action: UpsertActionKind::Inserted,
row,
auto_inc: assigned,
})
}
(Some((_old_id, old_row)), UpsertAction::DoNothing) => Ok(UpsertResult {
action: UpsertActionKind::Unchanged,
row: old_row,
auto_inc: None,
}),
(Some((old_id, old_row)), UpsertAction::DoUpdate(update_cells)) => {
self.require_columns(table, crate::auth::ColumnOperation::Update, &update_cells)?;
let changed_columns = changed_columns(&update_cells);
let merged = merge_cells(old_row.columns.clone(), update_cells);
if columns_equal(&old_row.columns, &merged) {
return Ok(UpsertResult {
action: UpsertActionKind::Unchanged,
row: old_row,
auto_inc: None,
});
}
let row = owned_row_from_cells(&merged);
self.staging.push((
id,
Staged::Update {
row_id: old_id,
new_row: merged,
changed_columns,
},
));
Ok(UpsertResult {
action: UpsertActionKind::Updated,
row,
auto_inc: None,
})
}
}
}
#[doc(hidden)]
pub fn upsert_bound(
&mut self,
table: &str,
expected_table_id: u64,
expected_schema_id: u64,
mut insert_cells: Vec<(u16, Value)>,
action: UpsertAction,
) -> Result<UpsertResult> {
self.require_columns(table, crate::auth::ColumnOperation::Insert, &insert_cells)?;
self.lock_auto_inc_barrier(expected_table_id, &insert_cells)?;
let handle = self.bound_table(table, expected_table_id, expected_schema_id)?;
self.reject_after_truncate(expected_table_id)?;
match (
self.existing_pk_row_in(&handle, &insert_cells, expected_table_id)?,
action,
) {
(None, _) => {
let mut target = handle.lock();
let assigned = target.fill_auto_inc(&mut insert_cells)?;
target.apply_defaults(&mut insert_cells)?;
drop(target);
let row = owned_row_from_cells(&insert_cells);
self.staging
.push((expected_table_id, Staged::Put(insert_cells)));
Ok(UpsertResult {
action: UpsertActionKind::Inserted,
row,
auto_inc: assigned,
})
}
(Some((_old_id, old_row)), UpsertAction::DoNothing) => Ok(UpsertResult {
action: UpsertActionKind::Unchanged,
row: old_row,
auto_inc: None,
}),
(Some((old_id, old_row)), UpsertAction::DoUpdate(update_cells)) => {
self.require_columns(table, crate::auth::ColumnOperation::Update, &update_cells)?;
let changed_columns = changed_columns(&update_cells);
let merged = merge_cells(old_row.columns.clone(), update_cells);
if columns_equal(&old_row.columns, &merged) {
return Ok(UpsertResult {
action: UpsertActionKind::Unchanged,
row: old_row,
auto_inc: None,
});
}
let row = owned_row_from_cells(&merged);
self.staging.push((
expected_table_id,
Staged::Update {
row_id: old_id,
new_row: merged,
changed_columns,
},
));
Ok(UpsertResult {
action: UpsertActionKind::Updated,
row,
auto_inc: None,
})
}
}
}
pub fn truncate(&mut self, table: &str) -> Result<()> {
self.db
.require_for(self.principal.as_ref(), &crate::auth::Permission::Admin)?;
let id = self.db.table_id(table)?;
for (table_id, op) in &self.staging {
if *table_id == id && !matches!(op, Staged::Truncate) {
return Err(MongrelError::InvalidArgument(
"truncate cannot be combined with other writes on the same table".into(),
));
}
}
self.staging.push((id, Staged::Truncate));
Ok(())
}
fn reject_after_truncate(&self, table_id: u64) -> Result<()> {
if self
.staging
.iter()
.any(|(tid, op)| *tid == table_id && matches!(op, Staged::Truncate))
{
return Err(MongrelError::InvalidArgument(
"truncate cannot be combined with other writes on the same table".into(),
));
}
Ok(())
}
fn require_columns(
&self,
table: &str,
operation: crate::auth::ColumnOperation,
cells: &[(u16, Value)],
) -> Result<()> {
let columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
self.db
.require_columns_for(table, operation, &columns, self.principal.as_ref())
}
fn require_delete(&self, table: &str) -> Result<()> {
self.db.require_for(
self.principal.as_ref(),
&crate::auth::Permission::Delete {
table: table.to_string(),
},
)
}
fn bound_table(
&self,
table: &str,
expected_table_id: u64,
expected_schema_id: u64,
) -> Result<TableHandle> {
let current = self.db.table_identity(table)?;
if current != (expected_table_id, expected_schema_id) {
return Err(MongrelError::Conflict(format!(
"table {table:?} changed after request authorization"
)));
}
self.db.table_by_id(expected_table_id)
}
fn existing_pk_row(
&mut self,
table: &str,
cells: &[(u16, Value)],
) -> Result<Option<(RowId, OwnedRow)>> {
let id = self.db.table_id(table)?;
let handle = self.db.table(table)?;
self.existing_pk_row_in(&handle, cells, id)
}
fn existing_pk_row_in(
&mut self,
handle: &TableHandle,
cells: &[(u16, Value)],
table_id: u64,
) -> Result<Option<(RowId, OwnedRow)>> {
let snap = self.statement_snapshot();
let target = handle.lock();
let Some(pk_col) = target.schema().primary_key() else {
return Ok(None);
};
let Some((_, pk_value)) = cells.iter().find(|(id, _)| *id == pk_col.id) else {
return Ok(None);
};
if matches!(pk_value, Value::Null) {
return Ok(None);
}
let Some(row_id) = target.lookup_pk(&pk_value.encode_key()) else {
return Ok(None);
};
let found = target
.get(row_id, snap)
.map(|row| (row_id, owned_row_from_map(row.columns)));
if found.is_some() {
self.read_set.record_row(table_id, row_id);
}
Ok(found)
}
pub fn commit(self) -> Result<Epoch> {
self.commit_full(None, None, None).map(|(epoch, _)| epoch)
}
pub fn commit_idempotent(self, request: IdempotencyRequest) -> Result<Epoch> {
self.commit_full(None, None, Some(request))
.map(|(epoch, _)| epoch)
}
pub fn commit_with_row_ids(self) -> Result<(Epoch, Vec<RowId>)> {
self.commit_full(None, None, None)
}
pub fn commit_controlled<F>(
self,
control: &crate::ExecutionControl,
mut before_commit: F,
) -> Result<Epoch>
where
F: FnMut() -> Result<()>,
{
self.commit_full(Some(control), Some(&mut before_commit), None)
.map(|(epoch, _)| epoch)
}
pub fn commit_controlled_with_row_ids<F>(
self,
control: &crate::ExecutionControl,
mut before_commit: F,
) -> Result<(Epoch, Vec<RowId>)>
where
F: FnMut() -> Result<()>,
{
self.commit_full(Some(control), Some(&mut before_commit), None)
}
fn commit_full(
mut self,
control: Option<&crate::ExecutionControl>,
before_commit: Option<&mut dyn FnMut() -> Result<()>>,
idempotency: Option<IdempotencyRequest>,
) -> Result<(Epoch, Vec<RowId>)> {
if let Some(message) = self.allocation_error.take() {
self.state.abort(AbortReason::Error(message.clone()));
return Err(MongrelError::Full(message));
}
self.state.begin_prepare();
let context = TxnCommitContext {
isolation: self.isolation,
read_ts: self.read_ts,
read_set: std::mem::take(&mut self.read_set),
predicate_set: std::mem::take(&mut self.predicate_set),
state: Some(self.state.clone()),
idempotency,
};
let staging = std::mem::take(&mut self.staging);
let external_states = std::mem::take(&mut self.external_states);
let materialized_view_updates = std::mem::take(&mut self.materialized_view_updates);
let principal = self.principal.take();
let result = match (control, before_commit) {
(Some(control), Some(before_commit)) => {
self.db.commit_transaction_with_external_states_controlled(
self.txn_id,
self.read.epoch,
staging,
external_states,
materialized_view_updates,
principal,
self.principal_catalog_bound,
self.external_trigger_bridge,
context,
control,
before_commit,
)
}
_ => self.db.commit_transaction_with_external_states(
self.txn_id,
self.read.epoch,
staging,
external_states,
materialized_view_updates,
principal,
self.principal_catalog_bound,
self.external_trigger_bridge,
context,
),
};
if let Err(error) = &result {
classify_commit_error(&self.state, error);
}
result
}
pub fn rollback(self) {
self.state.abort(AbortReason::RolledBack);
}
}
impl Drop for Transaction<'_> {
fn drop(&mut self) {
self.state.abort(AbortReason::RolledBack);
if self.txn_id != crate::wal::SYSTEM_TXN_ID {
self.db.release_txn_locks(self.txn_id);
}
}
}
fn owned_row_from_cells(cells: &[(u16, Value)]) -> OwnedRow {
let mut columns = cells.to_vec();
columns.sort_by_key(|(id, _)| *id);
OwnedRow { columns }
}
fn owned_row_from_map(columns: HashMap<u16, Value>) -> OwnedRow {
let mut columns: Vec<(u16, Value)> = columns.into_iter().collect();
columns.sort_by_key(|(id, _)| *id);
OwnedRow { columns }
}
fn merge_cells(mut base: Vec<(u16, Value)>, updates: Vec<(u16, Value)>) -> Vec<(u16, Value)> {
for (id, value) in updates {
base.retain(|(existing, _)| *existing != id);
base.push((id, value));
}
base.sort_by_key(|(id, _)| *id);
base
}
fn changed_columns(cells: &[(u16, Value)]) -> Vec<u16> {
let mut columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
columns.sort_unstable();
columns.dedup();
columns
}
fn columns_equal(a: &[(u16, Value)], b: &[(u16, Value)]) -> bool {
if a.len() != b.len() {
return false;
}
let mut a: Vec<_> = a.iter().collect();
let mut b: Vec<_> = b.iter().collect();
a.sort_by_key(|(id, _)| *id);
b.sort_by_key(|(id, _)| *id);
a.iter()
.zip(b.iter())
.all(|((id_a, v_a), (id_b, v_b))| id_a == id_b && v_a == v_b)
}
pub(crate) enum StagedOp {
Put(Vec<crate::memtable::Row>),
Delete(Vec<RowId>),
Truncate,
}
use std::collections::{BTreeMap, HashMap};
use std::hash::{Hash, Hasher};
#[derive(Clone, Debug)]
pub enum WriteKey {
Row { table_id: u64, row_id: u64 },
Unique {
table_id: u64,
index_id: u16,
key_hash: u64,
},
Table { table_id: u64 },
}
impl Hash for WriteKey {
fn hash<H: Hasher>(&self, state: &mut H) {
match self {
WriteKey::Row { table_id, row_id } => {
0u8.hash(state);
table_id.hash(state);
row_id.hash(state);
}
WriteKey::Unique {
table_id,
index_id,
key_hash,
} => {
1u8.hash(state);
table_id.hash(state);
index_id.hash(state);
key_hash.hash(state);
}
WriteKey::Table { table_id } => {
2u8.hash(state);
table_id.hash(state);
}
}
}
}
impl PartialEq for WriteKey {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(
WriteKey::Row {
table_id: a,
row_id: b,
},
WriteKey::Row {
table_id: c,
row_id: d,
},
) => a == c && b == d,
(
WriteKey::Unique {
table_id: a,
index_id: b,
key_hash: c,
},
WriteKey::Unique {
table_id: d,
index_id: e,
key_hash: f,
},
) => a == d && b == e && c == f,
(WriteKey::Table { table_id: a }, WriteKey::Table { table_id: b }) => a == b,
_ => false,
}
}
}
impl Eq for WriteKey {}
const CONFLICT_SHARDS: usize = 16;
pub struct ConflictIndex {
shards: [parking_lot::Mutex<HashMap<WriteKey, u64>>; CONFLICT_SHARDS],
table_truncate_epochs: parking_lot::Mutex<HashMap<u64, u64>>,
table_write_epochs: parking_lot::Mutex<HashMap<u64, u64>>,
version: std::sync::atomic::AtomicU64,
}
impl ConflictIndex {
pub fn new() -> Self {
Self {
shards: std::array::from_fn(|_| parking_lot::Mutex::new(HashMap::new())),
table_truncate_epochs: parking_lot::Mutex::new(HashMap::new()),
table_write_epochs: parking_lot::Mutex::new(HashMap::new()),
version: std::sync::atomic::AtomicU64::new(0),
}
}
pub fn version(&self) -> u64 {
self.version.load(std::sync::atomic::Ordering::Acquire)
}
fn shard(&self, key: &WriteKey) -> &parking_lot::Mutex<HashMap<WriteKey, u64>> {
let mut h = std::collections::hash_map::DefaultHasher::new();
key.hash(&mut h);
let idx = (h.finish() as usize) & (CONFLICT_SHARDS - 1);
&self.shards[idx]
}
pub fn conflicts(&self, keys: &[WriteKey], read_epoch: Epoch) -> bool {
for k in keys {
let s = self.shard(k);
if let Some(&ce) = s.lock().get(k) {
if ce > read_epoch.0 {
return true;
}
}
}
let truncates = self.table_truncate_epochs.lock();
let writes = self.table_write_epochs.lock();
for k in keys {
match k {
WriteKey::Row { table_id, .. } | WriteKey::Unique { table_id, .. } => {
if truncates.get(table_id).is_some_and(|&ce| ce > read_epoch.0) {
return true;
}
}
WriteKey::Table { table_id } => {
if writes.get(table_id).is_some_and(|&ce| ce > read_epoch.0) {
return true;
}
}
}
}
false
}
pub fn record(&self, keys: &[WriteKey], commit_epoch: Epoch) {
for k in keys {
let s = self.shard(k);
s.lock().insert(k.clone(), commit_epoch.0);
}
let mut truncates = self.table_truncate_epochs.lock();
let mut writes = self.table_write_epochs.lock();
for k in keys {
match k {
WriteKey::Table { table_id } => {
truncates
.entry(*table_id)
.and_modify(|ce| *ce = (*ce).max(commit_epoch.0))
.or_insert(commit_epoch.0);
}
WriteKey::Row { table_id, .. } | WriteKey::Unique { table_id, .. } => {
writes
.entry(*table_id)
.and_modify(|ce| *ce = (*ce).max(commit_epoch.0))
.or_insert(commit_epoch.0);
}
}
}
self.version
.fetch_add(1, std::sync::atomic::Ordering::Release);
}
pub fn prune_below(&self, min_active: Epoch) {
for s in &self.shards {
s.lock().retain(|_, ce| *ce >= min_active.0);
}
self.table_truncate_epochs
.lock()
.retain(|_, ce| *ce >= min_active.0);
self.table_write_epochs
.lock()
.retain(|_, ce| *ce >= min_active.0);
}
}
impl Default for ConflictIndex {
fn default() -> Self {
Self::new()
}
}
pub struct GroupCommit {
inner: PlMutex<GroupState>,
cv: Condvar,
lifecycle: Option<Arc<crate::core::LifecycleController>>,
}
struct GroupState {
durable_seq: u64,
syncing: bool,
poisoned: bool,
}
impl GroupCommit {
pub fn new(durable_seq: u64) -> Self {
Self {
inner: PlMutex::new(GroupState {
durable_seq,
syncing: false,
poisoned: false,
}),
cv: Condvar::new(),
lifecycle: None,
}
}
pub fn with_lifecycle(mut self, lifecycle: Arc<crate::core::LifecycleController>) -> Self {
self.lifecycle = Some(lifecycle);
self
}
pub fn await_durable(&self, wal: &PlMutex<SharedWal>, commit_seq: u64) -> Result<()> {
let mut st = self.inner.lock();
loop {
if st.poisoned {
return Err(MongrelError::Other(
"database poisoned by fsync error".into(),
));
}
if st.durable_seq >= commit_seq {
return Ok(());
}
if st.syncing {
self.cv.wait(&mut st);
continue;
}
st.syncing = true;
drop(st);
std::thread::sleep(std::time::Duration::from_micros(50));
let res = wal.lock().group_sync();
st = self.inner.lock();
st.syncing = false;
match res {
Ok(durable) => {
if durable > st.durable_seq {
st.durable_seq = durable;
}
self.cv.notify_all();
}
Err(e) => {
st.poisoned = true;
if let Some(lifecycle) = &self.lifecycle {
lifecycle.poison();
}
self.cv.notify_all();
return Err(e);
}
}
}
}
}
pub struct ActiveTxns {
inner: parking_lot::Mutex<BTreeMap<u64, u64>>,
}
impl ActiveTxns {
pub fn new() -> Self {
Self {
inner: parking_lot::Mutex::new(BTreeMap::new()),
}
}
pub fn register(&self, read_epoch: Epoch) -> ActiveTxnGuard<'_> {
let mut g = self.inner.lock();
*g.entry(read_epoch.0).or_insert(0) += 1;
ActiveTxnGuard {
active: self,
epoch: read_epoch.0,
}
}
pub fn min_read_epoch(&self) -> u64 {
self.inner.lock().keys().next().copied().unwrap_or(u64::MAX)
}
}
impl Default for ActiveTxns {
fn default() -> Self {
Self::new()
}
}
pub struct ActiveTxnGuard<'a> {
active: &'a ActiveTxns,
epoch: u64,
}
impl Drop for ActiveTxnGuard<'_> {
fn drop(&mut self) {
let mut g = self.active.inner.lock();
if let Some(count) = g.get_mut(&self.epoch) {
*count -= 1;
if *count == 0 {
g.remove(&self.epoch);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shared_transaction_allocator_never_crosses_open_generation() {
let allocator = PlMutex::new((7_u64 << 32) | u32::MAX as u64);
assert_eq!(
allocate_txn_id(&allocator).unwrap(),
(7_u64 << 32) | u32::MAX as u64
);
assert!(matches!(
allocate_txn_id(&allocator),
Err(MongrelError::Full(_))
));
}
#[test]
fn conflict_index_first_committer_wins_and_prunes_safely() {
let ci = ConflictIndex::new();
let k = vec![WriteKey::Row {
table_id: 1,
row_id: 7,
}];
assert!(!ci.conflicts(&k, Epoch(5)));
ci.record(&k, Epoch(6));
assert!(ci.conflicts(&k, Epoch(5)));
assert!(!ci.conflicts(&k, Epoch(6)));
ci.prune_below(Epoch(7));
assert!(!ci.conflicts(&k, Epoch(5)));
}
#[test]
fn conflict_index_table_scope_conflicts_both_directions() {
let ci = ConflictIndex::new();
ci.record(&[WriteKey::Table { table_id: 1 }], Epoch(6));
assert!(ci.conflicts(
&[WriteKey::Row {
table_id: 1,
row_id: 7,
}],
Epoch(5)
));
assert!(ci.conflicts(
&[WriteKey::Unique {
table_id: 1,
index_id: 0,
key_hash: 42,
}],
Epoch(5)
));
assert!(!ci.conflicts(
&[WriteKey::Row {
table_id: 2,
row_id: 7,
}],
Epoch(5)
));
let ci = ConflictIndex::new();
ci.record(
&[WriteKey::Row {
table_id: 1,
row_id: 7,
}],
Epoch(6),
);
assert!(ci.conflicts(&[WriteKey::Table { table_id: 1 }], Epoch(5)));
assert!(!ci.conflicts(&[WriteKey::Table { table_id: 2 }], Epoch(5)));
}
#[test]
fn writekey_eq_across_variants() {
let r1 = WriteKey::Row {
table_id: 1,
row_id: 2,
};
let r2 = WriteKey::Row {
table_id: 1,
row_id: 2,
};
let r3 = WriteKey::Row {
table_id: 1,
row_id: 3,
};
assert_eq!(r1, r2);
assert_ne!(r1, r3);
let u1 = WriteKey::Unique {
table_id: 1,
index_id: 0,
key_hash: 42,
};
let u2 = WriteKey::Unique {
table_id: 1,
index_id: 0,
key_hash: 42,
};
assert_eq!(u1, u2);
assert_ne!(r1, u1);
let t1 = WriteKey::Table { table_id: 5 };
let t2 = WriteKey::Table { table_id: 5 };
assert_eq!(t1, t2);
assert_ne!(t1, r1);
}
#[test]
fn active_txns_tracks_min_read_epoch() {
let at = ActiveTxns::new();
assert_eq!(at.min_read_epoch(), u64::MAX);
let g1 = at.register(Epoch(5));
assert_eq!(at.min_read_epoch(), 5);
let g2 = at.register(Epoch(3));
assert_eq!(at.min_read_epoch(), 3);
drop(g2);
assert_eq!(at.min_read_epoch(), 5);
drop(g1);
assert_eq!(at.min_read_epoch(), u64::MAX);
}
#[test]
fn active_txns_dedups_same_epoch() {
let at = ActiveTxns::new();
let g1 = at.register(Epoch(7));
let g2 = at.register(Epoch(7));
assert_eq!(at.min_read_epoch(), 7);
drop(g1);
assert_eq!(at.min_read_epoch(), 7);
drop(g2);
assert_eq!(at.min_read_epoch(), u64::MAX);
}
#[test]
fn isolation_level_snapshot_aliases_repeatable_read() {
#[allow(deprecated)]
let snapshot = IsolationLevel::Snapshot;
assert_eq!(snapshot.canonical(), IsolationLevel::RepeatableRead);
assert_eq!(IsolationLevel::default(), IsolationLevel::RepeatableRead);
assert_eq!(
IsolationLevel::ReadCommitted.canonical(),
IsolationLevel::ReadCommitted
);
assert_eq!(
IsolationLevel::Serializable.canonical(),
IsolationLevel::Serializable
);
}
#[test]
fn transaction_state_transitions_are_enforced() {
let handle = TxnStateHandle::new();
assert!(matches!(handle.state(), TransactionState::Active));
assert!(!handle.enter_commit_critical());
assert!(matches!(handle.state(), TransactionState::Active));
let receipt = mongreldb_log::CommitReceipt {
transaction_id: mongreldb_types::ids::TransactionId::from_bytes([0; 16]),
commit_ts: HlcTimestamp::ZERO,
log_position: mongreldb_log::LogPosition::ZERO,
durability: mongreldb_log::DurabilityLevel::GroupCommit,
};
assert!(!handle.committed(receipt.clone()));
assert!(handle.begin_prepare());
assert!(matches!(handle.state(), TransactionState::Preparing));
assert!(handle.enter_commit_critical());
assert!(matches!(handle.state(), TransactionState::CommitCritical));
assert!(!handle.abort(AbortReason::Conflict("late".into())));
assert!(matches!(handle.state(), TransactionState::CommitCritical));
assert!(handle.committed(receipt));
assert!(matches!(handle.state(), TransactionState::Committed(_)));
assert!(!handle.abort(AbortReason::RolledBack));
assert!(!handle.begin_prepare());
}
#[test]
fn abort_from_preparing_is_terminal() {
let handle = TxnStateHandle::new();
assert!(handle.abort(AbortReason::RolledBack));
assert!(matches!(
handle.state(),
TransactionState::Aborted(AbortReason::RolledBack)
));
assert!(!handle.begin_prepare());
let handle = TxnStateHandle::new();
handle.begin_prepare();
assert!(handle.abort(AbortReason::Validation("bad row".into())));
match handle.state() {
TransactionState::Aborted(AbortReason::Validation(message)) => {
assert_eq!(message, "bad row")
}
other => panic!("expected validation abort, got {other:?}"),
}
}
#[test]
fn classify_commit_error_leaves_post_fence_states_untouched() {
let handle = TxnStateHandle::new();
handle.begin_prepare();
classify_commit_error(&handle, &MongrelError::Conflict("ww".into()));
assert!(matches!(
handle.state(),
TransactionState::Aborted(AbortReason::Conflict(_))
));
let handle = TxnStateHandle::new();
handle.begin_prepare();
handle.enter_commit_critical();
classify_commit_error(
&handle,
&MongrelError::CommitOutcomeUnknown {
epoch: 7,
message: "fsync".into(),
},
);
assert!(matches!(handle.state(), TransactionState::CommitCritical));
classify_commit_error(
&handle,
&MongrelError::DurableCommit {
epoch: 7,
message: "publish".into(),
},
);
assert!(matches!(handle.state(), TransactionState::CommitCritical));
}
#[test]
fn classify_commit_error_maps_serialization_failure_to_conflict_abort() {
let handle = TxnStateHandle::new();
handle.begin_prepare();
classify_commit_error(
&handle,
&MongrelError::SerializationFailure {
message: "a concurrent commit invalidated this transaction's reads".into(),
},
);
match handle.state() {
TransactionState::Aborted(AbortReason::Conflict(message)) => {
assert_eq!(
message,
"serialization failure: a concurrent commit invalidated this transaction's reads"
);
}
other => panic!("expected conflict abort, got {other:?}"),
}
}
#[test]
fn ssi_validation_keys_cover_rows_and_predicates() {
let mut reads = ReadSet::default();
reads.record_row(3, RowId(9));
reads.record_row(3, RowId(9));
reads.record_row(4, RowId(1));
let mut predicates = PredicateSet::default();
predicates.record_table(5);
let keys = ssi_validation_keys(&reads, &predicates);
assert_eq!(keys.len(), 3);
assert!(keys.iter().any(|key| matches!(
key,
WriteKey::Row {
table_id: 3,
row_id: 9
}
)));
assert!(keys.iter().any(|key| matches!(
key,
WriteKey::Row {
table_id: 4,
row_id: 1
}
)));
assert!(keys
.iter()
.any(|key| matches!(key, WriteKey::Table { table_id: 5 })));
}
fn test_request(key: &str) -> IdempotencyRequest {
IdempotencyRequest {
key: key.to_string(),
owner: "alice".to_string(),
fingerprint: 42,
ttl: None,
}
}
fn test_commit_ts() -> HlcTimestamp {
HlcTimestamp {
physical_micros: 1_700_000_000_000_000,
logical: 3,
node_tiebreaker: 0,
}
}
#[test]
fn idempotency_ledger_replay_conflict_and_restart() {
let dir = tempfile::tempdir().unwrap();
let root = std::sync::Arc::new(crate::durable_file::DurableRoot::open(dir.path()).unwrap());
let ledger = IdempotencyLedger::open(std::sync::Arc::clone(&root), None).unwrap();
let request = test_request("k1");
assert!(matches!(
ledger.check_and_reserve(&request).unwrap(),
IdempotencyCheck::Reserved
));
assert!(matches!(
ledger.check_and_reserve(&request),
Err(MongrelError::Conflict(_))
));
ledger
.complete(&request, 7, Epoch(11), test_commit_ts())
.unwrap();
let replay = ledger.check_and_reserve(&request).unwrap();
let IdempotencyCheck::Replay(receipt) = replay else {
panic!("expected replay");
};
assert_eq!(receipt.log_position.index, 11);
assert_eq!(receipt.commit_ts, test_commit_ts());
assert_eq!(
receipt.durability,
mongreldb_log::DurabilityLevel::GroupCommit
);
let mut other = test_request("k1");
other.fingerprint = 43;
assert!(matches!(
ledger.check_and_reserve(&other),
Err(MongrelError::Conflict(_))
));
let mut foreign = test_request("k1");
foreign.owner = "bob".to_string();
assert!(matches!(
ledger.check_and_reserve(&foreign).unwrap(),
IdempotencyCheck::Reserved
));
drop(ledger);
let reopened = IdempotencyLedger::open(root, None).unwrap();
let replay = reopened.check_and_reserve(&request).unwrap();
let IdempotencyCheck::Replay(receipt) = replay else {
panic!("expected replay after restart");
};
assert_eq!(receipt.log_position.index, 11);
assert_eq!(receipt.commit_ts, test_commit_ts());
assert!(matches!(
reopened.check_and_reserve(&foreign),
Err(MongrelError::Conflict(_))
));
}
#[test]
fn idempotency_ledger_release_and_expiry() {
let dir = tempfile::tempdir().unwrap();
let root = std::sync::Arc::new(crate::durable_file::DurableRoot::open(dir.path()).unwrap());
let ledger = IdempotencyLedger::open(std::sync::Arc::clone(&root), None).unwrap();
let request = test_request("k-release");
assert!(matches!(
ledger.check_and_reserve(&request).unwrap(),
IdempotencyCheck::Reserved
));
ledger.release(&request);
assert!(matches!(
ledger.check_and_reserve(&request).unwrap(),
IdempotencyCheck::Reserved
));
ledger
.complete(&request, 9, Epoch(12), test_commit_ts())
.unwrap();
let mut expired = test_request("k-expired");
expired.ttl = Some(Duration::from_nanos(1));
assert!(matches!(
ledger.check_and_reserve(&expired).unwrap(),
IdempotencyCheck::Reserved
));
ledger
.complete(&expired, 10, Epoch(13), test_commit_ts())
.unwrap();
std::thread::sleep(Duration::from_millis(2));
assert!(matches!(
ledger.check_and_reserve(&expired).unwrap(),
IdempotencyCheck::Reserved
));
drop(ledger);
let reopened = IdempotencyLedger::open(root, None).unwrap();
assert!(matches!(
reopened.check_and_reserve(&expired).unwrap(),
IdempotencyCheck::Reserved
));
}
#[test]
fn idempotency_ledger_rejects_empty_key_or_owner() {
let dir = tempfile::tempdir().unwrap();
let root = std::sync::Arc::new(crate::durable_file::DurableRoot::open(dir.path()).unwrap());
let ledger = IdempotencyLedger::open(root, None).unwrap();
let mut request = test_request("");
assert!(matches!(
ledger.check_and_reserve(&request),
Err(MongrelError::InvalidArgument(_))
));
request.key = "k".to_string();
request.owner.clear();
assert!(matches!(
ledger.check_and_reserve(&request),
Err(MongrelError::InvalidArgument(_))
));
}
#[test]
fn idempotency_ledger_enforces_bounded_size() {
let mut records: Vec<StoredIdempotencyRecord> = (0..MAX_IDEMPOTENCY_RECORDS + 4)
.map(|index| StoredIdempotencyRecord {
owner: "o".to_string(),
key: format!("k{index}"),
fingerprint: 1,
expires_at_micros: None,
outcome: StoredIdempotencyOutcome::Committed {
txn_id: index as u64,
epoch: index as u64,
commit_ts: test_commit_ts(),
},
})
.collect();
enforce_bounds(&mut records).unwrap();
assert_eq!(records.len(), MAX_IDEMPOTENCY_RECORDS);
assert!(records.iter().all(|record| match &record.outcome {
StoredIdempotencyOutcome::Committed { epoch, .. } => *epoch >= 4,
StoredIdempotencyOutcome::Reserved => false,
}));
let mut reserved: Vec<StoredIdempotencyRecord> = (0..MAX_IDEMPOTENCY_RECORDS + 1)
.map(|index| StoredIdempotencyRecord {
owner: "o".to_string(),
key: format!("r{index}"),
fingerprint: 1,
expires_at_micros: None,
outcome: StoredIdempotencyOutcome::Reserved,
})
.collect();
assert!(matches!(
enforce_bounds(&mut reserved),
Err(MongrelError::ResourceLimitExceeded { .. })
));
}
#[test]
fn idempotency_ledger_tampered_file_fails_closed() {
let dir = tempfile::tempdir().unwrap();
let root = std::sync::Arc::new(crate::durable_file::DurableRoot::open(dir.path()).unwrap());
let ledger = IdempotencyLedger::open(std::sync::Arc::clone(&root), None).unwrap();
let request = test_request("k1");
ledger.check_and_reserve(&request).unwrap();
drop(ledger);
let path = dir.path().join(IDEMPOTENCY_FILENAME);
let mut bytes = std::fs::read(&path).unwrap();
let last = bytes.len() - 1;
bytes[last] ^= 0xFF;
std::fs::write(&path, bytes).unwrap();
assert!(IdempotencyLedger::open(root, None).is_err());
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum IsolationLevel {
#[default]
RepeatableRead,
#[deprecated(
note = "renamed to `RepeatableRead` (spec §10.2 S1B-002); identical semantics, kept for compatibility"
)]
Snapshot,
ReadCommitted,
Serializable,
}
impl IsolationLevel {
pub fn canonical(self) -> Self {
match self {
#[allow(deprecated)]
Self::Snapshot => Self::RepeatableRead,
other => other,
}
}
}
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct IdempotencyRequest {
pub key: String,
pub owner: String,
pub fingerprint: u64,
pub ttl: Option<Duration>,
}
pub const IDEMPOTENCY_FILENAME: &str = "TXN_IDEMPOTENCY";
const IDEMPOTENCY_FORMAT_VERSION: u16 = 1;
const IDEMPOTENCY_MAGIC: &[u8; 8] = b"MONGRTXI";
const MAX_IDEMPOTENCY_RECORDS: usize = 65_536;
const MAX_IDEMPOTENCY_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
enum StoredIdempotencyOutcome {
Reserved,
Committed {
txn_id: u64,
epoch: u64,
commit_ts: HlcTimestamp,
},
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct StoredIdempotencyRecord {
owner: String,
key: String,
fingerprint: u64,
expires_at_micros: Option<u64>,
outcome: StoredIdempotencyOutcome,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct IdempotencyEnvelope {
format_version: u16,
records: Vec<StoredIdempotencyRecord>,
}
pub(crate) enum IdempotencyCheck {
Reserved,
Replay(mongreldb_log::CommitReceipt),
}
pub(crate) struct IdempotencyLedger {
root: std::sync::Arc<crate::durable_file::DurableRoot>,
meta_dek: Option<[u8; crate::catalog::META_DEK_LEN]>,
inner: PlMutex<Vec<StoredIdempotencyRecord>>,
}
impl IdempotencyLedger {
pub(crate) fn open(
root: std::sync::Arc<crate::durable_file::DurableRoot>,
meta_dek: Option<[u8; crate::catalog::META_DEK_LEN]>,
) -> Result<Self> {
let mut inner = read_idempotency_file(&root, meta_dek.as_ref())?.unwrap_or_default();
sweep_expired(&mut inner, wall_micros());
Ok(Self {
root,
meta_dek,
inner: PlMutex::new(inner),
})
}
pub(crate) fn check_and_reserve(
&self,
request: &IdempotencyRequest,
) -> Result<IdempotencyCheck> {
validate_request(request)?;
let now = wall_micros();
let mut inner = self.inner.lock();
sweep_expired(&mut inner, now);
if let Some(existing) = inner
.iter()
.find(|record| record.owner == request.owner && record.key == request.key)
{
if existing.fingerprint != request.fingerprint {
return Err(MongrelError::Conflict(format!(
"idempotency key {:?} was already used with a different request",
request.key
)));
}
return match &existing.outcome {
StoredIdempotencyOutcome::Committed {
txn_id,
epoch,
commit_ts,
} => Ok(IdempotencyCheck::Replay(mongreldb_log::CommitReceipt {
transaction_id: crate::commit_log::transaction_id_from_txn(*txn_id),
commit_ts: *commit_ts,
log_position: mongreldb_log::LogPosition {
term: 0,
index: *epoch,
},
durability: mongreldb_log::DurabilityLevel::GroupCommit,
})),
StoredIdempotencyOutcome::Reserved => Err(MongrelError::Conflict(format!(
"idempotency key {:?} has an in-flight or interrupted commit; retry to resolve",
request.key
))),
};
}
inner.push(StoredIdempotencyRecord {
owner: request.owner.clone(),
key: request.key.clone(),
fingerprint: request.fingerprint,
expires_at_micros: expiry_micros(request.ttl, now),
outcome: StoredIdempotencyOutcome::Reserved,
});
persist_locked(&self.root, self.meta_dek.as_ref(), &mut inner)?;
Ok(IdempotencyCheck::Reserved)
}
pub(crate) fn complete(
&self,
request: &IdempotencyRequest,
txn_id: u64,
epoch: Epoch,
commit_ts: HlcTimestamp,
) -> Result<()> {
let mut inner = self.inner.lock();
let now = wall_micros();
let Some(record) = inner
.iter_mut()
.find(|record| record.owner == request.owner && record.key == request.key)
else {
return Err(MongrelError::Other(format!(
"idempotency reservation for key {:?} vanished during commit",
request.key
)));
};
record.expires_at_micros = expiry_micros(request.ttl, now);
record.outcome = StoredIdempotencyOutcome::Committed {
txn_id,
epoch: epoch.0,
commit_ts,
};
persist_locked(&self.root, self.meta_dek.as_ref(), &mut inner)
}
pub(crate) fn release(&self, request: &IdempotencyRequest) {
let mut inner = self.inner.lock();
inner.retain(|record| {
!(record.owner == request.owner
&& record.key == request.key
&& matches!(record.outcome, StoredIdempotencyOutcome::Reserved))
});
let _ = persist_locked(&self.root, self.meta_dek.as_ref(), &mut inner);
}
}
pub(crate) struct IdempotencyReservationGuard<'a> {
ledger: &'a IdempotencyLedger,
request: IdempotencyRequest,
armed: bool,
}
impl<'a> IdempotencyReservationGuard<'a> {
pub(crate) fn new(ledger: &'a IdempotencyLedger, request: IdempotencyRequest) -> Self {
Self {
ledger,
request,
armed: true,
}
}
pub(crate) fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for IdempotencyReservationGuard<'_> {
fn drop(&mut self) {
if self.armed {
self.ledger.release(&self.request);
}
}
}
fn validate_request(request: &IdempotencyRequest) -> Result<()> {
if request.key.is_empty() {
return Err(MongrelError::InvalidArgument(
"idempotency key must not be empty".into(),
));
}
if request.owner.is_empty() {
return Err(MongrelError::InvalidArgument(
"idempotency owner must not be empty".into(),
));
}
Ok(())
}
fn expiry_micros(ttl: Option<Duration>, now_micros: u64) -> Option<u64> {
ttl.map(|ttl| now_micros.saturating_add(u64::try_from(ttl.as_micros()).unwrap_or(u64::MAX)))
}
fn sweep_expired(records: &mut Vec<StoredIdempotencyRecord>, now_micros: u64) {
records.retain(|record| {
record
.expires_at_micros
.is_none_or(|expires_at| expires_at > now_micros)
});
}
fn enforce_bounds(records: &mut Vec<StoredIdempotencyRecord>) -> Result<()> {
while records.len() > MAX_IDEMPOTENCY_RECORDS {
let Some((oldest, _)) = records
.iter()
.enumerate()
.filter(|(_, record)| {
matches!(record.outcome, StoredIdempotencyOutcome::Committed { .. })
})
.min_by_key(|(_, record)| match &record.outcome {
StoredIdempotencyOutcome::Committed { epoch, .. } => *epoch,
StoredIdempotencyOutcome::Reserved => unreachable!(),
})
else {
return Err(MongrelError::ResourceLimitExceeded {
resource: "idempotency records",
requested: records.len(),
limit: MAX_IDEMPOTENCY_RECORDS,
});
};
records.remove(oldest);
}
Ok(())
}
fn persist_locked(
root: &crate::durable_file::DurableRoot,
meta_dek: Option<&[u8; crate::catalog::META_DEK_LEN]>,
records: &mut Vec<StoredIdempotencyRecord>,
) -> Result<()> {
enforce_bounds(records)?;
let body = serde_json::to_vec(&IdempotencyEnvelope {
format_version: IDEMPOTENCY_FORMAT_VERSION,
records: records.clone(),
})
.map_err(|error| MongrelError::Other(format!("idempotency ledger serialize: {error}")))?;
let payload = seal_idempotency(&body, meta_dek)?;
root.write_atomic(IDEMPOTENCY_FILENAME, &payload)?;
Ok(())
}
fn read_idempotency_file(
root: &crate::durable_file::DurableRoot,
meta_dek: Option<&[u8; crate::catalog::META_DEK_LEN]>,
) -> Result<Option<Vec<StoredIdempotencyRecord>>> {
use std::io::Read as _;
let file = match root.open_regular(IDEMPOTENCY_FILENAME) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
let length = file.metadata()?.len();
if length > MAX_IDEMPOTENCY_BYTES {
return Err(MongrelError::Other(format!(
"idempotency ledger of {length} bytes exceeds the {MAX_IDEMPOTENCY_BYTES}-byte limit"
)));
}
let mut bytes = Vec::with_capacity(length as usize);
file.take(MAX_IDEMPOTENCY_BYTES + 1)
.read_to_end(&mut bytes)?;
if bytes.len() as u64 != length {
return Err(MongrelError::Other(
"idempotency ledger length changed while reading".into(),
));
}
open_idempotency_payload(&bytes, meta_dek).map(Some)
}
fn decode_idempotency(body: &[u8]) -> Result<Vec<StoredIdempotencyRecord>> {
let envelope: IdempotencyEnvelope = serde_json::from_slice(body)
.map_err(|error| MongrelError::Other(format!("idempotency ledger deserialize: {error}")))?;
if envelope.format_version != IDEMPOTENCY_FORMAT_VERSION {
return Err(MongrelError::Other(format!(
"unsupported idempotency ledger format version {}",
envelope.format_version
)));
}
Ok(envelope.records)
}
fn plaintext_idempotency_frame(body: &[u8]) -> Vec<u8> {
use sha2::Digest as _;
let hash = sha2::Sha256::digest(body);
let mut out = Vec::with_capacity(body.len() + 8 + 32);
out.extend_from_slice(IDEMPOTENCY_MAGIC);
out.extend_from_slice(&hash);
out.extend_from_slice(body);
out
}
fn parse_idempotency_plaintext(bytes: &[u8]) -> Result<Vec<StoredIdempotencyRecord>> {
use sha2::Digest as _;
if bytes.len() < 8 + 32 || &bytes[..8] != IDEMPOTENCY_MAGIC {
return Err(MongrelError::Other(
"idempotency ledger magic mismatch (corrupt or sealed with a key)".into(),
));
}
let (tag, body) = bytes[8..].split_at(32);
let calc = sha2::Sha256::digest(body);
if tag != calc.as_slice() {
return Err(MongrelError::Other(
"idempotency ledger checksum mismatch (tampered or torn)".into(),
));
}
decode_idempotency(body)
}
#[cfg(feature = "encryption")]
fn seal_idempotency(
body: &[u8],
meta_dek: Option<&[u8; crate::catalog::META_DEK_LEN]>,
) -> Result<Vec<u8>> {
match meta_dek {
Some(dek) => crate::encryption::encrypt_blob(dek, body),
None => Ok(plaintext_idempotency_frame(body)),
}
}
#[cfg(not(feature = "encryption"))]
fn seal_idempotency(
body: &[u8],
meta_dek: Option<&[u8; crate::catalog::META_DEK_LEN]>,
) -> Result<Vec<u8>> {
if meta_dek.is_some() {
return Err(MongrelError::Encryption(
"a metadata key was supplied but the `encryption` feature is disabled".into(),
));
}
Ok(plaintext_idempotency_frame(body))
}
#[cfg(feature = "encryption")]
fn open_idempotency_payload(
bytes: &[u8],
meta_dek: Option<&[u8; crate::catalog::META_DEK_LEN]>,
) -> Result<Vec<StoredIdempotencyRecord>> {
match meta_dek {
Some(dek) => {
let body = crate::encryption::decrypt_blob(dek, bytes).map_err(|_| {
MongrelError::Decryption(
"idempotency ledger authentication failed (wrong key or tampered)".into(),
)
})?;
decode_idempotency(&body)
}
None => parse_idempotency_plaintext(bytes),
}
}
#[cfg(not(feature = "encryption"))]
fn open_idempotency_payload(
bytes: &[u8],
meta_dek: Option<&[u8; crate::catalog::META_DEK_LEN]>,
) -> Result<Vec<StoredIdempotencyRecord>> {
if meta_dek.is_some() {
return Err(MongrelError::Encryption(
"a metadata key was supplied but the `encryption` feature is disabled".into(),
));
}
parse_idempotency_plaintext(bytes)
}
fn wall_micros() -> u64 {
let micros = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_micros())
.unwrap_or(0);
u64::try_from(micros).unwrap_or(u64::MAX)
}