Skip to main content

mongreldb_core/
txn.rs

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