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
11use crate::database::{Database, ExternalTriggerBridge};
12use crate::epoch::{Epoch, Snapshot};
13use crate::error::{MongrelError, Result};
14use crate::memtable::Value;
15use crate::rowid::RowId;
16use crate::wal::SharedWal;
17use parking_lot::{Condvar, Mutex as PlMutex};
18
19/// One staged mutation against a named table.
20pub(crate) enum Staged {
21    Put(Vec<(u16, Value)>),
22    Delete(RowId),
23    /// Full post-update row image paired with its old row id. Kept distinct
24    /// through trigger/FK expansion so ON UPDATE cannot be confused with a
25    /// coincidental delete plus insert.
26    Update(RowId, Vec<(u16, Value)>),
27    Truncate,
28}
29
30#[derive(Debug, Clone)]
31pub struct OwnedRow {
32    pub columns: Vec<(u16, Value)>,
33}
34
35#[derive(Debug, Clone)]
36pub struct PutResult {
37    pub auto_inc: Option<i64>,
38    pub row: OwnedRow,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum UpsertActionKind {
43    Inserted,
44    Updated,
45    Unchanged,
46}
47
48#[derive(Debug, Clone)]
49pub enum UpsertAction {
50    DoNothing,
51    DoUpdate(Vec<(u16, Value)>),
52}
53
54#[derive(Debug, Clone)]
55pub struct UpsertResult {
56    pub action: UpsertActionKind,
57    pub row: OwnedRow,
58    pub auto_inc: Option<i64>,
59}
60
61/// An in-flight cross-table transaction. Holds a read snapshot taken at `begin`
62/// and stages writes; nothing is durable or visible until [`Self::commit`].
63pub struct Transaction<'db> {
64    db: &'db Database,
65    txn_id: u64,
66    read: Snapshot,
67    staging: Vec<(u64 /*table_id*/, Staged)>,
68    external_states: Vec<(String, Vec<u8>)>,
69    materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
70    principal: Option<crate::auth::Principal>,
71    external_trigger_bridge: Option<&'db dyn ExternalTriggerBridge>,
72    _active: Option<ActiveTxnGuard<'db>>,
73}
74
75impl<'db> Transaction<'db> {
76    pub(crate) fn new(db: &'db Database, txn_id: u64, read: Snapshot) -> Self {
77        let guard = db.register_active(read.epoch);
78        Self {
79            db,
80            txn_id,
81            read,
82            staging: Vec::new(),
83            external_states: Vec::new(),
84            materialized_view_updates: Vec::new(),
85            principal: None,
86            external_trigger_bridge: None,
87            _active: Some(guard),
88        }
89    }
90
91    pub(crate) fn with_external_trigger_bridge(
92        mut self,
93        bridge: &'db dyn ExternalTriggerBridge,
94    ) -> Self {
95        self.external_trigger_bridge = Some(bridge);
96        self
97    }
98
99    pub(crate) fn with_principal(mut self, principal: Option<crate::auth::Principal>) -> Self {
100        self.principal = principal;
101        self
102    }
103
104    pub fn read_snapshot(&self) -> Snapshot {
105        self.read
106    }
107
108    /// The transaction's id (generation-scoped: high 32 bits = open generation,
109    /// low 32 = per-open counter). Mainly diagnostic / test-facing.
110    pub fn txn_id(&self) -> u64 {
111        self.txn_id
112    }
113
114    /// Stage a put on `table`. The row id is allocated at commit so an aborted
115    /// transaction never consumes ids. If the table has an `AUTO_INCREMENT`
116    /// primary key and the column is omitted or null, the engine fills it now
117    /// and returns the assigned value; explicit ids are honored and advance the
118    /// counter. The value is staged in `cells`, so the commit path writes the
119    /// same id into the row.
120    pub fn put(&mut self, table: &str, mut cells: Vec<(u16, Value)>) -> Result<Option<i64>> {
121        self.require_columns(table, crate::auth::ColumnOperation::Insert, &cells)?;
122        let id = self.db.table_id(table)?;
123        let handle = self.db.table(table)?;
124        let mut t = handle.lock();
125        let assigned = t.fill_auto_inc(&mut cells)?;
126        t.apply_defaults(&mut cells)?;
127        drop(t);
128        self.staging.push((id, Staged::Put(cells)));
129        Ok(assigned)
130    }
131
132    pub fn put_returning(
133        &mut self,
134        table: &str,
135        mut cells: Vec<(u16, Value)>,
136    ) -> Result<PutResult> {
137        self.require_columns(table, crate::auth::ColumnOperation::Insert, &cells)?;
138        let id = self.db.table_id(table)?;
139        let handle = self.db.table(table)?;
140        let mut t = handle.lock();
141        let assigned = t.fill_auto_inc(&mut cells)?;
142        t.apply_defaults(&mut cells)?;
143        drop(t);
144        let row = owned_row_from_cells(&cells);
145        self.staging.push((id, Staged::Put(cells)));
146        Ok(PutResult {
147            auto_inc: assigned,
148            row,
149        })
150    }
151
152    /// Stage many puts on the same `table` with one table-id lookup + one
153    /// auto-inc lock pass. Each row is staged individually (same as repeated
154    /// `put`); the savings are the amortized lookups/locks for bulk guard-row
155    /// writes and batched application-row inserts. Returns the assigned
156    /// auto-increment values (`Some` only where the engine filled the column).
157    pub fn put_batch(
158        &mut self,
159        table: &str,
160        rows: Vec<Vec<(u16, Value)>>,
161    ) -> Result<Vec<Option<i64>>> {
162        for cells in &rows {
163            self.require_columns(table, crate::auth::ColumnOperation::Insert, cells)?;
164        }
165        let id = self.db.table_id(table)?;
166        let handle = self.db.table(table)?;
167        let mut t = handle.lock();
168        let mut assigned = Vec::with_capacity(rows.len());
169        for mut cells in rows {
170            let a = t.fill_auto_inc(&mut cells)?;
171            t.apply_defaults(&mut cells)?;
172            assigned.push(a);
173            self.staging.push((id, Staged::Put(cells)));
174        }
175        drop(t);
176        Ok(assigned)
177    }
178
179    /// Stage a delete of `row_id` on `table`.
180    pub fn delete(&mut self, table: &str, row_id: RowId) -> Result<()> {
181        self.require_delete(table)?;
182        let id = self.db.table_id(table)?;
183        self.reject_after_truncate(id)?;
184        self.staging.push((id, Staged::Delete(row_id)));
185        Ok(())
186    }
187
188    /// Stage opaque external-table module state. The payload is committed under
189    /// the same WAL `TxnCommit` as ordinary table writes.
190    pub fn put_external_state(&mut self, table: &str, state: Vec<u8>) -> Result<()> {
191        if self.db.external_table(table).is_none() {
192            return Err(MongrelError::NotFound(format!(
193                "external table {table:?} not found"
194            )));
195        }
196        self.external_states.push((table.to_string(), state));
197        Ok(())
198    }
199
200    /// Stage a materialized-view checkpoint in the same durable commit as its
201    /// row deltas. This makes incremental refresh replay idempotent after a
202    /// crash: data and the Last-Event-ID watermark advance together.
203    pub fn set_materialized_view_definition(
204        &mut self,
205        definition: crate::catalog::MaterializedViewEntry,
206    ) -> Result<()> {
207        self.db
208            .require_for(self.principal.as_ref(), &crate::auth::Permission::Ddl)?;
209        if self.db.table_id(&definition.name).is_err() {
210            return Err(MongrelError::NotFound(format!(
211                "materialized view table {:?} not found",
212                definition.name
213            )));
214        }
215        self.materialized_view_updates
216            .retain(|current| current.name != definition.name);
217        self.materialized_view_updates.push(definition);
218        Ok(())
219    }
220
221    pub fn delete_many(&mut self, table: &str, row_ids: Vec<RowId>) -> Result<Vec<OwnedRow>> {
222        self.require_delete(table)?;
223        let id = self.db.table_id(table)?;
224        self.reject_after_truncate(id)?;
225        let snap = self.read;
226        let handle = self.db.table(table)?;
227        let t = handle.lock();
228        let mut pre_images = Vec::with_capacity(row_ids.len());
229        for row_id in &row_ids {
230            if let Some(row) = t.get(*row_id, snap) {
231                pre_images.push(owned_row_from_map(row.columns));
232            }
233        }
234        drop(t);
235        for row_id in row_ids {
236            self.staging.push((id, Staged::Delete(row_id)));
237        }
238        Ok(pre_images)
239    }
240
241    pub fn update_many(
242        &mut self,
243        table: &str,
244        updates: Vec<(RowId, Vec<(u16, Value)>)>,
245    ) -> Result<Vec<OwnedRow>> {
246        for (_, cells) in &updates {
247            self.require_columns(table, crate::auth::ColumnOperation::Update, cells)?;
248        }
249        let id = self.db.table_id(table)?;
250        self.reject_after_truncate(id)?;
251        let snap = self.read;
252        let handle = self.db.table(table)?;
253        let t = handle.lock();
254        let mut post_images = Vec::with_capacity(updates.len());
255        let mut staged = Vec::with_capacity(updates.len());
256        for (old_id, new_cells) in updates {
257            let old_row = t
258                .get(old_id, snap)
259                .ok_or_else(|| MongrelError::NotFound(format!("row {old_id:?} not found")))?;
260            let merged = merge_cells(old_row.columns.into_iter().collect(), new_cells);
261            post_images.push(owned_row_from_cells(&merged));
262            staged.push((id, Staged::Update(old_id, merged)));
263        }
264        drop(t);
265        self.staging.extend(staged);
266        Ok(post_images)
267    }
268
269    pub fn upsert(
270        &mut self,
271        table: &str,
272        mut insert_cells: Vec<(u16, Value)>,
273        action: UpsertAction,
274    ) -> Result<UpsertResult> {
275        // Upsert may insert or update. Check Insert up front (the common
276        // path); the DoUpdate branch additionally checks Update before
277        // mutating an existing row.
278        self.require_columns(table, crate::auth::ColumnOperation::Insert, &insert_cells)?;
279        let id = self.db.table_id(table)?;
280        self.reject_after_truncate(id)?;
281        match (self.existing_pk_row(table, &insert_cells)?, action) {
282            (None, _) => {
283                let handle = self.db.table(table)?;
284                let mut t = handle.lock();
285                let assigned = t.fill_auto_inc(&mut insert_cells)?;
286                t.apply_defaults(&mut insert_cells)?;
287                drop(t);
288                let row = owned_row_from_cells(&insert_cells);
289                self.staging.push((id, Staged::Put(insert_cells)));
290                Ok(UpsertResult {
291                    action: UpsertActionKind::Inserted,
292                    row,
293                    auto_inc: assigned,
294                })
295            }
296            (Some((_old_id, old_row)), UpsertAction::DoNothing) => Ok(UpsertResult {
297                action: UpsertActionKind::Unchanged,
298                row: old_row,
299                auto_inc: None,
300            }),
301            (Some((old_id, old_row)), UpsertAction::DoUpdate(update_cells)) => {
302                // The update branch requires Update permission.
303                self.require_columns(table, crate::auth::ColumnOperation::Update, &update_cells)?;
304                let merged = merge_cells(old_row.columns.clone(), update_cells);
305                if columns_equal(&old_row.columns, &merged) {
306                    return Ok(UpsertResult {
307                        action: UpsertActionKind::Unchanged,
308                        row: old_row,
309                        auto_inc: None,
310                    });
311                }
312                let row = owned_row_from_cells(&merged);
313                self.staging.push((id, Staged::Update(old_id, merged)));
314                Ok(UpsertResult {
315                    action: UpsertActionKind::Updated,
316                    row,
317                    auto_inc: None,
318                })
319            }
320        }
321    }
322
323    pub fn truncate(&mut self, table: &str) -> Result<()> {
324        self.require_delete(table)?;
325        let id = self.db.table_id(table)?;
326        for (table_id, op) in &self.staging {
327            if *table_id == id && !matches!(op, Staged::Truncate) {
328                return Err(MongrelError::InvalidArgument(
329                    "truncate cannot be combined with other writes on the same table".into(),
330                ));
331            }
332        }
333        self.staging.push((id, Staged::Truncate));
334        Ok(())
335    }
336
337    fn reject_after_truncate(&self, table_id: u64) -> Result<()> {
338        if self
339            .staging
340            .iter()
341            .any(|(tid, op)| *tid == table_id && matches!(op, Staged::Truncate))
342        {
343            return Err(MongrelError::InvalidArgument(
344                "truncate cannot be combined with other writes on the same table".into(),
345            ));
346        }
347        Ok(())
348    }
349
350    fn require_columns(
351        &self,
352        table: &str,
353        operation: crate::auth::ColumnOperation,
354        cells: &[(u16, Value)],
355    ) -> Result<()> {
356        let columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
357        self.db
358            .require_columns_for(table, operation, &columns, self.principal.as_ref())
359    }
360
361    fn require_delete(&self, table: &str) -> Result<()> {
362        self.db.require_for(
363            self.principal.as_ref(),
364            &crate::auth::Permission::Delete {
365                table: table.to_string(),
366            },
367        )
368    }
369
370    fn existing_pk_row(
371        &self,
372        table: &str,
373        cells: &[(u16, Value)],
374    ) -> Result<Option<(RowId, OwnedRow)>> {
375        let handle = self.db.table(table)?;
376        let t = handle.lock();
377        let Some(pk_col) = t.schema().primary_key() else {
378            return Ok(None);
379        };
380        let Some((_, pk_value)) = cells.iter().find(|(id, _)| *id == pk_col.id) else {
381            return Ok(None);
382        };
383        if matches!(pk_value, Value::Null) {
384            return Ok(None);
385        }
386        let Some(row_id) = t.lookup_pk(&pk_value.encode_key()) else {
387            return Ok(None);
388        };
389        Ok(t.get(row_id, self.read)
390            .map(|row| (row_id, owned_row_from_map(row.columns))))
391    }
392
393    /// Commit: durably seal the staging under one epoch and publish it.
394    pub fn commit(self) -> Result<Epoch> {
395        self.db.commit_transaction_with_external_states(
396            self.txn_id,
397            self.read.epoch,
398            self.staging,
399            self.external_states,
400            self.materialized_view_updates,
401            self.principal,
402            self.external_trigger_bridge,
403        )
404    }
405
406    /// Rollback: discard staging. Nothing is appended to the WAL.
407    pub fn rollback(self) {
408        // Dropping `self` is enough — staging lives only in memory.
409    }
410}
411
412fn owned_row_from_cells(cells: &[(u16, Value)]) -> OwnedRow {
413    let mut columns = cells.to_vec();
414    columns.sort_by_key(|(id, _)| *id);
415    OwnedRow { columns }
416}
417
418fn owned_row_from_map(columns: HashMap<u16, Value>) -> OwnedRow {
419    let mut columns: Vec<(u16, Value)> = columns.into_iter().collect();
420    columns.sort_by_key(|(id, _)| *id);
421    OwnedRow { columns }
422}
423
424fn merge_cells(mut base: Vec<(u16, Value)>, updates: Vec<(u16, Value)>) -> Vec<(u16, Value)> {
425    for (id, value) in updates {
426        base.retain(|(existing, _)| *existing != id);
427        base.push((id, value));
428    }
429    base.sort_by_key(|(id, _)| *id);
430    base
431}
432
433fn columns_equal(a: &[(u16, Value)], b: &[(u16, Value)]) -> bool {
434    if a.len() != b.len() {
435        return false;
436    }
437    let mut a: Vec<_> = a.iter().collect();
438    let mut b: Vec<_> = b.iter().collect();
439    a.sort_by_key(|(id, _)| *id);
440    b.sort_by_key(|(id, _)| *id);
441    a.iter()
442        .zip(b.iter())
443        .all(|((id_a, v_a), (id_b, v_b))| id_a == id_b && v_a == v_b)
444}
445
446/// Staged operation produced after row-id allocation (internal to commit).
447pub(crate) enum StagedOp {
448    Put(crate::memtable::Row),
449    Delete(RowId),
450    Truncate,
451}
452
453// ── P3.1: conflict index + active-txn registry (spec §8.3, §9.2) ─────────
454
455use std::collections::{BTreeMap, HashMap};
456use std::hash::{Hash, Hasher};
457
458/// A write-set key broad enough to detect all write–write conflicts under
459/// snapshot isolation (spec §8.3, review fix #13).
460#[derive(Clone, Debug)]
461pub enum WriteKey {
462    /// Row-version key for updates/deletes of existing rows.
463    Row { table_id: u64, row_id: u64 },
464    /// Unique/PK key for inserts/updates touching a UNIQUE column.
465    Unique {
466        table_id: u64,
467        index_id: u16,
468        key_hash: u64,
469    },
470    /// Table-scope key for TRUNCATE/DROP/ALTER and any txn writing that table.
471    Table { table_id: u64 },
472}
473
474impl Hash for WriteKey {
475    fn hash<H: Hasher>(&self, state: &mut H) {
476        match self {
477            WriteKey::Row { table_id, row_id } => {
478                0u8.hash(state);
479                table_id.hash(state);
480                row_id.hash(state);
481            }
482            WriteKey::Unique {
483                table_id,
484                index_id,
485                key_hash,
486            } => {
487                1u8.hash(state);
488                table_id.hash(state);
489                index_id.hash(state);
490                key_hash.hash(state);
491            }
492            WriteKey::Table { table_id } => {
493                2u8.hash(state);
494                table_id.hash(state);
495            }
496        }
497    }
498}
499
500impl PartialEq for WriteKey {
501    fn eq(&self, other: &Self) -> bool {
502        match (self, other) {
503            (
504                WriteKey::Row {
505                    table_id: a,
506                    row_id: b,
507                },
508                WriteKey::Row {
509                    table_id: c,
510                    row_id: d,
511                },
512            ) => a == c && b == d,
513            (
514                WriteKey::Unique {
515                    table_id: a,
516                    index_id: b,
517                    key_hash: c,
518                },
519                WriteKey::Unique {
520                    table_id: d,
521                    index_id: e,
522                    key_hash: f,
523                },
524            ) => a == d && b == e && c == f,
525            (WriteKey::Table { table_id: a }, WriteKey::Table { table_id: b }) => a == b,
526            _ => false,
527        }
528    }
529}
530
531impl Eq for WriteKey {}
532
533const CONFLICT_SHARDS: usize = 16;
534
535/// A sharded concurrent map of `WriteKey → commit_epoch` recording recent
536/// committed writes (spec §9.2). Validation probes per write-set key; pruning
537/// drops entries below `min(active read_epoch)`.
538pub struct ConflictIndex {
539    shards: [parking_lot::Mutex<HashMap<WriteKey, u64>>; CONFLICT_SHARDS],
540    table_truncate_epochs: parking_lot::Mutex<HashMap<u64, u64>>,
541    table_write_epochs: parking_lot::Mutex<HashMap<u64, u64>>,
542    /// Bumped on every `record()` so pre-validation can detect whether new
543    /// commits arrived between the pre-check and the sequencer (spec §8.5,
544    /// review fix #17).
545    version: std::sync::atomic::AtomicU64,
546}
547
548impl ConflictIndex {
549    pub fn new() -> Self {
550        Self {
551            shards: std::array::from_fn(|_| parking_lot::Mutex::new(HashMap::new())),
552            table_truncate_epochs: parking_lot::Mutex::new(HashMap::new()),
553            table_write_epochs: parking_lot::Mutex::new(HashMap::new()),
554            version: std::sync::atomic::AtomicU64::new(0),
555        }
556    }
557
558    /// Current version (incremented on every `record`). Used by the two-phase
559    /// validation: pre-validate + snapshot version → sequencer re-checks only
560    /// if the version advanced.
561    pub fn version(&self) -> u64 {
562        self.version.load(std::sync::atomic::Ordering::Acquire)
563    }
564
565    fn shard(&self, key: &WriteKey) -> &parking_lot::Mutex<HashMap<WriteKey, u64>> {
566        let mut h = std::collections::hash_map::DefaultHasher::new();
567        key.hash(&mut h);
568        let idx = (h.finish() as usize) & (CONFLICT_SHARDS - 1);
569        &self.shards[idx]
570    }
571
572    /// Returns `true` if any key was committed at an epoch strictly greater
573    /// than `read_epoch` (write–write conflict under SI; first-committer-wins).
574    pub fn conflicts(&self, keys: &[WriteKey], read_epoch: Epoch) -> bool {
575        for k in keys {
576            let s = self.shard(k);
577            if let Some(&ce) = s.lock().get(k) {
578                if ce > read_epoch.0 {
579                    return true;
580                }
581            }
582        }
583        let truncates = self.table_truncate_epochs.lock();
584        let writes = self.table_write_epochs.lock();
585        for k in keys {
586            match k {
587                WriteKey::Row { table_id, .. } | WriteKey::Unique { table_id, .. } => {
588                    if truncates.get(table_id).is_some_and(|&ce| ce > read_epoch.0) {
589                        return true;
590                    }
591                }
592                WriteKey::Table { table_id } => {
593                    if writes.get(table_id).is_some_and(|&ce| ce > read_epoch.0) {
594                        return true;
595                    }
596                }
597            }
598        }
599        false
600    }
601
602    /// Record every write-set key at `commit_epoch`.
603    pub fn record(&self, keys: &[WriteKey], commit_epoch: Epoch) {
604        for k in keys {
605            let s = self.shard(k);
606            s.lock().insert(k.clone(), commit_epoch.0);
607        }
608        let mut truncates = self.table_truncate_epochs.lock();
609        let mut writes = self.table_write_epochs.lock();
610        for k in keys {
611            match k {
612                WriteKey::Table { table_id } => {
613                    truncates
614                        .entry(*table_id)
615                        .and_modify(|ce| *ce = (*ce).max(commit_epoch.0))
616                        .or_insert(commit_epoch.0);
617                }
618                WriteKey::Row { table_id, .. } | WriteKey::Unique { table_id, .. } => {
619                    writes
620                        .entry(*table_id)
621                        .and_modify(|ce| *ce = (*ce).max(commit_epoch.0))
622                        .or_insert(commit_epoch.0);
623                }
624            }
625        }
626        self.version
627            .fetch_add(1, std::sync::atomic::Ordering::Release);
628    }
629
630    /// Drop entries whose `commit_epoch < min_active` (they can never cause a
631    /// future conflict once no live txn reads below `min_active`).
632    pub fn prune_below(&self, min_active: Epoch) {
633        for s in &self.shards {
634            s.lock().retain(|_, ce| *ce >= min_active.0);
635        }
636        self.table_truncate_epochs
637            .lock()
638            .retain(|_, ce| *ce >= min_active.0);
639        self.table_write_epochs
640            .lock()
641            .retain(|_, ce| *ce >= min_active.0);
642    }
643}
644
645impl Default for ConflictIndex {
646    fn default() -> Self {
647        Self::new()
648    }
649}
650
651// ── P3.2: real group commit (spec §9.3) ─────────────────────────────────
652
653/// Group-commit coordinator (spec §9.3). The commit sequencer appends a txn's
654/// records under the WAL mutex but does **not** fsync there; instead each
655/// committer calls [`Self::await_durable`] with its commit record's WAL seq.
656/// Exactly one waiter becomes the *leader* and issues a single `group_sync`
657/// (fsync), which makes durable every record appended up to that point; the
658/// others are *followers* that simply wait until `durable_seq` reaches their
659/// commit seq. One fsync therefore covers a whole batch of concurrent commits.
660pub struct GroupCommit {
661    inner: PlMutex<GroupState>,
662    cv: Condvar,
663}
664
665struct GroupState {
666    durable_seq: u64,
667    syncing: bool,
668    poisoned: bool,
669}
670
671impl GroupCommit {
672    pub fn new(durable_seq: u64) -> Self {
673        Self {
674            inner: PlMutex::new(GroupState {
675                durable_seq,
676                syncing: false,
677                poisoned: false,
678            }),
679            cv: Condvar::new(),
680        }
681    }
682
683    /// Block until `commit_seq` is durable. The first eligible caller fsyncs on
684    /// behalf of the batch; the rest wait on the condvar. On fsync error the
685    /// coordinator is poisoned and every waiter (current and future) returns
686    /// `Err` (spec §9.3e). `wal` is the same `SharedWal` the sequencer appended
687    /// to — locked here only for the brief fsync, never across the wait.
688    pub fn await_durable(&self, wal: &PlMutex<SharedWal>, commit_seq: u64) -> Result<()> {
689        let mut st = self.inner.lock();
690        loop {
691            if st.poisoned {
692                return Err(MongrelError::Other(
693                    "database poisoned by fsync error".into(),
694                ));
695            }
696            if st.durable_seq >= commit_seq {
697                return Ok(());
698            }
699            if st.syncing {
700                // Another thread is the leader; wait for it to advance durability.
701                self.cv.wait(&mut st);
702                continue;
703            }
704            // Become the leader: fsync outside the coordinator lock (but under
705            // the WAL lock) so followers can queue up behind us.
706            st.syncing = true;
707            drop(st);
708            let res = wal.lock().group_sync();
709            st = self.inner.lock();
710            st.syncing = false;
711            match res {
712                Ok(durable) => {
713                    if durable > st.durable_seq {
714                        st.durable_seq = durable;
715                    }
716                    self.cv.notify_all();
717                    // Loop re-checks: our commit_seq <= durable (group_sync makes
718                    // everything appended-so-far durable), so we return Ok next.
719                }
720                Err(e) => {
721                    st.poisoned = true;
722                    self.cv.notify_all();
723                    return Err(e);
724                }
725            }
726        }
727    }
728}
729
730/// Tracks the `read_epoch` of every in-flight transaction (spec §9.2, review
731/// fix #12). `begin` registers **before** the first read; `min_read_epoch`
732/// drives conflict-index pruning.
733pub struct ActiveTxns {
734    inner: parking_lot::Mutex<BTreeMap<u64, u64>>,
735}
736
737impl ActiveTxns {
738    pub fn new() -> Self {
739        Self {
740            inner: parking_lot::Mutex::new(BTreeMap::new()),
741        }
742    }
743
744    /// Register a transaction's read epoch. Returns a guard that deregisters
745    /// on drop.
746    pub fn register(&self, read_epoch: Epoch) -> ActiveTxnGuard<'_> {
747        let mut g = self.inner.lock();
748        *g.entry(read_epoch.0).or_insert(0) += 1;
749        ActiveTxnGuard {
750            active: self,
751            epoch: read_epoch.0,
752        }
753    }
754
755    /// The lowest live `read_epoch`, or `u64::MAX` when no txn is active.
756    pub fn min_read_epoch(&self) -> u64 {
757        self.inner.lock().keys().next().copied().unwrap_or(u64::MAX)
758    }
759}
760
761impl Default for ActiveTxns {
762    fn default() -> Self {
763        Self::new()
764    }
765}
766
767/// Guard for an active transaction's read-epoch registration.
768pub struct ActiveTxnGuard<'a> {
769    active: &'a ActiveTxns,
770    epoch: u64,
771}
772
773impl Drop for ActiveTxnGuard<'_> {
774    fn drop(&mut self) {
775        let mut g = self.active.inner.lock();
776        if let Some(count) = g.get_mut(&self.epoch) {
777            *count -= 1;
778            if *count == 0 {
779                g.remove(&self.epoch);
780            }
781        }
782    }
783}
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788
789    #[test]
790    fn conflict_index_first_committer_wins_and_prunes_safely() {
791        let ci = ConflictIndex::new();
792        let k = vec![WriteKey::Row {
793            table_id: 1,
794            row_id: 7,
795        }];
796        assert!(!ci.conflicts(&k, Epoch(5)));
797        ci.record(&k, Epoch(6));
798        assert!(ci.conflicts(&k, Epoch(5)));
799        assert!(!ci.conflicts(&k, Epoch(6)));
800        ci.prune_below(Epoch(7));
801        assert!(!ci.conflicts(&k, Epoch(5)));
802    }
803
804    #[test]
805    fn conflict_index_table_scope_conflicts_both_directions() {
806        let ci = ConflictIndex::new();
807        ci.record(&[WriteKey::Table { table_id: 1 }], Epoch(6));
808        assert!(ci.conflicts(
809            &[WriteKey::Row {
810                table_id: 1,
811                row_id: 7,
812            }],
813            Epoch(5)
814        ));
815        assert!(ci.conflicts(
816            &[WriteKey::Unique {
817                table_id: 1,
818                index_id: 0,
819                key_hash: 42,
820            }],
821            Epoch(5)
822        ));
823        assert!(!ci.conflicts(
824            &[WriteKey::Row {
825                table_id: 2,
826                row_id: 7,
827            }],
828            Epoch(5)
829        ));
830
831        let ci = ConflictIndex::new();
832        ci.record(
833            &[WriteKey::Row {
834                table_id: 1,
835                row_id: 7,
836            }],
837            Epoch(6),
838        );
839        assert!(ci.conflicts(&[WriteKey::Table { table_id: 1 }], Epoch(5)));
840        assert!(!ci.conflicts(&[WriteKey::Table { table_id: 2 }], Epoch(5)));
841    }
842
843    #[test]
844    fn writekey_eq_across_variants() {
845        let r1 = WriteKey::Row {
846            table_id: 1,
847            row_id: 2,
848        };
849        let r2 = WriteKey::Row {
850            table_id: 1,
851            row_id: 2,
852        };
853        let r3 = WriteKey::Row {
854            table_id: 1,
855            row_id: 3,
856        };
857        assert_eq!(r1, r2);
858        assert_ne!(r1, r3);
859
860        let u1 = WriteKey::Unique {
861            table_id: 1,
862            index_id: 0,
863            key_hash: 42,
864        };
865        let u2 = WriteKey::Unique {
866            table_id: 1,
867            index_id: 0,
868            key_hash: 42,
869        };
870        assert_eq!(u1, u2);
871        assert_ne!(r1, u1);
872
873        let t1 = WriteKey::Table { table_id: 5 };
874        let t2 = WriteKey::Table { table_id: 5 };
875        assert_eq!(t1, t2);
876        assert_ne!(t1, r1);
877    }
878
879    #[test]
880    fn active_txns_tracks_min_read_epoch() {
881        let at = ActiveTxns::new();
882        assert_eq!(at.min_read_epoch(), u64::MAX);
883        let g1 = at.register(Epoch(5));
884        assert_eq!(at.min_read_epoch(), 5);
885        let g2 = at.register(Epoch(3));
886        assert_eq!(at.min_read_epoch(), 3);
887        drop(g2);
888        assert_eq!(at.min_read_epoch(), 5);
889        drop(g1);
890        assert_eq!(at.min_read_epoch(), u64::MAX);
891    }
892
893    #[test]
894    fn active_txns_dedups_same_epoch() {
895        let at = ActiveTxns::new();
896        let g1 = at.register(Epoch(7));
897        let g2 = at.register(Epoch(7));
898        assert_eq!(at.min_read_epoch(), 7);
899        drop(g1);
900        assert_eq!(at.min_read_epoch(), 7);
901        drop(g2);
902        assert_eq!(at.min_read_epoch(), u64::MAX);
903    }
904}
905
906/// Transaction isolation level. MongrelDB defaults to `Snapshot` (SI).
907///
908/// - `Snapshot`: reads see a consistent snapshot taken at `begin`; writes
909///   conflict on first-committer-wins for overlapping keys.
910/// - `ReadCommitted`: each read sees the latest committed epoch (no stale
911///   reads within a long transaction). Weaker than Snapshot but avoids
912///   aborts from read-write conflicts.
913/// - `Serializable`: same as Snapshot under MongrelDB's optimistic model —
914///   the conflict index already detects write-skew. Explicitly marked so
915///   callers can request the strongest level without behavioral surprise.
916#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
917pub enum IsolationLevel {
918    #[default]
919    Snapshot,
920    ReadCommitted,
921    Serializable,
922}