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