Skip to main content

mongreldb_core/
database.rs

1//! Multi-table `Database` container (spec §5, §6, §10).
2//!
3//! Owns the shared services — catalog, dual-counter epoch authority, shared
4//! raw/decoded page caches, snapshot-retention registry, and the DB-wide KEK —
5//! and mounts per-table [`Table`] engines under `tables/<id>/` that borrow them.
6//! P1 scope: per-table WALs remain (collapsed into one shared WAL in P2); the
7//! win here is one consistent commit clock across tables and one reopen path.
8
9use crate::catalog::{self, Catalog, CatalogEntry, TableState, META_DEK_LEN};
10use crate::engine::{SharedCtx, Table};
11use crate::epoch::{Epoch, EpochAuthority, Snapshot};
12use crate::error::{MongrelError, Result};
13use crate::retention::{OwnedSnapshotGuard, SnapshotGuard, SnapshotRegistry};
14use crate::schema::{AlterColumn, ColumnDef, Schema};
15use parking_lot::{Mutex, RwLock};
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20pub const TABLES_DIR: &str = "tables";
21pub const META_DIR: &str = "_meta";
22pub const KEYS_FILENAME: &str = "keys";
23
24/// Sentinel `table_id` for `CheckIssue`s that concern the shared WAL rather
25/// than any table. `u64::MAX` is never allocated to a real table (the catalog
26/// mints ids from 0 upward), so [`Database::doctor`] can safely skip them.
27pub const WAL_TABLE_ID: u64 = u64::MAX;
28
29/// A pending uniform-epoch run written during a large transaction (spec §8.5).
30struct SpilledRun {
31    table_id: u64,
32    run_id: u128,
33    pending_path: PathBuf,
34    rows: Vec<crate::memtable::Row>,
35    row_count: u64,
36    min_rid: u64,
37    max_rid: u64,
38}
39
40/// An integrity issue found by [`Database::check`] (spec §16).
41#[derive(Debug, Clone)]
42pub struct CheckIssue {
43    pub table_id: u64,
44    pub table_name: String,
45    pub severity: String,
46    pub description: String,
47}
48
49/// A handle to a live table inside a [`Database`]. Writes take the inner lock
50/// (P1); P3.3 replaces this with lock-free `ArcSwap` reads + a publish lock for
51/// writes.
52pub type TableHandle = Arc<Mutex<Table>>;
53
54/// A multi-table database: one catalog, one epoch clock, shared caches, a
55/// shared WAL, and a live map of name → `Arc<Table>`.
56pub struct Database {
57    root: PathBuf,
58    catalog: RwLock<Catalog>,
59    epoch: Arc<EpochAuthority>,
60    snapshots: Arc<SnapshotRegistry>,
61    page_cache: Arc<parking_lot::Mutex<crate::cache::PageCache>>,
62    decoded_cache: Arc<parking_lot::Mutex<crate::cache::DecodedPageCache>>,
63    commit_lock: Arc<Mutex<()>>,
64    /// One shared WAL multiplexing every table's records (spec §7.2). Owned
65    /// behind a `Mutex` so the transaction layer can append + group-sync. Shared
66    /// (via `Arc`) with every mounted `Table` so single-table `put`/`commit`
67    /// writes also land in this one WAL (B1 — one WAL per database).
68    shared_wal: Arc<Mutex<crate::wal::SharedWal>>,
69    /// Monotonic per-open transaction-id counter. Scoped by `open_generation`
70    /// in P2.7; here it just needs to be unique within an open. Shared with
71    /// mounted tables so their auto-commit txn ids never alias cross-table ones.
72    next_txn_id: Arc<Mutex<u64>>,
73    tables: RwLock<HashMap<u64, TableHandle>>,
74    kek: Option<Arc<crate::encryption::Kek>>,
75    /// Serializes DDL (create/drop table); data commits serialize through
76    /// `commit_lock` shared via `SharedCtx`.
77    ddl_lock: Mutex<()>,
78    meta_dek: Option<[u8; META_DEK_LEN]>,
79    /// P3.4: when staged bytes per table exceed this, write a uniform-epoch
80    /// pending run to `_txn/<txn_id>/` instead of streaming Put records (§8.5).
81    spill_threshold: std::sync::atomic::AtomicU64,
82    /// P3.1: write-key → commit_epoch for first-committer-wins conflict
83    /// detection (spec §9.2).
84    conflicts: crate::txn::ConflictIndex,
85    /// P3.1: min read_epoch of all in-flight txns, drives conflict-index
86    /// pruning (spec §9.2, review fix #12).
87    active_txns: crate::txn::ActiveTxns,
88    /// P3.2: set on fsync error — all subsequent writes fail fast (spec §9.3e).
89    /// Shared with mounted tables so a single-table commit also honors poison.
90    poisoned: Arc<std::sync::atomic::AtomicBool>,
91    /// P3.2: group-commit coordinator. The sequencer appends under the WAL lock
92    /// but defers the fsync to one leader here, so concurrent commits share a
93    /// single fsync (spec §9.3). Shared with mounted tables.
94    group: Arc<crate::txn::GroupCommit>,
95    /// P3.6: txn ids currently spilling into `_txn/<id>/`. GC never deletes a
96    /// live spill's pending dir (review fix #14, spec §6.4).
97    active_spills: Arc<crate::retention::ActiveSpills>,
98    /// Test-only barrier invoked after a transaction writes its spill runs but
99    /// before the sequencer/publish, so tests can race `gc()` against an
100    /// in-flight spill. `None` in production.
101    #[doc(hidden)]
102    spill_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
103}
104
105impl Database {
106    /// Create a fresh plaintext database at `root`.
107    pub fn create(root: impl AsRef<Path>) -> Result<Self> {
108        Self::create_inner(root, None)
109    }
110
111    /// Create a fresh encrypted database, deriving the DB-wide KEK from a
112    /// passphrase (Argon2id + HKDF). The salt is persisted at `_meta/keys`.
113    #[cfg(feature = "encryption")]
114    pub fn create_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
115        let root = root.as_ref();
116        std::fs::create_dir_all(root)?;
117        std::fs::create_dir_all(root.join(META_DIR))?;
118        let salt = crate::encryption::random_salt();
119        std::fs::write(root.join(META_DIR).join(KEYS_FILENAME), salt)?;
120        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
121        Self::create_inner(root, Some(kek))
122    }
123
124    /// Create a fresh encrypted database, deriving the DB-wide KEK from a raw
125    /// high-entropy key via HKDF. The salt is persisted at `_meta/keys`.
126    #[cfg(feature = "encryption")]
127    pub fn create_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
128        let root = root.as_ref();
129        std::fs::create_dir_all(root)?;
130        std::fs::create_dir_all(root.join(META_DIR))?;
131        let salt = crate::encryption::random_salt();
132        std::fs::write(root.join(META_DIR).join(KEYS_FILENAME), salt)?;
133        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
134        Self::create_inner(root, Some(kek))
135    }
136
137    fn create_inner(
138        root: impl AsRef<Path>,
139        kek: Option<Arc<crate::encryption::Kek>>,
140    ) -> Result<Self> {
141        let root = root.as_ref().to_path_buf();
142        std::fs::create_dir_all(&root)?;
143        std::fs::create_dir_all(root.join(TABLES_DIR))?;
144        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
145        let cat = Catalog::empty();
146        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
147        Self::finish_open(root, cat, kek, meta_dek, false)
148    }
149
150    /// Open an existing plaintext database.
151    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
152        Self::open_inner(root, None, None)
153    }
154
155    /// Open an existing encrypted database with a passphrase.
156    #[cfg(feature = "encryption")]
157    pub fn open_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
158        let root = root.as_ref();
159        let salt_bytes = std::fs::read(root.join(META_DIR).join(KEYS_FILENAME))
160            .map_err(|e| MongrelError::NotFound(format!("encryption salt file: {e}")))?;
161        let mut salt = [0u8; crate::encryption::SALT_LEN];
162        salt.copy_from_slice(&salt_bytes);
163        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
164        Self::open_inner(root, Some(kek), None)
165    }
166
167    /// Open an existing encrypted database using a raw high-entropy key.
168    #[cfg(feature = "encryption")]
169    pub fn open_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
170        let root = root.as_ref();
171        let salt_path = root.join(META_DIR).join(KEYS_FILENAME);
172        let salt_bytes = std::fs::read(&salt_path).map_err(|e| {
173            MongrelError::NotFound(format!(
174                "encryption salt file {:?}: {e} (database not encrypted, or corrupted)",
175                salt_path
176            ))
177        })?;
178        if salt_bytes.len() != crate::encryption::SALT_LEN {
179            return Err(MongrelError::InvalidArgument(format!(
180                "salt file is {} bytes, expected {}",
181                salt_bytes.len(),
182                crate::encryption::SALT_LEN
183            )));
184        }
185        let mut salt = [0u8; crate::encryption::SALT_LEN];
186        salt.copy_from_slice(&salt_bytes);
187        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
188        Self::open_inner(root, Some(kek), None)
189    }
190
191    fn open_inner(
192        root: impl AsRef<Path>,
193        kek: Option<Arc<crate::encryption::Kek>>,
194        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
195    ) -> Result<Self> {
196        let root = root.as_ref().to_path_buf();
197        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
198        let cat = catalog::read(&root, meta_dek.as_ref())?
199            .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
200        Self::finish_open(root, cat, kek, meta_dek, true)
201    }
202
203    fn finish_open(
204        root: PathBuf,
205        cat: Catalog,
206        kek: Option<Arc<crate::encryption::Kek>>,
207        meta_dek: Option<[u8; META_DEK_LEN]>,
208        existing: bool,
209    ) -> Result<Self> {
210        let epoch = Arc::new(EpochAuthority::new(cat.db_epoch));
211        let snapshots = Arc::new(SnapshotRegistry::new());
212        let page_cache = Arc::new(parking_lot::Mutex::new(crate::cache::PageCache::new(
213            crate::engine::PAGE_CACHE_CAPACITY,
214        )));
215        let decoded_cache = Arc::new(parking_lot::Mutex::new(
216            crate::cache::DecodedPageCache::new(crate::engine::DECODED_CACHE_CAPACITY),
217        ));
218        let commit_lock = Arc::new(Mutex::new(()));
219        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
220        let shared_wal = Arc::new(Mutex::new(if existing {
221            crate::wal::SharedWal::open(&root, Epoch(cat.db_epoch), wal_dek.clone())?
222        } else {
223            crate::wal::SharedWal::create_with_dek(&root, Epoch(cat.db_epoch), wal_dek.clone())?
224        }));
225        // Shared write-path state handed to every mounted table so single-table
226        // `put`/`commit` writes route through the one shared WAL, the one group-
227        // commit coordinator, and the one poison flag (B1).
228        let poisoned = Arc::new(std::sync::atomic::AtomicBool::new(false));
229        let group = Arc::new(crate::txn::GroupCommit::new(
230            shared_wal.lock().durable_seq(),
231        ));
232        // Final base value is set after the open-generation bump below; tables
233        // only draw ids once the user issues a write (post-open), so the
234        // placeholder is never observed.
235        let txn_ids = Arc::new(Mutex::new(1u64));
236
237        // Recover DDL from the shared WAL BEFORE opening tables (spec §15,
238        // review fix #16). A crash between WAL fsync and the catalog
239        // checkpoint leaves committed DDL durable in the WAL but absent from
240        // the on-disk catalog; replay it here so the table-mounting loop and
241        // data-record recovery see a correct catalog.
242        let mut cat = cat;
243        if existing {
244            recover_ddl_from_wal(&root, &mut cat, meta_dek.as_ref(), wal_dek.as_ref())?;
245        }
246
247        // Open every live table against the shared context. Mounted tables have
248        // no private WAL (B1) — `open_in` just loads the manifest/runs and
249        // advances the shared epoch authority to its manifest epoch, so the
250        // final shared watermark is the max across all tables. All of a mounted
251        // table's committed records are replayed below from the shared WAL.
252        let mut tables: HashMap<u64, TableHandle> = HashMap::new();
253        for entry in &cat.tables {
254            if !matches!(entry.state, TableState::Live) {
255                continue;
256            }
257            let tdir = root.join(TABLES_DIR).join(entry.table_id.to_string());
258            let ctx = SharedCtx {
259                epoch: Arc::clone(&epoch),
260                page_cache: Arc::clone(&page_cache),
261                decoded_cache: Arc::clone(&decoded_cache),
262                snapshots: Arc::clone(&snapshots),
263                kek: kek.clone(),
264                commit_lock: Arc::clone(&commit_lock),
265                shared: Some(crate::engine::SharedWalCtx {
266                    wal: Arc::clone(&shared_wal),
267                    group: Arc::clone(&group),
268                    poisoned: Arc::clone(&poisoned),
269                    txn_ids: Arc::clone(&txn_ids),
270                }),
271            };
272            let t = Table::open_in(&tdir, ctx)?;
273            tables.insert(entry.table_id, Arc::new(Mutex::new(t)));
274        }
275
276        // Recover transaction writes from the shared WAL (spec §15). This is the
277        // single durability source for mounted tables: it applies every committed
278        // record — both single-table `Table::commit` writes and cross-table
279        // transactions — gated by each table's `flushed_epoch` (records already
280        // durable in a run are not re-applied).
281        if existing {
282            recover_shared_wal(&root, &tables, &epoch, wal_dek.as_ref())?;
283            // P3.4: sweep stale `_txn/<txn_id>/` dirs left by aborted/crashed
284            // large transactions (spec §8.5, review fix #14).
285            sweep_pending_txn_dirs(&root, &cat);
286        }
287
288        // Bump `open_generation` on every open and scope transaction ids by it
289        // (`txn_id = (generation << 32) | counter`), so ids never alias across
290        // reopens (review fix #11). Persist the bumped generation to the catalog.
291        if existing {
292            cat.open_generation = cat.open_generation.wrapping_add(1);
293            catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
294        }
295        let next_txn_id = (cat.open_generation << 32) | 1;
296        // Seed the shared txn-id allocator now that the generation is final.
297        *txn_ids.lock() = next_txn_id;
298
299        Ok(Self {
300            root,
301            catalog: RwLock::new(cat),
302            epoch,
303            snapshots,
304            page_cache,
305            decoded_cache,
306            commit_lock,
307            shared_wal,
308            next_txn_id: txn_ids,
309            tables: RwLock::new(tables),
310            kek,
311            ddl_lock: Mutex::new(()),
312            meta_dek,
313            conflicts: crate::txn::ConflictIndex::new(),
314            active_txns: crate::txn::ActiveTxns::new(),
315            poisoned,
316            group,
317            spill_threshold: std::sync::atomic::AtomicU64::new(64 * 1024 * 1024),
318            active_spills: Arc::new(crate::retention::ActiveSpills::new()),
319            spill_hook: Mutex::new(None),
320        })
321    }
322
323    /// The current reader-visible epoch.
324    pub fn visible_epoch(&self) -> Epoch {
325        self.epoch.visible()
326    }
327
328    /// Clone the in-memory catalog (for diagnostics / tests).
329    pub fn catalog_snapshot(&self) -> Catalog {
330        self.catalog.read().clone()
331    }
332
333    /// The filesystem root this database was opened/created at.
334    pub fn root(&self) -> &Path {
335        &self.root
336    }
337
338    /// Resolve a table name → id (live tables only). pub(crate) so the
339    /// transaction layer can stage by name.
340    pub fn table_id(&self, name: &str) -> Result<u64> {
341        let cat = self.catalog.read();
342        cat.live(name)
343            .map(|e| e.table_id)
344            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
345    }
346
347    /// Begin a new transaction reading at the current visible epoch.
348    pub fn begin(&self) -> crate::txn::Transaction<'_> {
349        let txn_id = self.alloc_txn_id();
350        let read = Snapshot::at(self.epoch.visible());
351        crate::txn::Transaction::new(self, txn_id, read)
352    }
353
354    /// Run `f` in a transaction; commit on `Ok`, rollback on `Err`.
355    pub fn transaction<T>(
356        &self,
357        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
358    ) -> Result<T> {
359        let mut tx = self.begin();
360        match f(&mut tx) {
361            Ok(out) => {
362                tx.commit()?;
363                Ok(out)
364            }
365            Err(e) => {
366                tx.rollback();
367                Err(e)
368            }
369        }
370    }
371
372    /// Register a txn in `ActiveTxns` (spec §9.2, review fix #12). Called from
373    /// `Transaction::new` so registration happens **before** any read.
374    pub(crate) fn register_active(&self, epoch: Epoch) -> crate::txn::ActiveTxnGuard<'_> {
375        self.active_txns.register(epoch)
376    }
377
378    /// Authoritatively validate every declared constraint on the staged write
379    /// set under the transaction's read snapshot, AND expand ON DELETE CASCADE /
380    /// SET NULL actions into explicit child ops. Called from
381    /// [`Self::commit_transaction`] outside the WAL mutex. Returns the first
382    /// violation as an `Err`, aborting the commit atomically. This is the
383    /// server-side authority point: concurrent remote writers that each pass
384    /// their own client-side checks still cannot both commit a violating batch.
385    ///
386    /// Scope: CHECK (full, three-valued), UNIQUE beyond the PK (existence scan +
387    /// intra-transaction dedup; concurrent-txn races are additionally caught by
388    /// `WriteKey::Unique`), and FK insert-side parent existence + ON DELETE
389    /// {RESTRICT, CASCADE, SET NULL}. CASCADE appends child deletes (transitive
390    /// fixpoint); SET NULL appends child updates (FK columns nulled). Truncate is
391    /// RESTRICT-only (cascade-truncate is unsupported).
392    fn validate_constraints(
393        &self,
394        staging: &mut Vec<(u64, crate::txn::Staged)>,
395        read_epoch: Epoch,
396    ) -> Result<()> {
397        use crate::constraint::{encode_composite_key, validate_checks, FkAction};
398        use crate::memtable::Row;
399        use crate::txn::Staged;
400        use std::collections::HashSet;
401
402        let snapshot = Snapshot::at(read_epoch);
403        let cat = self.catalog.read();
404
405        // Collect live (id, name, constraints-bearing?) for staged tables.
406        let live: Vec<(u64, &str, &crate::schema::Schema)> = cat
407            .tables
408            .iter()
409            .filter(|e| matches!(e.state, TableState::Live))
410            .map(|e| (e.table_id, e.name.as_str(), &e.schema))
411            .collect();
412
413        // Fast path: bail if no live table declares any constraints at all.
414        let any_constraints = live.iter().any(|(_, _, s)| !s.constraints.is_empty());
415        if !any_constraints {
416            return Ok(());
417        }
418
419        // Lazily-loaded visible rows per table, shared across checks.
420        let mut rows_cache: HashMap<u64, Vec<Row>> = HashMap::new();
421        let mut load_rows = |table_id: u64| -> Result<Vec<Row>> {
422            if let Some(r) = rows_cache.get(&table_id) {
423                return Ok(r.clone());
424            }
425            let handle = self.table_by_id(table_id)?;
426            let rows = handle.lock().visible_rows(snapshot)?;
427            rows_cache.insert(table_id, rows.clone());
428            Ok(rows)
429        };
430
431        // ── Phase A: expand ON DELETE CASCADE / SET NULL into explicit child
432        // ops (transitive fixpoint). RESTRICT is not expanded here — it is
433        // enforced as a violation in Phase B. `cascaded` records every delete
434        // we have already expanded so a self-referential CASCADE FK cannot loop.
435        let mut cascaded: HashSet<(u64, u64)> = HashSet::new();
436        loop {
437            let mut new_ops: Vec<(u64, Staged)> = Vec::new();
438            let deletes: Vec<(u64, crate::rowid::RowId)> = staging
439                .iter()
440                .filter_map(|(t, op)| match op {
441                    Staged::Delete(rid) => Some((*t, *rid)),
442                    _ => None,
443                })
444                .collect();
445            for (table_id, rid) in deletes {
446                if !cascaded.insert((table_id, rid.0)) {
447                    continue;
448                }
449                let Some(tname) = live
450                    .iter()
451                    .find(|(t, _, _)| *t == table_id)
452                    .map(|(_, n, _)| *n)
453                else {
454                    continue;
455                };
456                let parent_handle = self.table_by_id(table_id)?;
457                let Some(parent_row) = parent_handle.lock().get(rid, snapshot) else {
458                    continue;
459                };
460                for (child_id, _child_name, child_schema) in &live {
461                    for fk in &child_schema.constraints.foreign_keys {
462                        if fk.ref_table != tname {
463                            continue;
464                        }
465                        let Some(parent_key) =
466                            encode_composite_key(&fk.ref_columns, &parent_row.columns)
467                        else {
468                            continue;
469                        };
470                        match fk.on_delete {
471                            FkAction::Restrict => continue,
472                            FkAction::Cascade => {
473                                let child_rows = load_rows(*child_id)?;
474                                for cr in &child_rows {
475                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
476                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
477                                            == Some(parent_key.as_slice())
478                                    {
479                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
480                                    }
481                                }
482                            }
483                            FkAction::SetNull => {
484                                let child_rows = load_rows(*child_id)?;
485                                for cr in &child_rows {
486                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
487                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
488                                            == Some(parent_key.as_slice())
489                                    {
490                                        // Re-emit the child row with the FK
491                                        // columns set to NULL (delete + put).
492                                        let mut cells: Vec<(u16, crate::memtable::Value)> = cr
493                                            .columns
494                                            .iter()
495                                            .map(|(k, v)| (*k, v.clone()))
496                                            .collect();
497                                        for cid in &fk.columns {
498                                            cells.retain(|(k, _)| k != cid);
499                                            cells.push((*cid, crate::memtable::Value::Null));
500                                        }
501                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
502                                        new_ops.push((*child_id, Staged::Put(cells)));
503                                    }
504                                }
505                            }
506                        }
507                    }
508                }
509            }
510            if new_ops.is_empty() {
511                break;
512            }
513            staging.extend(new_ops);
514        }
515
516        // Rows staged for deletion in THIS transaction (now including cascaded
517        // deletes). Used to exclude the old version of an updated row from
518        // unique-existence scans.
519        let staged_deletes: HashSet<(u64, u64)> = staging
520            .iter()
521            .filter_map(|(t, op)| match op {
522                Staged::Delete(rid) => Some((*t, rid.0)),
523                _ => None,
524            })
525            .collect();
526
527        // Intra-transaction unique-key dedup: (table_id, uc_id, key).
528        let mut seen_unique: HashSet<(u64, u16, Vec<u8>)> = HashSet::new();
529
530        // ── Phase B: validate the fully-expanded staging set.
531        for (table_id, op) in staging.iter() {
532            let Some((_, tname, schema)) = live.iter().find(|(t, _, _)| t == table_id).copied()
533            else {
534                continue;
535            };
536            let cells_map: HashMap<u16, crate::memtable::Value>;
537            match op {
538                Staged::Put(cells) => {
539                    cells_map = cells.iter().cloned().collect();
540
541                    // CHECK constraints.
542                    if !schema.constraints.checks.is_empty() {
543                        validate_checks(&schema.constraints.checks, &cells_map)?;
544                    }
545
546                    // UNIQUE (non-PK) constraints.
547                    for uc in &schema.constraints.uniques {
548                        let Some(key) = encode_composite_key(&uc.columns, &cells_map) else {
549                            continue; // NULL in a constrained column → skip (SQL).
550                        };
551                        let marker = (*table_id, uc.id, key.clone());
552                        if !seen_unique.insert(marker) {
553                            return Err(MongrelError::Conflict(format!(
554                                "UNIQUE constraint '{}' on table '{tname}' violated within batch",
555                                uc.name
556                            )));
557                        }
558                        let rows = load_rows(*table_id)?;
559                        for r in &rows {
560                            // Skip rows this same transaction is deleting (the
561                            // old version of an updated/cascade-deleted row).
562                            if staged_deletes.contains(&(*table_id, r.row_id.0)) {
563                                continue;
564                            }
565                            if let Some(theirs) = encode_composite_key(&uc.columns, &r.columns) {
566                                if theirs == key {
567                                    return Err(MongrelError::Conflict(format!(
568                                        "UNIQUE constraint '{}' on table '{tname}' violated",
569                                        uc.name
570                                    )));
571                                }
572                            }
573                        }
574                    }
575
576                    // FK insert-side: parent must exist.
577                    for fk in &schema.constraints.foreign_keys {
578                        let Some(child_key) = encode_composite_key(&fk.columns, &cells_map) else {
579                            continue; // NULL FK component → not checked (SQL).
580                        };
581                        let Some(parent_id) = cat
582                            .tables
583                            .iter()
584                            .find(|t| t.name == fk.ref_table)
585                            .map(|t| t.table_id)
586                        else {
587                            return Err(MongrelError::InvalidArgument(format!(
588                                "FOREIGN KEY '{}' references unknown table '{}'",
589                                fk.name, fk.ref_table
590                            )));
591                        };
592                        let parent_rows = load_rows(parent_id)?;
593                        let mut found = false;
594                        for r in &parent_rows {
595                            if staged_deletes.contains(&(parent_id, r.row_id.0)) {
596                                continue;
597                            }
598                            if let Some(pkey) = encode_composite_key(&fk.ref_columns, &r.columns) {
599                                if pkey == child_key {
600                                    found = true;
601                                    break;
602                                }
603                            }
604                        }
605                        if !found {
606                            return Err(MongrelError::Conflict(format!(
607                                "FOREIGN KEY '{}' on table '{tname}' has no matching parent in '{}'",
608                                fk.name, fk.ref_table
609                            )));
610                        }
611                    }
612                }
613                Staged::Delete(rid) => {
614                    // FK ON DELETE RESTRICT: a child row (whose FK action is
615                    // RESTRICT) referencing this parent blocks the delete.
616                    // CASCADE/SET NULL children were expanded in Phase A.
617                    let parent_handle = self.table_by_id(*table_id)?;
618                    let Some(parent_row) = parent_handle.lock().get(*rid, snapshot) else {
619                        continue;
620                    };
621                    for (child_id, child_name, child_schema) in &live {
622                        for fk in &child_schema.constraints.foreign_keys {
623                            if fk.ref_table != tname || fk.on_delete != FkAction::Restrict {
624                                continue;
625                            }
626                            let Some(parent_key) =
627                                encode_composite_key(&fk.ref_columns, &parent_row.columns)
628                            else {
629                                continue;
630                            };
631                            let child_rows = load_rows(*child_id)?;
632                            for r in &child_rows {
633                                // A child already being deleted by this txn
634                                // (cascade/inline) is not a restrict violation.
635                                if staged_deletes.contains(&(*child_id, r.row_id.0)) {
636                                    continue;
637                                }
638                                if let Some(ck) = encode_composite_key(&fk.columns, &r.columns) {
639                                    if ck == parent_key {
640                                        return Err(MongrelError::Conflict(format!(
641                                            "FOREIGN KEY '{}' on table '{child_name}' restricts delete (parent referenced)",
642                                            fk.name
643                                        )));
644                                    }
645                                }
646                            }
647                        }
648                    }
649                }
650                Staged::Truncate => {
651                    // Truncate is RESTRICT-only: reject if any child references
652                    // this table (any FK action), since cascade-truncate is
653                    // unsupported.
654                    for (child_id, child_name, child_schema) in &live {
655                        for fk in &child_schema.constraints.foreign_keys {
656                            if fk.ref_table != tname {
657                                continue;
658                            }
659                            let child_rows = load_rows(*child_id)?;
660                            if child_rows
661                                .iter()
662                                .any(|r| encode_composite_key(&fk.columns, &r.columns).is_some())
663                            {
664                                return Err(MongrelError::Conflict(format!(
665                                    "FOREIGN KEY '{}' on table '{child_name}' restricts truncate of '{tname}'",
666                                    fk.name
667                                )));
668                            }
669                        }
670                    }
671                }
672            }
673        }
674        Ok(())
675    }
676
677    /// Seal a transaction (spec §9.3):
678    /// 1. Prepare — derive write keys, allocate row ids (brief table locks).
679    /// 2. Sequencer — validate-first under the WAL mutex; abort on conflict
680    ///    with no epoch consumed; assign epoch, append data records + TxnCommit,
681    ///    group-sync, record conflict keys.
682    /// 3. Publish — apply to tables, advance visible in-order.
683    pub(crate) fn commit_transaction(
684        &self,
685        txn_id: u64,
686        read_epoch: Epoch,
687        mut staging: Vec<(u64, crate::txn::Staged)>,
688    ) -> Result<Epoch> {
689        use crate::memtable::Row;
690        use crate::txn::{Staged, StagedOp, WriteKey};
691        use crate::wal::Op;
692        use std::collections::hash_map::DefaultHasher;
693        use std::hash::{Hash, Hasher};
694        use std::sync::atomic::Ordering;
695
696        if self.poisoned.load(Ordering::Relaxed) {
697            return Err(MongrelError::Other(
698                "database poisoned by fsync error".into(),
699            ));
700        }
701
702        // ── 1. Prepare: derive write keys + allocate row ids ──
703        let write_keys = {
704            let cat = self.catalog.read();
705            let mut keys: Vec<WriteKey> = Vec::new();
706            for (table_id, staged) in &staging {
707                match staged {
708                    Staged::Put(cells) => {
709                        if let Some(entry) = cat.tables.iter().find(|t| t.table_id == *table_id) {
710                            for col in &entry.schema.columns {
711                                if col.flags.contains(crate::schema::ColumnFlags::PRIMARY_KEY) {
712                                    if let Some((_, val)) =
713                                        cells.iter().find(|(id, _)| *id == col.id)
714                                    {
715                                        let mut h = DefaultHasher::new();
716                                        val.encode_key().hash(&mut h);
717                                        keys.push(WriteKey::Unique {
718                                            table_id: *table_id,
719                                            index_id: 0,
720                                            key_hash: h.finish(),
721                                        });
722                                    }
723                                }
724                            }
725                            // Declared non-PK unique constraints register a
726                            // `WriteKey::Unique` (namespace-separated from the
727                            // PK's index_id==0 by setting the high bit) so two
728                            // concurrent transactions inserting the same key
729                            // cannot both commit. Rows with any NULL constrained
730                            // column are skipped (SQL semantics).
731                            for uc in &entry.schema.constraints.uniques {
732                                if let Some(key_bytes) = crate::constraint::encode_composite_key(
733                                    &uc.columns,
734                                    &cells.iter().cloned().collect(),
735                                ) {
736                                    let mut h = DefaultHasher::new();
737                                    key_bytes.hash(&mut h);
738                                    keys.push(WriteKey::Unique {
739                                        table_id: *table_id,
740                                        index_id: uc.id | 0x8000,
741                                        key_hash: h.finish(),
742                                    });
743                                }
744                            }
745                        }
746                    }
747                    Staged::Delete(rid) => keys.push(WriteKey::Row {
748                        table_id: *table_id,
749                        row_id: rid.0,
750                    }),
751                    Staged::Truncate => keys.push(WriteKey::Table {
752                        table_id: *table_id,
753                    }),
754                }
755            }
756            keys
757        };
758
759        // Opportunistic pruning.
760        let min_active = self.active_txns.min_read_epoch();
761        if min_active < u64::MAX {
762            self.conflicts.prune_below(Epoch(min_active));
763        }
764
765        // ── 1a. Pre-validate the full write-set OUTSIDE the sequencer (spec
766        // §8.5, review fix #17). Snapshot the conflict-index version so the
767        // sequencer only re-checks if new commits arrived in the interim.
768        if self.conflicts.conflicts(&write_keys, read_epoch) {
769            return Err(MongrelError::Conflict(
770                "write-write conflict (pre-validate, first-committer-wins)".into(),
771            ));
772        }
773        let pre_validate_version = self.conflicts.version();
774
775        // ── 1a½. Fill `AUTO_INCREMENT` columns for every staged put. This must
776        // happen before row ids are allocated and before rows are serialized
777        // (either as streamed Put records or spilled runs), so raw transaction
778        // users get engine-assigned ids just like single-table `put_returning`.
779        {
780            let tables = self.tables.read();
781            for (table_id, staged) in &mut staging {
782                if let Staged::Put(cells) = staged {
783                    if let Some(handle) = tables.get(table_id) {
784                        let mut t = handle.lock();
785                        t.fill_auto_inc(cells)?;
786                    }
787                }
788            }
789        }
790
791        // ── 1a¾. Validate declarative constraints (unique / FK / check) under
792        // the read snapshot, outside the WAL mutex. This is the authoritative
793        // server-side enforcement point: a batch that reaches commit either
794        // satisfies every declared constraint or is rejected atomically — no
795        // partial commit. Concurrent-txn uniqueness is additionally guarded by
796        // the `WriteKey::Unique` keys derived above.
797        self.validate_constraints(&mut staging, read_epoch)?;
798
799        // ── 1b. Spill: if a table's staged puts exceed the threshold, write a
800        // uniform-epoch pending run (spec §8.5). Rows in the run are NOT
801        // streamed as Put records; they are linked at publish time.
802        let mut spilled: Vec<SpilledRun> = Vec::new();
803        let mut spilled_tables: std::collections::HashSet<u64> = std::collections::HashSet::new();
804        // Protect this txn's `_txn/<id>/` dir from a concurrent `gc()` for as long
805        // as the spill runs are live (registered on first spill, dropped at the
806        // end of this function on commit/abort/error).
807        let mut spill_guard: Option<crate::retention::SpillGuard> = None;
808        {
809            let mut table_bytes: HashMap<u64, usize> = HashMap::new();
810            for (table_id, staged) in &staging {
811                if let Staged::Put(cells) = staged {
812                    *table_bytes.entry(*table_id).or_default() += cells.len() * 16;
813                }
814            }
815            let tables = self.tables.read();
816            for (&table_id, &bytes) in &table_bytes {
817                if bytes as u64
818                    <= self
819                        .spill_threshold
820                        .load(std::sync::atomic::Ordering::Relaxed)
821                {
822                    continue;
823                }
824                let Some(handle) = tables.get(&table_id) else {
825                    continue;
826                };
827                spill_guard.get_or_insert_with(|| self.active_spills.register(txn_id));
828                let mut t = handle.lock();
829                let tdir = t.table_dir().to_path_buf();
830                let txn_dir = tdir.join("_txn").join(txn_id.to_string());
831                std::fs::create_dir_all(&txn_dir)?;
832                let run_id = t.alloc_run_id() as u128;
833                let pending_path = txn_dir.join(format!("r-{run_id}.sr"));
834
835                let mut rows: Vec<Row> = Vec::new();
836                for (tid, staged) in &staging {
837                    if *tid != table_id {
838                        continue;
839                    }
840                    if let Staged::Put(cells) = staged {
841                        t.validate_cells_not_null(cells)?;
842                        let row_id = t.alloc_row_id();
843                        let mut row = Row::new(row_id, Epoch(0));
844                        for (c, v) in cells {
845                            row.columns.insert(*c, v.clone());
846                        }
847                        rows.push(row);
848                    }
849                }
850                let schema = t.schema_ref().clone();
851                let kek = t.kek_ref().cloned();
852                let specs = t.indexable_column_specs();
853                drop(t);
854
855                let mut writer = crate::sorted_run::RunWriter::new(&schema, run_id, Epoch(0), 0)
856                    .uniform_epoch(true);
857                if let Some(ref kek) = kek {
858                    writer = writer.with_encryption(kek.as_ref(), specs);
859                }
860                let header = writer.write(&pending_path, &rows)?;
861                let row_count = header.row_count;
862                let min_rid = rows.first().map(|r| r.row_id.0).unwrap_or(0);
863                let max_rid = rows.last().map(|r| r.row_id.0).unwrap_or(0);
864
865                spilled.push(SpilledRun {
866                    table_id,
867                    run_id,
868                    pending_path,
869                    rows,
870                    row_count,
871                    min_rid,
872                    max_rid,
873                });
874                spilled_tables.insert(table_id);
875            }
876        }
877
878        // Test seam: let a test race `gc()` against this in-flight spill.
879        if spill_guard.is_some() {
880            if let Some(hook) = self.spill_hook.lock().as_ref() {
881                hook();
882            }
883        }
884
885        // ── 1c. Pre-build non-spilled put rows OUTSIDE the WAL critical section.
886        // Allocating row ids + building the rows here (lock order: table handle →
887        // nothing) means the sequencer never locks a table handle while holding
888        // the shared-WAL mutex. That matters because `Table::commit`/`flush` lock
889        // the table handle THEN the shared WAL; if the sequencer did the reverse
890        // (WAL then handle) the two paths would deadlock (review fix: B1).
891        // Aligned 1:1 with `staging`; `None` for deletes and spilled puts.
892        // Row ids are allocated here, before the sequencer's delta conflict
893        // re-check, so a losing txn leaks the ids it reserved — harmless, the
894        // u64 row-id space is monotonic and gaps are expected (spills do the same).
895        let mut prebuilt: Vec<Option<Row>> = Vec::with_capacity(staging.len());
896        {
897            let tables = self.tables.read();
898            for (table_id, staged) in &staging {
899                match staged {
900                    Staged::Put(cells) if !spilled_tables.contains(table_id) => {
901                        let handle = tables.get(table_id).ok_or_else(|| {
902                            MongrelError::NotFound(format!("table {table_id} not mounted"))
903                        })?;
904                        let mut t = handle.lock();
905                        t.validate_cells_not_null(cells)?;
906                        let row_id = t.alloc_row_id();
907                        drop(t);
908                        let mut row = Row::new(row_id, Epoch(0));
909                        for (c, v) in cells {
910                            row.columns.insert(*c, v.clone());
911                        }
912                        prebuilt.push(Some(row));
913                    }
914                    Staged::Put(_) | Staged::Delete(_) | Staged::Truncate => prebuilt.push(None),
915                }
916            }
917        }
918
919        // ── 2. Sequencer: validate-first → assign → append → sync → record ──
920        let added_runs: Vec<crate::wal::AddedRun> = spilled
921            .iter()
922            .map(|s| crate::wal::AddedRun {
923                table_id: s.table_id,
924                run_id: s.run_id,
925                row_count: s.row_count,
926                level: 0,
927                min_row_id: s.min_rid,
928                max_row_id: s.max_rid,
929                content_hash: [0u8; 32],
930            })
931            .collect();
932        let (new_epoch, applies, commit_seq) = {
933            let mut wal = self.shared_wal.lock();
934
935            // Re-check only if the conflict index advanced since pre-validation
936            // (bounded delta — spec §8.5, review fix #17). If the version is
937            // unchanged, the pre-check result is still valid and the sequencer
938            // does O(1) work regardless of write-set size.
939            if self.conflicts.version() != pre_validate_version
940                && self.conflicts.conflicts(&write_keys, read_epoch)
941            {
942                // Abort: this txn assigned no epoch yet, so drop the quarantined
943                // spill runs we wrote during prepare instead of leaking them in
944                // `_txn/` until the next GC/reopen sweep.
945                drop(wal);
946                for s in &spilled {
947                    if let Some(parent) = s.pending_path.parent() {
948                        let _ = std::fs::remove_dir_all(parent);
949                    }
950                }
951                return Err(MongrelError::Conflict(
952                    "write-write conflict (sequencer delta re-check)".into(),
953                ));
954            }
955
956            let new_epoch = self.epoch.bump_assigned();
957            let mut applies: Vec<(u64, Vec<StagedOp>)> = Vec::new();
958
959            for (idx, (table_id, staged)) in staging.iter().enumerate() {
960                // Skip puts for tables that were spilled — their data is in a
961                // pending run, not in streamed Put records.
962                if spilled_tables.contains(table_id) && matches!(staged, Staged::Put(_)) {
963                    continue;
964                }
965                let mut ops = Vec::new();
966                match staged {
967                    Staged::Put(_) => {
968                        // Stamp the pre-built row at the real assigned epoch.
969                        let mut row = prebuilt[idx].take().expect("prebuilt put row");
970                        row.committed_epoch = new_epoch;
971                        let payload = bincode::serialize(&vec![row.clone()])
972                            .map_err(|e| MongrelError::Other(format!("row serialize: {e}")))?;
973                        wal.append(
974                            txn_id,
975                            *table_id,
976                            Op::Put {
977                                table_id: *table_id,
978                                rows: payload,
979                            },
980                        )?;
981                        ops.push(StagedOp::Put(row));
982                    }
983                    Staged::Delete(rid) => {
984                        wal.append(
985                            txn_id,
986                            *table_id,
987                            Op::Delete {
988                                table_id: *table_id,
989                                row_ids: vec![*rid],
990                            },
991                        )?;
992                        ops.push(StagedOp::Delete(*rid));
993                    }
994                    Staged::Truncate => {
995                        wal.append(
996                            txn_id,
997                            *table_id,
998                            Op::TruncateTable {
999                                table_id: *table_id,
1000                            },
1001                        )?;
1002                        ops.push(StagedOp::Truncate);
1003                    }
1004                }
1005                applies.push((*table_id, ops));
1006            }
1007
1008            let commit_seq = wal.append_commit(txn_id, new_epoch, &added_runs)?;
1009
1010            // Record the conflict + assign the epoch under the WAL lock so commit
1011            // order == WAL append order, but DO NOT fsync here (P3.2): the fsync
1012            // moves out of this critical section to the group-commit coordinator
1013            // so concurrent committers share a single leader fsync.
1014            self.conflicts.record(&write_keys, new_epoch);
1015            (new_epoch, applies, commit_seq)
1016        };
1017
1018        // ── 2b. Durability: one leader fsync serves this whole batch (P3.2). ──
1019        self.group
1020            .await_durable(&self.shared_wal, commit_seq)
1021            .inspect_err(|_| {
1022                self.poisoned.store(true, Ordering::Relaxed);
1023            })?;
1024
1025        // ── 3. Publish: link spilled runs + apply non-spilled ops ──
1026        {
1027            let tables = self.tables.read();
1028            // Link spilled runs first.
1029            for s in &spilled {
1030                if let Some(handle) = tables.get(&s.table_id) {
1031                    let mut t = handle.lock();
1032                    let dest = t.run_path(s.run_id as u64);
1033                    std::fs::rename(&s.pending_path, &dest)?;
1034                    // Clean up the now-empty `_txn/<txn_id>/` dir.
1035                    if let Some(parent) = s.pending_path.parent() {
1036                        let _ = std::fs::remove_dir_all(parent);
1037                    }
1038                    t.link_run(crate::manifest::RunRef {
1039                        run_id: s.run_id,
1040                        level: 0,
1041                        epoch_created: new_epoch.0,
1042                        row_count: s.row_count,
1043                    });
1044                    // Update indexes + live_count from the spilled rows WITHOUT
1045                    // materializing them in the memtable (P3.4): they are served
1046                    // from the linked uniform-epoch run, read at `new_epoch` via
1047                    // the RunRef. This keeps peak memory bounded for large txns.
1048                    t.apply_run_metadata(&s.rows)?;
1049                    t.invalidate_pending_cache();
1050                    t.persist_manifest(new_epoch)?;
1051                }
1052            }
1053            // Apply non-spilled ops.
1054            for (table_id, ops) in applies {
1055                if let Some(handle) = tables.get(&table_id) {
1056                    let mut t = handle.lock();
1057                    for op in ops {
1058                        match op {
1059                            StagedOp::Put(row) => t.apply_put_rows(vec![row])?,
1060                            StagedOp::Delete(rid) => t.apply_delete(rid, new_epoch),
1061                            StagedOp::Truncate => t.apply_truncate(new_epoch)?,
1062                        }
1063                    }
1064                    t.invalidate_pending_cache();
1065                    t.persist_manifest(new_epoch)?;
1066                }
1067            }
1068        }
1069
1070        self.advance_visible(new_epoch);
1071        Ok(new_epoch)
1072    }
1073
1074    /// Advance `visible` in-order: epoch E becomes visible only once E and all
1075    /// prior unpublished epochs have finished publishing (spec §9.3e). The
1076    /// in-order gate lives on the shared [`EpochAuthority`] so this path, the
1077    /// single-table `Table::commit` path, and DDL all share one watermark and
1078    /// can never publish out of assigned order under concurrency.
1079    fn advance_visible(&self, published: Epoch) {
1080        self.epoch.publish_in_order(published);
1081    }
1082
1083    /// Register a read snapshot at the current visible epoch and return it with
1084    /// a guard that retains it for GC until dropped.
1085    pub fn snapshot(&self) -> (Snapshot, SnapshotGuard<'_>) {
1086        let e = self.epoch.visible();
1087        let g = self.snapshots.register(e);
1088        (Snapshot::at(e), g)
1089    }
1090
1091    /// Owned (clonable-handle) variant of [`Self::snapshot`] for cross-thread
1092    /// retention.
1093    pub fn snapshot_owned(&self) -> (Snapshot, OwnedSnapshotGuard) {
1094        let e = self.epoch.visible();
1095        let g = self.snapshots.register_owned(e);
1096        (Snapshot::at(e), g)
1097    }
1098
1099    /// Names of all live tables.
1100    pub fn table_names(&self) -> Vec<String> {
1101        self.catalog
1102            .read()
1103            .tables
1104            .iter()
1105            .filter(|t| matches!(t.state, TableState::Live))
1106            .map(|t| t.name.clone())
1107            .collect()
1108    }
1109
1110    /// Look up a live table by name.
1111    pub fn table(&self, name: &str) -> Result<TableHandle> {
1112        let cat = self.catalog.read();
1113        let entry = cat
1114            .live(name)
1115            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
1116        let id = entry.table_id;
1117        drop(cat);
1118        self.tables
1119            .read()
1120            .get(&id)
1121            .cloned()
1122            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not mounted")))
1123    }
1124
1125    /// Resolve a live table id → mounted handle (used by the constraint
1126    /// validation pass and other id-qualified internal paths).
1127    fn table_by_id(&self, id: u64) -> Result<TableHandle> {
1128        self.tables
1129            .read()
1130            .get(&id)
1131            .cloned()
1132            .ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
1133    }
1134
1135    /// Create a new table. The DDL is first logged to the shared WAL
1136    /// (`Op::Ddl(CreateTable)` + `TxnCommit`) and group-synced so it is durable
1137    /// BEFORE the in-memory catalog and table map are mutated; the catalog
1138    /// checkpoint is rewritten afterwards (spec §15, review fix #16). A reopen
1139    /// that sees a stale catalog still recovers the table by replaying the Ddl.
1140    pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
1141        use crate::wal::DdlOp;
1142        use std::sync::atomic::Ordering;
1143
1144        if self.poisoned.load(Ordering::Relaxed) {
1145            return Err(MongrelError::Other(
1146                "database poisoned by fsync error".into(),
1147            ));
1148        }
1149
1150        let _g = self.ddl_lock.lock();
1151        {
1152            let cat = self.catalog.read();
1153            if cat.live(name).is_some() {
1154                return Err(MongrelError::InvalidArgument(format!(
1155                    "table {name:?} already exists"
1156                )));
1157            }
1158        }
1159
1160        // Allocate id + epoch + txn id under the commit lock so the DDL commit
1161        // is serialized with data commits (in-order publish).
1162        let commit_lock = Arc::clone(&self.commit_lock);
1163        let _c = commit_lock.lock();
1164        let table_id = {
1165            let mut cat = self.catalog.write();
1166            let id = cat.next_table_id;
1167            cat.next_table_id += 1;
1168            id
1169        };
1170        let epoch = self.epoch.bump_assigned();
1171        let txn_id = self.alloc_txn_id();
1172
1173        // Stamp the schema_id with the unique table_id so every table in the
1174        // database has a distinct schema_id (caller-provided values are
1175        // ignored to prevent collisions).
1176        let mut schema = schema;
1177        schema.schema_id = table_id;
1178        // Defense in depth: reject an invalid schema BEFORE any durable
1179        // side-effect. `Table::create_in` re-validates, but by then the DDL has
1180        // already been appended to the shared WAL; a failing create_in would
1181        // leave a dangling entry that `recover_ddl_from_wal` replays without
1182        // re-validating, corrupting the catalog on reopen. Validating here
1183        // keeps the WAL free of schemas that can never be opened.
1184        schema.validate_auto_increment()?;
1185
1186        // 1. Log the DDL + commit marker to the shared WAL, then make it durable
1187        //    via the group-commit coordinator (no fsync under the WAL lock — P3.2).
1188        let schema_json = DdlOp::encode_schema(&schema)?;
1189        let commit_seq = {
1190            let mut wal = self.shared_wal.lock();
1191            wal.append(
1192                txn_id,
1193                table_id,
1194                crate::wal::Op::Ddl(DdlOp::CreateTable {
1195                    table_id,
1196                    name: name.to_string(),
1197                    schema_json,
1198                }),
1199            )?;
1200            wal.append_commit(txn_id, epoch, &[])?
1201        };
1202        self.group
1203            .await_durable(&self.shared_wal, commit_seq)
1204            .inspect_err(|_| {
1205                self.poisoned.store(true, Ordering::Relaxed);
1206            })?;
1207
1208        // 2. Create the on-disk table dir + manifest.
1209        let tdir = self.root.join(TABLES_DIR).join(table_id.to_string());
1210        std::fs::create_dir_all(&tdir)?;
1211        let ctx = SharedCtx {
1212            epoch: Arc::clone(&self.epoch),
1213            page_cache: Arc::clone(&self.page_cache),
1214            decoded_cache: Arc::clone(&self.decoded_cache),
1215            snapshots: Arc::clone(&self.snapshots),
1216            kek: self.kek.clone(),
1217            commit_lock: Arc::clone(&self.commit_lock),
1218            shared: Some(crate::engine::SharedWalCtx {
1219                wal: Arc::clone(&self.shared_wal),
1220                group: Arc::clone(&self.group),
1221                poisoned: Arc::clone(&self.poisoned),
1222                txn_ids: Arc::clone(&self.next_txn_id),
1223            }),
1224        };
1225        let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
1226
1227        // 3. Mutate the in-memory catalog + mount the table, then rewrite the
1228        //    catalog checkpoint (lazy: outside the WAL critical section).
1229        {
1230            let mut cat = self.catalog.write();
1231            cat.tables.push(CatalogEntry {
1232                table_id,
1233                name: name.to_string(),
1234                schema,
1235                state: TableState::Live,
1236                created_epoch: epoch.0,
1237            });
1238        }
1239        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1240        self.tables
1241            .write()
1242            .insert(table_id, Arc::new(Mutex::new(table)));
1243
1244        self.advance_visible(epoch);
1245        Ok(table_id)
1246    }
1247
1248    /// Logically drop a table, logging the DDL through the shared WAL first.
1249    pub fn drop_table(&self, name: &str) -> Result<()> {
1250        use crate::wal::DdlOp;
1251        use std::sync::atomic::Ordering;
1252
1253        if self.poisoned.load(Ordering::Relaxed) {
1254            return Err(MongrelError::Other(
1255                "database poisoned by fsync error".into(),
1256            ));
1257        }
1258
1259        let _g = self.ddl_lock.lock();
1260        let table_id = {
1261            let cat = self.catalog.read();
1262            cat.live(name)
1263                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
1264                .table_id
1265        };
1266
1267        let commit_lock = Arc::clone(&self.commit_lock);
1268        let _c = commit_lock.lock();
1269        let epoch = self.epoch.bump_assigned();
1270        let txn_id = self.alloc_txn_id();
1271        let commit_seq = {
1272            let mut wal = self.shared_wal.lock();
1273            wal.append(
1274                txn_id,
1275                table_id,
1276                crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
1277            )?;
1278            wal.append_commit(txn_id, epoch, &[])?
1279        };
1280        self.group
1281            .await_durable(&self.shared_wal, commit_seq)
1282            .inspect_err(|_| {
1283                self.poisoned.store(true, Ordering::Relaxed);
1284            })?;
1285
1286        {
1287            let mut cat = self.catalog.write();
1288            let entry = cat
1289                .tables
1290                .iter_mut()
1291                .find(|t| t.table_id == table_id)
1292                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
1293            entry.state = TableState::Dropped { at_epoch: epoch.0 };
1294        }
1295        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1296        self.tables.write().remove(&table_id);
1297
1298        self.advance_visible(epoch);
1299        Ok(())
1300    }
1301
1302    /// Rename a live table. `name` must exist and `new_name` must not collide
1303    /// with any live table; both checks run under `ddl_lock` so they are atomic
1304    /// with the rename and with concurrent `create_table` existence checks (no
1305    /// TOCTOU window). A no-op rename (`name == new_name`) succeeds without
1306    /// side-effects. The rename is logged to the shared WAL as
1307    /// `DdlOp::RenameTable` and recovered on reopen; the `table_id`, schema,
1308    /// and on-disk layout are unchanged (the table is keyed by `table_id`, so
1309    /// the in-memory object does not move — only the catalog name changes).
1310    pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
1311        use crate::wal::DdlOp;
1312        use std::sync::atomic::Ordering;
1313
1314        if self.poisoned.load(Ordering::Relaxed) {
1315            return Err(MongrelError::Other(
1316                "database poisoned by fsync error".into(),
1317            ));
1318        }
1319
1320        // A no-op rename short-circuits before any locking, so it can never
1321        // trip the "target already exists" check (the source *is* that name).
1322        if name == new_name {
1323            return Ok(());
1324        }
1325        if new_name.is_empty() {
1326            return Err(MongrelError::InvalidArgument(
1327                "rename_table: new name must not be empty".into(),
1328            ));
1329        }
1330
1331        let _g = self.ddl_lock.lock();
1332        let table_id = {
1333            let cat = self.catalog.read();
1334            let src = cat
1335                .live(name)
1336                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
1337            // Target must be free. Checked under ddl_lock, which every other
1338            // DDL (create/rename/drop) also holds, so a concurrent operation
1339            // cannot claim `new_name` between this check and the catalog write.
1340            if cat.live(new_name).is_some() {
1341                return Err(MongrelError::InvalidArgument(format!(
1342                    "rename_table: a table named {new_name:?} already exists"
1343                )));
1344            }
1345            src.table_id
1346        };
1347
1348        let commit_lock = Arc::clone(&self.commit_lock);
1349        let _c = commit_lock.lock();
1350        let epoch = self.epoch.bump_assigned();
1351        let txn_id = self.alloc_txn_id();
1352        let commit_seq = {
1353            let mut wal = self.shared_wal.lock();
1354            wal.append(
1355                txn_id,
1356                table_id,
1357                crate::wal::Op::Ddl(DdlOp::RenameTable {
1358                    table_id,
1359                    new_name: new_name.to_string(),
1360                }),
1361            )?;
1362            wal.append_commit(txn_id, epoch, &[])?
1363        };
1364        self.group
1365            .await_durable(&self.shared_wal, commit_seq)
1366            .inspect_err(|_| {
1367                self.poisoned.store(true, Ordering::Relaxed);
1368            })?;
1369
1370        {
1371            let mut cat = self.catalog.write();
1372            let entry = cat
1373                .tables
1374                .iter_mut()
1375                .find(|t| t.table_id == table_id)
1376                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
1377            entry.name = new_name.to_string();
1378        }
1379        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1380        // The in-memory table object is keyed by table_id, not name, so it does
1381        // not move and live TableHandles remain valid.
1382        self.advance_visible(epoch);
1383        Ok(())
1384    }
1385
1386    pub fn alter_column(
1387        &self,
1388        table_name: &str,
1389        column_name: &str,
1390        change: AlterColumn,
1391    ) -> Result<ColumnDef> {
1392        use crate::wal::DdlOp;
1393        use std::sync::atomic::Ordering;
1394
1395        if self.poisoned.load(Ordering::Relaxed) {
1396            return Err(MongrelError::Other(
1397                "database poisoned by fsync error".into(),
1398            ));
1399        }
1400
1401        let _g = self.ddl_lock.lock();
1402        let table_id = {
1403            let cat = self.catalog.read();
1404            cat.live(table_name)
1405                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
1406                .table_id
1407        };
1408        let handle =
1409            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
1410                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
1411            })?;
1412        let mut table = handle.lock();
1413        let column = table.prepare_alter_column(column_name, &change)?;
1414        if table
1415            .schema()
1416            .columns
1417            .iter()
1418            .find(|c| c.id == column.id)
1419            .is_some_and(|c| c == &column)
1420        {
1421            return Ok(column);
1422        }
1423
1424        let commit_lock = Arc::clone(&self.commit_lock);
1425        let _c = commit_lock.lock();
1426        let epoch = self.epoch.bump_assigned();
1427        let txn_id = self.alloc_txn_id();
1428        let column_json = DdlOp::encode_column(&column)?;
1429        let commit_seq = {
1430            let mut wal = self.shared_wal.lock();
1431            wal.append(
1432                txn_id,
1433                table_id,
1434                crate::wal::Op::Ddl(DdlOp::AlterTable {
1435                    table_id,
1436                    column_json,
1437                }),
1438            )?;
1439            wal.append_commit(txn_id, epoch, &[])?
1440        };
1441        self.group
1442            .await_durable(&self.shared_wal, commit_seq)
1443            .inspect_err(|_| {
1444                self.poisoned.store(true, Ordering::Relaxed);
1445            })?;
1446
1447        table.apply_altered_column(column.clone())?;
1448        let schema = table.schema().clone();
1449        drop(table);
1450
1451        {
1452            let mut cat = self.catalog.write();
1453            let entry = cat
1454                .tables
1455                .iter_mut()
1456                .find(|t| t.table_id == table_id)
1457                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
1458            entry.schema = schema;
1459        }
1460        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1461
1462        self.advance_visible(epoch);
1463        Ok(column)
1464    }
1465
1466    /// Retention-gated garbage collection (spec §6.4, §7.4, §16). Deletes:
1467    /// - Dropped-table subdirs whose `at_epoch < min_active_snapshot`.
1468    /// - Stale `_txn/` dirs (aborted/crashed large-txn pending runs).
1469    ///
1470    /// Returns the number of items reclaimed.
1471    pub fn gc(&self) -> Result<usize> {
1472        let min_active = self.snapshots.min_active(self.epoch.visible()).0;
1473        let mut reclaimed = 0;
1474
1475        // Reclaim dropped-table dirs where no pinned snapshot still needs them.
1476        let cat = self.catalog.read();
1477        for entry in &cat.tables {
1478            if let TableState::Dropped { at_epoch } = entry.state {
1479                if at_epoch <= min_active {
1480                    let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
1481                    if tdir.exists() {
1482                        std::fs::remove_dir_all(&tdir)?;
1483                        reclaimed += 1;
1484                    }
1485                }
1486            }
1487        }
1488        drop(cat);
1489
1490        // Sweep stale _txn/<id>/ dirs on remaining live tables — but NEVER an
1491        // in-flight spill's dir (deleting it would lose the pending run and fail
1492        // the commit, review fix #14). Each `_txn/` subdir is named by its txn id;
1493        // skip any id still registered in `active_spills`.
1494        let cat = self.catalog.read();
1495        for entry in &cat.tables {
1496            if !matches!(entry.state, TableState::Live) {
1497                continue;
1498            }
1499            let txn_dir = self
1500                .root
1501                .join(TABLES_DIR)
1502                .join(entry.table_id.to_string())
1503                .join("_txn");
1504            if !txn_dir.exists() {
1505                continue;
1506            }
1507            for sub in std::fs::read_dir(&txn_dir)? {
1508                let sub = sub?;
1509                let name = sub.file_name();
1510                let Some(name) = name.to_str() else { continue };
1511                // A non-numeric entry can't belong to a live txn — sweep it.
1512                let is_active = name
1513                    .parse::<u64>()
1514                    .map(|id| self.active_spills.is_active(id))
1515                    .unwrap_or(false);
1516                if is_active {
1517                    continue;
1518                }
1519                std::fs::remove_dir_all(sub.path())?;
1520                reclaimed += 1;
1521            }
1522        }
1523        drop(cat);
1524
1525        // Reap compaction-superseded runs whose retire epoch no pinned snapshot
1526        // can still need (spec §6.4). Each table deletes its own retired files
1527        // gated on `min_active` and persists its manifest.
1528        let tables = self.tables.read();
1529        for handle in tables.values() {
1530            reclaimed += handle.lock().reap_retiring(Epoch(min_active))?;
1531        }
1532
1533        // WAL-segment GC (spec §6.4/§16). `SharedWal::open` mints a fresh active
1534        // segment on every reopen without truncating the prior ones, so rotated
1535        // segments accumulate. Once every live table's committed data is durable
1536        // in runs (no in-memory rows) and no in-flight spill is open, all rotated
1537        // (non-active) segments are redundant for recovery and safe to delete —
1538        // an in-flight txn only ever appends to the active segment, which is
1539        // never deleted.
1540        let all_durable = self.active_spills.is_idle()
1541            && tables.values().all(|h| {
1542                let g = h.lock();
1543                g.memtable_len() == 0 && g.mutable_run_len() == 0
1544            });
1545        drop(tables);
1546        if all_durable {
1547            reclaimed += self.shared_wal.lock().gc_segments(u64::MAX)?;
1548        }
1549
1550        Ok(reclaimed)
1551    }
1552    fn alloc_txn_id(&self) -> u64 {
1553        let mut g = self.next_txn_id.lock();
1554        let id = *g;
1555        *g = g.wrapping_add(1);
1556        id
1557    }
1558
1559    /// Set the per-table spill threshold (bytes). When a transaction's staged
1560    /// bytes for a single table exceed this, the rows are written as a
1561    /// uniform-epoch pending run instead of streamed Put records (spec §8.5).
1562    pub fn set_spill_threshold(&self, bytes: u64) {
1563        self.spill_threshold
1564            .store(bytes, std::sync::atomic::Ordering::Relaxed);
1565    }
1566
1567    /// Test-only: install a hook invoked after a transaction writes its spill
1568    /// runs but before the sequencer, so a test can race `gc()` against an
1569    /// in-flight spill. Not part of the stable API.
1570    #[doc(hidden)]
1571    pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
1572        *self.spill_hook.lock() = Some(Box::new(f));
1573    }
1574
1575    /// Number of WAL fsyncs issued so far (test/diagnostic). With group commit
1576    /// this stays well below the number of committed transactions when commits
1577    /// are concurrent (one leader fsync covers a whole batch — spec §9.3).
1578    #[doc(hidden)]
1579    pub fn __wal_group_sync_count(&self) -> u64 {
1580        self.shared_wal.lock().group_sync_count()
1581    }
1582
1583    /// Force the poisoned state (test-only) to verify the §9.3e fail-fast
1584    /// contract that an fsync error would trigger in production.
1585    #[doc(hidden)]
1586    pub fn __poison(&self) {
1587        self.poisoned
1588            .store(true, std::sync::atomic::Ordering::Relaxed);
1589    }
1590
1591    /// Verify multi-table integrity (spec §16). For every live table this:
1592    /// authenticates the manifest; opens each `RunRef`'s file through
1593    /// [`RunReader`](crate::sorted_run::RunReader), which verifies the run footer
1594    /// checksum and — for encrypted DBs — the keyed run-metadata MAC; checks each
1595    /// run's physical row count against its `RunRef`; flags `RunRef`s whose file
1596    /// is missing (dangling) and `.sr` files on disk that no `RunRef` references
1597    /// (orphan); and verifies `flushed_epoch <= current_epoch`. Returns the list
1598    /// of issues found (empty = healthy). Orphans are `warning`-severity; all
1599    /// other findings are `error`-severity (so [`Self::doctor`] quarantines them).
1600    ///
1601    /// Cost: O(total run bytes) — the footer checksum is verified over each run's
1602    /// full body, so this is an integrity tool, not a hot path.
1603    pub fn check(&self) -> Vec<CheckIssue> {
1604        let mut issues = Vec::new();
1605        let cat = self.catalog.read();
1606        let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
1607        for entry in &cat.tables {
1608            if !matches!(entry.state, TableState::Live) {
1609                continue;
1610            }
1611            let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
1612            let mut err = |sev: &str, desc: String| {
1613                issues.push(CheckIssue {
1614                    table_id: entry.table_id,
1615                    table_name: entry.name.clone(),
1616                    severity: sev.into(),
1617                    description: desc,
1618                });
1619            };
1620            let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
1621                Ok(m) => m,
1622                Err(e) => {
1623                    err("error", format!("manifest read failed: {e}"));
1624                    continue;
1625                }
1626            };
1627            if m.flushed_epoch > m.current_epoch {
1628                err(
1629                    "error",
1630                    format!(
1631                        "flushed_epoch {} exceeds current_epoch {} (impossible)",
1632                        m.flushed_epoch, m.current_epoch
1633                    ),
1634                );
1635            }
1636
1637            let runs_dir = tdir.join(crate::engine::RUNS_DIR);
1638            let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
1639            for rr in &m.runs {
1640                referenced.insert(rr.run_id);
1641                let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
1642                if !run_path.exists() {
1643                    err("error", format!("missing run file: r-{}.sr", rr.run_id));
1644                    continue;
1645                }
1646                match crate::sorted_run::RunReader::open(
1647                    &run_path,
1648                    entry.schema.clone(),
1649                    self.kek.clone(),
1650                ) {
1651                    Ok(reader) => {
1652                        if reader.row_count() as u64 != rr.row_count {
1653                            err(
1654                                "error",
1655                                format!(
1656                                    "run r-{} row count mismatch: manifest {} vs run {}",
1657                                    rr.run_id,
1658                                    rr.row_count,
1659                                    reader.row_count()
1660                                ),
1661                            );
1662                        }
1663                    }
1664                    Err(e) => {
1665                        err(
1666                            "error",
1667                            format!("run r-{} integrity check failed: {e}", rr.run_id),
1668                        );
1669                    }
1670                }
1671            }
1672
1673            // Compaction-superseded runs awaiting retention-gated deletion are
1674            // tracked in `retiring`; their files are expected on disk, so they
1675            // are not orphans.
1676            for r in &m.retiring {
1677                referenced.insert(r.run_id);
1678            }
1679
1680            // Orphan `.sr` files present on disk but absent from the manifest.
1681            if let Ok(rd) = std::fs::read_dir(&runs_dir) {
1682                for ent in rd.flatten() {
1683                    let p = ent.path();
1684                    if p.extension().and_then(|s| s.to_str()) != Some("sr") {
1685                        continue;
1686                    }
1687                    let run_id = p
1688                        .file_stem()
1689                        .and_then(|s| s.to_str())
1690                        .and_then(|s| s.strip_prefix("r-"))
1691                        .and_then(|s| s.parse::<u128>().ok());
1692                    if let Some(id) = run_id {
1693                        if !referenced.contains(&id) {
1694                            err(
1695                                "warning",
1696                                format!("orphan run file r-{id}.sr not referenced by the manifest"),
1697                            );
1698                        }
1699                    }
1700                }
1701            }
1702        }
1703
1704        // WAL retention / integrity invariant (spec §16): every on-disk WAL
1705        // segment must open (header magic + version, and the frame cipher must
1706        // be derivable for an encrypted WAL). A segment that won't open is
1707        // corrupt or truncated and would break crash recovery. `table_id` is
1708        // the reserved `WAL_TABLE_ID` sentinel (u64::MAX) so [`Self::doctor`]
1709        // never confuses a WAL issue with a real table.
1710        for (seg, msg) in self.shared_wal.lock().verify_segments() {
1711            issues.push(CheckIssue {
1712                table_id: WAL_TABLE_ID,
1713                table_name: "<wal>".into(),
1714                severity: "error".into(),
1715                description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
1716            });
1717        }
1718        issues
1719    }
1720
1721    /// Quarantine unreadable tables (spec §16). Moves corrupt table dirs to
1722    /// `_quarantine/<table_id>/`, marks them dropped in the catalog, and
1723    /// unmounts them from the live table map so the DB still opens.
1724    pub fn doctor(&self) -> Result<Vec<u64>> {
1725        // Hold the DDL lock for the whole operation to prevent concurrent
1726        // create_table/drop_table from racing the catalog/dir mutation.
1727        let _ddl = self.ddl_lock.lock();
1728        let issues = self.check();
1729        // A corrupt WAL segment is reported as an error but is NOT a table
1730        // problem — quarantining an innocent table cannot fix it (and the first
1731        // real table is id 0, so the WAL sentinel WAL_TABLE_ID = u64::MAX keeps
1732        // them disjoint). The admin must address WAL corruption manually.
1733        let bad_tables: std::collections::HashSet<u64> = issues
1734            .iter()
1735            .filter(|i| i.severity == "error" && i.table_id != WAL_TABLE_ID)
1736            .map(|i| i.table_id)
1737            .collect();
1738        if bad_tables.is_empty() {
1739            return Ok(Vec::new());
1740        }
1741
1742        let qdir = self.root.join("_quarantine");
1743        std::fs::create_dir_all(&qdir)?;
1744        let mut quarantined = Vec::new();
1745        for &table_id in &bad_tables {
1746            let tdir = self.root.join(TABLES_DIR).join(table_id.to_string());
1747            if tdir.exists() {
1748                let dest = qdir.join(table_id.to_string());
1749                std::fs::rename(&tdir, &dest)?;
1750            }
1751            {
1752                let mut cat = self.catalog.write();
1753                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
1754                    entry.state = TableState::Dropped {
1755                        at_epoch: self.epoch.visible().0,
1756                    };
1757                }
1758            }
1759            // Unmount the live handle so no further access reaches the moved dir.
1760            self.tables.write().remove(&table_id);
1761            quarantined.push(table_id);
1762        }
1763        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1764        Ok(quarantined)
1765    }
1766
1767    /// The DB-wide KEK (if encrypted).
1768    #[allow(dead_code)]
1769    pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
1770        self.kek.as_ref()
1771    }
1772
1773    /// Shared epoch authority (used by the transaction layer in P2).
1774    #[allow(dead_code)]
1775    pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
1776        &self.epoch
1777    }
1778
1779    /// Shared snapshot registry (used by GC in P3.6).
1780    #[allow(dead_code)]
1781    pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
1782        &self.snapshots
1783    }
1784}
1785
1786/// Two-pass, `flushed_epoch`-gated recovery of the shared WAL (spec §15).
1787///
1788/// Pass 1 scans every `TxnCommit` marker and records `txn_id → commit_epoch`
1789/// (the per-txn outcome; aborted / in-flight / torn-tail txns are absent). Pass
1790/// 2 applies each committed data record (Put/Delete) to its table at the commit
1791/// epoch, skipping records whose `commit_epoch <= table.flushed_epoch` (already
1792/// durable in a sorted run). Finally the shared epoch authority is raised to the
1793/// max committed epoch so the next commit continues monotonically.
1794fn recover_shared_wal(
1795    root: &Path,
1796    tables: &HashMap<u64, TableHandle>,
1797    epoch: &EpochAuthority,
1798    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
1799) -> Result<()> {
1800    use crate::memtable::Row;
1801    use crate::rowid::RowId;
1802    use crate::wal::{Op, SharedWal};
1803
1804    let records = SharedWal::replay_with_dek(root, wal_dek)?;
1805
1806    // Pass 1: committed-txn outcomes + collect spilled-run info.
1807    let mut committed: HashMap<u64, u64> = HashMap::new();
1808    let mut spilled_to_link: Vec<(
1809        u64, /*txn_id*/
1810        u64, /*epoch*/
1811        Vec<crate::wal::AddedRun>,
1812    )> = Vec::new();
1813    for r in &records {
1814        if let Op::TxnCommit {
1815            epoch: ce,
1816            ref added_runs,
1817        } = r.op
1818        {
1819            committed.insert(r.txn_id, ce);
1820            if !added_runs.is_empty() {
1821                spilled_to_link.push((r.txn_id, ce, added_runs.clone()));
1822            }
1823        }
1824    }
1825
1826    // Pass 2: stage data per table, gated by flushed_epoch.
1827    type TableStage = (Vec<Row>, Vec<(RowId, Epoch)>, Option<Epoch>, Epoch);
1828    let mut stage: HashMap<u64, TableStage> = HashMap::new();
1829    let mut max_epoch = epoch.visible().0;
1830    for r in records {
1831        let Some(&ce) = committed.get(&r.txn_id) else {
1832            continue; // aborted / in-flight — discard
1833        };
1834        let commit_epoch = Epoch(ce);
1835        max_epoch = max_epoch.max(ce);
1836        match r.op {
1837            Op::Put { table_id, rows } => {
1838                // Skip if this table already flushed past the commit epoch.
1839                let skip = tables
1840                    .get(&table_id)
1841                    .map(|h| h.lock().flushed_epoch() >= ce)
1842                    .unwrap_or(true);
1843                if skip {
1844                    continue;
1845                }
1846                let rows: Vec<Row> = match bincode::deserialize(&rows) {
1847                    Ok(v) => v,
1848                    Err(_) => continue,
1849                };
1850                // Re-stamp each row at the txn commit epoch (rows are pre-stamped
1851                // at pending_epoch which equals the commit epoch, but be robust).
1852                let rows: Vec<Row> = rows
1853                    .into_iter()
1854                    .map(|mut row| {
1855                        row.committed_epoch = commit_epoch;
1856                        row
1857                    })
1858                    .collect();
1859                let entry = stage
1860                    .entry(table_id)
1861                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
1862                entry.0.extend(rows);
1863                entry.3 = commit_epoch;
1864            }
1865            Op::Delete { table_id, row_ids } => {
1866                let skip = tables
1867                    .get(&table_id)
1868                    .map(|h| h.lock().flushed_epoch() >= ce)
1869                    .unwrap_or(true);
1870                if skip {
1871                    continue;
1872                }
1873                let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
1874                let entry = stage
1875                    .entry(table_id)
1876                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
1877                entry.1.extend(dels);
1878                entry.3 = commit_epoch;
1879            }
1880            Op::TruncateTable { table_id } => {
1881                let skip = tables
1882                    .get(&table_id)
1883                    .map(|h| h.lock().flushed_epoch() >= ce)
1884                    .unwrap_or(true);
1885                if skip {
1886                    continue;
1887                }
1888                stage.insert(
1889                    table_id,
1890                    (Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
1891                );
1892            }
1893            _ => {}
1894        }
1895    }
1896
1897    for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
1898        let Some(handle) = tables.get(&table_id) else {
1899            continue;
1900        };
1901        let mut t = handle.lock();
1902        if let Some(epoch) = truncate_epoch {
1903            t.apply_truncate(epoch)?;
1904        }
1905        t.recover_apply(rows, deletes)?;
1906        if truncate_epoch.is_some() {
1907            let rows = t.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
1908            t.live_count = rows.len() as u64;
1909            t.persist_manifest(table_epoch)?;
1910        }
1911    }
1912
1913    // Pass 3: link spilled runs from committed txns (spec §8.5). A crash
1914    // between TxnCommit sync and the publish phase leaves the run in
1915    // `_txn/<txn_id>/`. Move it to `_runs/` and add the RunRef.
1916    for (txn_id, ce, added_runs) in &spilled_to_link {
1917        for ar in added_runs {
1918            let Some(handle) = tables.get(&ar.table_id) else {
1919                continue;
1920            };
1921            let mut t = handle.lock();
1922            let dest = t.run_path(ar.run_id as u64);
1923            if !dest.exists() {
1924                let pending = root
1925                    .join(TABLES_DIR)
1926                    .join(ar.table_id.to_string())
1927                    .join("_txn")
1928                    .join(txn_id.to_string())
1929                    .join(format!("r-{}.sr", ar.run_id));
1930                if pending.exists() {
1931                    if let Some(parent) = pending.parent() {
1932                        std::fs::rename(&pending, &dest)?;
1933                        let _ = std::fs::remove_dir_all(parent);
1934                    }
1935                }
1936            }
1937            // Only link a run whose file is actually present, and never re-link
1938            // one the publish phase already persisted into the manifest (which is
1939            // the common clean-reopen case, since the `TxnCommit` lives in the WAL
1940            // until segment GC). `recover_spilled_run` is idempotent + reconciles
1941            // `live_count`/indexes only when the run is genuinely new.
1942            if t.run_path(ar.run_id as u64).exists() {
1943                t.recover_spilled_run(crate::manifest::RunRef {
1944                    run_id: ar.run_id,
1945                    level: ar.level,
1946                    epoch_created: *ce,
1947                    row_count: ar.row_count,
1948                });
1949            }
1950        }
1951    }
1952
1953    epoch.advance_recovered(Epoch(max_epoch));
1954    Ok(())
1955}
1956
1957/// Replay committed `Op::Ddl` records from the shared WAL into the catalog
1958/// (spec §15, review fix #16). A crash between WAL group-sync and the catalog
1959/// checkpoint leaves DDL durable in the WAL but absent from the on-disk
1960/// catalog. This pass closes that window by reconstructing missing entries
1961/// (and marking committed drops) before tables are mounted.
1962fn recover_ddl_from_wal(
1963    root: &Path,
1964    cat: &mut Catalog,
1965    meta_dek: Option<&[u8; META_DEK_LEN]>,
1966    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
1967) -> Result<()> {
1968    use crate::wal::{DdlOp, Op, SharedWal};
1969
1970    let records = match SharedWal::replay_with_dek(root, wal_dek) {
1971        Ok(r) => r,
1972        Err(_) => return Ok(()),
1973    };
1974
1975    let mut committed: HashMap<u64, u64> = HashMap::new();
1976    for r in &records {
1977        if let Op::TxnCommit { epoch: ce, .. } = r.op {
1978            committed.insert(r.txn_id, ce);
1979        }
1980    }
1981
1982    let mut changed = false;
1983    for r in records {
1984        let Some(&ce) = committed.get(&r.txn_id) else {
1985            continue;
1986        };
1987        match r.op {
1988            Op::Ddl(DdlOp::CreateTable {
1989                table_id,
1990                ref name,
1991                ref schema_json,
1992            }) => {
1993                if cat.tables.iter().any(|t| t.table_id == table_id) {
1994                    continue;
1995                }
1996                let schema = DdlOp::decode_schema(schema_json)?;
1997                let tdir = root.join(TABLES_DIR).join(table_id.to_string());
1998                if !tdir.exists() {
1999                    std::fs::create_dir_all(tdir.join(crate::engine::WAL_DIR))?;
2000                    std::fs::create_dir_all(tdir.join(crate::engine::RUNS_DIR))?;
2001                    crate::engine::write_schema(&tdir, &schema)?;
2002                    // The DB-wide meta DEK is also the per-table manifest meta
2003                    // DEK (both derive from the KEK via `derive_meta_key`), so a
2004                    // reconstructed manifest must be sealed with it — otherwise
2005                    // the follow-up `Table::open_in` cannot authenticate it on an
2006                    // encrypted DB and the table becomes permanently unopenable.
2007                    let mut m = crate::manifest::Manifest::new(table_id, schema.schema_id);
2008                    crate::manifest::write_atomic(&tdir, &mut m, meta_dek)?;
2009                }
2010                cat.tables.push(CatalogEntry {
2011                    table_id,
2012                    name: name.clone(),
2013                    schema,
2014                    state: TableState::Live,
2015                    created_epoch: ce,
2016                });
2017                cat.next_table_id = cat.next_table_id.max(table_id + 1);
2018                changed = true;
2019            }
2020            Op::Ddl(DdlOp::DropTable { table_id }) => {
2021                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
2022                    if matches!(entry.state, TableState::Live) {
2023                        entry.state = TableState::Dropped { at_epoch: ce };
2024                        changed = true;
2025                    }
2026                }
2027            }
2028            Op::Ddl(DdlOp::RenameTable {
2029                table_id,
2030                ref new_name,
2031            }) => {
2032                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
2033                    if entry.name != *new_name {
2034                        entry.name = new_name.clone();
2035                        changed = true;
2036                    }
2037                }
2038                // If the entry is absent, its CreateTable was already
2039                // checkpointed carrying the post-rename name, so there is
2040                // nothing to apply — a no-op, not an error.
2041            }
2042            Op::Ddl(DdlOp::AlterTable {
2043                table_id,
2044                ref column_json,
2045            }) => {
2046                let column = DdlOp::decode_column(column_json)?;
2047                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
2048                    if apply_recovered_column_def(&mut entry.schema, column) {
2049                        let tdir = root.join(TABLES_DIR).join(table_id.to_string());
2050                        if tdir.exists() {
2051                            crate::engine::write_schema(&tdir, &entry.schema)?;
2052                        }
2053                        changed = true;
2054                    }
2055                }
2056            }
2057            _ => {}
2058        }
2059    }
2060
2061    if changed {
2062        catalog::write_atomic(root, cat, meta_dek)?;
2063    }
2064    Ok(())
2065}
2066
2067fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> bool {
2068    match schema.columns.iter_mut().find(|c| c.id == column.id) {
2069        Some(existing) if *existing == column => false,
2070        Some(existing) => {
2071            *existing = column;
2072            schema.schema_id = schema.schema_id.saturating_add(1);
2073            true
2074        }
2075        None => {
2076            schema.columns.push(column);
2077            schema.schema_id = schema.schema_id.saturating_add(1);
2078            true
2079        }
2080    }
2081}
2082
2083/// Sweep stale `_txn/<txn_id>/` dirs from every table (spec §8.5, review fix
2084/// #14). These dirs hold pending uniform-epoch runs from large transactions
2085/// that were aborted or crashed before commit. On open, all such dirs are safe
2086/// to remove — committed txns moved their runs to `_runs/` at publish time.
2087fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
2088    for entry in &cat.tables {
2089        let txn_dir = root
2090            .join(TABLES_DIR)
2091            .join(entry.table_id.to_string())
2092            .join("_txn");
2093        if txn_dir.exists() {
2094            let _ = std::fs::remove_dir_all(&txn_dir);
2095        }
2096    }
2097}