powdb_storage/table.rs
1use crate::btree::BTree;
2use crate::catalog::{expression_index_file_name, ExpressionIndexMeta};
3use crate::error::StorageError;
4use crate::heap::HeapFile;
5use crate::page::{OVERFLOW_CHAIN_END, OVERFLOW_PAYLOAD_CAP};
6use crate::row::{
7 decode_column, decode_row, encode_row_into_with_layout, encode_row_v2_into,
8 patch_var_column_in_place, plan_spill, OverflowStub, RowLayout, MAX_VALUE_SIZE,
9};
10use crate::stored_json_path::StoredJsonPathSegmentV1;
11use crate::types::*;
12use std::io;
13use std::path::Path;
14
15/// Per-indexed-column metadata owning the BTree inline.
16///
17/// Mission C Phase 15 introduced this struct as a cache of `col_idx`,
18/// `col_name`, and `is_int` so the hot `Table::insert` path could skip
19/// the schema column-name linear scan. Mission C Phase 17 folds the
20/// BTree itself into this struct, retiring the parallel
21/// `FxHashMap<String, BTree>` that the hot write paths were otherwise
22/// forced to probe every single call. Everything the write paths need
23/// is now in a single tight `Vec<IndexedCol>` — no hash, no string
24/// compare, no out-of-line allocation.
25pub(crate) struct IndexedCol {
26 /// Schema column index of the indexed column.
27 pub col_idx: usize,
28 /// Column name — still needed to resolve name-based lookups from the
29 /// executor (`tbl.index("id")`, etc.). Cost is only paid on the
30 /// rarer name-keyed read paths.
31 pub col_name: String,
32 /// `true` when the column type is `TypeId::Int`. Lets `insert` /
33 /// `delete` take the `insert_int` / `delete_int` fast paths without
34 /// re-matching the schema every call.
35 pub is_int: bool,
36 /// `true` for primary key / explicitly unique indexes. `false` for
37 /// secondary indexes on non-unique columns. Non-unique indexes use
38 /// composite keys (column_value + RowId) so duplicate column values
39 /// don't overwrite each other.
40 pub unique: bool,
41 /// The B+ tree. Lives inline alongside the metadata so the hot
42 /// insert/delete/update loops can touch a single cache line per
43 /// index entry instead of chasing a separate HashMap probe.
44 pub btree: BTree,
45}
46
47pub(crate) struct ExpressionIndexedPath {
48 pub meta: ExpressionIndexMeta,
49 pub root_col_idx: usize,
50 pub btree: BTree,
51}
52
53/// A table combines a heap file, schema, and optional indexes.
54///
55/// Mission C Phase 17: indexes used to live in a `FxHashMap<String,
56/// BTree>` alongside a parallel `Vec<IndexedCol>` of metadata. Every row
57/// insert paid an FxHash of the index column name to look the btree back
58/// out of the map. This phase collapses both data structures into a
59/// single `Vec<IndexedCol>` where each entry owns its btree inline —
60/// the hot write path walks one small vec and calls straight through to
61/// `insert_int`.
62///
63/// Mission C Phase 2: holds `encode_scratch`, a reusable buffer for
64/// [`crate::row::encode_row_into`]. Bench loops that push thousands of
65/// rows through `insert`/`update` reuse the same allocation across calls,
66/// cutting the allocator traffic to ~zero after the first row.
67pub struct Table {
68 pub(crate) schema: Schema,
69 pub heap: HeapFile,
70 /// Reusable scratch buffer for row encoding. Cleared on every call.
71 encode_scratch: Vec<u8>,
72 /// Per-indexed-column metadata, each entry owning its BTree inline.
73 /// Public to the crate so the query executor's IndexScan fast paths
74 /// can reach in via the `index()` / `index_mut()` helpers instead
75 /// of probing a separate hash map.
76 pub(crate) indexed_cols: Vec<IndexedCol>,
77 pub(crate) expression_indexes: Vec<ExpressionIndexedPath>,
78 /// Mission C Phase 7: cached row layout so `delete` can decode only
79 /// the indexed columns out of the raw page bytes without running the
80 /// full per-row offset calculation every call.
81 row_layout: RowLayout,
82 /// Per-column literal defaults, aligned to `schema.columns` by position.
83 /// Empty means "no defaults" (the common case); otherwise `defaults[i]`
84 /// is the default for column `i`, or `None` if that column has none.
85 defaults: Vec<Option<Value>>,
86 /// Which columns are `auto` (auto-incrementing), aligned to
87 /// `schema.columns`. Empty means no auto columns (the common case).
88 auto_cols: Vec<bool>,
89 /// Next value to assign per auto column, aligned to `schema.columns`.
90 /// Lazily computed from the persisted rows on first insert after open
91 /// (so the sequence resumes above the highest existing id), guarded by
92 /// `auto_next_ready`.
93 auto_next: Vec<i64>,
94 auto_next_ready: bool,
95}
96
97/// The refusal for a duplicate key in a unique column index.
98///
99/// Raised as a typed [`StorageError`] inside the `io::Error` so callers can
100/// downcast it back to its variant (see `StorageError::kind_of_io_error`)
101/// instead of recovering the kind by matching this message. The rendered text
102/// is unchanged: it is on the server's egress allowlist and clients assert on
103/// it.
104fn unique_column_error(table: &str, column: &str) -> io::Error {
105 io::Error::new(
106 io::ErrorKind::InvalidInput,
107 StorageError::UniqueConstraintViolation {
108 table: table.to_string(),
109 column: column.to_string(),
110 },
111 )
112}
113
114/// The same refusal for a unique expression index. See
115/// [`unique_column_error`].
116fn expression_unique_error(table: &str, expression: &str) -> io::Error {
117 io::Error::new(
118 io::ErrorKind::InvalidInput,
119 StorageError::UniqueExpressionIndexViolation {
120 table: table.to_string(),
121 expression: expression.to_string(),
122 },
123 )
124}
125
126fn expression_key(meta: &ExpressionIndexMeta, root: &Value) -> io::Result<Value> {
127 let Value::Json(document) = root else {
128 if root.is_empty() {
129 return Ok(Value::Empty);
130 }
131 return Err(io::Error::new(
132 io::ErrorKind::InvalidData,
133 "expression index root value is not JSON",
134 ));
135 };
136 let mut node = document.as_ref();
137 for segment in &meta.json_path.segments {
138 let path_segment = match segment {
139 StoredJsonPathSegmentV1::Key(key) => crate::pj1::PathSeg::Key(key),
140 StoredJsonPathSegmentV1::Index(index) => crate::pj1::PathSeg::Index(*index),
141 };
142 let Some(next) = crate::pj1::pj1_get(node, &path_segment) else {
143 return Ok(Value::Empty);
144 };
145 node = next;
146 }
147 match crate::pj1::pj1_scalar(node).map_err(|error| {
148 io::Error::new(
149 io::ErrorKind::InvalidData,
150 format!("invalid PJ1 while extracting expression index key: {error}"),
151 )
152 })? {
153 crate::pj1::Pj1Scalar::Null => Ok(Value::Empty),
154 crate::pj1::Pj1Scalar::Bool(value) => Ok(Value::Bool(value)),
155 crate::pj1::Pj1Scalar::Int(value) => Ok(Value::Int(value)),
156 crate::pj1::Pj1Scalar::Float(value) => Ok(Value::Float(value)),
157 crate::pj1::Pj1Scalar::Str(value) => Ok(Value::Str(value.to_owned())),
158 crate::pj1::Pj1Scalar::NonScalar => Err(io::Error::new(
159 io::ErrorKind::InvalidInput,
160 format!(
161 "expression index key must be scalar: {}",
162 meta.canonical_text
163 ),
164 )),
165 }
166}
167
168impl Table {
169 pub fn create(schema: Schema, data_dir: &Path) -> io::Result<Self> {
170 let heap_path = data_dir.join(format!("{}.heap", schema.table_name));
171 let heap = HeapFile::create(&heap_path)?;
172 let row_layout = RowLayout::new(&schema);
173 Ok(Table {
174 schema,
175 heap,
176 encode_scratch: Vec::new(),
177 indexed_cols: Vec::new(),
178 expression_indexes: Vec::new(),
179 row_layout,
180 defaults: Vec::new(),
181 auto_cols: Vec::new(),
182 auto_next: Vec::new(),
183 auto_next_ready: false,
184 })
185 }
186
187 /// Reopen an existing table from disk. Caller supplies the schema (loaded
188 /// from the catalog file). No index columns are supplied, so no index is
189 /// rehydrated or rebuilt here; prefer `open_with_indexes` when the
190 /// catalog knows which columns are indexed.
191 pub fn open(schema: Schema, data_dir: &Path) -> io::Result<Self> {
192 Self::open_with_indexes(schema, data_dir, &[], &[])
193 }
194
195 /// Mission 3: reopen an existing table from disk, also rehydrating any
196 /// persisted b-tree indexes.
197 ///
198 /// For each name in `indexed_col_names`:
199 /// - If the `{table}_{col}.idx` file exists at the current format,
200 /// load it via `BTree::load` — O(file size) memcpy+decode, no heap
201 /// scan.
202 /// - If the file is a pre-v3 non-unique index (unescaped composite
203 /// Str keys), it is never served: it is rebuilt from the heap in
204 /// the escaped format and, on a writable open, saved as v3.
205 /// - If the file is missing (e.g. first open after upgrading from
206 /// pre-Mission-3 catalogs), fall back to the create-time rebuild
207 /// path: scan the heap and insert every non-empty value. After the
208 /// rebuild, `save` the freshly built tree so subsequent opens hit
209 /// the fast path.
210 pub(crate) fn open_with_indexes(
211 schema: Schema,
212 data_dir: &Path,
213 indexed_col_metas: &[crate::catalog::IndexedColMeta],
214 expression_index_metas: &[ExpressionIndexMeta],
215 ) -> io::Result<Self> {
216 Self::open_with_indexes_inner(
217 schema,
218 data_dir,
219 indexed_col_metas,
220 expression_index_metas,
221 false,
222 )
223 }
224
225 /// Read-only reopen for snapshot serving: the heap and every persisted index
226 /// are opened without a writable handle. A missing index artifact is rebuilt
227 /// **in memory** but never saved (a read-only directory must not be mutated),
228 /// so index and expression-index reads work without touching disk.
229 pub(crate) fn open_with_indexes_read_only(
230 schema: Schema,
231 data_dir: &Path,
232 indexed_col_metas: &[crate::catalog::IndexedColMeta],
233 expression_index_metas: &[ExpressionIndexMeta],
234 ) -> io::Result<Self> {
235 Self::open_with_indexes_inner(
236 schema,
237 data_dir,
238 indexed_col_metas,
239 expression_index_metas,
240 true,
241 )
242 }
243
244 fn open_with_indexes_inner(
245 schema: Schema,
246 data_dir: &Path,
247 indexed_col_metas: &[crate::catalog::IndexedColMeta],
248 expression_index_metas: &[ExpressionIndexMeta],
249 read_only: bool,
250 ) -> io::Result<Self> {
251 let heap_path = data_dir.join(format!("{}.heap", schema.table_name));
252 let heap = if read_only {
253 HeapFile::open_read_only(&heap_path)?
254 } else {
255 HeapFile::open(&heap_path)?
256 };
257 let row_layout = RowLayout::new(&schema);
258 let mut table = Table {
259 schema,
260 heap,
261 encode_scratch: Vec::new(),
262 indexed_cols: Vec::new(),
263 expression_indexes: Vec::new(),
264 row_layout,
265 defaults: Vec::new(),
266 auto_cols: Vec::new(),
267 auto_next: Vec::new(),
268 auto_next_ready: false,
269 };
270
271 for meta in indexed_col_metas {
272 let col_name = &meta.name;
273 let unique = meta.unique;
274 let col_idx = match table.schema.column_index(col_name) {
275 Some(i) => i,
276 // Schema drift: the catalog lists an index on a column that
277 // no longer exists. Silently drop the index rather than
278 // failing the whole open — matches the `drop column`
279 // rewrite path, which already blows away indexes.
280 None => continue,
281 };
282 let is_int = table.schema.columns[col_idx].type_id == TypeId::Int;
283 let idx_path = data_dir.join(format!("{}_{}.idx", table.schema.table_name, col_name));
284
285 // Rebuild this column index from the heap. Reassemble via
286 // `table.scan()` so a spilled (v2) indexed column contributes its
287 // true key -- a v1-only `decode_row` would read it as `Empty` and
288 // build a btree missing that row's key (P2). Non-unique indexes are
289 // created at v3 (NUL-escaped composite Str keys); unique indexes
290 // stay v1.
291 let build_from_heap = |path: &std::path::Path| -> io::Result<BTree> {
292 let mut bt = if unique {
293 BTree::create(path)?
294 } else {
295 BTree::create_non_unique(path)?
296 };
297 for (rid, row) in table.scan() {
298 if !row[col_idx].is_empty() {
299 if unique {
300 bt.insert(row[col_idx].clone(), rid);
301 } else {
302 bt.insert_non_unique(row[col_idx].clone(), rid);
303 }
304 }
305 }
306 // Read-only serving must not persist the rebuilt tree; it lives
307 // only for this handle's lifetime.
308 if !read_only {
309 bt.save()?;
310 }
311 Ok(bt)
312 };
313
314 let btree = if idx_path.exists() {
315 let loaded = BTree::load(&idx_path)?;
316 if !unique && loaded.format_version() < crate::btree::BTREE_VERSION {
317 // Migration: an old-format (v1/v2) non-unique column index
318 // encodes composite Str keys without NUL escaping, so its
319 // lookups can return wrong rows for embedded-NUL values.
320 // Never serve it -- rebuild from the heap in the v3 escaped
321 // format and save (the same machinery crash recovery uses).
322 // A read-only open cannot persist the rebuild, so it pays
323 // this scan on every open of an old-format directory; do
324 // one writable open (or re-backup from an upgraded
325 // primary) to persist v3.
326 tracing::info!(
327 index = %idx_path.display(),
328 read_only,
329 "rebuilding pre-v3 non-unique index in NUL-safe format"
330 );
331 build_from_heap(&idx_path)?
332 } else {
333 // v3 non-unique (or any unique) index: serve as loaded.
334 // Load counts distinct keys in Raw mode; a non-unique column
335 // index stores composite keys, so switch it to count
336 // distinct by value prefix.
337 let mut loaded = loaded;
338 if !unique {
339 loaded.mark_composite();
340 }
341 loaded
342 }
343 } else {
344 build_from_heap(&idx_path)?
345 };
346
347 table.indexed_cols.push(IndexedCol {
348 col_idx,
349 col_name: col_name.clone(),
350 is_int,
351 unique,
352 btree,
353 });
354 }
355
356 for meta in expression_index_metas {
357 let root_col_idx = table
358 .schema
359 .column_index(&meta.json_path.column)
360 .ok_or_else(|| {
361 io::Error::new(
362 io::ErrorKind::InvalidData,
363 "expression index root column is absent",
364 )
365 })?;
366 let idx_path = data_dir.join(expression_index_file_name(
367 &table.schema.table_name,
368 meta.index_id,
369 ));
370 if idx_path.exists() {
371 let btree = BTree::load(&idx_path)?;
372 // Expression indexes need the v2 `empty_rids` side list. They
373 // store raw (never composite Str) keys, so the v3 NUL-escaping
374 // does not apply and pre-existing v2 files load unchanged.
375 if btree.format_version() < crate::btree::EXPRESSION_BTREE_VERSION {
376 return Err(io::Error::new(
377 io::ErrorKind::InvalidData,
378 "expression index requires BIDX v2 (empty_rids side list)",
379 ));
380 }
381 table.expression_indexes.push(ExpressionIndexedPath {
382 meta: meta.clone(),
383 root_col_idx,
384 btree,
385 });
386 } else {
387 // Expression indexes were not released before the dedicated
388 // `.eidx` namespace. Rebuild any missing artifact from the
389 // heap instead of probing or deleting an ambiguous legacy
390 // `.idx` pathname that may belong to a live column index. In
391 // read-only mode the rebuilt tree is kept in memory only.
392 table.build_expression_index(meta.clone(), &idx_path, !read_only)?;
393 }
394 }
395
396 Ok(table)
397 }
398
399 /// Mission 3: catalog uses this to snapshot the list of columns that
400 /// currently have an index, so it can be persisted in `catalog.bin`.
401 pub(crate) fn indexed_column_names(&self) -> Vec<String> {
402 self.indexed_cols
403 .iter()
404 .map(|c| c.col_name.clone())
405 .collect()
406 }
407
408 /// Snapshot index metadata (name + uniqueness) for catalog persistence.
409 pub(crate) fn indexed_column_metas(&self) -> Vec<crate::catalog::IndexedColMeta> {
410 self.indexed_cols
411 .iter()
412 .map(|c| crate::catalog::IndexedColMeta {
413 name: c.col_name.clone(),
414 unique: c.unique,
415 })
416 .collect()
417 }
418
419 pub(crate) fn expression_index_metas(&self) -> Vec<ExpressionIndexMeta> {
420 self.expression_indexes
421 .iter()
422 .map(|index| index.meta.clone())
423 .collect()
424 }
425
426 pub(crate) fn expression_index_btree(&self, index_id: u64) -> Option<&BTree> {
427 self.expression_indexes
428 .iter()
429 .find(|index| index.meta.index_id == index_id)
430 .map(|index| &index.btree)
431 }
432
433 pub(crate) fn expression_index_btree_mut(&mut self, index_id: u64) -> Option<&mut BTree> {
434 self.expression_indexes
435 .iter_mut()
436 .find(|index| index.meta.index_id == index_id)
437 .map(|index| &mut index.btree)
438 }
439
440 /// Forget the plain b-tree index on `col_name`, if there is one.
441 ///
442 /// Returns `true` when an entry was actually removed, so the caller knows
443 /// whether there is a `{table}_{col}.idx` file left to delete. Called
444 /// unconditionally on a column drop rather than relying on the rewrite
445 /// path, because the rewrite only runs when the table has rows: an empty
446 /// table would otherwise keep an index entry naming a column that no longer
447 /// exists until the next reopen rebuilt the list from the schema.
448 pub(crate) fn remove_index_for_column(&mut self, col_name: &str) -> bool {
449 let previous_len = self.indexed_cols.len();
450 self.indexed_cols.retain(|c| c.col_name != col_name);
451 self.indexed_cols.len() != previous_len
452 }
453
454 pub(crate) fn remove_expression_indexes_for_root(&mut self, root: &str) -> Vec<u64> {
455 let mut removed = Vec::new();
456 self.expression_indexes.retain(|index| {
457 if index.meta.json_path.column == root {
458 removed.push(index.meta.index_id);
459 false
460 } else {
461 true
462 }
463 });
464 removed
465 }
466
467 pub(crate) fn remove_expression_index_by_id(&mut self, index_id: u64) -> bool {
468 let previous_len = self.expression_indexes.len();
469 self.expression_indexes
470 .retain(|index| index.meta.index_id != index_id);
471 self.expression_indexes.len() != previous_len
472 }
473
474 pub(crate) fn take_expression_index(&mut self, index_id: u64) -> Option<ExpressionIndexedPath> {
475 let position = self
476 .expression_indexes
477 .iter()
478 .position(|index| index.meta.index_id == index_id)?;
479 Some(self.expression_indexes.remove(position))
480 }
481
482 pub(crate) fn restore_expression_index(&mut self, index: ExpressionIndexedPath) {
483 self.expression_indexes.push(index);
484 }
485
486 pub(crate) fn expression_index_ids(&self) -> Vec<u64> {
487 self.expression_indexes
488 .iter()
489 .map(|index| index.meta.index_id)
490 .collect()
491 }
492
493 pub(crate) fn install_expression_index(
494 &mut self,
495 meta: ExpressionIndexMeta,
496 path: &Path,
497 ) -> io::Result<()> {
498 self.build_expression_index(meta, path, true)
499 }
500
501 /// Build an expression index from a heap scan. When `save` is false the tree
502 /// is registered in memory but never written to disk: the read-only
503 /// snapshot-serving path, which must not mutate the directory.
504 fn build_expression_index(
505 &mut self,
506 meta: ExpressionIndexMeta,
507 path: &Path,
508 save: bool,
509 ) -> io::Result<()> {
510 let root_col_idx = self
511 .schema
512 .column_index(&meta.json_path.column)
513 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "JSON root column not found"))?;
514 let mut btree = BTree::create_v2(path)?;
515 let rids = self.heap.scan().map(|(rid, _)| rid).collect::<Vec<_>>();
516 for rid in rids {
517 let root = self
518 .get_projected(rid, &[root_col_idx])?
519 .and_then(|mut values| values.pop())
520 .ok_or_else(|| {
521 io::Error::new(
522 io::ErrorKind::NotFound,
523 "row disappeared while building expression index",
524 )
525 })?;
526 let key = expression_key(&meta, &root)?;
527 if key.is_empty() {
528 btree.insert_empty(rid);
529 } else if meta.unique {
530 if btree.lookup(&key).is_some() {
531 return Err(expression_unique_error(
532 &self.schema.table_name,
533 &meta.canonical_text,
534 ));
535 }
536 btree.insert(key, rid);
537 } else {
538 btree.insert_duplicate(key, rid);
539 }
540 }
541 if save {
542 btree.save()?;
543 }
544 self.expression_indexes.push(ExpressionIndexedPath {
545 meta,
546 root_col_idx,
547 btree,
548 });
549 Ok(())
550 }
551
552 pub(crate) fn preflight_insert(&self, values: &Row) -> io::Result<Option<Vec<Value>>> {
553 for entry in &self.indexed_cols {
554 if !entry.unique {
555 continue;
556 }
557 let val = &values[entry.col_idx];
558 if !val.is_empty() && entry.btree.lookup(val).is_some() {
559 return Err(unique_column_error(
560 &self.schema.table_name,
561 &entry.col_name,
562 ));
563 }
564 }
565 // Keep the overwhelmingly common column-only table on the legacy
566 // insert shape. In particular, avoid constructing an expression-key
567 // result and entering a second maintenance loop for every row when no
568 // expression index exists.
569 if self.expression_indexes.is_empty() {
570 return Ok(None);
571 }
572 let keys = self.expression_keys(values)?;
573 for (index, key) in self.expression_indexes.iter().zip(&keys) {
574 if index.meta.unique && !key.is_empty() && index.btree.lookup(key).is_some() {
575 return Err(expression_unique_error(
576 &self.schema.table_name,
577 &index.meta.canonical_text,
578 ));
579 }
580 }
581 Ok(Some(keys))
582 }
583
584 pub(crate) fn preflight_update(&self, rid: RowId, values: &Row) -> io::Result<()> {
585 let old_row = if self.indexed_cols.iter().any(|index| index.unique) {
586 self.get(rid)
587 } else {
588 None
589 };
590 for entry in self.indexed_cols.iter().filter(|index| index.unique) {
591 let new_value = &values[entry.col_idx];
592 if new_value.is_empty()
593 || old_row
594 .as_ref()
595 .is_some_and(|old| old[entry.col_idx] == *new_value)
596 {
597 continue;
598 }
599 if entry
600 .btree
601 .lookup(new_value)
602 .is_some_and(|existing| existing != rid)
603 {
604 return Err(unique_column_error(
605 &self.schema.table_name,
606 &entry.col_name,
607 ));
608 }
609 }
610
611 let new_keys = self.expression_keys(values)?;
612 let old_keys = self.expression_keys_at(rid)?.ok_or_else(|| {
613 io::Error::new(io::ErrorKind::NotFound, "row not found for index update")
614 })?;
615 for ((index, old_key), new_key) in
616 self.expression_indexes.iter().zip(old_keys).zip(new_keys)
617 {
618 if !index.meta.unique || new_key.is_empty() || old_key == new_key {
619 continue;
620 }
621 if index
622 .btree
623 .lookup(&new_key)
624 .is_some_and(|existing| existing != rid)
625 {
626 return Err(expression_unique_error(
627 &self.schema.table_name,
628 &index.meta.canonical_text,
629 ));
630 }
631 }
632 Ok(())
633 }
634
635 fn expression_keys(&self, values: &Row) -> io::Result<Vec<Value>> {
636 self.expression_indexes
637 .iter()
638 .map(|index| expression_key(&index.meta, &values[index.root_col_idx]))
639 .collect()
640 }
641
642 fn expression_keys_at(&self, rid: RowId) -> io::Result<Option<Vec<Value>>> {
643 if self.expression_indexes.is_empty() {
644 return Ok(Some(Vec::new()));
645 }
646 let roots = self
647 .expression_indexes
648 .iter()
649 .map(|index| index.root_col_idx)
650 .collect::<Vec<_>>();
651 let Some(root_values) = self.get_projected(rid, &roots)? else {
652 return Ok(None);
653 };
654 self.expression_indexes
655 .iter()
656 .zip(root_values)
657 .map(|(index, root)| expression_key(&index.meta, &root))
658 .collect::<io::Result<Vec<_>>>()
659 .map(Some)
660 }
661
662 fn maintain_expression_indexes_on_insert(&mut self, keys: &[Value], rid: RowId) {
663 for (index, key) in self.expression_indexes.iter_mut().zip(keys) {
664 if key.is_empty() {
665 index.btree.insert_empty(rid);
666 } else if index.meta.unique {
667 index.btree.insert(key.clone(), rid);
668 } else {
669 index.btree.insert_duplicate(key.clone(), rid);
670 }
671 }
672 }
673
674 /// Install the per-column defaults (called at create time and on reopen
675 /// from the persisted catalog). Aligned to `schema.columns` by position.
676 pub(crate) fn set_defaults(&mut self, defaults: Vec<Option<Value>>) {
677 self.defaults = defaults;
678 }
679
680 /// Per-column defaults, aligned to `schema.columns` by position. Empty when
681 /// no column has a default.
682 pub(crate) fn defaults(&self) -> &[Option<Value>] {
683 &self.defaults
684 }
685
686 /// Install which columns are `auto` (called at create time and on reopen
687 /// from the persisted catalog), aligned to `schema.columns` by position.
688 pub(crate) fn set_auto_cols(&mut self, auto_cols: Vec<bool>) {
689 self.auto_cols = auto_cols;
690 // Force the counters to be recomputed from the current rows on next use.
691 self.auto_next_ready = false;
692 }
693
694 /// Whether this table has any `auto` column.
695 pub(crate) fn has_auto(&self) -> bool {
696 self.auto_cols.iter().any(|&a| a)
697 }
698
699 /// Which columns are `auto`, aligned to `schema.columns`. Empty when none.
700 pub(crate) fn auto_cols(&self) -> &[bool] {
701 &self.auto_cols
702 }
703
704 /// Fill any omitted (`Empty`) `auto` column in `values` from the per-table
705 /// sequence and advance it. An explicitly-provided value is left as-is but
706 /// still pushes the sequence past it, so later auto ids never collide with
707 /// an id the caller chose. No-op when the table has no auto columns.
708 pub(crate) fn assign_auto(&mut self, values: &mut [Value]) {
709 if !self.has_auto() {
710 return;
711 }
712 if !self.auto_next_ready {
713 self.init_auto_next();
714 }
715 for (i, &is_auto) in self.auto_cols.iter().enumerate() {
716 if !is_auto || i >= values.len() {
717 continue;
718 }
719 match values[i] {
720 Value::Empty => {
721 values[i] = Value::Int(self.auto_next[i]);
722 self.auto_next[i] += 1;
723 }
724 Value::Int(v) if v >= self.auto_next[i] => {
725 self.auto_next[i] = v + 1;
726 }
727 _ => {}
728 }
729 }
730 }
731
732 /// Seed each auto column's next value to one past the highest value already
733 /// stored, so the sequence resumes correctly after a restart (committed
734 /// rows are already replayed from the WAL by the time inserts run). An
735 /// empty column starts at 1.
736 fn init_auto_next(&mut self) {
737 let n = self.schema.columns.len();
738 let mut next = vec![1i64; n];
739 let auto_idxs: Vec<usize> = self
740 .auto_cols
741 .iter()
742 .enumerate()
743 .filter_map(|(i, &a)| if a { Some(i) } else { None })
744 .collect();
745 if !auto_idxs.is_empty() {
746 for (_rid, row) in self.heap.scan() {
747 let decoded = crate::row::decode_row(&self.schema, &row);
748 for &i in &auto_idxs {
749 if let Some(Value::Int(v)) = decoded.get(i) {
750 if *v >= next[i] {
751 next[i] = *v + 1;
752 }
753 }
754 }
755 }
756 }
757 self.auto_next = next;
758 self.auto_next_ready = true;
759 }
760
761 /// Recalculate the cached row layout from the current schema. Must be
762 /// called after any schema mutation (add/drop column).
763 pub(crate) fn refresh_layout(&mut self) {
764 self.row_layout = RowLayout::new(&self.schema);
765 }
766
767 /// Return the table schema without exposing structural mutation.
768 /// Schema changes are catalog-owned so prepared-query metadata is
769 /// invalidated whenever column layout or table identity changes.
770 pub fn schema(&self) -> &Schema {
771 &self.schema
772 }
773
774 /// Rewrite every live heap row to match a new schema shape.
775 ///
776 /// This is the backfill path for `ALTER TABLE ADD COLUMN`. Before
777 /// this existed, the catalog happily swapped the schema in memory
778 /// and left old rows on disk with the OLD variable-column offset
779 /// table layout. Any subsequent `decode_row` then panicked with
780 /// `range end index X out of range` because the decoder reads
781 /// `n_var + 1` offsets using the NEW schema.
782 ///
783 /// The caller passes in the pre-mutation schema so rows can be
784 /// decoded correctly; `self.schema` must already hold the NEW
785 /// schema when this is invoked. `fill_values` must have
786 /// `new_schema.columns.len()` entries and supplies the values for
787 /// columns that did not exist in the old schema (use
788 /// `Value::Empty` for optional adds).
789 ///
790 /// Rewrites every row via `HeapFile::update`, which may move the
791 /// row to a new page when the new encoding is larger. Any secondary
792 /// indexes are rebuilt from scratch at the end because their
793 /// `RowId` pointers can become stale during the rewrite.
794 ///
795 /// Not on any hot path — ALTER is a rare administrative op, so this
796 /// intentionally prefers simplicity (collect snapshot → rewrite →
797 /// rebuild indexes) over any of the fast-path tricks used by
798 /// insert/update/delete.
799 pub(crate) fn rewrite_rows_for_schema_change(
800 &mut self,
801 old_schema: &Schema,
802 fill_values: &[Value],
803 data_dir: &Path,
804 ) -> io::Result<()> {
805 debug_assert_eq!(fill_values.len(), self.schema.columns.len());
806
807 // Snapshot every live (rid, old_bytes) pair up front. We can't
808 // mutate `self.heap` while iterating it, and the rewrite grows
809 // every row (+2 bytes of offset table at minimum), so in-place
810 // updates are not guaranteed.
811 let snapshot: Vec<(RowId, Vec<u8>)> = self.heap.scan().collect();
812
813 // Map from old column index → new column index, or `None` if
814 // the old column was dropped by the schema change. The caller
815 // is expected to keep surviving columns in their original
816 // positions. We look up by name so ADD and DROP can share the
817 // same path: ADD has every old column present in the new
818 // schema; DROP has exactly one missing.
819 let old_to_new: Vec<Option<usize>> = old_schema
820 .columns
821 .iter()
822 .map(|c| self.schema.column_index(&c.name))
823 .collect();
824
825 // Phase 1 (immutable reads): reassemble each old row to its logical
826 // values, mapping columns into the new shape, and gather any overflow
827 // chain it referenced. A v2 (spilled) old row MUST be reassembled via
828 // `decode_row_v2` -- a v1-only `decode_row` reads its spilled columns as
829 // `Empty` and the ALTER would silently drop the out-of-line value (P1).
830 // We use the OLD schema's layout because `self.row_layout` already
831 // reflects the NEW schema.
832 let old_layout = RowLayout::new(old_schema);
833 let mut rewritten: Vec<(RowId, Vec<Value>, Vec<u32>)> = Vec::with_capacity(snapshot.len());
834 for (rid, old_bytes) in &snapshot {
835 let old_row = if crate::row::row_is_v2(old_bytes) {
836 crate::row::decode_row_v2(old_schema, &old_layout, old_bytes, |stub| {
837 self.heap.read_overflow_value(stub).map_err(io::Error::from)
838 })?
839 } else {
840 decode_row(old_schema, old_bytes)
841 };
842 // Chain pages the old row referenced (empty for inline rows). Freed
843 // after the rewrite so the ALTER does not leak the old out-of-line
844 // values.
845 let mut old_pages: Vec<u32> = Vec::new();
846 if crate::row::row_is_v2(old_bytes) {
847 let mut heads: Vec<u32> = Vec::new();
848 crate::row::for_each_stub(old_schema, &old_layout, old_bytes, |_c, stub| {
849 heads.push(stub.first_page);
850 });
851 for h in heads {
852 old_pages.extend(self.heap.overflow_chain_pages(h)?);
853 }
854 }
855 // Start from the caller-supplied defaults for the new shape, then
856 // overwrite with whatever the old row had. Dropped columns are
857 // skipped (their value has nowhere to go in the new row).
858 let mut new_row: Vec<Value> = fill_values.to_vec();
859 for (old_idx, val) in old_row.into_iter().enumerate() {
860 if let Some(new_idx) = old_to_new[old_idx] {
861 new_row[new_idx] = val;
862 }
863 }
864 rewritten.push((*rid, new_row, old_pages));
865 }
866
867 // Phase 2 (mutations): re-encode each row into the new shape and write
868 // it back. A row whose surviving values still exceed the inline cap
869 // re-spills through `encode_row_spilling` (writing fresh chains), so a
870 // large value survives ALTER; then the old chain is freed.
871 for (rid, new_row, old_pages) in rewritten {
872 if crate::row::v1_encoded_len(&self.row_layout, &new_row)
873 <= crate::page::MAX_ROW_DATA_SIZE
874 {
875 encode_row_into_with_layout(
876 &self.schema,
877 &self.row_layout,
878 &new_row,
879 &mut self.encode_scratch,
880 );
881 let encoded = std::mem::take(&mut self.encode_scratch);
882 self.heap.update(rid, &encoded)?;
883 self.encode_scratch = encoded;
884 } else {
885 let encoded = self.encode_row_spilling(&new_row)?;
886 self.heap.update(rid, &encoded)?;
887 }
888 // Free the old chain now that the row has been rewritten. Fresh
889 // chains for later rows may reuse these pages.
890 if !old_pages.is_empty() {
891 self.heap.release_overflow_pages(&old_pages);
892 }
893 }
894
895 // Rebuild every secondary index from the rewritten heap. The
896 // in-memory btree is the source of truth for reads, and its
897 // RowId pointers may now be stale after the heap rewrite.
898 if !self.indexed_cols.is_empty() {
899 // Preserve per-index metadata (col_idx, col_name, is_int)
900 // via fresh BTree instances. The old btrees are dropped
901 // when `indexed_cols` is reassigned.
902 //
903 // Resolve each index's column position from the *new* schema by
904 // name rather than trusting the `col_idx` it carried in. A drop
905 // makes both of the cached values wrong at once: the dropped
906 // column's own entry now names a column that is gone, and every
907 // index sitting after it has shifted one slot left. Reusing the old
908 // number indexed past the end of the rewritten row and aborted the
909 // process (`panic = "abort"`) before the catalog was persisted,
910 // while the DdlDropColumn WAL record was already durable — so every
911 // later open replayed it and aborted in the same place. Name
912 // resolution is what the expression-index arm below already does.
913 let existing: Vec<(usize, String, bool, bool)> = self
914 .indexed_cols
915 .iter()
916 .filter_map(|c| {
917 let col_idx = self.schema.column_index(&c.col_name)?;
918 Some((col_idx, c.col_name.clone(), c.is_int, c.unique))
919 })
920 .collect();
921
922 // Drain the old entries first so the borrow of
923 // `self.indexed_cols` is clear before we start scanning.
924 self.indexed_cols.clear();
925
926 // Snapshot the rewritten heap once. The rewrite above may have
927 // re-spilled a surviving large indexed value, so rows can be v2 and
928 // must be reassembled (`self.scan()`) -- a v1-only `decode_row`
929 // would read a spilled indexed column as `Empty` and build a btree
930 // missing that key (P2).
931 let rebuilt_rows: Vec<(RowId, Vec<Value>)> = self.scan().collect();
932 for (col_idx, col_name, is_int, unique) in existing {
933 // Mission 3: write the freshly rebuilt index back to its
934 // canonical `{table}_{col}.idx` file so a subsequent
935 // restart loads the up-to-date tree instead of the stale
936 // pre-rewrite version (whose RowIds may now point at
937 // moved rows).
938 let idx_path =
939 data_dir.join(format!("{}_{}.idx", self.schema.table_name, col_name));
940 let mut btree = if unique {
941 crate::btree::BTree::create(&idx_path)?
942 } else {
943 // Match the other rebuild sites: non-unique trees start at
944 // v3 so an all-empty column cannot persist a v1 file that
945 // the next open would needlessly re-migrate.
946 crate::btree::BTree::create_non_unique(&idx_path)?
947 };
948 for (rid, row) in &rebuilt_rows {
949 let (rid, v) = (*rid, &row[col_idx]);
950 if v.is_empty() {
951 continue;
952 }
953 if unique {
954 if is_int {
955 if let Value::Int(i) = v {
956 btree.insert_int(*i, rid);
957 continue;
958 }
959 }
960 btree.insert(v.clone(), rid);
961 } else {
962 btree.insert_non_unique(v.clone(), rid);
963 }
964 }
965 btree.save()?;
966 self.indexed_cols.push(IndexedCol {
967 col_idx,
968 col_name,
969 is_int,
970 unique,
971 btree,
972 });
973 }
974 }
975
976 if !self.expression_indexes.is_empty() {
977 for index in &mut self.expression_indexes {
978 index.root_col_idx = self
979 .schema
980 .column_index(&index.meta.json_path.column)
981 .ok_or_else(|| {
982 io::Error::new(
983 io::ErrorKind::InvalidData,
984 "expression index root disappeared during schema rewrite",
985 )
986 })?;
987 }
988 self.rebuild_indexes_from_heap()?;
989 }
990
991 Ok(())
992 }
993
994 /// Look up an index by column name. Returns `None` if no index on
995 /// this column. Used by the read-side executor paths (IndexScan,
996 /// Project(IndexScan), etc.) that still need name-based resolution;
997 /// the write-side hot paths iterate `indexed_cols` directly.
998 #[inline]
999 pub fn index(&self, col_name: &str) -> Option<&BTree> {
1000 self.indexed_cols
1001 .iter()
1002 .find(|c| c.col_name == col_name)
1003 .map(|c| &c.btree)
1004 }
1005
1006 /// Mutable counterpart to [`Self::index`].
1007 #[inline]
1008 pub fn index_mut(&mut self, col_name: &str) -> Option<&mut BTree> {
1009 self.indexed_cols
1010 .iter_mut()
1011 .find(|c| c.col_name == col_name)
1012 .map(|c| &mut c.btree)
1013 }
1014
1015 /// `true` if this table has an index on the named column.
1016 #[inline]
1017 pub fn has_index(&self, col_name: &str) -> bool {
1018 self.indexed_cols.iter().any(|c| c.col_name == col_name)
1019 }
1020
1021 /// `true` if this table has no secondary indexes at all.
1022 #[inline]
1023 pub fn indexes_is_empty(&self) -> bool {
1024 self.indexed_cols.is_empty() && self.expression_indexes.is_empty()
1025 }
1026
1027 /// Mission C Phase 15: the hot insert path used to do two wasted
1028 /// things per secondary index, on every row:
1029 /// 1. `for (col_name, btree) in &mut self.indexes` walked an
1030 /// FxHashMap by iterator (cheap but not free), and
1031 /// 2. `self.schema.column_index(col_name)` walked `schema.columns`
1032 /// doing an O(n_cols) strcmp linear search to translate the
1033 /// column name back into its schema position.
1034 ///
1035 /// For the `insert_batch_1k` bench (1K rows, User table, one index on
1036 /// `id`) that came out to ~6 strcmps * 1000 rows = 6K wasted
1037 /// comparisons per iteration, plus the HashMap iter overhead. We now
1038 /// iterate the precomputed `indexed_cols` slice directly, which hands
1039 /// us `(col_idx, col_name, is_int)` per entry, and route int keys
1040 /// straight through `BTree::insert_int` to skip the generic
1041 /// `Value::Ord` dispatch on every binary-search comparison.
1042 pub fn insert(&mut self, values: &Row) -> io::Result<RowId> {
1043 let expression_keys = self.preflight_insert(values)?;
1044 // Common case: the row fits inline (v1) — encode straight into scratch,
1045 // byte-identical to pre-v0.11. Size it first WITHOUT encoding so a huge
1046 // value (which the debug v1 encoder would panic on) routes to spill.
1047 if crate::row::v1_encoded_len(&self.row_layout, values) <= crate::page::MAX_ROW_DATA_SIZE {
1048 encode_row_into_with_layout(
1049 &self.schema,
1050 &self.row_layout,
1051 values,
1052 &mut self.encode_scratch,
1053 );
1054 let rid = self.heap.insert(&self.encode_scratch)?;
1055 self.maintain_indexes_on_insert(values, rid);
1056 if let Some(expression_keys) = expression_keys.as_deref() {
1057 self.maintain_expression_indexes_on_insert(expression_keys, rid);
1058 }
1059 return Ok(rid);
1060 }
1061 // Otherwise spill the largest var values out of line and store a v2
1062 // stub row. This self-contained path (no WAL) backs the WAL-off and
1063 // direct-`Table` callers; the WAL path in `Catalog` writes the same
1064 // chains but logs each chunk for crash recovery.
1065 let encoded = self.encode_row_spilling(values)?;
1066 let rid = self.heap.insert(&encoded)?;
1067 self.maintain_indexes_on_insert(values, rid);
1068 if let Some(expression_keys) = expression_keys.as_deref() {
1069 self.maintain_expression_indexes_on_insert(expression_keys, rid);
1070 }
1071 Ok(rid)
1072 }
1073
1074 /// Mark-and-sweep this table's overflow pages (design 3.6). Reclaims every
1075 /// Overflow-typed page not referenced by a live row's stub — i.e. orphans
1076 /// left by crashed transactions or by delete/chain-replacing updates whose
1077 /// pages were never returned to the free list. Returns the reclaimed page
1078 /// ids; the caller (catalog) logs them as one `OverflowFree` record.
1079 ///
1080 /// Runs against an on-disk-consistent view (flushes dirty pages first) and
1081 /// reads only version words, bitmaps, and stubs — never a full decode.
1082 pub(crate) fn sweep_overflow(&mut self) -> io::Result<Vec<u32>> {
1083 self.heap.flush_all_dirty()?;
1084 // Mark: collect every referenced chain head from live v2 rows, then
1085 // walk each chain into the referenced set. Stubs are gathered first so
1086 // the row scan's `&heap` borrow is released before the chain walks.
1087 let schema = &self.schema;
1088 let layout = &self.row_layout;
1089 let mut heads: Vec<u32> = Vec::new();
1090 self.heap.for_each_row(|_rid, bytes| {
1091 if crate::row::row_is_v2(bytes) {
1092 crate::row::for_each_stub(schema, layout, bytes, |_col, stub| {
1093 heads.push(stub.first_page);
1094 });
1095 }
1096 });
1097 let mut referenced: std::collections::HashSet<u32> = std::collections::HashSet::new();
1098 for head in heads {
1099 for pid in self.heap.overflow_chain_pages(head)? {
1100 referenced.insert(pid);
1101 }
1102 }
1103 // Sweep: reclaim unreferenced overflow pages below the watermark.
1104 self.heap.sweep_unreferenced_overflow(&referenced)
1105 }
1106
1107 /// Collect every overflow-chain page id referenced by the row at `rid`, or
1108 /// an empty vec when the table has never spilled, the row is gone, or the
1109 /// row is inline (v1). Used by the catalog to free a row's old chain when an
1110 /// update replaces/removes its spilled value or the row is deleted (design
1111 /// 3.6), so steady-state churn reclaims pages instead of leaking them.
1112 ///
1113 /// Does not touch the free list itself: the caller decides when it is safe
1114 /// to release (immediately for autocommit, at commit for an explicit tx).
1115 pub(crate) fn overflow_chain_pages_at(&self, rid: RowId) -> io::Result<Vec<u32>> {
1116 if !self.has_overflow_rows() {
1117 return Ok(Vec::new());
1118 }
1119 let data = match self.heap.get(rid) {
1120 Some(d) => d,
1121 None => return Ok(Vec::new()),
1122 };
1123 if !crate::row::row_is_v2(&data) {
1124 return Ok(Vec::new());
1125 }
1126 let mut heads: Vec<u32> = Vec::new();
1127 crate::row::for_each_stub(&self.schema, &self.row_layout, &data, |_col, stub| {
1128 heads.push(stub.first_page);
1129 });
1130 let mut pages = Vec::new();
1131 for head in heads {
1132 pages.extend(self.heap.overflow_chain_pages(head)?);
1133 }
1134 Ok(pages)
1135 }
1136
1137 /// Return a set of overflow-chain pages to this table's in-memory free list
1138 /// for reuse by the next spill. The catalog calls this once it is safe to
1139 /// reclaim (see [`Self::overflow_chain_pages_at`]).
1140 pub(crate) fn release_overflow_pages(&mut self, pages: &[u32]) {
1141 self.heap.release_overflow_pages(pages);
1142 }
1143
1144 /// Build the v2 stub-row encoding for `values`, writing each spilled
1145 /// value's overflow chain directly to the heap (no WAL — the WAL path
1146 /// lives in `Catalog`). Enforces `MAX_VALUE_SIZE` per value.
1147 fn encode_row_spilling(&mut self, values: &Row) -> io::Result<Vec<u8>> {
1148 let v1_len = crate::row::v1_encoded_len(&self.row_layout, values);
1149 let is_indexed = self.indexed_col_mask();
1150 let chosen = plan_spill(&self.row_layout, values, v1_len, &is_indexed);
1151 let n_var = self.row_layout.n_var();
1152 let mut spilled: Vec<Option<OverflowStub>> = vec![None; n_var];
1153 for col_idx in chosen {
1154 let var_idx = self
1155 .row_layout
1156 .var_index(col_idx)
1157 .expect("plan_spill only returns var columns");
1158 let bytes: Vec<u8> = match &values[col_idx] {
1159 Value::Str(s) => s.as_bytes().to_vec(),
1160 Value::Bytes(b) => b.to_vec(),
1161 Value::Json(b) => b.to_vec(),
1162 _ => continue,
1163 };
1164 if bytes.len() > MAX_VALUE_SIZE {
1165 return Err(StorageError::ValueTooLarge {
1166 size: bytes.len(),
1167 max: MAX_VALUE_SIZE,
1168 }
1169 .into());
1170 }
1171 spilled[var_idx] = Some(self.write_value_chain(&bytes)?);
1172 }
1173 let mut out = Vec::new();
1174 encode_row_v2_into(&self.schema, &self.row_layout, values, &spilled, &mut out);
1175 Ok(out)
1176 }
1177
1178 /// Allocate and write an overflow chain for `value` directly to the heap
1179 /// (LSN 0, no WAL). Head-first, singly linked. Returns the stub.
1180 fn write_value_chain(&mut self, value: &[u8]) -> io::Result<OverflowStub> {
1181 let n = value.len().div_ceil(OVERFLOW_PAYLOAD_CAP).max(1);
1182 let mut pages = Vec::with_capacity(n);
1183 for _ in 0..n {
1184 pages.push(self.heap.allocate_overflow_page()?);
1185 }
1186 for i in 0..n {
1187 let start = i * OVERFLOW_PAYLOAD_CAP;
1188 let end = (start + OVERFLOW_PAYLOAD_CAP).min(value.len());
1189 let next = if i + 1 < n {
1190 pages[i + 1]
1191 } else {
1192 OVERFLOW_CHAIN_END
1193 };
1194 self.heap
1195 .write_overflow_page(pages[i], next, &value[start..end], 0)?;
1196 }
1197 Ok(OverflowStub::new(
1198 value.len() as u64,
1199 pages[0],
1200 crc32fast::hash(value),
1201 ))
1202 }
1203
1204 /// Insert a row whose bytes were encoded by the caller (used by the
1205 /// overflow spill path, which builds a v2 stub row and writes the
1206 /// out-of-line chains before calling here). Index maintenance still uses
1207 /// the LOGICAL `values` — extraction happens on the full value before
1208 /// spill, so indexes never see a stub.
1209 pub(crate) fn insert_encoded(&mut self, values: &Row, encoded: &[u8]) -> io::Result<RowId> {
1210 let expression_keys = self.preflight_insert(values)?;
1211 let rid = self.heap.insert(encoded)?;
1212 self.maintain_indexes_on_insert(values, rid);
1213 if let Some(expression_keys) = expression_keys.as_deref() {
1214 self.maintain_expression_indexes_on_insert(expression_keys, rid);
1215 }
1216 Ok(rid)
1217 }
1218
1219 /// Insert the row's indexed columns into every b-tree from the logical
1220 /// values. Blocker B3: marks trees dirty in memory; the save is deferred
1221 /// to the next checkpoint.
1222 fn maintain_indexes_on_insert(&mut self, values: &Row, rid: RowId) {
1223 if self.indexed_cols.is_empty() {
1224 return;
1225 }
1226 for entry in &mut self.indexed_cols {
1227 let val = &values[entry.col_idx];
1228 if val.is_empty() {
1229 continue;
1230 }
1231 if entry.unique {
1232 if entry.is_int {
1233 if let Value::Int(i) = val {
1234 entry.btree.insert_int(*i, rid);
1235 continue;
1236 }
1237 }
1238 entry.btree.insert(val.clone(), rid);
1239 } else {
1240 entry.btree.insert_non_unique(val.clone(), rid);
1241 }
1242 }
1243 }
1244
1245 /// Blocker B3: flush every dirty btree index to disk. Wired into
1246 /// [`crate::catalog::Catalog::checkpoint`] and its `Drop` impl so
1247 /// we get one fsync + rename per dirty index per checkpoint, not
1248 /// one per inserted row. Clean trees (no mutations since last
1249 /// save) are free — `BTree::save_if_dirty` early-returns.
1250 pub(crate) fn save_dirty_indexes(&mut self) -> io::Result<()> {
1251 for entry in self.indexed_cols.iter_mut() {
1252 entry.btree.save_if_dirty()?;
1253 }
1254 for entry in self.expression_indexes.iter_mut() {
1255 entry.btree.save_if_dirty()?;
1256 }
1257 Ok(())
1258 }
1259
1260 /// Discard uncommitted, in-memory index mutations so they never reach
1261 /// disk. Called by ROLLBACK on the catalog it is about to drop: without
1262 /// this, the drop-time checkpoint's `save_dirty_indexes` would flush the
1263 /// rolled-back index writes to the `.idx` files, poisoning the unique
1264 /// index (see `Catalog::rollback_to_last_sync_inner`). Mirrors
1265 /// `Heap::discard_dirty` for the heap side.
1266 pub(crate) fn discard_dirty_indexes(&mut self) {
1267 for entry in self.indexed_cols.iter_mut() {
1268 entry.btree.discard_dirty();
1269 }
1270 for entry in self.expression_indexes.iter_mut() {
1271 entry.btree.discard_dirty();
1272 }
1273 }
1274
1275 /// Blocker B3: rebuild every secondary index from the heap.
1276 ///
1277 /// Used by the crash-recovery path in `Catalog::open`: after WAL
1278 /// replay lands rows back in the heap, the on-disk `.idx` files
1279 /// may lag (or lead) the heap because the prior session deferred
1280 /// btree saves until checkpoint. Replaying is cheap — we walk the
1281 /// heap once per index — and produces a tree that exactly matches
1282 /// the current heap state, which is the invariant subsequent
1283 /// inserts assume.
1284 ///
1285 /// After this call, every indexed tree is marked dirty so the
1286 /// next `Catalog::checkpoint` persists the recovered state.
1287 pub(crate) fn rebuild_indexes_from_heap(&mut self) -> io::Result<()> {
1288 if self.indexed_cols.is_empty() && self.expression_indexes.is_empty() {
1289 return Ok(());
1290 }
1291
1292 // Snapshot raw rows once so the reassembly below can take a second
1293 // shared borrow of the heap (read_overflow_value) without conflicting
1294 // with a live scan iterator. Rebuild is a cold recovery path, so the
1295 // owned snapshot is fine.
1296 let raw_rows: Vec<(RowId, Vec<u8>)> = self.heap.scan().collect();
1297 let schema = &self.schema;
1298 let layout = &self.row_layout;
1299 let heap = &self.heap;
1300 for entry in self.indexed_cols.iter_mut() {
1301 // Non-unique indexes rebuild at v3 (NUL-escaped composite Str keys);
1302 // unique indexes stay v1.
1303 let mut fresh = if entry.unique {
1304 BTree::create(entry.btree.file_path())?
1305 } else {
1306 BTree::create_non_unique(entry.btree.file_path())?
1307 };
1308 for (rid, raw) in &raw_rows {
1309 let (rid, raw) = (*rid, raw.as_slice());
1310 // v2 rows must be reassembled so a spilled indexed column
1311 // produces its true key, not Empty (P2: create-index /
1312 // rebuild-after-spill must not build a btree of missing keys).
1313 let owned;
1314 let v: &Value = if crate::row::row_is_v2(raw) {
1315 match crate::row::decode_row_v2(schema, layout, raw, |stub| {
1316 heap.read_overflow_value(stub).map_err(io::Error::from)
1317 }) {
1318 Ok(row) => {
1319 owned = row[entry.col_idx].clone();
1320 &owned
1321 }
1322 Err(_) => continue,
1323 }
1324 } else {
1325 owned = decode_column(schema, layout, raw, entry.col_idx);
1326 &owned
1327 };
1328 if v.is_empty() {
1329 continue;
1330 }
1331 if entry.unique {
1332 if entry.is_int {
1333 if let Value::Int(i) = v {
1334 fresh.insert_int(*i, rid);
1335 continue;
1336 }
1337 }
1338 fresh.insert(v.clone(), rid);
1339 } else {
1340 fresh.insert_non_unique(v.clone(), rid);
1341 }
1342 }
1343 // Force-mark dirty so the next checkpoint flushes the
1344 // freshly rebuilt tree, even if no further mutations
1345 // happen before shutdown.
1346 fresh.mark_dirty();
1347 entry.btree = fresh;
1348 }
1349 for entry in self.expression_indexes.iter_mut() {
1350 let mut fresh = BTree::create_v2(entry.btree.file_path())?;
1351 for (rid, raw) in &raw_rows {
1352 let row = if crate::row::row_is_v2(raw) {
1353 crate::row::decode_row_v2(schema, layout, raw, |stub| {
1354 heap.read_overflow_value(stub).map_err(io::Error::from)
1355 })?
1356 } else {
1357 decode_row(schema, raw)
1358 };
1359 let key = expression_key(&entry.meta, &row[entry.root_col_idx])?;
1360 if key.is_empty() {
1361 fresh.insert_empty(*rid);
1362 } else if entry.meta.unique {
1363 if fresh.lookup(&key).is_some() {
1364 return Err(expression_unique_error(
1365 &self.schema.table_name,
1366 &entry.meta.canonical_text,
1367 ));
1368 }
1369 fresh.insert(key, *rid);
1370 } else {
1371 fresh.insert_duplicate(key, *rid);
1372 }
1373 }
1374 fresh.mark_dirty();
1375 entry.btree = fresh;
1376 }
1377 Ok(())
1378 }
1379
1380 pub fn get(&self, rid: RowId) -> Option<Row> {
1381 let data = self.heap.get(rid)?;
1382 if crate::row::row_is_v2(&data) {
1383 // v2 row: reassemble each spilled column from its overflow chain.
1384 return crate::row::decode_row_v2(&self.schema, &self.row_layout, &data, |stub| {
1385 self.heap.read_overflow_value(stub).map_err(io::Error::from)
1386 })
1387 .ok();
1388 }
1389 Some(decode_row(&self.schema, &data))
1390 }
1391
1392 /// Read only the requested logical columns from one row.
1393 ///
1394 /// Output order exactly follows `column_indices`, including duplicates.
1395 /// Inline values are decoded directly from the row body. For a v2 row,
1396 /// an overflow chain is fetched and verified only when its column was
1397 /// requested; an unselected spilled value is never touched.
1398 pub fn get_projected(
1399 &self,
1400 rid: RowId,
1401 column_indices: &[usize],
1402 ) -> io::Result<Option<Vec<Value>>> {
1403 for &column_index in column_indices {
1404 if column_index >= self.schema.columns.len() {
1405 return Err(io::Error::new(
1406 io::ErrorKind::InvalidInput,
1407 format!(
1408 "projected column index {column_index} out of range for {} columns",
1409 self.schema.columns.len()
1410 ),
1411 ));
1412 }
1413 }
1414
1415 let Some(data) = self.heap.get(rid) else {
1416 return Ok(None);
1417 };
1418 let mut values: Vec<Value> = Vec::with_capacity(column_indices.len());
1419 for (request_position, &column_index) in column_indices.iter().enumerate() {
1420 if let Some(previous_position) = column_indices[..request_position]
1421 .iter()
1422 .position(|&previous| previous == column_index)
1423 {
1424 values.push(values[previous_position].clone());
1425 continue;
1426 }
1427
1428 let value = if let Some(stub) =
1429 crate::row::raw_stub(&self.schema, &self.row_layout, &data, column_index)
1430 {
1431 let bytes = self
1432 .heap
1433 .read_overflow_value(&stub)
1434 .map_err(io::Error::from)?;
1435 match self.schema.columns[column_index].type_id {
1436 TypeId::Str => Value::Str(String::from_utf8(bytes).map_err(|error| {
1437 io::Error::new(
1438 io::ErrorKind::InvalidData,
1439 format!("invalid UTF-8 in projected string column: {error}"),
1440 )
1441 })?),
1442 TypeId::Bytes => Value::Bytes(bytes),
1443 TypeId::Json => Value::Json(bytes.into()),
1444 _ => {
1445 return Err(io::Error::new(
1446 io::ErrorKind::InvalidData,
1447 "fixed-width column has an overflow stub",
1448 ));
1449 }
1450 }
1451 } else {
1452 decode_column(&self.schema, &self.row_layout, &data, column_index)
1453 };
1454 values.push(value);
1455 }
1456 Ok(Some(values))
1457 }
1458
1459 /// Delete a row. Mission C Phase 7: if the table has indexes, we used to
1460 /// call `decode_row` here — allocating `Row` + every column's `Value`
1461 /// just to read the two or three columns that actually feed the index.
1462 /// Now we borrow the raw page bytes once and call `decode_column` for
1463 /// exactly the indexed columns, skipping the rest of the row entirely.
1464 ///
1465 /// Mission C Phase 11: the Phase 7 version still allocated a
1466 /// `Vec<(usize, Value)>` per row so the btree mutations could happen
1467 /// after the hot-page borrow closed. That's 3300 heap allocations per
1468 /// 100K-row `delete_by_filter` iteration — gone in Phase 11 via
1469 /// struct-field borrow splitting, so the btree lives alongside the
1470 /// page borrow inside the closure.
1471 pub fn delete(&mut self, rid: RowId) -> io::Result<()> {
1472 if self.indexed_cols.is_empty() && self.expression_indexes.is_empty() {
1473 return self.heap.delete(rid);
1474 }
1475 let expression_keys = self.expression_keys_at(rid)?.ok_or_else(|| {
1476 io::Error::new(io::ErrorKind::NotFound, "row not found for index deletion")
1477 })?;
1478
1479 // Split the borrow so `indexed_cols` (mutable — the btree lives
1480 // inside each entry now) can be captured by the closure alongside
1481 // `heap` (also mutable). Rust's disjoint-field borrowing lets
1482 // this compile without cloning anything.
1483 let Table {
1484 heap,
1485 schema,
1486 row_layout: layout,
1487 indexed_cols,
1488 ..
1489 } = self;
1490
1491 // A spilled indexed column holds only a stub inline, so its key must be
1492 // reassembled from the overflow chain — a v1-only `decode_column`
1493 // yields `Empty` and leaves the btree entry dangling (P2). Collect such
1494 // columns under the pinned borrow, reassemble after it closes. The v1
1495 // fast path is unchanged (no v2 row ⇒ `raw_stub` is always None ⇒ no
1496 // allocation, no deferral).
1497 let mut deferred: Vec<(usize, crate::row::OverflowStub)> = Vec::new();
1498 heap.with_row_bytes(rid, |data| {
1499 for (slot, entry) in indexed_cols.iter_mut().enumerate() {
1500 if let Some(stub) = crate::row::raw_stub(schema, layout, data, entry.col_idx) {
1501 deferred.push((slot, stub));
1502 continue;
1503 }
1504 let val = decode_column(schema, layout, data, entry.col_idx);
1505 if val.is_empty() {
1506 continue;
1507 }
1508 if entry.unique {
1509 // Unique index: key is the column value directly.
1510 match &val {
1511 Value::Int(i) => {
1512 entry.btree.delete_int(*i);
1513 }
1514 _ => {
1515 entry.btree.delete(&val);
1516 }
1517 }
1518 } else {
1519 // Non-unique index: key is composite (col_val, rid).
1520 entry.btree.delete_non_unique(&val, rid);
1521 }
1522 }
1523 })?;
1524
1525 // Reassemble + delete keys for any spilled indexed columns (rare:
1526 // `plan_spill` keeps indexed columns inline, so this only fires for
1527 // legacy rows or a column indexed AFTER its values spilled). The chain
1528 // pages are still intact here — `heap.delete` below only clears the
1529 // stub row's slot, never the overflow chain.
1530 for (slot, stub) in deferred {
1531 let bytes = heap.read_overflow_value(&stub).map_err(io::Error::from)?;
1532 let entry = &mut indexed_cols[slot];
1533 let val = match schema.columns[entry.col_idx].type_id {
1534 TypeId::Str => Value::Str(String::from_utf8_lossy(&bytes).into_owned()),
1535 TypeId::Bytes => Value::Bytes(bytes),
1536 TypeId::Json => Value::Json(bytes.into()),
1537 _ => continue,
1538 };
1539 if entry.unique {
1540 entry.btree.delete(&val);
1541 } else {
1542 entry.btree.delete_non_unique(&val, rid);
1543 }
1544 }
1545
1546 for (index, key) in self.expression_indexes.iter_mut().zip(&expression_keys) {
1547 if key.is_empty() {
1548 index.btree.delete_empty(rid);
1549 } else {
1550 index.btree.delete_pair(key, rid);
1551 }
1552 }
1553
1554 self.heap.delete(rid)?;
1555 // Blocker B3: btree mutations above marked the indexes dirty.
1556 // The actual persist happens at the next `Catalog::checkpoint`
1557 // (or `Drop`), batching many deletes into one fsync per index.
1558 Ok(())
1559 }
1560
1561 /// Mission C Phase 12: bulk delete a list of rids, batching the
1562 /// secondary-index maintenance.
1563 ///
1564 /// For a 100K-row `delete_by_filter` that removes ~20% of the rows,
1565 /// the per-row `Table::delete` path pays ~4ms of pure `Vec::remove`
1566 /// memmove inside the btree: every call shifts up to 4KB of leaf
1567 /// entries. This helper collects the indexed-column keys first,
1568 /// deletes the heap slots one by one (hot-page writes), then compacts
1569 /// each btree in a single pass via [`BTree::delete_many_int`].
1570 ///
1571 /// Restrictions / fall-through:
1572 /// - If the table has no indexes, this is equivalent to looping over
1573 /// `heap.delete`.
1574 /// - If any indexed column is not `TypeId::Int`, this falls back to
1575 /// the per-row `delete` path. The int-only constraint matches the
1576 /// only btree batch primitive we have (`delete_many_int`) and
1577 /// covers the overwhelmingly common case (primary keys,
1578 /// `created_at`, foreign keys).
1579 ///
1580 /// Returns the number of rows removed.
1581 pub fn delete_many(&mut self, rids: &[RowId]) -> io::Result<u64> {
1582 if rids.is_empty() {
1583 return Ok(0);
1584 }
1585 if !self.expression_indexes.is_empty() {
1586 let mut count = 0;
1587 for &rid in rids {
1588 self.delete(rid)?;
1589 count += 1;
1590 }
1591 return Ok(count);
1592 }
1593 if self.indexed_cols.is_empty() {
1594 for &rid in rids {
1595 self.heap.delete(rid)?;
1596 }
1597 return Ok(rids.len() as u64);
1598 }
1599
1600 // All indexed cols must be int AND unique for the batch btree
1601 // path to apply. Non-unique indexes use composite keys, so the
1602 // `delete_many_int` primitive (which searches by raw i64) won't
1603 // find them.
1604 let all_int_unique = self.indexed_cols.iter().all(|c| c.is_int && c.unique);
1605 if !all_int_unique {
1606 // Mixed index types — defer to the generic per-row path.
1607 let mut count = 0u64;
1608 for &rid in rids {
1609 self.delete(rid)?;
1610 count += 1;
1611 }
1612 return Ok(count);
1613 }
1614
1615 // Split the borrow so the closure can capture `schema`/`layout`/
1616 // `indexed_cols` while `heap` is borrowed mutably by
1617 // `delete_with_hook`.
1618 let Table {
1619 heap,
1620 schema,
1621 row_layout: layout,
1622 indexed_cols,
1623 ..
1624 } = self;
1625
1626 let n_indexed = indexed_cols.len();
1627 let mut keys_per_index: Vec<Vec<i64>> = (0..n_indexed)
1628 .map(|_| Vec::with_capacity(rids.len()))
1629 .collect();
1630
1631 let mut count = 0u64;
1632 for &rid in rids {
1633 let found = heap.delete_with_hook(rid, |data| {
1634 for (slot_i, entry) in indexed_cols.iter().enumerate() {
1635 if let Value::Int(i) = decode_column(schema, layout, data, entry.col_idx) {
1636 keys_per_index[slot_i].push(i);
1637 }
1638 }
1639 })?;
1640 if found {
1641 count += 1;
1642 }
1643 }
1644
1645 // Batch-compact each btree in a single leaf-chain walk. Mission C
1646 // Phase 17: btrees now live inline in indexed_cols, so this is a
1647 // direct `iter_mut()` over the same slice the hook above borrowed
1648 // immutably — no HashMap probe required.
1649 for (slot_i, entry) in indexed_cols.iter_mut().enumerate() {
1650 let keys = &mut keys_per_index[slot_i];
1651 keys.sort_unstable();
1652 entry.btree.delete_many_int(keys);
1653 }
1654
1655 // Blocker B3: indexes are now dirty in memory; `delete_many_int`
1656 // already flipped the dirty flag on each mutated btree above.
1657 // Checkpoint batches the persist.
1658
1659 Ok(count)
1660 }
1661
1662 /// Single-pass scan-and-delete driven by a raw-bytes predicate. Walks
1663 /// the heap once, marks matching rows deleted in place, and updates
1664 /// any int-keyed secondary indexes in a single batched
1665 /// `delete_many_int` per index at the end. Non-int secondary indexes
1666 /// fall back to per-key `btree.delete`, but still ride the same
1667 /// single heap pass.
1668 ///
1669 /// Mission C Phase 16: this is the Table-level hook for
1670 /// [`HeapFile::scan_delete_matching`]. See that method for the
1671 /// fusion rationale. The executor's `Delete` fast path routes
1672 /// `Filter(SeqScan)` / `SeqScan`-shaped delete plans here when the
1673 /// predicate compiles.
1674 pub fn scan_delete_matching<P>(&mut self, pred: P) -> io::Result<u64>
1675 where
1676 P: FnMut(&[u8]) -> bool,
1677 {
1678 self.scan_delete_matching_with_hook(pred, |_, _| {})
1679 }
1680
1681 /// Variant of [`Self::scan_delete_matching`] that lets the caller
1682 /// observe every matched row just before it's marked deleted. Used
1683 /// by [`crate::catalog::Catalog::scan_delete_matching_logged`] to
1684 /// emit one WAL `Delete` record per victim in the same single-pass
1685 /// scan — no second walk over the heap, no per-row `ensure_hot`
1686 /// round-trip.
1687 ///
1688 /// The user hook runs inside the heap's pinned hot-page borrow, so
1689 /// it must not call back into the catalog / table / heap. The WAL
1690 /// append path only writes into an in-memory buffer and is safe.
1691 pub fn scan_delete_matching_with_hook<P, H>(
1692 &mut self,
1693 mut pred: P,
1694 mut user_hook: H,
1695 ) -> io::Result<u64>
1696 where
1697 P: FnMut(&[u8]) -> bool,
1698 H: FnMut(RowId, &[u8]),
1699 {
1700 if !self.expression_indexes.is_empty() {
1701 let victims = self
1702 .heap
1703 .scan()
1704 .filter(|(_, bytes)| pred(bytes))
1705 .collect::<Vec<_>>();
1706 let count = victims.len() as u64;
1707 for (rid, bytes) in victims {
1708 user_hook(rid, &bytes);
1709 self.delete(rid)?;
1710 }
1711 return Ok(count);
1712 }
1713 if self.indexed_cols.is_empty() {
1714 return self.heap.scan_delete_matching(pred, |rid, bytes| {
1715 user_hook(rid, bytes);
1716 });
1717 }
1718
1719 // Split the borrow so the hook closure can capture schema /
1720 // layout / indexed_cols (immutably for reads) while `heap` is
1721 // mutably borrowed by `scan_delete_matching`. After the scan
1722 // completes, the closure is dropped, freeing the shared borrow
1723 // of `indexed_cols` so we can flip to `iter_mut()` for the
1724 // batch btree compaction.
1725 let Table {
1726 heap,
1727 schema,
1728 row_layout: layout,
1729 indexed_cols,
1730 ..
1731 } = self;
1732
1733 let n_indexed = indexed_cols.len();
1734 let all_int_unique = indexed_cols.iter().all(|c| c.is_int && c.unique);
1735
1736 if all_int_unique {
1737 let mut keys_per_index: Vec<Vec<i64>> =
1738 (0..n_indexed).map(|_| Vec::with_capacity(1024)).collect();
1739
1740 let count = heap.scan_delete_matching(pred, |rid, data| {
1741 for (slot_i, entry) in indexed_cols.iter().enumerate() {
1742 if let Value::Int(i) = decode_column(schema, layout, data, entry.col_idx) {
1743 keys_per_index[slot_i].push(i);
1744 }
1745 }
1746 user_hook(rid, data);
1747 })?;
1748
1749 // Mission C Phase 17: btrees live inline in indexed_cols,
1750 // so this direct iter_mut replaces the old HashMap probe.
1751 for (slot_i, entry) in indexed_cols.iter_mut().enumerate() {
1752 let keys = &mut keys_per_index[slot_i];
1753 keys.sort_unstable();
1754 entry.btree.delete_many_int(keys);
1755 }
1756 // Blocker B3: dirty flags are already set by the
1757 // per-btree `delete_many_int` call above; checkpoint
1758 // handles the persist.
1759 return Ok(count);
1760 }
1761
1762 // Mixed / non-int / non-unique secondary indexes: single heap
1763 // pass, per-key btree deletes at the end. We collect (value, rid)
1764 // pairs so non-unique indexes can delete the correct composite key.
1765 let mut entries_per_index: Vec<Vec<(Value, RowId)>> =
1766 (0..n_indexed).map(|_| Vec::with_capacity(256)).collect();
1767 // Spilled indexed columns (v2 rows) can't be decoded inline — collect
1768 // their stubs and reassemble after the scan releases the heap borrow
1769 // (P2: a v1-only decode_column would yield Empty ⇒ dangling entry).
1770 let mut deferred: Vec<(usize, crate::row::OverflowStub, RowId)> = Vec::new();
1771
1772 let count = heap.scan_delete_matching(pred, |rid, data| {
1773 for (slot_i, entry) in indexed_cols.iter().enumerate() {
1774 if let Some(stub) = crate::row::raw_stub(schema, layout, data, entry.col_idx) {
1775 deferred.push((slot_i, stub, rid));
1776 continue;
1777 }
1778 let v = decode_column(schema, layout, data, entry.col_idx);
1779 if !v.is_empty() {
1780 entries_per_index[slot_i].push((v, rid));
1781 }
1782 }
1783 user_hook(rid, data);
1784 })?;
1785
1786 // Reassemble spilled indexed keys now that the scan's heap borrow is
1787 // released (the deleted rows' overflow chains are still intact — the
1788 // scan clears slots only, not chains).
1789 for (slot_i, stub, rid) in deferred {
1790 let bytes = heap.read_overflow_value(&stub).map_err(io::Error::from)?;
1791 let v = match schema.columns[indexed_cols[slot_i].col_idx].type_id {
1792 TypeId::Str => Value::Str(String::from_utf8_lossy(&bytes).into_owned()),
1793 TypeId::Bytes => Value::Bytes(bytes),
1794 TypeId::Json => Value::Json(bytes.into()),
1795 _ => continue,
1796 };
1797 entries_per_index[slot_i].push((v, rid));
1798 }
1799
1800 for (slot_i, entry) in indexed_cols.iter_mut().enumerate() {
1801 for (v, rid) in &entries_per_index[slot_i] {
1802 if entry.unique {
1803 entry.btree.delete(v);
1804 } else {
1805 entry.btree.delete_non_unique(v, *rid);
1806 }
1807 }
1808 }
1809 // Blocker B3: btree dirty flags are set by `delete`; checkpoint
1810 // flushes later.
1811 Ok(count)
1812 }
1813
1814 /// Single-pass fused scan + in-place patch. Evaluates `pred` on raw
1815 /// row bytes and applies `try_mutate` to each match on the same hot
1816 /// page — no second pass. Returns `(patched_count, fallback_rids)`.
1817 ///
1818 /// The `hook` closure fires after each successful patch with the
1819 /// post-mutation bytes, used for WAL logging.
1820 ///
1821 /// Perf sprint: this is the update analogue of
1822 /// `scan_delete_matching_with_hook`. Eliminates the two-pass
1823 /// collect-then-patch pattern that doubled `ensure_hot` calls for
1824 /// `update_by_filter`.
1825 pub fn scan_patch_matching_with_hook<P, M, H>(
1826 &mut self,
1827 pred: P,
1828 try_mutate: M,
1829 hook: H,
1830 ) -> io::Result<(u64, Vec<RowId>)>
1831 where
1832 P: FnMut(&[u8]) -> bool,
1833 M: FnMut(&mut [u8]) -> Option<u16>,
1834 H: FnMut(RowId, &[u8]),
1835 {
1836 // No index maintenance needed — callers guarantee the patched
1837 // columns are not indexed (same constraint as the per-rid
1838 // `with_row_bytes_mut` / `patch_var_col_in_place` fast paths).
1839 self.heap.scan_patch_matching(pred, try_mutate, hook)
1840 }
1841
1842 /// Update a row in place when possible. Falls back to delete+insert only
1843 /// if the new encoding doesn't fit in the current slot.
1844 ///
1845 /// Mission D5: the previous implementation always did `delete + insert`,
1846 /// which:
1847 /// 1. read+wrote the page twice (once to clear the slot, once to fill it
1848 /// again — usually on a different page),
1849 /// 2. did an O(N) scan over `pages_with_space` for every insert,
1850 /// 3. mutated every index even when the indexed column hadn't changed.
1851 ///
1852 /// On `update_by_filter` (50K matching rows, status-only update, no
1853 /// index on status) that turned ~1ms of work into 30 seconds — a
1854 /// catastrophic O(N²)-ish gap vs SQLite (6.7ms total). The fix is to
1855 /// (a) prefer `heap.update` which tries in-place first and (b) only
1856 /// touch indexes whose value actually changed.
1857 pub fn update(&mut self, rid: RowId, values: &Row) -> io::Result<RowId> {
1858 self.update_hinted(rid, values, None)
1859 }
1860
1861 /// Same as `update`, but the caller can supply the set of column
1862 /// indices that actually changed. If supplied, the old-row read is
1863 /// skipped entirely when none of the changed columns is indexed.
1864 ///
1865 /// Mission C Phase 2: `update_by_filter` hits this path ~50K times with
1866 /// a single-column assignment (status) on a table whose only index is
1867 /// on `id`. The old code called `self.get(rid)` unconditionally — a
1868 /// heap read + full decode every time — even though the result was
1869 /// always thrown away for non-indexed updates. Skipping that read is
1870 /// worth ~300ns/row, or ~15ms on a 50K-row update_by_filter.
1871 pub fn update_hinted(
1872 &mut self,
1873 rid: RowId,
1874 values: &Row,
1875 changed_col_indices: Option<&[usize]>,
1876 ) -> io::Result<RowId> {
1877 self.preflight_update(rid, values)?;
1878 // Size the new row first: if it exceeds the inline cap, an overflow
1879 // transition takes delete+insert of a v2 stub row (self-contained,
1880 // no WAL — the WAL path lives in `Catalog::update`). In-place patch
1881 // fast paths stay v1/inline-only (they never reach here for big rows).
1882 if crate::row::v1_encoded_len(&self.row_layout, values) > crate::page::MAX_ROW_DATA_SIZE {
1883 let encoded = self.encode_row_spilling(values)?;
1884 return self.apply_update(rid, values, &encoded, changed_col_indices);
1885 }
1886 encode_row_into_with_layout(
1887 &self.schema,
1888 &self.row_layout,
1889 values,
1890 &mut self.encode_scratch,
1891 );
1892 // Move the scratch out so `apply_update` can borrow `self` mutably for
1893 // the heap update without aliasing the scratch buffer.
1894 let encoded = std::mem::take(&mut self.encode_scratch);
1895 let result = self.apply_update(rid, values, &encoded, changed_col_indices);
1896 self.encode_scratch = encoded;
1897 result
1898 }
1899
1900 /// Update a row with caller-supplied pre-encoded bytes (used by the
1901 /// catalog WAL path, which builds the v2 stub row and logs the overflow
1902 /// chains itself). Index maintenance uses the logical `values`.
1903 pub(crate) fn update_encoded(
1904 &mut self,
1905 rid: RowId,
1906 values: &Row,
1907 encoded: &[u8],
1908 changed_col_indices: Option<&[usize]>,
1909 ) -> io::Result<RowId> {
1910 self.apply_update(rid, values, encoded, changed_col_indices)
1911 }
1912
1913 /// Shared core of the update paths: unique pre-check, heap update with the
1914 /// already-encoded bytes, and secondary-index maintenance from the logical
1915 /// `values`.
1916 fn apply_update(
1917 &mut self,
1918 rid: RowId,
1919 values: &Row,
1920 encoded: &[u8],
1921 changed_col_indices: Option<&[usize]>,
1922 ) -> io::Result<RowId> {
1923 self.preflight_update(rid, values)?;
1924 let old_expression_keys = self.expression_keys_at(rid)?.ok_or_else(|| {
1925 io::Error::new(io::ErrorKind::NotFound, "row not found for index update")
1926 })?;
1927 let new_expression_keys = self.expression_keys(values)?;
1928 let touches_index = if self.indexed_cols.is_empty() {
1929 false
1930 } else if let Some(changed) = changed_col_indices {
1931 self.indexed_cols
1932 .iter()
1933 .any(|c| changed.contains(&c.col_idx))
1934 } else {
1935 // No hint — fall back to the safe path that reads the old row.
1936 true
1937 };
1938
1939 let old_row = if touches_index { self.get(rid) } else { None };
1940
1941 let new_rid = self.heap.update(rid, encoded)?;
1942
1943 // P0 (overflow relocation): a spill/unspill (or any grow) update turns
1944 // into heap delete+insert and hands back a NEW rid. When no indexed
1945 // column changed, `touches_index` is false and the block below is
1946 // skipped -- but every index entry still points at the OLD rid, so a
1947 // relocated row vanishes from all keyed access (point lookup, filtered
1948 // update/delete) and only an unfiltered scan finds it. Repoint every
1949 // index from `rid` -> `new_rid` using the (unchanged) current values,
1950 // which for an unchanged column equal the old key. The `touches_index`
1951 // path already handles relocation via its `new_rid == rid` guards.
1952 if !touches_index && new_rid != rid && !self.indexed_cols.is_empty() {
1953 for entry in self.indexed_cols.iter_mut() {
1954 let val = &values[entry.col_idx];
1955 if val.is_empty() {
1956 continue;
1957 }
1958 if entry.unique {
1959 entry.btree.delete(val);
1960 entry.btree.insert(val.clone(), new_rid);
1961 } else {
1962 entry.btree.delete_non_unique(val, rid);
1963 entry.btree.insert_non_unique(val.clone(), new_rid);
1964 }
1965 }
1966 }
1967
1968 if touches_index {
1969 // Mission C Phase 17: walk the Vec<IndexedCol> directly.
1970 // `col_idx` is already precomputed on each entry, so we
1971 // don't even re-probe schema.column_index here.
1972 for entry in self.indexed_cols.iter_mut() {
1973 let new_val = &values[entry.col_idx];
1974 let old_val_opt = old_row.as_ref().map(|r| &r[entry.col_idx]);
1975
1976 if entry.unique {
1977 if let Some(old_val) = old_val_opt {
1978 if old_val == new_val && new_rid == rid {
1979 continue;
1980 }
1981 if !old_val.is_empty() {
1982 entry.btree.delete(old_val);
1983 }
1984 }
1985 if !new_val.is_empty() {
1986 entry.btree.insert(new_val.clone(), new_rid);
1987 }
1988 } else {
1989 // Non-unique: delete old composite, insert new composite.
1990 if let Some(old_val) = old_val_opt {
1991 if old_val == new_val && new_rid == rid {
1992 continue;
1993 }
1994 if !old_val.is_empty() {
1995 entry.btree.delete_non_unique(old_val, rid);
1996 }
1997 }
1998 if !new_val.is_empty() {
1999 entry.btree.insert_non_unique(new_val.clone(), new_rid);
2000 }
2001 }
2002 }
2003 }
2004 for ((index, old_key), new_key) in self
2005 .expression_indexes
2006 .iter_mut()
2007 .zip(old_expression_keys)
2008 .zip(new_expression_keys)
2009 {
2010 if old_key == new_key && rid == new_rid {
2011 continue;
2012 }
2013 if old_key.is_empty() {
2014 index.btree.delete_empty(rid);
2015 } else {
2016 index.btree.delete_pair(&old_key, rid);
2017 }
2018 if new_key.is_empty() {
2019 index.btree.insert_empty(new_rid);
2020 } else if index.meta.unique {
2021 index.btree.insert(new_key, new_rid);
2022 } else {
2023 index.btree.insert_duplicate(new_key, new_rid);
2024 }
2025 }
2026 // Blocker B3: any mutated btree is now dirty; checkpoint will
2027 // persist it. No per-row fsync on this hot path.
2028 Ok(new_rid)
2029 }
2030
2031 /// Patch a row's raw bytes in place. Caller guarantees the mutation
2032 /// does not change the row's total length and does not touch any
2033 /// indexed column — indexes are NOT updated by this path.
2034 ///
2035 /// Mission C Phase 4: see `HeapFile::with_row_bytes_mut`. This is the
2036 /// primitive that backs the executor's single-column fixed-width
2037 /// update fast path.
2038 #[inline]
2039 pub fn with_row_bytes_mut<F>(&mut self, rid: RowId, f: F) -> io::Result<bool>
2040 where
2041 F: FnOnce(&mut [u8]),
2042 {
2043 self.heap.with_row_bytes_mut(rid, f)
2044 }
2045
2046 /// Patch a single var-length column in place, shrinking the row when
2047 /// the new value is smaller than the old one. Returns `Ok(true)` on
2048 /// success, `Ok(false)` when the new value would grow the row or the
2049 /// slot is gone (caller should fall back to the full update path).
2050 ///
2051 /// The caller is responsible for ensuring no indexed column is
2052 /// touched by this patch — indexes are NOT maintained here.
2053 ///
2054 /// Mission C Phase 10: backs the executor's `update_by_filter` fast
2055 /// path for var-length single-column assignments.
2056 #[inline]
2057 pub fn patch_var_col_in_place(
2058 &mut self,
2059 rid: RowId,
2060 col_idx: usize,
2061 new_value: Option<&[u8]>,
2062 ) -> io::Result<bool> {
2063 if self.has_indexed_col(col_idx) {
2064 return Err(io::Error::new(
2065 io::ErrorKind::InvalidInput,
2066 "cannot byte-patch an indexed column",
2067 ));
2068 }
2069 let layout = &self.row_layout;
2070 self.heap.patch_row_shrink(rid, |bytes| {
2071 patch_var_column_in_place(bytes, layout, col_idx, new_value)
2072 })
2073 }
2074
2075 /// Cached row layout for this table. Used by the executor to plan
2076 /// the byte-patch fast paths without re-walking the schema.
2077 #[inline]
2078 pub fn row_layout(&self) -> &RowLayout {
2079 &self.row_layout
2080 }
2081
2082 /// Mission C Phase 15: does the given schema column index have an
2083 /// index attached? Used by the executor's update fast-path planner
2084 /// to decide whether a byte-patch update is safe (no index to
2085 /// maintain). Linear scan over `indexed_cols` — typically 1–3
2086 /// entries, so cheaper than a HashMap lookup by name.
2087 #[inline]
2088 pub fn has_indexed_col(&self, col_idx: usize) -> bool {
2089 self.indexed_cols.iter().any(|c| c.col_idx == col_idx)
2090 || self
2091 .expression_indexes
2092 .iter()
2093 .any(|index| index.root_col_idx == col_idx)
2094 }
2095
2096 /// A `is_indexed[col_idx]` mask over all schema columns, for
2097 /// [`crate::row::plan_spill`] so indexed columns are kept inline (see the
2098 /// P2 dangling-index-entry fix). Cheap: one bool vec, a handful of index
2099 /// entries walked.
2100 pub(crate) fn indexed_col_mask(&self) -> Vec<bool> {
2101 let mut mask = vec![false; self.schema.columns.len()];
2102 for entry in &self.indexed_cols {
2103 if entry.col_idx < mask.len() {
2104 mask[entry.col_idx] = true;
2105 }
2106 }
2107 mask
2108 }
2109
2110 /// Heap on-disk format version. `>= HEAP_FORMAT_VERSION_WITH_OVERFLOW` (3)
2111 /// means the table has used overflow pages at least once, so it may hold
2112 /// v2 (spilled) rows. The executor uses this to route such tables away from
2113 /// the v1-only raw-byte fast paths (which cannot correctly read or patch a
2114 /// v2 row) and onto the reassembling decode paths.
2115 #[inline]
2116 pub fn format_version(&self) -> u16 {
2117 self.heap.format_version()
2118 }
2119
2120 /// Whether this table may hold v2 (spilled) rows: true once its heap has
2121 /// ever written an overflow chain. The executor gates the v1-only raw-byte
2122 /// read/patch fast paths on this — a spilled table takes the reassembling
2123 /// decode paths instead (correct for values of any size, including the
2124 /// `>= 64KB` values that cannot be re-inlined into a u16 v1 row).
2125 #[inline]
2126 pub fn has_overflow_rows(&self) -> bool {
2127 self.heap.format_version() >= crate::heap::HEAP_FORMAT_VERSION_WITH_OVERFLOW
2128 }
2129
2130 pub fn scan(&self) -> impl Iterator<Item = (RowId, Row)> + '_ {
2131 self.heap.scan().map(|(rid, data)| {
2132 if crate::row::row_is_v2(&data) {
2133 let row =
2134 crate::row::decode_row_v2(&self.schema, &self.row_layout, &data, |stub| {
2135 self.heap.read_overflow_value(stub).map_err(io::Error::from)
2136 })
2137 // A corrupt chain during a scan degrades to Empty cells rather
2138 // than aborting the whole scan; `get` surfaces the typed error.
2139 .unwrap_or_else(|_| vec![Value::Empty; self.schema.columns.len()]);
2140 (rid, row)
2141 } else {
2142 (rid, decode_row(&self.schema, &data))
2143 }
2144 })
2145 }
2146
2147 /// Zero-copy scan that passes raw row bytes to the callback. v1/v0 rows
2148 /// are handed through untouched (zero copy). A v2 row is reassembled into
2149 /// an equivalent v1 (fully inline) row first — its spilled columns are
2150 /// fetched from the overflow chains — so every downstream consumer
2151 /// (`decode_row`, `decode_column`, compiled predicates) sees a v1 layout
2152 /// and needs no v2 awareness. Only the rare v2 rows pay the reassembly;
2153 /// v1 rows stay on the mmap zero-copy path. A row whose chain is corrupt
2154 /// is skipped (its typed error surfaces via `get`).
2155 pub fn for_each_row_raw<F>(&self, mut f: F)
2156 where
2157 F: FnMut(RowId, &[u8]),
2158 {
2159 let schema = &self.schema;
2160 let layout = &self.row_layout;
2161 let heap = &self.heap;
2162 heap.for_each_row(|rid, data| {
2163 if crate::row::row_is_v2(data) {
2164 if let Ok(v1) = crate::row::rehydrate_v2_to_v1(schema, layout, data, |stub| {
2165 heap.read_overflow_value(stub).map_err(io::Error::from)
2166 }) {
2167 f(rid, &v1);
2168 }
2169 } else {
2170 f(rid, data);
2171 }
2172 });
2173 }
2174
2175 /// Zero-copy scan with early termination. The callback returns
2176 /// `ControlFlow::Break(())` to stop. Used by `Limit` fast paths. v2 rows
2177 /// are reassembled to v1 first (see [`Self::for_each_row_raw`]).
2178 pub fn try_for_each_row_raw<F>(&self, mut f: F)
2179 where
2180 F: FnMut(RowId, &[u8]) -> std::ops::ControlFlow<()>,
2181 {
2182 use std::ops::ControlFlow;
2183 let schema = &self.schema;
2184 let layout = &self.row_layout;
2185 let heap = &self.heap;
2186 heap.try_for_each_row(|rid, data| {
2187 if crate::row::row_is_v2(data) {
2188 match crate::row::rehydrate_v2_to_v1(schema, layout, data, |stub| {
2189 heap.read_overflow_value(stub).map_err(io::Error::from)
2190 }) {
2191 Ok(v1) => f(rid, &v1),
2192 Err(_) => ControlFlow::Continue(()),
2193 }
2194 } else {
2195 f(rid, data)
2196 }
2197 });
2198 }
2199
2200 pub fn index_lookup(&self, col_name: &str, key: &Value) -> Option<(RowId, Row)> {
2201 let entry = self.indexed_cols.iter().find(|c| c.col_name == col_name)?;
2202 if entry.unique {
2203 let rid = entry.btree.lookup(key)?;
2204 let row = self.get(rid)?;
2205 Some((rid, row))
2206 } else {
2207 // Non-unique: return the first match (for backwards compat).
2208 let rids = entry.btree.lookup_prefix(key);
2209 let rid = *rids.first()?;
2210 let row = self.get(rid)?;
2211 Some((rid, row))
2212 }
2213 }
2214
2215 /// Look up ALL matching rows for a column value. For unique indexes
2216 /// this returns 0 or 1 results. For non-unique indexes this returns
2217 /// all rows whose indexed column equals `key`.
2218 pub fn index_lookup_all(&self, col_name: &str, key: &Value) -> Vec<RowId> {
2219 let entry = match self.indexed_cols.iter().find(|c| c.col_name == col_name) {
2220 Some(e) => e,
2221 None => return Vec::new(),
2222 };
2223 if entry.unique {
2224 match entry.btree.lookup(key) {
2225 Some(rid) => vec![rid],
2226 None => Vec::new(),
2227 }
2228 } else {
2229 entry.btree.lookup_prefix(key)
2230 }
2231 }
2232
2233 /// Check if an index on the given column is unique.
2234 pub fn is_index_unique(&self, col_name: &str) -> Option<bool> {
2235 self.indexed_cols
2236 .iter()
2237 .find(|c| c.col_name == col_name)
2238 .map(|c| c.unique)
2239 }
2240
2241 /// Create a non-unique secondary index on a column. Duplicate column
2242 /// values are supported via composite keys (column_value, RowId).
2243 pub fn create_index(&mut self, col_name: &str, data_dir: &Path) -> io::Result<()> {
2244 self.create_index_with_unique(col_name, data_dir, false)
2245 }
2246
2247 /// Create an index on a column with an explicit uniqueness flag.
2248 /// `unique = true` creates a traditional unique index where duplicate
2249 /// key inserts overwrite (suitable for primary keys). `unique = false`
2250 /// creates a non-unique secondary index using composite keys.
2251 pub fn create_index_with_unique(
2252 &mut self,
2253 col_name: &str,
2254 data_dir: &Path,
2255 unique: bool,
2256 ) -> io::Result<()> {
2257 let col_idx = self
2258 .schema
2259 .column_index(col_name)
2260 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "column not found"))?;
2261
2262 // Mission C Phase 17: if this column already has an index,
2263 // no-op (matches the prior map.insert semantics of silently
2264 // replacing a duplicate, minus the wasted work).
2265 if self.indexed_cols.iter().any(|c| c.col_idx == col_idx) {
2266 return Ok(());
2267 }
2268
2269 let idx_path = data_dir.join(format!("{}_{}.idx", self.schema.table_name, col_name));
2270 // Non-unique indexes use v3 NUL-escaped composite Str keys even when the
2271 // table is empty; unique indexes stay v1.
2272 let mut btree = if unique {
2273 BTree::create(&idx_path)?
2274 } else {
2275 BTree::create_non_unique(&idx_path)?
2276 };
2277
2278 // Build index from existing data.
2279 for (rid, row) in self.scan() {
2280 if !row[col_idx].is_empty() {
2281 if unique {
2282 btree.insert(row[col_idx].clone(), rid);
2283 } else {
2284 btree.insert_non_unique(row[col_idx].clone(), rid);
2285 }
2286 }
2287 }
2288
2289 // Mission 3: persist the freshly-built index so it survives a
2290 // restart. `BTree::create` stashed the path inside the tree, so
2291 // `save()` writes to the right place. Subsequent inserts / updates
2292 // / deletes will re-save after each mutation (see `save_if_touched`).
2293 btree.save()?;
2294
2295 // Mission C Phase 17: store the btree inline alongside the
2296 // cached col_idx / col_name / is_int metadata — single tight
2297 // entry per index, walked directly by the hot write paths.
2298 let is_int = self.schema.columns[col_idx].type_id == TypeId::Int;
2299 self.indexed_cols.push(IndexedCol {
2300 col_idx,
2301 col_name: col_name.to_string(),
2302 is_int,
2303 unique,
2304 btree,
2305 });
2306 Ok(())
2307 }
2308}
2309
2310#[cfg(test)]
2311mod projected_tests {
2312 use super::*;
2313
2314 fn projected_table() -> (tempfile::TempDir, Table, RowId, Vec<Value>) {
2315 let dir = tempfile::tempdir().expect("tempdir");
2316 let schema = Schema {
2317 table_name: "Projected".into(),
2318 columns: vec![
2319 ColumnDef {
2320 name: "id".into(),
2321 type_id: TypeId::Int,
2322 required: true,
2323 position: 0,
2324 },
2325 ColumnDef {
2326 name: "document".into(),
2327 type_id: TypeId::Json,
2328 required: true,
2329 position: 1,
2330 },
2331 ColumnDef {
2332 name: "payload".into(),
2333 type_id: TypeId::Bytes,
2334 required: true,
2335 position: 2,
2336 },
2337 ],
2338 };
2339 let mut table = Table::create(schema, dir.path()).expect("create table");
2340 let json_text = format!(r#"{{"payload":"{}"}}"#, "j".repeat(9_000));
2341 let json = crate::pj1::parse_json_text(&json_text).expect("valid JSON");
2342 let row = vec![
2343 Value::Int(7),
2344 Value::Json(json.into_boxed_slice()),
2345 Value::Bytes(vec![0xA5; 9_000]),
2346 ];
2347 let rid = table.insert(&row).expect("insert spilled row");
2348 let raw = table.heap.get(rid).expect("raw row");
2349 assert!(crate::row::row_is_v2(&raw));
2350 assert!(crate::row::raw_stub(&table.schema, table.row_layout(), &raw, 1).is_some());
2351 assert!(crate::row::raw_stub(&table.schema, table.row_layout(), &raw, 2).is_some());
2352 (dir, table, rid, row)
2353 }
2354
2355 #[test]
2356 fn projected_read_preserves_order_duplicates_and_spilled_values() {
2357 let (_dir, table, rid, row) = projected_table();
2358 let projected = table
2359 .get_projected(rid, &[2, 0, 1, 2])
2360 .expect("projected read")
2361 .expect("row exists");
2362 assert_eq!(
2363 projected,
2364 vec![
2365 row[2].clone(),
2366 row[0].clone(),
2367 row[1].clone(),
2368 row[2].clone()
2369 ]
2370 );
2371 assert_eq!(
2372 table.get_projected(rid, &[]).expect("empty projection"),
2373 Some(Vec::new())
2374 );
2375 assert_eq!(
2376 table
2377 .get_projected(
2378 RowId {
2379 page_id: u32::MAX,
2380 slot_index: u16::MAX,
2381 },
2382 &[0],
2383 )
2384 .expect("missing RID"),
2385 None
2386 );
2387 assert!(table.get_projected(rid, &[3]).is_err());
2388 }
2389
2390 #[test]
2391 fn projected_read_ignores_unselected_corrupt_spill() {
2392 let (_dir, mut table, rid, row) = projected_table();
2393 let raw = table.heap.get(rid).expect("raw row");
2394 let document_stub = crate::row::raw_stub(&table.schema, table.row_layout(), &raw, 1)
2395 .expect("document stub");
2396 table
2397 .heap
2398 .write_overflow_page(document_stub.first_page, OVERFLOW_CHAIN_END, b"corrupt", 0)
2399 .expect("corrupt selected chain deterministically");
2400
2401 let healthy_projection = table
2402 .get_projected(rid, &[0, 2])
2403 .expect("unselected corruption must be untouched")
2404 .expect("row exists");
2405 assert_eq!(healthy_projection, vec![row[0].clone(), row[2].clone()]);
2406
2407 let error = table
2408 .get_projected(rid, &[1])
2409 .expect_err("selected corrupt chain must fail");
2410 assert!(error.to_string().contains("overflow value length"));
2411 }
2412}
2413
2414#[cfg(test)]
2415mod nul_migration_tests {
2416 use super::*;
2417 use crate::btree::{BTREE_VERSION, LEGACY_BTREE_VERSION};
2418 use crate::catalog::IndexedColMeta;
2419
2420 fn schema() -> Schema {
2421 Schema {
2422 table_name: "T".into(),
2423 columns: vec![
2424 ColumnDef {
2425 name: "id".into(),
2426 type_id: TypeId::Int,
2427 required: true,
2428 position: 0,
2429 },
2430 ColumnDef {
2431 name: "name".into(),
2432 type_id: TypeId::Str,
2433 required: false,
2434 position: 1,
2435 },
2436 ],
2437 }
2438 }
2439
2440 fn row(id: i64, name: &str) -> Row {
2441 vec![Value::Int(id), Value::Str(name.into())]
2442 }
2443
2444 /// Write an OLD-format (v1, unescaped bare-0x00 terminator) non-unique
2445 /// composite index file matching the given (value, rid) pairs. This is the
2446 /// pre-v0.16 on-disk shape that the migration path must never serve.
2447 fn write_old_format_index(path: &std::path::Path, pairs: &[(&str, RowId)]) {
2448 let mut bt = BTree::create(path).unwrap(); // v1, Raw
2449 let str_tag = Value::Str(String::new()).type_id() as u8;
2450 for (val, rid) in pairs {
2451 let mut buf = Vec::new();
2452 buf.push(str_tag);
2453 buf.extend_from_slice(val.as_bytes());
2454 buf.push(0); // OLD single bare terminator (the buggy encoding)
2455 let rid_bits = ((rid.page_id as u64) << 16) | rid.slot_index as u64;
2456 buf.extend_from_slice(&rid_bits.to_be_bytes());
2457 bt.insert(Value::Bytes(buf), *rid);
2458 }
2459 // Never touched insert_non_unique, so it stays v1.
2460 assert_eq!(bt.format_version(), LEGACY_BTREE_VERSION);
2461 bt.save_to(path).unwrap();
2462 }
2463
2464 #[test]
2465 fn old_nonunique_index_rebuilt_to_v3_and_serves_correct_rows() {
2466 let dir = tempfile::tempdir().unwrap();
2467 let mut table = Table::create(schema(), dir.path()).unwrap();
2468 let r1 = table.insert(&row(1, "A")).unwrap();
2469 let r2 = table.insert(&row(2, "A\0")).unwrap();
2470 let r3 = table.insert(&row(3, "A")).unwrap();
2471 let r4 = table.insert(&row(4, "B")).unwrap();
2472 drop(table);
2473
2474 let idx_path = dir.path().join("T_name.idx");
2475 write_old_format_index(&idx_path, &[("A", r1), ("A\0", r2), ("A", r3), ("B", r4)]);
2476
2477 // The old file genuinely exhibits the bug it is meant to reproduce:
2478 // "A" over-matches the "A\0" row.
2479 {
2480 let mut old = BTree::load(&idx_path).unwrap();
2481 old.mark_composite();
2482 assert!(
2483 old.lookup_prefix(&Value::Str("A".into())).contains(&r2),
2484 "old-format file must exhibit the wrong-rows bug"
2485 );
2486 }
2487
2488 // Reopen with the catalog claiming a non-unique index on "name".
2489 let metas = vec![IndexedColMeta {
2490 name: "name".into(),
2491 unique: false,
2492 }];
2493 let reopened = Table::open_with_indexes(schema(), dir.path(), &metas, &[]).unwrap();
2494 let idx = reopened.index("name").expect("index present");
2495 assert_eq!(
2496 idx.format_version(),
2497 BTREE_VERSION,
2498 "old non-unique index rebuilt to v3 on open"
2499 );
2500
2501 let mut a = idx.lookup_prefix(&Value::Str("A".into()));
2502 a.sort_by_key(|r| (r.page_id, r.slot_index));
2503 let mut want = vec![r1, r3];
2504 want.sort_by_key(|r| (r.page_id, r.slot_index));
2505 assert_eq!(a, want, "\"A\" serves only its own rows after rebuild");
2506 assert_eq!(idx.lookup_prefix(&Value::Str("A\0".into())), vec![r2]);
2507 assert_eq!(idx.lookup_prefix(&Value::Str("B".into())), vec![r4]);
2508
2509 // The rebuild was persisted (writable open), so the on-disk file is v3.
2510 assert_eq!(
2511 BTree::load(&idx_path).unwrap().format_version(),
2512 BTREE_VERSION,
2513 "rebuilt v3 index saved back to disk"
2514 );
2515 }
2516
2517 #[test]
2518 fn old_unique_index_loads_without_rebuild() {
2519 let dir = tempfile::tempdir().unwrap();
2520 let mut table = Table::create(schema(), dir.path()).unwrap();
2521 table.insert(&row(1, "A")).unwrap();
2522 table.insert(&row(2, "B")).unwrap();
2523 // A real v1 unique index (unique indexes never used composite Str keys).
2524 table
2525 .create_index_with_unique("name", dir.path(), true)
2526 .unwrap();
2527 drop(table);
2528
2529 let idx_path = dir.path().join("T_name.idx");
2530 assert_eq!(
2531 BTree::load(&idx_path).unwrap().format_version(),
2532 LEGACY_BTREE_VERSION,
2533 "unique index is v1 on disk"
2534 );
2535
2536 let metas = vec![IndexedColMeta {
2537 name: "name".into(),
2538 unique: true,
2539 }];
2540 let reopened = Table::open_with_indexes(schema(), dir.path(), &metas, &[]).unwrap();
2541 let idx = reopened.index("name").expect("index present");
2542 assert_eq!(
2543 idx.format_version(),
2544 LEGACY_BTREE_VERSION,
2545 "unique index must NOT be rebuilt or version-bumped on open"
2546 );
2547 assert!(
2548 idx.lookup(&Value::Str("A".into())).is_some(),
2549 "unique index still serves its keys"
2550 );
2551 // File on disk still v1: untouched.
2552 assert_eq!(
2553 BTree::load(&idx_path).unwrap().format_version(),
2554 LEGACY_BTREE_VERSION,
2555 "unique index file unchanged on disk"
2556 );
2557 }
2558}