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