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;