Skip to main content

mongreldb_core/
txn.rs

1//! Cross-table transactions on the shared WAL (spec §8.2, single-applier subset
2//! — parallelism arrives in P3).
3//!
4//! A [`Transaction`] stages puts/deletes keyed by table; [`Transaction::commit`]
5//! reserves a commit epoch from the shared authority, appends the staged data
6//! records + a `TxnCommit` marker to the shared WAL, group-fsyncs, applies the
7//! staging to each table's memtable + indexes at the commit epoch, persists the
8//! per-table manifests, and publishes the visible watermark. Rollback (or a
9//! dropped transaction) discards the staging and appends nothing durable.
10//!
11//! ## Stage 1B (spec §10.2)
12//!
13//! - S1B-001: every transaction carries a formal [`TransactionState`]
14//!   (`Active` → `Preparing` → `CommitCritical` → `Committed`/`Aborted`),
15//!   its [`IsolationLevel`], its HLC read timestamp, and its read/write/
16//!   predicate sets.
17//! - S1B-002: `ReadCommitted` re-pins its read snapshot per statement,
18//!   `RepeatableRead` keeps the fixed-at-begin snapshot (the historical
19//!   `Snapshot` name is a deprecated alias), and `Serializable` adds
20//!   SSI-style read/predicate-set certification at commit: when a concurrent
21//!   commit invalidated anything the transaction read (a dangerous
22//!   structure), the commit aborts with a serialization failure instead of
23//!   allowing a non-serializable interleaving.
24//! - S1B-005: [`Transaction::commit_idempotent`] accepts an idempotency key
25//!   plus owner plus request fingerprint plus expiry; a repeated key with an
26//!   identical request replays the original commit receipt, a repeated key
27//!   with a different request conflicts, and records persist durably in a
28//!   sibling `TXN_IDEMPOTENCY` file (mirroring `jobs.rs`'s `JOBS` pattern).
29
30use crate::database::{Database, ExternalTriggerBridge, TableHandle};
31use crate::epoch::{Epoch, Snapshot};
32use crate::error::{MongrelError, Result};
33use crate::memtable::Value;
34use crate::rowid::RowId;
35use crate::wal::SharedWal;
36use mongreldb_types::hlc::HlcTimestamp;
37use parking_lot::{Condvar, Mutex as PlMutex};
38use std::sync::Arc;
39
40pub(crate) fn allocate_txn_id(allocator: &PlMutex<u64>) -> Result<u64> {
41    let mut next = allocator.lock();
42    let id = *next;
43    if id == crate::wal::SYSTEM_TXN_ID || id & u32::MAX as u64 == 0 {
44        return Err(MongrelError::Full(
45            "per-open transaction id namespace exhausted; reopen the database".into(),
46        ));
47    }
48    *next = id.checked_add(1).ok_or_else(|| {
49        MongrelError::Full(
50            "per-open transaction id namespace exhausted; reopen the database".into(),
51        )
52    })?;
53    Ok(id)
54}
55
56/// One staged mutation against a named table.
57pub(crate) enum Staged {
58    Put(Vec<(u16, Value)>),
59    Delete(RowId),
60    /// Full post-update row image plus the logical columns changed by this
61    /// operation. Authorization uses `changed_columns`; constraints, RLS,
62    /// triggers, WAL publication, and index maintenance use `new_row`.
63    Update {
64        row_id: RowId,
65        new_row: Vec<(u16, Value)>,
66        changed_columns: Vec<u16>,
67    },
68    Truncate,
69}
70
71#[derive(Debug, Clone)]
72pub struct OwnedRow {
73    pub columns: Vec<(u16, Value)>,
74}
75
76#[derive(Debug, Clone)]
77pub struct PutResult {
78    pub auto_inc: Option<i64>,
79    pub row: OwnedRow,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum UpsertActionKind {
84    Inserted,
85    Updated,
86    Unchanged,
87}
88
89#[derive(Debug, Clone)]
90pub enum UpsertAction {
91    DoNothing,
92    DoUpdate(Vec<(u16, Value)>),
93}
94
95#[derive(Debug, Clone)]
96pub struct UpsertResult {
97    pub action: UpsertActionKind,
98    pub row: OwnedRow,
99    pub auto_inc: Option<i64>,
100}
101
102// ── S1B-001: formal transaction state (spec §10.2) ───────────────────────
103
104/// Why a transaction ended without a commit receipt (spec §10.2, S1B-001).
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum AbortReason {
107    /// Explicit rollback, or the transaction was dropped while still active.
108    RolledBack,
109    /// Write/write conflict (first-committer-wins) or an SSI serialization
110    /// failure detected at commit.
111    Conflict(String),
112    /// Constraint, authorization, or catalog validation failed before the
113    /// commit fence.
114    Validation(String),
115    /// Cancellation or deadline before the commit fence.
116    Cancelled(String),
117    /// Any other pre-fence failure.
118    Error(String),
119}
120
121/// Formal transaction state (spec §10.2, S1B-001).
122///
123/// Once a commit is `CommitCritical` its outcome may be durable; from there
124/// the only honest transition is `Committed(receipt)`. A post-fence failure
125/// (unknown outcome) leaves the state `CommitCritical` rather than reporting
126/// an abort that may be false (spec §4.7).
127#[derive(Debug, Clone)]
128pub enum TransactionState {
129    /// Staging writes; no commit attempted.
130    Active,
131    /// Commit entered: preparation and validation are underway, nothing can
132    /// be durable yet.
133    Preparing,
134    /// Commit timestamp assigned and the commit proposal in flight; the
135    /// outcome may already be durable.
136    CommitCritical,
137    /// Durable and published; carries the commit log's irrevocable receipt.
138    Committed(mongreldb_log::CommitReceipt),
139    /// Ended before the commit fence with nothing durable.
140    Aborted(AbortReason),
141}
142
143/// Shared, inspectable handle to a transaction's formal state (S1B-001).
144///
145/// Transitions are enforced: `Active → Preparing → CommitCritical →
146/// Committed`, and `Aborted` is reachable only from `Active`/`Preparing`.
147/// A transaction outlives its `commit()` call through this handle, so tests
148/// and higher layers can inspect the final state after the `Transaction`
149/// value itself is consumed.
150#[derive(Debug, Clone)]
151pub struct TxnStateHandle {
152    inner: std::sync::Arc<PlMutex<TransactionState>>,
153}
154
155impl TxnStateHandle {
156    fn new() -> Self {
157        Self {
158            inner: std::sync::Arc::new(PlMutex::new(TransactionState::Active)),
159        }
160    }
161
162    /// The current state (cloned).
163    pub fn state(&self) -> TransactionState {
164        self.inner.lock().clone()
165    }
166
167    /// Transition to `next` when legal; returns whether the transition
168    /// happened. Illegal transitions are rejected (the state is unchanged).
169    fn transition(&self, next: TransactionState) -> bool {
170        let mut current = self.inner.lock();
171        let legal = matches!(
172            (&*current, &next),
173            (TransactionState::Active, TransactionState::Preparing)
174                | (TransactionState::Active, TransactionState::Aborted(_))
175                | (
176                    TransactionState::Preparing,
177                    TransactionState::CommitCritical
178                )
179                | (TransactionState::Preparing, TransactionState::Aborted(_))
180                // Idempotent replay (S1B-005): the commit resolved to a
181                // pre-existing receipt without entering commit-critical.
182                | (TransactionState::Preparing, TransactionState::Committed(_))
183                | (
184                    TransactionState::CommitCritical,
185                    TransactionState::Committed(_)
186                )
187        );
188        if legal {
189            *current = next;
190        }
191        legal
192    }
193
194    pub(crate) fn begin_prepare(&self) -> bool {
195        self.transition(TransactionState::Preparing)
196    }
197
198    pub(crate) fn enter_commit_critical(&self) -> bool {
199        self.transition(TransactionState::CommitCritical)
200    }
201
202    pub(crate) fn committed(&self, receipt: mongreldb_log::CommitReceipt) -> bool {
203        self.transition(TransactionState::Committed(receipt))
204    }
205
206    /// Abort from `Active`/`Preparing`. From `CommitCritical` this is a no-op
207    /// returning `false`: a possibly-durable commit is never reported aborted.
208    pub fn abort(&self, reason: AbortReason) -> bool {
209        self.transition(TransactionState::Aborted(reason))
210    }
211}
212
213/// Map a commit-path error onto the transaction state: pre-fence failures
214/// abort; post-fence unknown-outcome errors leave `CommitCritical` intact.
215pub(crate) fn classify_commit_error(state: &TxnStateHandle, error: &MongrelError) {
216    let reason = match error {
217        MongrelError::Conflict(message) => AbortReason::Conflict(message.clone()),
218        // SSI certification abort: the same retry-the-whole-transaction
219        // discipline as a write/write conflict (taxonomy category 8 keeps it
220        // precise for bindings). The recorded message keeps the variant's
221        // display prefix so state inspection reads exactly like the error.
222        MongrelError::SerializationFailure { message } => {
223            AbortReason::Conflict(format!("serialization failure: {message}"))
224        }
225        MongrelError::Cancelled => AbortReason::Cancelled("cancelled".into()),
226        MongrelError::DeadlineExceeded => AbortReason::Cancelled("deadline exceeded".into()),
227        // Post-fence: the commit may be durable (spec §4.7). Do not abort.
228        MongrelError::CommitOutcomeUnknown { .. } | MongrelError::DurableCommit { .. } => return,
229        MongrelError::InvalidArgument(message)
230        | MongrelError::Schema(message)
231        | MongrelError::TriggerValidation(message) => AbortReason::Validation(message.clone()),
232        other => AbortReason::Error(other.to_string()),
233    };
234    state.abort(reason);
235}
236
237// ── S1B-001/002: read, write, and predicate sets ─────────────────────────
238
239/// Point reads a transaction performed, in shared `(table_id, row_id)` space
240/// (spec §10.2, S1B-001). Recorded at every isolation level; certified
241/// against the conflict index at commit time for `Serializable`.
242#[derive(Debug, Clone, Default)]
243pub struct ReadSet {
244    rows: std::collections::BTreeSet<(u64, u64)>,
245}
246
247impl ReadSet {
248    pub(crate) fn record_row(&mut self, table_id: u64, row_id: RowId) {
249        self.rows.insert((table_id, row_id.0));
250    }
251
252    /// The recorded `(table_id, row_id)` point reads.
253    pub fn rows(&self) -> impl Iterator<Item = (u64, u64)> + '_ {
254        self.rows.iter().copied()
255    }
256
257    pub fn len(&self) -> usize {
258        self.rows.len()
259    }
260
261    pub fn is_empty(&self) -> bool {
262        self.rows.is_empty()
263    }
264}
265
266/// Predicate/range reads at table granularity (spec §10.2, S1B-002). A scan
267/// or range lookup registers the table here; `Serializable` certification
268/// treats any concurrent write on a registered table as a phantom
269/// invalidation. Table granularity is deliberately conservative — the
270/// single-node commit path trades some false-positive aborts for a simple,
271/// sound phantom check.
272#[derive(Debug, Clone, Default)]
273pub struct PredicateSet {
274    tables: std::collections::BTreeSet<u64>,
275}
276
277impl PredicateSet {
278    pub(crate) fn record_table(&mut self, table_id: u64) {
279        self.tables.insert(table_id);
280    }
281
282    /// The tables with registered predicate/range reads.
283    pub fn tables(&self) -> impl Iterator<Item = u64> + '_ {
284        self.tables.iter().copied()
285    }
286
287    pub fn len(&self) -> usize {
288        self.tables.len()
289    }
290
291    pub fn is_empty(&self) -> bool {
292        self.tables.is_empty()
293    }
294}
295
296/// The SSI certification keys for a commit (S1B-002): every point read as a
297/// row key, every predicate read as a table key, probed against the
298/// [`ConflictIndex`] exactly like first-committer-wins write keys.
299pub(crate) fn ssi_validation_keys(
300    read_set: &ReadSet,
301    predicate_set: &PredicateSet,
302) -> Vec<WriteKey> {
303    let mut keys = Vec::with_capacity(read_set.len() + predicate_set.len());
304    for (table_id, row_id) in read_set.rows() {
305        keys.push(WriteKey::Row { table_id, row_id });
306    }
307    for table_id in predicate_set.tables() {
308        keys.push(WriteKey::Table { table_id });
309    }
310    keys
311}
312
313// ── S1B-004/005: per-commit context and idempotency ──────────────────────
314
315/// Per-commit context threaded from a [`Transaction`] into the commit
316/// sequencer (spec §10.2, S1B-001/002/004/005).
317pub(crate) struct TxnCommitContext {
318    /// Isolation level the transaction ran at.
319    pub isolation: IsolationLevel,
320    /// HLC read timestamp captured at `begin` (spec §8.2 participant
321    /// timestamp). `None` when clock-skew rejection deferred allocation to
322    /// the commit path, or for internal commits with no transaction.
323    pub read_ts: Option<HlcTimestamp>,
324    /// Tracked point reads (SSI certification input).
325    pub read_set: ReadSet,
326    /// Tracked predicate/range reads (SSI certification input).
327    pub predicate_set: PredicateSet,
328    /// Formal state handle driven through the commit protocol.
329    pub state: Option<TxnStateHandle>,
330    /// Optional idempotency parameters (S1B-005).
331    pub idempotency: Option<IdempotencyRequest>,
332}
333
334impl TxnCommitContext {
335    /// Internal commit paths (catalog backfills, external-table state) that do
336    /// not originate from a user `Transaction`: repeatable-read semantics, no
337    /// tracked reads, no state handle, no idempotency.
338    pub(crate) fn internal() -> Self {
339        Self {
340            isolation: IsolationLevel::RepeatableRead,
341            read_ts: None,
342            read_set: ReadSet::default(),
343            predicate_set: PredicateSet::default(),
344            state: None,
345            idempotency: None,
346        }
347    }
348}
349
350/// An in-flight cross-table transaction. Holds a read snapshot taken at `begin`
351/// and stages writes; nothing is durable or visible until [`Self::commit`].
352///
353/// S1B-001 state: the transaction carries its [`IsolationLevel`], the HLC read
354/// timestamp captured at `begin`, its read/predicate sets, and a formal
355/// [`TransactionState`] inspectable through [`Self::state`]/[`Self::state_handle`].
356pub struct Transaction<'db> {
357    db: &'db Database,
358    txn_id: u64,
359    allocation_error: Option<String>,
360    isolation: IsolationLevel,
361    read: Snapshot,
362    read_ts: Option<HlcTimestamp>,
363    read_set: ReadSet,
364    predicate_set: PredicateSet,
365    state: TxnStateHandle,
366    staging: Vec<(u64 /*table_id*/, Staged)>,
367    external_states: Vec<(String, Vec<u8>)>,
368    materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
369    principal: Option<crate::auth::Principal>,
370    principal_catalog_bound: bool,
371    external_trigger_bridge: Option<&'db dyn ExternalTriggerBridge>,
372    _active: Option<ActiveTxnGuard<'db>>,
373}
374
375impl<'db> Transaction<'db> {
376    pub(crate) fn new(
377        db: &'db Database,
378        txn_id: Result<u64>,
379        read: Snapshot,
380        isolation: IsolationLevel,
381    ) -> Self {
382        let guard = db.register_active(read.epoch);
383        // §8.2: the transaction's read timestamp is a commit-timestamp
384        // participant. Skew rejection defers allocation to the commit path,
385        // which fails closed there instead.
386        let read_ts = db.hlc_clock().now().ok();
387        let (txn_id, allocation_error) = match txn_id {
388            Ok(txn_id) => (txn_id, None),
389            Err(MongrelError::Full(message)) => (crate::wal::SYSTEM_TXN_ID, Some(message)),
390            Err(error) => (crate::wal::SYSTEM_TXN_ID, Some(error.to_string())),
391        };
392        Self {
393            db,
394            txn_id,
395            allocation_error,
396            isolation,
397            read,
398            read_ts,
399            read_set: ReadSet::default(),
400            predicate_set: PredicateSet::default(),
401            state: TxnStateHandle::new(),
402            staging: Vec::new(),
403            external_states: Vec::new(),
404            materialized_view_updates: Vec::new(),
405            principal: None,
406            principal_catalog_bound: false,
407            external_trigger_bridge: None,
408            _active: Some(guard),
409        }
410    }
411
412    pub(crate) fn with_external_trigger_bridge(
413        mut self,
414        bridge: &'db dyn ExternalTriggerBridge,
415    ) -> Self {
416        self.external_trigger_bridge = Some(bridge);
417        self
418    }
419
420    pub(crate) fn with_principal(
421        mut self,
422        principal: Option<crate::auth::Principal>,
423        catalog_bound: bool,
424    ) -> Self {
425        self.principal = principal;
426        self.principal_catalog_bound = catalog_bound;
427        self
428    }
429
430    pub fn read_snapshot(&self) -> Snapshot {
431        self.read
432    }
433
434    /// The transaction's id (generation-scoped: high 32 bits = open generation,
435    /// low 32 = per-open counter). Mainly diagnostic / test-facing.
436    pub fn txn_id(&self) -> u64 {
437        self.txn_id
438    }
439
440    /// The isolation level this transaction was begun with (S1B-001/002).
441    pub fn isolation(&self) -> IsolationLevel {
442        self.isolation
443    }
444
445    /// The HLC read timestamp captured at `begin` (spec §8.2). `None` when
446    /// clock-skew rejection deferred allocation to the commit path.
447    pub fn read_ts(&self) -> Option<HlcTimestamp> {
448        self.read_ts
449    }
450
451    /// The current formal state (S1B-001).
452    pub fn state(&self) -> TransactionState {
453        self.state.state()
454    }
455
456    /// A cloneable handle to the formal state, inspectable after this value
457    /// is consumed by `commit`/`rollback` (S1B-001).
458    pub fn state_handle(&self) -> TxnStateHandle {
459        self.state.clone()
460    }
461
462    /// Point reads recorded so far (S1B-001; certified at `Serializable`).
463    pub fn read_set(&self) -> &ReadSet {
464        &self.read_set
465    }
466
467    /// Predicate/range reads recorded so far (S1B-001/002).
468    pub fn predicate_set(&self) -> &PredicateSet {
469        &self.predicate_set
470    }
471
472    /// The snapshot a statement reads at (S1B-002): `ReadCommitted` re-pins
473    /// at the latest visible epoch per statement; every other level reads
474    /// the fixed begin snapshot.
475    fn statement_snapshot(&self) -> Snapshot {
476        match self.isolation.canonical() {
477            IsolationLevel::ReadCommitted => self.db.visible_snapshot(),
478            _ => self.read,
479        }
480    }
481
482    /// Read one row at this transaction's current statement snapshot and
483    /// record the point read in the transaction's read set. `ReadCommitted`
484    /// statements observe commits that landed since the previous statement;
485    /// `RepeatableRead`/`Serializable` observe the fixed begin snapshot.
486    pub fn get(&mut self, table: &str, row_id: RowId) -> Result<Option<OwnedRow>> {
487        let snap = self.statement_snapshot();
488        let id = self.db.table_id(table)?;
489        let handle = self.db.table(table)?;
490        let row = handle.read().get(row_id, snap);
491        Ok(row.map(|row| {
492            self.read_set.record_row(id, row_id);
493            owned_row_from_map(row.columns)
494        }))
495    }
496
497    /// Register a table-granularity predicate/range read (S1B-002). Scan and
498    /// range-lookup paths report here so `Serializable` certification detects
499    /// phantoms: any concurrent write on the table then invalidates the read.
500    pub fn track_predicate_read(&mut self, table: &str) -> Result<()> {
501        let id = self.db.table_id(table)?;
502        self.predicate_set.record_table(id);
503        Ok(())
504    }
505
506    /// Stage a put on `table`. The row id is allocated at commit so an aborted
507    /// transaction never consumes ids. If the table has an `AUTO_INCREMENT`
508    /// primary key and the column is omitted or null, the engine fills it now
509    /// and returns the assigned value; explicit ids are honored and advance the
510    /// counter. The value is staged in `cells`, so the commit path writes the
511    /// same id into the row.
512    /// S1B-003: serialize auto-increment allocation for `table_id` on its
513    /// sequence barrier BEFORE the engine allocates, so concurrently
514    /// committing transactions' assigned values map monotonically onto commit
515    /// order. Held until the transaction ends (the commit-path guard, or the
516    /// drop release on rollback/abort). Acquired before any table lock —
517    /// never the reverse — to keep the lock order consistent with the commit
518    /// path, and only when this staging actually allocates (an explicit
519    /// auto-inc value advances the counter without allocation).
520    fn lock_auto_inc_barrier(&self, table_id: u64, cells: &[(u16, Value)]) -> Result<()> {
521        if self.db.table_auto_inc_would_allocate(table_id, cells) {
522            self.db.acquire_txn_lock(
523                self.txn_id,
524                crate::locks::LockKey::sequence_barrier(format!("auto_inc:{table_id}").as_str()),
525                crate::locks::LockMode::Exclusive,
526                None,
527            )?;
528        }
529        Ok(())
530    }
531
532    pub fn put(&mut self, table: &str, mut cells: Vec<(u16, Value)>) -> Result<Option<i64>> {
533        self.require_columns(table, crate::auth::ColumnOperation::Insert, &cells)?;
534        let id = self.db.table_id(table)?;
535        self.lock_auto_inc_barrier(id, &cells)?;
536        let handle = self.db.table(table)?;
537        let mut t = handle.lock();
538        let assigned = t.fill_auto_inc(&mut cells)?;
539        t.apply_defaults(&mut cells)?;
540        drop(t);
541        self.staging.push((id, Staged::Put(cells)));
542        Ok(assigned)
543    }
544
545    /// Stage a row in a hidden CTAS build table.
546    #[doc(hidden)]
547    pub fn put_building(
548        &mut self,
549        table: &str,
550        mut cells: Vec<(u16, Value)>,
551    ) -> Result<Option<i64>> {
552        self.db
553            .require_for(self.principal.as_ref(), &crate::auth::Permission::Ddl)?;
554        let id = self.db.building_table_id(table)?;
555        self.lock_auto_inc_barrier(id, &cells)?;
556        let handle = self.db.table_by_id(id)?;
557        let mut target = handle.lock();
558        let assigned = target.fill_auto_inc(&mut cells)?;
559        target.apply_defaults(&mut cells)?;
560        let primary_key_column = target
561            .schema()
562            .primary_key()
563            .map(|column| column.id)
564            .ok_or_else(|| MongrelError::Schema("CTAS build table has no primary key".into()))?;
565        let primary_key = cells
566            .iter()
567            .find(|(column, _)| *column == primary_key_column)
568            .map(|(_, value)| value)
569            .ok_or_else(|| MongrelError::InvalidArgument("CTAS primary key is missing".into()))?;
570        if matches!(primary_key, Value::Null) {
571            return Err(MongrelError::InvalidArgument(
572                "CTAS primary key cannot be NULL".into(),
573            ));
574        }
575        let primary_key = primary_key.encode_key();
576        let replacing = self
577            .staging
578            .iter()
579            .any(|(table_id, staged)| *table_id == id && matches!(staged, Staged::Truncate));
580        if !replacing && target.lookup_pk(&primary_key).is_some() {
581            return Err(MongrelError::InvalidArgument(
582                "duplicate CTAS primary key".into(),
583            ));
584        }
585        drop(target);
586        if self.staging.iter().any(|(staged_table, staged)| {
587            if *staged_table != id {
588                return false;
589            }
590            let Staged::Put(staged_cells) = staged else {
591                return false;
592            };
593            staged_cells
594                .iter()
595                .find(|(column, _)| *column == primary_key_column)
596                .is_some_and(|(_, value)| value.encode_key() == primary_key)
597        }) {
598            return Err(MongrelError::InvalidArgument(
599                "duplicate CTAS primary key".into(),
600            ));
601        }
602        self.staging.push((id, Staged::Put(cells)));
603        Ok(assigned)
604    }
605
606    /// Stage a truncate against an unpublished building table.
607    #[doc(hidden)]
608    pub fn truncate_building(&mut self, table: &str) -> Result<()> {
609        self.db
610            .require_for(self.principal.as_ref(), &crate::auth::Permission::Ddl)?;
611        let id = self.db.building_table_id(table)?;
612        if self.staging.iter().any(|(table_id, _)| *table_id == id) {
613            return Err(MongrelError::InvalidArgument(
614                "building-table truncate must be staged before replacement rows".into(),
615            ));
616        }
617        self.staging.push((id, Staged::Truncate));
618        Ok(())
619    }
620
621    pub fn put_returning(
622        &mut self,
623        table: &str,
624        mut cells: Vec<(u16, Value)>,
625    ) -> Result<PutResult> {
626        self.require_columns(table, crate::auth::ColumnOperation::Insert, &cells)?;
627        let id = self.db.table_id(table)?;
628        self.lock_auto_inc_barrier(id, &cells)?;
629        let handle = self.db.table(table)?;
630        let mut t = handle.lock();
631        let assigned = t.fill_auto_inc(&mut cells)?;
632        t.apply_defaults(&mut cells)?;
633        drop(t);
634        let row = owned_row_from_cells(&cells);
635        self.staging.push((id, Staged::Put(cells)));
636        Ok(PutResult {
637            auto_inc: assigned,
638            row,
639        })
640    }
641
642    /// Stage a returning put only if `table` still names the exact catalog
643    /// resource checked by the caller.
644    #[doc(hidden)]
645    pub fn put_returning_bound(
646        &mut self,
647        table: &str,
648        expected_table_id: u64,
649        expected_schema_id: u64,
650        mut cells: Vec<(u16, Value)>,
651    ) -> Result<PutResult> {
652        self.require_columns(table, crate::auth::ColumnOperation::Insert, &cells)?;
653        self.lock_auto_inc_barrier(expected_table_id, &cells)?;
654        let handle = self.bound_table(table, expected_table_id, expected_schema_id)?;
655        let mut target = handle.lock();
656        let assigned = target.fill_auto_inc(&mut cells)?;
657        target.apply_defaults(&mut cells)?;
658        drop(target);
659        let row = owned_row_from_cells(&cells);
660        self.staging.push((expected_table_id, Staged::Put(cells)));
661        Ok(PutResult {
662            auto_inc: assigned,
663            row,
664        })
665    }
666
667    /// Stage many puts on the same `table` with one table-id lookup + one
668    /// auto-inc lock pass. Each row is staged individually (same as repeated
669    /// `put`); the savings are the amortized lookups/locks for bulk guard-row
670    /// writes and batched application-row inserts. Returns the assigned
671    /// auto-increment values (`Some` only where the engine filled the column).
672    pub fn put_batch(
673        &mut self,
674        table: &str,
675        rows: Vec<Vec<(u16, Value)>>,
676    ) -> Result<Vec<Option<i64>>> {
677        if !rows.is_empty() {
678            let mut columns = rows
679                .iter()
680                .flat_map(|cells| cells.iter().map(|(column, _)| *column))
681                .collect::<Vec<_>>();
682            columns.sort_unstable();
683            columns.dedup();
684            self.db.require_columns_for(
685                table,
686                crate::auth::ColumnOperation::Insert,
687                &columns,
688                self.principal.as_ref(),
689            )?;
690        }
691        let id = self.db.table_id(table)?;
692        for cells in &rows {
693            self.lock_auto_inc_barrier(id, cells)?;
694        }
695        let handle = self.db.table(table)?;
696        let mut t = handle.lock();
697        let mut assigned = Vec::with_capacity(rows.len());
698        for mut cells in rows {
699            let a = t.fill_auto_inc(&mut cells)?;
700            t.apply_defaults(&mut cells)?;
701            assigned.push(a);
702            self.staging.push((id, Staged::Put(cells)));
703        }
704        drop(t);
705        Ok(assigned)
706    }
707
708    /// Stage a delete of `row_id` on `table`.
709    pub fn delete(&mut self, table: &str, row_id: RowId) -> Result<()> {
710        self.delete_batch(table, vec![row_id])
711    }
712
713    /// Stage a delete only against the exact table generation checked by the
714    /// caller.
715    #[doc(hidden)]
716    pub fn delete_bound(
717        &mut self,
718        table: &str,
719        expected_table_id: u64,
720        expected_schema_id: u64,
721        row_id: RowId,
722    ) -> Result<()> {
723        self.require_delete(table)?;
724        self.bound_table(table, expected_table_id, expected_schema_id)?;
725        self.reject_after_truncate(expected_table_id)?;
726        self.staging
727            .push((expected_table_id, Staged::Delete(row_id)));
728        Ok(())
729    }
730
731    /// Resolve and delete a primary key only on the exact table generation
732    /// checked by the caller. Returns `false` when the key is absent.
733    #[doc(hidden)]
734    pub fn delete_by_pk_bound(
735        &mut self,
736        table: &str,
737        expected_table_id: u64,
738        expected_schema_id: u64,
739        key: &Value,
740    ) -> Result<bool> {
741        self.require_delete(table)?;
742        let handle = self.bound_table(table, expected_table_id, expected_schema_id)?;
743        self.reject_after_truncate(expected_table_id)?;
744        let row_id = {
745            let mut target = handle.lock();
746            target.ensure_indexes_complete()?;
747            target.lookup_pk(&key.encode_key())
748        };
749        let Some(row_id) = row_id else {
750            return Ok(false);
751        };
752        self.read_set.record_row(expected_table_id, row_id);
753        self.staging
754            .push((expected_table_id, Staged::Delete(row_id)));
755        Ok(true)
756    }
757
758    /// Stage deletes without materializing pre-images.
759    pub fn delete_batch(&mut self, table: &str, row_ids: Vec<RowId>) -> Result<()> {
760        self.require_delete(table)?;
761        let id = self.db.table_id(table)?;
762        self.reject_after_truncate(id)?;
763        self.staging.extend(
764            row_ids
765                .into_iter()
766                .map(|row_id| (id, Staged::Delete(row_id))),
767        );
768        Ok(())
769    }
770
771    /// Stage opaque external-table module state. The payload is committed under
772    /// the same WAL `TxnCommit` as ordinary table writes.
773    pub fn put_external_state(&mut self, table: &str, state: Vec<u8>) -> Result<()> {
774        if self.db.external_table(table).is_none() {
775            return Err(MongrelError::NotFound(format!(
776                "external table {table:?} not found"
777            )));
778        }
779        self.external_states.push((table.to_string(), state));
780        Ok(())
781    }
782
783    /// Stage a materialized-view checkpoint in the same durable commit as its
784    /// row deltas. This makes incremental refresh replay idempotent after a
785    /// crash: data and the Last-Event-ID watermark advance together.
786    pub fn set_materialized_view_definition(
787        &mut self,
788        definition: crate::catalog::MaterializedViewEntry,
789    ) -> Result<()> {
790        self.db
791            .require_for(self.principal.as_ref(), &crate::auth::Permission::Ddl)?;
792        if self.db.table_id(&definition.name).is_err() {
793            return Err(MongrelError::NotFound(format!(
794                "materialized view table {:?} not found",
795                definition.name
796            )));
797        }
798        self.materialized_view_updates
799            .retain(|current| current.name != definition.name);
800        self.materialized_view_updates.push(definition);
801        Ok(())
802    }
803
804    pub fn delete_many(&mut self, table: &str, row_ids: Vec<RowId>) -> Result<Vec<OwnedRow>> {
805        self.require_delete(table)?;
806        let id = self.db.table_id(table)?;
807        self.reject_after_truncate(id)?;
808        let snap = self.statement_snapshot();
809        let handle = self.db.table(table)?;
810        let t = handle.lock();
811        let mut pre_images = Vec::with_capacity(row_ids.len());
812        for row_id in &row_ids {
813            if let Some(row) = t.get(*row_id, snap) {
814                pre_images.push(owned_row_from_map(row.columns));
815                self.read_set.record_row(id, *row_id);
816            }
817        }
818        drop(t);
819        for row_id in row_ids {
820            self.staging.push((id, Staged::Delete(row_id)));
821        }
822        Ok(pre_images)
823    }
824
825    pub fn update_many(
826        &mut self,
827        table: &str,
828        updates: Vec<(RowId, Vec<(u16, Value)>)>,
829    ) -> Result<Vec<OwnedRow>> {
830        if !updates.is_empty() {
831            let mut columns = updates
832                .iter()
833                .flat_map(|(_, cells)| cells.iter().map(|(column, _)| *column))
834                .collect::<Vec<_>>();
835            columns.sort_unstable();
836            columns.dedup();
837            self.db.require_columns_for(
838                table,
839                crate::auth::ColumnOperation::Update,
840                &columns,
841                self.principal.as_ref(),
842            )?;
843        }
844        let id = self.db.table_id(table)?;
845        self.reject_after_truncate(id)?;
846        let snap = self.statement_snapshot();
847        let handle = self.db.table(table)?;
848        let t = handle.lock();
849        let mut post_images = Vec::with_capacity(updates.len());
850        let mut staged = Vec::with_capacity(updates.len());
851        for (old_id, new_cells) in updates {
852            let changed_columns = changed_columns(&new_cells);
853            let old_row = t
854                .get(old_id, snap)
855                .ok_or_else(|| MongrelError::NotFound(format!("row {old_id:?} not found")))?;
856            self.read_set.record_row(id, old_id);
857            let merged = merge_cells(old_row.columns.into_iter().collect(), new_cells);
858            post_images.push(owned_row_from_cells(&merged));
859            staged.push((
860                id,
861                Staged::Update {
862                    row_id: old_id,
863                    new_row: merged,
864                    changed_columns,
865                },
866            ));
867        }
868        drop(t);
869        self.staging.extend(staged);
870        Ok(post_images)
871    }
872
873    pub fn upsert(
874        &mut self,
875        table: &str,
876        mut insert_cells: Vec<(u16, Value)>,
877        action: UpsertAction,
878    ) -> Result<UpsertResult> {
879        // Upsert may insert or update. Check Insert up front (the common
880        // path); the DoUpdate branch additionally checks Update before
881        // mutating an existing row.
882        self.require_columns(table, crate::auth::ColumnOperation::Insert, &insert_cells)?;
883        let id = self.db.table_id(table)?;
884        self.lock_auto_inc_barrier(id, &insert_cells)?;
885        self.reject_after_truncate(id)?;
886        match (self.existing_pk_row(table, &insert_cells)?, action) {
887            (None, _) => {
888                let handle = self.db.table(table)?;
889                let mut t = handle.lock();
890                let assigned = t.fill_auto_inc(&mut insert_cells)?;
891                t.apply_defaults(&mut insert_cells)?;
892                drop(t);
893                let row = owned_row_from_cells(&insert_cells);
894                self.staging.push((id, Staged::Put(insert_cells)));
895                Ok(UpsertResult {
896                    action: UpsertActionKind::Inserted,
897                    row,
898                    auto_inc: assigned,
899                })
900            }
901            (Some((_old_id, old_row)), UpsertAction::DoNothing) => Ok(UpsertResult {
902                action: UpsertActionKind::Unchanged,
903                row: old_row,
904                auto_inc: None,
905            }),
906            (Some((old_id, old_row)), UpsertAction::DoUpdate(update_cells)) => {
907                // The update branch requires Update permission.
908                self.require_columns(table, crate::auth::ColumnOperation::Update, &update_cells)?;
909                let changed_columns = changed_columns(&update_cells);
910                let merged = merge_cells(old_row.columns.clone(), update_cells);
911                if columns_equal(&old_row.columns, &merged) {
912                    return Ok(UpsertResult {
913                        action: UpsertActionKind::Unchanged,
914                        row: old_row,
915                        auto_inc: None,
916                    });
917                }
918                let row = owned_row_from_cells(&merged);
919                self.staging.push((
920                    id,
921                    Staged::Update {
922                        row_id: old_id,
923                        new_row: merged,
924                        changed_columns,
925                    },
926                ));
927                Ok(UpsertResult {
928                    action: UpsertActionKind::Updated,
929                    row,
930                    auto_inc: None,
931                })
932            }
933        }
934    }
935
936    /// Stage an upsert only if `table` still names the exact catalog resource
937    /// checked by the caller.
938    #[doc(hidden)]
939    pub fn upsert_bound(
940        &mut self,
941        table: &str,
942        expected_table_id: u64,
943        expected_schema_id: u64,
944        mut insert_cells: Vec<(u16, Value)>,
945        action: UpsertAction,
946    ) -> Result<UpsertResult> {
947        self.require_columns(table, crate::auth::ColumnOperation::Insert, &insert_cells)?;
948        self.lock_auto_inc_barrier(expected_table_id, &insert_cells)?;
949        let handle = self.bound_table(table, expected_table_id, expected_schema_id)?;
950        self.reject_after_truncate(expected_table_id)?;
951        match (
952            self.existing_pk_row_in(&handle, &insert_cells, expected_table_id)?,
953            action,
954        ) {
955            (None, _) => {
956                let mut target = handle.lock();
957                let assigned = target.fill_auto_inc(&mut insert_cells)?;
958                target.apply_defaults(&mut insert_cells)?;
959                drop(target);
960                let row = owned_row_from_cells(&insert_cells);
961                self.staging
962                    .push((expected_table_id, Staged::Put(insert_cells)));
963                Ok(UpsertResult {
964                    action: UpsertActionKind::Inserted,
965                    row,
966                    auto_inc: assigned,
967                })
968            }
969            (Some((_old_id, old_row)), UpsertAction::DoNothing) => Ok(UpsertResult {
970                action: UpsertActionKind::Unchanged,
971                row: old_row,
972                auto_inc: None,
973            }),
974            (Some((old_id, old_row)), UpsertAction::DoUpdate(update_cells)) => {
975                self.require_columns(table, crate::auth::ColumnOperation::Update, &update_cells)?;
976                let changed_columns = changed_columns(&update_cells);
977                let merged = merge_cells(old_row.columns.clone(), update_cells);
978                if columns_equal(&old_row.columns, &merged) {
979                    return Ok(UpsertResult {
980                        action: UpsertActionKind::Unchanged,
981                        row: old_row,
982                        auto_inc: None,
983                    });
984                }
985                let row = owned_row_from_cells(&merged);
986                self.staging.push((
987                    expected_table_id,
988                    Staged::Update {
989                        row_id: old_id,
990                        new_row: merged,
991                        changed_columns,
992                    },
993                ));
994                Ok(UpsertResult {
995                    action: UpsertActionKind::Updated,
996                    row,
997                    auto_inc: None,
998                })
999            }
1000        }
1001    }
1002
1003    pub fn truncate(&mut self, table: &str) -> Result<()> {
1004        self.db
1005            .require_for(self.principal.as_ref(), &crate::auth::Permission::Admin)?;
1006        let id = self.db.table_id(table)?;
1007        for (table_id, op) in &self.staging {
1008            if *table_id == id && !matches!(op, Staged::Truncate) {
1009                return Err(MongrelError::InvalidArgument(
1010                    "truncate cannot be combined with other writes on the same table".into(),
1011                ));
1012            }
1013        }
1014        self.staging.push((id, Staged::Truncate));
1015        Ok(())
1016    }
1017
1018    fn reject_after_truncate(&self, table_id: u64) -> Result<()> {
1019        if self
1020            .staging
1021            .iter()
1022            .any(|(tid, op)| *tid == table_id && matches!(op, Staged::Truncate))
1023        {
1024            return Err(MongrelError::InvalidArgument(
1025                "truncate cannot be combined with other writes on the same table".into(),
1026            ));
1027        }
1028        Ok(())
1029    }
1030
1031    fn require_columns(
1032        &self,
1033        table: &str,
1034        operation: crate::auth::ColumnOperation,
1035        cells: &[(u16, Value)],
1036    ) -> Result<()> {
1037        let columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
1038        self.db
1039            .require_columns_for(table, operation, &columns, self.principal.as_ref())
1040    }
1041
1042    fn require_delete(&self, table: &str) -> Result<()> {
1043        self.db.require_for(
1044            self.principal.as_ref(),
1045            &crate::auth::Permission::Delete {
1046                table: table.to_string(),
1047            },
1048        )
1049    }
1050
1051    fn bound_table(
1052        &self,
1053        table: &str,
1054        expected_table_id: u64,
1055        expected_schema_id: u64,
1056    ) -> Result<TableHandle> {
1057        let current = self.db.table_identity(table)?;
1058        if current != (expected_table_id, expected_schema_id) {
1059            return Err(MongrelError::Conflict(format!(
1060                "table {table:?} changed after request authorization"
1061            )));
1062        }
1063        self.db.table_by_id(expected_table_id)
1064    }
1065
1066    fn existing_pk_row(
1067        &mut self,
1068        table: &str,
1069        cells: &[(u16, Value)],
1070    ) -> Result<Option<(RowId, OwnedRow)>> {
1071        let id = self.db.table_id(table)?;
1072        let handle = self.db.table(table)?;
1073        self.existing_pk_row_in(&handle, cells, id)
1074    }
1075
1076    fn existing_pk_row_in(
1077        &mut self,
1078        handle: &TableHandle,
1079        cells: &[(u16, Value)],
1080        table_id: u64,
1081    ) -> Result<Option<(RowId, OwnedRow)>> {
1082        let snap = self.statement_snapshot();
1083        let target = handle.lock();
1084        let Some(pk_col) = target.schema().primary_key() else {
1085            return Ok(None);
1086        };
1087        let Some((_, pk_value)) = cells.iter().find(|(id, _)| *id == pk_col.id) else {
1088            return Ok(None);
1089        };
1090        if matches!(pk_value, Value::Null) {
1091            return Ok(None);
1092        }
1093        let Some(row_id) = target.lookup_pk(&pk_value.encode_key()) else {
1094            return Ok(None);
1095        };
1096        let found = target
1097            .get(row_id, snap)
1098            .map(|row| (row_id, owned_row_from_map(row.columns)));
1099        if found.is_some() {
1100            self.read_set.record_row(table_id, row_id);
1101        }
1102        Ok(found)
1103    }
1104
1105    /// Commit: durably seal the staging under one epoch and publish it.
1106    pub fn commit(self) -> Result<Epoch> {
1107        self.commit_full(None, None, None).map(|(epoch, _)| epoch)
1108    }
1109
1110    /// Commit with idempotency parameters (spec §10.2, S1B-005): a repeated
1111    /// key with an identical request fingerprint returns the original commit
1112    /// epoch (the original receipt is visible through the state handle); a
1113    /// repeated key with a different fingerprint returns `Conflict`.
1114    pub fn commit_idempotent(self, request: IdempotencyRequest) -> Result<Epoch> {
1115        self.commit_full(None, None, Some(request))
1116            .map(|(epoch, _)| epoch)
1117    }
1118
1119    pub fn commit_with_row_ids(self) -> Result<(Epoch, Vec<RowId>)> {
1120        self.commit_full(None, None, None)
1121    }
1122
1123    /// Cooperatively prepare this transaction, then invoke `before_commit`
1124    /// immediately before the first WAL append can occur. If cancellation or
1125    /// the callback wins, no commit epoch or WAL record is produced.
1126    pub fn commit_controlled<F>(
1127        self,
1128        control: &crate::ExecutionControl,
1129        mut before_commit: F,
1130    ) -> Result<Epoch>
1131    where
1132        F: FnMut() -> Result<()>,
1133    {
1134        self.commit_full(Some(control), Some(&mut before_commit), None)
1135            .map(|(epoch, _)| epoch)
1136    }
1137
1138    pub fn commit_controlled_with_row_ids<F>(
1139        self,
1140        control: &crate::ExecutionControl,
1141        mut before_commit: F,
1142    ) -> Result<(Epoch, Vec<RowId>)>
1143    where
1144        F: FnMut() -> Result<()>,
1145    {
1146        self.commit_full(Some(control), Some(&mut before_commit), None)
1147    }
1148
1149    /// The single commit entry point (S1B-004): validates state, drives the
1150    /// formal state machine (`Active → Preparing → …`), threads the S1B
1151    /// commit context into the sequencer, and classifies the outcome onto
1152    /// the state handle (`Committed` is set by the sequencer once published;
1153    /// pre-fence errors abort here; post-fence unknown outcomes leave
1154    /// `CommitCritical` intact).
1155    ///
1156    /// FND-006: `txn.prepare.before`/`txn.prepare.after` bracket the
1157    /// transition into `Preparing`. A `before` failure never enters prepare;
1158    /// an `after` failure leaves `Preparing` and is classified as a pre-fence
1159    /// abort (nothing durable yet).
1160    fn commit_full(
1161        mut self,
1162        control: Option<&crate::ExecutionControl>,
1163        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
1164        idempotency: Option<IdempotencyRequest>,
1165    ) -> Result<(Epoch, Vec<RowId>)> {
1166        if let Some(message) = self.allocation_error.take() {
1167            self.state.abort(AbortReason::Error(message.clone()));
1168            return Err(MongrelError::Full(message));
1169        }
1170        // FND-006: arm before the formal prepare transition so a Fail aborts
1171        // while still Active (no Preparing/Committed observation).
1172        if let Err(fault) = mongreldb_fault::inject("txn.prepare.before") {
1173            let error = crate::commit_log::fault_as_io(fault);
1174            classify_commit_error(&self.state, &error);
1175            return Err(error);
1176        }
1177        self.state.begin_prepare();
1178        if let Err(fault) = mongreldb_fault::inject("txn.prepare.after") {
1179            let error = crate::commit_log::fault_as_io(fault);
1180            classify_commit_error(&self.state, &error);
1181            return Err(error);
1182        }
1183        let context = TxnCommitContext {
1184            isolation: self.isolation,
1185            read_ts: self.read_ts,
1186            read_set: std::mem::take(&mut self.read_set),
1187            predicate_set: std::mem::take(&mut self.predicate_set),
1188            state: Some(self.state.clone()),
1189            idempotency,
1190        };
1191        let staging = std::mem::take(&mut self.staging);
1192        let external_states = std::mem::take(&mut self.external_states);
1193        let materialized_view_updates = std::mem::take(&mut self.materialized_view_updates);
1194        let principal = self.principal.take();
1195        let result = match (control, before_commit) {
1196            (Some(control), Some(before_commit)) => {
1197                self.db.commit_transaction_with_external_states_controlled(
1198                    self.txn_id,
1199                    self.read.epoch,
1200                    staging,
1201                    external_states,
1202                    materialized_view_updates,
1203                    principal,
1204                    self.principal_catalog_bound,
1205                    self.external_trigger_bridge,
1206                    context,
1207                    control,
1208                    before_commit,
1209                )
1210            }
1211            _ => self.db.commit_transaction_with_external_states(
1212                self.txn_id,
1213                self.read.epoch,
1214                staging,
1215                external_states,
1216                materialized_view_updates,
1217                principal,
1218                self.principal_catalog_bound,
1219                self.external_trigger_bridge,
1220                context,
1221            ),
1222        };
1223        if let Err(error) = &result {
1224            classify_commit_error(&self.state, error);
1225        }
1226        result
1227    }
1228
1229    /// Rollback: discard staging. Nothing is appended to the WAL.
1230    pub fn rollback(self) {
1231        self.state.abort(AbortReason::RolledBack);
1232        // Dropping `self` discards the staging — it lives only in memory.
1233    }
1234}
1235
1236impl Drop for Transaction<'_> {
1237    fn drop(&mut self) {
1238        // A transaction dropped without commit is aborted (nothing durable
1239        // was appended). `Committed`/`CommitCritical` states set by a
1240        // completed or in-flight commit are left untouched.
1241        self.state.abort(AbortReason::RolledBack);
1242        // S1B-004 step 12: a transaction never outlives its lock holds. The
1243        // commit path already released them through its guard, so this only
1244        // fires for rollback/abort/drop paths — and is a no-op otherwise.
1245        if self.txn_id != crate::wal::SYSTEM_TXN_ID {
1246            self.db.release_txn_locks(self.txn_id);
1247        }
1248    }
1249}
1250
1251fn owned_row_from_cells(cells: &[(u16, Value)]) -> OwnedRow {
1252    let mut columns = cells.to_vec();
1253    columns.sort_by_key(|(id, _)| *id);
1254    OwnedRow { columns }
1255}
1256
1257fn owned_row_from_map(columns: HashMap<u16, Value>) -> OwnedRow {
1258    let mut columns: Vec<(u16, Value)> = columns.into_iter().collect();
1259    columns.sort_by_key(|(id, _)| *id);
1260    OwnedRow { columns }
1261}
1262
1263fn merge_cells(mut base: Vec<(u16, Value)>, updates: Vec<(u16, Value)>) -> Vec<(u16, Value)> {
1264    for (id, value) in updates {
1265        base.retain(|(existing, _)| *existing != id);
1266        base.push((id, value));
1267    }
1268    base.sort_by_key(|(id, _)| *id);
1269    base
1270}
1271
1272fn changed_columns(cells: &[(u16, Value)]) -> Vec<u16> {
1273    let mut columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
1274    columns.sort_unstable();
1275    columns.dedup();
1276    columns
1277}
1278
1279fn columns_equal(a: &[(u16, Value)], b: &[(u16, Value)]) -> bool {
1280    if a.len() != b.len() {
1281        return false;
1282    }
1283    let mut a: Vec<_> = a.iter().collect();
1284    let mut b: Vec<_> = b.iter().collect();
1285    a.sort_by_key(|(id, _)| *id);
1286    b.sort_by_key(|(id, _)| *id);
1287    a.iter()
1288        .zip(b.iter())
1289        .all(|((id_a, v_a), (id_b, v_b))| id_a == id_b && v_a == v_b)
1290}
1291
1292/// Staged operation produced after row-id allocation (internal to commit).
1293pub(crate) enum StagedOp {
1294    Put(Vec<crate::memtable::Row>),
1295    Delete(Vec<RowId>),
1296    Truncate,
1297}
1298
1299// ── P3.1: conflict index + active-txn registry (spec §8.3, §9.2) ─────────
1300
1301use std::collections::{BTreeMap, HashMap};
1302use std::hash::{Hash, Hasher};
1303
1304/// A write-set key broad enough to detect all write–write conflicts under
1305/// snapshot isolation (spec §8.3, review fix #13).
1306#[derive(Clone, Debug)]
1307pub enum WriteKey {
1308    /// Row-version key for updates/deletes of existing rows.
1309    Row { table_id: u64, row_id: u64 },
1310    /// Unique/PK key for inserts/updates touching a UNIQUE column.
1311    Unique {
1312        table_id: u64,
1313        index_id: u16,
1314        key_hash: u64,
1315    },
1316    /// Table-scope key for TRUNCATE/DROP/ALTER and any txn writing that table.
1317    Table { table_id: u64 },
1318}
1319
1320impl Hash for WriteKey {
1321    fn hash<H: Hasher>(&self, state: &mut H) {
1322        match self {
1323            WriteKey::Row { table_id, row_id } => {
1324                0u8.hash(state);
1325                table_id.hash(state);
1326                row_id.hash(state);
1327            }
1328            WriteKey::Unique {
1329                table_id,
1330                index_id,
1331                key_hash,
1332            } => {
1333                1u8.hash(state);
1334                table_id.hash(state);
1335                index_id.hash(state);
1336                key_hash.hash(state);
1337            }
1338            WriteKey::Table { table_id } => {
1339                2u8.hash(state);
1340                table_id.hash(state);
1341            }
1342        }
1343    }
1344}
1345
1346impl PartialEq for WriteKey {
1347    fn eq(&self, other: &Self) -> bool {
1348        match (self, other) {
1349            (
1350                WriteKey::Row {
1351                    table_id: a,
1352                    row_id: b,
1353                },
1354                WriteKey::Row {
1355                    table_id: c,
1356                    row_id: d,
1357                },
1358            ) => a == c && b == d,
1359            (
1360                WriteKey::Unique {
1361                    table_id: a,
1362                    index_id: b,
1363                    key_hash: c,
1364                },
1365                WriteKey::Unique {
1366                    table_id: d,
1367                    index_id: e,
1368                    key_hash: f,
1369                },
1370            ) => a == d && b == e && c == f,
1371            (WriteKey::Table { table_id: a }, WriteKey::Table { table_id: b }) => a == b,
1372            _ => false,
1373        }
1374    }
1375}
1376
1377impl Eq for WriteKey {}
1378
1379const CONFLICT_SHARDS: usize = 16;
1380
1381/// A sharded concurrent map of `WriteKey → commit_epoch` recording recent
1382/// committed writes (spec §9.2). Validation probes per write-set key; pruning
1383/// drops entries below `min(active read_epoch)`.
1384pub struct ConflictIndex {
1385    shards: [parking_lot::Mutex<HashMap<WriteKey, u64>>; CONFLICT_SHARDS],
1386    table_truncate_epochs: parking_lot::Mutex<HashMap<u64, u64>>,
1387    table_write_epochs: parking_lot::Mutex<HashMap<u64, u64>>,
1388    /// Bumped on every `record()` so pre-validation can detect whether new
1389    /// commits arrived between the pre-check and the sequencer (spec §8.5,
1390    /// review fix #17).
1391    version: std::sync::atomic::AtomicU64,
1392    /// Highest active-reader floor already applied to every shard. Commit
1393    /// epochs only advance within one open, so no write recorded after a prune
1394    /// can fall below an unchanged floor.
1395    last_prune_floor: std::sync::atomic::AtomicU64,
1396    #[cfg(test)]
1397    prune_passes: std::sync::atomic::AtomicU64,
1398}
1399
1400impl ConflictIndex {
1401    pub fn new() -> Self {
1402        Self {
1403            shards: std::array::from_fn(|_| parking_lot::Mutex::new(HashMap::new())),
1404            table_truncate_epochs: parking_lot::Mutex::new(HashMap::new()),
1405            table_write_epochs: parking_lot::Mutex::new(HashMap::new()),
1406            version: std::sync::atomic::AtomicU64::new(0),
1407            last_prune_floor: std::sync::atomic::AtomicU64::new(0),
1408            #[cfg(test)]
1409            prune_passes: std::sync::atomic::AtomicU64::new(0),
1410        }
1411    }
1412
1413    /// Current version (incremented on every `record`). Used by the two-phase
1414    /// validation: pre-validate + snapshot version → sequencer re-checks only
1415    /// if the version advanced.
1416    pub fn version(&self) -> u64 {
1417        self.version.load(std::sync::atomic::Ordering::Acquire)
1418    }
1419
1420    fn shard(&self, key: &WriteKey) -> &parking_lot::Mutex<HashMap<WriteKey, u64>> {
1421        let mut h = std::collections::hash_map::DefaultHasher::new();
1422        key.hash(&mut h);
1423        let idx = (h.finish() as usize) & (CONFLICT_SHARDS - 1);
1424        &self.shards[idx]
1425    }
1426
1427    /// Returns `true` if any key was committed at an epoch strictly greater
1428    /// than `read_epoch` (write–write conflict under SI; first-committer-wins).
1429    pub fn conflicts(&self, keys: &[WriteKey], read_epoch: Epoch) -> bool {
1430        for k in keys {
1431            let s = self.shard(k);
1432            if let Some(&ce) = s.lock().get(k) {
1433                if ce > read_epoch.0 {
1434                    return true;
1435                }
1436            }
1437        }
1438        let truncates = self.table_truncate_epochs.lock();
1439        let writes = self.table_write_epochs.lock();
1440        for k in keys {
1441            match k {
1442                WriteKey::Row { table_id, .. } | WriteKey::Unique { table_id, .. } => {
1443                    if truncates.get(table_id).is_some_and(|&ce| ce > read_epoch.0) {
1444                        return true;
1445                    }
1446                }
1447                WriteKey::Table { table_id } => {
1448                    if writes.get(table_id).is_some_and(|&ce| ce > read_epoch.0) {
1449                        return true;
1450                    }
1451                }
1452            }
1453        }
1454        false
1455    }
1456
1457    /// Record every write-set key at `commit_epoch`.
1458    pub fn record(&self, keys: &[WriteKey], commit_epoch: Epoch) {
1459        for k in keys {
1460            let s = self.shard(k);
1461            s.lock().insert(k.clone(), commit_epoch.0);
1462        }
1463        let mut truncates = self.table_truncate_epochs.lock();
1464        let mut writes = self.table_write_epochs.lock();
1465        for k in keys {
1466            match k {
1467                WriteKey::Table { table_id } => {
1468                    truncates
1469                        .entry(*table_id)
1470                        .and_modify(|ce| *ce = (*ce).max(commit_epoch.0))
1471                        .or_insert(commit_epoch.0);
1472                }
1473                WriteKey::Row { table_id, .. } | WriteKey::Unique { table_id, .. } => {
1474                    writes
1475                        .entry(*table_id)
1476                        .and_modify(|ce| *ce = (*ce).max(commit_epoch.0))
1477                        .or_insert(commit_epoch.0);
1478                }
1479            }
1480        }
1481        self.version
1482            .fetch_add(1, std::sync::atomic::Ordering::Release);
1483    }
1484
1485    /// Drop entries whose `commit_epoch < min_active` (they can never cause a
1486    /// future conflict once no live txn reads below `min_active`).
1487    pub fn prune_below(&self, min_active: Epoch) {
1488        let previous = self
1489            .last_prune_floor
1490            .fetch_max(min_active.0, std::sync::atomic::Ordering::AcqRel);
1491        if min_active.0 <= previous {
1492            return;
1493        }
1494        #[cfg(test)]
1495        self.prune_passes
1496            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1497
1498        for s in &self.shards {
1499            s.lock().retain(|_, ce| *ce >= min_active.0);
1500        }
1501        self.table_truncate_epochs
1502            .lock()
1503            .retain(|_, ce| *ce >= min_active.0);
1504        self.table_write_epochs
1505            .lock()
1506            .retain(|_, ce| *ce >= min_active.0);
1507    }
1508
1509    #[cfg(test)]
1510    fn prune_passes(&self) -> u64 {
1511        self.prune_passes.load(std::sync::atomic::Ordering::Relaxed)
1512    }
1513}
1514
1515impl Default for ConflictIndex {
1516    fn default() -> Self {
1517        Self::new()
1518    }
1519}
1520
1521// ── P3.2: real group commit (spec §9.3) ─────────────────────────────────
1522
1523/// Group-commit coordinator (spec §9.3). The commit sequencer appends a txn's
1524/// records under the WAL mutex but does **not** fsync there; instead each
1525/// committer calls [`Self::await_durable`] with its commit record's WAL seq.
1526/// Exactly one waiter becomes the *leader* and issues a single `group_sync`
1527/// (fsync), which makes durable every record appended up to that point; the
1528/// others are *followers* that simply wait until `durable_seq` reaches their
1529/// commit seq. One fsync therefore covers a whole batch of concurrent commits.
1530pub struct GroupCommit {
1531    inner: PlMutex<GroupState>,
1532    cv: Condvar,
1533    /// S1A-004: the owning core's lifecycle, poisoned on fsync error so every
1534    /// later operation is rejected at admission. `None` for standalone tables
1535    /// that have no core lifecycle.
1536    lifecycle: Option<Arc<crate::core::LifecycleController>>,
1537}
1538
1539struct GroupState {
1540    durable_seq: u64,
1541    syncing: bool,
1542    poisoned: bool,
1543}
1544
1545impl GroupCommit {
1546    pub fn new(durable_seq: u64) -> Self {
1547        Self {
1548            inner: PlMutex::new(GroupState {
1549                durable_seq,
1550                syncing: false,
1551                poisoned: false,
1552            }),
1553            cv: Condvar::new(),
1554            lifecycle: None,
1555        }
1556    }
1557
1558    /// Attach the owning core's lifecycle controller (S1A-004): an fsync
1559    /// error poisons it, transitioning the core to
1560    /// [`crate::core::LifecycleState::Poisoned`].
1561    pub fn with_lifecycle(mut self, lifecycle: Arc<crate::core::LifecycleController>) -> Self {
1562        self.lifecycle = Some(lifecycle);
1563        self
1564    }
1565
1566    /// Block until `commit_seq` is durable. The first eligible caller fsyncs on
1567    /// behalf of the batch; the rest wait on the condvar. On fsync error the
1568    /// coordinator is poisoned and every waiter (current and future) returns
1569    /// `Err` (spec §9.3e). `wal` is the same `SharedWal` the sequencer appended
1570    /// to — locked here only for the brief fsync, never across the wait.
1571    pub fn await_durable(&self, wal: &PlMutex<SharedWal>, commit_seq: u64) -> Result<()> {
1572        let mut st = self.inner.lock();
1573        loop {
1574            if st.poisoned {
1575                return Err(MongrelError::Other(
1576                    "database poisoned by fsync error".into(),
1577                ));
1578            }
1579            if st.durable_seq >= commit_seq {
1580                return Ok(());
1581            }
1582            if st.syncing {
1583                // Another thread is the leader; wait for it to advance durability.
1584                self.cv.wait(&mut st);
1585                continue;
1586            }
1587            // Become the leader: fsync outside the coordinator lock (but under
1588            // the WAL lock) so followers can queue up behind us.
1589            st.syncing = true;
1590            drop(st);
1591            // ponytail: fixed 50 µs batch window; make adaptive if isolated commit latency matters.
1592            std::thread::sleep(std::time::Duration::from_micros(50));
1593            let res = wal.lock().group_sync();
1594            st = self.inner.lock();
1595            st.syncing = false;
1596            match res {
1597                Ok(durable) => {
1598                    if durable > st.durable_seq {
1599                        st.durable_seq = durable;
1600                    }
1601                    self.cv.notify_all();
1602                    // Loop re-checks: our commit_seq <= durable (group_sync makes
1603                    // everything appended-so-far durable), so we return Ok next.
1604                }
1605                Err(e) => {
1606                    st.poisoned = true;
1607                    // S1A-004: the fsync poison is unrecoverable — transition
1608                    // the core lifecycle so every later operation is rejected
1609                    // at admission, not just at the next WAL append.
1610                    if let Some(lifecycle) = &self.lifecycle {
1611                        lifecycle.poison();
1612                    }
1613                    self.cv.notify_all();
1614                    return Err(e);
1615                }
1616            }
1617        }
1618    }
1619}
1620
1621/// Tracks the `read_epoch` of every in-flight transaction (spec §9.2, review
1622/// fix #12). `begin` registers **before** the first read; `min_read_epoch`
1623/// drives conflict-index pruning.
1624pub struct ActiveTxns {
1625    inner: parking_lot::Mutex<BTreeMap<u64, u64>>,
1626}
1627
1628impl ActiveTxns {
1629    pub fn new() -> Self {
1630        Self {
1631            inner: parking_lot::Mutex::new(BTreeMap::new()),
1632        }
1633    }
1634
1635    /// Register a transaction's read epoch. Returns a guard that deregisters
1636    /// on drop.
1637    pub fn register(&self, read_epoch: Epoch) -> ActiveTxnGuard<'_> {
1638        let mut g = self.inner.lock();
1639        *g.entry(read_epoch.0).or_insert(0) += 1;
1640        ActiveTxnGuard {
1641            active: self,
1642            epoch: read_epoch.0,
1643        }
1644    }
1645
1646    /// The lowest live `read_epoch`, or `u64::MAX` when no txn is active.
1647    pub fn min_read_epoch(&self) -> u64 {
1648        self.inner.lock().keys().next().copied().unwrap_or(u64::MAX)
1649    }
1650}
1651
1652impl Default for ActiveTxns {
1653    fn default() -> Self {
1654        Self::new()
1655    }
1656}
1657
1658/// Guard for an active transaction's read-epoch registration.
1659pub struct ActiveTxnGuard<'a> {
1660    active: &'a ActiveTxns,
1661    epoch: u64,
1662}
1663
1664impl Drop for ActiveTxnGuard<'_> {
1665    fn drop(&mut self) {
1666        let mut g = self.active.inner.lock();
1667        if let Some(count) = g.get_mut(&self.epoch) {
1668            *count -= 1;
1669            if *count == 0 {
1670                g.remove(&self.epoch);
1671            }
1672        }
1673    }
1674}
1675
1676#[cfg(test)]
1677mod tests {
1678    use super::*;
1679
1680    #[test]
1681    fn shared_transaction_allocator_never_crosses_open_generation() {
1682        let allocator = PlMutex::new((7_u64 << 32) | u32::MAX as u64);
1683        assert_eq!(
1684            allocate_txn_id(&allocator).unwrap(),
1685            (7_u64 << 32) | u32::MAX as u64
1686        );
1687        assert!(matches!(
1688            allocate_txn_id(&allocator),
1689            Err(MongrelError::Full(_))
1690        ));
1691    }
1692
1693    #[test]
1694    fn conflict_index_first_committer_wins_and_prunes_safely() {
1695        let ci = ConflictIndex::new();
1696        let k = vec![WriteKey::Row {
1697            table_id: 1,
1698            row_id: 7,
1699        }];
1700        assert!(!ci.conflicts(&k, Epoch(5)));
1701        ci.record(&k, Epoch(6));
1702        assert!(ci.conflicts(&k, Epoch(5)));
1703        assert!(!ci.conflicts(&k, Epoch(6)));
1704        ci.prune_below(Epoch(7));
1705        assert!(!ci.conflicts(&k, Epoch(5)));
1706    }
1707
1708    #[test]
1709    fn conflict_index_skips_repeated_unchanged_prune_floor() {
1710        let ci = ConflictIndex::new();
1711        let first = WriteKey::Row {
1712            table_id: 1,
1713            row_id: 7,
1714        };
1715        let second = WriteKey::Row {
1716            table_id: 1,
1717            row_id: 8,
1718        };
1719
1720        ci.record(std::slice::from_ref(&first), Epoch(6));
1721        ci.prune_below(Epoch(5));
1722        assert_eq!(ci.prune_passes(), 1);
1723
1724        // A later commit cannot be below the unchanged active-reader floor, so
1725        // rescanning every accumulated shard would retain every entry again.
1726        ci.record(std::slice::from_ref(&second), Epoch(7));
1727        ci.prune_below(Epoch(5));
1728        assert_eq!(ci.prune_passes(), 1);
1729        assert!(ci.conflicts(std::slice::from_ref(&first), Epoch(4)));
1730        assert!(ci.conflicts(std::slice::from_ref(&second), Epoch(4)));
1731
1732        ci.prune_below(Epoch(8));
1733        assert_eq!(ci.prune_passes(), 2);
1734        assert!(!ci.conflicts(&[first, second], Epoch(4)));
1735    }
1736
1737    #[test]
1738    fn conflict_index_table_scope_conflicts_both_directions() {
1739        let ci = ConflictIndex::new();
1740        ci.record(&[WriteKey::Table { table_id: 1 }], Epoch(6));
1741        assert!(ci.conflicts(
1742            &[WriteKey::Row {
1743                table_id: 1,
1744                row_id: 7,
1745            }],
1746            Epoch(5)
1747        ));
1748        assert!(ci.conflicts(
1749            &[WriteKey::Unique {
1750                table_id: 1,
1751                index_id: 0,
1752                key_hash: 42,
1753            }],
1754            Epoch(5)
1755        ));
1756        assert!(!ci.conflicts(
1757            &[WriteKey::Row {
1758                table_id: 2,
1759                row_id: 7,
1760            }],
1761            Epoch(5)
1762        ));
1763
1764        let ci = ConflictIndex::new();
1765        ci.record(
1766            &[WriteKey::Row {
1767                table_id: 1,
1768                row_id: 7,
1769            }],
1770            Epoch(6),
1771        );
1772        assert!(ci.conflicts(&[WriteKey::Table { table_id: 1 }], Epoch(5)));
1773        assert!(!ci.conflicts(&[WriteKey::Table { table_id: 2 }], Epoch(5)));
1774    }
1775
1776    #[test]
1777    fn writekey_eq_across_variants() {
1778        let r1 = WriteKey::Row {
1779            table_id: 1,
1780            row_id: 2,
1781        };
1782        let r2 = WriteKey::Row {
1783            table_id: 1,
1784            row_id: 2,
1785        };
1786        let r3 = WriteKey::Row {
1787            table_id: 1,
1788            row_id: 3,
1789        };
1790        assert_eq!(r1, r2);
1791        assert_ne!(r1, r3);
1792
1793        let u1 = WriteKey::Unique {
1794            table_id: 1,
1795            index_id: 0,
1796            key_hash: 42,
1797        };
1798        let u2 = WriteKey::Unique {
1799            table_id: 1,
1800            index_id: 0,
1801            key_hash: 42,
1802        };
1803        assert_eq!(u1, u2);
1804        assert_ne!(r1, u1);
1805
1806        let t1 = WriteKey::Table { table_id: 5 };
1807        let t2 = WriteKey::Table { table_id: 5 };
1808        assert_eq!(t1, t2);
1809        assert_ne!(t1, r1);
1810    }
1811
1812    #[test]
1813    fn active_txns_tracks_min_read_epoch() {
1814        let at = ActiveTxns::new();
1815        assert_eq!(at.min_read_epoch(), u64::MAX);
1816        let g1 = at.register(Epoch(5));
1817        assert_eq!(at.min_read_epoch(), 5);
1818        let g2 = at.register(Epoch(3));
1819        assert_eq!(at.min_read_epoch(), 3);
1820        drop(g2);
1821        assert_eq!(at.min_read_epoch(), 5);
1822        drop(g1);
1823        assert_eq!(at.min_read_epoch(), u64::MAX);
1824    }
1825
1826    #[test]
1827    fn active_txns_dedups_same_epoch() {
1828        let at = ActiveTxns::new();
1829        let g1 = at.register(Epoch(7));
1830        let g2 = at.register(Epoch(7));
1831        assert_eq!(at.min_read_epoch(), 7);
1832        drop(g1);
1833        assert_eq!(at.min_read_epoch(), 7);
1834        drop(g2);
1835        assert_eq!(at.min_read_epoch(), u64::MAX);
1836    }
1837
1838    #[test]
1839    fn isolation_level_snapshot_aliases_repeatable_read() {
1840        #[allow(deprecated)]
1841        let snapshot = IsolationLevel::Snapshot;
1842        assert_eq!(snapshot.canonical(), IsolationLevel::RepeatableRead);
1843        assert_eq!(IsolationLevel::default(), IsolationLevel::RepeatableRead);
1844        assert_eq!(
1845            IsolationLevel::ReadCommitted.canonical(),
1846            IsolationLevel::ReadCommitted
1847        );
1848        assert_eq!(
1849            IsolationLevel::Serializable.canonical(),
1850            IsolationLevel::Serializable
1851        );
1852    }
1853
1854    #[test]
1855    fn transaction_state_transitions_are_enforced() {
1856        let handle = TxnStateHandle::new();
1857        assert!(matches!(handle.state(), TransactionState::Active));
1858        // Illegal: Active cannot become Committed or CommitCritical directly.
1859        assert!(!handle.enter_commit_critical());
1860        assert!(matches!(handle.state(), TransactionState::Active));
1861        // Illegal: Committed is unreachable from Active.
1862        let receipt = mongreldb_log::CommitReceipt {
1863            transaction_id: mongreldb_types::ids::TransactionId::from_bytes([0; 16]),
1864            commit_ts: HlcTimestamp::ZERO,
1865            log_position: mongreldb_log::LogPosition::ZERO,
1866            durability: mongreldb_log::DurabilityLevel::GroupCommit,
1867        };
1868        assert!(!handle.committed(receipt.clone()));
1869
1870        assert!(handle.begin_prepare());
1871        assert!(matches!(handle.state(), TransactionState::Preparing));
1872        assert!(handle.enter_commit_critical());
1873        assert!(matches!(handle.state(), TransactionState::CommitCritical));
1874        // CommitCritical never reports aborted (spec §4.7).
1875        assert!(!handle.abort(AbortReason::Conflict("late".into())));
1876        assert!(matches!(handle.state(), TransactionState::CommitCritical));
1877        assert!(handle.committed(receipt));
1878        assert!(matches!(handle.state(), TransactionState::Committed(_)));
1879        // Terminal: no further transitions.
1880        assert!(!handle.abort(AbortReason::RolledBack));
1881        assert!(!handle.begin_prepare());
1882    }
1883
1884    #[test]
1885    fn abort_from_preparing_is_terminal() {
1886        let handle = TxnStateHandle::new();
1887        assert!(handle.abort(AbortReason::RolledBack));
1888        assert!(matches!(
1889            handle.state(),
1890            TransactionState::Aborted(AbortReason::RolledBack)
1891        ));
1892        assert!(!handle.begin_prepare());
1893
1894        let handle = TxnStateHandle::new();
1895        handle.begin_prepare();
1896        assert!(handle.abort(AbortReason::Validation("bad row".into())));
1897        match handle.state() {
1898            TransactionState::Aborted(AbortReason::Validation(message)) => {
1899                assert_eq!(message, "bad row")
1900            }
1901            other => panic!("expected validation abort, got {other:?}"),
1902        }
1903    }
1904
1905    #[test]
1906    fn classify_commit_error_leaves_post_fence_states_untouched() {
1907        let handle = TxnStateHandle::new();
1908        handle.begin_prepare();
1909        classify_commit_error(&handle, &MongrelError::Conflict("ww".into()));
1910        assert!(matches!(
1911            handle.state(),
1912            TransactionState::Aborted(AbortReason::Conflict(_))
1913        ));
1914
1915        let handle = TxnStateHandle::new();
1916        handle.begin_prepare();
1917        handle.enter_commit_critical();
1918        classify_commit_error(
1919            &handle,
1920            &MongrelError::CommitOutcomeUnknown {
1921                epoch: 7,
1922                message: "fsync".into(),
1923            },
1924        );
1925        assert!(matches!(handle.state(), TransactionState::CommitCritical));
1926        classify_commit_error(
1927            &handle,
1928            &MongrelError::DurableCommit {
1929                epoch: 7,
1930                message: "publish".into(),
1931            },
1932        );
1933        assert!(matches!(handle.state(), TransactionState::CommitCritical));
1934    }
1935
1936    #[test]
1937    fn classify_commit_error_maps_serialization_failure_to_conflict_abort() {
1938        // The native SSI abort variant aborts pre-fence with the same
1939        // retry-the-whole-transaction reason as a write/write conflict, and
1940        // the recorded message keeps the variant's display prefix.
1941        let handle = TxnStateHandle::new();
1942        handle.begin_prepare();
1943        classify_commit_error(
1944            &handle,
1945            &MongrelError::SerializationFailure {
1946                message: "a concurrent commit invalidated this transaction's reads".into(),
1947            },
1948        );
1949        match handle.state() {
1950            TransactionState::Aborted(AbortReason::Conflict(message)) => {
1951                assert_eq!(
1952                    message,
1953                    "serialization failure: a concurrent commit invalidated this transaction's reads"
1954                );
1955            }
1956            other => panic!("expected conflict abort, got {other:?}"),
1957        }
1958    }
1959
1960    #[test]
1961    fn ssi_validation_keys_cover_rows_and_predicates() {
1962        let mut reads = ReadSet::default();
1963        reads.record_row(3, RowId(9));
1964        reads.record_row(3, RowId(9));
1965        reads.record_row(4, RowId(1));
1966        let mut predicates = PredicateSet::default();
1967        predicates.record_table(5);
1968        let keys = ssi_validation_keys(&reads, &predicates);
1969        assert_eq!(keys.len(), 3);
1970        assert!(keys.iter().any(|key| matches!(
1971            key,
1972            WriteKey::Row {
1973                table_id: 3,
1974                row_id: 9
1975            }
1976        )));
1977        assert!(keys.iter().any(|key| matches!(
1978            key,
1979            WriteKey::Row {
1980                table_id: 4,
1981                row_id: 1
1982            }
1983        )));
1984        assert!(keys
1985            .iter()
1986            .any(|key| matches!(key, WriteKey::Table { table_id: 5 })));
1987    }
1988
1989    fn test_request(key: &str) -> IdempotencyRequest {
1990        IdempotencyRequest {
1991            key: key.to_string(),
1992            owner: "alice".to_string(),
1993            fingerprint: 42,
1994            ttl: None,
1995        }
1996    }
1997
1998    fn test_commit_ts() -> HlcTimestamp {
1999        HlcTimestamp {
2000            physical_micros: 1_700_000_000_000_000,
2001            logical: 3,
2002            node_tiebreaker: 0,
2003        }
2004    }
2005
2006    #[test]
2007    fn idempotency_ledger_replay_conflict_and_restart() {
2008        let dir = tempfile::tempdir().unwrap();
2009        let root = std::sync::Arc::new(crate::durable_file::DurableRoot::open(dir.path()).unwrap());
2010        let ledger = IdempotencyLedger::open(std::sync::Arc::clone(&root), None).unwrap();
2011
2012        let request = test_request("k1");
2013        assert!(matches!(
2014            ledger.check_and_reserve(&request).unwrap(),
2015            IdempotencyCheck::Reserved
2016        ));
2017        // In-flight duplicate conflicts.
2018        assert!(matches!(
2019            ledger.check_and_reserve(&request),
2020            Err(MongrelError::Conflict(_))
2021        ));
2022        ledger
2023            .complete(&request, 7, Epoch(11), test_commit_ts())
2024            .unwrap();
2025
2026        // Identical request replays the original receipt.
2027        let replay = ledger.check_and_reserve(&request).unwrap();
2028        let IdempotencyCheck::Replay(receipt) = replay else {
2029            panic!("expected replay");
2030        };
2031        assert_eq!(receipt.log_position.index, 11);
2032        assert_eq!(receipt.commit_ts, test_commit_ts());
2033        assert_eq!(
2034            receipt.durability,
2035            mongreldb_log::DurabilityLevel::GroupCommit
2036        );
2037
2038        // Same key, different fingerprint conflicts.
2039        let mut other = test_request("k1");
2040        other.fingerprint = 43;
2041        assert!(matches!(
2042            ledger.check_and_reserve(&other),
2043            Err(MongrelError::Conflict(_))
2044        ));
2045        // Same key, different owner is an independent key.
2046        let mut foreign = test_request("k1");
2047        foreign.owner = "bob".to_string();
2048        assert!(matches!(
2049            ledger.check_and_reserve(&foreign).unwrap(),
2050            IdempotencyCheck::Reserved
2051        ));
2052        drop(ledger);
2053
2054        // Restart: records survive, replay still returns the original receipt.
2055        let reopened = IdempotencyLedger::open(root, None).unwrap();
2056        let replay = reopened.check_and_reserve(&request).unwrap();
2057        let IdempotencyCheck::Replay(receipt) = replay else {
2058            panic!("expected replay after restart");
2059        };
2060        assert_eq!(receipt.log_position.index, 11);
2061        assert_eq!(receipt.commit_ts, test_commit_ts());
2062        // The interrupted reservation also survived and fails closed.
2063        assert!(matches!(
2064            reopened.check_and_reserve(&foreign),
2065            Err(MongrelError::Conflict(_))
2066        ));
2067    }
2068
2069    #[test]
2070    fn idempotency_ledger_release_and_expiry() {
2071        let dir = tempfile::tempdir().unwrap();
2072        let root = std::sync::Arc::new(crate::durable_file::DurableRoot::open(dir.path()).unwrap());
2073        let ledger = IdempotencyLedger::open(std::sync::Arc::clone(&root), None).unwrap();
2074
2075        // A released reservation frees the key for a fresh attempt.
2076        let request = test_request("k-release");
2077        assert!(matches!(
2078            ledger.check_and_reserve(&request).unwrap(),
2079            IdempotencyCheck::Reserved
2080        ));
2081        ledger.release(&request);
2082        assert!(matches!(
2083            ledger.check_and_reserve(&request).unwrap(),
2084            IdempotencyCheck::Reserved
2085        ));
2086        ledger
2087            .complete(&request, 9, Epoch(12), test_commit_ts())
2088            .unwrap();
2089
2090        // An already-expired record is swept on the next check, so the key
2091        // can be reused.
2092        let mut expired = test_request("k-expired");
2093        expired.ttl = Some(Duration::from_nanos(1));
2094        assert!(matches!(
2095            ledger.check_and_reserve(&expired).unwrap(),
2096            IdempotencyCheck::Reserved
2097        ));
2098        ledger
2099            .complete(&expired, 10, Epoch(13), test_commit_ts())
2100            .unwrap();
2101        std::thread::sleep(Duration::from_millis(2));
2102        assert!(matches!(
2103            ledger.check_and_reserve(&expired).unwrap(),
2104            IdempotencyCheck::Reserved
2105        ));
2106
2107        // Restart sweeps the expired record too.
2108        drop(ledger);
2109        let reopened = IdempotencyLedger::open(root, None).unwrap();
2110        assert!(matches!(
2111            reopened.check_and_reserve(&expired).unwrap(),
2112            IdempotencyCheck::Reserved
2113        ));
2114    }
2115
2116    #[test]
2117    fn idempotency_ledger_rejects_empty_key_or_owner() {
2118        let dir = tempfile::tempdir().unwrap();
2119        let root = std::sync::Arc::new(crate::durable_file::DurableRoot::open(dir.path()).unwrap());
2120        let ledger = IdempotencyLedger::open(root, None).unwrap();
2121        let mut request = test_request("");
2122        assert!(matches!(
2123            ledger.check_and_reserve(&request),
2124            Err(MongrelError::InvalidArgument(_))
2125        ));
2126        request.key = "k".to_string();
2127        request.owner.clear();
2128        assert!(matches!(
2129            ledger.check_and_reserve(&request),
2130            Err(MongrelError::InvalidArgument(_))
2131        ));
2132    }
2133
2134    #[test]
2135    fn idempotency_ledger_enforces_bounded_size() {
2136        let mut records: Vec<StoredIdempotencyRecord> = (0..MAX_IDEMPOTENCY_RECORDS + 4)
2137            .map(|index| StoredIdempotencyRecord {
2138                owner: "o".to_string(),
2139                key: format!("k{index}"),
2140                fingerprint: 1,
2141                expires_at_micros: None,
2142                outcome: StoredIdempotencyOutcome::Committed {
2143                    txn_id: index as u64,
2144                    epoch: index as u64,
2145                    commit_ts: test_commit_ts(),
2146                },
2147            })
2148            .collect();
2149        enforce_bounds(&mut records).unwrap();
2150        assert_eq!(records.len(), MAX_IDEMPOTENCY_RECORDS);
2151        // Oldest (lowest epoch) evicted first.
2152        assert!(records.iter().all(|record| match &record.outcome {
2153            StoredIdempotencyOutcome::Committed { epoch, .. } => *epoch >= 4,
2154            StoredIdempotencyOutcome::Reserved => false,
2155        }));
2156
2157        // A ledger of only in-flight reservations cannot be bounded by
2158        // eviction: fail closed.
2159        let mut reserved: Vec<StoredIdempotencyRecord> = (0..MAX_IDEMPOTENCY_RECORDS + 1)
2160            .map(|index| StoredIdempotencyRecord {
2161                owner: "o".to_string(),
2162                key: format!("r{index}"),
2163                fingerprint: 1,
2164                expires_at_micros: None,
2165                outcome: StoredIdempotencyOutcome::Reserved,
2166            })
2167            .collect();
2168        assert!(matches!(
2169            enforce_bounds(&mut reserved),
2170            Err(MongrelError::ResourceLimitExceeded { .. })
2171        ));
2172    }
2173
2174    #[test]
2175    fn idempotency_ledger_tampered_file_fails_closed() {
2176        let dir = tempfile::tempdir().unwrap();
2177        let root = std::sync::Arc::new(crate::durable_file::DurableRoot::open(dir.path()).unwrap());
2178        let ledger = IdempotencyLedger::open(std::sync::Arc::clone(&root), None).unwrap();
2179        let request = test_request("k1");
2180        ledger.check_and_reserve(&request).unwrap();
2181        drop(ledger);
2182
2183        let path = dir.path().join(IDEMPOTENCY_FILENAME);
2184        let mut bytes = std::fs::read(&path).unwrap();
2185        let last = bytes.len() - 1;
2186        bytes[last] ^= 0xFF;
2187        std::fs::write(&path, bytes).unwrap();
2188        assert!(IdempotencyLedger::open(root, None).is_err());
2189    }
2190}
2191
2192/// Transaction isolation level (spec §10.2, S1B-002). MongrelDB defaults to
2193/// `RepeatableRead` (snapshot isolation).
2194///
2195/// - `RepeatableRead`: one snapshot fixed at `begin`; own staged writes are
2196///   visible to the transaction; write/write conflicts abort one transaction
2197///   (first-committer-wins). This is the engine's historical `Snapshot` (SI)
2198///   semantics under its SQL-standard name.
2199/// - `Snapshot`: deprecated alias of `RepeatableRead`, kept so existing call
2200///   sites compile unchanged. The two are interchangeable everywhere via
2201///   [`Self::canonical`].
2202/// - `ReadCommitted`: each statement obtains a new snapshot at the latest
2203///   visible epoch, so committed concurrent changes may appear between
2204///   statements. Commit-time conflict validation still uses the begin epoch
2205///   (conservative first-committer-wins).
2206/// - `Serializable`: repeatable-read snapshot plus SSI-style certification —
2207///   the commit sequencer tracks read dependencies (point reads), predicate/
2208///   range reads (table granularity), and write dependencies, and aborts
2209///   with a serialization failure when a concurrent commit invalidated a
2210///   tracked read (the rw-antidependency dangerous structure).
2211#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2212pub enum IsolationLevel {
2213    #[default]
2214    RepeatableRead,
2215    #[deprecated(
2216        note = "renamed to `RepeatableRead` (spec §10.2 S1B-002); identical semantics, kept for compatibility"
2217    )]
2218    Snapshot,
2219    ReadCommitted,
2220    Serializable,
2221}
2222
2223impl IsolationLevel {
2224    /// Collapse aliases onto their canonical variant: `Snapshot` behaves
2225    /// exactly as `RepeatableRead`.
2226    pub fn canonical(self) -> Self {
2227        match self {
2228            #[allow(deprecated)]
2229            Self::Snapshot => Self::RepeatableRead,
2230            other => other,
2231        }
2232    }
2233}
2234
2235// ── S1B-005: durable transaction idempotency ─────────────────────────────
2236
2237use std::time::Duration;
2238
2239/// Client-supplied idempotency parameters for one commit (spec §10.2,
2240/// S1B-005).
2241#[derive(Debug, Clone)]
2242pub struct IdempotencyRequest {
2243    /// The idempotency key, unique per owner.
2244    pub key: String,
2245    /// Owning principal; keys from different owners never alias.
2246    pub owner: String,
2247    /// Fingerprint of the request payload (caller-computed hash). A repeated
2248    /// key with the same fingerprint replays the original receipt; a
2249    /// different fingerprint conflicts.
2250    pub fingerprint: u64,
2251    /// How long the record is retained after the commit completes. `None`
2252    /// keeps the record until the ledger's bounded-size eviction.
2253    pub ttl: Option<Duration>,
2254}
2255
2256/// Sibling ledger file next to `CATALOG`/`JOBS` (mirroring the `JOBS`
2257/// pattern): checksum-framed JSON, atomically renamed, optionally sealed
2258/// with the metadata DEK.
2259pub const IDEMPOTENCY_FILENAME: &str = "TXN_IDEMPOTENCY";
2260
2261const IDEMPOTENCY_FORMAT_VERSION: u16 = 1;
2262const IDEMPOTENCY_MAGIC: &[u8; 8] = b"MONGRTXI";
2263/// Bounded ledger: at most this many records are retained (oldest committed
2264/// records are evicted first; in-flight reservations are never evicted).
2265const MAX_IDEMPOTENCY_RECORDS: usize = 65_536;
2266/// Hard cap on the ledger file size (fail closed beyond it).
2267const MAX_IDEMPOTENCY_BYTES: u64 = 64 * 1024 * 1024;
2268
2269/// The durable outcome of one idempotency key.
2270#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2271enum StoredIdempotencyOutcome {
2272    /// A commit was attempted but its receipt was never recorded (crash
2273    /// window). Retries fail closed with a conflict.
2274    Reserved,
2275    /// The commit is durable; this is the receipt to replay.
2276    Committed {
2277        txn_id: u64,
2278        epoch: u64,
2279        commit_ts: HlcTimestamp,
2280    },
2281}
2282
2283#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2284struct StoredIdempotencyRecord {
2285    owner: String,
2286    key: String,
2287    fingerprint: u64,
2288    expires_at_micros: Option<u64>,
2289    outcome: StoredIdempotencyOutcome,
2290}
2291
2292#[derive(serde::Serialize, serde::Deserialize)]
2293struct IdempotencyEnvelope {
2294    format_version: u16,
2295    records: Vec<StoredIdempotencyRecord>,
2296}
2297
2298/// Result of the pre-propose idempotency check.
2299pub(crate) enum IdempotencyCheck {
2300    /// First attempt under this key: it is now reserved (durably) and the
2301    /// commit must be completed or released.
2302    Reserved,
2303    /// An identical request already committed: replay the original receipt
2304    /// without re-executing.
2305    Replay(mongreldb_log::CommitReceipt),
2306}
2307
2308/// The durable idempotency ledger (S1B-005). In-memory slots mirror the
2309/// sibling file; every mutation is persisted through
2310/// [`crate::durable_file::DurableRoot::write_atomic`] before it is relied
2311/// upon, so a response never acknowledges an idempotency record that a
2312/// restart cannot see.
2313pub(crate) struct IdempotencyLedger {
2314    root: std::sync::Arc<crate::durable_file::DurableRoot>,
2315    meta_dek: Option<[u8; crate::catalog::META_DEK_LEN]>,
2316    inner: PlMutex<Vec<StoredIdempotencyRecord>>,
2317}
2318
2319impl IdempotencyLedger {
2320    /// Open the ledger, loading any persisted records. Missing file means an
2321    /// empty ledger; present-but-unverifiable content fails closed (like the
2322    /// job registry). Expired records are swept in memory on open; the file
2323    /// is rewritten lazily on the next mutation.
2324    pub(crate) fn open(
2325        root: std::sync::Arc<crate::durable_file::DurableRoot>,
2326        meta_dek: Option<[u8; crate::catalog::META_DEK_LEN]>,
2327    ) -> Result<Self> {
2328        let mut inner = read_idempotency_file(&root, meta_dek.as_ref())?.unwrap_or_default();
2329        sweep_expired(&mut inner, wall_micros());
2330        Ok(Self {
2331            root,
2332            meta_dek,
2333            inner: PlMutex::new(inner),
2334        })
2335    }
2336
2337    /// Pre-propose check (S1B-005): sweep expired records, then classify the
2338    /// request. A new key is reserved and the reservation persisted BEFORE
2339    /// the commit proposal, so a crash between the durable commit and the
2340    /// receipt record leaves a `Reserved` slot that fails closed on retry.
2341    pub(crate) fn check_and_reserve(
2342        &self,
2343        request: &IdempotencyRequest,
2344    ) -> Result<IdempotencyCheck> {
2345        validate_request(request)?;
2346        let now = wall_micros();
2347        let mut inner = self.inner.lock();
2348        sweep_expired(&mut inner, now);
2349        if let Some(existing) = inner
2350            .iter()
2351            .find(|record| record.owner == request.owner && record.key == request.key)
2352        {
2353            if existing.fingerprint != request.fingerprint {
2354                return Err(MongrelError::Conflict(format!(
2355                    "idempotency key {:?} was already used with a different request",
2356                    request.key
2357                )));
2358            }
2359            return match &existing.outcome {
2360                StoredIdempotencyOutcome::Committed {
2361                    txn_id,
2362                    epoch,
2363                    commit_ts,
2364                } => Ok(IdempotencyCheck::Replay(mongreldb_log::CommitReceipt {
2365                    transaction_id: crate::commit_log::transaction_id_from_txn(*txn_id),
2366                    commit_ts: *commit_ts,
2367                    log_position: mongreldb_log::LogPosition {
2368                        term: 0,
2369                        index: *epoch,
2370                    },
2371                    durability: mongreldb_log::DurabilityLevel::GroupCommit,
2372                })),
2373                StoredIdempotencyOutcome::Reserved => Err(MongrelError::Conflict(format!(
2374                    "idempotency key {:?} has an in-flight or interrupted commit; retry to resolve",
2375                    request.key
2376                ))),
2377            };
2378        }
2379        inner.push(StoredIdempotencyRecord {
2380            owner: request.owner.clone(),
2381            key: request.key.clone(),
2382            fingerprint: request.fingerprint,
2383            expires_at_micros: expiry_micros(request.ttl, now),
2384            outcome: StoredIdempotencyOutcome::Reserved,
2385        });
2386        persist_locked(&self.root, self.meta_dek.as_ref(), &mut inner)?;
2387        Ok(IdempotencyCheck::Reserved)
2388    }
2389
2390    /// Record the original commit receipt against a reserved key (after the
2391    /// receipt exists, before the caller is told success). Never sweeps: the
2392    /// reservation being completed belongs to an in-flight commit and must
2393    /// survive even an absurdly short TTL (expiry applies once the commit
2394    /// has completed, measured from completion).
2395    pub(crate) fn complete(
2396        &self,
2397        request: &IdempotencyRequest,
2398        txn_id: u64,
2399        epoch: Epoch,
2400        commit_ts: HlcTimestamp,
2401    ) -> Result<()> {
2402        let mut inner = self.inner.lock();
2403        let now = wall_micros();
2404        let Some(record) = inner
2405            .iter_mut()
2406            .find(|record| record.owner == request.owner && record.key == request.key)
2407        else {
2408            return Err(MongrelError::Other(format!(
2409                "idempotency reservation for key {:?} vanished during commit",
2410                request.key
2411            )));
2412        };
2413        record.expires_at_micros = expiry_micros(request.ttl, now);
2414        record.outcome = StoredIdempotencyOutcome::Committed {
2415            txn_id,
2416            epoch: epoch.0,
2417            commit_ts,
2418        };
2419        persist_locked(&self.root, self.meta_dek.as_ref(), &mut inner)
2420    }
2421
2422    /// Drop a reservation whose commit never became durable (pre-fence
2423    /// failure). Best-effort: the caller is already handling the commit
2424    /// error, and a leaked reservation expires by TTL or conflicts safely.
2425    pub(crate) fn release(&self, request: &IdempotencyRequest) {
2426        let mut inner = self.inner.lock();
2427        inner.retain(|record| {
2428            !(record.owner == request.owner
2429                && record.key == request.key
2430                && matches!(record.outcome, StoredIdempotencyOutcome::Reserved))
2431        });
2432        let _ = persist_locked(&self.root, self.meta_dek.as_ref(), &mut inner);
2433    }
2434}
2435
2436/// Guard releasing an idempotency reservation on drop unless disarmed (the
2437/// commit reached its receipt).
2438pub(crate) struct IdempotencyReservationGuard<'a> {
2439    ledger: &'a IdempotencyLedger,
2440    request: IdempotencyRequest,
2441    armed: bool,
2442}
2443
2444impl<'a> IdempotencyReservationGuard<'a> {
2445    pub(crate) fn new(ledger: &'a IdempotencyLedger, request: IdempotencyRequest) -> Self {
2446        Self {
2447            ledger,
2448            request,
2449            armed: true,
2450        }
2451    }
2452
2453    pub(crate) fn disarm(&mut self) {
2454        self.armed = false;
2455    }
2456}
2457
2458impl Drop for IdempotencyReservationGuard<'_> {
2459    fn drop(&mut self) {
2460        if self.armed {
2461            self.ledger.release(&self.request);
2462        }
2463    }
2464}
2465
2466fn validate_request(request: &IdempotencyRequest) -> Result<()> {
2467    if request.key.is_empty() {
2468        return Err(MongrelError::InvalidArgument(
2469            "idempotency key must not be empty".into(),
2470        ));
2471    }
2472    if request.owner.is_empty() {
2473        return Err(MongrelError::InvalidArgument(
2474            "idempotency owner must not be empty".into(),
2475        ));
2476    }
2477    Ok(())
2478}
2479
2480fn expiry_micros(ttl: Option<Duration>, now_micros: u64) -> Option<u64> {
2481    ttl.map(|ttl| now_micros.saturating_add(u64::try_from(ttl.as_micros()).unwrap_or(u64::MAX)))
2482}
2483
2484fn sweep_expired(records: &mut Vec<StoredIdempotencyRecord>, now_micros: u64) {
2485    records.retain(|record| {
2486        record
2487            .expires_at_micros
2488            .is_none_or(|expires_at| expires_at > now_micros)
2489    });
2490}
2491
2492/// Evict oldest committed records beyond the bounded size. Reservations in
2493/// flight are never evicted; a ledger that still exceeds the bound fails
2494/// closed with `ResourceLimitExceeded`.
2495fn enforce_bounds(records: &mut Vec<StoredIdempotencyRecord>) -> Result<()> {
2496    while records.len() > MAX_IDEMPOTENCY_RECORDS {
2497        let Some((oldest, _)) = records
2498            .iter()
2499            .enumerate()
2500            .filter(|(_, record)| {
2501                matches!(record.outcome, StoredIdempotencyOutcome::Committed { .. })
2502            })
2503            .min_by_key(|(_, record)| match &record.outcome {
2504                StoredIdempotencyOutcome::Committed { epoch, .. } => *epoch,
2505                StoredIdempotencyOutcome::Reserved => unreachable!(),
2506            })
2507        else {
2508            return Err(MongrelError::ResourceLimitExceeded {
2509                resource: "idempotency records",
2510                requested: records.len(),
2511                limit: MAX_IDEMPOTENCY_RECORDS,
2512            });
2513        };
2514        records.remove(oldest);
2515    }
2516    Ok(())
2517}
2518
2519fn persist_locked(
2520    root: &crate::durable_file::DurableRoot,
2521    meta_dek: Option<&[u8; crate::catalog::META_DEK_LEN]>,
2522    records: &mut Vec<StoredIdempotencyRecord>,
2523) -> Result<()> {
2524    enforce_bounds(records)?;
2525    let body = serde_json::to_vec(&IdempotencyEnvelope {
2526        format_version: IDEMPOTENCY_FORMAT_VERSION,
2527        records: records.clone(),
2528    })
2529    .map_err(|error| MongrelError::Other(format!("idempotency ledger serialize: {error}")))?;
2530    let payload = seal_idempotency(&body, meta_dek)?;
2531    root.write_atomic(IDEMPOTENCY_FILENAME, &payload)?;
2532    Ok(())
2533}
2534
2535fn read_idempotency_file(
2536    root: &crate::durable_file::DurableRoot,
2537    meta_dek: Option<&[u8; crate::catalog::META_DEK_LEN]>,
2538) -> Result<Option<Vec<StoredIdempotencyRecord>>> {
2539    use std::io::Read as _;
2540    let file = match root.open_regular(IDEMPOTENCY_FILENAME) {
2541        Ok(file) => file,
2542        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2543        Err(error) => return Err(error.into()),
2544    };
2545    let length = file.metadata()?.len();
2546    if length > MAX_IDEMPOTENCY_BYTES {
2547        return Err(MongrelError::Other(format!(
2548            "idempotency ledger of {length} bytes exceeds the {MAX_IDEMPOTENCY_BYTES}-byte limit"
2549        )));
2550    }
2551    let mut bytes = Vec::with_capacity(length as usize);
2552    file.take(MAX_IDEMPOTENCY_BYTES + 1)
2553        .read_to_end(&mut bytes)?;
2554    if bytes.len() as u64 != length {
2555        return Err(MongrelError::Other(
2556            "idempotency ledger length changed while reading".into(),
2557        ));
2558    }
2559    open_idempotency_payload(&bytes, meta_dek).map(Some)
2560}
2561
2562fn decode_idempotency(body: &[u8]) -> Result<Vec<StoredIdempotencyRecord>> {
2563    let envelope: IdempotencyEnvelope = serde_json::from_slice(body)
2564        .map_err(|error| MongrelError::Other(format!("idempotency ledger deserialize: {error}")))?;
2565    if envelope.format_version != IDEMPOTENCY_FORMAT_VERSION {
2566        return Err(MongrelError::Other(format!(
2567            "unsupported idempotency ledger format version {}",
2568            envelope.format_version
2569        )));
2570    }
2571    Ok(envelope.records)
2572}
2573
2574fn plaintext_idempotency_frame(body: &[u8]) -> Vec<u8> {
2575    use sha2::Digest as _;
2576    let hash = sha2::Sha256::digest(body);
2577    let mut out = Vec::with_capacity(body.len() + 8 + 32);
2578    out.extend_from_slice(IDEMPOTENCY_MAGIC);
2579    out.extend_from_slice(&hash);
2580    out.extend_from_slice(body);
2581    out
2582}
2583
2584fn parse_idempotency_plaintext(bytes: &[u8]) -> Result<Vec<StoredIdempotencyRecord>> {
2585    use sha2::Digest as _;
2586    if bytes.len() < 8 + 32 || &bytes[..8] != IDEMPOTENCY_MAGIC {
2587        return Err(MongrelError::Other(
2588            "idempotency ledger magic mismatch (corrupt or sealed with a key)".into(),
2589        ));
2590    }
2591    let (tag, body) = bytes[8..].split_at(32);
2592    let calc = sha2::Sha256::digest(body);
2593    if tag != calc.as_slice() {
2594        return Err(MongrelError::Other(
2595            "idempotency ledger checksum mismatch (tampered or torn)".into(),
2596        ));
2597    }
2598    decode_idempotency(body)
2599}
2600
2601fn seal_idempotency(
2602    body: &[u8],
2603    meta_dek: Option<&[u8; crate::catalog::META_DEK_LEN]>,
2604) -> Result<Vec<u8>> {
2605    match meta_dek {
2606        Some(dek) => crate::encryption::encrypt_blob(dek, body),
2607        None => Ok(plaintext_idempotency_frame(body)),
2608    }
2609}
2610
2611fn open_idempotency_payload(
2612    bytes: &[u8],
2613    meta_dek: Option<&[u8; crate::catalog::META_DEK_LEN]>,
2614) -> Result<Vec<StoredIdempotencyRecord>> {
2615    match meta_dek {
2616        // Fail closed: an unauthenticated ledger is an error, never "no
2617        // records" (mirroring the job registry).
2618        Some(dek) => {
2619            let body = crate::encryption::decrypt_blob(dek, bytes).map_err(|_| {
2620                MongrelError::Decryption(
2621                    "idempotency ledger authentication failed (wrong key or tampered)".into(),
2622                )
2623            })?;
2624            decode_idempotency(&body)
2625        }
2626        None => parse_idempotency_plaintext(bytes),
2627    }
2628}
2629
2630/// Wall-clock microseconds since the Unix epoch (saturating), for expiry.
2631fn wall_micros() -> u64 {
2632    let micros = std::time::SystemTime::now()
2633        .duration_since(std::time::UNIX_EPOCH)
2634        .map(|duration| duration.as_micros())
2635        .unwrap_or(0);
2636    u64::try_from(micros).unwrap_or(u64::MAX)
2637}