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 => Snapshot::at(self.db.visible_epoch()),
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.lock().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    fn commit_full(
1156        mut self,
1157        control: Option<&crate::ExecutionControl>,
1158        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
1159        idempotency: Option<IdempotencyRequest>,
1160    ) -> Result<(Epoch, Vec<RowId>)> {
1161        if let Some(message) = self.allocation_error.take() {
1162            self.state.abort(AbortReason::Error(message.clone()));
1163            return Err(MongrelError::Full(message));
1164        }
1165        self.state.begin_prepare();
1166        let context = TxnCommitContext {
1167            isolation: self.isolation,
1168            read_ts: self.read_ts,
1169            read_set: std::mem::take(&mut self.read_set),
1170            predicate_set: std::mem::take(&mut self.predicate_set),
1171            state: Some(self.state.clone()),
1172            idempotency,
1173        };
1174        let staging = std::mem::take(&mut self.staging);
1175        let external_states = std::mem::take(&mut self.external_states);
1176        let materialized_view_updates = std::mem::take(&mut self.materialized_view_updates);
1177        let principal = self.principal.take();
1178        let result = match (control, before_commit) {
1179            (Some(control), Some(before_commit)) => {
1180                self.db.commit_transaction_with_external_states_controlled(
1181                    self.txn_id,
1182                    self.read.epoch,
1183                    staging,
1184                    external_states,
1185                    materialized_view_updates,
1186                    principal,
1187                    self.principal_catalog_bound,
1188                    self.external_trigger_bridge,
1189                    context,
1190                    control,
1191                    before_commit,
1192                )
1193            }
1194            _ => self.db.commit_transaction_with_external_states(
1195                self.txn_id,
1196                self.read.epoch,
1197                staging,
1198                external_states,
1199                materialized_view_updates,
1200                principal,
1201                self.principal_catalog_bound,
1202                self.external_trigger_bridge,
1203                context,
1204            ),
1205        };
1206        if let Err(error) = &result {
1207            classify_commit_error(&self.state, error);
1208        }
1209        result
1210    }
1211
1212    /// Rollback: discard staging. Nothing is appended to the WAL.
1213    pub fn rollback(self) {
1214        self.state.abort(AbortReason::RolledBack);
1215        // Dropping `self` discards the staging — it lives only in memory.
1216    }
1217}
1218
1219impl Drop for Transaction<'_> {
1220    fn drop(&mut self) {
1221        // A transaction dropped without commit is aborted (nothing durable
1222        // was appended). `Committed`/`CommitCritical` states set by a
1223        // completed or in-flight commit are left untouched.
1224        self.state.abort(AbortReason::RolledBack);
1225        // S1B-004 step 12: a transaction never outlives its lock holds. The
1226        // commit path already released them through its guard, so this only
1227        // fires for rollback/abort/drop paths — and is a no-op otherwise.
1228        if self.txn_id != crate::wal::SYSTEM_TXN_ID {
1229            self.db.release_txn_locks(self.txn_id);
1230        }
1231    }
1232}
1233
1234fn owned_row_from_cells(cells: &[(u16, Value)]) -> OwnedRow {
1235    let mut columns = cells.to_vec();
1236    columns.sort_by_key(|(id, _)| *id);
1237    OwnedRow { columns }
1238}
1239
1240fn owned_row_from_map(columns: HashMap<u16, Value>) -> OwnedRow {
1241    let mut columns: Vec<(u16, Value)> = columns.into_iter().collect();
1242    columns.sort_by_key(|(id, _)| *id);
1243    OwnedRow { columns }
1244}
1245
1246fn merge_cells(mut base: Vec<(u16, Value)>, updates: Vec<(u16, Value)>) -> Vec<(u16, Value)> {
1247    for (id, value) in updates {
1248        base.retain(|(existing, _)| *existing != id);
1249        base.push((id, value));
1250    }
1251    base.sort_by_key(|(id, _)| *id);
1252    base
1253}
1254
1255fn changed_columns(cells: &[(u16, Value)]) -> Vec<u16> {
1256    let mut columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
1257    columns.sort_unstable();
1258    columns.dedup();
1259    columns
1260}
1261
1262fn columns_equal(a: &[(u16, Value)], b: &[(u16, Value)]) -> bool {
1263    if a.len() != b.len() {
1264        return false;
1265    }
1266    let mut a: Vec<_> = a.iter().collect();
1267    let mut b: Vec<_> = b.iter().collect();
1268    a.sort_by_key(|(id, _)| *id);
1269    b.sort_by_key(|(id, _)| *id);
1270    a.iter()
1271        .zip(b.iter())
1272        .all(|((id_a, v_a), (id_b, v_b))| id_a == id_b && v_a == v_b)
1273}
1274
1275/// Staged operation produced after row-id allocation (internal to commit).
1276pub(crate) enum StagedOp {
1277    Put(Vec<crate::memtable::Row>),
1278    Delete(Vec<RowId>),
1279    Truncate,
1280}
1281
1282// ── P3.1: conflict index + active-txn registry (spec §8.3, §9.2) ─────────
1283
1284use std::collections::{BTreeMap, HashMap};
1285use std::hash::{Hash, Hasher};
1286
1287/// A write-set key broad enough to detect all write–write conflicts under
1288/// snapshot isolation (spec §8.3, review fix #13).
1289#[derive(Clone, Debug)]
1290pub enum WriteKey {
1291    /// Row-version key for updates/deletes of existing rows.
1292    Row { table_id: u64, row_id: u64 },
1293    /// Unique/PK key for inserts/updates touching a UNIQUE column.
1294    Unique {
1295        table_id: u64,
1296        index_id: u16,
1297        key_hash: u64,
1298    },
1299    /// Table-scope key for TRUNCATE/DROP/ALTER and any txn writing that table.
1300    Table { table_id: u64 },
1301}
1302
1303impl Hash for WriteKey {
1304    fn hash<H: Hasher>(&self, state: &mut H) {
1305        match self {
1306            WriteKey::Row { table_id, row_id } => {
1307                0u8.hash(state);
1308                table_id.hash(state);
1309                row_id.hash(state);
1310            }
1311            WriteKey::Unique {
1312                table_id,
1313                index_id,
1314                key_hash,
1315            } => {
1316                1u8.hash(state);
1317                table_id.hash(state);
1318                index_id.hash(state);
1319                key_hash.hash(state);
1320            }
1321            WriteKey::Table { table_id } => {
1322                2u8.hash(state);
1323                table_id.hash(state);
1324            }
1325        }
1326    }
1327}
1328
1329impl PartialEq for WriteKey {
1330    fn eq(&self, other: &Self) -> bool {
1331        match (self, other) {
1332            (
1333                WriteKey::Row {
1334                    table_id: a,
1335                    row_id: b,
1336                },
1337                WriteKey::Row {
1338                    table_id: c,
1339                    row_id: d,
1340                },
1341            ) => a == c && b == d,
1342            (
1343                WriteKey::Unique {
1344                    table_id: a,
1345                    index_id: b,
1346                    key_hash: c,
1347                },
1348                WriteKey::Unique {
1349                    table_id: d,
1350                    index_id: e,
1351                    key_hash: f,
1352                },
1353            ) => a == d && b == e && c == f,
1354            (WriteKey::Table { table_id: a }, WriteKey::Table { table_id: b }) => a == b,
1355            _ => false,
1356        }
1357    }
1358}
1359
1360impl Eq for WriteKey {}
1361
1362const CONFLICT_SHARDS: usize = 16;
1363
1364/// A sharded concurrent map of `WriteKey → commit_epoch` recording recent
1365/// committed writes (spec §9.2). Validation probes per write-set key; pruning
1366/// drops entries below `min(active read_epoch)`.
1367pub struct ConflictIndex {
1368    shards: [parking_lot::Mutex<HashMap<WriteKey, u64>>; CONFLICT_SHARDS],
1369    table_truncate_epochs: parking_lot::Mutex<HashMap<u64, u64>>,
1370    table_write_epochs: parking_lot::Mutex<HashMap<u64, u64>>,
1371    /// Bumped on every `record()` so pre-validation can detect whether new
1372    /// commits arrived between the pre-check and the sequencer (spec §8.5,
1373    /// review fix #17).
1374    version: std::sync::atomic::AtomicU64,
1375}
1376
1377impl ConflictIndex {
1378    pub fn new() -> Self {
1379        Self {
1380            shards: std::array::from_fn(|_| parking_lot::Mutex::new(HashMap::new())),
1381            table_truncate_epochs: parking_lot::Mutex::new(HashMap::new()),
1382            table_write_epochs: parking_lot::Mutex::new(HashMap::new()),
1383            version: std::sync::atomic::AtomicU64::new(0),
1384        }
1385    }
1386
1387    /// Current version (incremented on every `record`). Used by the two-phase
1388    /// validation: pre-validate + snapshot version → sequencer re-checks only
1389    /// if the version advanced.
1390    pub fn version(&self) -> u64 {
1391        self.version.load(std::sync::atomic::Ordering::Acquire)
1392    }
1393
1394    fn shard(&self, key: &WriteKey) -> &parking_lot::Mutex<HashMap<WriteKey, u64>> {
1395        let mut h = std::collections::hash_map::DefaultHasher::new();
1396        key.hash(&mut h);
1397        let idx = (h.finish() as usize) & (CONFLICT_SHARDS - 1);
1398        &self.shards[idx]
1399    }
1400
1401    /// Returns `true` if any key was committed at an epoch strictly greater
1402    /// than `read_epoch` (write–write conflict under SI; first-committer-wins).
1403    pub fn conflicts(&self, keys: &[WriteKey], read_epoch: Epoch) -> bool {
1404        for k in keys {
1405            let s = self.shard(k);
1406            if let Some(&ce) = s.lock().get(k) {
1407                if ce > read_epoch.0 {
1408                    return true;
1409                }
1410            }
1411        }
1412        let truncates = self.table_truncate_epochs.lock();
1413        let writes = self.table_write_epochs.lock();
1414        for k in keys {
1415            match k {
1416                WriteKey::Row { table_id, .. } | WriteKey::Unique { table_id, .. } => {
1417                    if truncates.get(table_id).is_some_and(|&ce| ce > read_epoch.0) {
1418                        return true;
1419                    }
1420                }
1421                WriteKey::Table { table_id } => {
1422                    if writes.get(table_id).is_some_and(|&ce| ce > read_epoch.0) {
1423                        return true;
1424                    }
1425                }
1426            }
1427        }
1428        false
1429    }
1430
1431    /// Record every write-set key at `commit_epoch`.
1432    pub fn record(&self, keys: &[WriteKey], commit_epoch: Epoch) {
1433        for k in keys {
1434            let s = self.shard(k);
1435            s.lock().insert(k.clone(), commit_epoch.0);
1436        }
1437        let mut truncates = self.table_truncate_epochs.lock();
1438        let mut writes = self.table_write_epochs.lock();
1439        for k in keys {
1440            match k {
1441                WriteKey::Table { table_id } => {
1442                    truncates
1443                        .entry(*table_id)
1444                        .and_modify(|ce| *ce = (*ce).max(commit_epoch.0))
1445                        .or_insert(commit_epoch.0);
1446                }
1447                WriteKey::Row { table_id, .. } | WriteKey::Unique { table_id, .. } => {
1448                    writes
1449                        .entry(*table_id)
1450                        .and_modify(|ce| *ce = (*ce).max(commit_epoch.0))
1451                        .or_insert(commit_epoch.0);
1452                }
1453            }
1454        }
1455        self.version
1456            .fetch_add(1, std::sync::atomic::Ordering::Release);
1457    }
1458
1459    /// Drop entries whose `commit_epoch < min_active` (they can never cause a
1460    /// future conflict once no live txn reads below `min_active`).
1461    pub fn prune_below(&self, min_active: Epoch) {
1462        for s in &self.shards {
1463            s.lock().retain(|_, ce| *ce >= min_active.0);
1464        }
1465        self.table_truncate_epochs
1466            .lock()
1467            .retain(|_, ce| *ce >= min_active.0);
1468        self.table_write_epochs
1469            .lock()
1470            .retain(|_, ce| *ce >= min_active.0);
1471    }
1472}
1473
1474impl Default for ConflictIndex {
1475    fn default() -> Self {
1476        Self::new()
1477    }
1478}
1479
1480// ── P3.2: real group commit (spec §9.3) ─────────────────────────────────
1481
1482/// Group-commit coordinator (spec §9.3). The commit sequencer appends a txn's
1483/// records under the WAL mutex but does **not** fsync there; instead each
1484/// committer calls [`Self::await_durable`] with its commit record's WAL seq.
1485/// Exactly one waiter becomes the *leader* and issues a single `group_sync`
1486/// (fsync), which makes durable every record appended up to that point; the
1487/// others are *followers* that simply wait until `durable_seq` reaches their
1488/// commit seq. One fsync therefore covers a whole batch of concurrent commits.
1489pub struct GroupCommit {
1490    inner: PlMutex<GroupState>,
1491    cv: Condvar,
1492    /// S1A-004: the owning core's lifecycle, poisoned on fsync error so every
1493    /// later operation is rejected at admission. `None` for standalone tables
1494    /// that have no core lifecycle.
1495    lifecycle: Option<Arc<crate::core::LifecycleController>>,
1496}
1497
1498struct GroupState {
1499    durable_seq: u64,
1500    syncing: bool,
1501    poisoned: bool,
1502}
1503
1504impl GroupCommit {
1505    pub fn new(durable_seq: u64) -> Self {
1506        Self {
1507            inner: PlMutex::new(GroupState {
1508                durable_seq,
1509                syncing: false,
1510                poisoned: false,
1511            }),
1512            cv: Condvar::new(),
1513            lifecycle: None,
1514        }
1515    }
1516
1517    /// Attach the owning core's lifecycle controller (S1A-004): an fsync
1518    /// error poisons it, transitioning the core to
1519    /// [`crate::core::LifecycleState::Poisoned`].
1520    pub fn with_lifecycle(mut self, lifecycle: Arc<crate::core::LifecycleController>) -> Self {
1521        self.lifecycle = Some(lifecycle);
1522        self
1523    }
1524
1525    /// Block until `commit_seq` is durable. The first eligible caller fsyncs on
1526    /// behalf of the batch; the rest wait on the condvar. On fsync error the
1527    /// coordinator is poisoned and every waiter (current and future) returns
1528    /// `Err` (spec §9.3e). `wal` is the same `SharedWal` the sequencer appended
1529    /// to — locked here only for the brief fsync, never across the wait.
1530    pub fn await_durable(&self, wal: &PlMutex<SharedWal>, commit_seq: u64) -> Result<()> {
1531        let mut st = self.inner.lock();
1532        loop {
1533            if st.poisoned {
1534                return Err(MongrelError::Other(
1535                    "database poisoned by fsync error".into(),
1536                ));
1537            }
1538            if st.durable_seq >= commit_seq {
1539                return Ok(());
1540            }
1541            if st.syncing {
1542                // Another thread is the leader; wait for it to advance durability.
1543                self.cv.wait(&mut st);
1544                continue;
1545            }
1546            // Become the leader: fsync outside the coordinator lock (but under
1547            // the WAL lock) so followers can queue up behind us.
1548            st.syncing = true;
1549            drop(st);
1550            // ponytail: fixed 50 µs batch window; make adaptive if isolated commit latency matters.
1551            std::thread::sleep(std::time::Duration::from_micros(50));
1552            let res = wal.lock().group_sync();
1553            st = self.inner.lock();
1554            st.syncing = false;
1555            match res {
1556                Ok(durable) => {
1557                    if durable > st.durable_seq {
1558                        st.durable_seq = durable;
1559                    }
1560                    self.cv.notify_all();
1561                    // Loop re-checks: our commit_seq <= durable (group_sync makes
1562                    // everything appended-so-far durable), so we return Ok next.
1563                }
1564                Err(e) => {
1565                    st.poisoned = true;
1566                    // S1A-004: the fsync poison is unrecoverable — transition
1567                    // the core lifecycle so every later operation is rejected
1568                    // at admission, not just at the next WAL append.
1569                    if let Some(lifecycle) = &self.lifecycle {
1570                        lifecycle.poison();
1571                    }
1572                    self.cv.notify_all();
1573                    return Err(e);
1574                }
1575            }
1576        }
1577    }
1578}
1579
1580/// Tracks the `read_epoch` of every in-flight transaction (spec §9.2, review
1581/// fix #12). `begin` registers **before** the first read; `min_read_epoch`
1582/// drives conflict-index pruning.
1583pub struct ActiveTxns {
1584    inner: parking_lot::Mutex<BTreeMap<u64, u64>>,
1585}
1586
1587impl ActiveTxns {
1588    pub fn new() -> Self {
1589        Self {
1590            inner: parking_lot::Mutex::new(BTreeMap::new()),
1591        }
1592    }
1593
1594    /// Register a transaction's read epoch. Returns a guard that deregisters
1595    /// on drop.
1596    pub fn register(&self, read_epoch: Epoch) -> ActiveTxnGuard<'_> {
1597        let mut g = self.inner.lock();
1598        *g.entry(read_epoch.0).or_insert(0) += 1;
1599        ActiveTxnGuard {
1600            active: self,
1601            epoch: read_epoch.0,
1602        }
1603    }
1604
1605    /// The lowest live `read_epoch`, or `u64::MAX` when no txn is active.
1606    pub fn min_read_epoch(&self) -> u64 {
1607        self.inner.lock().keys().next().copied().unwrap_or(u64::MAX)
1608    }
1609}
1610
1611impl Default for ActiveTxns {
1612    fn default() -> Self {
1613        Self::new()
1614    }
1615}
1616
1617/// Guard for an active transaction's read-epoch registration.
1618pub struct ActiveTxnGuard<'a> {
1619    active: &'a ActiveTxns,
1620    epoch: u64,
1621}
1622
1623impl Drop for ActiveTxnGuard<'_> {
1624    fn drop(&mut self) {
1625        let mut g = self.active.inner.lock();
1626        if let Some(count) = g.get_mut(&self.epoch) {
1627            *count -= 1;
1628            if *count == 0 {
1629                g.remove(&self.epoch);
1630            }
1631        }
1632    }
1633}
1634
1635#[cfg(test)]
1636mod tests {
1637    use super::*;
1638
1639    #[test]
1640    fn shared_transaction_allocator_never_crosses_open_generation() {
1641        let allocator = PlMutex::new((7_u64 << 32) | u32::MAX as u64);
1642        assert_eq!(
1643            allocate_txn_id(&allocator).unwrap(),
1644            (7_u64 << 32) | u32::MAX as u64
1645        );
1646        assert!(matches!(
1647            allocate_txn_id(&allocator),
1648            Err(MongrelError::Full(_))
1649        ));
1650    }
1651
1652    #[test]
1653    fn conflict_index_first_committer_wins_and_prunes_safely() {
1654        let ci = ConflictIndex::new();
1655        let k = vec![WriteKey::Row {
1656            table_id: 1,
1657            row_id: 7,
1658        }];
1659        assert!(!ci.conflicts(&k, Epoch(5)));
1660        ci.record(&k, Epoch(6));
1661        assert!(ci.conflicts(&k, Epoch(5)));
1662        assert!(!ci.conflicts(&k, Epoch(6)));
1663        ci.prune_below(Epoch(7));
1664        assert!(!ci.conflicts(&k, Epoch(5)));
1665    }
1666
1667    #[test]
1668    fn conflict_index_table_scope_conflicts_both_directions() {
1669        let ci = ConflictIndex::new();
1670        ci.record(&[WriteKey::Table { table_id: 1 }], Epoch(6));
1671        assert!(ci.conflicts(
1672            &[WriteKey::Row {
1673                table_id: 1,
1674                row_id: 7,
1675            }],
1676            Epoch(5)
1677        ));
1678        assert!(ci.conflicts(
1679            &[WriteKey::Unique {
1680                table_id: 1,
1681                index_id: 0,
1682                key_hash: 42,
1683            }],
1684            Epoch(5)
1685        ));
1686        assert!(!ci.conflicts(
1687            &[WriteKey::Row {
1688                table_id: 2,
1689                row_id: 7,
1690            }],
1691            Epoch(5)
1692        ));
1693
1694        let ci = ConflictIndex::new();
1695        ci.record(
1696            &[WriteKey::Row {
1697                table_id: 1,
1698                row_id: 7,
1699            }],
1700            Epoch(6),
1701        );
1702        assert!(ci.conflicts(&[WriteKey::Table { table_id: 1 }], Epoch(5)));
1703        assert!(!ci.conflicts(&[WriteKey::Table { table_id: 2 }], Epoch(5)));
1704    }
1705
1706    #[test]
1707    fn writekey_eq_across_variants() {
1708        let r1 = WriteKey::Row {
1709            table_id: 1,
1710            row_id: 2,
1711        };
1712        let r2 = WriteKey::Row {
1713            table_id: 1,
1714            row_id: 2,
1715        };
1716        let r3 = WriteKey::Row {
1717            table_id: 1,
1718            row_id: 3,
1719        };
1720        assert_eq!(r1, r2);
1721        assert_ne!(r1, r3);
1722
1723        let u1 = WriteKey::Unique {
1724            table_id: 1,
1725            index_id: 0,
1726            key_hash: 42,
1727        };
1728        let u2 = WriteKey::Unique {
1729            table_id: 1,
1730            index_id: 0,
1731            key_hash: 42,
1732        };
1733        assert_eq!(u1, u2);
1734        assert_ne!(r1, u1);
1735
1736        let t1 = WriteKey::Table { table_id: 5 };
1737        let t2 = WriteKey::Table { table_id: 5 };
1738        assert_eq!(t1, t2);
1739        assert_ne!(t1, r1);
1740    }
1741
1742    #[test]
1743    fn active_txns_tracks_min_read_epoch() {
1744        let at = ActiveTxns::new();
1745        assert_eq!(at.min_read_epoch(), u64::MAX);
1746        let g1 = at.register(Epoch(5));
1747        assert_eq!(at.min_read_epoch(), 5);
1748        let g2 = at.register(Epoch(3));
1749        assert_eq!(at.min_read_epoch(), 3);
1750        drop(g2);
1751        assert_eq!(at.min_read_epoch(), 5);
1752        drop(g1);
1753        assert_eq!(at.min_read_epoch(), u64::MAX);
1754    }
1755
1756    #[test]
1757    fn active_txns_dedups_same_epoch() {
1758        let at = ActiveTxns::new();
1759        let g1 = at.register(Epoch(7));
1760        let g2 = at.register(Epoch(7));
1761        assert_eq!(at.min_read_epoch(), 7);
1762        drop(g1);
1763        assert_eq!(at.min_read_epoch(), 7);
1764        drop(g2);
1765        assert_eq!(at.min_read_epoch(), u64::MAX);
1766    }
1767
1768    #[test]
1769    fn isolation_level_snapshot_aliases_repeatable_read() {
1770        #[allow(deprecated)]
1771        let snapshot = IsolationLevel::Snapshot;
1772        assert_eq!(snapshot.canonical(), IsolationLevel::RepeatableRead);
1773        assert_eq!(IsolationLevel::default(), IsolationLevel::RepeatableRead);
1774        assert_eq!(
1775            IsolationLevel::ReadCommitted.canonical(),
1776            IsolationLevel::ReadCommitted
1777        );
1778        assert_eq!(
1779            IsolationLevel::Serializable.canonical(),
1780            IsolationLevel::Serializable
1781        );
1782    }
1783
1784    #[test]
1785    fn transaction_state_transitions_are_enforced() {
1786        let handle = TxnStateHandle::new();
1787        assert!(matches!(handle.state(), TransactionState::Active));
1788        // Illegal: Active cannot become Committed or CommitCritical directly.
1789        assert!(!handle.enter_commit_critical());
1790        assert!(matches!(handle.state(), TransactionState::Active));
1791        // Illegal: Committed is unreachable from Active.
1792        let receipt = mongreldb_log::CommitReceipt {
1793            transaction_id: mongreldb_types::ids::TransactionId::from_bytes([0; 16]),
1794            commit_ts: HlcTimestamp::ZERO,
1795            log_position: mongreldb_log::LogPosition::ZERO,
1796            durability: mongreldb_log::DurabilityLevel::GroupCommit,
1797        };
1798        assert!(!handle.committed(receipt.clone()));
1799
1800        assert!(handle.begin_prepare());
1801        assert!(matches!(handle.state(), TransactionState::Preparing));
1802        assert!(handle.enter_commit_critical());
1803        assert!(matches!(handle.state(), TransactionState::CommitCritical));
1804        // CommitCritical never reports aborted (spec §4.7).
1805        assert!(!handle.abort(AbortReason::Conflict("late".into())));
1806        assert!(matches!(handle.state(), TransactionState::CommitCritical));
1807        assert!(handle.committed(receipt));
1808        assert!(matches!(handle.state(), TransactionState::Committed(_)));
1809        // Terminal: no further transitions.
1810        assert!(!handle.abort(AbortReason::RolledBack));
1811        assert!(!handle.begin_prepare());
1812    }
1813
1814    #[test]
1815    fn abort_from_preparing_is_terminal() {
1816        let handle = TxnStateHandle::new();
1817        assert!(handle.abort(AbortReason::RolledBack));
1818        assert!(matches!(
1819            handle.state(),
1820            TransactionState::Aborted(AbortReason::RolledBack)
1821        ));
1822        assert!(!handle.begin_prepare());
1823
1824        let handle = TxnStateHandle::new();
1825        handle.begin_prepare();
1826        assert!(handle.abort(AbortReason::Validation("bad row".into())));
1827        match handle.state() {
1828            TransactionState::Aborted(AbortReason::Validation(message)) => {
1829                assert_eq!(message, "bad row")
1830            }
1831            other => panic!("expected validation abort, got {other:?}"),
1832        }
1833    }
1834
1835    #[test]
1836    fn classify_commit_error_leaves_post_fence_states_untouched() {
1837        let handle = TxnStateHandle::new();
1838        handle.begin_prepare();
1839        classify_commit_error(&handle, &MongrelError::Conflict("ww".into()));
1840        assert!(matches!(
1841            handle.state(),
1842            TransactionState::Aborted(AbortReason::Conflict(_))
1843        ));
1844
1845        let handle = TxnStateHandle::new();
1846        handle.begin_prepare();
1847        handle.enter_commit_critical();
1848        classify_commit_error(
1849            &handle,
1850            &MongrelError::CommitOutcomeUnknown {
1851                epoch: 7,
1852                message: "fsync".into(),
1853            },
1854        );
1855        assert!(matches!(handle.state(), TransactionState::CommitCritical));
1856        classify_commit_error(
1857            &handle,
1858            &MongrelError::DurableCommit {
1859                epoch: 7,
1860                message: "publish".into(),
1861            },
1862        );
1863        assert!(matches!(handle.state(), TransactionState::CommitCritical));
1864    }
1865
1866    #[test]
1867    fn classify_commit_error_maps_serialization_failure_to_conflict_abort() {
1868        // The native SSI abort variant aborts pre-fence with the same
1869        // retry-the-whole-transaction reason as a write/write conflict, and
1870        // the recorded message keeps the variant's display prefix.
1871        let handle = TxnStateHandle::new();
1872        handle.begin_prepare();
1873        classify_commit_error(
1874            &handle,
1875            &MongrelError::SerializationFailure {
1876                message: "a concurrent commit invalidated this transaction's reads".into(),
1877            },
1878        );
1879        match handle.state() {
1880            TransactionState::Aborted(AbortReason::Conflict(message)) => {
1881                assert_eq!(
1882                    message,
1883                    "serialization failure: a concurrent commit invalidated this transaction's reads"
1884                );
1885            }
1886            other => panic!("expected conflict abort, got {other:?}"),
1887        }
1888    }
1889
1890    #[test]
1891    fn ssi_validation_keys_cover_rows_and_predicates() {
1892        let mut reads = ReadSet::default();
1893        reads.record_row(3, RowId(9));
1894        reads.record_row(3, RowId(9));
1895        reads.record_row(4, RowId(1));
1896        let mut predicates = PredicateSet::default();
1897        predicates.record_table(5);
1898        let keys = ssi_validation_keys(&reads, &predicates);
1899        assert_eq!(keys.len(), 3);
1900        assert!(keys.iter().any(|key| matches!(
1901            key,
1902            WriteKey::Row {
1903                table_id: 3,
1904                row_id: 9
1905            }
1906        )));
1907        assert!(keys.iter().any(|key| matches!(
1908            key,
1909            WriteKey::Row {
1910                table_id: 4,
1911                row_id: 1
1912            }
1913        )));
1914        assert!(keys
1915            .iter()
1916            .any(|key| matches!(key, WriteKey::Table { table_id: 5 })));
1917    }
1918
1919    fn test_request(key: &str) -> IdempotencyRequest {
1920        IdempotencyRequest {
1921            key: key.to_string(),
1922            owner: "alice".to_string(),
1923            fingerprint: 42,
1924            ttl: None,
1925        }
1926    }
1927
1928    fn test_commit_ts() -> HlcTimestamp {
1929        HlcTimestamp {
1930            physical_micros: 1_700_000_000_000_000,
1931            logical: 3,
1932            node_tiebreaker: 0,
1933        }
1934    }
1935
1936    #[test]
1937    fn idempotency_ledger_replay_conflict_and_restart() {
1938        let dir = tempfile::tempdir().unwrap();
1939        let root = std::sync::Arc::new(crate::durable_file::DurableRoot::open(dir.path()).unwrap());
1940        let ledger = IdempotencyLedger::open(std::sync::Arc::clone(&root), None).unwrap();
1941
1942        let request = test_request("k1");
1943        assert!(matches!(
1944            ledger.check_and_reserve(&request).unwrap(),
1945            IdempotencyCheck::Reserved
1946        ));
1947        // In-flight duplicate conflicts.
1948        assert!(matches!(
1949            ledger.check_and_reserve(&request),
1950            Err(MongrelError::Conflict(_))
1951        ));
1952        ledger
1953            .complete(&request, 7, Epoch(11), test_commit_ts())
1954            .unwrap();
1955
1956        // Identical request replays the original receipt.
1957        let replay = ledger.check_and_reserve(&request).unwrap();
1958        let IdempotencyCheck::Replay(receipt) = replay else {
1959            panic!("expected replay");
1960        };
1961        assert_eq!(receipt.log_position.index, 11);
1962        assert_eq!(receipt.commit_ts, test_commit_ts());
1963        assert_eq!(
1964            receipt.durability,
1965            mongreldb_log::DurabilityLevel::GroupCommit
1966        );
1967
1968        // Same key, different fingerprint conflicts.
1969        let mut other = test_request("k1");
1970        other.fingerprint = 43;
1971        assert!(matches!(
1972            ledger.check_and_reserve(&other),
1973            Err(MongrelError::Conflict(_))
1974        ));
1975        // Same key, different owner is an independent key.
1976        let mut foreign = test_request("k1");
1977        foreign.owner = "bob".to_string();
1978        assert!(matches!(
1979            ledger.check_and_reserve(&foreign).unwrap(),
1980            IdempotencyCheck::Reserved
1981        ));
1982        drop(ledger);
1983
1984        // Restart: records survive, replay still returns the original receipt.
1985        let reopened = IdempotencyLedger::open(root, None).unwrap();
1986        let replay = reopened.check_and_reserve(&request).unwrap();
1987        let IdempotencyCheck::Replay(receipt) = replay else {
1988            panic!("expected replay after restart");
1989        };
1990        assert_eq!(receipt.log_position.index, 11);
1991        assert_eq!(receipt.commit_ts, test_commit_ts());
1992        // The interrupted reservation also survived and fails closed.
1993        assert!(matches!(
1994            reopened.check_and_reserve(&foreign),
1995            Err(MongrelError::Conflict(_))
1996        ));
1997    }
1998
1999    #[test]
2000    fn idempotency_ledger_release_and_expiry() {
2001        let dir = tempfile::tempdir().unwrap();
2002        let root = std::sync::Arc::new(crate::durable_file::DurableRoot::open(dir.path()).unwrap());
2003        let ledger = IdempotencyLedger::open(std::sync::Arc::clone(&root), None).unwrap();
2004
2005        // A released reservation frees the key for a fresh attempt.
2006        let request = test_request("k-release");
2007        assert!(matches!(
2008            ledger.check_and_reserve(&request).unwrap(),
2009            IdempotencyCheck::Reserved
2010        ));
2011        ledger.release(&request);
2012        assert!(matches!(
2013            ledger.check_and_reserve(&request).unwrap(),
2014            IdempotencyCheck::Reserved
2015        ));
2016        ledger
2017            .complete(&request, 9, Epoch(12), test_commit_ts())
2018            .unwrap();
2019
2020        // An already-expired record is swept on the next check, so the key
2021        // can be reused.
2022        let mut expired = test_request("k-expired");
2023        expired.ttl = Some(Duration::from_nanos(1));
2024        assert!(matches!(
2025            ledger.check_and_reserve(&expired).unwrap(),
2026            IdempotencyCheck::Reserved
2027        ));
2028        ledger
2029            .complete(&expired, 10, Epoch(13), test_commit_ts())
2030            .unwrap();
2031        std::thread::sleep(Duration::from_millis(2));
2032        assert!(matches!(
2033            ledger.check_and_reserve(&expired).unwrap(),
2034            IdempotencyCheck::Reserved
2035        ));
2036
2037        // Restart sweeps the expired record too.
2038        drop(ledger);
2039        let reopened = IdempotencyLedger::open(root, None).unwrap();
2040        assert!(matches!(
2041            reopened.check_and_reserve(&expired).unwrap(),
2042            IdempotencyCheck::Reserved
2043        ));
2044    }
2045
2046    #[test]
2047    fn idempotency_ledger_rejects_empty_key_or_owner() {
2048        let dir = tempfile::tempdir().unwrap();
2049        let root = std::sync::Arc::new(crate::durable_file::DurableRoot::open(dir.path()).unwrap());
2050        let ledger = IdempotencyLedger::open(root, None).unwrap();
2051        let mut request = test_request("");
2052        assert!(matches!(
2053            ledger.check_and_reserve(&request),
2054            Err(MongrelError::InvalidArgument(_))
2055        ));
2056        request.key = "k".to_string();
2057        request.owner.clear();
2058        assert!(matches!(
2059            ledger.check_and_reserve(&request),
2060            Err(MongrelError::InvalidArgument(_))
2061        ));
2062    }
2063
2064    #[test]
2065    fn idempotency_ledger_enforces_bounded_size() {
2066        let mut records: Vec<StoredIdempotencyRecord> = (0..MAX_IDEMPOTENCY_RECORDS + 4)
2067            .map(|index| StoredIdempotencyRecord {
2068                owner: "o".to_string(),
2069                key: format!("k{index}"),
2070                fingerprint: 1,
2071                expires_at_micros: None,
2072                outcome: StoredIdempotencyOutcome::Committed {
2073                    txn_id: index as u64,
2074                    epoch: index as u64,
2075                    commit_ts: test_commit_ts(),
2076                },
2077            })
2078            .collect();
2079        enforce_bounds(&mut records).unwrap();
2080        assert_eq!(records.len(), MAX_IDEMPOTENCY_RECORDS);
2081        // Oldest (lowest epoch) evicted first.
2082        assert!(records.iter().all(|record| match &record.outcome {
2083            StoredIdempotencyOutcome::Committed { epoch, .. } => *epoch >= 4,
2084            StoredIdempotencyOutcome::Reserved => false,
2085        }));
2086
2087        // A ledger of only in-flight reservations cannot be bounded by
2088        // eviction: fail closed.
2089        let mut reserved: Vec<StoredIdempotencyRecord> = (0..MAX_IDEMPOTENCY_RECORDS + 1)
2090            .map(|index| StoredIdempotencyRecord {
2091                owner: "o".to_string(),
2092                key: format!("r{index}"),
2093                fingerprint: 1,
2094                expires_at_micros: None,
2095                outcome: StoredIdempotencyOutcome::Reserved,
2096            })
2097            .collect();
2098        assert!(matches!(
2099            enforce_bounds(&mut reserved),
2100            Err(MongrelError::ResourceLimitExceeded { .. })
2101        ));
2102    }
2103
2104    #[test]
2105    fn idempotency_ledger_tampered_file_fails_closed() {
2106        let dir = tempfile::tempdir().unwrap();
2107        let root = std::sync::Arc::new(crate::durable_file::DurableRoot::open(dir.path()).unwrap());
2108        let ledger = IdempotencyLedger::open(std::sync::Arc::clone(&root), None).unwrap();
2109        let request = test_request("k1");
2110        ledger.check_and_reserve(&request).unwrap();
2111        drop(ledger);
2112
2113        let path = dir.path().join(IDEMPOTENCY_FILENAME);
2114        let mut bytes = std::fs::read(&path).unwrap();
2115        let last = bytes.len() - 1;
2116        bytes[last] ^= 0xFF;
2117        std::fs::write(&path, bytes).unwrap();
2118        assert!(IdempotencyLedger::open(root, None).is_err());
2119    }
2120}
2121
2122/// Transaction isolation level (spec §10.2, S1B-002). MongrelDB defaults to
2123/// `RepeatableRead` (snapshot isolation).
2124///
2125/// - `RepeatableRead`: one snapshot fixed at `begin`; own staged writes are
2126///   visible to the transaction; write/write conflicts abort one transaction
2127///   (first-committer-wins). This is the engine's historical `Snapshot` (SI)
2128///   semantics under its SQL-standard name.
2129/// - `Snapshot`: deprecated alias of `RepeatableRead`, kept so existing call
2130///   sites compile unchanged. The two are interchangeable everywhere via
2131///   [`Self::canonical`].
2132/// - `ReadCommitted`: each statement obtains a new snapshot at the latest
2133///   visible epoch, so committed concurrent changes may appear between
2134///   statements. Commit-time conflict validation still uses the begin epoch
2135///   (conservative first-committer-wins).
2136/// - `Serializable`: repeatable-read snapshot plus SSI-style certification —
2137///   the commit sequencer tracks read dependencies (point reads), predicate/
2138///   range reads (table granularity), and write dependencies, and aborts
2139///   with a serialization failure when a concurrent commit invalidated a
2140///   tracked read (the rw-antidependency dangerous structure).
2141#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2142pub enum IsolationLevel {
2143    #[default]
2144    RepeatableRead,
2145    #[deprecated(
2146        note = "renamed to `RepeatableRead` (spec §10.2 S1B-002); identical semantics, kept for compatibility"
2147    )]
2148    Snapshot,
2149    ReadCommitted,
2150    Serializable,
2151}
2152
2153impl IsolationLevel {
2154    /// Collapse aliases onto their canonical variant: `Snapshot` behaves
2155    /// exactly as `RepeatableRead`.
2156    pub fn canonical(self) -> Self {
2157        match self {
2158            #[allow(deprecated)]
2159            Self::Snapshot => Self::RepeatableRead,
2160            other => other,
2161        }
2162    }
2163}
2164
2165// ── S1B-005: durable transaction idempotency ─────────────────────────────
2166
2167use std::time::Duration;
2168
2169/// Client-supplied idempotency parameters for one commit (spec §10.2,
2170/// S1B-005).
2171#[derive(Debug, Clone)]
2172pub struct IdempotencyRequest {
2173    /// The idempotency key, unique per owner.
2174    pub key: String,
2175    /// Owning principal; keys from different owners never alias.
2176    pub owner: String,
2177    /// Fingerprint of the request payload (caller-computed hash). A repeated
2178    /// key with the same fingerprint replays the original receipt; a
2179    /// different fingerprint conflicts.
2180    pub fingerprint: u64,
2181    /// How long the record is retained after the commit completes. `None`
2182    /// keeps the record until the ledger's bounded-size eviction.
2183    pub ttl: Option<Duration>,
2184}
2185
2186/// Sibling ledger file next to `CATALOG`/`JOBS` (mirroring the `JOBS`
2187/// pattern): checksum-framed JSON, atomically renamed, optionally sealed
2188/// with the metadata DEK.
2189pub const IDEMPOTENCY_FILENAME: &str = "TXN_IDEMPOTENCY";
2190
2191const IDEMPOTENCY_FORMAT_VERSION: u16 = 1;
2192const IDEMPOTENCY_MAGIC: &[u8; 8] = b"MONGRTXI";
2193/// Bounded ledger: at most this many records are retained (oldest committed
2194/// records are evicted first; in-flight reservations are never evicted).
2195const MAX_IDEMPOTENCY_RECORDS: usize = 65_536;
2196/// Hard cap on the ledger file size (fail closed beyond it).
2197const MAX_IDEMPOTENCY_BYTES: u64 = 64 * 1024 * 1024;
2198
2199/// The durable outcome of one idempotency key.
2200#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2201enum StoredIdempotencyOutcome {
2202    /// A commit was attempted but its receipt was never recorded (crash
2203    /// window). Retries fail closed with a conflict.
2204    Reserved,
2205    /// The commit is durable; this is the receipt to replay.
2206    Committed {
2207        txn_id: u64,
2208        epoch: u64,
2209        commit_ts: HlcTimestamp,
2210    },
2211}
2212
2213#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2214struct StoredIdempotencyRecord {
2215    owner: String,
2216    key: String,
2217    fingerprint: u64,
2218    expires_at_micros: Option<u64>,
2219    outcome: StoredIdempotencyOutcome,
2220}
2221
2222#[derive(serde::Serialize, serde::Deserialize)]
2223struct IdempotencyEnvelope {
2224    format_version: u16,
2225    records: Vec<StoredIdempotencyRecord>,
2226}
2227
2228/// Result of the pre-propose idempotency check.
2229pub(crate) enum IdempotencyCheck {
2230    /// First attempt under this key: it is now reserved (durably) and the
2231    /// commit must be completed or released.
2232    Reserved,
2233    /// An identical request already committed: replay the original receipt
2234    /// without re-executing.
2235    Replay(mongreldb_log::CommitReceipt),
2236}
2237
2238/// The durable idempotency ledger (S1B-005). In-memory slots mirror the
2239/// sibling file; every mutation is persisted through
2240/// [`crate::durable_file::DurableRoot::write_atomic`] before it is relied
2241/// upon, so a response never acknowledges an idempotency record that a
2242/// restart cannot see.
2243pub(crate) struct IdempotencyLedger {
2244    root: std::sync::Arc<crate::durable_file::DurableRoot>,
2245    meta_dek: Option<[u8; crate::catalog::META_DEK_LEN]>,
2246    inner: PlMutex<Vec<StoredIdempotencyRecord>>,
2247}
2248
2249impl IdempotencyLedger {
2250    /// Open the ledger, loading any persisted records. Missing file means an
2251    /// empty ledger; present-but-unverifiable content fails closed (like the
2252    /// job registry). Expired records are swept in memory on open; the file
2253    /// is rewritten lazily on the next mutation.
2254    pub(crate) fn open(
2255        root: std::sync::Arc<crate::durable_file::DurableRoot>,
2256        meta_dek: Option<[u8; crate::catalog::META_DEK_LEN]>,
2257    ) -> Result<Self> {
2258        let mut inner = read_idempotency_file(&root, meta_dek.as_ref())?.unwrap_or_default();
2259        sweep_expired(&mut inner, wall_micros());
2260        Ok(Self {
2261            root,
2262            meta_dek,
2263            inner: PlMutex::new(inner),
2264        })
2265    }
2266
2267    /// Pre-propose check (S1B-005): sweep expired records, then classify the
2268    /// request. A new key is reserved and the reservation persisted BEFORE
2269    /// the commit proposal, so a crash between the durable commit and the
2270    /// receipt record leaves a `Reserved` slot that fails closed on retry.
2271    pub(crate) fn check_and_reserve(
2272        &self,
2273        request: &IdempotencyRequest,
2274    ) -> Result<IdempotencyCheck> {
2275        validate_request(request)?;
2276        let now = wall_micros();
2277        let mut inner = self.inner.lock();
2278        sweep_expired(&mut inner, now);
2279        if let Some(existing) = inner
2280            .iter()
2281            .find(|record| record.owner == request.owner && record.key == request.key)
2282        {
2283            if existing.fingerprint != request.fingerprint {
2284                return Err(MongrelError::Conflict(format!(
2285                    "idempotency key {:?} was already used with a different request",
2286                    request.key
2287                )));
2288            }
2289            return match &existing.outcome {
2290                StoredIdempotencyOutcome::Committed {
2291                    txn_id,
2292                    epoch,
2293                    commit_ts,
2294                } => Ok(IdempotencyCheck::Replay(mongreldb_log::CommitReceipt {
2295                    transaction_id: crate::commit_log::transaction_id_from_txn(*txn_id),
2296                    commit_ts: *commit_ts,
2297                    log_position: mongreldb_log::LogPosition {
2298                        term: 0,
2299                        index: *epoch,
2300                    },
2301                    durability: mongreldb_log::DurabilityLevel::GroupCommit,
2302                })),
2303                StoredIdempotencyOutcome::Reserved => Err(MongrelError::Conflict(format!(
2304                    "idempotency key {:?} has an in-flight or interrupted commit; retry to resolve",
2305                    request.key
2306                ))),
2307            };
2308        }
2309        inner.push(StoredIdempotencyRecord {
2310            owner: request.owner.clone(),
2311            key: request.key.clone(),
2312            fingerprint: request.fingerprint,
2313            expires_at_micros: expiry_micros(request.ttl, now),
2314            outcome: StoredIdempotencyOutcome::Reserved,
2315        });
2316        persist_locked(&self.root, self.meta_dek.as_ref(), &mut inner)?;
2317        Ok(IdempotencyCheck::Reserved)
2318    }
2319
2320    /// Record the original commit receipt against a reserved key (after the
2321    /// receipt exists, before the caller is told success). Never sweeps: the
2322    /// reservation being completed belongs to an in-flight commit and must
2323    /// survive even an absurdly short TTL (expiry applies once the commit
2324    /// has completed, measured from completion).
2325    pub(crate) fn complete(
2326        &self,
2327        request: &IdempotencyRequest,
2328        txn_id: u64,
2329        epoch: Epoch,
2330        commit_ts: HlcTimestamp,
2331    ) -> Result<()> {
2332        let mut inner = self.inner.lock();
2333        let now = wall_micros();
2334        let Some(record) = inner
2335            .iter_mut()
2336            .find(|record| record.owner == request.owner && record.key == request.key)
2337        else {
2338            return Err(MongrelError::Other(format!(
2339                "idempotency reservation for key {:?} vanished during commit",
2340                request.key
2341            )));
2342        };
2343        record.expires_at_micros = expiry_micros(request.ttl, now);
2344        record.outcome = StoredIdempotencyOutcome::Committed {
2345            txn_id,
2346            epoch: epoch.0,
2347            commit_ts,
2348        };
2349        persist_locked(&self.root, self.meta_dek.as_ref(), &mut inner)
2350    }
2351
2352    /// Drop a reservation whose commit never became durable (pre-fence
2353    /// failure). Best-effort: the caller is already handling the commit
2354    /// error, and a leaked reservation expires by TTL or conflicts safely.
2355    pub(crate) fn release(&self, request: &IdempotencyRequest) {
2356        let mut inner = self.inner.lock();
2357        inner.retain(|record| {
2358            !(record.owner == request.owner
2359                && record.key == request.key
2360                && matches!(record.outcome, StoredIdempotencyOutcome::Reserved))
2361        });
2362        let _ = persist_locked(&self.root, self.meta_dek.as_ref(), &mut inner);
2363    }
2364}
2365
2366/// Guard releasing an idempotency reservation on drop unless disarmed (the
2367/// commit reached its receipt).
2368pub(crate) struct IdempotencyReservationGuard<'a> {
2369    ledger: &'a IdempotencyLedger,
2370    request: IdempotencyRequest,
2371    armed: bool,
2372}
2373
2374impl<'a> IdempotencyReservationGuard<'a> {
2375    pub(crate) fn new(ledger: &'a IdempotencyLedger, request: IdempotencyRequest) -> Self {
2376        Self {
2377            ledger,
2378            request,
2379            armed: true,
2380        }
2381    }
2382
2383    pub(crate) fn disarm(&mut self) {
2384        self.armed = false;
2385    }
2386}
2387
2388impl Drop for IdempotencyReservationGuard<'_> {
2389    fn drop(&mut self) {
2390        if self.armed {
2391            self.ledger.release(&self.request);
2392        }
2393    }
2394}
2395
2396fn validate_request(request: &IdempotencyRequest) -> Result<()> {
2397    if request.key.is_empty() {
2398        return Err(MongrelError::InvalidArgument(
2399            "idempotency key must not be empty".into(),
2400        ));
2401    }
2402    if request.owner.is_empty() {
2403        return Err(MongrelError::InvalidArgument(
2404            "idempotency owner must not be empty".into(),
2405        ));
2406    }
2407    Ok(())
2408}
2409
2410fn expiry_micros(ttl: Option<Duration>, now_micros: u64) -> Option<u64> {
2411    ttl.map(|ttl| now_micros.saturating_add(u64::try_from(ttl.as_micros()).unwrap_or(u64::MAX)))
2412}
2413
2414fn sweep_expired(records: &mut Vec<StoredIdempotencyRecord>, now_micros: u64) {
2415    records.retain(|record| {
2416        record
2417            .expires_at_micros
2418            .is_none_or(|expires_at| expires_at > now_micros)
2419    });
2420}
2421
2422/// Evict oldest committed records beyond the bounded size. Reservations in
2423/// flight are never evicted; a ledger that still exceeds the bound fails
2424/// closed with `ResourceLimitExceeded`.
2425fn enforce_bounds(records: &mut Vec<StoredIdempotencyRecord>) -> Result<()> {
2426    while records.len() > MAX_IDEMPOTENCY_RECORDS {
2427        let Some((oldest, _)) = records
2428            .iter()
2429            .enumerate()
2430            .filter(|(_, record)| {
2431                matches!(record.outcome, StoredIdempotencyOutcome::Committed { .. })
2432            })
2433            .min_by_key(|(_, record)| match &record.outcome {
2434                StoredIdempotencyOutcome::Committed { epoch, .. } => *epoch,
2435                StoredIdempotencyOutcome::Reserved => unreachable!(),
2436            })
2437        else {
2438            return Err(MongrelError::ResourceLimitExceeded {
2439                resource: "idempotency records",
2440                requested: records.len(),
2441                limit: MAX_IDEMPOTENCY_RECORDS,
2442            });
2443        };
2444        records.remove(oldest);
2445    }
2446    Ok(())
2447}
2448
2449fn persist_locked(
2450    root: &crate::durable_file::DurableRoot,
2451    meta_dek: Option<&[u8; crate::catalog::META_DEK_LEN]>,
2452    records: &mut Vec<StoredIdempotencyRecord>,
2453) -> Result<()> {
2454    enforce_bounds(records)?;
2455    let body = serde_json::to_vec(&IdempotencyEnvelope {
2456        format_version: IDEMPOTENCY_FORMAT_VERSION,
2457        records: records.clone(),
2458    })
2459    .map_err(|error| MongrelError::Other(format!("idempotency ledger serialize: {error}")))?;
2460    let payload = seal_idempotency(&body, meta_dek)?;
2461    root.write_atomic(IDEMPOTENCY_FILENAME, &payload)?;
2462    Ok(())
2463}
2464
2465fn read_idempotency_file(
2466    root: &crate::durable_file::DurableRoot,
2467    meta_dek: Option<&[u8; crate::catalog::META_DEK_LEN]>,
2468) -> Result<Option<Vec<StoredIdempotencyRecord>>> {
2469    use std::io::Read as _;
2470    let file = match root.open_regular(IDEMPOTENCY_FILENAME) {
2471        Ok(file) => file,
2472        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2473        Err(error) => return Err(error.into()),
2474    };
2475    let length = file.metadata()?.len();
2476    if length > MAX_IDEMPOTENCY_BYTES {
2477        return Err(MongrelError::Other(format!(
2478            "idempotency ledger of {length} bytes exceeds the {MAX_IDEMPOTENCY_BYTES}-byte limit"
2479        )));
2480    }
2481    let mut bytes = Vec::with_capacity(length as usize);
2482    file.take(MAX_IDEMPOTENCY_BYTES + 1)
2483        .read_to_end(&mut bytes)?;
2484    if bytes.len() as u64 != length {
2485        return Err(MongrelError::Other(
2486            "idempotency ledger length changed while reading".into(),
2487        ));
2488    }
2489    open_idempotency_payload(&bytes, meta_dek).map(Some)
2490}
2491
2492fn decode_idempotency(body: &[u8]) -> Result<Vec<StoredIdempotencyRecord>> {
2493    let envelope: IdempotencyEnvelope = serde_json::from_slice(body)
2494        .map_err(|error| MongrelError::Other(format!("idempotency ledger deserialize: {error}")))?;
2495    if envelope.format_version != IDEMPOTENCY_FORMAT_VERSION {
2496        return Err(MongrelError::Other(format!(
2497            "unsupported idempotency ledger format version {}",
2498            envelope.format_version
2499        )));
2500    }
2501    Ok(envelope.records)
2502}
2503
2504fn plaintext_idempotency_frame(body: &[u8]) -> Vec<u8> {
2505    use sha2::Digest as _;
2506    let hash = sha2::Sha256::digest(body);
2507    let mut out = Vec::with_capacity(body.len() + 8 + 32);
2508    out.extend_from_slice(IDEMPOTENCY_MAGIC);
2509    out.extend_from_slice(&hash);
2510    out.extend_from_slice(body);
2511    out
2512}
2513
2514fn parse_idempotency_plaintext(bytes: &[u8]) -> Result<Vec<StoredIdempotencyRecord>> {
2515    use sha2::Digest as _;
2516    if bytes.len() < 8 + 32 || &bytes[..8] != IDEMPOTENCY_MAGIC {
2517        return Err(MongrelError::Other(
2518            "idempotency ledger magic mismatch (corrupt or sealed with a key)".into(),
2519        ));
2520    }
2521    let (tag, body) = bytes[8..].split_at(32);
2522    let calc = sha2::Sha256::digest(body);
2523    if tag != calc.as_slice() {
2524        return Err(MongrelError::Other(
2525            "idempotency ledger checksum mismatch (tampered or torn)".into(),
2526        ));
2527    }
2528    decode_idempotency(body)
2529}
2530
2531#[cfg(feature = "encryption")]
2532fn seal_idempotency(
2533    body: &[u8],
2534    meta_dek: Option<&[u8; crate::catalog::META_DEK_LEN]>,
2535) -> Result<Vec<u8>> {
2536    match meta_dek {
2537        Some(dek) => crate::encryption::encrypt_blob(dek, body),
2538        None => Ok(plaintext_idempotency_frame(body)),
2539    }
2540}
2541
2542#[cfg(not(feature = "encryption"))]
2543fn seal_idempotency(
2544    body: &[u8],
2545    meta_dek: Option<&[u8; crate::catalog::META_DEK_LEN]>,
2546) -> Result<Vec<u8>> {
2547    if meta_dek.is_some() {
2548        return Err(MongrelError::Encryption(
2549            "a metadata key was supplied but the `encryption` feature is disabled".into(),
2550        ));
2551    }
2552    Ok(plaintext_idempotency_frame(body))
2553}
2554
2555#[cfg(feature = "encryption")]
2556fn open_idempotency_payload(
2557    bytes: &[u8],
2558    meta_dek: Option<&[u8; crate::catalog::META_DEK_LEN]>,
2559) -> Result<Vec<StoredIdempotencyRecord>> {
2560    match meta_dek {
2561        // Fail closed: an unauthenticated ledger is an error, never "no
2562        // records" (mirroring the job registry).
2563        Some(dek) => {
2564            let body = crate::encryption::decrypt_blob(dek, bytes).map_err(|_| {
2565                MongrelError::Decryption(
2566                    "idempotency ledger authentication failed (wrong key or tampered)".into(),
2567                )
2568            })?;
2569            decode_idempotency(&body)
2570        }
2571        None => parse_idempotency_plaintext(bytes),
2572    }
2573}
2574
2575#[cfg(not(feature = "encryption"))]
2576fn open_idempotency_payload(
2577    bytes: &[u8],
2578    meta_dek: Option<&[u8; crate::catalog::META_DEK_LEN]>,
2579) -> Result<Vec<StoredIdempotencyRecord>> {
2580    if meta_dek.is_some() {
2581        return Err(MongrelError::Encryption(
2582            "a metadata key was supplied but the `encryption` feature is disabled".into(),
2583        ));
2584    }
2585    parse_idempotency_plaintext(bytes)
2586}
2587
2588/// Wall-clock microseconds since the Unix epoch (saturating), for expiry.
2589fn wall_micros() -> u64 {
2590    let micros = std::time::SystemTime::now()
2591        .duration_since(std::time::UNIX_EPOCH)
2592        .map(|duration| duration.as_micros())
2593        .unwrap_or(0);
2594    u64::try_from(micros).unwrap_or(u64::MAX)
2595}