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