Skip to main content

powdb_storage/catalog/
mod.rs

1use crate::btree::{BTree, IndexStats};
2use crate::error::StorageError;
3use crate::heap::{DirtyPageBudget, HeapFile};
4use crate::page::{UpdateFit, OVERFLOW_CHAIN_END, OVERFLOW_PAYLOAD_CAP};
5use crate::row::{encode_row_into, encode_row_v2_into, plan_spill, OverflowStub, MAX_VALUE_SIZE};
6use crate::stored_json_path::{StoredJsonPathSegmentV1, StoredJsonPathV1};
7use crate::table::Table;
8use crate::types::*;
9use crate::wal::{Wal, WalDurabilityTicket, WalRecord, WalRecordType, WalSyncMode};
10use rustc_hash::FxHashMap;
11use std::fs;
12use std::io::{self, Read, Write};
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::Arc;
16use tracing::{info, warn};
17
18static NEXT_STRUCTURE_GENERATION: AtomicU64 = AtomicU64::new(1);
19
20fn next_structure_generation() -> u64 {
21    NEXT_STRUCTURE_GENERATION.fetch_add(1, Ordering::Relaxed)
22}
23
24/// Reject an encoded row that exceeds the single-page capacity BEFORE it is
25/// appended to the WAL. The heap performs the same check at its own insert/
26/// update boundary, but the update paths log to the WAL first — a logged
27/// record whose row the heap then rejects would poison the next replay.
28fn check_encoded_row_size(encoded: &[u8]) -> io::Result<()> {
29    if encoded.len() > crate::page::MAX_ROW_DATA_SIZE {
30        return Err(crate::error::StorageError::RowTooLarge {
31            size: encoded.len(),
32            max: crate::page::MAX_ROW_DATA_SIZE,
33        }
34        .into());
35    }
36    Ok(())
37}
38
39/// Validate that a name (table or column) is safe for use in file paths and
40/// follows the identifier convention: starts with a letter or underscore,
41/// followed by letters, digits, or underscores.
42fn validate_identifier(kind: &str, name: &str) -> io::Result<()> {
43    if name.is_empty() {
44        return Err(io::Error::new(
45            io::ErrorKind::InvalidInput,
46            format!("invalid {kind} name: must not be empty"),
47        ));
48    }
49    let mut chars = name.chars();
50    // Infallible: we returned early if `name.is_empty()` above.
51    let first = chars.next().expect("non-empty name");
52    if !first.is_ascii_alphabetic() && first != '_' {
53        return Err(io::Error::new(
54            io::ErrorKind::InvalidInput,
55            format!("invalid {kind} name '{name}': must start with a letter or underscore"),
56        ));
57    }
58    for ch in chars {
59        if !ch.is_ascii_alphanumeric() && ch != '_' {
60            return Err(io::Error::new(
61                io::ErrorKind::InvalidInput,
62                format!(
63                    "invalid {kind} name '{name}': must contain only letters, digits, and underscores"
64                ),
65            ));
66        }
67    }
68    Ok(())
69}
70
71/// Validate a table name for path safety.
72fn validate_table_name(name: &str) -> io::Result<()> {
73    validate_identifier("table", name)
74}
75
76/// Validate a column name for path safety.
77fn validate_column_name(name: &str) -> io::Result<()> {
78    validate_identifier("column", name)
79}
80
81/// On-disk catalog file: lists every table's schema so we can reopen them
82/// after a restart. Format is a small custom binary blob (no serde dep).
83///
84/// Mission 3: version 2 appends a per-table list of indexed column names
85/// after the column list, so indexes can be rehydrated on `Catalog::open`.
86/// Version 1 files still load cleanly — they're treated as having zero
87/// indexed columns, and the next `create_index` (or implicit rebuild on
88/// first open, depending on the caller) will populate the list.
89const CATALOG_FILE: &str = "catalog.bin";
90pub const CATALOG_LSN_FILE: &str = "catalog.lsn";
91const CATALOG_MAGIC: &[u8; 4] = b"BCAT";
92/// Version 4 appends a per-table column-defaults section after the indexed
93/// column list; version 5 appends an auto-increment column section after that.
94/// Older files load cleanly (no defaults / no auto columns).
95pub const LEGACY_CATALOG_VERSION: u16 = 5;
96/// Version 6 (activated lazily since v0.13.0) appends the expression-index
97/// section plus a next-index-id header field. A database that declares an
98/// expression index but no relationship link stays at exactly this version.
99pub const EXPRESSION_INDEX_CATALOG_VERSION: u16 = 6;
100/// Version 7 appends a relationship-link section after the table entries (and
101/// after each table's expression indexes) and before the trailing CRC. It
102/// activates lazily on the first `create_link`, mirroring how v6 activates on
103/// the first expression index; a link-free database stays byte-for-byte a v6
104/// (or older) file forever.
105pub const CATALOG_VERSION: u16 = 7;
106
107/// Persisted metadata for a JSON-path expression index. Expression index files
108/// are addressed only by `index_id`; canonical expression text never reaches a
109/// filesystem path.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct ExpressionIndexMeta {
112    pub index_id: u64,
113    pub unique: bool,
114    pub canonical_version: u16,
115    pub canonical_text: String,
116    pub json_path: StoredJsonPathV1,
117}
118
119/// Cardinality of a relationship link: whether the target key is backed by a
120/// unique index/constraint. The fact lives in the index metadata and nowhere
121/// else, and [`Catalog::derive_link_kind`] is the only thing that reads it.
122///
123/// The engine keeps no cached copy of the answer. The `u8` written into
124/// [`LinkDef::kind`] is an advisory record of what the derivation returned when
125/// the link was declared: it stays in the v7 catalog format so old and new
126/// files stay byte-compatible, it is never resynced, and nothing in the engine
127/// may branch on it. See `docs/FORMAT.md`.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum LinkKind {
130    /// N:1 scalar hop — the target key is unique, so a hop resolves to at most
131    /// one target row (`0` on disk).
132    ToOne,
133    /// 1:N nested block — the target key is non-unique, so a hop can fan out to
134    /// many target rows (`1` on disk).
135    ToMany,
136}
137
138impl LinkKind {
139    fn to_u8(self) -> u8 {
140        match self {
141            LinkKind::ToOne => 0,
142            LinkKind::ToMany => 1,
143        }
144    }
145
146    fn from_u8(tag: u8) -> io::Result<Self> {
147        match tag {
148            0 => Ok(LinkKind::ToOne),
149            1 => Ok(LinkKind::ToMany),
150            other => Err(io::Error::new(
151                io::ErrorKind::InvalidData,
152                format!("unknown link kind tag: {other}"),
153            )),
154        }
155    }
156}
157
158/// Persisted relationship-link metadata. A link is a read-only naming layer over
159/// columns that already exist: it names a traversal path (`owner.name -> target`)
160/// resolved through `local_key = target_key`. Links add no storage and enforce no
161/// referential integrity on write; dropping a referenced table or column is
162/// refused while the link exists (the same discipline indexes use).
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct LinkDef {
165    /// Owner table the link is declared on. Registry key part 1.
166    pub owner_type: String,
167    /// Traversal name (`o.<name>...`). Unique per owner type. Registry key part 2.
168    pub name: String,
169    /// Target table the link resolves to. Must exist at declare time.
170    pub target_type: String,
171    /// Column on the owner supplying the join value.
172    pub local_key: String,
173    /// Column on the target matched against `local_key`.
174    pub target_key: String,
175    /// ADVISORY ONLY. Whatever [`Catalog::derive_link_kind`] returned at the
176    /// moment the link was declared, kept so the v7 on-disk layout does not
177    /// change. It is deliberately never refreshed, so it goes stale as soon as
178    /// `alter <Target> add unique .<key>` runs after the link, and it is wrong
179    /// in every database written by the declare-order-dependent versions.
180    ///
181    /// Never branch on this field. Every correctness decision (traversal gates,
182    /// `describe`, `schema links`) must call [`Catalog::link_kind`] or
183    /// [`Catalog::derive_link_kind`], which read index uniqueness live.
184    pub kind: LinkKind,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum IndexKeySource {
189    Column {
190        column: String,
191    },
192    Expression {
193        index_id: u64,
194        canonical_version: u16,
195        canonical_text: String,
196        json_path: StoredJsonPathV1,
197    },
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct IndexMetadata {
202    pub unique: bool,
203    pub source: IndexKeySource,
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum IndexOrderDirection {
208    Asc,
209    Desc,
210}
211
212/// Expression-index artifacts live in a filename namespace disjoint from
213/// legacy column indexes. Column indexes always end in `.idx`; expression
214/// indexes always end in `.eidx`. Keeping the extension distinct prevents a
215/// table/column underscore decomposition from ever aliasing an expression ID.
216pub fn expression_index_file_name(table: &str, index_id: u64) -> String {
217    format!("{table}_{index_id}.eidx")
218}
219
220/// Mission 2 (durability): the single shared WAL file lives under the catalog's
221/// data directory with this name. One WAL covers every table in the catalog.
222const WAL_FILE: &str = "wal.log";
223const SYNC_STATE_DIR: &str = ".powdb-sync";
224const SYNC_IDENTITY_FILE: &str = "identity.json";
225
226/// WAL batch size: flush auto-triggers after this many records, in addition
227/// to the explicit `wal.flush()` each top-level mutation does. Kept small so
228/// the tests see a predictable amount of buffering.
229const WAL_BATCH_SIZE: usize = 64;
230type WalArchiveCallback<'a> = &'a mut dyn FnMut(&Path, &[WalRecord]) -> io::Result<()>;
231
232fn read_durable_lsn(data_dir: &Path) -> io::Result<u64> {
233    let path = data_dir.join(CATALOG_LSN_FILE);
234    let bytes = match fs::read(path) {
235        Ok(bytes) => bytes,
236        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(0),
237        Err(err) => return Err(err),
238    };
239    if bytes.len() != 8 {
240        return Err(io::Error::new(
241            io::ErrorKind::InvalidData,
242            "catalog LSN sidecar has invalid length",
243        ));
244    }
245    let mut buf = [0u8; 8];
246    buf.copy_from_slice(&bytes);
247    Ok(u64::from_le_bytes(buf))
248}
249
250fn write_durable_lsn(data_dir: &Path, lsn: u64) -> io::Result<()> {
251    let path = data_dir.join(CATALOG_LSN_FILE);
252    let tmp_path = data_dir.join(format!("{CATALOG_LSN_FILE}.tmp"));
253    let mut file = fs::File::create(&tmp_path)?;
254    file.write_all(&lsn.to_le_bytes())?;
255    file.sync_all()?;
256    drop(file);
257    fs::rename(&tmp_path, &path)?;
258    sync_directory(data_dir)?;
259    Ok(())
260}
261
262#[cfg(unix)]
263pub(crate) fn sync_directory(path: &Path) -> io::Result<()> {
264    fs::File::open(path)?.sync_all()
265}
266
267#[cfg(not(unix))]
268pub(crate) fn sync_directory(path: &Path) -> io::Result<()> {
269    let _ = path;
270    Ok(())
271}
272
273#[cfg(test)]
274thread_local! {
275    static CATALOG_PERSIST_FAILPOINT: std::cell::Cell<u8> = const { std::cell::Cell::new(0) };
276}
277
278#[cfg(test)]
279fn take_catalog_persist_failpoint(stage: u8) -> bool {
280    CATALOG_PERSIST_FAILPOINT.with(|failpoint| {
281        if failpoint.get() == stage {
282            failpoint.set(0);
283            true
284        } else {
285            false
286        }
287    })
288}
289
290enum CatalogPersistError {
291    BeforeActivation(io::Error),
292    AfterActivation(io::Error),
293}
294
295impl CatalogPersistError {
296    fn into_io_error(self) -> io::Error {
297        match self {
298            Self::BeforeActivation(error) | Self::AfterActivation(error) => error,
299        }
300    }
301}
302
303fn max_record_lsn(records: &[WalRecord]) -> Option<u64> {
304    records.iter().map(|record| record.lsn).max()
305}
306
307/// System catalog: registry of all tables.
308///
309/// Mission C Phase 18: tables live in a `Vec<Table>` addressed by a `slot`
310/// index, with a parallel `FxHashMap<String, usize>` for name-based resolution.
311/// DROP TABLE can move slots, so prepared fast paths pair a cached slot with the
312/// O(1) structural generation below and fall back when any DDL invalidates it.
313///
314/// Earlier design (pre-Phase 18) held tables in a `FxHashMap<String, Table>`
315/// directly. That meant the `insert_batch_1k` hot path paid an
316/// `FxHash("User")` + bucket walk per row just to dispatch into the
317/// table — about 20-40ns out of a 233ns budget.
318pub struct Catalog {
319    /// All tables, in insertion order. Indexed by `slot: usize`.
320    tables: Vec<Table>,
321    /// Name → slot index. Populated in sync with `tables` on every
322    /// `create_table` / `open`.
323    name_to_slot: FxHashMap<String, usize>,
324    data_dir: PathBuf,
325    /// Mission 2: shared write-ahead log owned by the catalog. Every
326    /// mutation (insert/update/delete) records its intent here BEFORE
327    /// touching the heap so a mid-write crash can be recovered from on the
328    /// next open. Flushed to disk at the end of every top-level op.
329    wal: Wal,
330    /// Monotonic transaction-id counter. Autocommit statements may allocate
331    /// multiple ids (one per row-level primitive), while explicit transactions
332    /// reuse one id for the whole BEGIN..COMMIT scope.
333    next_tx_id: u64,
334    /// Active explicit transaction id, if any. Owned by the connection/session
335    /// driving this catalog through `Engine`.
336    active_tx_id: Option<u64>,
337    /// Durable WAL byte offset captured at BEGIN. ROLLBACK truncates back to
338    /// this boundary so auto-flushed uncommitted records cannot replay later.
339    tx_start_len: Option<u64>,
340    /// Autocommit row-mutation tx ids appended since the previous group commit.
341    /// `commit_autocommit` writes commit markers for these ids before fsync.
342    pending_autocommit_tx_ids: Vec<u64>,
343    /// Has this catalog been cleanly checkpointed at least once since it
344    /// was opened? Used by `Drop` to decide whether to treat its own flush
345    /// as fatal (it isn't — we still try best-effort).
346    checkpointed: bool,
347    /// Catalog-level durable LSN. Heap page LSNs cover row mutations, but
348    /// DDL-only changes can advance the WAL without touching a data page.
349    durable_lsn: u64,
350    /// Overflow-chain pages to return to their table's free list once the
351    /// current EXPLICIT transaction commits (design 3.6 pending-free list).
352    /// Populated only while `active_tx_id.is_some()`: a chain-replacing update
353    /// or a delete inside a transaction cannot free its old chain immediately,
354    /// because ROLLBACK resurrects the old row and its stub must still address a
355    /// live chain. Autocommit mutations free immediately (no rollback window).
356    /// Drained by `commit_transaction`; discarded (via reopen) by ROLLBACK.
357    /// Entries are `(table_slot, chain_pages)`.
358    pending_free_overflow: Vec<(usize, Vec<u32>)>,
359    /// Catalog format currently active on disk. v6 activates lazily on the
360    /// first successful expression-index metadata creation.
361    active_catalog_version: u16,
362    /// Global, durable, monotonically increasing expression-index identity.
363    next_index_id: u64,
364    /// Relationship-link registry, in declaration order (so serialization and
365    /// the `links()` iterator are deterministic). Keyed logically by
366    /// `(owner_type, name)`; uniqueness of that pair is enforced in
367    /// `create_link`. Populated from disk on `open`. v7 activates on the first
368    /// entry; a link-free catalog never writes the links section.
369    links: Vec<LinkDef>,
370    /// Process-local catalog structure identity. Any table/schema/default/
371    /// auto/index DDL replaces this token, invalidating cached prepared
372    /// metadata in O(1). Opening a replacement Catalog (including rollback)
373    /// also receives a fresh token.
374    structure_generation: u64,
375    /// True when opened via [`Catalog::open_read_only`] for snapshot serving.
376    /// The heap/index/WAL files are read-only handles, no LSN stamping or
377    /// overflow sweep ran at open, and [`Drop`] skips the checkpoint (which would
378    /// otherwise flush pages and truncate the WAL, mutating the directory).
379    read_only: bool,
380    /// Ceiling on unflushed heap pages, charged across every table here. An
381    /// explicit transaction that exceeds it is refused rather than allowed to
382    /// grow the per-table dirty buffers until the process is OOM-killed.
383    dirty_budget: Arc<DirtyPageBudget>,
384}
385
386impl Catalog {
387    /// Create a brand-new catalog. Wipes any existing catalog file in this directory.
388    ///
389    /// # Examples
390    ///
391    /// ```
392    /// use powdb_storage::catalog::Catalog;
393    /// use powdb_storage::types::{Schema, ColumnDef, TypeId};
394    ///
395    /// let dir = tempfile::tempdir().unwrap();
396    /// let mut catalog = Catalog::create(dir.path()).unwrap();
397    ///
398    /// let schema = Schema {
399    ///     table_name: "User".to_string(),
400    ///     columns: vec![
401    ///         ColumnDef { name: "name".to_string(), type_id: TypeId::Str, required: true, position: 0 },
402    ///         ColumnDef { name: "age".to_string(), type_id: TypeId::Int, required: false, position: 1 },
403    ///     ],
404    /// };
405    /// catalog.create_table(schema).unwrap();
406    /// ```
407    pub fn create(data_dir: &Path) -> io::Result<Self> {
408        crate::create_data_dir_secure(data_dir)?;
409        let wal_path = data_dir.join(WAL_FILE);
410        let wal = Wal::create(&wal_path, WAL_BATCH_SIZE)?;
411        let cat = Catalog {
412            tables: Vec::new(),
413            name_to_slot: FxHashMap::default(),
414            data_dir: data_dir.to_path_buf(),
415            wal,
416            next_tx_id: 1,
417            active_tx_id: None,
418            tx_start_len: None,
419            pending_autocommit_tx_ids: Vec::new(),
420            pending_free_overflow: Vec::new(),
421            checkpointed: false,
422            durable_lsn: 0,
423            active_catalog_version: LEGACY_CATALOG_VERSION,
424            next_index_id: 1,
425            links: Vec::new(),
426            structure_generation: next_structure_generation(),
427            read_only: false,
428            dirty_budget: Arc::new(DirtyPageBudget::default()),
429        };
430        cat.persist()?;
431        Ok(cat)
432    }
433
434    /// Open an existing catalog from disk, rehydrating every table. If no
435    /// catalog file is present this returns NotFound — callers can fall back
436    /// to `create` for a fresh data dir.
437    ///
438    /// Mission 2: after the per-table heap files are reopened, this replays
439    /// any records left in the WAL from a previous (crashed) session. The
440    /// WAL is then truncated once the replay lands cleanly on disk — that
441    /// re-establishes the "empty WAL = last shutdown was clean" invariant.
442    pub fn open(data_dir: &Path) -> io::Result<Self> {
443        Self::open_inner(data_dir, None)
444    }
445
446    /// Open an existing catalog and archive any replayed WAL records before
447    /// recovery truncates the WAL. This is for sync-aware callers that must
448    /// retain history needed by replicas.
449    ///
450    /// Replication boundary: this hook exists so `powdb-sync` can preserve WAL
451    /// history before storage recovery truncates it. Ordinary embedded/server
452    /// callers should use `open`; do not build application-level recovery flows
453    /// directly on this hook.
454    pub fn open_with_wal_archive<F>(data_dir: &Path, mut archive: F) -> io::Result<Self>
455    where
456        F: FnMut(&Path, &[WalRecord]) -> io::Result<()>,
457    {
458        let archive: WalArchiveCallback<'_> = &mut archive;
459        Self::open_inner(data_dir, Some(archive))
460    }
461
462    fn open_inner(data_dir: &Path, archive: Option<WalArchiveCallback<'_>>) -> io::Result<Self> {
463        let cat_path = data_dir.join(CATALOG_FILE);
464        if !cat_path.exists() {
465            return Err(io::Error::new(io::ErrorKind::NotFound, "no catalog file"));
466        }
467        let catalog_file = read_catalog_file(&cat_path)?;
468        let active_catalog_version = catalog_file.version;
469        let next_index_id = catalog_file.next_index_id;
470        let links = catalog_file.links;
471        let entries = catalog_file.entries;
472        let durable_lsn = read_durable_lsn(data_dir)?;
473        let mut tables: Vec<Table> = Vec::with_capacity(entries.len());
474        let mut name_to_slot =
475            FxHashMap::with_capacity_and_hasher(entries.len(), Default::default());
476        let dirty_budget = Arc::new(DirtyPageBudget::default());
477        for CatalogEntry {
478            schema,
479            indexed_cols,
480            expression_indexes: expression_metas,
481            defaults,
482            auto_cols,
483        } in entries
484        {
485            let name = schema.table_name.clone();
486            // Mission 3: rehydrate persisted indexes. `Table::open_with_indexes`
487            // tries to `BTree::load` each named index file; if a file is
488            // missing (e.g. first open after upgrade from catalog v1) it
489            // falls back to rebuilding from the heap scan and saving to
490            // disk so subsequent opens hit the fast path.
491            let mut table =
492                Table::open_with_indexes(schema, data_dir, &indexed_cols, &expression_metas)?;
493            table.heap.set_dirty_budget(Arc::clone(&dirty_budget));
494            table.set_defaults(defaults);
495            table.set_auto_cols(auto_cols);
496            name_to_slot.insert(name.clone(), tables.len());
497            tables.push(table);
498        }
499        let wal_path = data_dir.join(WAL_FILE);
500        let wal = Wal::open(&wal_path, WAL_BATCH_SIZE)?;
501        let mut cat = Catalog {
502            tables,
503            name_to_slot,
504            data_dir: data_dir.to_path_buf(),
505            wal,
506            next_tx_id: 1,
507            active_tx_id: None,
508            tx_start_len: None,
509            pending_autocommit_tx_ids: Vec::new(),
510            pending_free_overflow: Vec::new(),
511            checkpointed: false,
512            durable_lsn,
513            active_catalog_version,
514            next_index_id,
515            links,
516            structure_generation: next_structure_generation(),
517            read_only: false,
518            dirty_budget,
519        };
520        cat.replay_wal(archive)?;
521        // Restore WAL LSN monotonicity across the restart. Heap pages carry
522        // LSNs stamped by replay (catalog.rs set_page_lsn) and by DDL
523        // rewrites (stamp_all_pages_min_lsn), but `Wal::open` reset the
524        // counter to 1. If the next write reused an LSN <= a stamped page
525        // LSN, the following crash's replay would skip it as already-applied
526        // — the data-loss bug behind the v0.4.x yanks. This runs on every
527        // open (including the empty-WAL clean-shutdown path, where pages may
528        // still carry LSNs from an earlier recovery). LSNs must be monotonic
529        // across restarts.
530        let max_page_lsn = cat
531            .tables
532            .iter()
533            .map(|t| t.heap.max_page_lsn())
534            .max()
535            .unwrap_or(0);
536        let max_known_lsn = max_page_lsn.max(cat.durable_lsn);
537        cat.wal.set_next_lsn_at_least(max_known_lsn + 1);
538        // Auto-sweep overflow orphans after recovery: a crash is exactly when
539        // a chain page can end up flushed but referenced by no committed row
540        // (its Insert was uncommitted, or its Delete committed). Reclaim them
541        // now (design 3.6). Best-effort — a sweep failure must not block open.
542        if let Err(e) = cat.sweep_all() {
543            warn!(error = %e, "post-recovery overflow sweep failed (non-fatal)");
544        }
545        Ok(cat)
546    }
547
548    /// Open a catalog **read-only** for snapshot serving (tier 1 of the replica
549    /// story). This is for a *quiescent* directory: a restored backup or a
550    /// checkpointed replica, both guaranteed WAL-clean.
551    ///
552    /// Unlike [`Catalog::open`], this path:
553    /// - opens every heap and index file read-only (no writable descriptor);
554    /// - never calls `set_permissions` (it validates the directory instead);
555    /// - **refuses** a non-empty WAL rather than replaying and truncating it ,
556    ///   an unclean directory must be recovered by a read-write engine first;
557    /// - stamps no page LSNs, sweeps no overflow orphans, and truncates nothing.
558    ///
559    /// The result never mutates the directory, so N read-only processes can serve
560    /// the same snapshot concurrently.
561    pub fn open_read_only(data_dir: &Path) -> io::Result<Self> {
562        crate::validate_data_dir_read_only(data_dir)?;
563        let cat_path = data_dir.join(CATALOG_FILE);
564        if !cat_path.exists() {
565            return Err(io::Error::new(io::ErrorKind::NotFound, "no catalog file"));
566        }
567
568        // Refuse a non-empty WAL: the directory has un-checkpointed mutations
569        // that only a read-write open may safely replay. Naming the remedy keeps
570        // the operator from guessing.
571        let wal_path = data_dir.join(WAL_FILE);
572        if crate::wal::wal_has_committed_records(&wal_path)? {
573            return Err(io::Error::new(
574                io::ErrorKind::InvalidData,
575                "cannot open read-only: the WAL is not empty (the directory has \
576                 un-checkpointed writes). Open the directory once with a read-write \
577                 engine to recover, or restore from a backup, then serve it read-only",
578            ));
579        }
580
581        let catalog_file = read_catalog_file(&cat_path)?;
582        let active_catalog_version = catalog_file.version;
583        let next_index_id = catalog_file.next_index_id;
584        let links = catalog_file.links;
585        let entries = catalog_file.entries;
586        let durable_lsn = read_durable_lsn(data_dir)?;
587        let mut tables: Vec<Table> = Vec::with_capacity(entries.len());
588        let mut name_to_slot =
589            FxHashMap::with_capacity_and_hasher(entries.len(), Default::default());
590        let dirty_budget = Arc::new(DirtyPageBudget::default());
591        for CatalogEntry {
592            schema,
593            indexed_cols,
594            expression_indexes: expression_metas,
595            defaults,
596            auto_cols,
597        } in entries
598        {
599            let name = schema.table_name.clone();
600            let mut table = Table::open_with_indexes_read_only(
601                schema,
602                data_dir,
603                &indexed_cols,
604                &expression_metas,
605            )?;
606            table.heap.set_dirty_budget(Arc::clone(&dirty_budget));
607            table.set_defaults(defaults);
608            table.set_auto_cols(auto_cols);
609            name_to_slot.insert(name.clone(), tables.len());
610            tables.push(table);
611        }
612        let wal = Wal::open_read_only(&wal_path, WAL_BATCH_SIZE)?;
613        Ok(Catalog {
614            tables,
615            name_to_slot,
616            data_dir: data_dir.to_path_buf(),
617            wal,
618            next_tx_id: 1,
619            active_tx_id: None,
620            tx_start_len: None,
621            pending_autocommit_tx_ids: Vec::new(),
622            pending_free_overflow: Vec::new(),
623            checkpointed: false,
624            durable_lsn,
625            active_catalog_version,
626            next_index_id,
627            links,
628            structure_generation: next_structure_generation(),
629            read_only: true,
630            dirty_budget,
631        })
632    }
633
634    /// Replay every record currently buffered in the WAL file onto the open
635    /// tables. This is the recovery path: after a crash the heap files on
636    /// disk may be missing mutations that were logged to the WAL but never
637    /// written back to their pages. We re-apply every record unconditionally.
638    ///
639    /// **Idempotence:**
640    /// - `Delete`: idempotent — `HeapFile::delete` on an already-deleted or
641    ///   missing slot is a no-op.
642    /// - `Update`: idempotent — re-applies the same new row bytes to the
643    ///   same `RowId`, which either replaces the existing (already-updated)
644    ///   row with itself or lands the update for the first time.
645    /// - `Insert`: **NOT strictly idempotent**. `HeapFile::insert` allocates
646    ///   a fresh `RowId` on every call, so a row that was already flushed
647    ///   to disk will be re-inserted at a new location, producing a
648    ///   duplicate. See the mission report for the full caveat.
649    ///
650    /// The practical consequences are:
651    ///   1. On a "pure crash" (no heap pages ever flushed between open and
652    ///      crash), replay cleanly restores every logged row.
653    ///   2. On a crash where some heap pages were flushed by the hot-page
654    ///      eviction logic, replay may restore those rows a second time.
655    ///      A future mission can fix this with LSN-tagged pages.
656    ///
657    /// After a successful replay we truncate the WAL so the next shutdown
658    /// (crash or otherwise) replays only the NEW records.
659    fn replay_wal(&mut self, mut archive: Option<WalArchiveCallback<'_>>) -> io::Result<()> {
660        let records = self.wal.read_all()?;
661        if records.is_empty() {
662            return Ok(());
663        }
664        if archive.is_none() {
665            self.ensure_plain_wal_truncate_allowed(&records)?;
666        }
667        self.replay_records(&records)?;
668        if let Some(archive) = archive.as_mut() {
669            archive(&self.data_dir, &records)?;
670        }
671        self.wal.truncate()?;
672        Ok(())
673    }
674
675    /// Apply an LSN-preserving WAL record stream without appending it to the
676    /// local WAL. Sync callers must validate lineage and contiguity before
677    /// calling this method.
678    ///
679    /// Replication boundary: this is a storage adapter for `powdb-sync`, not a
680    /// general mutation API. Callers must reject unsupported record classes,
681    /// hold their own replica progress state, and pass only contiguous,
682    /// transaction-complete ranges or chunks.
683    pub fn apply_wal_records(&mut self, records: &[WalRecord]) -> io::Result<()> {
684        self.ensure_no_active_transaction_for_checkpoint()?;
685        self.ensure_no_pending_wal_records()?;
686        self.replay_records(records)
687    }
688
689    /// Sync callers use this before deciding an apply is a no-op. A replica with
690    /// local WAL history is divergent until a higher layer explicitly repairs it.
691    pub fn ensure_no_pending_wal_records(&self) -> io::Result<()> {
692        if self.wal.has_pending() || !self.wal.read_all()?.is_empty() {
693            return Err(io::Error::other(
694                "cannot apply replicated WAL records while local WAL records are pending",
695            ));
696        }
697        Ok(())
698    }
699
700    fn replay_records(&mut self, records: &[WalRecord]) -> io::Result<()> {
701        if records.is_empty() {
702            return Ok(());
703        }
704
705        info!(count = records.len(), "applying WAL records");
706
707        // Per-page LSN redo (ARIES-style). A record is already durable iff
708        // its *target page* carries an LSN >= the record's LSN. The previous
709        // implementation used a single per-table max LSN, which is unsafe:
710        // a low-LSN record on an unflushed page would be wrongly skipped
711        // because some other, flushed page of the same table advertised a
712        // higher LSN — silently dropping the record (one of the v0.4.x
713        // data-loss bugs). Every record now carries its real RowId (inserts
714        // included), so the target page is always known.
715        let has_boundaries = records.iter().any(|rec| {
716            matches!(
717                rec.record_type,
718                WalRecordType::Begin | WalRecordType::Commit | WalRecordType::Rollback
719            )
720        });
721        let mut committed_row_records = vec![true; records.len()];
722        if has_boundaries {
723            committed_row_records.fill(false);
724            let mut pending_tx_spans: Vec<(u64, Vec<usize>)> = Vec::new();
725            for (index, rec) in records.iter().enumerate() {
726                match rec.record_type {
727                    WalRecordType::Insert
728                    | WalRecordType::Update
729                    | WalRecordType::Delete
730                    | WalRecordType::OverflowWrite
731                    | WalRecordType::OverflowFree
732                        if rec.tx_id == 0 =>
733                    {
734                        committed_row_records[index] = true;
735                    }
736                    WalRecordType::Insert
737                    | WalRecordType::Update
738                    | WalRecordType::Delete
739                    | WalRecordType::OverflowWrite
740                    | WalRecordType::OverflowFree => {
741                        if let Some((_, rows)) = pending_tx_spans
742                            .iter_mut()
743                            .rev()
744                            .find(|(tx_id, _)| *tx_id == rec.tx_id)
745                        {
746                            rows.push(index);
747                        } else {
748                            pending_tx_spans.push((rec.tx_id, vec![index]));
749                        }
750                    }
751                    WalRecordType::Begin if rec.tx_id != 0 => {
752                        pending_tx_spans.push((rec.tx_id, Vec::new()));
753                    }
754                    WalRecordType::Commit if rec.tx_id != 0 => {
755                        if let Some(span_index) = pending_tx_spans
756                            .iter()
757                            .rposition(|(tx_id, _)| *tx_id == rec.tx_id)
758                        {
759                            let (_, rows) = pending_tx_spans.remove(span_index);
760                            for row_index in rows {
761                                committed_row_records[row_index] = true;
762                            }
763                        }
764                    }
765                    WalRecordType::Rollback if rec.tx_id != 0 => {
766                        if let Some(span_index) = pending_tx_spans
767                            .iter()
768                            .rposition(|(tx_id, _)| *tx_id == rec.tx_id)
769                        {
770                            pending_tx_spans.remove(span_index);
771                        }
772                    }
773                    _ => {}
774                }
775            }
776        }
777
778        let mut replayed_inserts = 0usize;
779        let mut replayed_updates = 0usize;
780        let mut replayed_deletes = 0usize;
781        let mut skipped = 0usize;
782        let mut skipped_uncommitted = 0usize;
783        let mut saw_ddl = false;
784        for (index, rec) in records.iter().enumerate() {
785            if has_boundaries
786                && !committed_row_records[index]
787                && matches!(
788                    rec.record_type,
789                    WalRecordType::Insert
790                        | WalRecordType::Update
791                        | WalRecordType::Delete
792                        | WalRecordType::OverflowWrite
793                        | WalRecordType::OverflowFree
794                )
795            {
796                skipped_uncommitted += 1;
797                continue;
798            }
799            match rec.record_type {
800                WalRecordType::Insert => {
801                    if let Some((table_name, rid, row_bytes)) = decode_wal_payload(&rec.data) {
802                        if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
803                            let tbl = &mut self.tables[slot];
804                            // Already persisted on its page? Skip — re-running
805                            // the insert would allocate a fresh slot and
806                            // duplicate the row.
807                            if rec.lsn > 0 && tbl.heap.page_lsn(rid.page_id) >= rec.lsn {
808                                skipped += 1;
809                                continue;
810                            }
811                            // Not yet durable: place the row at its exact
812                            // logged RowId so later Update/Delete records
813                            // (which carry that RowId) stay correctly
814                            // targeted. A plain re-`insert` would self-assign
815                            // a fresh slot whose position can diverge from the
816                            // original after a partial-flush crash.
817                            tbl.heap.insert_at(rid, &row_bytes)?;
818                            tbl.heap.set_page_lsn(rid.page_id, rec.lsn)?;
819                            replayed_inserts += 1;
820                        }
821                    }
822                }
823                WalRecordType::Update => {
824                    if let Some((table_name, rid, row_bytes)) = decode_wal_payload(&rec.data) {
825                        if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
826                            let tbl = &mut self.tables[slot];
827                            if rec.lsn > 0 && tbl.heap.page_lsn(rid.page_id) >= rec.lsn {
828                                skipped += 1;
829                                continue;
830                            }
831                            let new_rid = tbl.heap.update(rid, &row_bytes)?;
832                            // Stamp the source page too, not just the landing
833                            // page. A record written before v0.23 could be a
834                            // relocating update, whose redo is delete+insert
835                            // into a self-assigned slot, replay cannot place
836                            // it where the crashed session did, because the
837                            // record carries only the old RowId. Stamping the
838                            // source page is what stops a *second* recovery
839                            // from relocating it again to a third position.
840                            // Relocating updates written from here on are
841                            // logged as a Delete plus an Insert, both of which
842                            // redo position-stably (see `update_logged`).
843                            if new_rid != rid {
844                                tbl.heap.set_page_lsn(rid.page_id, rec.lsn)?;
845                            }
846                            tbl.heap.set_page_lsn(new_rid.page_id, rec.lsn)?;
847                            replayed_updates += 1;
848                        }
849                    }
850                }
851                WalRecordType::Delete => {
852                    if let Some((table_name, rid, _)) = decode_wal_payload(&rec.data) {
853                        if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
854                            let tbl = &mut self.tables[slot];
855                            if rec.lsn > 0 && tbl.heap.page_lsn(rid.page_id) >= rec.lsn {
856                                skipped += 1;
857                                continue;
858                            }
859                            let _ = tbl.heap.delete(rid);
860                            tbl.heap.set_page_lsn(rid.page_id, rec.lsn)?;
861                            replayed_deletes += 1;
862                        }
863                    }
864                }
865                WalRecordType::OverflowWrite => {
866                    // Physical redo of one chain chunk. Applied by page id
867                    // under the per-page LSN skip, so double replay is a
868                    // no-op. Ordered before its Insert/Update in the log, so
869                    // the stub the row carries always points at live pages.
870                    if let Some((table_name, page_id, next_page, chunk)) =
871                        decode_overflow_write_payload(&rec.data)
872                    {
873                        if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
874                            let tbl = &mut self.tables[slot];
875                            if rec.lsn > 0 && tbl.heap.overflow_page_lsn(page_id) >= rec.lsn {
876                                skipped += 1;
877                                continue;
878                            }
879                            tbl.heap
880                                .write_overflow_page(page_id, next_page, &chunk, rec.lsn)?;
881                        }
882                    }
883                }
884                WalRecordType::OverflowFree => {
885                    // Return a freed chain's pages to the in-memory free list.
886                    // Only reached for committed records (uncommitted frees
887                    // are skipped above), so a live row can never lose its
888                    // chain to a rolled-back free.
889                    if let Some((table_name, pages)) = decode_overflow_free_payload(&rec.data) {
890                        if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
891                            self.tables[slot].heap.release_overflow_pages(&pages);
892                        }
893                    }
894                }
895                WalRecordType::Begin | WalRecordType::Commit | WalRecordType::Rollback => {
896                    // Boundary records were consumed in the first pass.
897                }
898                WalRecordType::DdlCreateTable => {
899                    saw_ddl = true;
900                    if let Some((schema, defaults, auto_cols)) = decode_ddl_create_table(&rec.data)
901                    {
902                        if !self.name_to_slot.contains_key(&schema.table_name) {
903                            if let Ok(mut table) = Table::create(schema, &self.data_dir) {
904                                table.heap.set_dirty_budget(Arc::clone(&self.dirty_budget));
905                                table.set_defaults(defaults);
906                                table.set_auto_cols(auto_cols);
907                                let slot = self.tables.len();
908                                let name = table.schema.table_name.clone();
909                                self.tables.push(table);
910                                self.name_to_slot.insert(name, slot);
911                            }
912                        }
913                    }
914                }
915                WalRecordType::DdlDropTable => {
916                    saw_ddl = true;
917                    if let Some((table_name, _)) = decode_ddl_table_name(&rec.data) {
918                        if let Some(&slot) = self.name_to_slot.get(&table_name) {
919                            let heap_path = self.data_dir.join(format!("{table_name}.heap"));
920                            if heap_path.exists() {
921                                let _ = fs::remove_file(&heap_path);
922                            }
923                            for col_name in self.tables[slot].indexed_column_names() {
924                                let idx_path =
925                                    self.data_dir.join(format!("{table_name}_{col_name}.idx"));
926                                if idx_path.exists() {
927                                    let _ = fs::remove_file(&idx_path);
928                                }
929                            }
930                            for index_id in self.tables[slot].expression_index_ids() {
931                                let idx_path = self
932                                    .data_dir
933                                    .join(expression_index_file_name(&table_name, index_id));
934                                let _ = fs::remove_file(idx_path);
935                            }
936                            self.name_to_slot.remove(&table_name);
937                            let last = self.tables.len() - 1;
938                            if slot != last {
939                                let moved_name = self.tables[last].schema.table_name.clone();
940                                self.tables.swap(slot, last);
941                                self.name_to_slot.insert(moved_name, slot);
942                            }
943                            self.tables.pop();
944                        }
945                    }
946                }
947                WalRecordType::DdlAddColumn => {
948                    saw_ddl = true;
949                    if let Some((table_name, col)) = decode_ddl_alter_add_column(&rec.data) {
950                        if let Some(&slot) = self.name_to_slot.get(&table_name) {
951                            let tbl = &mut self.tables[slot];
952                            if !tbl.schema.columns.iter().any(|c| c.name == col.name) {
953                                let old_schema = tbl.schema.clone();
954                                let has_rows = tbl.heap.has_rows()?;
955                                tbl.schema.columns.push(col);
956                                tbl.refresh_layout();
957                                if has_rows {
958                                    let fill = vec![Value::Empty; tbl.schema.columns.len()];
959                                    let data_dir = self.data_dir.clone();
960                                    let _ = tbl.rewrite_rows_for_schema_change(
961                                        &old_schema,
962                                        &fill,
963                                        &data_dir,
964                                    );
965                                }
966                            }
967                            // Stamp every page with the DDL's LSN so a
968                            // subsequent restart's per-page check skips the
969                            // pre-DDL Insert/Update/Delete records — they
970                            // have already been folded into the new layout
971                            // by the rewrite above. See
972                            // `stamp_all_pages_min_lsn` doc.
973                            if rec.lsn > 0 {
974                                let _ = tbl.heap.stamp_all_pages_min_lsn(rec.lsn);
975                            }
976                        }
977                    }
978                }
979                WalRecordType::DdlDropColumn => {
980                    saw_ddl = true;
981                    if let Some((table_name, col_name)) = decode_ddl_alter_drop_column(&rec.data) {
982                        if let Some(&slot) = self.name_to_slot.get(&table_name) {
983                            {
984                                let tbl = &mut self.tables[slot];
985                                if let Some(idx) =
986                                    tbl.schema.columns.iter().position(|c| c.name == col_name)
987                                {
988                                    let old_schema = tbl.schema.clone();
989                                    let has_rows = tbl.heap.has_rows()?;
990                                    tbl.schema.columns.remove(idx);
991                                    for (i, c) in tbl.schema.columns.iter_mut().enumerate() {
992                                        c.position = i as u16;
993                                    }
994                                    tbl.refresh_layout();
995                                    if has_rows {
996                                        let fill = vec![Value::Empty; tbl.schema.columns.len()];
997                                        let data_dir = self.data_dir.clone();
998                                        let _ = tbl.rewrite_rows_for_schema_change(
999                                            &old_schema,
1000                                            &fill,
1001                                            &data_dir,
1002                                        );
1003                                    }
1004                                }
1005                                if rec.lsn > 0 {
1006                                    let _ = tbl.heap.stamp_all_pages_min_lsn(rec.lsn);
1007                                }
1008                            }
1009
1010                            let removed_ids =
1011                                self.tables[slot].remove_expression_indexes_for_root(&col_name);
1012                            for index_id in removed_ids {
1013                                let idx_path = self
1014                                    .data_dir
1015                                    .join(expression_index_file_name(&table_name, index_id));
1016                                let _ = fs::remove_file(idx_path);
1017                            }
1018                        }
1019                    }
1020                }
1021            }
1022        }
1023        info!(
1024            inserts = replayed_inserts,
1025            updates = replayed_updates,
1026            deletes = replayed_deletes,
1027            skipped = skipped,
1028            skipped_uncommitted = skipped_uncommitted,
1029            "WAL record apply complete (commit-boundary + LSN idempotent)"
1030        );
1031        if saw_ddl {
1032            self.persist()?;
1033        }
1034        // Persist the replayed changes to disk before truncating the WAL,
1035        // otherwise a crash between here and the next checkpoint would lose
1036        // the replayed records. `flush_all_dirty` on every heap moves every
1037        // dirty page through the normal write path.
1038        //
1039        // Blocker B3: under the deferred-index-save model, the on-disk
1040        // `.idx` files may lag the heap because the pre-crash session
1041        // never got to its next `checkpoint`. Replay restored the
1042        // heap rows above, but the btrees that loaded from those
1043        // possibly-stale `.idx` files don't know about them. Rebuild
1044        // every secondary index from the post-replay heap so the
1045        // trees exactly match disk. The rebuild is O(heap) per
1046        // indexed column, which is fine on a crash-recovery path.
1047        for tbl in &mut self.tables {
1048            tbl.heap.flush_all_dirty()?;
1049            tbl.heap.flush()?;
1050            tbl.rebuild_indexes_from_heap()?;
1051            // Flush the rebuilt indexes now so a crash between here
1052            // and the next mutation still leaves `.idx` files matching
1053            // the heap. Without this, a second crash before any
1054            // insert could leave us back where we started.
1055            tbl.save_dirty_indexes()?;
1056        }
1057        if let Some(max_lsn) = max_record_lsn(records) {
1058            self.record_durable_lsn_at_least(max_lsn)?;
1059            self.wal.set_next_lsn_at_least(max_lsn.saturating_add(1));
1060        }
1061        Ok(())
1062    }
1063
1064    /// Flush every dirty heap page and truncate the WAL. This is the
1065    /// "clean shutdown" point — after this returns, the on-disk heap files
1066    /// are fully consistent and the WAL is empty, so the next `open` will
1067    /// skip replay entirely.
1068    ///
1069    /// Safe to call multiple times. Safe to call on a catalog that has
1070    /// performed zero mutations since the last checkpoint (in which case
1071    /// the flushes are no-ops and the truncate is a bounded syscall).
1072    pub fn checkpoint(&mut self) -> io::Result<()> {
1073        self.ensure_no_active_transaction_for_checkpoint()?;
1074        self.ensure_plain_checkpoint_allowed_before_flush()?;
1075        self.flush_checkpoint_state()?;
1076        self.wal.flush()?;
1077        self.record_durable_lsn_at_least(self.wal.last_appended_lsn())?;
1078        self.wal.truncate()?;
1079        self.checkpointed = true;
1080        Ok(())
1081    }
1082
1083    /// Flush every dirty heap page, archive retained WAL records, then
1084    /// truncate the WAL. Sync-aware callers use this to make archive-before-
1085    /// truncate explicit without making storage depend on the sync crate.
1086    ///
1087    /// Replication boundary: this hook is for retained-history publication.
1088    /// It should stay behind sync-aware lifecycle helpers rather than becoming
1089    /// an ordinary checkpoint surface for application code.
1090    pub fn checkpoint_with_wal_archive<F>(&mut self, mut archive: F) -> io::Result<()>
1091    where
1092        F: FnMut(&Path, &[WalRecord]) -> io::Result<()>,
1093    {
1094        self.ensure_no_active_transaction_for_checkpoint()?;
1095        self.commit_autocommit()?;
1096        self.flush_checkpoint_state()?;
1097        self.wal.flush()?;
1098        let records = self.wal.read_all()?;
1099        let archive: WalArchiveCallback<'_> = &mut archive;
1100        archive(&self.data_dir, &records)?;
1101        if let Some(max_lsn) = max_record_lsn(&records) {
1102            self.record_durable_lsn_at_least(max_lsn)?;
1103        } else {
1104            self.record_durable_lsn_at_least(self.wal.last_appended_lsn())?;
1105        }
1106        self.wal.truncate()?;
1107        self.checkpointed = true;
1108        Ok(())
1109    }
1110
1111    fn ensure_no_active_transaction_for_checkpoint(&self) -> io::Result<()> {
1112        if self.active_tx_id.is_some() {
1113            return Err(io::Error::other(
1114                "cannot checkpoint while an explicit transaction is active",
1115            ));
1116        }
1117        Ok(())
1118    }
1119
1120    /// Refuse DDL while an explicit transaction is active.
1121    ///
1122    /// DDL is not transactional here: `drop_table` unlinks the heap and
1123    /// rewrites the catalog immediately, and `alter_table_*` rewrites every
1124    /// row in place. ROLLBACK restores the catalog from disk, which by then
1125    /// already reflects the DDL, so a `begin / drop / rollback` sequence used
1126    /// to report success at every step and leave the table permanently gone.
1127    /// Refusing the statement is the correct fix; making DDL transactional is
1128    /// a separate, deliberately deferred decision.
1129    fn ensure_no_active_transaction_for_ddl(&self, verb: &'static str) -> io::Result<()> {
1130        if self.active_tx_id.is_some() {
1131            return Err(io::Error::new(
1132                io::ErrorKind::InvalidInput,
1133                StorageError::DdlInTransaction { verb },
1134            ));
1135        }
1136        Ok(())
1137    }
1138
1139    fn flush_checkpoint_state(&mut self) -> io::Result<()> {
1140        for tbl in &mut self.tables {
1141            tbl.heap.flush_all_dirty()?;
1142            tbl.heap.flush()?;
1143            // Blocker B3: the hot insert/update/delete paths no longer
1144            // fsync index files per row — they only mark the in-memory
1145            // btree dirty. Checkpoint is where those deferred saves
1146            // actually hit disk. Clean (non-dirty) indexes are free.
1147            tbl.save_dirty_indexes()?;
1148        }
1149        Ok(())
1150    }
1151
1152    fn ensure_plain_checkpoint_allowed_before_flush(&self) -> io::Result<()> {
1153        if !self.sync_identity_file_exists() {
1154            return Ok(());
1155        }
1156        if self.wal.has_pending() {
1157            return Err(io::Error::other(
1158                "sync identity exists but checkpoint/recovery was called without a WAL archive hook; refusing to truncate retained history",
1159            ));
1160        }
1161        let records = self.wal.read_all()?;
1162        self.ensure_plain_wal_truncate_allowed(&records)
1163    }
1164
1165    fn ensure_plain_wal_truncate_allowed(&self, records: &[WalRecord]) -> io::Result<()> {
1166        if records.is_empty() {
1167            return Ok(());
1168        }
1169        if self.sync_identity_file_exists() {
1170            return Err(io::Error::other(
1171                "sync identity exists but checkpoint/recovery was called without a WAL archive hook; refusing to truncate retained history",
1172            ));
1173        }
1174        Ok(())
1175    }
1176
1177    fn sync_identity_file_exists(&self) -> bool {
1178        self.data_dir
1179            .join(SYNC_STATE_DIR)
1180            .join(SYNC_IDENTITY_FILE)
1181            .exists()
1182    }
1183
1184    fn record_durable_lsn_at_least(&mut self, lsn: u64) -> io::Result<()> {
1185        if lsn <= self.durable_lsn {
1186            return Ok(());
1187        }
1188        self.durable_lsn = lsn;
1189        write_durable_lsn(&self.data_dir, lsn)
1190    }
1191
1192    /// Allocate or return the transaction id for the current mutation.
1193    #[inline]
1194    /// Free (or defer freeing) the overflow-chain pages a mutation just
1195    /// orphaned. In autocommit there is no rollback window, so the pages return
1196    /// to the table's free list immediately and the next spill reuses them
1197    /// (bounding steady-state churn). Inside an explicit transaction the free is
1198    /// held on `pending_free_overflow` until COMMIT: a ROLLBACK reopens the
1199    /// catalog from disk, discarding this list, so the resurrected old row still
1200    /// points at a live chain. Reuse is crash-safe without an `OverflowFree`
1201    /// record because a later spill that overwrites a reused page logs its own
1202    /// per-page `OverflowWrite` (LSN-idempotent), and post-recovery `sweep`
1203    /// reclaims anything the in-memory list lost.
1204    fn free_overflow_chain(&mut self, slot: usize, pages: Vec<u32>) {
1205        if pages.is_empty() {
1206            return;
1207        }
1208        if self.active_tx_id.is_some() {
1209            self.pending_free_overflow.push((slot, pages));
1210        } else {
1211            self.tables[slot].release_overflow_pages(&pages);
1212        }
1213    }
1214
1215    fn next_tx(&mut self) -> u64 {
1216        if let Some(id) = self.active_tx_id {
1217            return id;
1218        }
1219        let id = self.next_tx_id;
1220        self.next_tx_id = self.next_tx_id.wrapping_add(1);
1221        id
1222    }
1223
1224    /// Begin a connection/session-scoped explicit transaction.
1225    pub fn begin_transaction(&mut self) -> io::Result<()> {
1226        if self.active_tx_id.is_some() {
1227            return Err(io::Error::new(
1228                io::ErrorKind::InvalidInput,
1229                "explicit transaction is already active",
1230            ));
1231        }
1232        let start_len = self.wal.synced_len()?;
1233        let id = self.next_tx_id;
1234        self.next_tx_id = self.next_tx_id.wrapping_add(1);
1235        self.active_tx_id = Some(id);
1236        // From here until COMMIT/ROLLBACK the dirty pages are the only copy of
1237        // the transaction's state, so they may not be spilled to disk to
1238        // relieve the budget. See `DirtyPageBudget`.
1239        self.dirty_budget.set_rollback_pinned(true);
1240        self.tx_start_len = Some(start_len);
1241        self.pending_autocommit_tx_ids.clear();
1242        if !self.wal.is_off() {
1243            self.wal.append(id, WalRecordType::Begin, &[])?;
1244            self.wal.flush()?;
1245        }
1246        Ok(())
1247    }
1248
1249    /// Commit the active explicit transaction by appending a durable boundary
1250    /// marker after its row records.
1251    pub fn commit_transaction(&mut self) -> io::Result<()> {
1252        self.dirty_budget.set_rollback_pinned(false);
1253        if let Some(id) = self.active_tx_id.take() {
1254            if !self.wal.is_off() {
1255                self.wal.append(id, WalRecordType::Commit, &[])?;
1256                self.wal.flush()?;
1257            }
1258        }
1259        self.tx_start_len = None;
1260        // The transaction committed: its rows are durable and can no longer be
1261        // resurrected by ROLLBACK, so the old chains they replaced/removed are
1262        // safe to reclaim. (Populated only while a tx was active.)
1263        for (slot, pages) in std::mem::take(&mut self.pending_free_overflow) {
1264            self.tables[slot].release_overflow_pages(&pages);
1265        }
1266        Ok(())
1267    }
1268
1269    /// Commit any autocommit row mutations accumulated by the current
1270    /// statement. Pure reads/DDL have no pending tx ids and fall through to a
1271    /// cheap WAL flush/no-op.
1272    pub fn commit_autocommit(&mut self) -> io::Result<()> {
1273        if !self.wal.is_off() && !self.pending_autocommit_tx_ids.is_empty() {
1274            self.pending_autocommit_tx_ids.sort_unstable();
1275            self.pending_autocommit_tx_ids.dedup();
1276            for id in self.pending_autocommit_tx_ids.drain(..) {
1277                self.wal.append(id, WalRecordType::Commit, &[])?;
1278            }
1279        }
1280        self.wal.flush()
1281    }
1282
1283    /// Append a mutation record to the WAL buffer. **Does not flush.**
1284    ///
1285    /// Mission B (post-review): per-row `wal.flush()` was a ~1ms fsync on
1286    /// every mutation, turning `update_by_filter` into a ~19s workload.
1287    /// The flush is now deferred to [`Self::sync_wal`], which the executor
1288    /// calls exactly once at the end of every mutating statement. This
1289    /// gives us statement-level group commit: N-row updates pay one fsync,
1290    /// not N.
1291    ///
1292    /// Durability contract: any path that observes `Ok(...)` back from
1293    /// the executor must have called `sync_wal` before returning that
1294    /// Ok. Replay is still correct because WAL records are appended in
1295    /// order and only records that reached `fdatasync`ed bytes are
1296    /// replayed.
1297    fn wal_log(
1298        &mut self,
1299        tx_id: u64,
1300        record_type: WalRecordType,
1301        table: &str,
1302        rid: RowId,
1303        row_bytes: &[u8],
1304    ) -> io::Result<()> {
1305        // Mission B (post-review, second pass): when the WAL is in Off
1306        // mode the `append` call below is a no-op, so building the
1307        // payload first wastes a `Vec` allocation + ~3 extends per
1308        // mutation. The catalog hot paths check `wal.is_off()` before
1309        // calling here, but this guard is the belt-and-braces version
1310        // for any internal caller that doesn't.
1311        if self.wal.is_off() {
1312            return Ok(());
1313        }
1314        let payload = encode_wal_payload(table, rid, row_bytes);
1315        self.wal.append(tx_id, record_type, &payload)?;
1316        if self.active_tx_id.is_none() {
1317            self.pending_autocommit_tx_ids.push(tx_id);
1318        }
1319        Ok(())
1320    }
1321
1322    /// Flush any buffered WAL records to disk. Called by the executor
1323    /// at the end of every mutating statement so the group-commit
1324    /// window is exactly one statement.
1325    ///
1326    /// See `Self::wal_log` for the durability contract.
1327    #[inline]
1328    pub fn sync_wal(&mut self) -> io::Result<()> {
1329        self.wal.flush()
1330    }
1331
1332    /// Set the WAL sync mode. Production code should leave this at the
1333    /// default ([`WalSyncMode::Full`]). Benchmarks set it to
1334    /// [`WalSyncMode::Off`] to compare apples-to-apples against
1335    /// `:memory:` SQLite (which has zero fsync cost).
1336    ///
1337    /// **Never** call this with `Off` in production — a machine crash
1338    /// can lose any record written since the last `sync_wal` returned.
1339    pub fn set_wal_sync_mode(&mut self, mode: WalSyncMode) {
1340        self.wal.set_sync_mode(mode);
1341    }
1342
1343    /// Ceiling on unflushed heap pages held across every table, in bytes.
1344    /// Defaults to [`crate::heap::DEFAULT_DIRTY_PAGE_BUDGET`]. A transaction
1345    /// that would exceed it fails with `StorageError::TransactionTooLarge`
1346    /// instead of pinning memory until the process is OOM-killed.
1347    pub fn set_dirty_page_budget_bytes(&mut self, limit_bytes: usize) {
1348        self.dirty_budget.set_limit_bytes(limit_bytes);
1349    }
1350
1351    pub fn dirty_page_budget_bytes(&self) -> usize {
1352        self.dirty_budget.limit_bytes()
1353    }
1354
1355    /// Unflushed heap pages currently buffered across every table.
1356    pub fn dirty_pages_buffered(&self) -> usize {
1357        self.dirty_budget.charged_pages()
1358    }
1359
1360    /// Defer Full-mode commit fsyncs (WAL group commit). While enabled, the
1361    /// commit paths register the WAL generation they need durable instead of
1362    /// fsyncing inline; the pending claim is retrieved with
1363    /// [`Self::take_wal_durability_ticket`] and the caller must wait on it
1364    /// before acknowledging the statement. This lets the fsync leave the
1365    /// engine's exclusive-lock hold so overlapping committers can share one
1366    /// fsync. `Normal`/`Off` modes are unaffected.
1367    pub fn set_wal_sync_deferred(&mut self, defer: bool) {
1368        self.wal.set_defer_sync(defer);
1369    }
1370
1371    /// Take the durability claim registered by deferred commit flushes since
1372    /// the last take, if any. See [`Self::set_wal_sync_deferred`].
1373    pub fn take_wal_durability_ticket(&mut self) -> Option<WalDurabilityTicket> {
1374        self.wal.take_durability_ticket()
1375    }
1376
1377    /// Number of fsyncs issued against the WAL (test/metrics hook).
1378    pub fn wal_fsync_count(&self) -> u64 {
1379        self.wal.fsync_count()
1380    }
1381
1382    /// Discard in-memory mutations made since the last `sync_wal()` and
1383    /// restore the catalog to its on-disk state. Used by ROLLBACK to
1384    /// undo an in-progress transaction's changes.
1385    ///
1386    /// This re-opens the catalog from the checkpoint file and replays
1387    /// only the durable (already flushed) WAL records. Any WAL records
1388    /// that were appended but not yet flushed are lost.
1389    ///
1390    /// **Critical**: before replacing `*self` we must discard every
1391    /// dirty in-memory page across all heaps. Otherwise the old
1392    /// `Catalog`'s `Drop` impl calls `checkpoint()` which flushes those
1393    /// dirty pages to disk — and the freshly-opened replacement catalog
1394    /// would then read the flushed (uncommitted) rows back, defeating
1395    /// the entire rollback.
1396    pub fn rollback_to_last_sync(&mut self) -> io::Result<()> {
1397        self.rollback_to_last_sync_inner(None)
1398    }
1399
1400    /// Roll back the active transaction, then reopen/replay any remaining WAL
1401    /// through an archive hook before recovery truncates it. Sync-aware callers
1402    /// use this when committed pre-transaction records must remain available to
1403    /// replicas after rollback.
1404    pub fn rollback_to_last_sync_with_wal_archive<F>(&mut self, mut archive: F) -> io::Result<()>
1405    where
1406        F: FnMut(&Path, &[WalRecord]) -> io::Result<()>,
1407    {
1408        let archive: WalArchiveCallback<'_> = &mut archive;
1409        self.rollback_to_last_sync_inner(Some(archive))
1410    }
1411
1412    fn rollback_to_last_sync_inner(
1413        &mut self,
1414        mut archive: Option<WalArchiveCallback<'_>>,
1415    ) -> io::Result<()> {
1416        let start_len = self.tx_start_len.unwrap_or(0);
1417        let prearchived = if let Some(archive) = archive.as_mut() {
1418            let records = self.wal.read_through_len(start_len)?;
1419            if !records.is_empty() {
1420                archive(&self.data_dir, &records)?;
1421            }
1422            true
1423        } else {
1424            false
1425        };
1426
1427        let start_len = self.tx_start_len.take().unwrap_or(0);
1428        if let Some(id) = self.active_tx_id.take() {
1429            if !self.wal.is_off() {
1430                let _ = self.wal.append(id, WalRecordType::Rollback, &[]);
1431            }
1432        }
1433        self.wal.discard_and_truncate_to(start_len)?;
1434
1435        // Step 1: throw away every uncommitted in-memory write so the
1436        // upcoming Drop of `*self` has nothing dirty to flush. This covers
1437        // both the heap pages AND the btree index mutations: the Drop below
1438        // runs `checkpoint()` (active_tx_id was already taken above), whose
1439        // `save_dirty_indexes` would otherwise flush the rolled-back index
1440        // writes to the `.idx` files — poisoning the unique index. The
1441        // freshly-opened replacement catalog reloads clean trees from the
1442        // untouched on-disk `.idx`, so discarding the dirty flags here is
1443        // what actually reverts the transaction's index writes.
1444        for tbl in &mut self.tables {
1445            tbl.heap.discard_dirty();
1446            tbl.discard_dirty_indexes();
1447        }
1448        // Step 2: discard WAL records appended since the last explicit
1449        // sync point. Large pending records can spill through BufWriter and
1450        // become file-visible before `sync_wal()`; truncating to the last
1451        // synced boundary prevents `open()` below from replaying rolled-back
1452        // transaction records.
1453        self.wal.discard_pending()?;
1454        // Step 3: re-open the catalog from disk. The heap files on disk
1455        // still reflect the last checkpoint (pre-transaction state)
1456        // because we never flushed the transaction's dirty pages.
1457        let data_dir = self.data_dir.clone();
1458        let sync_mode = self.wal.sync_mode();
1459        let mut restored = if prearchived {
1460            let mut already_archived = |_dir: &Path, _records: &[WalRecord]| Ok(());
1461            let archive: WalArchiveCallback<'_> = &mut already_archived;
1462            Self::open_inner(&data_dir, Some(archive))?
1463        } else {
1464            Self::open_inner(&data_dir, archive)?
1465        };
1466        // Row-only rollback reopens the catalog to discard dirty heap/index
1467        // state, but it does not change prepared-query metadata. Preserve the
1468        // O(1) token in that common case so existing PreparedQuery handles keep
1469        // their fast path. Any schema/default/auto/index difference retains the
1470        // fresh token assigned by open_inner and invalidates cached metadata.
1471        if self.has_same_prepared_structure(&restored) {
1472            restored.structure_generation = self.structure_generation;
1473        }
1474        let dirty_budget_limit = self.dirty_budget.limit_bytes();
1475        *self = restored;
1476        self.wal.set_sync_mode(sync_mode);
1477        // The replacement catalog brought a fresh (unpinned, empty) budget;
1478        // carry the configured ceiling across, like the sync mode above.
1479        self.dirty_budget.set_limit_bytes(dirty_budget_limit);
1480        Ok(())
1481    }
1482
1483    fn abandon_active_transaction_for_drop(&mut self) -> io::Result<()> {
1484        self.dirty_budget.set_rollback_pinned(false);
1485        for tbl in &mut self.tables {
1486            tbl.heap.discard_dirty();
1487        }
1488        self.pending_autocommit_tx_ids.clear();
1489        let truncate_result = match self.tx_start_len.take() {
1490            Some(start_len) => self.wal.discard_and_truncate_to(start_len),
1491            None => self.wal.discard_pending(),
1492        };
1493        self.active_tx_id = None;
1494        truncate_result
1495    }
1496
1497    /// Returns a reference to the data directory.
1498    pub fn data_dir(&self) -> &Path {
1499        &self.data_dir
1500    }
1501
1502    /// Highest page LSN across all tables (0 if nothing has been written).
1503    /// This is the durability high-water mark — the LSN a backup taken now
1504    /// corresponds to, and the value `Catalog::open` uses to restore
1505    /// `next_lsn` after a reopen/restore.
1506    pub fn max_lsn(&self) -> u64 {
1507        let max_page_lsn = self
1508            .tables
1509            .iter()
1510            .map(|t| t.heap.max_page_lsn())
1511            .max()
1512            .unwrap_or(0);
1513        max_page_lsn
1514            .max(self.durable_lsn)
1515            .max(self.wal.last_appended_lsn())
1516    }
1517
1518    pub fn create_table(&mut self, schema: Schema) -> io::Result<()> {
1519        self.create_table_full(schema, Vec::new(), Vec::new())
1520    }
1521
1522    /// Create a table whose columns carry literal defaults. `defaults` is
1523    /// aligned to `schema.columns` by position (and may be shorter / empty for
1524    /// columns without a default).
1525    pub fn create_table_with_defaults(
1526        &mut self,
1527        schema: Schema,
1528        defaults: Vec<Option<Value>>,
1529    ) -> io::Result<()> {
1530        self.create_table_full(schema, defaults, Vec::new())
1531    }
1532
1533    /// Create a table with per-column literal defaults and auto-increment
1534    /// flags. Both vecs are aligned to `schema.columns` by position (and may be
1535    /// empty). Defaults and auto flags are WAL-logged and persisted in the
1536    /// catalog so they survive a restart.
1537    pub fn create_table_full(
1538        &mut self,
1539        schema: Schema,
1540        defaults: Vec<Option<Value>>,
1541        auto_cols: Vec<bool>,
1542    ) -> io::Result<()> {
1543        self.ensure_no_active_transaction_for_ddl("create table")?;
1544        self.invalidate_structure();
1545        validate_table_name(&schema.table_name)?;
1546        for col in &schema.columns {
1547            validate_column_name(&col.name)?;
1548        }
1549        let name = schema.table_name.clone();
1550        if self.name_to_slot.contains_key(&name) {
1551            return Err(io::Error::new(
1552                io::ErrorKind::AlreadyExists,
1553                format!("table '{name}' already exists"),
1554            ));
1555        }
1556        if !self.wal.is_off() {
1557            let payload = encode_ddl_create_table(&schema, &defaults, &auto_cols);
1558            self.wal
1559                .append(0, WalRecordType::DdlCreateTable, &payload)?;
1560            self.wal.flush()?;
1561        }
1562        let mut table = Table::create(schema, &self.data_dir)?;
1563        table.heap.set_dirty_budget(Arc::clone(&self.dirty_budget));
1564        table.set_defaults(defaults);
1565        table.set_auto_cols(auto_cols);
1566        let slot = self.tables.len();
1567        self.tables.push(table);
1568        self.name_to_slot.insert(name, slot);
1569        self.persist()?;
1570        Ok(())
1571    }
1572
1573    /// Per-column literal defaults for a table, aligned to its columns by
1574    /// position. `None` when the table is unknown; an empty slice when no
1575    /// column has a default.
1576    pub fn column_defaults(&self, table: &str) -> Option<&[Option<Value>]> {
1577        let slot = *self.name_to_slot.get(table)?;
1578        Some(self.tables[slot].defaults())
1579    }
1580
1581    /// Which columns of a table are `auto`, aligned to its columns by position.
1582    /// `None` when the table is unknown; an empty slice when none are auto.
1583    pub fn auto_columns(&self, table: &str) -> Option<&[bool]> {
1584        let slot = *self.name_to_slot.get(table)?;
1585        Some(self.tables[slot].auto_cols())
1586    }
1587
1588    /// Fill any omitted (`Empty`) auto column in `values` from the table's
1589    /// sequence and advance it. No-op when the table is unknown or has no auto
1590    /// columns. Fails closed if seeding the sequence requires a scan and that
1591    /// scan hits an unreadable page (a silently short seed would hand out
1592    /// colliding auto ids).
1593    pub fn assign_auto_columns(&mut self, table: &str, values: &mut [Value]) -> io::Result<()> {
1594        if let Some(&slot) = self.name_to_slot.get(table) {
1595            self.tables[slot].assign_auto(values)?;
1596        }
1597        Ok(())
1598    }
1599
1600    /// Write the current set of schemas to disk atomically (write-then-rename).
1601    ///
1602    /// Mission 3: also writes the per-table list of indexed column names so
1603    /// `Catalog::open` can rehydrate b-tree indexes on restart.
1604    fn persist_at_activation_boundary(&self) -> Result<(), CatalogPersistError> {
1605        let cat_path = self.data_dir.join(CATALOG_FILE);
1606        let tmp_path = self.data_dir.join(format!("{CATALOG_FILE}.tmp"));
1607        let entries: Vec<CatalogEntryRef<'_>> = self
1608            .tables
1609            .iter()
1610            .map(|t| CatalogEntryRef {
1611                schema: &t.schema,
1612                indexed_cols: t.indexed_column_metas(),
1613                expression_indexes: t.expression_index_metas(),
1614                defaults: t.defaults(),
1615                auto_cols: t.auto_cols(),
1616            })
1617            .collect();
1618        write_catalog_file(
1619            &tmp_path,
1620            self.active_catalog_version,
1621            self.next_index_id,
1622            &entries,
1623            &self.links,
1624        )
1625        .map_err(CatalogPersistError::BeforeActivation)?;
1626        #[cfg(test)]
1627        if take_catalog_persist_failpoint(1) {
1628            return Err(CatalogPersistError::BeforeActivation(io::Error::other(
1629                "injected catalog failure before rename",
1630            )));
1631        }
1632        fs::rename(&tmp_path, &cat_path).map_err(CatalogPersistError::BeforeActivation)?;
1633        #[cfg(test)]
1634        let directory_sync = if take_catalog_persist_failpoint(2) {
1635            Err(io::Error::other(
1636                "injected catalog directory sync failure after rename",
1637            ))
1638        } else {
1639            sync_directory(&self.data_dir)
1640        };
1641        #[cfg(not(test))]
1642        let directory_sync = sync_directory(&self.data_dir);
1643        directory_sync.map_err(CatalogPersistError::AfterActivation)
1644    }
1645
1646    fn persist(&self) -> io::Result<()> {
1647        self.persist_at_activation_boundary()
1648            .map_err(CatalogPersistError::into_io_error)
1649    }
1650
1651    /// Resolve a table name to its current slot index. DROP TABLE uses
1652    /// swap-remove, so prepared-query fast paths pair this value with
1653    /// [`Self::structure_generation`] before every slot-indexed access.
1654    #[inline]
1655    pub fn table_slot(&self, name: &str) -> Option<usize> {
1656        self.name_to_slot.get(name).copied()
1657    }
1658
1659    /// O(1) prepared-metadata validity token. It is process-local by design:
1660    /// prepared queries do not cross process boundaries, and a reopened or
1661    /// rollback-replaced Catalog must invalidate every cached slot/offset.
1662    #[inline]
1663    pub fn structure_generation(&self) -> u64 {
1664        self.structure_generation
1665    }
1666
1667    #[inline]
1668    fn invalidate_structure(&mut self) {
1669        self.structure_generation = next_structure_generation();
1670    }
1671
1672    fn has_same_prepared_structure(&self, other: &Self) -> bool {
1673        // Links are part of the structure prepared plans resolve against, even
1674        // though DDL inside a transaction is refused today and so no rollback
1675        // can currently change them.
1676        self.links == other.links
1677            && self.tables.len() == other.tables.len()
1678            && self.tables.iter().zip(&other.tables).all(|(left, right)| {
1679                let left_schema = &left.schema;
1680                let right_schema = &right.schema;
1681                left_schema.table_name == right_schema.table_name
1682                    && left_schema.columns.len() == right_schema.columns.len()
1683                    && left_schema.columns.iter().zip(&right_schema.columns).all(
1684                        |(left_col, right_col)| {
1685                            left_col.name == right_col.name
1686                                && left_col.type_id == right_col.type_id
1687                                && left_col.required == right_col.required
1688                                && left_col.position == right_col.position
1689                        },
1690                    )
1691                    && left.defaults() == right.defaults()
1692                    && left.auto_cols() == right.auto_cols()
1693                    && {
1694                        let left_indexes = left.indexed_column_metas();
1695                        let right_indexes = right.indexed_column_metas();
1696                        left_indexes.len() == right_indexes.len()
1697                            && left_indexes.iter().zip(&right_indexes).all(
1698                                |(left_index, right_index)| {
1699                                    left_index.name == right_index.name
1700                                        && left_index.unique == right_index.unique
1701                                },
1702                            )
1703                    }
1704                    && left.expression_index_metas() == right.expression_index_metas()
1705            })
1706    }
1707
1708    /// O(1) slot-indexed table access. Panics on an out-of-range slot
1709    /// — callers must have obtained the slot via `table_slot()`.
1710    #[inline]
1711    pub fn table_by_slot(&self, slot: usize) -> &Table {
1712        &self.tables[slot]
1713    }
1714
1715    /// Mutable counterpart to [`Self::table_by_slot`].
1716    #[inline]
1717    pub fn table_by_slot_mut(&mut self, slot: usize) -> &mut Table {
1718        &mut self.tables[slot]
1719    }
1720
1721    pub fn get_table(&self, name: &str) -> Option<&Table> {
1722        let slot = *self.name_to_slot.get(name)?;
1723        Some(&self.tables[slot])
1724    }
1725
1726    pub fn get_table_mut(&mut self, name: &str) -> Option<&mut Table> {
1727        let slot = *self.name_to_slot.get(name)?;
1728        Some(&mut self.tables[slot])
1729    }
1730
1731    /// Whether `table` may hold v2 (spilled) rows (see
1732    /// [`Table::has_overflow_rows`]). Unknown table ⇒ false. The executor gates
1733    /// its v1-only raw-byte fast paths on this.
1734    #[inline]
1735    pub fn table_has_overflow(&self, table: &str) -> bool {
1736        self.get_table(table)
1737            .map(|t| t.has_overflow_rows())
1738            .unwrap_or(false)
1739    }
1740
1741    /// Private helper: resolve a table name to `&Table`, or return an
1742    /// `io::Error` with the same "table '<name>' not found" message the
1743    /// older `get_mut().ok_or_else(...)` callers produced. Phase 18
1744    /// consolidates ~14 copies of that idiom into this one place.
1745    #[inline]
1746    fn by_name(&self, table: &str) -> io::Result<&Table> {
1747        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
1748            io::Error::new(
1749                io::ErrorKind::NotFound,
1750                format!("table '{table}' not found"),
1751            )
1752        })?;
1753        Ok(&self.tables[slot])
1754    }
1755
1756    /// Mutable counterpart to [`Self::by_name`].
1757    #[inline]
1758    fn by_name_mut(&mut self, table: &str) -> io::Result<&mut Table> {
1759        let slot = self.slot_of(table)?;
1760        Ok(&mut self.tables[slot])
1761    }
1762
1763    /// Mark-and-sweep one table's overflow pages, returning the number of pages
1764    /// reclaimed (design 3.6, door D12). Reclaimed pages are logged as a single
1765    /// `OverflowFree` record so the reclamation is crash-safe, then returned to
1766    /// the free list for reuse. Intended to run under the table write lock.
1767    pub fn sweep(&mut self, table: &str) -> io::Result<usize> {
1768        let slot = self.slot_of(table)?;
1769        let reclaimed = self.tables[slot].sweep_overflow()?;
1770        if !reclaimed.is_empty() && !self.wal.is_off() {
1771            let payload = encode_overflow_free_payload(table, &reclaimed);
1772            self.wal.append(0, WalRecordType::OverflowFree, &payload)?;
1773            self.wal.flush()?;
1774        }
1775        Ok(reclaimed.len())
1776    }
1777
1778    /// Sweep overflow pages across every table. Returns the total reclaimed.
1779    pub fn sweep_all(&mut self) -> io::Result<usize> {
1780        let names: Vec<String> = self
1781            .tables
1782            .iter()
1783            .map(|t| t.schema.table_name.clone())
1784            .collect();
1785        let mut total = 0;
1786        for name in names {
1787            total += self.sweep(&name)?;
1788        }
1789        Ok(total)
1790    }
1791
1792    fn slot_of(&self, table: &str) -> io::Result<usize> {
1793        self.name_to_slot.get(table).copied().ok_or_else(|| {
1794            io::Error::new(
1795                io::ErrorKind::NotFound,
1796                format!("table '{table}' not found"),
1797            )
1798        })
1799    }
1800
1801    pub fn insert(&mut self, table: &str, values: &Row) -> io::Result<RowId> {
1802        // Mission 2: encode the row into a scratch buffer first so we can
1803        // log it to the WAL before touching the heap. We re-encode inside
1804        // `Table::insert`, which keeps the insert hot path untouched — the
1805        // WAL encode here is additive.
1806        //
1807        // Mission B (post-review, second pass): in `WalSyncMode::Off` the
1808        // entire WAL pipeline is a no-op, so skip the per-row
1809        // `encode_row_into` allocation and `wal_log` call entirely.
1810        if self.wal.is_off() {
1811            return self.by_name_mut(table)?.insert(values);
1812        }
1813        let slot = self.slot_of(table)?;
1814        let _ = self.tables[slot].preflight_insert(values)?;
1815        // Allocate the tx id up front: any overflow chains for a spilled row
1816        // must be logged under the SAME tx (and before the Insert record) so
1817        // an uncommitted big row's chain writes are skipped on replay.
1818        let tx_id = self.next_tx();
1819        let row_bytes = {
1820            let Catalog { tables, wal, .. } = self;
1821            encode_row_with_spill_logged(&mut tables[slot], wal, tx_id, values)?
1822        };
1823        // Insert the (v1 or v2) row bytes into the heap FIRST so the Insert
1824        // record carries the real RowId. Index maintenance uses the logical
1825        // `values`, so a spilled column is indexed by its full value, never
1826        // the stub. See the v0.4.x idempotency rationale in the git history.
1827        let new_rid = self.tables[slot].insert_encoded(values, &row_bytes)?;
1828        self.wal_log(tx_id, WalRecordType::Insert, table, new_rid, &row_bytes)?;
1829        let lsn = self.wal.last_appended_lsn();
1830        if lsn > 0 {
1831            self.tables[slot].heap.set_page_lsn(new_rid.page_id, lsn)?;
1832        }
1833        Ok(new_rid)
1834    }
1835
1836    /// WAL-logged insert addressed by table slot index instead of name.
1837    /// Backs the executor's prepared-insert fast path, which resolves the
1838    /// slot at prepare time to skip the name→slot hash probe. Behaves exactly
1839    /// like [`Self::insert`] (logs the record with the real RowId, stamps the
1840    /// landing page's LSN) — the prepared path previously called the raw
1841    /// `Table::insert` and bypassed the WAL entirely, silently losing every
1842    /// prepared insert on a crash.
1843    pub fn insert_by_slot(&mut self, slot: usize, values: &Row) -> io::Result<RowId> {
1844        if self.wal.is_off() {
1845            return self.tables[slot].insert(values);
1846        }
1847        let _ = self.tables[slot].preflight_insert(values)?;
1848        let tx_id = self.next_tx();
1849        let autocommit = self.active_tx_id.is_none();
1850        let Catalog { tables, wal, .. } = self;
1851        let tbl = &mut tables[slot];
1852        // Spill-aware encode (logs any overflow chains under `tx_id`, before
1853        // the Insert record). Returns v1 bytes for rows that fit inline.
1854        let row_bytes = encode_row_with_spill_logged(tbl, wal, tx_id, values)?;
1855        // Insert first so the WAL record carries the real RowId (see
1856        // `insert` for the ordering/durability argument).
1857        let new_rid = tbl.insert_encoded(values, &row_bytes)?;
1858        let payload = encode_wal_payload(&tbl.schema.table_name, new_rid, &row_bytes);
1859        wal.append(tx_id, WalRecordType::Insert, &payload)?;
1860        if autocommit {
1861            self.pending_autocommit_tx_ids.push(tx_id);
1862        }
1863        let lsn = wal.last_appended_lsn();
1864        if lsn > 0 {
1865            tbl.heap.set_page_lsn(new_rid.page_id, lsn)?;
1866        }
1867        Ok(new_rid)
1868    }
1869
1870    pub fn get(&self, table: &str, rid: RowId) -> Option<Row> {
1871        self.get_table(table)?.get(rid)
1872    }
1873
1874    pub fn get_projected(
1875        &self,
1876        table: &str,
1877        rid: RowId,
1878        column_indices: &[usize],
1879    ) -> io::Result<Option<Vec<Value>>> {
1880        self.by_name(table)?.get_projected(rid, column_indices)
1881    }
1882
1883    pub fn delete(&mut self, table: &str, rid: RowId) -> io::Result<()> {
1884        let slot = self.slot_of(table)?;
1885        // Capture the deleted row's overflow chain BEFORE the heap slot is
1886        // cleared, so it can be freed once safe (design 3.6). Empty for
1887        // inline-only tables (cheap `has_overflow_rows` check).
1888        let old_pages = self.tables[slot].overflow_chain_pages_at(rid)?;
1889        // Mission B (post-review, second pass): WAL Off → no payload
1890        // construction.
1891        if self.wal.is_off() {
1892            self.tables[slot].delete(rid)?;
1893            self.free_overflow_chain(slot, old_pages);
1894            return Ok(());
1895        }
1896        let tx_id = self.next_tx();
1897        // Delete records carry only the rid — no row payload.
1898        self.wal_log(tx_id, WalRecordType::Delete, table, rid, &[])?;
1899        let lsn = self.wal.last_appended_lsn();
1900        self.tables[slot].delete(rid)?;
1901        // Redoing a delete is idempotent, so the stamp is not what makes
1902        // recovery correct here: it is what lets the per-page guard skip a
1903        // record whose page already reached disk, instead of re-walking every
1904        // delete the WAL still holds on every single recovery.
1905        if lsn > 0 {
1906            self.tables[slot].heap.set_page_lsn(rid.page_id, lsn)?;
1907        }
1908        self.free_overflow_chain(slot, old_pages);
1909        Ok(())
1910    }
1911
1912    /// Mission C Phase 12: bulk delete a list of rids, batching btree
1913    /// maintenance. See [`Table::delete_many`] for the full explanation
1914    /// and fall-through rules. Returns the number of rows removed.
1915    pub fn delete_many(&mut self, table: &str, rids: &[RowId]) -> io::Result<u64> {
1916        // Mission 2: log every rid as an individual Delete record. The
1917        // WAL flush is deferred to the executor's statement-end
1918        // `sync_wal` — see [`Self::wal_log`] for the group-commit rules.
1919        //
1920        // Mission B (post-review, second pass): in Off mode skip the
1921        // entire per-row payload loop — `wal.append` would no-op every
1922        // call but the `encode_wal_payload` Vec alloc would still run.
1923        let slot = self.slot_of(table)?;
1924        // Gather every deleted row's overflow chain up front (empty and cheap
1925        // for inline-only tables) so the pages can be freed once safe.
1926        let old_pages = self.collect_overflow_pages(slot, rids)?;
1927        if self.wal.is_off() {
1928            let count = self.tables[slot].delete_many(rids)?;
1929            self.free_overflow_chain(slot, old_pages);
1930            return Ok(count);
1931        }
1932        let tx_id = self.next_tx();
1933        for &rid in rids {
1934            let payload = encode_wal_payload(table, rid, &[]);
1935            self.wal.append(tx_id, WalRecordType::Delete, &payload)?;
1936        }
1937        if self.active_tx_id.is_none() && !rids.is_empty() {
1938            self.pending_autocommit_tx_ids.push(tx_id);
1939        }
1940        let count = self.tables[slot].delete_many(rids)?;
1941        self.free_overflow_chain(slot, old_pages);
1942        Ok(count)
1943    }
1944
1945    /// Collect all overflow-chain pages referenced by `rids` in one table.
1946    /// Returns empty for inline-only tables without touching any row.
1947    fn collect_overflow_pages(&self, slot: usize, rids: &[RowId]) -> io::Result<Vec<u32>> {
1948        if !self.tables[slot].has_overflow_rows() {
1949            return Ok(Vec::new());
1950        }
1951        let mut pages = Vec::new();
1952        for &rid in rids {
1953            pages.extend(self.tables[slot].overflow_chain_pages_at(rid)?);
1954        }
1955        Ok(pages)
1956    }
1957
1958    /// Single-pass scan-and-delete driven by a raw-bytes predicate. See
1959    /// [`Table::scan_delete_matching`] and `HeapFile::scan_delete_matching`
1960    /// for the fusion rationale.
1961    ///
1962    /// Prefer [`Self::scan_delete_matching_logged`] from any
1963    /// caller that needs crash durability. This variant writes no WAL
1964    /// records, so a crash between the scan and the next checkpoint
1965    /// would lose the deletes. Kept here for internal paths (e.g.
1966    /// `drop_table`) where the whole heap is about to be removed anyway.
1967    pub fn scan_delete_matching<P>(&mut self, table: &str, pred: P) -> io::Result<u64>
1968    where
1969        P: FnMut(&[u8]) -> bool,
1970    {
1971        self.by_name_mut(table)?.scan_delete_matching(pred)
1972    }
1973
1974    /// WAL-logged variant of [`Self::scan_delete_matching`].
1975    /// Every matched row emits one `WalRecordType::Delete` record in the
1976    /// same single-pass scan (via the table's `_with_hook` variant), so
1977    /// crash recovery sees every deletion. Used by the executor's
1978    /// `Delete(Filter(SeqScan))` and bare `Delete(SeqScan)` fast paths.
1979    ///
1980    /// Performance cost vs the non-logged primitive is one per-row WAL
1981    /// append into the in-memory buffer plus one `fsync` at the end —
1982    /// the heap scan itself still runs as a single pass with one
1983    /// `ensure_hot` per page.
1984    pub fn scan_delete_matching_logged<P>(&mut self, table: &str, pred: P) -> io::Result<u64>
1985    where
1986        P: FnMut(&[u8]) -> bool,
1987    {
1988        // Mission B (post-review, second pass): in Off mode the per-row
1989        // hook would build a Vec, do five extends, and then `append`
1990        // would no-op. Skip the WAL hook entirely and route through
1991        // the no-WAL primitive — same single-pass scan, zero per-row
1992        // payload work.
1993        if self.wal.is_off() {
1994            return self.by_name_mut(table)?.scan_delete_matching(pred);
1995        }
1996        // Resolve slot up front so we can split the borrow — the user
1997        // hook closes over `&mut self.wal`, which can't coexist with a
1998        // `by_name_mut` borrow of `self.tables`.
1999        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
2000            io::Error::new(
2001                io::ErrorKind::NotFound,
2002                format!("table '{table}' not found"),
2003            )
2004        })?;
2005        let tx_id = self.next_tx();
2006        let autocommit = self.active_tx_id.is_none();
2007        // Split-borrow the catalog fields so the hook can write into
2008        // `wal` while the scan pins `tables[slot]` mutably.
2009        let Catalog { tables, wal, .. } = self;
2010        let tbl = &mut tables[slot];
2011        // Pre-encode the table-name prefix of every WAL payload once —
2012        // it doesn't vary row-to-row, and the per-row rid+row bytes are
2013        // the only things we append inside the hook.
2014        let name_bytes = table.as_bytes();
2015        let count = tbl.scan_delete_matching_with_hook(pred, |rid, row_bytes| {
2016            let mut payload: Vec<u8> =
2017                Vec::with_capacity(4 + name_bytes.len() + 10 + row_bytes.len());
2018            payload.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
2019            payload.extend_from_slice(name_bytes);
2020            payload.extend_from_slice(&rid.page_id.to_le_bytes());
2021            payload.extend_from_slice(&rid.slot_index.to_le_bytes());
2022            // Delete records carry no row payload on replay, but we
2023            // match the `encode_wal_payload` layout so `decode_wal_payload`
2024            // (which is type-agnostic) parses them cleanly.
2025            payload.extend_from_slice(&0u32.to_le_bytes());
2026            // Best-effort append — if it errors we have no way to
2027            // propagate from inside the hook; we swallow it here and
2028            // the outer scan's `io::Result` will still succeed. In
2029            // practice the `BufWriter`-backed `Wal::append` only errors
2030            // on allocation failure or a disk-full fsync, both of
2031            // which would fail the outer flush below as well.
2032            let _ = wal.append(tx_id, WalRecordType::Delete, &payload);
2033        })?;
2034        if autocommit && count > 0 {
2035            self.pending_autocommit_tx_ids.push(tx_id);
2036        }
2037        // Flush is deferred to the executor's statement-end `sync_wal`.
2038        Ok(count)
2039    }
2040
2041    /// Single-pass fused scan + in-place patch with WAL logging.
2042    /// Evaluates `pred` on raw row bytes and applies `try_mutate` to each
2043    /// match on the same hot page — no second pass. Returns
2044    /// `(patched_count, fallback_rids)`.
2045    ///
2046    /// Perf sprint: update analogue of `scan_delete_matching_logged`.
2047    /// Eliminates the two-pass collect-then-patch pattern.
2048    pub fn scan_patch_matching_logged<P, M>(
2049        &mut self,
2050        table: &str,
2051        pred: P,
2052        try_mutate: M,
2053    ) -> io::Result<(u64, Vec<RowId>)>
2054    where
2055        P: FnMut(&[u8]) -> bool,
2056        M: FnMut(&mut [u8]) -> Option<u16>,
2057    {
2058        if self.wal.is_off() {
2059            return self.by_name_mut(table)?.scan_patch_matching_with_hook(
2060                pred,
2061                try_mutate,
2062                |_, _| {},
2063            );
2064        }
2065        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
2066            io::Error::new(
2067                io::ErrorKind::NotFound,
2068                format!("table '{table}' not found"),
2069            )
2070        })?;
2071        let tx_id = self.next_tx();
2072        let autocommit = self.active_tx_id.is_none();
2073        let Catalog { tables, wal, .. } = self;
2074        let tbl = &mut tables[slot];
2075        let name_bytes = table.as_bytes();
2076        let result = tbl.scan_patch_matching_with_hook(pred, try_mutate, |rid, row_bytes| {
2077            let mut payload: Vec<u8> =
2078                Vec::with_capacity(4 + name_bytes.len() + 10 + row_bytes.len());
2079            payload.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
2080            payload.extend_from_slice(name_bytes);
2081            payload.extend_from_slice(&rid.page_id.to_le_bytes());
2082            payload.extend_from_slice(&rid.slot_index.to_le_bytes());
2083            payload.extend_from_slice(&(row_bytes.len() as u32).to_le_bytes());
2084            payload.extend_from_slice(row_bytes);
2085            let _ = wal.append(tx_id, WalRecordType::Update, &payload);
2086        })?;
2087        if autocommit && result.0 > 0 {
2088            self.pending_autocommit_tx_ids.push(tx_id);
2089        }
2090        Ok(result)
2091    }
2092
2093    pub fn update(&mut self, table: &str, rid: RowId, values: &Row) -> io::Result<RowId> {
2094        // Mission B (post-review, second pass): WAL Off → no payload
2095        // construction.
2096        if self.wal.is_off() {
2097            let slot = self.slot_of(table)?;
2098            let old_pages = self.tables[slot].overflow_chain_pages_at(rid)?;
2099            let new_rid = self.tables[slot].update(rid, values)?;
2100            self.free_overflow_chain(slot, old_pages);
2101            return Ok(new_rid);
2102        }
2103        let slot = self.slot_of(table)?;
2104        self.update_logged(slot, table, rid, values, None)
2105    }
2106
2107    /// Shared WAL-logged body of [`Self::update`] and [`Self::update_hinted`].
2108    /// The two entry points differ only in the changed-column hint they pass
2109    /// through to the table, and the redo shape they have to log is decided
2110    /// identically, so the decision lives in one place.
2111    fn update_logged(
2112        &mut self,
2113        slot: usize,
2114        table: &str,
2115        rid: RowId,
2116        values: &Row,
2117        changed_col_indices: Option<&[usize]>,
2118    ) -> io::Result<RowId> {
2119        self.tables[slot].preflight_update(rid, values)?;
2120        let tx_id = self.next_tx();
2121        // Capture the old row's overflow chain (empty for inline-only tables)
2122        // BEFORE the update replaces it, so it can be freed once safe (design
2123        // 3.6). A chain-replacing update always orphans the old chain.
2124        let old_pages = self.tables[slot].overflow_chain_pages_at(rid)?;
2125        // Spill-aware encode: logs any overflow chains under `tx_id` (before
2126        // the row record) and returns the v1/v2 row bytes. An overflow
2127        // transition relocates the row via heap delete+insert inside
2128        // `update_encoded`; the old row's chain (if any) is left for `sweep`.
2129        let row_bytes = {
2130            let Catalog { tables, wal, .. } = self;
2131            encode_row_with_spill_logged(&mut tables[slot], wal, tx_id, values)?
2132        };
2133        // Reject oversized rows BEFORE appending any record: a logged mutation
2134        // the heap then rejects would poison the next replay. (A v2 stub row is
2135        // always small; only a non-spilled v1 row can trip this.)
2136        check_encoded_row_size(&row_bytes)?;
2137        let fit = self.tables[slot].heap.update_fit(rid, row_bytes.len())?;
2138        if fit == UpdateFit::Relocates {
2139            // A row that no longer fits its page is moved by the heap with
2140            // delete + insert, and *that* is not idempotent: `HeapFile::insert`
2141            // self-assigns a slot, so redoing one Update record would drop the
2142            // row wherever recovery's free list happens to point. If the
2143            // pre-crash copy was already durable that leaves two live copies of
2144            // the row; and either way every later Insert record loses the page
2145            // layout it was logged against. So log the two physical steps the
2146            // heap is about to take: a Delete redoes idempotently, and an
2147            // Insert redoes at its exact RowId through `insert_at`.
2148            self.wal_log(tx_id, WalRecordType::Delete, table, rid, &[])?;
2149            let delete_lsn = self.wal.last_appended_lsn();
2150            let new_rid =
2151                self.tables[slot].update_encoded(rid, values, &row_bytes, changed_col_indices)?;
2152            // Like `insert`, the record is appended after the heap call so it
2153            // carries the real landing RowId.
2154            self.wal_log(tx_id, WalRecordType::Insert, table, new_rid, &row_bytes)?;
2155            let insert_lsn = self.wal.last_appended_lsn();
2156            // Stamp both pages so the per-page redo guard can fire on each
2157            // half independently, a crash that flushed one page but not the
2158            // other must redo only the missing half.
2159            if delete_lsn > 0 {
2160                self.tables[slot]
2161                    .heap
2162                    .set_page_lsn(rid.page_id, delete_lsn)?;
2163            }
2164            if insert_lsn > 0 {
2165                self.tables[slot]
2166                    .heap
2167                    .set_page_lsn(new_rid.page_id, insert_lsn)?;
2168            }
2169            self.free_overflow_chain(slot, old_pages);
2170            return Ok(new_rid);
2171        }
2172        // `Missing` also lands here: `update_encoded` rejects a vanished row
2173        // with a typed error, and reaching that error without having logged
2174        // anything is what keeps a failed statement out of the next replay.
2175        self.wal_log(tx_id, WalRecordType::Update, table, rid, &row_bytes)?;
2176        let lsn = self.wal.last_appended_lsn();
2177        let new_rid =
2178            self.tables[slot].update_encoded(rid, values, &row_bytes, changed_col_indices)?;
2179        // An in-place redo is idempotent, but stamping is what lets the guard
2180        // skip it once the page is durable. Unstamped, every Update record
2181        // re-applied on every recovery, and a grow-in-place update re-appends
2182        // its bytes at `free_start` each time until the page runs out of room
2183        // and the redo starts relocating rows.
2184        if lsn > 0 {
2185            self.tables[slot].heap.set_page_lsn(new_rid.page_id, lsn)?;
2186        }
2187        self.free_overflow_chain(slot, old_pages);
2188        Ok(new_rid)
2189    }
2190
2191    /// Mission C Phase 2: update with a hint about which columns actually
2192    /// changed. Lets [`Table::update_hinted`] skip the old-row read when
2193    /// the hint shows no indexed column is in the changed set.
2194    pub fn update_hinted(
2195        &mut self,
2196        table: &str,
2197        rid: RowId,
2198        values: &Row,
2199        changed_col_indices: Option<&[usize]>,
2200    ) -> io::Result<RowId> {
2201        // Mission B (post-review, second pass): WAL Off → no payload
2202        // construction. The `update_by_filter` powql bench drives this
2203        // path tens of thousands of times per iteration.
2204        if self.wal.is_off() {
2205            let slot = self.slot_of(table)?;
2206            let old_pages = self.tables[slot].overflow_chain_pages_at(rid)?;
2207            let new_rid = self.tables[slot].update_hinted(rid, values, changed_col_indices)?;
2208            self.free_overflow_chain(slot, old_pages);
2209            return Ok(new_rid);
2210        }
2211        let slot = self.slot_of(table)?;
2212        self.update_logged(slot, table, rid, values, changed_col_indices)
2213    }
2214
2215    /// Mission C Phase 4: fast-path update that patches a row's raw bytes
2216    /// in place, skipping decode/encode. Caller guarantees the mutation
2217    /// preserves the row length and touches no indexed column. Returns
2218    /// `Ok(true)` if the patch landed, `Ok(false)` if the row is gone.
2219    ///
2220    /// This primitive does NOT log to the WAL. Executor
2221    /// callers must route through [`Self::update_row_bytes_logged`] (or
2222    /// [`Self::update_row_bytes_logged_by_slot`]) so crash recovery
2223    /// sees the patched bytes. This raw form is retained for replay
2224    /// itself and any future callers that can tolerate the non-durable
2225    /// contract.
2226    #[inline]
2227    pub fn with_row_bytes_mut<F>(&mut self, table: &str, rid: RowId, f: F) -> io::Result<bool>
2228    where
2229        F: FnOnce(&mut [u8]),
2230    {
2231        self.by_name_mut(table)?.with_row_bytes_mut(rid, f)
2232    }
2233
2234    /// WAL-logged variant of [`Self::with_row_bytes_mut`].
2235    /// Applies `f` to the live row bytes on the hot page, then reads
2236    /// the mutated bytes back and emits a `WalRecordType::Update`
2237    /// record so replay will re-apply the same patch after a crash.
2238    ///
2239    /// Ordering: the hot-page mutation happens first (in-memory only,
2240    /// no disk I/O), then the WAL record is appended and flushed. A
2241    /// crash after the mutation but before the WAL flush loses the
2242    /// update, but the caller never saw success in that case, so the
2243    /// contract holds: any `Ok(true)` return is durable.
2244    ///
2245    /// No hot-page eviction can happen between steps because this
2246    /// method holds the catalog's `&mut self` exclusively.
2247    #[inline]
2248    pub fn update_row_bytes_logged<F>(&mut self, table: &str, rid: RowId, f: F) -> io::Result<bool>
2249    where
2250        F: FnOnce(&mut [u8]),
2251    {
2252        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
2253            io::Error::new(
2254                io::ErrorKind::NotFound,
2255                format!("table '{table}' not found"),
2256            )
2257        })?;
2258        self.update_row_bytes_logged_by_slot(slot, rid, f)
2259    }
2260
2261    /// Slot-indexed counterpart to [`Self::update_row_bytes_logged`].
2262    /// Used by prepared-query fast paths that already cached the table
2263    /// slot at prepare time and want to skip the name->slot probe on
2264    /// every execution.
2265    #[inline]
2266    pub fn update_row_bytes_logged_by_slot<F>(
2267        &mut self,
2268        slot: usize,
2269        rid: RowId,
2270        f: F,
2271    ) -> io::Result<bool>
2272    where
2273        F: FnOnce(&mut [u8]),
2274    {
2275        // Step 1: apply the mutation on the hot page. Failure here
2276        // (slot gone) short-circuits with Ok(false) — no WAL record.
2277        let tbl = &mut self.tables[slot];
2278        let ok = tbl.with_row_bytes_mut(rid, f)?;
2279        if !ok {
2280            return Ok(false);
2281        }
2282        // Mission B (post-review, second pass): in Off mode the per-row
2283        // get + clone + table-name clone + wal_log call are all wasted
2284        // — `wal.append` would no-op. Skip the snapshot path entirely.
2285        if self.wal.is_off() {
2286            return Ok(true);
2287        }
2288        // Step 2: snapshot the now-mutated bytes. `HeapFile::get`
2289        // observes the pinned hot page, so it returns the fresh row.
2290        let new_bytes = match tbl.heap.get(rid) {
2291            Some(b) => b,
2292            // Shouldn't happen — we just patched it — but be defensive.
2293            None => return Ok(false),
2294        };
2295        // Step 3: log + flush. Clone the table name out of the schema
2296        // so we can drop the `&mut tbl` borrow before touching `self.wal`.
2297        let table_name = tbl.schema.table_name.clone();
2298        let tx_id = self.next_tx();
2299        self.wal_log(tx_id, WalRecordType::Update, &table_name, rid, &new_bytes)?;
2300        Ok(true)
2301    }
2302
2303    /// Mission C Phase 10: var-column in-place update fast path. Patches
2304    /// a single variable-length column's bytes directly into the row's
2305    /// slot, shrinking the row if the new value is smaller. Returns
2306    /// `Ok(false)` if the new value would grow the row (caller must fall
2307    /// back to the full encode path) or the row is gone.
2308    ///
2309    /// Caller guarantees no indexed column is touched — indexes are NOT
2310    /// maintained by this primitive.
2311    ///
2312    /// Not WAL-logged. Executor callers should use
2313    /// [`Self::patch_var_col_logged`] instead.
2314    #[inline]
2315    pub fn patch_var_col_in_place(
2316        &mut self,
2317        table: &str,
2318        rid: RowId,
2319        col_idx: usize,
2320        new_value: Option<&[u8]>,
2321    ) -> io::Result<bool> {
2322        self.by_name_mut(table)?
2323            .patch_var_col_in_place(rid, col_idx, new_value)
2324    }
2325
2326    /// WAL-logged variant of [`Self::patch_var_col_in_place`].
2327    /// Runs the in-place shrink on the hot page, then reads the mutated
2328    /// row bytes back and logs a `WalRecordType::Update` record. On a
2329    /// `false` return (grow-case bail) nothing is logged — the caller's
2330    /// fall-through to `update_hinted` handles the WAL itself.
2331    pub fn patch_var_col_logged(
2332        &mut self,
2333        table: &str,
2334        rid: RowId,
2335        col_idx: usize,
2336        new_value: Option<&[u8]>,
2337    ) -> io::Result<bool> {
2338        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
2339            io::Error::new(
2340                io::ErrorKind::NotFound,
2341                format!("table '{table}' not found"),
2342            )
2343        })?;
2344        let tbl = &mut self.tables[slot];
2345        let ok = tbl.patch_var_col_in_place(rid, col_idx, new_value)?;
2346        if !ok {
2347            return Ok(false);
2348        }
2349        // Mission B (post-review, second pass): WAL Off → skip the
2350        // snapshot + clone + log entirely.
2351        if self.wal.is_off() {
2352            return Ok(true);
2353        }
2354        let new_bytes = match tbl.heap.get(rid) {
2355            Some(b) => b,
2356            None => return Ok(false),
2357        };
2358        let table_name = tbl.schema.table_name.clone();
2359        let tx_id = self.next_tx();
2360        self.wal_log(tx_id, WalRecordType::Update, &table_name, rid, &new_bytes)?;
2361        Ok(true)
2362    }
2363
2364    pub fn scan(
2365        &self,
2366        table: &str,
2367    ) -> io::Result<impl Iterator<Item = io::Result<(RowId, Row)>> + '_> {
2368        Ok(self.by_name(table)?.scan())
2369    }
2370
2371    /// Zero-copy scan: passes raw row bytes to the callback without any
2372    /// per-row allocation. Used by the executor's fast paths.
2373    pub fn for_each_row_raw<F>(&self, table: &str, f: F) -> io::Result<()>
2374    where
2375        F: FnMut(RowId, &[u8]),
2376    {
2377        self.by_name(table)?.for_each_row_raw(f)
2378    }
2379
2380    /// Zero-copy scan with early termination. The callback returns
2381    /// `ControlFlow::Break(())` to stop. Used by `Limit` fast paths so a
2382    /// `limit 100` query doesn't pay decode/predicate cost for every row
2383    /// in the table after the limit is reached.
2384    pub fn try_for_each_row_raw<F>(&self, table: &str, f: F) -> io::Result<()>
2385    where
2386        F: FnMut(RowId, &[u8]) -> std::ops::ControlFlow<()>,
2387    {
2388        self.by_name(table)?.try_for_each_row_raw(f)
2389    }
2390
2391    pub fn create_index(&mut self, table: &str, column: &str) -> io::Result<()> {
2392        self.create_index_unique(table, column, false)
2393    }
2394
2395    /// Create an index with an explicit uniqueness flag. `unique = true`
2396    /// for primary-key-like columns where duplicate values should
2397    /// overwrite. `unique = false` for secondary indexes that allow
2398    /// duplicate column values (the default via `create_index`).
2399    pub fn create_index_unique(
2400        &mut self,
2401        table: &str,
2402        column: &str,
2403        unique: bool,
2404    ) -> io::Result<()> {
2405        self.ensure_no_active_transaction_for_ddl("create index")?;
2406        self.invalidate_structure();
2407        let data_dir = self.data_dir.clone();
2408        self.by_name_mut(table)?
2409            .create_index_with_unique(column, &data_dir, unique)?;
2410        // Mission 3: persist the updated catalog so the indexed column
2411        // list survives a restart. `Table::create_index` already saved
2412        // the btree file itself.
2413        self.persist()
2414    }
2415
2416    pub fn active_catalog_version(&self) -> u16 {
2417        self.active_catalog_version
2418    }
2419
2420    pub fn next_index_id(&self) -> u64 {
2421        self.next_index_id
2422    }
2423
2424    /// Return both legacy column-index and v6 expression-index identities.
2425    pub fn index_metadata(&self, table: &str) -> Option<Vec<IndexMetadata>> {
2426        let table_ref = self.get_table(table)?;
2427        let mut metadata = table_ref
2428            .indexed_column_metas()
2429            .into_iter()
2430            .map(|index| IndexMetadata {
2431                unique: index.unique,
2432                source: IndexKeySource::Column { column: index.name },
2433            })
2434            .collect::<Vec<_>>();
2435        metadata.extend(table_ref.expression_index_metas().into_iter().map(|index| {
2436            IndexMetadata {
2437                unique: index.unique,
2438                source: IndexKeySource::Expression {
2439                    index_id: index.index_id,
2440                    canonical_version: index.canonical_version,
2441                    canonical_text: index.canonical_text,
2442                    json_path: index.json_path,
2443                },
2444            }
2445        }));
2446        Some(metadata)
2447    }
2448
2449    pub fn expression_index_metadata(&self, table: &str) -> Option<Vec<ExpressionIndexMeta>> {
2450        Some(self.get_table(table)?.expression_index_metas())
2451    }
2452
2453    pub fn expression_index_btree(&self, table: &str, index_id: u64) -> Option<&BTree> {
2454        self.get_table(table)?.expression_index_btree(index_id)
2455    }
2456
2457    /// Per-index statistics for a column index. O(1) read of the loaded tree's
2458    /// in-memory counters; `None` when the table or column index is absent. Used
2459    /// by the conjunction index chooser during plan lowering.
2460    pub fn index_stats(&self, table: &str, column: &str) -> Option<IndexStats> {
2461        Some(self.get_table(table)?.index(column)?.stats())
2462    }
2463
2464    /// Per-index statistics for an expression index by id. O(1).
2465    pub fn expression_index_stats(&self, table: &str, index_id: u64) -> Option<IndexStats> {
2466        Some(
2467            self.get_table(table)?
2468                .expression_index_btree(index_id)?
2469                .stats(),
2470        )
2471    }
2472
2473    /// Capped count of column-index entries equal to `key`, or `None` when
2474    /// `column` has no index. `O(min(count, cap))` allocation-free leaf walk used
2475    /// by the planner's skew guard to detect a hot literal without materialising
2476    /// its (possibly huge) RowId list. Routes to the raw-key counter for a unique
2477    /// index and the composite-prefix counter for a non-unique one.
2478    pub fn index_key_count_capped(
2479        &self,
2480        table: &str,
2481        column: &str,
2482        key: &Value,
2483        cap: usize,
2484    ) -> Option<usize> {
2485        let unique = self.is_index_unique(table, column)?;
2486        let tree = self.get_table(table)?.index(column)?;
2487        Some(if unique {
2488            tree.count_key_capped(key, cap)
2489        } else {
2490            tree.count_prefix_capped(key, cap)
2491        })
2492    }
2493
2494    /// Capped count of expression-index entries equal to `key` (a raw-key tree
2495    /// whose duplicate keys repeat physically). `O(min(count, cap))`.
2496    pub fn expression_index_key_count_capped(
2497        &self,
2498        table: &str,
2499        index_id: u64,
2500        key: &Value,
2501        cap: usize,
2502    ) -> Option<usize> {
2503        Some(
2504            self.get_table(table)?
2505                .expression_index_btree(index_id)?
2506                .count_key_capped(key, cap),
2507        )
2508    }
2509
2510    pub fn expression_index_btree_mut(&mut self, table: &str, index_id: u64) -> Option<&mut BTree> {
2511        self.get_table_mut(table)?
2512            .expression_index_btree_mut(index_id)
2513    }
2514
2515    pub fn expression_index_lookup_all(
2516        &self,
2517        table: &str,
2518        index_id: u64,
2519        key: &Value,
2520    ) -> io::Result<Vec<RowId>> {
2521        let tree = self
2522            .by_name(table)?
2523            .expression_index_btree(index_id)
2524            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "expression index not found"))?;
2525        Ok(tree.lookup_all(key))
2526    }
2527
2528    pub fn expression_index_range_rids(
2529        &self,
2530        table: &str,
2531        index_id: u64,
2532        start: Option<&Value>,
2533        end: Option<&Value>,
2534    ) -> io::Result<Vec<RowId>> {
2535        let tree = self
2536            .by_name(table)?
2537            .expression_index_btree(index_id)
2538            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "expression index not found"))?;
2539        Ok(tree.raw_range_rids(start, end))
2540    }
2541
2542    pub fn expression_index_ordered_rids(
2543        &self,
2544        table: &str,
2545        index_id: u64,
2546    ) -> io::Result<Vec<RowId>> {
2547        let tree = self
2548            .by_name(table)?
2549            .expression_index_btree(index_id)
2550            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "expression index not found"))?;
2551        Ok(tree.ordered_rids_nulls_last())
2552    }
2553
2554    pub fn expression_index_ordered_rids_bounded(
2555        &self,
2556        table: &str,
2557        index_id: u64,
2558        direction: IndexOrderDirection,
2559        offset: usize,
2560        limit: usize,
2561    ) -> io::Result<Vec<RowId>> {
2562        let tree = self
2563            .by_name(table)?
2564            .expression_index_btree(index_id)
2565            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "expression index not found"))?;
2566        Ok(tree.bounded_ordered_rids_nulls_last(
2567            direction == IndexOrderDirection::Desc,
2568            offset,
2569            limit,
2570        ))
2571    }
2572
2573    pub fn drop_expression_index(&mut self, table: &str, index_id: u64) -> io::Result<()> {
2574        self.ensure_no_active_transaction_for_ddl("drop index")?;
2575        self.invalidate_structure();
2576        validate_table_name(table)?;
2577        let removed = self
2578            .by_name_mut(table)?
2579            .take_expression_index(index_id)
2580            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "expression index not found"))?;
2581        match self.persist_at_activation_boundary() {
2582            Ok(()) => {}
2583            Err(CatalogPersistError::BeforeActivation(error)) => {
2584                self.by_name_mut(table)?.restore_expression_index(removed);
2585                return Err(error);
2586            }
2587            Err(CatalogPersistError::AfterActivation(error)) => {
2588                warn!(
2589                    path = %self.data_dir.display(),
2590                    error = %error,
2591                    "expression index drop committed but catalog directory sync failed"
2592                );
2593            }
2594        }
2595        let index_path = self
2596            .data_dir
2597            .join(expression_index_file_name(table, index_id));
2598        if let Err(error) = fs::remove_file(&index_path) {
2599            if error.kind() != io::ErrorKind::NotFound {
2600                warn!(path = %index_path.display(), error = %error, "failed to remove dropped expression index file");
2601            }
2602        } else if let Err(error) = sync_directory(&self.data_dir) {
2603            warn!(path = %self.data_dir.display(), error = %error, "failed to sync expression index deletion");
2604        }
2605        Ok(())
2606    }
2607
2608    /// Persist expression-index identity and create its backup-compatible
2609    /// `.eidx` file. The catalog stays at v5 until every validation and file
2610    /// creation step succeeds; the v6 catalog rename is the activation point.
2611    pub fn create_expression_index_metadata(
2612        &mut self,
2613        table: &str,
2614        canonical_version: u16,
2615        canonical_text: impl Into<String>,
2616        json_path: StoredJsonPathV1,
2617        unique: bool,
2618    ) -> io::Result<u64> {
2619        self.ensure_no_active_transaction_for_ddl("create index")?;
2620        self.invalidate_structure();
2621        validate_table_name(table)?;
2622        validate_column_name(&json_path.column)?;
2623        if canonical_version == 0 {
2624            return Err(io::Error::new(
2625                io::ErrorKind::InvalidInput,
2626                "expression canonical version must be non-zero",
2627            ));
2628        }
2629        let canonical_text = canonical_text.into();
2630        if canonical_text.is_empty() {
2631            return Err(io::Error::new(
2632                io::ErrorKind::InvalidInput,
2633                "expression canonical text must not be empty",
2634            ));
2635        }
2636        if canonical_version == 1 && canonical_text != json_path.canonical_text() {
2637            return Err(io::Error::new(
2638                io::ErrorKind::InvalidInput,
2639                "expression canonical text does not match its stored JSON path",
2640            ));
2641        }
2642        let table_ref = self.by_name(table)?;
2643        let root_index = table_ref
2644            .schema
2645            .column_index(&json_path.column)
2646            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "JSON root column not found"))?;
2647        if table_ref.schema.columns[root_index].type_id != TypeId::Json {
2648            return Err(io::Error::new(
2649                io::ErrorKind::InvalidInput,
2650                "expression index root column must have type json",
2651            ));
2652        }
2653        if table_ref.expression_index_metas().iter().any(|index| {
2654            index.canonical_version == canonical_version && index.canonical_text == canonical_text
2655        }) {
2656            return Err(io::Error::new(
2657                io::ErrorKind::AlreadyExists,
2658                "expression index already exists",
2659            ));
2660        }
2661
2662        let index_id = self.next_index_id;
2663        let next_index_id = index_id
2664            .checked_add(1)
2665            .ok_or_else(|| io::Error::other("expression index id space exhausted"))?;
2666        let index_path = self
2667            .data_dir
2668            .join(expression_index_file_name(table, index_id));
2669        if index_path.exists() {
2670            // The allocator proves this ID is not referenced by the active
2671            // catalog. A file here can therefore only be an orphan from a
2672            // crash after the index-file fsync but before catalog activation.
2673            fs::remove_file(&index_path)?;
2674            sync_directory(&self.data_dir)?;
2675        }
2676        let meta = ExpressionIndexMeta {
2677            index_id,
2678            unique,
2679            canonical_version,
2680            canonical_text,
2681            json_path,
2682        };
2683        self.by_name_mut(table)?
2684            .install_expression_index(meta, &index_path)?;
2685        if let Err(error) = sync_directory(&self.data_dir) {
2686            self.by_name_mut(table)?
2687                .remove_expression_index_by_id(index_id);
2688            let _ = fs::remove_file(&index_path);
2689            return Err(error);
2690        }
2691
2692        let previous_version = self.active_catalog_version;
2693        let previous_next_id = self.next_index_id;
2694        // Activate exactly the format version this feature needs (v6). Using a
2695        // `max` (never a bare assign) keeps a database that already declared a
2696        // link at v7 from being silently downgraded when it later adds an
2697        // expression index.
2698        self.active_catalog_version = self
2699            .active_catalog_version
2700            .max(EXPRESSION_INDEX_CATALOG_VERSION);
2701        self.next_index_id = next_index_id;
2702        match self.persist_at_activation_boundary() {
2703            Ok(()) => {}
2704            Err(CatalogPersistError::BeforeActivation(error)) => {
2705                self.by_name_mut(table)?
2706                    .remove_expression_index_by_id(index_id);
2707                self.active_catalog_version = previous_version;
2708                self.next_index_id = previous_next_id;
2709                let _ = fs::remove_file(&index_path);
2710                let _ = sync_directory(&self.data_dir);
2711                return Err(error);
2712            }
2713            Err(CatalogPersistError::AfterActivation(error)) => {
2714                warn!(
2715                    path = %self.data_dir.display(),
2716                    error = %error,
2717                    "expression index creation committed but catalog directory sync failed"
2718                );
2719            }
2720        }
2721        Ok(index_id)
2722    }
2723
2724    /// Declare a relationship link on `def.owner_type`. This is the first
2725    /// operation that activates catalog format v7; a database that never calls
2726    /// it stays at its current (v6-or-older) version.
2727    ///
2728    /// Validation (all at declare time): both `owner_type` and `target_type`
2729    /// must be existing tables, `local_key` must be a column on the owner,
2730    /// `target_key` a column on the target, and `name` must collide with neither
2731    /// a column on the owner nor an existing link on the same owner.
2732    ///
2733    /// Declaration order does not pin the cardinality, because the engine does
2734    /// not store the cardinality: every reader calls [`Self::link_kind`], which
2735    /// derives it from the target key's uniqueness at the moment of the read.
2736    /// `link` then `unique` and `unique` then `link` therefore reach the same
2737    /// schema with the same behaviour. The advisory [`LinkDef::kind`] byte is
2738    /// seeded here from the same derivation purely so the v7 on-disk layout
2739    /// keeps a value in that position; any `kind` supplied by the caller is
2740    /// ignored, and nothing reads the byte back.
2741    ///
2742    /// Activation is lazy and crash-safe, mirroring
2743    /// [`Self::create_expression_index_metadata`]: on any persist failure before
2744    /// the catalog rename, the in-memory registry and the format version both
2745    /// revert and no partial state remains.
2746    pub fn create_link(&mut self, def: LinkDef) -> io::Result<()> {
2747        self.ensure_no_active_transaction_for_ddl("create link")?;
2748        self.invalidate_structure();
2749        validate_table_name(&def.owner_type)?;
2750        validate_table_name(&def.target_type)?;
2751        validate_column_name(&def.name)?;
2752        validate_column_name(&def.local_key)?;
2753        validate_column_name(&def.target_key)?;
2754
2755        // Owner table + local key must exist; the link name must not shadow a
2756        // column or an existing link on the owner.
2757        {
2758            let owner = self.by_name(&def.owner_type)?;
2759            if owner.schema.column_index(&def.local_key).is_none() {
2760                return Err(io::Error::new(
2761                    io::ErrorKind::NotFound,
2762                    format!(
2763                        "link local key '{}' is not a column on owner type '{}'",
2764                        def.local_key, def.owner_type
2765                    ),
2766                ));
2767            }
2768            if owner.schema.column_index(&def.name).is_some() {
2769                return Err(io::Error::new(
2770                    io::ErrorKind::AlreadyExists,
2771                    format!(
2772                        "link name '{}' collides with a column on owner type '{}'",
2773                        def.name, def.owner_type
2774                    ),
2775                ));
2776            }
2777        }
2778        if self
2779            .links
2780            .iter()
2781            .any(|l| l.owner_type == def.owner_type && l.name == def.name)
2782        {
2783            return Err(io::Error::new(
2784                io::ErrorKind::AlreadyExists,
2785                format!(
2786                    "link '{}' already exists on owner type '{}'",
2787                    def.name, def.owner_type
2788                ),
2789            ));
2790        }
2791
2792        // Target table + target key must exist.
2793        {
2794            let target = self.by_name(&def.target_type)?;
2795            if target.schema.column_index(&def.target_key).is_none() {
2796                return Err(io::Error::new(
2797                    io::ErrorKind::NotFound,
2798                    format!(
2799                        "link target key '{}' is not a column on target type '{}'",
2800                        def.target_key, def.target_type
2801                    ),
2802                ));
2803            }
2804        }
2805
2806        // Seed the advisory byte so the v7 layout keeps a value in that slot.
2807        // It is written once and never refreshed; readers derive instead, so
2808        // this value is a record of the past, not a decision about the future.
2809        let kind = self.derive_link_kind(&def.target_type, &def.target_key);
2810        let stored = LinkDef { kind, ..def };
2811
2812        // Lazy activation + proven rollback pattern (see
2813        // create_expression_index_metadata). Register, bump the version, persist;
2814        // on a pre-rename failure, undo both.
2815        self.links.push(stored);
2816        let previous_version = self.active_catalog_version;
2817        self.active_catalog_version = self.active_catalog_version.max(CATALOG_VERSION);
2818        match self.persist_at_activation_boundary() {
2819            Ok(()) => {}
2820            Err(CatalogPersistError::BeforeActivation(error)) => {
2821                self.links.pop();
2822                self.active_catalog_version = previous_version;
2823                let _ = sync_directory(&self.data_dir);
2824                return Err(error);
2825            }
2826            Err(CatalogPersistError::AfterActivation(error)) => {
2827                warn!(
2828                    path = %self.data_dir.display(),
2829                    error = %error,
2830                    "link creation committed but catalog directory sync failed"
2831                );
2832            }
2833        }
2834        Ok(())
2835    }
2836
2837    /// Cardinality of a link between `target_type.target_key` and its owners,
2838    /// computed from the catalog as it stands right now. A unique index on the
2839    /// target key means a hop matches at most one row (`ToOne`); anything else
2840    /// (a plain index, or no index at all) can fan out (`ToMany`).
2841    ///
2842    /// This is the only place the fact is computed, and there is no cached copy
2843    /// of the answer anywhere: [`LinkDef::kind`] is advisory and must not be
2844    /// consulted. Every cardinality decision in the engine ends up here, so a
2845    /// schema change is visible to the next statement with no repair pass, no
2846    /// re-declaration and no reopen.
2847    pub fn derive_link_kind(&self, target_type: &str, target_key: &str) -> LinkKind {
2848        if self.is_index_unique(target_type, target_key) == Some(true) {
2849            LinkKind::ToOne
2850        } else {
2851            LinkKind::ToMany
2852        }
2853    }
2854
2855    /// Cardinality of the link registered under `(owner_type, name)`, derived
2856    /// from the catalog as it stands now. `None` when no such link exists.
2857    /// This is the accessor every correctness decision should use; reading
2858    /// [`LinkDef::kind`] instead is the bug this API exists to prevent.
2859    pub fn link_kind(&self, owner_type: &str, name: &str) -> Option<LinkKind> {
2860        let link = self.link(owner_type, name)?;
2861        Some(self.derive_link_kind(&link.target_type, &link.target_key))
2862    }
2863
2864    /// Resolve a link by its `(owner_type, name)` registry key. The returned
2865    /// reference borrows the catalog immutably.
2866    pub fn link(&self, owner_type: &str, name: &str) -> Option<&LinkDef> {
2867        self.links
2868            .iter()
2869            .find(|l| l.owner_type == owner_type && l.name == name)
2870    }
2871
2872    /// Iterate every declared link in declaration order.
2873    pub fn links(&self) -> impl Iterator<Item = &LinkDef> + '_ {
2874        self.links.iter()
2875    }
2876
2877    /// Remove a link by its `(owner_type, name)` registry key. Metadata-only:
2878    /// it deletes no data and touches no secondary structures. Errors with
2879    /// `NotFound` if no such link exists. Does not downgrade the format version
2880    /// (consistent with every other drop path — the version floor only rises).
2881    pub fn drop_link(&mut self, owner_type: &str, name: &str) -> io::Result<()> {
2882        self.ensure_no_active_transaction_for_ddl("drop link")?;
2883        self.invalidate_structure();
2884        let idx = self
2885            .links
2886            .iter()
2887            .position(|l| l.owner_type == owner_type && l.name == name)
2888            .ok_or_else(|| {
2889                io::Error::new(
2890                    io::ErrorKind::NotFound,
2891                    format!("link '{name}' not found on owner type '{owner_type}'"),
2892                )
2893            })?;
2894        let removed = self.links.remove(idx);
2895        if let Err(error) = self.persist() {
2896            // Restore the in-memory registry so it matches what is still on disk.
2897            self.links.insert(idx, removed);
2898            return Err(error);
2899        }
2900        Ok(())
2901    }
2902
2903    /// First link that references `table` as either owner or target, if any.
2904    /// Used to guard `DROP TABLE` (a referenced table cannot be dropped while a
2905    /// link names it, the same discipline indexes use).
2906    fn link_referencing_table(&self, table: &str) -> Option<&LinkDef> {
2907        self.links
2908            .iter()
2909            .find(|l| l.owner_type == table || l.target_type == table)
2910    }
2911
2912    /// First link that references `table.column` (owner local key or target
2913    /// key), if any. Used to guard `ALTER TABLE DROP COLUMN`.
2914    fn link_referencing_column(&self, table: &str, column: &str) -> Option<&LinkDef> {
2915        self.links.iter().find(|l| {
2916            (l.owner_type == table && l.local_key == column)
2917                || (l.target_type == table && l.target_key == column)
2918        })
2919    }
2920
2921    /// Whether `table.column` has a UNIQUE index. Returns `Some(true)` for
2922    /// a unique index, `Some(false)` for a non-unique index, and `None`
2923    /// when the column is not indexed or the table is unknown.
2924    pub fn is_index_unique(&self, table: &str, column: &str) -> Option<bool> {
2925        self.get_table(table)?.is_index_unique(column)
2926    }
2927
2928    /// Whether `table.column` has any index (unique or non-unique).
2929    pub fn has_index(&self, table: &str, column: &str) -> bool {
2930        self.get_table(table)
2931            .map(|t| t.has_index(column))
2932            .unwrap_or(false)
2933    }
2934
2935    pub fn index_lookup(&self, table: &str, column: &str, key: &Value) -> io::Result<Option<Row>> {
2936        Ok(self
2937            .by_name(table)?
2938            .index_lookup(column, key)
2939            .map(|(_, row)| row))
2940    }
2941
2942    pub fn list_tables(&self) -> Vec<&str> {
2943        // Phase 18: iterate the Vec directly — schema.table_name is
2944        // the source of truth, and Vec order is insertion order (more
2945        // deterministic than the old FxHashMap keys).
2946        self.tables
2947            .iter()
2948            .map(|t| t.schema.table_name.as_str())
2949            .collect()
2950    }
2951
2952    pub fn schema(&self, table: &str) -> Option<&Schema> {
2953        let slot = *self.name_to_slot.get(table)?;
2954        Some(&self.tables[slot].schema)
2955    }
2956
2957    /// Drop a table: remove from the catalog and delete its data files.
2958    /// Returns `Err` if the table doesn't exist.
2959    pub fn drop_table(&mut self, name: &str) -> io::Result<()> {
2960        self.ensure_no_active_transaction_for_ddl("drop table")?;
2961        self.invalidate_structure();
2962        validate_table_name(name)?;
2963        let slot = *self.name_to_slot.get(name).ok_or_else(|| {
2964            io::Error::new(io::ErrorKind::NotFound, format!("table '{name}' not found"))
2965        })?;
2966        // A live relationship link that names this table (as owner or target)
2967        // pins it in place, the same integrity discipline indexes use. The
2968        // link has to go first, and PowQL has no statement that removes one,
2969        // so the message names the surface that does rather than inventing a
2970        // `drop link` statement the parser would reject.
2971        if let Some(link) = self.link_referencing_table(name) {
2972            return Err(io::Error::new(
2973                io::ErrorKind::InvalidInput,
2974                format!(
2975                    "cannot drop table '{name}': link '{}' on '{}' references it. \
2976                     Remove the link first with the embedded API \
2977                     `Catalog::drop_link(\"{}\", \"{}\")`; PowQL has no statement \
2978                     that removes a link",
2979                    link.name, link.owner_type, link.owner_type, link.name
2980                ),
2981            ));
2982        }
2983        if !self.wal.is_off() {
2984            let payload = encode_ddl_drop_table(name);
2985            self.wal.append(0, WalRecordType::DdlDropTable, &payload)?;
2986            self.wal.flush()?;
2987        }
2988        // Remove the data file.
2989        let table = &self.tables[slot];
2990        let heap_path = self
2991            .data_dir
2992            .join(format!("{}.heap", table.schema.table_name));
2993        // Mission 3: remove only the .idx files that actually exist
2994        // (i.e. the columns the table currently has indexed). The pre-
2995        // Mission-3 code iterated every schema column blindly — harmless
2996        // but noisy. Now that we persist a real list of indexed columns,
2997        // we can be precise.
2998        let mut doomed_paths: Vec<PathBuf> = table
2999            .indexed_column_names()
3000            .into_iter()
3001            .map(|col_name| self.data_dir.join(format!("{name}_{col_name}.idx")))
3002            .collect();
3003        doomed_paths.extend(table.expression_index_ids().into_iter().map(|index_id| {
3004            self.data_dir
3005                .join(expression_index_file_name(name, index_id))
3006        }));
3007        // Swap-remove from the Vec and fix up name_to_slot.
3008        self.name_to_slot.remove(name);
3009        let last = self.tables.len() - 1;
3010        if slot != last {
3011            let moved_name = self.tables[last].schema.table_name.clone();
3012            self.tables.swap(slot, last);
3013            self.name_to_slot.insert(moved_name, slot);
3014        }
3015        self.tables.pop();
3016        // The catalog goes first, and nothing is unlinked until it lands.
3017        // `Catalog::open` opens every heap the on-disk catalog names *before*
3018        // it replays the WAL, so a crash between an early unlink and this
3019        // persist would leave a catalog pointing at a heap that no longer
3020        // exists, an open that fails outright, with the `DdlDropTable` record
3021        // that would have finished the drop never even read. Unlinking after
3022        // the catalog is durable inverts the failure into a harmless orphan
3023        // file, which the next `drop_table` of the same name overwrites.
3024        self.persist()?;
3025        if heap_path.exists() {
3026            fs::remove_file(&heap_path)?;
3027        }
3028        for idx_path in doomed_paths {
3029            if idx_path.exists() {
3030                let _ = fs::remove_file(idx_path);
3031            }
3032        }
3033        Ok(())
3034    }
3035
3036    /// Add a column to an existing table's schema and backfill all
3037    /// existing rows to match the new shape.
3038    ///
3039    /// Older versions of this method only mutated the in-memory schema
3040    /// and relied on a (false) claim that "the heap format already
3041    /// handles short rows gracefully". It doesn't: `decode_row` reads
3042    /// exactly `n_var + 1` variable-column offsets from the row bytes
3043    /// using the CURRENT schema. Any row encoded with the old schema's
3044    /// (smaller) offset table would walk off the end of its buffer and
3045    /// panic with "range end index X out of range for slice of length Y"
3046    /// — which is exactly what a bare `Type` scan triggered right after
3047    /// an ALTER ADD COLUMN.
3048    ///
3049    /// The fix: rewrite every existing row through
3050    /// `Table::rewrite_rows_for_schema_change` so the on-disk
3051    /// encoding matches the new schema layout. Existing rows get
3052    /// `Value::Empty` for the new column.
3053    ///
3054    /// If the new column is `required` we refuse to add it to a
3055    /// non-empty table — there is no default value to backfill with,
3056    /// and silently storing `Empty` in a required slot would just
3057    /// shift the invariant violation to the next query.
3058    pub fn alter_table_add_column(&mut self, table: &str, col: ColumnDef) -> io::Result<()> {
3059        self.ensure_no_active_transaction_for_ddl("alter table add column")?;
3060        self.invalidate_structure();
3061        let data_dir = self.data_dir.clone();
3062        {
3063            let tbl = self.by_name_mut(table)?;
3064            if tbl.schema.columns.iter().any(|c| c.name == col.name) {
3065                return Err(io::Error::new(
3066                    io::ErrorKind::AlreadyExists,
3067                    format!("column '{}' already exists in table '{table}'", col.name),
3068                ));
3069            }
3070        }
3071        let barrier_lsn = if !self.wal.is_off() {
3072            let payload = encode_ddl_alter_add_column(table, &col);
3073            self.wal.append(0, WalRecordType::DdlAddColumn, &payload)?;
3074            self.wal.flush()?;
3075            self.wal.last_appended_lsn()
3076        } else {
3077            0
3078        };
3079        let tbl = self.by_name_mut(table)?;
3080
3081        let old_schema = tbl.schema.clone();
3082
3083        // Peek at the heap to learn whether there are any existing
3084        // rows at all. An empty table is always safe to alter — no
3085        // rewrite needed, required columns are fine, etc.
3086        let has_rows = tbl.heap.has_rows()?;
3087
3088        if has_rows && col.required {
3089            return Err(io::Error::new(
3090                io::ErrorKind::InvalidInput,
3091                format!(
3092                    "cannot add required column '{}' to non-empty table '{table}': \
3093                     no default value to backfill existing rows with",
3094                    col.name
3095                ),
3096            ));
3097        }
3098
3099        // Commit the new column into the schema and refresh the
3100        // cached layout so the rewrite below encodes with the new
3101        // shape.
3102        tbl.schema.columns.push(col);
3103        tbl.refresh_layout();
3104
3105        if has_rows {
3106            // Build the "fill" template: all Empty, matching the new
3107            // schema width. `rewrite_rows_for_schema_change` will
3108            // overwrite old-column slots from each live row and leave
3109            // the new slot as Empty.
3110            let fill: Vec<Value> = vec![Value::Empty; tbl.schema.columns.len()];
3111            tbl.rewrite_rows_for_schema_change(&old_schema, &fill, &data_dir)?;
3112        }
3113        // P0 fix (v0.4.3): stamp every heap page with the DDL record's
3114        // LSN so any pre-DDL Insert/Update/Delete WAL record gets
3115        // skipped on replay. Without this barrier, a restart after
3116        // `alter add column` would replay pre-alter inserts (encoded in
3117        // the OLD layout) onto a heap that's already in the NEW layout,
3118        // producing a mixed-version heap that panics on the next
3119        // projection. Regression: see `restart_after_alter_add_column_then_index`.
3120        if barrier_lsn > 0 {
3121            tbl.heap.stamp_all_pages_min_lsn(barrier_lsn)?;
3122            tbl.heap.flush()?;
3123        }
3124
3125        self.persist()?;
3126        Ok(())
3127    }
3128
3129    /// Remove a column from an existing table's schema and rewrite
3130    /// every live row to match the new shape.
3131    ///
3132    /// Older versions of this method only mutated the in-memory schema
3133    /// and claimed that "reads simply won't decode the dropped column".
3134    /// That was wrong in several ways:
3135    ///
3136    ///   1. The null bitmap is indexed by column position. Dropping a
3137    ///      column shifts every later column's bit left, but old rows
3138    ///      still have bits in the original positions — so `is_null`
3139    ///      checks silently lie for every column after the dropped one.
3140    ///   2. The bitmap's byte width (`ceil(n_cols/8)`) can shrink when
3141    ///      `n_cols` crosses an 8-boundary, shifting every subsequent
3142    ///      byte of the row against the decoder's cursor.
3143    ///   3. Fixed-region size and the variable-offset-table width both
3144    ///      depend on the column set, so dropping any fixed or variable
3145    ///      column slides every following byte.
3146    ///
3147    /// The fix mirrors `alter_table_add_column`: snapshot the old
3148    /// schema, mutate to the new schema, then rewrite every row
3149    /// through `Table::rewrite_rows_for_schema_change`. Dropping a
3150    /// column from an empty table skips the rewrite.
3151    pub fn alter_table_drop_column(&mut self, table: &str, col_name: &str) -> io::Result<()> {
3152        self.ensure_no_active_transaction_for_ddl("alter table drop column")?;
3153        self.invalidate_structure();
3154        let data_dir = self.data_dir.clone();
3155        {
3156            let tbl = self.by_name_mut(table)?;
3157            tbl.schema
3158                .columns
3159                .iter()
3160                .position(|c| c.name == col_name)
3161                .ok_or_else(|| {
3162                    io::Error::new(
3163                        io::ErrorKind::NotFound,
3164                        format!("column '{col_name}' not found in table '{table}'"),
3165                    )
3166                })?;
3167        }
3168        // A live link that names this column (as owner local key or target key)
3169        // pins it in place. Same remedy wording as `drop_table`: name the API
3170        // that can actually remove a link instead of a PowQL statement that
3171        // does not exist.
3172        if let Some(link) = self.link_referencing_column(table, col_name) {
3173            return Err(io::Error::new(
3174                io::ErrorKind::InvalidInput,
3175                format!(
3176                    "cannot drop column '{col_name}' from '{table}': link '{}' on '{}' \
3177                     references it. Remove the link first with the embedded API \
3178                     `Catalog::drop_link(\"{}\", \"{}\")`; PowQL has no statement \
3179                     that removes a link",
3180                    link.name, link.owner_type, link.owner_type, link.name
3181                ),
3182            ));
3183        }
3184        let removed_expression_index_ids = self
3185            .by_name_mut(table)?
3186            .remove_expression_indexes_for_root(col_name);
3187        let had_plain_index = self.by_name_mut(table)?.remove_index_for_column(col_name);
3188        let barrier_lsn = if !self.wal.is_off() {
3189            let payload = encode_ddl_alter_drop_column(table, col_name);
3190            self.wal.append(0, WalRecordType::DdlDropColumn, &payload)?;
3191            self.wal.flush()?;
3192            self.wal.last_appended_lsn()
3193        } else {
3194            0
3195        };
3196        let tbl = self.by_name_mut(table)?;
3197        let idx = tbl
3198            .schema
3199            .columns
3200            .iter()
3201            .position(|c| c.name == col_name)
3202            .ok_or_else(|| {
3203                io::Error::new(
3204                    io::ErrorKind::NotFound,
3205                    format!("column '{col_name}' not found in table '{table}'"),
3206                )
3207            })?;
3208
3209        // Snapshot for decoding old rows.
3210        let old_schema = tbl.schema.clone();
3211        let has_rows = tbl.heap.has_rows()?;
3212
3213        // Commit the schema change.
3214        tbl.schema.columns.remove(idx);
3215        for (i, col) in tbl.schema.columns.iter_mut().enumerate() {
3216            col.position = i as u16;
3217        }
3218        tbl.refresh_layout();
3219
3220        if has_rows {
3221            // Build a filler matching the new (smaller) shape. The
3222            // rewrite path overwrites each new-column slot from the
3223            // matching old-column value by name, so the filler only
3224            // matters for brand-new columns — drop has none, so
3225            // `Empty` is a safe placeholder that never gets read.
3226            let fill: Vec<Value> = vec![Value::Empty; tbl.schema.columns.len()];
3227            tbl.rewrite_rows_for_schema_change(&old_schema, &fill, &data_dir)?;
3228        }
3229        // P0 fix: see matching comment in alter_table_add_column.
3230        if barrier_lsn > 0 {
3231            tbl.heap.stamp_all_pages_min_lsn(barrier_lsn)?;
3232            tbl.heap.flush()?;
3233        }
3234
3235        self.persist()?;
3236        for index_id in removed_expression_index_ids {
3237            let idx_path = self
3238                .data_dir
3239                .join(expression_index_file_name(table, index_id));
3240            let _ = fs::remove_file(idx_path);
3241        }
3242        // Same cleanup for a plain column index. Left behind, the file is not
3243        // just clutter: it is a tree keyed on a column the schema no longer
3244        // has, sitting under the exact name a future `{table}_{col}.idx` would
3245        // claim if the column were ever added back.
3246        if had_plain_index {
3247            let idx_path = self.data_dir.join(format!("{table}_{col_name}.idx"));
3248            let _ = fs::remove_file(idx_path);
3249        }
3250        Ok(())
3251    }
3252}
3253
3254impl Drop for Catalog {
3255    fn drop(&mut self) {
3256        // A read-only snapshot handle never wrote anything and holds read-only
3257        // file descriptors; checkpointing would try to flush pages and truncate
3258        // the WAL, mutating a directory that must stay byte-identical.
3259        if self.read_only {
3260            return;
3261        }
3262        if self.active_tx_id.is_some() {
3263            if let Err(e) = self.abandon_active_transaction_for_drop() {
3264                warn!(error = %e, "catalog drop active transaction cleanup failed");
3265            }
3266            return;
3267        }
3268        // Mission 2: best-effort clean shutdown. `checkpoint` flushes
3269        // every heap and truncates the WAL, which is what
3270        // [`Catalog::open`] relies on to know that no replay is needed.
3271        //
3272        // We swallow errors here because Rust's `Drop` can't propagate
3273        // them and panicking during unwind is always a bigger problem
3274        // than a failed flush. The worst case on a failed drop-time
3275        // checkpoint is that the next open sees a non-empty WAL and
3276        // replays it (potentially producing duplicates — see the
3277        // [`Self::replay_wal`] caveat). That's strictly better than
3278        // losing committed writes.
3279        if let Err(e) = self.checkpoint() {
3280            warn!(error = %e, "catalog drop checkpoint failed");
3281        }
3282    }
3283}
3284
3285// ─── WAL payload codec ─────────────────────────────────────────────────────
3286//
3287// Per-record payload layout (little-endian):
3288//
3289//   table_name_len : u32
3290//   table_name     : utf-8 bytes
3291//   page_id        : u32   (for insert: 0, ignored on replay)
3292//   slot_index     : u16   (for insert: 0, ignored on replay)
3293//   row_len        : u32
3294//   row_bytes      : raw encoded row (length = row_len)
3295//
3296// Lives next to `Catalog` because this is the only code that produces or
3297// consumes these records — the `Wal` itself is payload-agnostic.
3298
3299mod ddl_payload;
3300mod file;
3301#[cfg(test)]
3302mod tests;
3303mod wal_payload;
3304
3305use ddl_payload::*;
3306use file::*;
3307use wal_payload::*;
3308
3309// Crate-visible items defined in the child files keep their
3310// `crate::catalog::` path through explicit re-exports (a private glob
3311// does not re-export). The `pub(super)` helpers stay private to this tree.
3312pub(crate) use ddl_payload::IndexedColMeta;
3313
3314pub use file::read_active_catalog_version;