Skip to main content

mongreldb_core/
database.rs

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