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