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