spg_storage/table.rs
1//! The `Table` storage object: row insert/update/delete, index
2//! construction + rebuild (BTree / BRIN / GIN / GIN-trgm /
3//! GIN-fulltext / NSW), cold-locator registration, and schema
4//! mutation (add/drop/rename column). Split out of lib.rs (monster
5//! tier-3 cut 4). The `Table` struct itself stays in lib.rs as
6//! storage vocabulary; this module is the inherent `impl` over it.
7//! `Table`'s private fields are reachable here because `table` is a
8//! descendant module of the crate root where the struct is declared.
9
10use super::*;
11
12impl Table {
13 pub fn new(schema: TableSchema) -> Self {
14 Self {
15 schema,
16 db_collation: None,
17 expr_index_complete: alloc::collections::BTreeSet::new(),
18 rel_id: crate::row_header::RelId::UNASSIGNED,
19 rows: PersistentVec::new(),
20 headers: PersistentVec::new(),
21 rowids: PersistentVec::new(),
22 next_rowid: alloc::sync::Arc::new(core::sync::atomic::AtomicU64::new(1)),
23 dead_rows: 0,
24 stat_tup_ins: 0,
25 stat_tup_upd: 0,
26 stat_tup_del: 0,
27 scan_stats: crate::ScanStats::default(),
28 last_autovacuum_us: None,
29 last_analyze_us: None,
30 indices: Vec::new(),
31 hot_bytes: 0,
32 cold_row_count: 0,
33 cold_row_count_stale: false,
34 redo_log: None,
35 excl_indexes: Vec::new(),
36 tx_write_track: None,
37 prune_horizon: 0,
38 }
39 }
40
41 /// v7.37.15 (Phase C.1) — allocate the next stable [`RowId`] for
42 /// this relation. Monotonic, never reused. Callers push the
43 /// returned id onto `rowids` in lock-step with the `rows` /
44 /// `headers` append so `rowids[i]` names the row at slot `i`.
45 fn alloc_rowid(&mut self) -> crate::row_header::RowId {
46 // fetch_add on the lineage-shared counter: clones (transaction
47 // shadows, snapshots) mint from the SAME sequence, so ids stay
48 // unique across concurrent shadows. Relaxed suffices — all
49 // minting happens under the engine's single writer guard; the
50 // atomic is for clone-shared identity, not for racing threads.
51 let id = crate::row_header::RowId(
52 self.next_rowid
53 .fetch_add(1, core::sync::atomic::Ordering::Relaxed),
54 );
55 id
56 }
57
58 /// v7.37.15 (Phase C.1) — read-only access to the stable row ids
59 /// parallel to `rows()`. `rowids().len() == rows().len()` is the
60 /// load-bearing lock-step invariant (asserted in debug builds at
61 /// every mutation boundary alongside `headers`).
62 #[must_use]
63 pub fn rowids(&self) -> &PersistentVec<crate::row_header::RowId> {
64 &self.rowids
65 }
66
67 /// v7.37.15 (Phase C.1) — this relation's stable identity.
68 /// [`RelId::UNASSIGNED`](crate::row_header::RelId::UNASSIGNED) for
69 /// a bare `Table::new`; a real id once the catalog stamps it.
70 #[must_use]
71 pub fn rel_id(&self) -> crate::row_header::RelId {
72 self.rel_id
73 }
74
75 /// v7.37.15 (Phase C.1) — stamp this relation's stable identity.
76 /// Called by `Catalog::create_table` and the deserialize
77 /// dense-assign pass; idempotent overwrite.
78 pub(crate) fn set_rel_id(&mut self, id: crate::row_header::RelId) {
79 self.rel_id = id;
80 }
81
82 /// v7.37.15 (Phase C.1) — rebuild the `rowids` vec so it is dense
83 /// `1..=rows.len()` and reset the allocator above it. Used on the
84 /// load / snapshot-restore path where rows arrive without ids
85 /// (pre-V6 envelope): every row gets a fresh id, sufficient while
86 /// ids are process-local bookkeeping. Keeps the lock-step
87 /// invariant against the freshly-loaded `rows`.
88 pub fn assign_dense_rowids(&mut self) {
89 let n = self.rows.len();
90 let mut fresh: PersistentVec<crate::row_header::RowId> = PersistentVec::new();
91 for i in 0..n {
92 fresh.push_mut(crate::row_header::RowId((i + 1) as u64));
93 }
94 self.rowids = fresh;
95 self.next_rowid
96 .store((n as u64) + 1, core::sync::atomic::Ordering::Relaxed);
97 debug_assert_eq!(
98 self.rows.len(),
99 self.rowids.len(),
100 "rowids must stay in lock-step with rows after assign_dense_rowids"
101 );
102 }
103
104 /// v7.37.16 (autovacuum) — number of tombstoned-but-present hot rows.
105 /// Incrementally maintained; drives the engine's autovacuum threshold.
106 #[must_use]
107 pub fn dead_rows(&self) -> u64 {
108 self.dead_rows
109 }
110
111 /// v7.37.16 (autovacuum) — loader-side rebase of the dead-row
112 /// counter (the v53 MVCC appendix restores headers verbatim).
113 pub(crate) fn set_dead_rows_on_load(&mut self, dead: u64) {
114 self.dead_rows = dead;
115 }
116
117 /// v7.39 (pg_stat knife A) — bump the volatile write counters the
118 /// engine's DML dispatcher reports per statement.
119 pub fn bump_write_stats(&mut self, ins: u64, upd: u64, del: u64) {
120 self.stat_tup_ins = self.stat_tup_ins.saturating_add(ins);
121 self.stat_tup_upd = self.stat_tup_upd.saturating_add(upd);
122 self.stat_tup_del = self.stat_tup_del.saturating_add(del);
123 }
124
125 /// `(n_tup_ins, n_tup_upd, n_tup_del)` for pg_stat_user_tables.
126 #[must_use]
127 pub fn write_stats(&self) -> (u64, u64, u64) {
128 (self.stat_tup_ins, self.stat_tup_upd, self.stat_tup_del)
129 }
130
131 /// v7.39 (pg_stat knife C) — maintenance stamps for
132 /// pg_stat_user_tables (`(last_autovacuum_us, last_analyze_us)`).
133 #[must_use]
134 pub fn maintenance_stamps(&self) -> (Option<i64>, Option<i64>) {
135 (self.last_autovacuum_us, self.last_analyze_us)
136 }
137
138 pub fn stamp_autovacuum(&mut self, unix_us: i64) {
139 self.last_autovacuum_us = Some(unix_us);
140 }
141
142 pub fn stamp_analyze(&mut self, unix_us: i64) {
143 self.last_analyze_us = Some(unix_us);
144 }
145
146 /// v7.39 (pg_stat knife B) — the scan counters (read side of
147 /// pg_stat_user_tables).
148 #[must_use]
149 pub fn scan_stats(&self) -> &crate::ScanStats {
150 &self.scan_stats
151 }
152
153 /// v7.39 (pg_stat knife B) — one sequential scan over the visible
154 /// rows, reported by engine scan loops that walk headers directly
155 /// (parallel shards, the aggregate full scan) instead of
156 /// `scan_visible`.
157 pub fn note_seq_scan(&self) {
158 use core::sync::atomic::Ordering;
159 self.scan_stats.seq_scan.fetch_add(1, Ordering::Relaxed);
160 let visible = (self.rows.len() as u64).saturating_sub(self.dead_rows);
161 self.scan_stats
162 .seq_tup_read
163 .fetch_add(visible, Ordering::Relaxed);
164 }
165
166 /// v7.39 (pg_stat knife B) — one index scan returning `fetched`
167 /// rows (the engine's index-seek paths report here).
168 pub fn note_index_scan(&self, fetched: u64) {
169 use core::sync::atomic::Ordering;
170 self.scan_stats.idx_scan.fetch_add(1, Ordering::Relaxed);
171 self.scan_stats
172 .idx_tup_fetch
173 .fetch_add(fetched, Ordering::Relaxed);
174 }
175
176 /// v7.37.15 (Phase A.2) — read-only access to the per-row
177 /// MVCC visibility headers. `headers().len() == rows().len()`
178 /// is the load-bearing invariant; Phase B scan paths consult
179 /// `headers()[idx]` to decide visibility.
180 #[must_use]
181 pub fn headers(&self) -> &PersistentVec<crate::row_header::RowHeader> {
182 &self.headers
183 }
184
185 /// v7.37.15 (Phase B TDD) — `#[cfg(test)]`-only mutable header
186 /// access for tests that need to simulate Phase C semantics
187 /// (writer-side xmin/xmax stamping) before the real stamping
188 /// API lands. Phase C will provide a writer-aware setter that
189 /// keeps headers + xact bookkeeping consistent.
190 #[cfg(test)]
191 pub(crate) fn headers_mut_for_test(
192 &mut self,
193 ) -> &mut PersistentVec<crate::row_header::RowHeader> {
194 &mut self.headers
195 }
196
197 /// v7.37.16 (Epic W) — `#[cfg(test)]`-only read of the relation's
198 /// next-RowId allocator cursor, so the snapshot round-trip tests can
199 /// assert it is restored correctly (strictly above every persisted
200 /// id) without a public accessor on the hot path.
201 #[cfg(test)]
202 pub(crate) fn next_rowid_for_test(&self) -> u64 {
203 self.next_rowid.load(core::sync::atomic::Ordering::Relaxed)
204 }
205
206 /// v7.37.15 (Phase C) — engine writer path. Same as [`insert`]
207 /// but stamps `xmin` on the new row's header with the writing
208 /// transaction's id (caller-supplied; obtained from the engine's
209 /// monotonic version counter). The fresh insert is alive
210 /// (`xmax = XMAX_ALIVE`); a later UPDATE / DELETE will set
211 /// `xmax` to a later version, leaving the row physically
212 /// present until vacuum reclaims it (Phase D).
213 ///
214 /// Callers in [`crate::row_header::next_version`] order:
215 /// 1. allocate version V via `next_version()`
216 /// 2. call `insert_with_xmin(row, V)`
217 /// 3. update any indexes (as `insert` does)
218 ///
219 /// `xmin = XMIN_FROZEN` short-circuits to plain `insert`
220 /// behaviour so the legacy in-memory / WAL-replay paths keep
221 /// returning identical results when they end up here.
222 pub fn insert_with_xmin(&mut self, row: Row<'static>, xmin: u64) -> Result<(), StorageError> {
223 self.insert_with_xmin_keyed(row, xmin, None)
224 }
225
226 /// [`Table::insert_with_xmin`] with the expression indexes' keys
227 /// supplied. See [`Table::insert_keyed`].
228 pub fn insert_with_xmin_keyed(
229 &mut self,
230 row: Row<'static>,
231 xmin: u64,
232 expr_values: Option<&[Option<Value<'static>>]>,
233 ) -> Result<(), StorageError> {
234 if xmin == crate::row_header::XMIN_FROZEN {
235 return self.insert_keyed(row, expr_values);
236 }
237 self.insert_keyed(row, expr_values)?;
238 // Insert appended `RowHeader::frozen()`; overwrite with the
239 // alive-xmin header so visibility scans against snapshots
240 // taken before the writer's commit hide this row. Subsequent
241 // commit is recorded by the WAL; replay re-applies via the
242 // plain `insert_no_index` path and stamps frozen — but a
243 // snapshot taken AFTER commit sees `xmin = V <= snapshot.version`
244 // and the in_progress bitset no longer contains V, so the
245 // row passes the visibility predicate identically.
246 let last = self
247 .headers
248 .len()
249 .checked_sub(1)
250 .expect("insert appended a header");
251 if let Some(new_headers) = self
252 .headers
253 .set(last, crate::row_header::RowHeader::alive(xmin))
254 {
255 self.headers = new_headers;
256 }
257 // v7.38.2 (R2) — record the versioned insert for the rebase's
258 // incremental write-set (see `Table::tx_write_track`).
259 if xmin != 0 {
260 let rid = self
261 .rowids
262 .get(last)
263 .copied()
264 .unwrap_or(crate::row_header::RowId::UNASSIGNED);
265 self.write_track_for(xmin).inserted.push((last, rid));
266 }
267 debug_assert_eq!(
268 self.rows.len(),
269 self.headers.len(),
270 "headers must stay in lock-step with rows after insert_with_xmin"
271 );
272 Ok(())
273 }
274
275 /// v7.38.2 (R2) — the per-table write track, claimed by `v`. A
276 /// different version taking the table replaces the track (one
277 /// writer per shadow; on the base this bounds memory to the last
278 /// writer's footprint). See `Table::tx_write_track`.
279 fn write_track_for(&mut self, v: u64) -> &mut TxWriteTrack {
280 let replace = self.tx_write_track.as_ref().is_none_or(|t| t.version != v);
281 if replace {
282 self.tx_write_track = Some(TxWriteTrack {
283 version: v,
284 ..TxWriteTrack::default()
285 });
286 }
287 self.tx_write_track.as_mut().expect("just ensured")
288 }
289
290 /// v7.39 (round 493) — publish the snapshot floor the insert path may
291 /// prune dead index entries under. See `prune_horizon`.
292 ///
293 /// The engine sets this from `vacuum_oldest_active()` before a
294 /// statement's inserts. `0` disables pruning, which is the default and
295 /// is always safe.
296 pub fn set_prune_horizon(&mut self, horizon: u64) {
297 self.prune_horizon = horizon;
298 }
299
300 /// v7.37.15 (Phase D) — single-table vacuum pass. Walks the
301 /// header vec and physically removes any row whose delete
302 /// commit is older than `oldest_active_snapshot`. Returns the
303 /// number of reclaimable rows (with `dry_run == true`) or the
304 /// number actually reclaimed.
305 ///
306 /// `oldest_active_snapshot` is the floor of every live
307 /// snapshot's `version` — the engine maintains this; hosts
308 /// pass it through.
309 ///
310 /// Phase D ships the storage primitive. Hosts (spg-embedded /
311 /// spg-server) schedule the pass on their own thread.
312 pub fn vacuum(
313 &mut self,
314 oldest_active_snapshot: u64,
315 dry_run: bool,
316 ) -> crate::vacuum::VacuumReport {
317 let examined = self.headers.len() as u64;
318 // Collect the reclaimable positions in a first pass so the
319 // mutation can rebuild both the rows and the headers vec
320 // together (their lock-step invariant survives).
321 let to_reclaim: alloc::vec::Vec<usize> = (0..self.headers.len())
322 .filter(|&i| {
323 self.headers
324 .get(i)
325 .map(|h| crate::vacuum::is_reclaimable(h.xmax, oldest_active_snapshot))
326 .unwrap_or(false)
327 })
328 .collect();
329 if to_reclaim.is_empty() || dry_run {
330 return crate::vacuum::VacuumReport {
331 rows_reclaimed: to_reclaim.len() as u64,
332 rows_examined: examined,
333 per_table: alloc::vec::Vec::new(),
334 };
335 }
336 // Drive the existing per-position delete path so both rows
337 // and headers shrink together (it's the only mutator that
338 // already maintains the lock-step invariant).
339 let removed = self.delete_rows_no_index(&to_reclaim);
340 self.rebuild_indices();
341 crate::vacuum::VacuumReport {
342 rows_reclaimed: removed as u64,
343 rows_examined: examined,
344 per_table: alloc::vec::Vec::new(),
345 }
346 }
347
348 /// v7.37.15 (Phase C) — mark the row at `position` as deleted
349 /// by version `xmax`. The row stays physically present; later
350 /// vacuum (Phase D) reclaims it once no live snapshot can
351 /// still see it.
352 ///
353 /// Returns `Err(Corrupt)` on out-of-bounds and silently no-ops
354 /// when the row is already tombstoned (a later DELETE on an
355 /// already-deleted row should not change xmax — the original
356 /// deletion wins).
357 pub fn mark_row_deleted(&mut self, position: usize, xmax: u64) -> Result<(), StorageError> {
358 if position >= self.headers.len() {
359 return Err(StorageError::Corrupt(alloc::format!(
360 "mark_row_deleted: position {position} out of bounds (headers={})",
361 self.headers.len()
362 )));
363 }
364 let mut h = *self.headers.get(position).expect("position bounds-checked");
365 if h.xmax != crate::row_header::XMAX_ALIVE {
366 // Already tombstoned by an earlier delete. Keep the
367 // original xmax — first-deleter-wins.
368 return Ok(());
369 }
370 h.xmax = xmax;
371 if let Some(new_headers) = self.headers.set(position, h) {
372 self.headers = new_headers;
373 }
374 self.dead_rows += 1;
375 // v7.38.2 (R2) — record the versioned tombstone (stable RowId).
376 if xmax != 0 && xmax != crate::row_header::XMAX_ALIVE {
377 let rid = self
378 .rowids
379 .get(position)
380 .copied()
381 .unwrap_or(crate::row_header::RowId::UNASSIGNED);
382 self.write_track_for(xmax).tombstoned.push(rid);
383 }
384 // v7.37.15 (Epic W durable-tombstone slice) — capture the
385 // in-place tombstone as row-level redo so a gate-on
386 // (`SPG_MVCC_INPLACE`) DELETE / UPDATE-old-version /
387 // ON-CONFLICT survives crash recovery. Unlike `delete_rows`
388 // (which records `RowChange::Delete` with physical positions),
389 // the tombstone keeps the slot, so it is named by the row's
390 // stable `RowId` — read from `self.rowids()[position]` here,
391 // before any later compaction shifts the slot. `xmax` is the
392 // deleting statement's writer version (the engine passes
393 // `writer_version_for_current_stmt`), so no post-drain stamp is
394 // needed. Only paid for when redo capture is on; a no-op
395 // (already-tombstoned / out-of-bounds) returned above and
396 // records nothing.
397 if self.redo_log.is_some() {
398 let rowid = self
399 .rowids()
400 .get(position)
401 .copied()
402 .unwrap_or(crate::row_header::RowId::UNASSIGNED);
403 self.record_redo(move |table| RowChange::Tombstone {
404 table,
405 rowids: alloc::vec![rowid],
406 xmax,
407 });
408 }
409 Ok(())
410 }
411
412 /// v7.37.16 — batch form of [`Table::mark_row_deleted`]: stamp `xmax`
413 /// on every alive, in-bounds position and record ONE
414 /// `RowChange::Tombstone` carrying all affected `RowId`s (the codec
415 /// and replay already handle multi-rowid records). The per-row form
416 /// paid one redo record — a Vec alloc plus a log push — PER ROW,
417 /// ~800 ns/row on a 10k-row gate-on DELETE (heavy_write del_10k).
418 /// Semantics match the single-row form: already-tombstoned keeps its
419 /// original xmax (first-deleter-wins), out-of-bounds is skipped.
420 /// Returns the number of rows NEWLY tombstoned.
421 pub fn mark_rows_deleted(&mut self, positions: &[usize], xmax: u64) -> usize {
422 let mut rowids: alloc::vec::Vec<crate::row_header::RowId> = alloc::vec::Vec::new();
423 let capture = self.redo_log.is_some();
424 let mut newly = 0usize;
425 for &position in positions {
426 // v7.37.16 — `get_mut` (transient in-place edit when the
427 // headers trie is uniquely owned) instead of the `set`
428 // path-copy: a 10k-row tombstone pass was spending ~3 ms in
429 // per-row spine copies.
430 match self.headers.get_mut(position) {
431 Some(h) if h.xmax == crate::row_header::XMAX_ALIVE => {
432 h.xmax = xmax;
433 }
434 _ => continue, // out-of-bounds or already tombstoned
435 }
436 self.dead_rows += 1;
437 newly += 1;
438 // v7.38.2 (R2) — record the versioned tombstone.
439 if xmax != 0 && xmax != crate::row_header::XMAX_ALIVE {
440 let rid = self
441 .rowids
442 .get(position)
443 .copied()
444 .unwrap_or(crate::row_header::RowId::UNASSIGNED);
445 self.write_track_for(xmax).tombstoned.push(rid);
446 }
447 if capture {
448 rowids.push(
449 self.rowids()
450 .get(position)
451 .copied()
452 .unwrap_or(crate::row_header::RowId::UNASSIGNED),
453 );
454 }
455 }
456 if capture && !rowids.is_empty() {
457 self.record_redo(move |table| RowChange::Tombstone {
458 table,
459 rowids,
460 xmax,
461 });
462 }
463 newly
464 }
465
466 /// v7.37.17 (Phase E RC rebase) — extract the write-set one writer
467 /// version left on this table, expressed against stable [`RowId`]s
468 /// so it can be replayed onto a FRESHER catalog clone whose
469 /// physical slots differ. `inserted` carries INSERT rows and the
470 /// new versions of UPDATEs (`xmin == v`); `tombstoned` carries the
471 /// ids DELETE / UPDATE-old-version stamped (`xmax == v`). A row
472 /// both inserted and tombstoned by the same version appears in
473 /// both lists; replay applies inserts first, tombstones second —
474 /// net effect identical.
475 #[must_use]
476 pub fn extract_tx_writeset(&self, v: u64) -> crate::TxWriteSet {
477 // v7.38.2 (R2) — incremental fast path: the funnels recorded
478 // exactly which slots `v` marked, so extraction is O(writes).
479 // Every recorded insert is re-verified against the live header
480 // and rowid; one mismatch (slot shifted, inherited track,
481 // rows written before tracking) abandons the fast path for the
482 // scan below — the track can be incomplete for a version it
483 // does not name, never for the one it does, because claiming a
484 // version resets it and every marker for that version appends.
485 if let Some(track) = &self.tx_write_track
486 && track.version == v
487 {
488 let mut inserted: alloc::vec::Vec<(crate::row_header::RowId, Row<'static>)> =
489 alloc::vec::Vec::with_capacity(track.inserted.len());
490 let mut ok = true;
491 for &(pos, rid) in &track.inserted {
492 let verified = self.headers.get(pos).is_some_and(|h| h.xmin == v)
493 && self.rowids.get(pos).copied() == Some(rid);
494 if !verified {
495 ok = false;
496 break;
497 }
498 match self.rows.get(pos) {
499 Some(row) => inserted.push((rid, row.clone())),
500 None => {
501 ok = false;
502 break;
503 }
504 }
505 }
506 if ok {
507 return crate::TxWriteSet {
508 inserted,
509 tombstoned: track.tombstoned.clone(),
510 };
511 }
512 }
513 let mut inserted: alloc::vec::Vec<(crate::row_header::RowId, Row<'static>)> =
514 alloc::vec::Vec::new();
515 let mut tombstoned: alloc::vec::Vec<crate::row_header::RowId> = alloc::vec::Vec::new();
516 for (i, h) in self.headers.iter().enumerate() {
517 let rid = self
518 .rowids
519 .get(i)
520 .copied()
521 .unwrap_or(crate::row_header::RowId::UNASSIGNED);
522 if h.xmin == v
523 && let Some(row) = self.rows.get(i)
524 {
525 inserted.push((rid, row.clone()));
526 }
527 if h.xmax == v {
528 tombstoned.push(rid);
529 }
530 }
531 crate::TxWriteSet {
532 inserted,
533 tombstoned,
534 }
535 }
536
537 /// v7.38.2 (R2 round 4) — the slot a RowId lives in, in O(log n).
538 ///
539 /// RowIds are allocated monotonically and pushed in lock-step with
540 /// rows, so `rowids` is ascending everywhere except the handful of
541 /// slots the rebase replay rewrote with a restored original id.
542 /// Binary search answers the ascending majority and only ever
543 /// returns a slot it has just VERIFIED names `rid`; anything it
544 /// cannot answer falls through to the linear scan, so the fast path
545 /// can only be slow, never wrong. The slot naming `rid` is THE slot:
546 /// `next_rowid` is a lineage-shared `Arc<AtomicU64>`, so a shadow
547 /// and the live table it rebases onto mint from ONE sequence and
548 /// cannot collide.
549 ///
550 /// What it replaces: both rebase-path lookups walked every row per
551 /// tombstone. At pgbench scale 5 (500k accounts) that alone cost
552 /// 2.4x throughput at c=4 while PG18 got FASTER on the same widening
553 /// — the O(rows) signature that named this attack.
554 fn rowid_position(&self, rid: crate::row_header::RowId) -> Option<usize> {
555 let n = self.rowids.len();
556 let (mut lo, mut hi) = (0usize, n);
557 while lo < hi {
558 let mid = lo + (hi - lo) / 2;
559 match self.rowids.get(mid) {
560 Some(m) if *m == rid => return Some(mid),
561 Some(m) if *m < rid => lo = mid + 1,
562 Some(_) => hi = mid,
563 None => break,
564 }
565 }
566 (0..n).find(|&i| self.rowids.get(i) == Some(&rid))
567 }
568
569 /// v7.37.17 (Phase E4 fix) — read-only conflict probe for a
570 /// write-set's tombstones against THIS (fresher) relation: a target
571 /// RowId that is gone, or already tombstoned by a DIFFERENT
572 /// version, is a write-write conflict. Callers use this BEFORE
573 /// `replay_tx_writeset` so a conflicting UPDATE can drop its
574 /// paired insert too (atomicity of tombstone+insert pairs).
575 #[must_use]
576 pub fn tombstone_conflicts(
577 &self,
578 rids: &[crate::row_header::RowId],
579 v: u64,
580 ) -> alloc::vec::Vec<crate::row_header::RowId> {
581 rids.iter()
582 .filter(|rid| match self.rowid_position(**rid) {
583 Some(i) => self
584 .headers
585 .get(i)
586 .is_some_and(|h| h.xmax != crate::row_header::XMAX_ALIVE && h.xmax != v),
587 None => true,
588 })
589 .copied()
590 .collect()
591 }
592
593 /// v7.37.17 (Phase E RC rebase) — replay a write-set extracted from
594 /// an OLDER clone of this relation onto this (fresher) one, keeping
595 /// the original RowIds. Deliberately does NOT capture redo: a
596 /// replay re-expresses writes the transaction already made, it is
597 /// not a new mutation (the redo story rides the eventual COMMIT).
598 /// Returns the ids whose tombstone could not be applied because the
599 /// row is gone or already tombstoned by a DIFFERENT version — the
600 /// write-write conflict surface (RC skips them per PG semantics;
601 /// RR/SER turn them into serialization_failure — Phase E3).
602 pub fn replay_tx_writeset(
603 &mut self,
604 ws: &crate::TxWriteSet,
605 v: u64,
606 ) -> alloc::vec::Vec<crate::row_header::RowId> {
607 for (rid, row) in &ws.inserted {
608 // Full insert (validation + index maintenance + fresh
609 // header/rowid), then re-stamp the header's xmin and put
610 // the ORIGINAL RowId back. The allocator id the insert
611 // burned is simply never used — ids are never recycled, so
612 // a gap is harmless. Insert can only fail on schema
613 // mismatch, impossible for a row this same relation
614 // already accepted; a debug_assert documents that.
615 let res = self.insert(row.clone());
616 debug_assert!(res.is_ok(), "writeset replay re-inserts a validated row");
617 if res.is_err() {
618 continue;
619 }
620 let last = self.rows.len() - 1;
621 if let Some(h) = self.headers.get_mut(last) {
622 h.xmin = v;
623 }
624 if let Some(slot) = self.rowids.get_mut(last) {
625 *slot = *rid;
626 }
627 // v7.38.2 (R2) — the replay marks xmin=v OUTSIDE the
628 // insert funnel, so record it here too: without this a
629 // SECOND rebase's fast extraction would miss every row the
630 // first rebase replayed — a lost write.
631 self.write_track_for(v).inserted.push((last, *rid));
632 }
633 let mut conflicts: alloc::vec::Vec<crate::row_header::RowId> = alloc::vec::Vec::new();
634 for rid in &ws.tombstoned {
635 let pos = self.rowid_position(*rid);
636 match pos {
637 Some(i) => match self.headers.get_mut(i) {
638 Some(h) if h.xmax == crate::row_header::XMAX_ALIVE => {
639 h.xmax = v;
640 self.dead_rows += 1;
641 // v7.38.2 (R2) — same funnel-bypass recording
642 // as the insert replay above.
643 self.write_track_for(v).tombstoned.push(*rid);
644 }
645 Some(h) if h.xmax == v => {} // already ours (idempotent)
646 _ => conflicts.push(*rid),
647 },
648 None => conflicts.push(*rid),
649 }
650 }
651 conflicts
652 }
653
654 /// v7.34 (crash-recovery P0 #2) — start capturing row-level redo into
655 /// this table (engine call before a mutating statement when
656 /// persistence is on). Idempotent; existing captured changes are kept.
657 pub fn enable_redo(&mut self) {
658 if self.redo_log.is_none() {
659 self.redo_log = Some(Vec::new());
660 }
661 }
662
663 /// v7.34 — drain the captured redo changes and stop capturing.
664 /// Returns the physical [`RowChange`]s applied since `enable_redo`,
665 /// in apply order (empty when capture was off or nothing changed).
666 pub fn take_redo(&mut self) -> Vec<RowChange> {
667 self.redo_log.take().unwrap_or_default()
668 }
669
670 /// Record one captured change when redo capture is on. The table name
671 /// rides on the change (taken from the schema) so a drained log is
672 /// self-describing against the whole catalog.
673 fn record_redo(&mut self, make: impl FnOnce(String) -> RowChange) {
674 if self.redo_log.is_some() {
675 let change = make(self.schema.name.clone());
676 if let Some(log) = self.redo_log.as_mut() {
677 log.push(change);
678 }
679 }
680 }
681
682 /// Total encoded byte size of every row currently in the hot tier
683 /// (`self.rows`). See struct docs for the maintenance contract.
684 /// Returns 0 for an empty table.
685 #[must_use]
686 pub const fn hot_bytes(&self) -> u64 {
687 self.hot_bytes
688 }
689
690 /// v6.7.0 — cached count of cold-tier rows. See struct field
691 /// docs for the staleness contract.
692 #[must_use]
693 pub const fn cold_row_count(&self) -> u64 {
694 self.cold_row_count
695 }
696
697 /// v6.7.0 — overwrite the cached count. Called by the engine's
698 /// `analyze_one_table` after walking the indices.
699 pub fn set_cold_row_count(&mut self, n: u64) {
700 self.cold_row_count = n;
701 self.cold_row_count_stale = false;
702 }
703
704 /// v6.7.0 — mark the cached count as potentially out of date.
705 /// Called by freezer / promote / DELETE paths so a subsequent
706 /// `spg_statistic` read knows the number may not reflect the
707 /// current state.
708 pub fn mark_cold_row_count_stale(&mut self) {
709 self.cold_row_count_stale = true;
710 }
711
712 /// v6.7.0 — report whether the cached count is known to be out
713 /// of date. Exposed for completeness; the virtual table surface
714 /// returns the cached value regardless.
715 #[must_use]
716 pub const fn cold_row_count_stale(&self) -> bool {
717 self.cold_row_count_stale
718 }
719
720 /// v7.36 — O(1) "could this table possibly have cold rows?"
721 /// predicate, intended for perf-critical executor hot paths
722 /// that just need to skip the cold-tier branch when there's
723 /// definitely nothing there. Reads the cached `cold_row_count`:
724 /// - cache fresh + cache == 0 → return false (fast path)
725 /// - cache stale → return true (conservative; the executor
726 /// pays the cold-aware path's `iter_cold_rows_*` cost but
727 /// stays correct)
728 /// - cache fresh + cache > 0 → return true
729 /// `count_cold_locators` remains the right call for the EXACT
730 /// count (ANALYZE etc.) — its O(N) walk is unsuitable per join
731 /// stage.
732 #[must_use]
733 pub const fn has_cold_rows_fast(&self) -> bool {
734 self.cold_row_count_stale || self.cold_row_count > 0
735 }
736
737 /// r944 — every BTree index a cold row could have been filed under.
738 ///
739 /// The freeze writes a row's locator into exactly ONE index
740 /// (`register_cold_locators` takes a single index name) and the
741 /// freezer picks that index by its own rule, so a reader that guesses
742 /// a different one finds nothing. Round 943 is that bug: the freezer
743 /// chose the first BTree index over any integer column, the scan
744 /// looked at the first index on the primary key's column, and 15
745 /// frozen rows of 40 vanished from a plain `SELECT`.
746 ///
747 /// Union over all of them rather than guessing one. Because each
748 /// row's locator exists in exactly one index, the union yields every
749 /// row once and needs no visited-set.
750 ///
751 /// Deliberately NOT filtered to declared-unique indices. Freezing
752 /// through an index whose keys repeat is a real limitation —
753 /// `resolve_cold_locator` resolves BY KEY and cannot say which of two
754 /// rows sharing one was meant — but that limit belongs to the freeze,
755 /// which builds the segment keyed that way. Filtering it here only
756 /// hides rows that were frozen anyway, which is the bug rather than a
757 /// guard against it; the freezer's own tests freeze tables whose
758 /// integer index carries no uniqueness constraint.
759 pub fn cold_capable_indices(&self) -> impl Iterator<Item = &Index> {
760 self.indices
761 .iter()
762 .filter(|i| matches!(i.kind, IndexKind::BTree(_)))
763 }
764
765 /// v6.7.0 — walk every BTree index and count `RowLocator::Cold`
766 /// entries; return the MAX across indices. The freeze path
767 /// (`freeze_oldest_to_cold`) writes cold locators to ONE
768 /// designated index — that index ends up with the full per-row
769 /// count. MAX-across-indices yields the precise count when a
770 /// PK-style index exists; for multi-index tables without a
771 /// covering index it's a lower bound (rare in practice).
772 /// Caller responsibility: only invoke under `engine.write()`
773 /// or after taking ownership; the walk is O(N) over every
774 /// (key, locator) pair.
775 #[must_use]
776 pub fn count_cold_locators(&self) -> u64 {
777 let mut best: u64 = 0;
778 for idx in &self.indices {
779 if let IndexKind::BTree(map) = &idx.kind {
780 let n: u64 = map
781 .iter()
782 .map(|(_, locs)| locs.iter().filter(|l| l.is_cold()).count() as u64)
783 .sum();
784 if n > best {
785 best = n;
786 }
787 }
788 }
789 best
790 }
791
792 pub const fn schema(&self) -> &TableSchema {
793 &self.schema
794 }
795
796 /// v6.7.2 — mutable schema accessor for ALTER TABLE paths.
797 /// Used by `Engine::exec_alter_table` to flip per-table
798 /// settings like `hot_tier_bytes`.
799 pub const fn schema_mut(&mut self) -> &mut TableSchema {
800 &mut self.schema
801 }
802
803 /// v4.39: returns the persistent row vector by reference. Callers that
804 /// used to take `&[Row]` should switch to `.iter()` (via
805 /// `IntoIterator for &PersistentVec`) or `.get(i)` for indexing.
806 /// v7.38.11 — the column positions this table has BRIN indexes on.
807 pub fn brin_columns(&self) -> alloc::vec::Vec<usize> {
808 self.indices
809 .iter()
810 .filter(|i| matches!(i.kind, crate::IndexKind::Brin { .. }))
811 .map(|i| i.column_position)
812 .collect()
813 }
814
815 /// v7.38.11 — the slot ranges a BRIN index cannot rule out for
816 /// `col_pos` under `lo <= x` / `x <= hi`, or `None` when there is
817 /// no BRIN index on that column.
818 ///
819 /// `None` and "every slot" are deliberately different answers:
820 /// `None` means this table has nothing to say, so a caller that
821 /// does not understand BRIN keeps scanning exactly as before.
822 ///
823 /// A range is skipped only when its summary PROVES no row in it can
824 /// match. A range with no summary — never written, or written only
825 /// with values this index cannot order — is always kept. The
826 /// predicate still runs on every row that survives: the summary
827 /// decides what to skip, never what to return.
828 #[must_use]
829 pub fn brin_candidate_slots(
830 &self,
831 col_pos: usize,
832 lo: Option<i64>,
833 hi: Option<i64>,
834 ) -> Option<alloc::vec::Vec<core::ops::Range<usize>>> {
835 let summaries = self.indices.iter().find_map(|idx| match &idx.kind {
836 crate::IndexKind::Brin { summaries, .. } if idx.column_position == col_pos => {
837 Some(summaries)
838 }
839 _ => None,
840 })?;
841 let n = self.rows.len();
842 let mut out: alloc::vec::Vec<core::ops::Range<usize>> = alloc::vec::Vec::new();
843 let mut start = 0usize;
844 while start < n {
845 let end = (start + crate::BRIN_RANGE_ROWS).min(n);
846 let keep = match summaries.get(start / crate::BRIN_RANGE_ROWS) {
847 // Proven disjoint from the predicate's interval.
848 Some(Some((rmin, rmax))) => {
849 !(lo.is_some_and(|l| *rmax < l) || hi.is_some_and(|h| *rmin > h))
850 }
851 // No summary: nothing is proven, so nothing is skipped.
852 _ => true,
853 };
854 if keep {
855 match out.last_mut() {
856 Some(last) if last.end == start => last.end = end,
857 _ => out.push(start..end),
858 }
859 }
860 start = end;
861 }
862 Some(out)
863 }
864
865 pub const fn rows(&self) -> &PersistentVec<Row<'static>> {
866 &self.rows
867 }
868
869 pub const fn row_count(&self) -> usize {
870 self.rows.len()
871 }
872
873 /// v7.37.15 (Phase B) — answer "is row at `idx` visible under
874 /// `snapshot`?" without exposing the header internals to the
875 /// engine. Callers in scan paths consult this BEFORE yielding
876 /// the row.
877 ///
878 /// Defensive: out-of-bounds `idx` and the (impossible, asserted)
879 /// length mismatch return `false`, mirroring "row is not there
880 /// so it's not visible." Production scans never see either.
881 ///
882 /// Phase A always returns `true` because every header is
883 /// `RowHeader::frozen()` and `Snapshot::unbounded()` accepts
884 /// every header. The full visibility behaviour engages once
885 /// Phase C writers start stamping real `xmin`/`xmax`.
886 #[must_use]
887 pub fn is_row_visible(&self, idx: usize, snapshot: &crate::snapshot::Snapshot) -> bool {
888 match self.headers.get(idx) {
889 Some(h) => self.header_visible(idx, h, snapshot),
890 None => false,
891 }
892 }
893
894 /// v7.39 (round 486) — the visibility decision once the header is
895 /// already in hand. `scan_visible` walks rows and headers in
896 /// lockstep, so it has the header without paying a second trie
897 /// descent to look it up by index.
898 fn header_visible(
899 &self,
900 idx: usize,
901 h: &crate::row_header::RowHeader,
902 snapshot: &crate::snapshot::Snapshot,
903 ) -> bool {
904 // v7.39 (round 297, E3 Phase 1b) — `SKIP LOCKED` rides here so
905 // that every row source honours it; see `Snapshot::locked_out`.
906 if let Some((rel, set)) = &snapshot.locked_out
907 && *rel == self.rel_id
908 && set.contains(&idx)
909 {
910 return false;
911 }
912 snapshot.visible(h)
913 }
914
915 /// v7.37.15 (Phase D) — true iff every row in this table is
916 /// known-all-visible to every snapshot (frozen xmin + alive
917 /// xmax). When true, `scan_visible` skips the per-row check
918 /// entirely — the scan degenerates to a plain `rows().iter()`.
919 ///
920 /// Maintained lazily: any insert/update that stamps a non-
921 /// frozen xmin / xmax clears the cached flag; the next call to
922 /// this method recomputes by walking the header vec. The walk
923 /// is O(n) in the rare case (only when an MVCC writer ran on
924 /// this table); steady-state legacy workloads hit the cached
925 /// `true` and scan at pre-v7.37.15 speed.
926 ///
927 /// Phase D wires this into the engine's hot-tier scan
928 /// optimisation; the bit also serves the per-segment all-
929 /// visible bitmap (each cold segment is a separately tracked
930 /// `all_visible` bit, but cold segments are frozen wholesale
931 /// so they're trivially `true`).
932 #[must_use]
933 pub fn is_all_visible(&self) -> bool {
934 // Compute on the fly. Caching is a follow-up optimisation
935 // (would require &mut self or a Cell); the v7.37.15
936 // initial ship favours correctness + simplicity over the
937 // amortised constant.
938 self.headers
939 .iter()
940 .all(crate::row_header::RowHeader::is_all_visible_fast)
941 }
942
943 /// v7.37.15 (Phase B / D) — iterate over `(idx, row)` pairs whose
944 /// header is visible under `snapshot`. This is the engine-side
945 /// drop-in replacement for `for (i, r) in t.rows().iter().enumerate()`
946 /// at scan sites. The check is a single branch + atomic
947 /// register read inside the snapshot path; with `Snapshot::unbounded`
948 /// the optimiser folds the gate away.
949 ///
950 /// `'a` lifetime on `snapshot` keeps the helper zero-cost in
951 /// the hot loop — no Arc bump, no allocation.
952 /// v7.39 (round 560) — is the row at this position visible to the
953 /// snapshot? Exposed so an index-only walk can decide without
954 /// fetching the row it is deciding about.
955 #[must_use]
956 pub fn position_visible(&self, idx: usize, snapshot: &crate::snapshot::Snapshot) -> bool {
957 self.headers
958 .get(idx)
959 .is_some_and(|h| self.header_visible(idx, h, snapshot))
960 }
961
962 /// v7.39 (round 562) — the same question asked many times over
963 /// ascending positions, without descending the header trie for each
964 /// one.
965 ///
966 /// A profile of the server serving a 100k-row index-only range put
967 /// 27% of the connection thread's CPU on the per-row visibility test.
968 /// The headers are a `PersistentVec` — a 32-way trie — so
969 /// `position_visible` is four dependent pointer loads per row. A
970 /// sequential scan never pays that: it walks rows and headers in
971 /// lockstep. An index walk cannot, but its positions arrive in
972 /// ascending order and a leaf holds 32 of them, so keeping the run
973 /// between calls turns 32 descents into one.
974 ///
975 /// A position outside the held run just descends, so an index whose
976 /// order is uncorrelated with position costs what it costs today.
977 #[must_use]
978 pub fn header_runs(&self) -> HeaderRuns<'_> {
979 HeaderRuns {
980 table: self,
981 run: None,
982 }
983 }
984
985 /// v7.39 (round 559) — how many rows a snapshot sees, without
986 /// touching a single one of them.
987 ///
988 /// `count(*)` already short-circuits to `rows.len()` in the
989 /// aggregate layer, so the O(1) part was never the problem: the cost
990 /// is UPSTREAM, materialising every visible row so that layer can
991 /// take its length. `scan_visible` zips the row trie with the
992 /// headers, and a count needs only the headers.
993 ///
994 /// Measured over pgwire on 500k rows, `SELECT count(*)`:
995 ///
996 /// ```text
997 /// PG18 (2 parallel workers) 8.2 ms
998 /// PG18 (parallelism off) 10.3 ms
999 /// SPG 16.5 ms = 33 ns/row
1000 /// ```
1001 ///
1002 /// — 1.6x slower than a single-threaded PG on the commonest
1003 /// aggregate there is, which no ledger entry recorded.
1004 pub fn count_visible(&self, snapshot: &crate::snapshot::Snapshot) -> usize {
1005 self.note_seq_scan();
1006 self.headers
1007 .iter()
1008 .enumerate()
1009 .filter(|(i, h)| self.header_visible(*i, h, snapshot))
1010 .count()
1011 }
1012
1013 pub fn scan_visible<'a, 'b>(
1014 &'a self,
1015 snapshot: &'b crate::snapshot::Snapshot,
1016 ) -> impl Iterator<Item = (usize, &'a Row<'static>)> + 'b
1017 where
1018 'a: 'b,
1019 {
1020 // v7.39 (pg_stat knife B) — one sequential scan; tup_read is
1021 // the visible-row estimate (an early-terminating consumer —
1022 // LIMIT — reads fewer; the lazy iterator can't report back).
1023 // Two relaxed atomic adds per SCAN (not per row).
1024 self.note_seq_scan();
1025 // v7.39 (round 486) — headers ride alongside the rows instead of
1026 // being looked up by index. `headers.len() == rows.len()` is an
1027 // asserted invariant, so the zip drops nothing; an index lookup
1028 // costs a trie descent per row, and the walk costs one per leaf.
1029 self.rows
1030 .iter()
1031 .zip(self.headers.iter())
1032 .enumerate()
1033 .filter(move |(i, (_, h))| self.header_visible(*i, h, snapshot))
1034 .map(|(i, (r, _))| (i, r))
1035 }
1036
1037 /// The hot-tier slot a scan should resume at, given the last
1038 /// [`RowId`](crate::row_header::RowId) it consumed and where that row
1039 /// used to sit.
1040 ///
1041 /// Slots move. `vacuum` reclaims tombstones by rebuilding the row
1042 /// vector, so every position after the first reclaimed one shifts
1043 /// down — a reader that remembered a bare index would silently skip
1044 /// or repeat rows. Row ids do not move: they are allocated
1045 /// monotonically and never reused, which makes them the only stable
1046 /// way to say "carry on after this row".
1047 ///
1048 /// `hint` is the position that row occupied when it was read. It is
1049 /// still right whenever nothing was reclaimed under the reader, so
1050 /// the check costs one lookup; the binary search is the fallback for
1051 /// when it is not, and it works because appends only ever push
1052 /// larger ids and reclaiming preserves their order.
1053 pub fn resume_slot_after(&self, last: crate::row_header::RowId, hint: usize) -> usize {
1054 if hint > 0 && self.rowids.get(hint - 1).is_some_and(|&r| r == last) {
1055 return hint;
1056 }
1057 let (mut lo, mut hi) = (0usize, self.rowids.len());
1058 while lo < hi {
1059 let mid = lo + (hi - lo) / 2;
1060 match self.rowids.get(mid) {
1061 Some(&r) if r <= last => lo = mid + 1,
1062 _ => hi = mid,
1063 }
1064 }
1065 lo
1066 }
1067
1068 /// The same visibility-gated walk as [`Table::scan_visible`], resuming
1069 /// at hot-tier index `start`.
1070 ///
1071 /// A server-side cursor hands out its result in batches and has to
1072 /// continue where the previous batch stopped. Restarting the walk per
1073 /// batch and discarding a growing prefix would make an N-batch drain
1074 /// quadratic in the row count, so the resume point is a parameter
1075 /// rather than something the caller skips over.
1076 ///
1077 /// `start` is a hot-tier position, not a [`RowId`](crate::row_header::RowId):
1078 /// callers that resume across a compaction must re-derive it, which is
1079 /// why the cursor path only resumes tables with no cold segments.
1080 ///
1081 /// `note_seq_scan` fires only for `start == 0`. One cursor drained in
1082 /// 300 batches is one sequential scan of the table, and counting it
1083 /// 300 times would misreport `pg_stat_user_tables.seq_scan`.
1084 pub fn scan_visible_from<'a, 'b>(
1085 &'a self,
1086 start: usize,
1087 snapshot: &'b crate::snapshot::Snapshot,
1088 ) -> impl Iterator<Item = (usize, &'a Row<'static>)> + 'b
1089 where
1090 'a: 'b,
1091 {
1092 if start == 0 {
1093 self.note_seq_scan();
1094 }
1095 self.rows
1096 .iter()
1097 .zip(self.headers.iter())
1098 .enumerate()
1099 .skip(start)
1100 .filter(move |(i, (_, h))| self.header_visible(*i, h, snapshot))
1101 .map(|(i, (r, _))| (i, r))
1102 }
1103
1104 /// v7.38.11 — like [`Table::scan_visible_from`] but visiting only
1105 /// the slots a BRIN summary could not rule out.
1106 ///
1107 /// The caller passes ranges it got from
1108 /// [`Table::brin_candidate_slots`]; passing `0..len` gives exactly
1109 /// the same rows in the same order as the unpruned scan, which is
1110 /// how the callers that have no BRIN index keep their behaviour.
1111 pub fn scan_visible_slots<'a, 'b>(
1112 &'a self,
1113 slots: alloc::vec::Vec<core::ops::Range<usize>>,
1114 snapshot: &'b crate::snapshot::Snapshot,
1115 ) -> impl Iterator<Item = (usize, &'a Row<'static>)> + 'b
1116 where
1117 'a: 'b,
1118 {
1119 self.note_seq_scan();
1120 slots.into_iter().flatten().filter_map(move |i| {
1121 let h = self.headers.get(i)?;
1122 if !self.header_visible(i, h, snapshot) {
1123 return None;
1124 }
1125 self.rows.get(i).map(|r| (i, r))
1126 })
1127 }
1128
1129 /// v6.8.0 — exposed for the engine layer to patch
1130 /// `Index::included_columns` post-creation. Could fold into
1131 /// `add_index` once the engine's IF-NOT-EXISTS guard moves up,
1132 /// but the patch shape is the minimal change for v6.8.0.
1133 pub fn indices_mut(&mut self) -> &mut [Index] {
1134 &mut self.indices
1135 }
1136
1137 pub fn indices(&self) -> &[Index] {
1138 &self.indices
1139 }
1140
1141 /// Compute the next `AUTO_INCREMENT` value for the column at
1142 /// `col_pos`. Defined as `max(existing) + 1`, falling back to `1`
1143 /// when the column currently holds no integer values. NULL / non-
1144 /// integer cells are skipped. Returns `None` when the column isn't
1145 /// an integer type.
1146 /// v7.38.19 — the next value for `col_pos` taken from its index,
1147 /// or `None` when there is no index to take it from.
1148 ///
1149 /// A B-tree already holds these values in order, so its largest key
1150 /// is a descent rather than a walk of every row. [`Self::next_auto_value`]
1151 /// walked, and one INSERT with a `bigserial` id cost:
1152 ///
1153 /// ```text
1154 /// rows before after PostgreSQL 18
1155 /// 1,000 1.831 ms — 1.245
1156 /// 50,000 2.703 — 1.386
1157 /// 200,000 3.666 1.106 1.075
1158 /// ```
1159 ///
1160 /// Theirs is flat because a sequence is a counter; ours grew with
1161 /// the table, so an ingest workload got slower the longer it ran.
1162 ///
1163 /// Separate from `next_auto_value` and public so a test can ask
1164 /// WHICH path a table takes — the two answer identically, measured,
1165 /// so nothing else can tell them apart from the outside. Deleting
1166 /// the highest row and inserting again gives `1,2,3,4,6` either way
1167 /// and on PostgreSQL 18.4, because a deleted row leaves a version
1168 /// behind that both the tree and the scan still see.
1169 pub fn auto_value_from_index(&self, col_pos: usize) -> Option<i64> {
1170 let m = self.index_on(col_pos)?.max_int_key()?;
1171 let floor = self
1172 .schema
1173 .columns
1174 .get(col_pos)
1175 .and_then(|c| c.auto_restart)
1176 .unwrap_or(i64::MIN);
1177 Some((m + 1).max(floor))
1178 }
1179
1180 pub fn next_auto_value(&self, col_pos: usize) -> Option<i64> {
1181 let ty = self.schema.columns.get(col_pos)?.ty;
1182 if !matches!(ty, DataType::SmallInt | DataType::Int | DataType::BigInt) {
1183 return None;
1184 }
1185 if let Some(v) = self.auto_value_from_index(col_pos) {
1186 return Some(v);
1187 }
1188 let mut max: Option<i64> = None;
1189 for row in &self.rows {
1190 match row.values.get(col_pos) {
1191 Some(Value::SmallInt(n)) => {
1192 let v = i64::from(*n);
1193 max = Some(max.map_or(v, |m| m.max(v)));
1194 }
1195 Some(Value::Int(n)) => {
1196 let v = i64::from(*n);
1197 max = Some(max.map_or(v, |m| m.max(v)));
1198 }
1199 Some(Value::BigInt(n)) => {
1200 max = Some(max.map_or(*n, |m| m.max(*n)));
1201 }
1202 _ => {}
1203 }
1204 }
1205 // v7.39 (round 220) — `ALTER … ALTER COLUMN … RESTART [WITH n]`
1206 // lifts the next allocated value to at least n (a floor over the
1207 // max+1 scan). A dump-restore RESTART lands exactly on n; a
1208 // backward RESTART is safely ignored (no duplicate-key landmine,
1209 // unlike PG).
1210 let base = max.map_or(1, |m| m + 1);
1211 let floor = self
1212 .schema
1213 .columns
1214 .get(col_pos)
1215 .and_then(|c| c.auto_restart)
1216 .unwrap_or(i64::MIN);
1217 Some(base.max(floor))
1218 }
1219
1220 /// Return the first index defined over `column_position`, if any.
1221 /// (`v0.8` supports at most one index per column logically; the search
1222 /// just picks the first match.)
1223 pub fn index_on(&self, column_position: usize) -> Option<&Index> {
1224 // v6.7.1 — prefer BTree (has the key→locator map needed
1225 // for `lookup_eq`) over BRIN (metadata-only). When only a
1226 // BRIN exists on the column, return None so the executor
1227 // falls back to the hot-tier row scan instead of trying
1228 // to use BRIN for an equality lookup (which would always
1229 // return an empty slice and look like "no rows matched").
1230 // v7.38.18 (S0) — and skip a locale-collated index whose tree is
1231 // not filled yet, for the reason the comment above gives about
1232 // BRIN. Such a tree is keyed by ICU sort keys that only the
1233 // engine can produce, so between `CREATE INDEX` and the refresh
1234 // that fills it — and after any row rewrite that retires it — it
1235 // is EMPTY, and an empty tree answers no rows to everything.
1236 // Filtering here rather than at each of the dozen seeks is what
1237 // makes that safe by construction instead of by remembering.
1238 let usable = |i: &&Index| {
1239 // v7.38.18 — an EXPRESSION index is not this column's index.
1240 // Its tree holds `lower(s)`, not `s`, so a probe built from
1241 // the column's own value asks it a question its keys cannot
1242 // answer. Measured on a plain `C` database, before any of
1243 // this version's collation work: three rows, `WHERE s =
1244 // 'Row7'` answered 1, then `CREATE INDEX ix_expr ON
1245 // ix((lower(s)))` and the same query answered 0. PG 18.4
1246 // answers 1 both times, with `enable_seqscan=off` so it
1247 // really used the index.
1248 //
1249 // v7.38.16 fixed this shape for GIN — "an index answered a
1250 // question its keys could not answer, and the caller read
1251 // the empty result as the answer" — and the B-tree copy of
1252 // it was two lines away. The expression-index seek is a
1253 // separate path (`try_expression_index_seek`) and keeps
1254 // working; this only stops the EXPRESSION index from
1255 // standing in for a COLUMN one.
1256 if i.expression.is_some() {
1257 return false;
1258 }
1259 Self::index_collation_in(&self.schema, i, self.db_collation.as_deref().unwrap_or("C"))
1260 .is_none()
1261 || self.expr_index_complete.contains(&i.name)
1262 };
1263 self.indices
1264 .iter()
1265 .find(|i| {
1266 i.column_position == column_position
1267 && matches!(i.kind, IndexKind::BTree(_))
1268 && usable(i)
1269 })
1270 .or_else(|| {
1271 self.indices.iter().find(|i| {
1272 i.column_position == column_position
1273 && matches!(i.kind, IndexKind::Nsw(_))
1274 && i.expression.is_none()
1275 })
1276 })
1277 }
1278
1279 /// Insert one row after validating it matches the schema (length + type).
1280 /// Returns `StorageError` on mismatch — the table is left unchanged.
1281 /// Updates every defined index with the new row's key.
1282 /// Insert a row, maintaining every index that keys on a column's own
1283 /// value.
1284 ///
1285 /// An index that keys on an EXPRESSION is not one of those: its key is
1286 /// not in the row, and this crate has no expression evaluator. Callers
1287 /// that can compute those keys pass them through
1288 /// [`Table::insert_keyed`]; this entry point cannot, so it marks each
1289 /// such index incomplete and the engine rebuilds it before it is next
1290 /// consulted. The index is never left holding keys that do not match
1291 /// its expression — that was the v6.8.2 shape, and it cost 1.9x a
1292 /// plain insert to maintain something no lookup could match.
1293 pub fn insert(&mut self, row: Row<'static>) -> Result<(), StorageError> {
1294 self.insert_keyed(row, None)
1295 }
1296
1297 /// [`Table::insert`] with the expression indexes' VALUES supplied by
1298 /// the caller, one slot per entry of [`Table::indices`] (`None` where
1299 /// the index does not key on an expression, or where the expression
1300 /// evaluated to NULL and so enters no entry, exactly as a NULL column
1301 /// value does).
1302 ///
1303 /// A value, not a key: each index kind makes its own entries out of
1304 /// one. A B-tree wants an [`IndexKey`], a full-text GIN wants the
1305 /// lexemes of a `TsVector`, a trigram GIN wants the shingles of a
1306 /// string, a JSONB GIN wants the tokens of a document. All four ask
1307 /// the same question of the row — "what does this expression say
1308 /// here?" — and only the caller can answer it.
1309 pub fn insert_keyed(
1310 &mut self,
1311 row: Row<'static>,
1312 expr_values: Option<&[Option<Value<'static>>]>,
1313 ) -> Result<(), StorageError> {
1314 if row.len() != self.schema.columns.len() {
1315 return Err(StorageError::ArityMismatch {
1316 expected: self.schema.columns.len(),
1317 actual: row.len(),
1318 });
1319 }
1320 for (i, (val, col)) in row.values.iter().zip(&self.schema.columns).enumerate() {
1321 if val.is_null() {
1322 if !col.nullable {
1323 return Err(StorageError::NullInNotNull {
1324 column: col.name.clone(),
1325 });
1326 }
1327 continue;
1328 }
1329 // v7.39 (read01 round 54) — `data_type()` is None for the
1330 // eval-only variants that carry no DataType (RegClass, Composite).
1331 // They are NOT NULL, so `.expect("non-null")` PANICKED on them —
1332 // materialising a CTE like `WITH w AS (SELECT 't'::regclass)` blew
1333 // up the query with an "internal error". Report a clean type
1334 // mismatch instead; the engine coerces these before they get here
1335 // on every path that knows how.
1336 let Some(actual) = val.data_type() else {
1337 // An eval-only value (RegClass carries oid + name, Composite a
1338 // field tuple) has no DataType in the storage lattice. It is
1339 // NOT NULL, so the old `.expect("non-null")` PANICKED — which
1340 // is how `WITH w AS (SELECT 't'::regclass)` blew up with an
1341 // "internal error". Accept it: the value keeps its dual shape
1342 // and downstream comparisons (RegClass vs BigInt oid) handle it.
1343 continue;
1344 };
1345 // A Vector column needs the variant AND the dimension to
1346 // agree, which the equality inside `column_accepts` already
1347 // encodes because DataType::Vector carries the dim.
1348 let compatible = column_accepts(actual, col.ty);
1349 if !compatible {
1350 return Err(StorageError::TypeMismatch {
1351 column: col.name.clone(),
1352 expected: col.ty,
1353 actual,
1354 position: i,
1355 });
1356 }
1357 }
1358 let new_row_idx = self.rows.len();
1359 // v7.39 (round 493) — disjoint borrows: the BTree arm below reads
1360 // headers to decide which of this key's locators are dead while
1361 // holding `indices` mutably.
1362 let horizon = self.prune_horizon;
1363 let headers = &self.headers;
1364 // Pre-validate before mutating: ensure indices receive an IndexKey.
1365 // For NSW we defer the graph update to *after* the row is pushed
1366 // so the kNN search can see it in `self.rows`.
1367 // Expression indexes whose key the caller could not supply. Named
1368 // here and struck from `expr_index_complete` after the loop, which
1369 // holds `self.indices` mutably.
1370 let mut went_stale: Vec<String> = Vec::new();
1371 // v7.38.18 (S0) — which slots take a supplied key, decided
1372 // before the loop borrows `indices` mutably. An expression
1373 // index and a locale-collated column index are the same
1374 // mechanism from here on.
1375 let db_coll = self.db_collation.as_deref().unwrap_or("C");
1376 let supplied_slots: Vec<bool> = self
1377 .indices
1378 .iter()
1379 .map(|i| {
1380 i.expression.is_some()
1381 || Self::index_collation_in(&self.schema, i, db_coll).is_some()
1382 })
1383 .collect();
1384 for (slot, idx) in self.indices.iter_mut().enumerate() {
1385 // What this index reads for this row: the expression's value
1386 // when it keys on one, the leading column's cell otherwise.
1387 // `None` means the caller could not supply it, and the index
1388 // drops out of service rather than take a wrong entry.
1389 let supplied = if supplied_slots[slot] {
1390 match expr_values.and_then(|vs| vs.get(slot)) {
1391 Some(v) => v.as_ref(),
1392 None => {
1393 went_stale.push(idx.name.clone());
1394 continue;
1395 }
1396 }
1397 } else {
1398 Some(&row.values[idx.column_position])
1399 };
1400 let Some(cell) = supplied else { continue };
1401 match &mut idx.kind {
1402 IndexKind::BTree(map) => {
1403 if let Some(key) = IndexKey::from_value(cell) {
1404 // v4.40: PersistentBTreeMap has no in-place entry-or-default.
1405 // Clone-then-insert keeps the same semantics — for typical
1406 // unique-key schemas the Vec is 1-element so the clone is
1407 // O(1). For dup-heavy columns it's O(M) per insert, traded
1408 // for the structural-sharing win at clone time.
1409 //
1410 // v7.39 (round 558) — TAKE the list instead of cloning it.
1411 // `insert_mut` returns the previous value by MOVE, so the
1412 // O(M) copy the note above accepted is avoidable, and the
1413 // retain below still gets the list in hand. What that
1414 // trade cost, measured on a 50k table:
1415 //
1416 // UPDATE h SET v = 1 WHERE v <= 10000 (10k -> ONE key)
1417 // v indexed 150.6 ms v unindexed 31.2 ms
1418 // UPDATE h SET v = v + 1 WHERE v <= 10000 (distinct keys)
1419 // v indexed 34.7 ms v unindexed 32.0 ms
1420 //
1421 // 11.9 µs/row when the new keys collide against 0.27 when
1422 // they do not — 44x for the same row count, because the
1423 // k-th insert under one key copied a k-element list. Under
1424 // in-place MVCC an UPDATE appends a new row VERSION, so an
1425 // ordinary `SET flag = 'done'` over a batch lands every
1426 // one of them on the same key.
1427 let mut entries = map
1428 .insert_mut(key.clone(), crate::posting::PostingList::new())
1429 .unwrap_or_default();
1430 // v7.39 (round 493) — drop this key's dead versions while
1431 // the list is already in hand.
1432 //
1433 // "The Vec is 1-element for unique-key schemas" is what
1434 // churn breaks: a posting list carries one locator per row
1435 // VERSION, so deleting and re-inserting the same id grows
1436 // it without bound between vacuums. Round 492 counted 61
1437 // locators under one PK by cycle 60, each costing the
1438 // uniqueness probe a header lookup, and round 490 found the
1439 // range seek walking the same versions.
1440 //
1441 // Vacuum already prunes them — by rebuilding every index,
1442 // which is why it runs rarely enough for this to matter.
1443 // Here the work is free: the list is cloned on this path
1444 // anyway and is about to be written back.
1445 //
1446 // Safety is vacuum's own argument: `prune_horizon` is the
1447 // floor of every live snapshot, so a version reclaimable
1448 // under it is invisible to every reader that exists or can
1449 // yet begin (a later snapshot's version is >= the floor).
1450 // A horizon of 0 keeps everything.
1451 // v7.39 (round 558) — AMORTISE it.
1452 //
1453 // The retain walks the whole list, so running it on
1454 // every insert is O(M) per insert and O(n²) over a
1455 // statement that puts n row versions under one key.
1456 // Measured on a 50k table, 10k rows updated:
1457 //
1458 // retain every insert off
1459 // SET v = 1 (dupes) 135.9 ms 11.7
1460 // SET v = v+1 (distinct) 31.4 ms 13.4
1461 //
1462 // and the second line has no colliding key at all —
1463 // the OTHER index (g, 100 distinct values over 50k
1464 // rows) supplies lists long enough on its own. Every
1465 // insert on every index was paying it.
1466 //
1467 // Pruning only when the list has DOUBLED keeps round
1468 // 493's bound — the list stays within 2x its pruned
1469 // size, so the seek still never walks an unbounded
1470 // version chain — while the total work over n inserts
1471 // becomes n + n/2 + n/4 + … = O(n). Skipping a prune
1472 // can only delay reclamation; it never drops a live
1473 // locator, so the safety argument in the note above is
1474 // untouched.
1475 if horizon > 0 && entries.len() > 1 && entries.len().is_power_of_two() {
1476 entries.retain(|loc| match loc {
1477 RowLocator::Hot(i) => headers.get(i).is_none_or(|h| {
1478 !crate::vacuum::is_reclaimable(h.xmax, horizon)
1479 }),
1480 RowLocator::Cold { .. } => true,
1481 });
1482 }
1483 entries.push(RowLocator::Hot(new_row_idx));
1484 map.insert_mut(key, entries);
1485 }
1486 }
1487 // v7.38.1 (L12) — multi-column key: every component must
1488 // key, or the row is not entered (a `=` probe can never
1489 // select the NULL it would stand for). The take/prune/
1490 // push dance is the BTree arm's, for the same churn
1491 // reasons.
1492 IndexKind::BTreeMulti(map) => {
1493 if let Some(key) = crate::compose_multi_key(
1494 &row.values,
1495 idx.column_position,
1496 &idx.extra_column_positions,
1497 ) {
1498 let mut entries = map
1499 .insert_mut(key.clone(), crate::posting::PostingList::new())
1500 .unwrap_or_default();
1501 if horizon > 0 && entries.len() > 1 && entries.len().is_power_of_two() {
1502 entries.retain(|loc| match loc {
1503 RowLocator::Hot(i) => headers.get(i).is_none_or(|h| {
1504 !crate::vacuum::is_reclaimable(h.xmax, horizon)
1505 }),
1506 RowLocator::Cold { .. } => true,
1507 });
1508 }
1509 entries.push(RowLocator::Hot(new_row_idx));
1510 map.insert_mut(key, entries);
1511 }
1512 }
1513 IndexKind::Gin(map) => {
1514 // v7.12.3 — extend posting list per lexeme word.
1515 // NULL or non-TsVector cell → no-op (cell carries
1516 // no lexemes to index).
1517 if let Value::TsVector(lexemes) = cell {
1518 for lex in lexemes {
1519 if let Some(entries) = map.get_mut(&lex.word) {
1520 entries.push(RowLocator::Hot(new_row_idx));
1521 } else {
1522 map.insert_mut(
1523 lex.word.clone(),
1524 crate::posting::PostingList::single(RowLocator::Hot(
1525 new_row_idx,
1526 )),
1527 );
1528 }
1529 }
1530 }
1531 }
1532 IndexKind::GinTrgm(map) => {
1533 // v7.15.0 — trigram GIN. Shingle the TEXT cell
1534 // into PG-compatible 3-byte trigrams and extend
1535 // each trigram's posting list.
1536 if let Value::Text(s) = cell {
1537 for tri in trgm::extract_trigrams(s) {
1538 // r1019 — address the String-keyed map with the borrowed
1539 // trigram; allocate one only for a key the map has never
1540 // seen, which after the first rows is rare.
1541 let key = trgm::trigram_str(&tri);
1542 if let Some(entries) = map.get_mut_by(key) {
1543 entries.push(RowLocator::Hot(new_row_idx));
1544 } else {
1545 map.insert_mut(
1546 alloc::string::ToString::to_string(key),
1547 crate::posting::PostingList::single(RowLocator::Hot(
1548 new_row_idx,
1549 )),
1550 );
1551 }
1552 }
1553 }
1554 }
1555 IndexKind::GinFulltext(map) => {
1556 // v7.17.0 Phase 2.2 — MySQL FULLTEXT-shape
1557 // GIN over a TEXT / VARCHAR cell. Tokenise
1558 // via the storage-local `simple_lex` (same
1559 // rule as `to_tsvector('simple', text)`) and
1560 // extend each lexeme's posting list.
1561 let text_cell = match cell {
1562 Value::Text(s) => Some(s.as_ref()),
1563 // mysqldump-style mediumtext / longtext
1564 // land as Value::Text on insert; varchar
1565 // cells likewise. Anything else (NULL,
1566 // integer, …) contributes no lexemes.
1567 _ => None,
1568 };
1569 if let Some(s) = text_cell {
1570 for lex in fts_simple::simple_lex(s) {
1571 if let Some(entries) = map.get_mut(&lex) {
1572 entries.push(RowLocator::Hot(new_row_idx));
1573 } else {
1574 map.insert_mut(
1575 lex,
1576 crate::posting::PostingList::single(RowLocator::Hot(
1577 new_row_idx,
1578 )),
1579 );
1580 }
1581 }
1582 }
1583 }
1584 IndexKind::GinJsonb(map) => {
1585 // v7.37.8(sentori Epic 5 P2)— real JSONB-GIN.
1586 // Extract canonical `(path, leaf)` tokens from
1587 // the cell text and extend each token's posting
1588 // list. NULL or non-Json cell contributes no
1589 // tokens(`labels @> '...'` against a NULL row
1590 // is always false so absence here is correct).
1591 let json_cell = match cell {
1592 Value::Json(s) => Some(s.as_ref()),
1593 _ => None,
1594 };
1595 if let Some(s) = json_cell {
1596 for tok in jsonb_gin::extract_tokens(s) {
1597 if let Some(entries) = map.get_mut(&tok) {
1598 entries.push(RowLocator::Hot(new_row_idx));
1599 } else {
1600 map.insert_mut(
1601 tok,
1602 crate::posting::PostingList::single(RowLocator::Hot(
1603 new_row_idx,
1604 )),
1605 );
1606 }
1607 }
1608 }
1609 }
1610 // v7.38.11 — widen the summary covering this slot.
1611 //
1612 // Widen-only: this can make a range less selective and
1613 // never makes it skip a row. A value with no BRIN
1614 // ordering leaves the range as it was, and a range that
1615 // has never seen one stays `None`, which the scan reads
1616 // as "cannot be skipped".
1617 IndexKind::Brin { summaries, .. } => {
1618 let r = new_row_idx / crate::BRIN_RANGE_ROWS;
1619 if summaries.len() <= r {
1620 summaries.resize(r + 1, None);
1621 }
1622 if let Some(n) = crate::brin_scalar(cell) {
1623 summaries[r] = Some(match summaries[r] {
1624 Some((lo, hi)) => (lo.min(n), hi.max(n)),
1625 None => (n, n),
1626 });
1627 }
1628 }
1629 // NSW handled below after the row push (so the new row
1630 // is visible to the kNN-graph connect step).
1631 IndexKind::Nsw(_) => {}
1632 }
1633 }
1634 for name in went_stale {
1635 self.expr_index_complete.remove(&name);
1636 }
1637 // v7.39 (round 215) — maintain the range-exclusion indexes for the
1638 // freshly-inserted row (before the move; `new_row_idx` is the slot it
1639 // will occupy). Mirrors the BTree maintenance above.
1640 if !self.excl_indexes.is_empty() {
1641 self.excl_indexes_on_insert(&row, new_row_idx);
1642 }
1643 // v5.2.1: maintain incremental hot-tier byte counter. Computed
1644 // before the move so we don't need to borrow `row` after push.
1645 self.hot_bytes = self
1646 .hot_bytes
1647 .saturating_add(row_body_encoded_len(&row, &self.schema) as u64);
1648 // v7.34 — capture the row-level redo before the row is moved in.
1649 // v7.37.15 (Epic W slice 1) — carry the stable RowId this insert
1650 // will receive. `alloc_rowid` below hands out `RowId(next_rowid)`
1651 // and bumps the counter unconditionally, so the id read here is
1652 // exactly the one the row ends up with. `writer_version` (xmin)
1653 // is 0: the writing TxId is not threaded to this layer yet (the
1654 // header pushed below is `RowHeader::frozen()`).
1655 let redo_rowid =
1656 crate::row_header::RowId(self.next_rowid.load(core::sync::atomic::Ordering::Relaxed));
1657 self.record_redo(|table| RowChange::Insert {
1658 table,
1659 row: row.clone(),
1660 rowid: redo_rowid,
1661 writer_version: 0,
1662 });
1663 // v4.39.1: push_mut keeps streaming inserts at Vec::push speed when
1664 // the table is uniquely owned (the spg-embedded path); inside a TX
1665 // wrap where a Catalog snapshot exists, push_mut path-copies the
1666 // tail just like push() and the snapshot stays valid.
1667 self.rows.push_mut(row);
1668 // v7.37.15 (Phase A.2) — keep `headers` lock-step with `rows`.
1669 // Phase A defaults every new insert to RowHeader::frozen() so
1670 // visibility checks against any snapshot return true; Phase C
1671 // upgrades the inserter to stamp the writing tx's xmin.
1672 self.headers
1673 .push_mut(crate::row_header::RowHeader::frozen());
1674 // v7.37.15 (Phase C.1) — allocate + push the stable RowId in
1675 // lock-step with rows/headers. Index locators still address
1676 // by physical slot at this commit; the id is additive
1677 // bookkeeping the lock table / HOT chains / WAL migrate to.
1678 let rid = self.alloc_rowid();
1679 self.rowids.push_mut(rid);
1680 // v7.37.15 (Epic W slice 1) — the id captured for the redo log
1681 // above must be the one actually assigned to the row.
1682 debug_assert_eq!(
1683 rid, redo_rowid,
1684 "redo-captured RowId must match the allocated RowId"
1685 );
1686 debug_assert_eq!(
1687 self.rows.len(),
1688 self.headers.len(),
1689 "headers must stay in lock-step with rows after insert"
1690 );
1691 debug_assert_eq!(
1692 self.rows.len(),
1693 self.rowids.len(),
1694 "rowids must stay in lock-step with rows after insert"
1695 );
1696 // NSW updates after the push so the new row is visible to the
1697 // greedy search used during connect.
1698 let new_row_idx = self.rows.len() - 1;
1699 let nsw_targets: Vec<usize> = self
1700 .indices
1701 .iter()
1702 .enumerate()
1703 .filter_map(|(i, idx)| {
1704 if matches!(idx.kind, IndexKind::Nsw(_)) {
1705 Some(i)
1706 } else {
1707 None
1708 }
1709 })
1710 .collect();
1711 for idx_pos in nsw_targets {
1712 nsw_insert_at(self, idx_pos, new_row_idx);
1713 }
1714 Ok(())
1715 }
1716
1717 /// Build a new B-tree index over the named column. Rebuilds from
1718 /// existing rows. Errors if `column_name` doesn't exist or the index
1719 /// name is taken.
1720 pub fn add_index(&mut self, name: String, column_name: &str) -> Result<(), StorageError> {
1721 if self.indices.iter().any(|i| i.name == name) {
1722 return Err(StorageError::DuplicateIndex { name });
1723 }
1724 let column_position = self.schema.column_position(column_name).ok_or_else(|| {
1725 StorageError::ColumnNotFound {
1726 column: column_name.into(),
1727 }
1728 })?;
1729 let mut idx = Index::new_btree(name, column_position);
1730 // v7.38.18 (S0) — a locale-collated column's tree cannot be
1731 // filled from the raw cells: its keys are ICU sort keys and this
1732 // crate holds no collator. Create it EMPTY and leave it out of
1733 // `expr_index_complete`, which is what makes every read path
1734 // decline it until the engine refreshes it — the same path an
1735 // expression index takes from birth.
1736 let collated = Self::index_collation_in(
1737 &self.schema,
1738 &idx,
1739 self.db_collation.as_deref().unwrap_or("C"),
1740 )
1741 .is_some();
1742 if let IndexKind::BTree(map) = &mut idx.kind
1743 && !collated
1744 {
1745 for (i, row) in self.rows.iter().enumerate() {
1746 if let Some(key) = IndexKey::from_value(&row.values[column_position]) {
1747 if let Some(entries) = map.get_mut(&key) {
1748 entries.push(RowLocator::Hot(i));
1749 } else {
1750 map.insert_mut(
1751 key,
1752 crate::posting::PostingList::single(RowLocator::Hot(i)),
1753 );
1754 }
1755 }
1756 }
1757 }
1758 self.indices.push(idx);
1759 Ok(())
1760 }
1761
1762 /// v7.39 (round 215) — ensure a range-exclusion index exists on
1763 /// `column_position`, building it from the current rows. Idempotent: a
1764 /// second call for the same column is a no-op. Called at CREATE TABLE /
1765 /// ALTER ADD EXCLUDE and on catalog load (rebuild-from-constraints).
1766 /// Tombstoned rows are indexed too (they are filtered by the consumer via
1767 /// `is_deleted()` at query time — the established index pattern).
1768 pub fn ensure_excl_range_index(&mut self, column_position: usize) {
1769 if self
1770 .excl_indexes
1771 .iter()
1772 .any(|e| e.column_position == column_position)
1773 {
1774 return;
1775 }
1776 let mut map: crate::PersistentBTreeMap<(i128, u8), crate::posting::PostingList> =
1777 crate::PersistentBTreeMap::new();
1778 for (i, row) in self.rows.iter().enumerate() {
1779 if let Some(v) = row.values.get(column_position)
1780 && let Some(key) = crate::range_excl_index_key(v)
1781 {
1782 if let Some(entries) = map.get_mut(&key) {
1783 entries.push(RowLocator::Hot(i));
1784 } else {
1785 map.insert_mut(key, crate::posting::PostingList::single(RowLocator::Hot(i)));
1786 }
1787 }
1788 }
1789 self.excl_indexes.push(crate::ExclRangeIndex {
1790 column_position,
1791 map,
1792 });
1793 }
1794
1795 /// v7.39 (round 215) — the range-exclusion index on `column_position`, if
1796 /// one was built. The EXCLUDE enforcement path probes its
1797 /// [`predecessor`](crate::PersistentBTreeMap::predecessor) + successors to
1798 /// find candidate overlaps in O(log n).
1799 #[must_use]
1800 pub fn excl_range_index(
1801 &self,
1802 column_position: usize,
1803 ) -> Option<&crate::PersistentBTreeMap<(i128, u8), crate::posting::PostingList>> {
1804 self.excl_indexes
1805 .iter()
1806 .find(|e| e.column_position == column_position)
1807 .map(|e| &e.map)
1808 }
1809
1810 /// v7.39 (round 215) — add a freshly-appended row at `row_idx` to every
1811 /// range-exclusion index. Called from `insert` after the row is pushed,
1812 /// mirroring the BTree secondary-index maintenance.
1813 fn excl_indexes_on_insert(&mut self, row: &Row<'static>, row_idx: usize) {
1814 for ex in &mut self.excl_indexes {
1815 if let Some(v) = row.values.get(ex.column_position)
1816 && let Some(key) = crate::range_excl_index_key(v)
1817 {
1818 if let Some(entries) = ex.map.get_mut(&key) {
1819 entries.push(RowLocator::Hot(row_idx));
1820 } else {
1821 ex.map.insert_mut(
1822 key,
1823 crate::posting::PostingList::single(RowLocator::Hot(row_idx)),
1824 );
1825 }
1826 }
1827 }
1828 }
1829
1830 /// v7.39 (round 215) — rebuild every range-exclusion index from the
1831 /// current rows (called from `rebuild_indices`, i.e. after a physical
1832 /// compaction/delete that shifted slots). Preserves which columns are
1833 /// indexed; re-emits all `Hot` locators.
1834 fn rebuild_excl_indexes(&mut self) {
1835 let cols: Vec<usize> = self
1836 .excl_indexes
1837 .iter()
1838 .map(|e| e.column_position)
1839 .collect();
1840 self.excl_indexes.clear();
1841 for c in cols {
1842 self.ensure_excl_range_index(c);
1843 }
1844 }
1845
1846 /// Build a new NSW (HNSW-flavoured) index over the named column.
1847 /// Required for `ORDER BY col <-> literal LIMIT k` to plan as a
1848 /// graph traversal instead of a full scan. Column must be a Vector
1849 /// type. `m` is the maximum number of neighbours per node.
1850 pub fn add_nsw_index(
1851 &mut self,
1852 name: String,
1853 column_name: &str,
1854 m: usize,
1855 ) -> Result<(), StorageError> {
1856 self.add_nsw_index_inner(name, column_name, m, None)
1857 }
1858
1859 /// v6.0.4 — synchronous rebuild of the named NSW index. If
1860 /// `new_encoding` is `Some(target)` and differs from the column's
1861 /// current encoding, every stored cell at the indexed column is
1862 /// re-coded into the target encoding before the new graph
1863 /// builds. Returns `IndexNotFound` if no index by that name exists
1864 /// and `Unsupported` for non-NSW indexes (`BTree` REBUILD is a no-op
1865 /// the engine layer rejects, not a storage-level concept).
1866 ///
1867 /// Holds the caller's `&mut self` for the duration — no
1868 /// concurrency / staging / WAL-replay machinery in v6.0.4. The
1869 /// "live" optimisation lands as v6.0.4.1.
1870 pub fn rebuild_nsw_index(
1871 &mut self,
1872 name: &str,
1873 new_encoding: Option<VecEncoding>,
1874 ) -> Result<(), StorageError> {
1875 let idx_pos = self
1876 .indices
1877 .iter()
1878 .position(|i| i.name == name)
1879 .ok_or_else(|| StorageError::IndexNotFound {
1880 name: String::from(name),
1881 })?;
1882 let col_pos = self.indices[idx_pos].column_position;
1883 let m = match &self.indices[idx_pos].kind {
1884 IndexKind::Nsw(g) => g.m,
1885 IndexKind::BTree(_)
1886 | IndexKind::Brin { .. }
1887 | IndexKind::Gin(_)
1888 | IndexKind::GinTrgm(_)
1889 | IndexKind::GinFulltext(_)
1890 | IndexKind::GinJsonb(_)
1891 | IndexKind::BTreeMulti(_) => {
1892 return Err(StorageError::Unsupported(format!(
1893 "ALTER INDEX REBUILD on non-NSW index {name:?} — only NSW indexes can rebuild"
1894 )));
1895 }
1896 };
1897 let col_name = self.schema.columns[col_pos].name.clone();
1898 // 1. Optional re-encoding pass. Done first so the cells
1899 // match the schema before the graph rebuild walks them.
1900 if let Some(target) = new_encoding {
1901 let current = match self.schema.columns[col_pos].ty {
1902 DataType::Vector { encoding, .. } => encoding,
1903 ref other => {
1904 return Err(StorageError::Unsupported(format!(
1905 "ALTER INDEX REBUILD WITH (encoding=…) on non-vector column type {other:?}"
1906 )));
1907 }
1908 };
1909 if target != current {
1910 let DataType::Vector { dim, .. } = self.schema.columns[col_pos].ty else {
1911 unreachable!("checked above")
1912 };
1913 let n = self.rows.len();
1914 for i in 0..n {
1915 let row = self
1916 .rows
1917 .get_mut(i)
1918 .expect("row index in bounds (we iterated up to len())");
1919 let cell = core::mem::replace(&mut row.values[col_pos], Value::Null);
1920 let recoded = recode_vector_cell(cell, target)?;
1921 row.values[col_pos] = recoded;
1922 }
1923 self.schema.columns[col_pos].ty = DataType::Vector {
1924 dim,
1925 encoding: target,
1926 };
1927 }
1928 }
1929 // 2. Drop the existing index slot + rebuild from row payload.
1930 self.indices.remove(idx_pos);
1931 self.add_nsw_index_inner(String::from(name), &col_name, m, None)?;
1932 Ok(())
1933 }
1934
1935 /// Restore an NSW index from a pre-built graph (used on
1936 /// deserialize). Skips the bulk-build pass since the topology is
1937 /// already known. Returns `DuplicateIndex` or `ColumnNotFound` on
1938 /// schema mismatch as usual.
1939 pub fn restore_nsw_index(
1940 &mut self,
1941 name: String,
1942 column_name: &str,
1943 graph: NswGraph,
1944 ) -> Result<(), StorageError> {
1945 self.add_nsw_index_inner(name, column_name, graph.m, Some(graph))
1946 }
1947
1948 /// Restore a `BTree` index from a pre-built `(IndexKey, Vec<RowLocator>)`
1949 /// map. Used by [`Catalog::deserialize`] when reading a v9 (or later)
1950 /// catalog snapshot — the map travels on disk so cold-tier locators
1951 /// survive a round-trip, instead of being rebuilt from `self.rows`
1952 /// (which would lose every Cold entry). Same error contract as
1953 /// [`Table::add_index`].
1954 pub fn restore_btree_index(
1955 &mut self,
1956 name: String,
1957 column_name: &str,
1958 map: PersistentBTreeMap<IndexKey, crate::posting::PostingList>,
1959 ) -> Result<(), StorageError> {
1960 if self.indices.iter().any(|i| i.name == name) {
1961 return Err(StorageError::DuplicateIndex { name });
1962 }
1963 let column_position = self.schema.column_position(column_name).ok_or_else(|| {
1964 StorageError::ColumnNotFound {
1965 column: column_name.into(),
1966 }
1967 })?;
1968 self.indices.push(Index {
1969 name,
1970 column_position,
1971 kind: IndexKind::BTree(map),
1972 included_columns: Vec::new(),
1973 partial_predicate: None,
1974 expression: None,
1975 is_unique: false,
1976 nulls_not_distinct: false,
1977 descending: false,
1978 nulls_first: None,
1979 collation: None,
1980 extra_column_positions: Vec::new(),
1981 });
1982 Ok(())
1983 }
1984
1985 /// v7.38.1 (L12) — snapshot-restore counterpart for a tag-7
1986 /// multi-column B-tree. The extras arrive via the per-index
1987 /// appendix, which `Catalog::deserialize` applies after this call —
1988 /// exactly as it does for every other restored kind.
1989 pub fn restore_btree_multi_index(
1990 &mut self,
1991 name: String,
1992 column_name: &str,
1993 map: PersistentBTreeMap<alloc::boxed::Box<[IndexKey]>, crate::posting::PostingList>,
1994 ) -> Result<(), StorageError> {
1995 if self.indices.iter().any(|i| i.name == name) {
1996 return Err(StorageError::DuplicateIndex { name });
1997 }
1998 let column_position = self.schema.column_position(column_name).ok_or_else(|| {
1999 StorageError::ColumnNotFound {
2000 column: column_name.into(),
2001 }
2002 })?;
2003 self.indices.push(Index {
2004 kind: IndexKind::BTreeMulti(map),
2005 ..Index::new_btree(name, column_position)
2006 });
2007 Ok(())
2008 }
2009
2010 /// One row's stored values, by physical position — the same positions
2011 /// a `RowLocator::Hot` names. Includes rows no snapshot can see: an
2012 /// index entry outlives the version it points at, and visibility is
2013 /// decided when the entry is followed, not when it is made.
2014 pub fn row_values_at(&self, position: usize) -> Option<&[Value<'static>]> {
2015 self.rows.get(position).map(|r| r.values.as_slice())
2016 }
2017
2018 /// Physical row count, dead versions included.
2019 pub fn stored_row_count(&self) -> usize {
2020 self.rows.len()
2021 }
2022
2023 /// Every path that rewrites `rows` wholesale, or appends without
2024 /// keying, drops the expression indexes back to unusable. They hold
2025 /// row positions, and this crate cannot re-derive their keys.
2026 fn invalidate_expr_indices(&mut self) {
2027 self.expr_index_complete.clear();
2028 }
2029
2030 /// v7.38.18 (S0) — the collation an index's keys are built under,
2031 /// when that is not byte order.
2032 ///
2033 /// A B-tree here orders `IndexKey` by a derived `Ord`, which for
2034 /// text is byte order. A column that collates by a LOCALE cannot key
2035 /// on its raw text, then: the tree would order the entries one way
2036 /// and the scan would answer another, and a range seek would return
2037 /// a subset. Measured before this existed, on a column declared
2038 /// `COLLATE "en_US.utf8"`: `WHERE x > 'b'` gave four rows scanning
2039 /// and one row seeking.
2040 ///
2041 /// So such an index takes a SUPPLIED key, exactly as an expression
2042 /// index does — the engine holds the collator, encodes the ICU sort
2043 /// key, and this crate stores the bytes it is handed. `None` means
2044 /// the index keys on its column's own value, which is the ordinary
2045 /// case and stays free.
2046 pub fn index_collation(&self, idx: &Index) -> Option<&str> {
2047 Self::index_collation_in(
2048 &self.schema,
2049 idx,
2050 self.db_collation.as_deref().unwrap_or("C"),
2051 )
2052 }
2053
2054 /// The same question against a schema alone, for the callers that
2055 /// already hold `self.indices` mutably.
2056 pub(crate) fn index_collation_of<'a>(
2057 schema: &'a crate::TableSchema,
2058 idx: &Index,
2059 ) -> Option<&'a str> {
2060 Self::index_collation_in(schema, idx, "C")
2061 }
2062
2063 /// v7.38.18 (S2) — the database collation in force for this table,
2064 /// which is what its undeclared text columns are compared under and
2065 /// what its indexes on them key under. `"C"` when none was set.
2066 pub fn db_collation(&self) -> &str {
2067 self.db_collation.as_deref().unwrap_or("C")
2068 }
2069
2070 /// v7.38.18 (S2) — set by the catalog that owns this table.
2071 pub fn set_db_collation(&mut self, name: &str) {
2072 self.db_collation = if name.eq_ignore_ascii_case("C") {
2073 None
2074 } else {
2075 Some(name.into())
2076 };
2077 }
2078
2079 /// The same question with the DATABASE's collation supplied, for S2:
2080 /// a text column that declares nothing inherits it, so an index on
2081 /// such a column keys under it too.
2082 pub(crate) fn index_collation_in<'a>(
2083 schema: &'a crate::TableSchema,
2084 idx: &Index,
2085 db: &'a str,
2086 ) -> Option<&'a str> {
2087 if idx.expression.is_some() {
2088 return None;
2089 }
2090 // v7.38.18 (S0) — a COMPOSITE index keys on a tuple built from
2091 // raw cells, and this crate builds that tuple. It is not a
2092 // supplied-key index, so it must not be reported as one: doing
2093 // so would put its entries in one space and its probes in
2094 // another, and `WHERE id = 7 AND s = 'row7'` answered 0 where
2095 // PG 18.4 answers 1. The engine's composite seek declines such
2096 // an index instead, which costs a scan.
2097 if !idx.extra_column_positions.is_empty() {
2098 return None;
2099 }
2100 let col = schema.columns.get(idx.column_position)?;
2101 if !matches!(
2102 col.ty,
2103 crate::DataType::Text | crate::DataType::Varchar(_) | crate::DataType::Char(_)
2104 ) {
2105 return None;
2106 }
2107 // v7.38.18 (S2) — a MySQL column is not a candidate for
2108 // inheritance. Its `Collation::CaseInsensitive` says the engine
2109 // FOLDS it, which is MySQL's model and not a locale's, and the
2110 // database collation is a PostgreSQL concept. A test server
2111 // started with `LANG=en_US.UTF-8` — which is most of them —
2112 // otherwise routed every MySQL text column into the ICU path,
2113 // and an indexed `s = 'ALPHA'` answered nothing where MySQL
2114 // 9.7.1 answers one row.
2115 if matches!(col.collation, crate::Collation::CaseInsensitive) {
2116 return None;
2117 }
2118 col.collation_name
2119 .as_deref()
2120 .or(Some(db))
2121 .filter(|n| crate::collation_uses_sort_key(n))
2122 }
2123
2124 /// Does this index's key come from the caller rather than from the
2125 /// row's own cell? True for an expression index and for one whose
2126 /// column collates by a locale; the two are the same mechanism.
2127 pub fn index_needs_supplied_key(&self, idx: &Index) -> bool {
2128 idx.expression.is_some() || self.index_collation(idx).is_some()
2129 }
2130
2131 /// Is this expression index's B-tree currently keyed by its
2132 /// expression's value, and therefore safe to look up in?
2133 ///
2134 /// `false` for every index read off disk, for one that a plain
2135 /// [`Table::insert`] has touched since it was built, and for an index
2136 /// that keys on a column (which has no expression to be complete
2137 /// about — ask `expression.is_none()` instead).
2138 pub fn expr_index_is_complete(&self, name: &str) -> bool {
2139 self.expr_index_complete.contains(name)
2140 }
2141
2142 /// Rebuild an expression index's B-tree from `keys`, one per row of
2143 /// this table in row order, and mark it complete.
2144 ///
2145 /// The caller owns the evaluator, so it owns the keys; this crate
2146 /// only owns the invariant that the map and the flag move together.
2147 /// Returns `false` — index untouched, still incomplete — when the
2148 /// index does not key on an expression, is not a B-tree, the key
2149 /// count does not match the row count, or any row body lives in a
2150 /// cold segment (whose values the caller could not have evaluated).
2151 pub fn rebuild_expression_index(
2152 &mut self,
2153 name: &str,
2154 values: &[Option<Value<'static>>],
2155 ) -> Result<bool, StorageError> {
2156 if values.len() != self.rows.len() || self.cold_row_count > 0 {
2157 return Ok(false);
2158 }
2159 let Some(pos) = self.indices.iter().position(|i| i.name == name) else {
2160 return Ok(false);
2161 };
2162 // v7.38.18 (S0) — an expression index or a locale-collated one.
2163 // Both take their keys from the caller; neither can be rebuilt
2164 // from the cells this crate can see.
2165 if self.indices[pos].expression.is_none()
2166 && Self::index_collation_in(
2167 &self.schema,
2168 &self.indices[pos],
2169 self.db_collation.as_deref().unwrap_or("C"),
2170 )
2171 .is_none()
2172 {
2173 return Ok(false);
2174 }
2175 // Empty the index, then re-enter every row through the ordinary
2176 // maintenance arms. Rebuilding by REPLAYING the insert path is
2177 // what keeps a rebuilt GIN identical to an incrementally
2178 // maintained one — the tokenising lives in one place and this is
2179 // not a second copy of it.
2180 match &mut self.indices[pos].kind {
2181 IndexKind::BTree(map) => *map = crate::persistent_btree::PersistentBTreeMap::new(),
2182 IndexKind::Gin(map) | IndexKind::GinFulltext(map) => {
2183 *map = crate::persistent_btree::PersistentBTreeMap::new();
2184 }
2185 IndexKind::GinTrgm(map) | IndexKind::GinJsonb(map) => {
2186 *map = crate::persistent_btree::PersistentBTreeMap::new();
2187 }
2188 // BRIN summarises by row position and HNSW is a graph over
2189 // the rows themselves; neither is rebuilt from a value list.
2190 _ => return Ok(false),
2191 }
2192 for (i, value) in values.iter().enumerate() {
2193 let Some(v) = value else { continue };
2194 self.enter_expression_entry(pos, v, i);
2195 }
2196 self.expr_index_complete.insert(name.into());
2197 Ok(true)
2198 }
2199}
2200
2201/// Extend one posting list, creating it when the key is new.
2202fn push_posting(
2203 map: &mut crate::persistent_btree::PersistentBTreeMap<String, crate::posting::PostingList>,
2204 key: String,
2205 row_idx: usize,
2206) {
2207 if let Some(entries) = map.get_mut(&key) {
2208 entries.push(RowLocator::Hot(row_idx));
2209 } else {
2210 map.insert_mut(
2211 key,
2212 crate::posting::PostingList::single(RowLocator::Hot(row_idx)),
2213 );
2214 }
2215}
2216
2217impl Table {
2218 /// Add one row's entries to one expression index, from the value its
2219 /// expression produced. The insert path's arms, reduced to the one
2220 /// index and the one row.
2221 fn enter_expression_entry(&mut self, pos: usize, cell: &Value<'static>, row_idx: usize) {
2222 let idx = &mut self.indices[pos];
2223 match &mut idx.kind {
2224 IndexKind::BTree(map) => {
2225 if let Some(key) = IndexKey::from_value(cell) {
2226 let mut entries = map
2227 .insert_mut(key.clone(), crate::posting::PostingList::new())
2228 .unwrap_or_default();
2229 entries.push(RowLocator::Hot(row_idx));
2230 map.insert_mut(key, entries);
2231 }
2232 }
2233 IndexKind::Gin(map) => {
2234 if let Value::TsVector(lexemes) = cell {
2235 for lex in lexemes {
2236 push_posting(map, lex.word.clone(), row_idx);
2237 }
2238 }
2239 }
2240 IndexKind::GinFulltext(map) => {
2241 if let Value::Text(s) = cell {
2242 for lex in fts_simple::simple_lex(s) {
2243 push_posting(map, lex, row_idx);
2244 }
2245 }
2246 }
2247 IndexKind::GinTrgm(map) => {
2248 if let Value::Text(s) = cell {
2249 for tri in trgm::extract_trigrams(s) {
2250 push_posting(
2251 map,
2252 alloc::string::ToString::to_string(trgm::trigram_str(&tri)),
2253 row_idx,
2254 );
2255 }
2256 }
2257 }
2258 IndexKind::GinJsonb(map) => {
2259 if let Value::Json(s) = cell {
2260 for tok in jsonb_gin::extract_tokens(s) {
2261 push_posting(map, tok, row_idx);
2262 }
2263 }
2264 }
2265 _ => {}
2266 }
2267 }
2268
2269 /// v7.38.18 (S0) — the LOCALE-COLLATED column indexes that are not
2270 /// currently usable, as `(index name, column position, collation)`.
2271 ///
2272 /// Sibling of [`Table::stale_expression_indices`] and consumed by the
2273 /// same refresh: the engine reads the column, encodes each value as
2274 /// an ICU sort key, and hands the keys back to
2275 /// [`Table::rebuild_expression_index`]. The two lists are separate
2276 /// because an expression index names an expression to re-parse and
2277 /// this one names a column and a collation, which is a different
2278 /// question with the same answer shape.
2279 pub fn stale_collated_indices(&self) -> Vec<(String, usize, String)> {
2280 self.indices
2281 .iter()
2282 .filter(|i| matches!(i.kind, IndexKind::BTree(_)))
2283 .filter(|i| !self.expr_index_complete.contains(&i.name))
2284 .filter_map(|i| {
2285 Self::index_collation_in(
2286 &self.schema,
2287 i,
2288 self.db_collation.as_deref().unwrap_or("C"),
2289 )
2290 .map(|c| (i.name.clone(), i.column_position, c.into()))
2291 })
2292 .collect()
2293 }
2294
2295 /// The expression indexes that are not currently usable, with the
2296 /// expression each one keys on. The engine evaluates these per row and
2297 /// hands the results back to [`Table::rebuild_expression_index`].
2298 pub fn stale_expression_indices(&self) -> Vec<(String, String)> {
2299 self.indices
2300 .iter()
2301 .filter(|i| {
2302 matches!(
2303 i.kind,
2304 IndexKind::BTree(_)
2305 | IndexKind::Gin(_)
2306 | IndexKind::GinFulltext(_)
2307 | IndexKind::GinTrgm(_)
2308 | IndexKind::GinJsonb(_)
2309 )
2310 })
2311 .filter_map(|i| i.expression.as_ref().map(|e| (i.name.clone(), e.clone())))
2312 .filter(|(n, _)| !self.expr_index_complete.contains(n))
2313 .collect()
2314 }
2315
2316 /// v7.38.1 (L12) — upgrade a leading-column B-tree that carries
2317 /// `extra_column_positions` into a real multi-column B-tree, in
2318 /// place, keeping every piece of index metadata. Returns `false`
2319 /// (untouched) when the index is not a plain BTree, has no extras,
2320 /// or keys on an expression (whose value is not a column's own).
2321 ///
2322 /// Cold locators block the conversion too: a composite key cannot
2323 /// be derived for a row whose body lives in a cold segment, and
2324 /// silently dropping the entry would drop the row from every seek.
2325 pub fn convert_index_to_multi(&mut self, name: &str) -> Result<bool, StorageError> {
2326 let Some(pos) = self.indices.iter().position(|i| i.name == name) else {
2327 return Ok(false);
2328 };
2329 {
2330 let idx = &self.indices[pos];
2331 if idx.extra_column_positions.is_empty()
2332 || idx.expression.is_some()
2333 || idx.partial_predicate.is_some()
2334 {
2335 return Ok(false);
2336 }
2337 match &idx.kind {
2338 IndexKind::BTree(map) => {
2339 if map.iter().any(|(_, locs)| locs.iter().any(|l| l.is_cold())) {
2340 return Ok(false);
2341 }
2342 }
2343 _ => return Ok(false),
2344 }
2345 }
2346 let column_position = self.indices[pos].column_position;
2347 let extras = self.indices[pos].extra_column_positions.clone();
2348 // Component-type gate: every non-null value of every component
2349 // must key, or rows could silently vanish from the index.
2350 for p in core::iter::once(column_position).chain(extras.iter().copied()) {
2351 match self.schema.columns.get(p) {
2352 Some(col) if crate::multi_component_type_ok(col.ty) => {}
2353 _ => return Ok(false),
2354 }
2355 }
2356 let mut pairs: Vec<(alloc::boxed::Box<[IndexKey]>, usize)> =
2357 Vec::with_capacity(self.rows.len());
2358 for (i, row) in self.rows.iter().enumerate() {
2359 if let Some(key) = crate::compose_multi_key(&row.values, column_position, &extras) {
2360 pairs.push((key, i));
2361 }
2362 }
2363 pairs.sort_by(|a, b| a.0.cmp(&b.0));
2364 let mut grouped: Vec<(alloc::boxed::Box<[IndexKey]>, crate::posting::PostingList)> =
2365 Vec::new();
2366 for (key, i) in pairs {
2367 match grouped.last_mut() {
2368 Some((k, locs)) if *k == key => locs.push(RowLocator::Hot(i)),
2369 _ => grouped.push((key, crate::posting::PostingList::single(RowLocator::Hot(i)))),
2370 }
2371 }
2372 self.indices[pos].kind = IndexKind::BTreeMulti(PersistentBTreeMap::from_sorted(grouped));
2373 Ok(true)
2374 }
2375
2376 /// v7.38.1 (L12) — build a real multi-column B-tree over
2377 /// `[leading, extras…]` from the current rows. The caller supplies
2378 /// resolved column positions; uniqueness and the rest of the
2379 /// index's metadata are applied by the caller afterwards, exactly
2380 /// as `add_index` callers do today.
2381 pub fn add_multi_index(
2382 &mut self,
2383 name: &str,
2384 column_position: usize,
2385 extra_column_positions: Vec<usize>,
2386 ) -> Result<(), StorageError> {
2387 if self.indices.iter().any(|i| i.name == name) {
2388 return Err(StorageError::DuplicateIndex { name: name.into() });
2389 }
2390 if extra_column_positions.is_empty() {
2391 return Err(StorageError::Unsupported(
2392 "add_multi_index: needs at least two columns; use add_index for one".into(),
2393 ));
2394 }
2395 for pos in core::iter::once(column_position).chain(extra_column_positions.iter().copied()) {
2396 match self.schema.columns.get(pos) {
2397 Some(col) if crate::multi_component_type_ok(col.ty) => {}
2398 _ => {
2399 return Err(StorageError::Unsupported(format!(
2400 "add_multi_index: component column {pos} has no total key form"
2401 )));
2402 }
2403 }
2404 }
2405 let mut idx = Index {
2406 extra_column_positions: extra_column_positions.clone(),
2407 ..Index::new_btree_multi(String::from(name), column_position)
2408 };
2409 let mut pairs: Vec<(alloc::boxed::Box<[IndexKey]>, usize)> =
2410 Vec::with_capacity(self.rows.len());
2411 for (i, row) in self.rows.iter().enumerate() {
2412 if let Some(key) =
2413 crate::compose_multi_key(&row.values, column_position, &extra_column_positions)
2414 {
2415 pairs.push((key, i));
2416 }
2417 }
2418 pairs.sort_by(|a, b| a.0.cmp(&b.0));
2419 let mut grouped: Vec<(alloc::boxed::Box<[IndexKey]>, crate::posting::PostingList)> =
2420 Vec::new();
2421 for (key, i) in pairs {
2422 match grouped.last_mut() {
2423 Some((k, locs)) if *k == key => locs.push(RowLocator::Hot(i)),
2424 _ => grouped.push((key, crate::posting::PostingList::single(RowLocator::Hot(i)))),
2425 }
2426 }
2427 idx.kind = IndexKind::BTreeMulti(PersistentBTreeMap::from_sorted(grouped));
2428 self.indices.push(idx);
2429 Ok(())
2430 }
2431
2432 /// v6.7.1 — public restore counterpart for BRIN indices. Used
2433 /// by `Catalog::deserialize` when a v10 snapshot carries a
2434 /// BRIN index entry. BRIN carries no in-memory data — only the
2435 /// `column_type` snapshot is restored.
2436 pub fn restore_brin_index(
2437 &mut self,
2438 name: String,
2439 column_name: &str,
2440 column_type: DataType,
2441 ) -> Result<(), StorageError> {
2442 if self.indices.iter().any(|i| i.name == name) {
2443 return Err(StorageError::DuplicateIndex { name });
2444 }
2445 let column_position = self.schema.column_position(column_name).ok_or_else(|| {
2446 StorageError::ColumnNotFound {
2447 column: column_name.into(),
2448 }
2449 })?;
2450 self.indices
2451 .push(Index::new_brin(name, column_position, column_type));
2452 Ok(())
2453 }
2454
2455 /// v6.7.1 — public CREATE INDEX counterpart for BRIN. Creates
2456 /// the index entry with a snapshot of the indexed column's
2457 /// current `DataType`.
2458 pub fn add_brin_index(&mut self, name: String, column_name: &str) -> Result<(), StorageError> {
2459 if self.indices.iter().any(|i| i.name == name) {
2460 return Err(StorageError::DuplicateIndex { name });
2461 }
2462 let column_position = self.schema.column_position(column_name).ok_or_else(|| {
2463 StorageError::ColumnNotFound {
2464 column: column_name.into(),
2465 }
2466 })?;
2467 let column_type = self.schema.columns[column_position].ty;
2468 self.indices
2469 .push(Index::new_brin(name, column_position, column_type));
2470 Ok(())
2471 }
2472
2473 /// v7.12.3 — Build a new GIN inverted index over a `tsvector`
2474 /// column. Populates posting lists from existing rows. Errors
2475 /// if the column doesn't exist, isn't `TsVector`, or the index
2476 /// name is taken.
2477 /// A GIN index whose entries come from an EXPRESSION, not from a
2478 /// column's own cell.
2479 ///
2480 /// `anchor_column` only gives the index a well-formed catalog
2481 /// position; its type is deliberately not checked, because the
2482 /// expression's type is what decides the posting-list shape and the
2483 /// anchor is typically the TEXT column the expression reads. The map
2484 /// starts empty and `Table::rebuild_expression_index` fills it.
2485 pub fn add_gin_index_on_expression(
2486 &mut self,
2487 name: String,
2488 anchor_column: &str,
2489 ) -> Result<(), StorageError> {
2490 if self.indices.iter().any(|i| i.name == name) {
2491 return Err(StorageError::DuplicateIndex { name });
2492 }
2493 let column_position = self.schema.column_position(anchor_column).ok_or_else(|| {
2494 StorageError::ColumnNotFound {
2495 column: anchor_column.into(),
2496 }
2497 })?;
2498 self.indices.push(Index::new_gin(name, column_position));
2499 Ok(())
2500 }
2501
2502 pub fn add_gin_index(&mut self, name: String, column_name: &str) -> Result<(), StorageError> {
2503 if self.indices.iter().any(|i| i.name == name) {
2504 return Err(StorageError::DuplicateIndex { name });
2505 }
2506 let column_position = self.schema.column_position(column_name).ok_or_else(|| {
2507 StorageError::ColumnNotFound {
2508 column: column_name.into(),
2509 }
2510 })?;
2511 if self.schema.columns[column_position].ty != DataType::TsVector {
2512 return Err(StorageError::Corrupt(format!(
2513 "GIN index {name:?} requires a tsvector column; \
2514 {column_name:?} is {:?}",
2515 self.schema.columns[column_position].ty
2516 )));
2517 }
2518 let mut idx = Index::new_gin(name, column_position);
2519 if let IndexKind::Gin(map) = &mut idx.kind {
2520 for (i, row) in self.rows.iter().enumerate() {
2521 if let Value::TsVector(lexemes) = &row.values[column_position] {
2522 for lex in lexemes {
2523 if let Some(entries) = map.get_mut(&lex.word) {
2524 entries.push(RowLocator::Hot(i));
2525 } else {
2526 map.insert_mut(
2527 lex.word.clone(),
2528 crate::posting::PostingList::single(RowLocator::Hot(i)),
2529 );
2530 }
2531 }
2532 }
2533 }
2534 }
2535 self.indices.push(idx);
2536 Ok(())
2537 }
2538
2539 /// v7.12.3 — Restore a GIN index from a deserialised snapshot.
2540 /// Mirrors [`Self::restore_btree_index`] but takes the GIN's
2541 /// `word → Vec<RowLocator>` posting-list map (already populated
2542 /// from the catalog stream) instead of an `IndexKey` map.
2543 pub fn restore_gin_index(
2544 &mut self,
2545 name: String,
2546 column_name: &str,
2547 map: PersistentBTreeMap<String, crate::posting::PostingList>,
2548 ) -> Result<(), StorageError> {
2549 if self.indices.iter().any(|i| i.name == name) {
2550 return Err(StorageError::DuplicateIndex { name });
2551 }
2552 let column_position = self.schema.column_position(column_name).ok_or_else(|| {
2553 StorageError::ColumnNotFound {
2554 column: column_name.into(),
2555 }
2556 })?;
2557 let mut idx = Index::new_gin(name, column_position);
2558 idx.kind = IndexKind::Gin(map);
2559 self.indices.push(idx);
2560 Ok(())
2561 }
2562
2563 /// v7.15.0 — `gin_trgm_ops` GIN over a TEXT column. Walks
2564 /// every row, shingles the cell into PG-compatible trigrams,
2565 /// and builds the posting-list map. NULL / non-TEXT cells
2566 /// contribute nothing (no trigrams).
2567 pub fn add_gin_trgm_index(
2568 &mut self,
2569 name: String,
2570 column_name: &str,
2571 ) -> Result<(), StorageError> {
2572 if self.indices.iter().any(|i| i.name == name) {
2573 return Err(StorageError::DuplicateIndex { name });
2574 }
2575 let column_position = self.schema.column_position(column_name).ok_or_else(|| {
2576 StorageError::ColumnNotFound {
2577 column: column_name.into(),
2578 }
2579 })?;
2580 if !matches!(
2581 self.schema.columns[column_position].ty,
2582 DataType::Text | DataType::Varchar(_)
2583 ) {
2584 return Err(StorageError::Corrupt(format!(
2585 "trigram-GIN index {name:?} requires a TEXT/VARCHAR column; \
2586 {column_name:?} is {:?}",
2587 self.schema.columns[column_position].ty
2588 )));
2589 }
2590 let mut idx = Index::new_gin_trgm(name, column_position);
2591 if let IndexKind::GinTrgm(map) = &mut idx.kind {
2592 for (i, row) in self.rows.iter().enumerate() {
2593 if let Value::Text(s) = &row.values[column_position] {
2594 for tri in trgm::extract_trigrams(s) {
2595 // r1019 — address the String-keyed map with the borrowed
2596 // trigram; allocate one only for a key the map has never
2597 // seen, which after the first rows is rare.
2598 let key = trgm::trigram_str(&tri);
2599 if let Some(entries) = map.get_mut_by(key) {
2600 entries.push(RowLocator::Hot(i));
2601 } else {
2602 map.insert_mut(
2603 alloc::string::ToString::to_string(key),
2604 crate::posting::PostingList::single(RowLocator::Hot(i)),
2605 );
2606 }
2607 }
2608 }
2609 }
2610 }
2611 self.indices.push(idx);
2612 Ok(())
2613 }
2614
2615 /// v7.15.0 — restore a trigram-GIN from its catalog snapshot
2616 /// payload. Mirrors [`Self::restore_gin_index`].
2617 pub fn restore_gin_trgm_index(
2618 &mut self,
2619 name: String,
2620 column_name: &str,
2621 map: PersistentBTreeMap<String, crate::posting::PostingList>,
2622 ) -> Result<(), StorageError> {
2623 if self.indices.iter().any(|i| i.name == name) {
2624 return Err(StorageError::DuplicateIndex { name });
2625 }
2626 let column_position = self.schema.column_position(column_name).ok_or_else(|| {
2627 StorageError::ColumnNotFound {
2628 column: column_name.into(),
2629 }
2630 })?;
2631 let mut idx = Index::new_gin_trgm(name, column_position);
2632 idx.kind = IndexKind::GinTrgm(map);
2633 self.indices.push(idx);
2634 Ok(())
2635 }
2636
2637 /// v7.17.0 Phase 2.2 — MySQL `FULLTEXT KEY` GIN over a TEXT
2638 /// column. Walks every row, tokenises the cell into lower-
2639 /// cased word lexemes (`fts_simple::simple_lex` — same rule
2640 /// as `to_tsvector('simple', text)`), and builds the
2641 /// posting-list map. NULL / non-TEXT cells contribute
2642 /// nothing (no lexemes).
2643 pub fn add_gin_fulltext_index(
2644 &mut self,
2645 name: String,
2646 column_name: &str,
2647 ) -> Result<(), StorageError> {
2648 if self.indices.iter().any(|i| i.name == name) {
2649 return Err(StorageError::DuplicateIndex { name });
2650 }
2651 let column_position = self.schema.column_position(column_name).ok_or_else(|| {
2652 StorageError::ColumnNotFound {
2653 column: column_name.into(),
2654 }
2655 })?;
2656 if !matches!(
2657 self.schema.columns[column_position].ty,
2658 DataType::Text | DataType::Varchar(_)
2659 ) {
2660 return Err(StorageError::Corrupt(format!(
2661 "fulltext-GIN index {name:?} requires a TEXT/VARCHAR column; \
2662 {column_name:?} is {:?}",
2663 self.schema.columns[column_position].ty
2664 )));
2665 }
2666 let mut idx = Index::new_gin_fulltext(name, column_position);
2667 if let IndexKind::GinFulltext(map) = &mut idx.kind {
2668 for (i, row) in self.rows.iter().enumerate() {
2669 if let Value::Text(s) = &row.values[column_position] {
2670 for lex in fts_simple::simple_lex(s) {
2671 if let Some(entries) = map.get_mut(&lex) {
2672 entries.push(RowLocator::Hot(i));
2673 } else {
2674 map.insert_mut(
2675 lex,
2676 crate::posting::PostingList::single(RowLocator::Hot(i)),
2677 );
2678 }
2679 }
2680 }
2681 }
2682 }
2683 self.indices.push(idx);
2684 Ok(())
2685 }
2686
2687 /// v7.17.0 Phase 2.2 — restore a fulltext-GIN from its
2688 /// catalog snapshot payload. Mirrors
2689 /// [`Self::restore_gin_trgm_index`].
2690 pub fn restore_gin_fulltext_index(
2691 &mut self,
2692 name: String,
2693 column_name: &str,
2694 map: PersistentBTreeMap<String, crate::posting::PostingList>,
2695 ) -> Result<(), StorageError> {
2696 if self.indices.iter().any(|i| i.name == name) {
2697 return Err(StorageError::DuplicateIndex { name });
2698 }
2699 let column_position = self.schema.column_position(column_name).ok_or_else(|| {
2700 StorageError::ColumnNotFound {
2701 column: column_name.into(),
2702 }
2703 })?;
2704 let mut idx = Index::new_gin_fulltext(name, column_position);
2705 idx.kind = IndexKind::GinFulltext(map);
2706 self.indices.push(idx);
2707 Ok(())
2708 }
2709
2710 /// v7.37.8(sentori Epic 5 P2)— JSONB-GIN over a `Json` /
2711 /// `Jsonb` column. Walks every row, extracts canonical
2712 /// `(path, leaf)` tokens via
2713 /// [`crate::jsonb_gin::extract_tokens`], and builds the
2714 /// posting-list map. NULL or non-Json cells contribute no
2715 /// tokens(`<col> @> <jsonb>` against a NULL row is always
2716 /// false so absence here is correct).
2717 pub fn add_gin_jsonb_index(
2718 &mut self,
2719 name: String,
2720 column_name: &str,
2721 ) -> Result<(), StorageError> {
2722 if self.indices.iter().any(|i| i.name == name) {
2723 return Err(StorageError::DuplicateIndex { name });
2724 }
2725 let column_position = self.schema.column_position(column_name).ok_or_else(|| {
2726 StorageError::ColumnNotFound {
2727 column: column_name.into(),
2728 }
2729 })?;
2730 if !matches!(
2731 self.schema.columns[column_position].ty,
2732 DataType::Json | DataType::Jsonb
2733 ) {
2734 return Err(StorageError::Corrupt(format!(
2735 "JSONB-GIN index {name:?} requires a JSON/JSONB column; \
2736 {column_name:?} is {:?}",
2737 self.schema.columns[column_position].ty
2738 )));
2739 }
2740 let mut idx = Index::new_gin_jsonb(name, column_position);
2741 if let IndexKind::GinJsonb(map) = &mut idx.kind {
2742 for (i, row) in self.rows.iter().enumerate() {
2743 if let Value::Json(s) = &row.values[column_position] {
2744 for tok in jsonb_gin::extract_tokens(s) {
2745 if let Some(entries) = map.get_mut(&tok) {
2746 entries.push(RowLocator::Hot(i));
2747 } else {
2748 map.insert_mut(
2749 tok,
2750 crate::posting::PostingList::single(RowLocator::Hot(i)),
2751 );
2752 }
2753 }
2754 }
2755 }
2756 }
2757 self.indices.push(idx);
2758 Ok(())
2759 }
2760
2761 /// v7.37.8 — restore a JSONB-GIN from its catalog snapshot
2762 /// payload. Mirrors [`Self::restore_gin_fulltext_index`].
2763 pub fn restore_gin_jsonb_index(
2764 &mut self,
2765 name: String,
2766 column_name: &str,
2767 map: PersistentBTreeMap<String, crate::posting::PostingList>,
2768 ) -> Result<(), StorageError> {
2769 if self.indices.iter().any(|i| i.name == name) {
2770 return Err(StorageError::DuplicateIndex { name });
2771 }
2772 let column_position = self.schema.column_position(column_name).ok_or_else(|| {
2773 StorageError::ColumnNotFound {
2774 column: column_name.into(),
2775 }
2776 })?;
2777 let mut idx = Index::new_gin_jsonb(name, column_position);
2778 idx.kind = IndexKind::GinJsonb(map);
2779 self.indices.push(idx);
2780 Ok(())
2781 }
2782
2783 /// v5.1: register cold-tier locators on a `BTree` index. Used
2784 /// after [`Catalog::load_segment_bytes`] to wire every cold-
2785 /// tier row's PK back to its segment so
2786 /// [`Catalog::lookup_by_pk`] can resolve it. Each call
2787 /// appends to the index — keys that already have hot or cold
2788 /// locators keep them. Returns the number of locators
2789 /// registered.
2790 ///
2791 /// Pre-v5.2 (freezer) this is the only path that adds Cold
2792 /// variants to a PB; post-freezer the background freezer
2793 /// thread produces these as a batch under the engine write
2794 /// lock and this API becomes its in-memory primitive.
2795 ///
2796 /// Errors if `index_name` doesn't exist or names an NSW graph
2797 /// (NSW indices don't carry per-key row locators — they're
2798 /// vector-search structures).
2799 pub fn register_cold_locators<I>(
2800 &mut self,
2801 index_name: &str,
2802 locators: I,
2803 ) -> Result<usize, StorageError>
2804 where
2805 I: IntoIterator<Item = (IndexKey, RowLocator)>,
2806 {
2807 let idx = self
2808 .indices
2809 .iter_mut()
2810 .find(|i| i.name == index_name)
2811 .ok_or_else(|| StorageError::Corrupt(format!("index {index_name:?} not found")))?;
2812 let map = match &mut idx.kind {
2813 IndexKind::BTree(map) => map,
2814 IndexKind::Nsw(_)
2815 | IndexKind::Brin { .. }
2816 | IndexKind::Gin(_)
2817 | IndexKind::GinTrgm(_)
2818 | IndexKind::GinFulltext(_)
2819 | IndexKind::GinJsonb(_)
2820 | IndexKind::BTreeMulti(_) => {
2821 return Err(StorageError::Corrupt(format!(
2822 "index {index_name:?} is not BTree; cold locators apply only to BTree indices"
2823 )));
2824 }
2825 };
2826 let mut count = 0usize;
2827 for (key, locator) in locators {
2828 if let Some(entries) = map.get_mut(&key) {
2829 entries.push(locator);
2830 } else {
2831 map.insert_mut(key, crate::posting::PostingList::single(locator));
2832 }
2833 count += 1;
2834 }
2835 Ok(count)
2836 }
2837
2838 /// v7.12.3 — GIN-side parallel to [`Self::register_cold_locators`].
2839 /// Re-attaches `word → cold RowLocator` posting-list entries after
2840 /// the from-rows rebuild loop. Errors when the index doesn't
2841 /// exist or isn't a GIN. Both tsvector-GIN and trigram-GIN
2842 /// variants share posting-list shape (`String → Vec<RowLocator>`),
2843 /// so this helper accepts either.
2844 pub fn register_gin_cold_locators<I>(
2845 &mut self,
2846 index_name: &str,
2847 locators: I,
2848 ) -> Result<usize, StorageError>
2849 where
2850 I: IntoIterator<Item = (String, RowLocator)>,
2851 {
2852 let idx = self
2853 .indices
2854 .iter_mut()
2855 .find(|i| i.name == index_name)
2856 .ok_or_else(|| StorageError::Corrupt(format!("index {index_name:?} not found")))?;
2857 let map = match &mut idx.kind {
2858 // v7.17.0 Phase 2.2 — fulltext-GIN posting lists are
2859 // shape-compatible with tsvector / trigram GINs, so
2860 // cold-locator re-attach handles all three.
2861 // v7.37.8 — JSONB-GIN shares the same posting-list shape,
2862 // so it joins the same re-attach path.
2863 IndexKind::Gin(map)
2864 | IndexKind::GinTrgm(map)
2865 | IndexKind::GinFulltext(map)
2866 | IndexKind::GinJsonb(map) => map,
2867 IndexKind::BTree(_)
2868 | IndexKind::Nsw(_)
2869 | IndexKind::Brin { .. }
2870 | IndexKind::BTreeMulti(_) => {
2871 return Err(StorageError::Corrupt(format!(
2872 "register_gin_cold_locators: index {index_name:?} is not GIN"
2873 )));
2874 }
2875 };
2876 let mut count = 0usize;
2877 for (word, locator) in locators {
2878 if let Some(entries) = map.get_mut(&word) {
2879 entries.push(locator);
2880 } else {
2881 map.insert_mut(word, crate::posting::PostingList::single(locator));
2882 }
2883 count += 1;
2884 }
2885 Ok(count)
2886 }
2887
2888 /// v5.2.3: remove every `Cold` locator currently registered on
2889 /// `index_name` under the given `key`. `Hot` locators for the
2890 /// same key are left in place — useful when a row has just been
2891 /// promoted hot-side and the caller wants the old Cold pointer
2892 /// retired without losing the new hot entry.
2893 ///
2894 /// Returns the number of cold locators removed (0 when the key
2895 /// has only hot entries or the key isn't present at all).
2896 /// Errors when the index doesn't exist or isn't a `BTree`.
2897 pub fn remove_cold_locators_for_key(
2898 &mut self,
2899 index_name: &str,
2900 key: &IndexKey,
2901 ) -> Result<usize, StorageError> {
2902 let idx = self
2903 .indices
2904 .iter_mut()
2905 .find(|i| i.name == index_name)
2906 .ok_or_else(|| {
2907 StorageError::Corrupt(format!(
2908 "remove_cold_locators_for_key: index {index_name:?} not found"
2909 ))
2910 })?;
2911 let map = match &mut idx.kind {
2912 IndexKind::BTree(map) => map,
2913 IndexKind::Nsw(_)
2914 | IndexKind::Brin { .. }
2915 | IndexKind::Gin(_)
2916 | IndexKind::GinTrgm(_)
2917 | IndexKind::GinFulltext(_)
2918 | IndexKind::GinJsonb(_)
2919 | IndexKind::BTreeMulti(_) => {
2920 return Err(StorageError::Corrupt(format!(
2921 "remove_cold_locators_for_key: index {index_name:?} is not BTree; \
2922 cold locators apply only to BTree indices"
2923 )));
2924 }
2925 };
2926 let Some(entries) = map.get(key) else {
2927 return Ok(0);
2928 };
2929 let mut kept: crate::posting::PostingList =
2930 entries.iter().copied().filter(RowLocator::is_hot).collect();
2931 let removed = entries.len() - kept.len();
2932 if removed == 0 {
2933 return Ok(0);
2934 }
2935 // PersistentBTreeMap has no remove API in v5.2; when every
2936 // locator for `key` was Cold, the key keeps an empty Vec
2937 // entry. `Index::lookup_eq` already treats `Some(&[])` and
2938 // `None` as the same empty slice (via `Vec::as_slice`), so
2939 // callers can't distinguish the two. The space cost is one
2940 // empty Vec per shadowed-then-promoted key — bounded and
2941 // recoverable when the future compaction job lands.
2942 map.insert_mut(key.clone(), kept);
2943 Ok(removed)
2944 }
2945
2946 /// v7.13.0 — append a new column to the schema and back-fill
2947 /// every existing row with `fill_value`. Used by the engine's
2948 /// `ALTER TABLE t ADD COLUMN …` handler (mailrs round-5 G1).
2949 /// Indices on existing columns keep working — column positions
2950 /// don't shift since the new column lands at the end — so no
2951 /// index rebuild is needed.
2952 pub fn add_column(&mut self, col: ColumnSchema, fill_value: Value<'static>) {
2953 self.schema.columns.push(col);
2954 let mut new_rows: PersistentVec<Row<'static>> = PersistentVec::new();
2955 for row in self.rows.iter() {
2956 let mut values = row.values.clone();
2957 values.push(fill_value.clone());
2958 new_rows.push_mut(Row::new(values));
2959 }
2960 self.invalidate_expr_indices();
2961 self.rows = new_rows;
2962 }
2963
2964 /// v7.15.0 — replace the partial-index predicate source on
2965 /// the index at slot `idx`. Used by `ALTER TABLE … RENAME
2966 /// COLUMN` after the engine rewrites column-identifier
2967 /// references in the predicate source text. Pure metadata
2968 /// edit; index rows are unaffected (they're keyed by
2969 /// column position, not predicate text).
2970 pub fn set_partial_predicate(&mut self, idx: usize, pred: Option<String>) {
2971 debug_assert!(idx < self.indices.len());
2972 self.indices[idx].partial_predicate = pred;
2973 }
2974
2975 /// v7.15.0 — rename the column at `col_pos` to `new_name`.
2976 /// The on-disk row encoding is positional, so no row rewrite
2977 /// is needed; only the schema's column name changes. Indices,
2978 /// UCs, FKs all key off column positions and are unaffected.
2979 /// Source-text references that hold the column name (CHECK
2980 /// predicates, partial-index predicates, runtime DEFAULT
2981 /// expressions, trigger `UPDATE OF` lists) are rewritten by
2982 /// the engine before this helper is called — the storage
2983 /// layer doesn't depend on `spg-sql` and so can't re-parse the
2984 /// predicate sources itself.
2985 pub fn rename_column(&mut self, col_pos: usize, new_name: &str) {
2986 debug_assert!(col_pos < self.schema.columns.len());
2987 self.schema.columns[col_pos].name = new_name.to_string();
2988 }
2989
2990 /// v7.13.3 — drop the column at `col_pos`. Removes the entry
2991 /// from the schema, the value from every row, any index that
2992 /// references the column (pure drop, not shift), and shifts
2993 /// every remaining index/UC/FK column position that pointed
2994 /// past `col_pos` down by one. Used by `ALTER TABLE t DROP
2995 /// COLUMN <c>` (mailrs round-7 S8). FK dependents on this
2996 /// column must already have been removed by the caller (CASCADE
2997 /// path); the helper assumes only same-column index removal is
2998 /// needed.
2999 pub fn drop_column(&mut self, col_pos: usize) {
3000 debug_assert!(col_pos < self.schema.columns.len());
3001 // v7.39 (round 215) — dropping a column shifts every later column's
3002 // position, which would leave a range-exclusion index pointing at the
3003 // wrong column. Drop the indexes rather than risk a silent-wrong
3004 // probe; enforce falls back to the correct O(n) scan until they are
3005 // rebuilt (`ensure_excl_range_index` from the constraint's updated
3006 // column position).
3007 self.excl_indexes.clear();
3008 // Strip the column from the schema.
3009 self.schema.columns.remove(col_pos);
3010 // Rewrite every row to omit the cell at col_pos.
3011 let mut new_rows: PersistentVec<Row> = PersistentVec::new();
3012 for row in self.rows.iter() {
3013 let mut values = row.values.clone();
3014 if col_pos < values.len() {
3015 values.remove(col_pos);
3016 }
3017 new_rows.push_mut(Row::new(values));
3018 }
3019 self.invalidate_expr_indices();
3020 self.rows = new_rows;
3021 // Drop indices on the column outright; shift the rest.
3022 // v7.38.1 (L12) — an index whose EXTRA columns name the dropped
3023 // one goes too (PG drops dependent indexes with the column).
3024 // Before this, `extra_column_positions` was neither dropped nor
3025 // shifted, so a composite UNIQUE's enforcement silently read
3026 // the wrong columns after any earlier column was dropped.
3027 self.indices.retain(|idx| {
3028 idx.column_position != col_pos && !idx.extra_column_positions.contains(&col_pos)
3029 });
3030 for idx in &mut self.indices {
3031 if idx.column_position > col_pos {
3032 idx.column_position -= 1;
3033 }
3034 // Same shift for any included-columns reference.
3035 for inc in &mut idx.included_columns {
3036 if *inc > col_pos {
3037 *inc -= 1;
3038 }
3039 }
3040 for extra in &mut idx.extra_column_positions {
3041 if *extra > col_pos {
3042 *extra -= 1;
3043 }
3044 }
3045 }
3046 // Shift uniqueness-constraint column positions (and drop
3047 // entries that lose all columns, though that shouldn't
3048 // happen in practice — caller has already CASCADE-removed
3049 // FKs and there's no general CASCADE for UCs).
3050 let mut surviving_ucs: Vec<UniquenessConstraint> = Vec::new();
3051 for mut uc in core::mem::take(&mut self.schema.uniqueness_constraints) {
3052 uc.columns.retain(|&c| c != col_pos);
3053 if uc.columns.is_empty() {
3054 continue;
3055 }
3056 for c in &mut uc.columns {
3057 if *c > col_pos {
3058 *c -= 1;
3059 }
3060 }
3061 surviving_ucs.push(uc);
3062 }
3063 self.schema.uniqueness_constraints = surviving_ucs;
3064 // Shift FK local_columns (parent-pointing column positions
3065 // are off-table and untouched).
3066 for fk in &mut self.schema.foreign_keys {
3067 for c in &mut fk.local_columns {
3068 if *c > col_pos {
3069 *c -= 1;
3070 }
3071 }
3072 }
3073 // Rebuild remaining indices' payload — the column-position
3074 // shift means existing IndexKey entries are still keyed by
3075 // the same column data but the position numbers changed;
3076 // existing key→locator maps stay valid because they're
3077 // keyed by Value not position. The rebuild is conservative
3078 // — same pattern delete_rows uses post-mutation.
3079 self.rebuild_indices();
3080 }
3081
3082 /// v4.4: delete the rows at the given positions in one pass.
3083 /// `positions` must be unique; ordering doesn't matter. Indices
3084 /// are rebuilt from scratch (cheaper than tracking incremental
3085 /// shifts across both B-tree and NSW). Returns the number of
3086 /// rows removed.
3087 /// v7.17.0 Phase 1.3 — wipe every row. Used by REFRESH
3088 /// MATERIALIZED VIEW; same effect as `delete_rows((0..N).into())`
3089 /// but skips the per-position bookkeeping for the all-removed
3090 /// fast path. Indices are rebuilt (empty).
3091 pub fn truncate(&mut self) {
3092 self.invalidate_expr_indices();
3093 self.rows = PersistentVec::new();
3094 // v7.37.15 (Phase A.2) — keep headers lock-step.
3095 self.headers = PersistentVec::new();
3096 // v7.37.15 (Phase C.1) — clear rowids lock-step. `next_rowid`
3097 // is NOT reset: ids stay globally monotonic within the
3098 // relation so a post-truncate insert never reuses a pre-
3099 // truncate id that a stale reference might still name.
3100 self.rowids = PersistentVec::new();
3101 self.hot_bytes = 0;
3102 self.rebuild_indices();
3103 }
3104
3105 pub fn delete_rows(&mut self, positions: &[usize]) -> usize {
3106 // v7.37.15 (Epic W slice 1) — capture the RowIds of the targeted
3107 // rows BEFORE the deletion shifts them out. One id per input
3108 // position (parallel to `positions`), `RowId::UNASSIGNED` for an
3109 // out-of-bounds position. Only pay for it when redo capture is
3110 // on. `writer_version` (xmax) is 0: the deleting TxId is not
3111 // threaded to this layer yet.
3112 let redo_rowids: Vec<crate::row_header::RowId> = if self.redo_log.is_some() {
3113 positions
3114 .iter()
3115 .map(|&p| {
3116 self.rowids()
3117 .get(p)
3118 .copied()
3119 .unwrap_or(crate::row_header::RowId::UNASSIGNED)
3120 })
3121 .collect()
3122 } else {
3123 Vec::new()
3124 };
3125 let removed = self.delete_rows_no_index(positions);
3126 if removed > 0 {
3127 self.rebuild_indices();
3128 // v7.34 — capture row-level redo. Record the input positions
3129 // (replay's `delete_rows` dedups + bounds-filters identically);
3130 // skip a no-op delete so the log stays minimal.
3131 self.record_redo(move |table| RowChange::Delete {
3132 table,
3133 positions: positions.to_vec(),
3134 rowids: redo_rowids,
3135 writer_version: 0,
3136 });
3137 }
3138 removed
3139 }
3140
3141 /// v7.37.5 (mailrs crash-recovery Ask 3) — row-only delete for the
3142 /// WAL-replay batch path: removes the rows + decrements `hot_bytes`,
3143 /// **does NOT** call `rebuild_indices()` and does **NOT** capture
3144 /// redo. The caller is responsible for invoking `rebuild_indices_pub`
3145 /// once after a sequence of `*_no_index` mutations on this table.
3146 /// Skipping the per-call rebuild closes the
3147 /// O(records × rows × indices × log rows) replay blow-up
3148 /// (5000 DELETEs × 100k × 13 × ln 100k ≈ minutes → seconds).
3149 /// Returns the number of rows actually removed (dedup + bounds-
3150 /// filtered identically to `delete_rows`).
3151 pub fn delete_rows_no_index(&mut self, positions: &[usize]) -> usize {
3152 if positions.is_empty() {
3153 return 0;
3154 }
3155 // Mark positions; v4.39: PV has no in-place retain, so we rebuild
3156 // a fresh PV by pushing the survivors. Still O(n log₃₂ n); the
3157 // structural-sharing win shows up at `Catalog::clone()`, not here.
3158 let mut to_remove = alloc::vec![false; self.rows.len()];
3159 let mut removed = 0;
3160 for &p in positions {
3161 if p < to_remove.len() && !to_remove[p] {
3162 to_remove[p] = true;
3163 removed += 1;
3164 }
3165 }
3166 if removed == 0 {
3167 return 0;
3168 }
3169 let mut new_rows: PersistentVec<Row> = PersistentVec::new();
3170 let mut new_headers: PersistentVec<crate::row_header::RowHeader> = PersistentVec::new();
3171 // v7.37.15 (Phase C.1) — survivors carry their stable RowId
3172 // across the compaction so a held lock / redo reference keeps
3173 // naming the same row while its physical slot shifts down.
3174 let mut new_rowids: PersistentVec<crate::row_header::RowId> = PersistentVec::new();
3175 let mut removed_bytes: u64 = 0;
3176 // v7.37.16 (autovacuum) — recount dead survivors: this rebuild
3177 // is the compaction hub (vacuum and physical delete both land
3178 // here), so the incremental counter re-bases exactly.
3179 let mut surviving_dead: u64 = 0;
3180 for (i, row) in self.rows.iter().enumerate() {
3181 if to_remove[i] {
3182 removed_bytes =
3183 removed_bytes.saturating_add(row_body_encoded_len(row, &self.schema) as u64);
3184 } else {
3185 new_rows.push_mut(row.clone());
3186 // v7.37.15 (Phase A.2) — keep headers lock-step.
3187 // Phase C will stamp xmax with the deleting tx's
3188 // id INSTEAD of physically dropping the row; Phase
3189 // A.2 keeps physical-delete semantics so
3190 // serialisation + WAL paths stay identical.
3191 if let Some(h) = self.headers.get(i) {
3192 if h.xmax != crate::row_header::XMAX_ALIVE {
3193 surviving_dead += 1;
3194 }
3195 new_headers.push_mut(*h);
3196 } else {
3197 new_headers.push_mut(crate::row_header::RowHeader::frozen());
3198 }
3199 if let Some(rid) = self.rowids.get(i) {
3200 new_rowids.push_mut(*rid);
3201 } else {
3202 // Should not happen once C.1 is wired everywhere;
3203 // allocate a fresh id as a defensive fallback so
3204 // the lock-step invariant survives a legacy path.
3205 let rid = crate::row_header::RowId(
3206 self.next_rowid
3207 .fetch_add(1, core::sync::atomic::Ordering::Relaxed),
3208 );
3209 new_rowids.push_mut(rid);
3210 }
3211 }
3212 }
3213 self.invalidate_expr_indices();
3214 self.rows = new_rows;
3215 self.headers = new_headers;
3216 self.rowids = new_rowids;
3217 self.hot_bytes = self.hot_bytes.saturating_sub(removed_bytes);
3218 self.dead_rows = surviving_dead;
3219 debug_assert_eq!(
3220 self.rows.len(),
3221 self.headers.len(),
3222 "headers must stay in lock-step with rows after delete_rows_no_index"
3223 );
3224 removed
3225 }
3226
3227 /// v7.37.5 — public alias for the private `rebuild_indices` helper.
3228 /// Used by `Catalog::apply_redo` to coalesce per-record rebuilds
3229 /// across a batch of `RowChange`s into one rebuild per touched table.
3230 pub fn rebuild_indices_pub(&mut self) {
3231 // Rebuilding from `column_position` cannot reproduce an
3232 // expression index's keys; it would refill it with the leading
3233 // column's values, which is the shape this version removed.
3234 self.invalidate_expr_indices();
3235 self.rebuild_indices();
3236 }
3237
3238 /// v7.37.5 (mailrs crash-recovery Ask 3) — replace the table's
3239 /// row vector + `hot_bytes` in one shot, then rebuild every
3240 /// index from the new rows. Used by `Catalog::apply_redo`'s
3241 /// batched run: a contiguous slice of `RowChange`s targeting
3242 /// this table is composed into a final `(PersistentVec<Row>,
3243 /// hot_bytes)` pair via in-memory bookkeeping, then handed to
3244 /// this method ONCE for index regeneration. Replaces N per-
3245 /// record `rebuild_indices` calls with 1 per run.
3246 /// v7.39 (flip crash-replay P0) — like
3247 /// [`Self::set_rows_and_rebuild_indices`] but KEEPS the caller's
3248 /// per-slot RowIds. Redo replay applies one WAL record per
3249 /// statement; reassigning ids between records broke every later
3250 /// record's tombstone targets (they name the ids the crashed
3251 /// process allocated), resurrecting deleted rows. The id
3252 /// allocator advances past every preserved id so post-replay
3253 /// inserts never collide.
3254 pub fn set_rows_and_rebuild_indices_with_rowids(
3255 &mut self,
3256 new_rows: PersistentVec<Row<'static>>,
3257 new_hot_bytes: u64,
3258 rowids: &[crate::row_header::RowId],
3259 headers: &[crate::row_header::RowHeader],
3260 ) {
3261 debug_assert_eq!(new_rows.len(), rowids.len());
3262 debug_assert_eq!(new_rows.len(), headers.len());
3263 let mut new_headers: PersistentVec<crate::row_header::RowHeader> = PersistentVec::new();
3264 let mut new_rowids: PersistentVec<crate::row_header::RowId> = PersistentVec::new();
3265 let mut dead: u64 = 0;
3266 for (rid, h) in rowids.iter().zip(headers) {
3267 // Preserve the caller's header — an earlier replayed WAL
3268 // record's tombstone stamp must survive this record's
3269 // rebuild (per-statement replay re-freezing every header
3270 // resurrected every previously-deleted row).
3271 if h.xmax != crate::row_header::XMAX_ALIVE {
3272 dead += 1;
3273 }
3274 new_headers.push_mut(*h);
3275 let rid = if *rid == crate::row_header::RowId::UNASSIGNED {
3276 crate::row_header::RowId(
3277 self.next_rowid
3278 .fetch_add(1, core::sync::atomic::Ordering::Relaxed),
3279 )
3280 } else {
3281 self.next_rowid
3282 .fetch_max(rid.0 + 1, core::sync::atomic::Ordering::Relaxed);
3283 *rid
3284 };
3285 new_rowids.push_mut(rid);
3286 }
3287 self.invalidate_expr_indices();
3288 self.rows = new_rows;
3289 self.headers = new_headers;
3290 self.rowids = new_rowids;
3291 self.hot_bytes = new_hot_bytes;
3292 self.dead_rows = dead;
3293 debug_assert_eq!(self.rows.len(), self.headers.len());
3294 debug_assert_eq!(self.rows.len(), self.rowids.len());
3295 self.rebuild_indices();
3296 }
3297
3298 pub fn set_rows_and_rebuild_indices(
3299 &mut self,
3300 new_rows: PersistentVec<Row<'static>>,
3301 new_hot_bytes: u64,
3302 ) {
3303 // v7.37.15 (Phase A.2) — synthesise frozen headers for
3304 // the replacement rows. Phase D's catalog snapshot format
3305 // (bumped to V6) will start carrying headers verbatim,
3306 // letting recovery preserve real xmin/xmax instead of
3307 // freezing everything; until then frozen is the safe
3308 // default for replay (all visible to every snapshot).
3309 let mut new_headers: PersistentVec<crate::row_header::RowHeader> = PersistentVec::new();
3310 // v7.37.15 (Phase C.1) — fresh monotonic ids for the
3311 // replacement rows drawn from the relation allocator, so a
3312 // post-replay id never collides with a pre-replay one.
3313 let mut new_rowids: PersistentVec<crate::row_header::RowId> = PersistentVec::new();
3314 for _ in 0..new_rows.len() {
3315 new_headers.push_mut(crate::row_header::RowHeader::frozen());
3316 let rid = crate::row_header::RowId(
3317 self.next_rowid
3318 .fetch_add(1, core::sync::atomic::Ordering::Relaxed),
3319 );
3320 new_rowids.push_mut(rid);
3321 }
3322 self.invalidate_expr_indices();
3323 self.rows = new_rows;
3324 self.headers = new_headers;
3325 self.rowids = new_rowids;
3326 self.hot_bytes = new_hot_bytes;
3327 // All-frozen replacement headers → no dead rows by construction.
3328 self.dead_rows = 0;
3329 debug_assert_eq!(
3330 self.rows.len(),
3331 self.headers.len(),
3332 "headers must stay in lock-step with rows after set_rows_and_rebuild_indices"
3333 );
3334 debug_assert_eq!(
3335 self.rows.len(),
3336 self.rowids.len(),
3337 "rowids must stay in lock-step with rows after set_rows_and_rebuild_indices"
3338 );
3339 self.rebuild_indices();
3340 }
3341
3342 /// v7.37.5 (mailrs crash-recovery Ask 3) — row-only insert for the
3343 /// WAL-replay batch path: pushes the row + bumps `hot_bytes`, and
3344 /// **does NOT** update any index (B-tree, GIN, NSW). The caller is
3345 /// responsible for invoking `rebuild_indices_pub` once after a
3346 /// sequence of `*_no_index` mutations on this table.
3347 /// Schema validation (arity + per-column type compatibility) is
3348 /// applied so a malformed redo log surfaces honestly.
3349 pub fn insert_no_index(&mut self, row: Row<'static>) -> Result<(), StorageError> {
3350 if row.len() != self.schema.columns.len() {
3351 return Err(StorageError::ArityMismatch {
3352 expected: self.schema.columns.len(),
3353 actual: row.len(),
3354 });
3355 }
3356 validate_row_against_schema(&row.values, &self.schema)?;
3357 self.invalidate_expr_indices();
3358 self.hot_bytes = self
3359 .hot_bytes
3360 .saturating_add(row_body_encoded_len(&row, &self.schema) as u64);
3361 self.rows.push_mut(row);
3362 // v7.37.15 (Phase A.2) — keep headers lock-step for the
3363 // WAL replay path. Replay-time headers are frozen because
3364 // pre-V6 envelopes carry no header info; Phase D will
3365 // restore the original xmin/xmax once the V6 catalog
3366 // format ships.
3367 self.headers
3368 .push_mut(crate::row_header::RowHeader::frozen());
3369 // v7.37.15 (Phase C.1) — RowId lock-step for the WAL-replay
3370 // append path.
3371 let rid = self.alloc_rowid();
3372 self.rowids.push_mut(rid);
3373 debug_assert_eq!(
3374 self.rows.len(),
3375 self.headers.len(),
3376 "headers must stay in lock-step with rows after insert_no_index"
3377 );
3378 debug_assert_eq!(
3379 self.rows.len(),
3380 self.rowids.len(),
3381 "rowids must stay in lock-step with rows after insert_no_index"
3382 );
3383 Ok(())
3384 }
3385
3386 /// v7.37.5 (mailrs crash-recovery Ask 3) — row-only update for the
3387 /// WAL-replay batch path: replaces the row at `position` + adjusts
3388 /// `hot_bytes`, and **does NOT** touch any index. Skipping the
3389 /// per-update incremental index work is safe because the trailing
3390 /// `rebuild_indices_pub` regenerates indices from `self.rows` in
3391 /// their final state.
3392 pub fn update_row_no_index(
3393 &mut self,
3394 position: usize,
3395 new_values: Vec<Value<'static>>,
3396 ) -> Result<(), StorageError> {
3397 if position >= self.rows.len() {
3398 return Err(StorageError::Corrupt(alloc::format!(
3399 "update_row_no_index: position {position} out of bounds (rows={})",
3400 self.rows.len()
3401 )));
3402 }
3403 if new_values.len() != self.schema.columns.len() {
3404 return Err(StorageError::ArityMismatch {
3405 expected: self.schema.columns.len(),
3406 actual: new_values.len(),
3407 });
3408 }
3409 validate_row_against_schema(&new_values, &self.schema)?;
3410 let old_row = self
3411 .rows
3412 .get(position)
3413 .expect("position bounds-checked above");
3414 let old_bytes = row_body_encoded_len(old_row, &self.schema) as u64;
3415 let new_row = Row::new(new_values);
3416 let new_bytes = row_body_encoded_len(&new_row, &self.schema) as u64;
3417 self.invalidate_expr_indices();
3418 self.rows = self
3419 .rows
3420 .set(position, new_row)
3421 .expect("position bounds-checked above");
3422 self.hot_bytes = self
3423 .hot_bytes
3424 .saturating_sub(old_bytes)
3425 .saturating_add(new_bytes);
3426 Ok(())
3427 }
3428
3429 /// v4.4: replace the row at `position` with `new_values` (must
3430 /// match the schema arity + types). v7.20: index maintenance is
3431 /// incremental — only indices whose key value changed are
3432 /// touched (B-tree entry move in place; NSW / BRIN / GIN fall
3433 /// back to a full rebuild when their column changed).
3434 pub fn update_row(
3435 &mut self,
3436 position: usize,
3437 new_values: Vec<Value<'static>>,
3438 ) -> Result<(), StorageError> {
3439 if position >= self.rows.len() {
3440 return Err(StorageError::Corrupt(alloc::format!(
3441 "update_row: position {position} out of bounds (rows={})",
3442 self.rows.len()
3443 )));
3444 }
3445 if new_values.len() != self.schema.columns.len() {
3446 return Err(StorageError::ArityMismatch {
3447 expected: self.schema.columns.len(),
3448 actual: new_values.len(),
3449 });
3450 }
3451 // Reuse the per-cell type-compat validation that `insert`
3452 // applies. The body below mirrors that check intentionally —
3453 // factoring it would be more code than the duplication.
3454 for (i, (val, col)) in new_values.iter().zip(&self.schema.columns).enumerate() {
3455 if val.is_null() {
3456 if !col.nullable {
3457 return Err(StorageError::NullInNotNull {
3458 column: col.name.clone(),
3459 });
3460 }
3461 continue;
3462 }
3463 // v7.39 (read01 round 54) — `data_type()` is None for the
3464 // eval-only variants that carry no DataType (RegClass, Composite).
3465 // They are NOT NULL, so `.expect("non-null")` PANICKED on them —
3466 // materialising a CTE like `WITH w AS (SELECT 't'::regclass)` blew
3467 // up the query with an "internal error". Report a clean type
3468 // mismatch instead; the engine coerces these before they get here
3469 // on every path that knows how.
3470 let Some(actual) = val.data_type() else {
3471 // An eval-only value (RegClass carries oid + name, Composite a
3472 // field tuple) has no DataType in the storage lattice. It is
3473 // NOT NULL, so the old `.expect("non-null")` PANICKED — which
3474 // is how `WITH w AS (SELECT 't'::regclass)` blew up with an
3475 // "internal error". Accept it: the value keeps its dual shape
3476 // and downstream comparisons (RegClass vs BigInt oid) handle it.
3477 continue;
3478 };
3479 let compatible = column_accepts(actual, col.ty);
3480 if !compatible {
3481 return Err(StorageError::TypeMismatch {
3482 column: col.name.clone(),
3483 expected: col.ty,
3484 actual,
3485 position: i,
3486 });
3487 }
3488 }
3489 let old_row = self
3490 .rows
3491 .get(position)
3492 .expect("position bounds-checked above");
3493 let old_bytes = row_body_encoded_len(old_row, &self.schema) as u64;
3494 let new_row = Row::new(new_values);
3495 let new_bytes = row_body_encoded_len(&new_row, &self.schema) as u64;
3496 // v7.20 P4 — incremental index maintenance. `rows.set`
3497 // replaces the row in place, so every OTHER row's Hot
3498 // locator stays valid; only indices whose key value
3499 // actually changed at `position` need touching. The
3500 // common OLTP shape (`UPDATE … SET non_indexed_col = …
3501 // WHERE pk = $1`) touches no index at all — pre-v7.20
3502 // this path paid a full rebuild_indices() (O(rows ×
3503 // indices)) per UPDATE, which dominated the profiled
3504 // write cost on a 5k-row table (~1 ms/stmt).
3505 //
3506 // BTree gets an in-place entry move (drop Hot(position)
3507 // from the old key's locator list, append to the new
3508 // key's). NSW graphs / BRIN summaries / GIN posting
3509 // lists have no cheap single-key move — a changed column
3510 // under one of those falls back to the full rebuild.
3511 enum IdxFix {
3512 BTreeMove {
3513 idx_pos: usize,
3514 old_key: Option<IndexKey>,
3515 new_key: Option<IndexKey>,
3516 },
3517 // v7.38.1 (L12) — composite-key move. `None` = the row is
3518 // not in the index on that side (some component unkeyable).
3519 MultiMove {
3520 idx_pos: usize,
3521 old_key: Option<alloc::boxed::Box<[IndexKey]>>,
3522 new_key: Option<alloc::boxed::Box<[IndexKey]>>,
3523 },
3524 FullRebuild,
3525 }
3526 let mut fixes: Vec<IdxFix> = Vec::new();
3527 for (idx_pos, idx) in self.indices.iter().enumerate() {
3528 let col = idx.column_position;
3529 // v7.38.1 (L12) — a multi index moves when ANY component
3530 // changes, so it must be judged on the whole tuple BEFORE
3531 // the leading-column short-circuit below can skip it.
3532 if matches!(idx.kind, IndexKind::BTreeMulti(_)) {
3533 // Cheap pre-check on the raw values — composing two
3534 // boxed key tuples per index per UPDATE is real money
3535 // on write-heavy loads, and most updates touch no key
3536 // component at all.
3537 let component_changed = core::iter::once(col)
3538 .chain(idx.extra_column_positions.iter().copied())
3539 .any(|p| old_row.values.get(p) != new_row.values.get(p));
3540 if component_changed {
3541 let old_key = idx.multi_key_for_row(&old_row.values);
3542 let new_key = idx.multi_key_for_row(&new_row.values);
3543 if old_key != new_key {
3544 fixes.push(IdxFix::MultiMove {
3545 idx_pos,
3546 old_key,
3547 new_key,
3548 });
3549 }
3550 }
3551 continue;
3552 }
3553 let old_v = &old_row.values[col];
3554 let new_v = &new_row.values[col];
3555 if old_v == new_v {
3556 continue;
3557 }
3558 match &idx.kind {
3559 IndexKind::BTree(_) => fixes.push(IdxFix::BTreeMove {
3560 idx_pos,
3561 old_key: IndexKey::from_value(old_v),
3562 new_key: IndexKey::from_value(new_v),
3563 }),
3564 IndexKind::Nsw(_)
3565 | IndexKind::Brin { .. }
3566 | IndexKind::Gin(_)
3567 | IndexKind::GinTrgm(_)
3568 | IndexKind::GinFulltext(_)
3569 | IndexKind::GinJsonb(_)
3570 | IndexKind::BTreeMulti(_) => {
3571 fixes.clear();
3572 fixes.push(IdxFix::FullRebuild);
3573 break;
3574 }
3575 }
3576 }
3577 // v7.39 (round 215) — capture the range-exclusion key move BEFORE the
3578 // in-place `set` consumes `new_row`. A `FullRebuild` (a GIN/NSW/BRIN
3579 // column changed) rebuilds the excl indexes too via `rebuild_indices`,
3580 // so only apply the incremental move on the pure-BTreeMove path.
3581 let excl_has_full = fixes.iter().any(|f| matches!(f, IdxFix::FullRebuild));
3582 let excl_moves: Vec<(usize, Option<(i128, u8)>, Option<(i128, u8)>)> =
3583 if self.excl_indexes.is_empty() || excl_has_full {
3584 Vec::new()
3585 } else {
3586 self.excl_indexes
3587 .iter()
3588 .filter_map(|e| {
3589 let c = e.column_position;
3590 let old_k = old_row.values.get(c).and_then(crate::range_excl_index_key);
3591 let new_k = new_row.values.get(c).and_then(crate::range_excl_index_key);
3592 if old_k == new_k {
3593 None // range bound unchanged — no index touch
3594 } else {
3595 Some((c, old_k, new_k))
3596 }
3597 })
3598 .collect()
3599 };
3600 self.invalidate_expr_indices();
3601 self.rows = self
3602 .rows
3603 .set(position, new_row)
3604 .expect("position bounds-checked above");
3605 self.hot_bytes = self
3606 .hot_bytes
3607 .saturating_sub(old_bytes)
3608 .saturating_add(new_bytes);
3609 // v7.34 — capture row-level redo (after the row is in place; the
3610 // immutable read of the new values is dropped before record_redo's
3611 // mutable borrow, and gated so capture-off pays nothing).
3612 if self.redo_log.is_some() {
3613 let new_row = self
3614 .rows
3615 .get(position)
3616 .map(|r| r.values.clone())
3617 .unwrap_or_default();
3618 // v7.37.15 (Epic W slice 1) — carry the stable RowId of the
3619 // updated row (`position` is bounds-checked above, so the id
3620 // is present). `writer_version` (xmax of the superseded
3621 // tuple) is 0: the writing TxId is not threaded here yet.
3622 let redo_rowid = self
3623 .rowids()
3624 .get(position)
3625 .copied()
3626 .unwrap_or(crate::row_header::RowId::UNASSIGNED);
3627 self.record_redo(|table| RowChange::Update {
3628 table,
3629 pos: position,
3630 new_row,
3631 rowid: redo_rowid,
3632 writer_version: 0,
3633 });
3634 }
3635 for fix in fixes {
3636 match fix {
3637 IdxFix::FullRebuild => {
3638 self.rebuild_indices();
3639 break;
3640 }
3641 IdxFix::BTreeMove {
3642 idx_pos,
3643 old_key,
3644 new_key,
3645 } => {
3646 let IndexKind::BTree(map) = &mut self.indices[idx_pos].kind else {
3647 unreachable!("IdxFix::BTreeMove built from a BTree index");
3648 };
3649 // NULL keys never enter the B-tree (from_value
3650 // returns None), so a None on either side means
3651 // "no entry on that side".
3652 if let Some(k) = old_key
3653 && let Some(locs) = map.get(&k)
3654 {
3655 let mut locs = locs.clone();
3656 locs.retain(|l| l != RowLocator::Hot(position));
3657 // No remove_mut on the persistent map: an
3658 // empty locator list is the tombstone —
3659 // lookup_eq returns an empty slice, and the
3660 // next rebuild_indices() drops the key.
3661 map.insert_mut(k, locs);
3662 }
3663 if let Some(k) = new_key {
3664 if let Some(entries) = map.get_mut(&k) {
3665 entries.push(RowLocator::Hot(position));
3666 } else {
3667 map.insert_mut(
3668 k,
3669 crate::posting::PostingList::single(RowLocator::Hot(position)),
3670 );
3671 }
3672 }
3673 }
3674 // v7.38.1 (L12) — same drop-old/append-new dance over the
3675 // composite key space.
3676 IdxFix::MultiMove {
3677 idx_pos,
3678 old_key,
3679 new_key,
3680 } => {
3681 let IndexKind::BTreeMulti(map) = &mut self.indices[idx_pos].kind else {
3682 unreachable!("IdxFix::MultiMove built from a BTreeMulti index");
3683 };
3684 if let Some(k) = old_key
3685 && let Some(locs) = map.get(&k)
3686 {
3687 let mut locs = locs.clone();
3688 locs.retain(|l| l != RowLocator::Hot(position));
3689 map.insert_mut(k, locs);
3690 }
3691 if let Some(k) = new_key {
3692 if let Some(entries) = map.get_mut(&k) {
3693 entries.push(RowLocator::Hot(position));
3694 } else {
3695 map.insert_mut(
3696 k,
3697 crate::posting::PostingList::single(RowLocator::Hot(position)),
3698 );
3699 }
3700 }
3701 }
3702 }
3703 }
3704 // v7.39 (round 215) — apply the range-exclusion key moves captured
3705 // above (skipped when a FullRebuild already re-emitted every excl
3706 // index). Same shape as the BTreeMove: drop Hot(position) from the
3707 // old key, append it to the new key.
3708 for (col, old_k, new_k) in excl_moves {
3709 let Some(ex) = self
3710 .excl_indexes
3711 .iter_mut()
3712 .find(|e| e.column_position == col)
3713 else {
3714 continue;
3715 };
3716 if let Some(k) = old_k
3717 && let Some(locs) = ex.map.get(&k)
3718 {
3719 let mut locs = locs.clone();
3720 locs.retain(|l| l != RowLocator::Hot(position));
3721 ex.map.insert_mut(k, locs);
3722 }
3723 if let Some(k) = new_k {
3724 if let Some(entries) = ex.map.get_mut(&k) {
3725 entries.push(RowLocator::Hot(position));
3726 } else {
3727 ex.map.insert_mut(
3728 k,
3729 crate::posting::PostingList::single(RowLocator::Hot(position)),
3730 );
3731 }
3732 }
3733 }
3734 Ok(())
3735 }
3736
3737 /// v4.4 helper used by `delete_rows` / `update_row`: discard all
3738 /// index payloads and rebuild from `self.rows`. Cheap enough
3739 /// for typical SPG scale (catalogs in the docker-compose
3740 /// deployment shape are small); the alternative — incremental
3741 /// shift bookkeeping across B-tree + NSW — would be far more
3742 /// invasive than the savings justify.
3743 fn rebuild_indices(&mut self) {
3744 // Rebuilding from `column_position` cannot reproduce an
3745 // expression index's keys; it would refill it with the leading
3746 // column's values, which is the shape this version removed.
3747 self.invalidate_expr_indices();
3748 // v5.2.3: capture every `Cold` locator on every BTree index
3749 // before the rebuild, so the from-rows re-emission below
3750 // (which only produces `Hot` locators) doesn't drop cold-
3751 // tier entries on keys unrelated to the row that changed.
3752 // Pre-v5.2.3 this was a `freeze_oldest_to_cold` worry only
3753 // and the freezer did its own capture-then-reregister; v5.2.3
3754 // promotes that pattern into the base helper because UPDATE
3755 // / DELETE now run rebuild_indices on tables with cold rows.
3756 let preserved_cold: Vec<(String, Vec<(IndexKey, RowLocator)>)> = self
3757 .indices
3758 .iter()
3759 .filter_map(|idx| match &idx.kind {
3760 IndexKind::BTree(map) => {
3761 let cold: Vec<(IndexKey, RowLocator)> = map
3762 .iter()
3763 .flat_map(|(k, locs)| {
3764 locs.iter()
3765 .filter(|l| l.is_cold())
3766 .copied()
3767 .map(move |l| (k.clone(), l))
3768 })
3769 .collect();
3770 if cold.is_empty() {
3771 None
3772 } else {
3773 Some((idx.name.clone(), cold))
3774 }
3775 }
3776 // BRIN / NSW carry no key→locator map. GIN handles
3777 // its own cold preservation below in `preserved_gin_cold`.
3778 // BTreeMulti never receives Cold locators (the freezer
3779 // refuses tables carrying one — see freeze site note).
3780 IndexKind::Nsw(_)
3781 | IndexKind::Brin { .. }
3782 | IndexKind::Gin(_)
3783 | IndexKind::GinTrgm(_)
3784 | IndexKind::GinFulltext(_)
3785 | IndexKind::GinJsonb(_)
3786 | IndexKind::BTreeMulti(_) => None,
3787 })
3788 .collect();
3789
3790 // v7.12.3 — same cold-preservation pattern for GIN's
3791 // `word → Vec<RowLocator>` posting lists. Parallel to the
3792 // BTree pass above (different key type so a separate vec is
3793 // cleaner than a generic merge). v7.15.0: trigram-GIN
3794 // (`gin_trgm_ops`) shares the same posting-list shape, so
3795 // one pass handles both — the `RebuildKind` carries the
3796 // kind tag to drive resurrection.
3797 let preserved_gin_cold: Vec<(String, Vec<(String, RowLocator)>)> = self
3798 .indices
3799 .iter()
3800 .filter_map(|idx| match &idx.kind {
3801 // v7.17.0 Phase 2.2 — fulltext-GIN posting lists
3802 // share the `String → Vec<RowLocator>` shape, so
3803 // cold preservation handles all three GIN flavours
3804 // in one pass.
3805 IndexKind::Gin(map)
3806 | IndexKind::GinTrgm(map)
3807 | IndexKind::GinFulltext(map)
3808 | IndexKind::GinJsonb(map) => {
3809 let cold: Vec<(String, RowLocator)> = map
3810 .iter()
3811 .flat_map(|(w, locs)| {
3812 locs.iter()
3813 .filter(|l| l.is_cold())
3814 .copied()
3815 .map(move |l| (w.clone(), l))
3816 })
3817 .collect();
3818 if cold.is_empty() {
3819 None
3820 } else {
3821 Some((idx.name.clone(), cold))
3822 }
3823 }
3824 IndexKind::BTree(_)
3825 | IndexKind::Nsw(_)
3826 | IndexKind::Brin { .. }
3827 | IndexKind::BTreeMulti(_) => None,
3828 })
3829 .collect();
3830
3831 // v6.7.1 — descriptor needs to capture index kind so the
3832 // rebuild loop can resurrect BTree / NSW / BRIN / GIN exactly
3833 // as they were. (NSW carries m; BRIN carries the column type
3834 // snapshot; BTree / GIN need no extra payload.)
3835 #[derive(Clone)]
3836 enum RebuildKind {
3837 BTree,
3838 // v7.38.1 (L12) — rebuilt from rows over the full column
3839 // tuple, exactly like BTree but with composite keys.
3840 BTreeMulti,
3841 Nsw(usize),
3842 Brin(DataType),
3843 Gin,
3844 GinTrgm,
3845 GinFulltext,
3846 GinJsonb,
3847 }
3848 // v7.39 (round 170) — the descriptor must carry the FULL index
3849 // metadata: the rebuild used to reconstruct via bare
3850 // `Index::new_btree(name, pos)`, silently DROPPING is_unique /
3851 // extra_column_positions / partial_predicate / expression /
3852 // included_columns / nulls_not_distinct — so the first VACUUM
3853 // (or any delete-path rebuild) turned every UNIQUE INDEX into a
3854 // plain one and stopped enforcing it (probe-reproduced:
3855 // duplicate keys inserted silently after VACUUM).
3856 struct RebuildDesc {
3857 name: String,
3858 column_position: usize,
3859 kind: RebuildKind,
3860 is_unique: bool,
3861 extra_column_positions: Vec<usize>,
3862 partial_predicate: Option<String>,
3863 expression: Option<String>,
3864 included_columns: Vec<usize>,
3865 nulls_not_distinct: bool,
3866 // v7.39 (round 537) — carried through a rebuild like the rest.
3867 descending: bool,
3868 nulls_first: Option<bool>,
3869 collation: Option<String>,
3870 }
3871 let descriptors: Vec<RebuildDesc> = self
3872 .indices
3873 .iter()
3874 .map(|idx| {
3875 let kind = match &idx.kind {
3876 IndexKind::Nsw(g) => RebuildKind::Nsw(g.m),
3877 IndexKind::Brin { column_type, .. } => RebuildKind::Brin(*column_type),
3878 IndexKind::BTree(_) => RebuildKind::BTree,
3879 IndexKind::BTreeMulti(_) => RebuildKind::BTreeMulti,
3880 IndexKind::Gin(_) => RebuildKind::Gin,
3881 IndexKind::GinTrgm(_) => RebuildKind::GinTrgm,
3882 IndexKind::GinFulltext(_) => RebuildKind::GinFulltext,
3883 IndexKind::GinJsonb(_) => RebuildKind::GinJsonb,
3884 };
3885 RebuildDesc {
3886 name: idx.name.clone(),
3887 column_position: idx.column_position,
3888 kind,
3889 is_unique: idx.is_unique,
3890 extra_column_positions: idx.extra_column_positions.clone(),
3891 partial_predicate: idx.partial_predicate.clone(),
3892 expression: idx.expression.clone(),
3893 included_columns: idx.included_columns.clone(),
3894 nulls_not_distinct: idx.nulls_not_distinct,
3895 descending: idx.descending,
3896 nulls_first: idx.nulls_first,
3897 collation: idx.collation.clone(),
3898 }
3899 })
3900 .collect();
3901 self.indices.clear();
3902 for desc in descriptors {
3903 let RebuildDesc {
3904 name,
3905 column_position,
3906 kind: rebuild_kind,
3907 is_unique,
3908 extra_column_positions,
3909 partial_predicate,
3910 expression,
3911 included_columns,
3912 nulls_not_distinct,
3913 descending,
3914 nulls_first,
3915 collation,
3916 } = desc;
3917 let pre_len = self.indices.len();
3918 match rebuild_kind {
3919 RebuildKind::Nsw(m) => {
3920 let idx = Index::new_nsw(name, column_position, m);
3921 self.indices.push(idx);
3922 let idx_pos = self.indices.len() - 1;
3923 let row_indices: Vec<usize> = (0..self.rows.len()).collect();
3924 for row_idx in row_indices {
3925 nsw_insert_at(self, idx_pos, row_idx);
3926 }
3927 }
3928 RebuildKind::Brin(column_type) => {
3929 self.indices
3930 .push(Index::new_brin(name, column_position, column_type));
3931 // v7.38.11 — recompute the hot-tier summaries. They
3932 // are derived from the rows, so a rebuild is a
3933 // single pass and can never disagree with what is
3934 // stored; that is also why they are not serialised.
3935 //
3936 // Without this an UPDATE — which lands here, since
3937 // BRIN cannot be repaired in place — would leave the
3938 // summaries empty. That is SAFE (an absent summary
3939 // is never skipped) but it silently turns pruning
3940 // off for the rest of the table's life, which is the
3941 // kind of regression nothing would report.
3942 let idx_pos = self.indices.len() - 1;
3943 let n = self.rows.len();
3944 let mut sums: Vec<Option<(i64, i64)>> =
3945 alloc::vec![None; n.div_ceil(crate::BRIN_RANGE_ROWS)];
3946 let mut cur = self.rows.run_cursor();
3947 for i in 0..n {
3948 let Some(row) = cur.get(i) else { continue };
3949 let Some(v) = row.values.get(column_position) else {
3950 continue;
3951 };
3952 if let Some(k) = crate::brin_scalar(v) {
3953 let r = i / crate::BRIN_RANGE_ROWS;
3954 sums[r] = Some(match sums[r] {
3955 Some((lo, hi)) => (lo.min(k), hi.max(k)),
3956 None => (k, k),
3957 });
3958 }
3959 }
3960 if let crate::IndexKind::Brin { summaries, .. } =
3961 &mut self.indices[idx_pos].kind
3962 {
3963 *summaries = sums;
3964 }
3965 }
3966 RebuildKind::BTree => {
3967 // v7.39 (round 170) — bulk build: collect + sort +
3968 // group + from_sorted. The per-row insert_mut paid a
3969 // path-copy allocation per row per index (~15ms per
3970 // index on a 50k-row VACUUM, the dominant cost).
3971 let mut idx = Index::new_btree(name, column_position);
3972 let mut pairs: Vec<(IndexKey, usize)> = Vec::with_capacity(self.rows.len());
3973 for (i, row) in self.rows.iter().enumerate() {
3974 if let Some(key) = IndexKey::from_value(&row.values[column_position]) {
3975 pairs.push((key, i));
3976 }
3977 }
3978 pairs.sort_by(|a, b| a.0.cmp(&b.0));
3979 let mut grouped: Vec<(IndexKey, crate::posting::PostingList)> = Vec::new();
3980 for (key, i) in pairs {
3981 match grouped.last_mut() {
3982 Some((k, locs)) if *k == key => locs.push(RowLocator::Hot(i)),
3983 _ => grouped.push((
3984 key,
3985 crate::posting::PostingList::single(RowLocator::Hot(i)),
3986 )),
3987 }
3988 }
3989 idx.kind = IndexKind::BTree(
3990 crate::persistent_btree::PersistentBTreeMap::from_sorted(grouped),
3991 );
3992 self.indices.push(idx);
3993 }
3994 // v7.38.1 (L12) — bulk build over the full column tuple.
3995 // Same collect + sort + group + from_sorted shape as the
3996 // BTree arm; a row with any unkeyable component stays out.
3997 RebuildKind::BTreeMulti => {
3998 let mut idx = Index::new_btree_multi(name, column_position);
3999 let mut pairs: Vec<(alloc::boxed::Box<[IndexKey]>, usize)> =
4000 Vec::with_capacity(self.rows.len());
4001 for (i, row) in self.rows.iter().enumerate() {
4002 if let Some(key) = crate::compose_multi_key(
4003 &row.values,
4004 column_position,
4005 &extra_column_positions,
4006 ) {
4007 pairs.push((key, i));
4008 }
4009 }
4010 pairs.sort_by(|a, b| a.0.cmp(&b.0));
4011 let mut grouped: Vec<(
4012 alloc::boxed::Box<[IndexKey]>,
4013 crate::posting::PostingList,
4014 )> = Vec::new();
4015 for (key, i) in pairs {
4016 match grouped.last_mut() {
4017 Some((k, locs)) if *k == key => locs.push(RowLocator::Hot(i)),
4018 _ => grouped.push((
4019 key,
4020 crate::posting::PostingList::single(RowLocator::Hot(i)),
4021 )),
4022 }
4023 }
4024 idx.kind = IndexKind::BTreeMulti(
4025 crate::persistent_btree::PersistentBTreeMap::from_sorted(grouped),
4026 );
4027 self.indices.push(idx);
4028 }
4029 RebuildKind::Gin => {
4030 let mut idx = Index::new_gin(name, column_position);
4031 if let IndexKind::Gin(map) = &mut idx.kind {
4032 for (i, row) in self.rows.iter().enumerate() {
4033 if let Value::TsVector(lexemes) = &row.values[column_position] {
4034 for lex in lexemes {
4035 if let Some(entries) = map.get_mut(&lex.word) {
4036 entries.push(RowLocator::Hot(i));
4037 } else {
4038 map.insert_mut(
4039 lex.word.clone(),
4040 crate::posting::PostingList::single(RowLocator::Hot(i)),
4041 );
4042 }
4043 }
4044 }
4045 }
4046 }
4047 self.indices.push(idx);
4048 }
4049 RebuildKind::GinTrgm => {
4050 let mut idx = Index::new_gin_trgm(name, column_position);
4051 if let IndexKind::GinTrgm(map) = &mut idx.kind {
4052 for (i, row) in self.rows.iter().enumerate() {
4053 if let Value::Text(s) = &row.values[column_position] {
4054 for tri in trgm::extract_trigrams(s) {
4055 // r1019 — address the String-keyed map with the borrowed
4056 // trigram; allocate one only for a key the map has never
4057 // seen, which after the first rows is rare.
4058 let key = trgm::trigram_str(&tri);
4059 if let Some(entries) = map.get_mut_by(key) {
4060 entries.push(RowLocator::Hot(i));
4061 } else {
4062 map.insert_mut(
4063 alloc::string::ToString::to_string(key),
4064 crate::posting::PostingList::single(RowLocator::Hot(i)),
4065 );
4066 }
4067 }
4068 }
4069 }
4070 }
4071 self.indices.push(idx);
4072 }
4073 RebuildKind::GinFulltext => {
4074 // v7.17.0 Phase 2.2 — re-derive the lexeme
4075 // posting list from each TEXT/VARCHAR cell.
4076 // Mirrors the GinTrgm rebuild shape but
4077 // tokenises via `fts_simple::simple_lex`
4078 // (same rule as `to_tsvector('simple')`).
4079 let mut idx = Index::new_gin_fulltext(name, column_position);
4080 if let IndexKind::GinFulltext(map) = &mut idx.kind {
4081 for (i, row) in self.rows.iter().enumerate() {
4082 if let Value::Text(s) = &row.values[column_position] {
4083 for lex in fts_simple::simple_lex(s) {
4084 if let Some(entries) = map.get_mut(&lex) {
4085 entries.push(RowLocator::Hot(i));
4086 } else {
4087 map.insert_mut(
4088 lex,
4089 crate::posting::PostingList::single(RowLocator::Hot(i)),
4090 );
4091 }
4092 }
4093 }
4094 }
4095 }
4096 self.indices.push(idx);
4097 }
4098 RebuildKind::GinJsonb => {
4099 // v7.37.8 — re-derive the JSONB posting list
4100 // from each `Value::Json` cell.
4101 let mut idx = Index::new_gin_jsonb(name, column_position);
4102 if let IndexKind::GinJsonb(map) = &mut idx.kind {
4103 for (i, row) in self.rows.iter().enumerate() {
4104 if let Value::Json(s) = &row.values[column_position] {
4105 for tok in jsonb_gin::extract_tokens(s) {
4106 if let Some(entries) = map.get_mut(&tok) {
4107 entries.push(RowLocator::Hot(i));
4108 } else {
4109 map.insert_mut(
4110 tok,
4111 crate::posting::PostingList::single(RowLocator::Hot(i)),
4112 );
4113 }
4114 }
4115 }
4116 }
4117 }
4118 self.indices.push(idx);
4119 }
4120 }
4121 // v7.39 (round 170) — restore the captured metadata onto
4122 // whatever this arm pushed (see RebuildDesc above).
4123 if let Some(idx) = self.indices.get_mut(pre_len) {
4124 idx.is_unique = is_unique;
4125 idx.extra_column_positions = extra_column_positions;
4126 idx.partial_predicate = partial_predicate;
4127 idx.expression = expression;
4128 idx.included_columns = included_columns;
4129 idx.nulls_not_distinct = nulls_not_distinct;
4130 idx.descending = descending;
4131 idx.nulls_first = nulls_first;
4132 idx.collation = collation;
4133 }
4134 }
4135
4136 // Re-attach preserved cold locators after the from-rows
4137 // rebuild. `register_cold_locators` handles the per-key
4138 // entries-vec append; no key collisions arise because the
4139 // rebuild loop above produced only Hot locators.
4140 for (idx_name, locators) in preserved_cold {
4141 // Errors here would only fire if the index disappeared
4142 // between snapshot and rebuild, which can't happen
4143 // because the rebuild restores the same descriptor set.
4144 let _ = self.register_cold_locators(&idx_name, locators);
4145 }
4146 // v7.12.3 — same for GIN posting-list cold locators.
4147 for (idx_name, locators) in preserved_gin_cold {
4148 let _ = self.register_gin_cold_locators(&idx_name, locators);
4149 }
4150 // v7.39 (round 215) — the range-exclusion indexes address rows by the
4151 // same physical slot, so a compaction that shifted slots invalidates
4152 // their Hot locators too. Re-emit them from the (post-compaction) rows.
4153 if !self.excl_indexes.is_empty() {
4154 self.rebuild_excl_indexes();
4155 }
4156 }
4157
4158 fn add_nsw_index_inner(
4159 &mut self,
4160 name: String,
4161 column_name: &str,
4162 m: usize,
4163 restore: Option<NswGraph>,
4164 ) -> Result<(), StorageError> {
4165 if self.indices.iter().any(|i| i.name == name) {
4166 return Err(StorageError::DuplicateIndex { name });
4167 }
4168 let column_position = self.schema.column_position(column_name).ok_or_else(|| {
4169 StorageError::ColumnNotFound {
4170 column: column_name.into(),
4171 }
4172 })?;
4173 if !matches!(
4174 self.schema.columns[column_position].ty,
4175 DataType::Vector { .. }
4176 ) {
4177 return Err(StorageError::TypeMismatch {
4178 column: column_name.into(),
4179 expected: DataType::Vector {
4180 dim: 0,
4181 encoding: VecEncoding::F32,
4182 },
4183 actual: self.schema.columns[column_position].ty,
4184 position: column_position,
4185 });
4186 }
4187 if let Some(graph) = restore {
4188 self.indices.push(Index {
4189 name,
4190 column_position,
4191 kind: IndexKind::Nsw(graph),
4192 included_columns: Vec::new(),
4193 partial_predicate: None,
4194 expression: None,
4195 is_unique: false,
4196 nulls_not_distinct: false,
4197 descending: false,
4198 nulls_first: None,
4199 collation: None,
4200 extra_column_positions: Vec::new(),
4201 });
4202 return Ok(());
4203 }
4204 let idx = Index::new_nsw(name, column_position, m);
4205 self.indices.push(idx);
4206 let idx_pos = self.indices.len() - 1;
4207 // Bulk-build by walking the existing rows in order — each insert
4208 // sees the partial graph and links into it.
4209 let row_indices: Vec<usize> = (0..self.rows.len()).collect();
4210 for row_idx in row_indices {
4211 nsw_insert_at(self, idx_pos, row_idx);
4212 }
4213 Ok(())
4214 }
4215}
4216
4217/// v7.37.5 (mailrs crash-recovery Ask 3) — per-cell schema-compat
4218/// check shared by `insert_no_index` and `update_row_no_index`. The
4219/// logic mirrors the inline body in `insert` / `update_row` (NULL
4220/// handling, the cross-type compatibility map: TEXT ↔ VARCHAR/CHAR/
4221/// JSON/JSONB, TIMESTAMP ↔ TIMESTAMPTZ, BIT ↔ VARBIT, INET ↔ CIDR,
4222/// NUMERIC scale match).
4223/// v7.39 (round 642/643) — does a value of type `actual` belong in a
4224/// column declared `declared`?
4225///
4226/// This existed in THREE copies — insert, update and the standalone
4227/// row validator — and they had drifted apart in three independent
4228/// places: only insert accepted the `name` pairs, only insert and
4229/// update accepted a bit-to-bit pair with differing typmods, and only
4230/// update accepted a NEGATIVE declared numeric scale. Each omission was
4231/// a hole waiting for a value to reach that path; none was a deliberate
4232/// tightening, so the union below is the rule and all three now ask it.
4233///
4234/// Measured before converging: every shape the three disagreed about
4235/// answers identically to PG18 today, so this fixes nothing observable.
4236/// What it fixes is the next type — adding `xid` in round 640 meant
4237/// remembering to patch three places, and forgetting one would have
4238/// half-wired it.
4239///
4240/// The rule itself: a pair is compatible when the value's storage shape
4241/// is what the column stores. Length and precision contracts are NOT
4242/// checked here — they belong to coercion, which runs first.
4243/// `#[inline]` is not decoration. Extracting this matrix out of its
4244/// three call sites — a change with no semantic content at all — cost
4245/// `SELECT count(*) FROM d WHERE g BETWEEN 10 AND 20` **23x**, 5.8 ms
4246/// to 133 ms over 500 000 rows, reproducibly and outside the panel.
4247/// None of the three callers is on a scan path; taking the matrix out
4248/// of them was enough to move whatever else in this module the row loop
4249/// depends on being inlined. Round 641 learned the same thing about
4250/// `eval::binop::compare`. A refactor that reads as pure structure is
4251/// still a codegen change.
4252#[inline]
4253fn column_accepts(actual: DataType, declared: DataType) -> bool {
4254 if actual == declared {
4255 return true;
4256 }
4257 if matches!(
4258 (actual, declared),
4259 // A NAME column stores a Value::Text: the type identity is the
4260 // schema's and a value can never be one, so both directions.
4261 (
4262 DataType::Text,
4263 DataType::Varchar(_)
4264 | DataType::Char(_)
4265 | DataType::Name
4266 | DataType::Json
4267 | DataType::Jsonb
4268 ) | (DataType::Name, DataType::Text)
4269 // An XID column stores the Value::BigInt a transaction id
4270 // has always been; xid8 has no value of its own at all.
4271 | (DataType::BigInt, DataType::Xid | DataType::Xid8)
4272 | (DataType::Xid | DataType::Xid8, DataType::BigInt)
4273 // v7.39 (round 667) — an OID column likewise stores a plain
4274 // integer. INT is listed as well as BIGINT because a bare
4275 // literal arrives as one: PG takes `INSERT INTO t(o) VALUES
4276 // (42)` into an oid column, and measured, it does NOT take the
4277 // same integer into an xid column ("column is of type xid but
4278 // expression is of type integer"). SPG has been laxer than PG
4279 // on that xid direction since before this round — that is the
4280 // limitation `DataType::Xid8` documents, not something added
4281 // here.
4282 | (
4283 DataType::BigInt | DataType::Int | DataType::SmallInt,
4284 DataType::Oid,
4285 )
4286 | (DataType::Oid, DataType::BigInt | DataType::Int)
4287 // v7.39 (round 694) — `oid[]` rides in a BigIntArray cell, so
4288 // it accepts one either way, exactly as the scalar above does.
4289 | (DataType::BigIntArray | DataType::IntArray, DataType::OidArray)
4290 | (DataType::OidArray, DataType::BigIntArray)
4291 | (DataType::Json | DataType::Jsonb, DataType::Text)
4292 | (DataType::Json, DataType::Jsonb)
4293 | (DataType::Jsonb, DataType::Json)
4294 | (DataType::Timestamp, DataType::Timestamptz)
4295 | (DataType::Timestamptz, DataType::Timestamp)
4296 // BIT / VARBIT share the BitString storage shape; INET /
4297 // CIDR likewise. Same-family pairs with different typmods
4298 // are compatible HERE — the length contract is coercion's.
4299 | (DataType::Bit(_), DataType::BitVarying(_))
4300 | (DataType::BitVarying(_), DataType::Bit(_))
4301 | (DataType::Bit(_), DataType::Bit(_))
4302 | (DataType::BitVarying(_), DataType::BitVarying(_))
4303 | (DataType::Inet, DataType::Cidr)
4304 | (DataType::Cidr, DataType::Inet)
4305 ) {
4306 return true;
4307 }
4308 // NUMERIC carries its own scale in the value while the column
4309 // declares the expected one. An unconstrained `numeric` (the
4310 // precision-0/scale-0 sentinel) takes any scale; a declared
4311 // `numeric(p,s)` needs the rescaled value; and a NEGATIVE declared
4312 // scale stores at display scale 0, having been rounded to a
4313 // multiple of 10^|s|.
4314 matches!(
4315 (actual, declared),
4316 (
4317 DataType::Numeric { scale: a, .. },
4318 DataType::Numeric {
4319 precision: bp,
4320 scale: b,
4321 },
4322 ) if a == b || (bp == 0 && b == 0) || (b < 0 && a == 0)
4323 )
4324}
4325
4326fn validate_row_against_schema(
4327 values: &[Value<'static>],
4328 schema: &TableSchema,
4329) -> Result<(), StorageError> {
4330 for (i, (val, col)) in values.iter().zip(&schema.columns).enumerate() {
4331 if val.is_null() {
4332 if !col.nullable {
4333 return Err(StorageError::NullInNotNull {
4334 column: col.name.clone(),
4335 });
4336 }
4337 continue;
4338 }
4339 // v7.39 (read01 round 54) — see above: no panic on an untyped value.
4340 let Some(actual) = val.data_type() else {
4341 // See above: an eval-only untyped value is accepted, not a panic.
4342 continue;
4343 };
4344 let compatible = column_accepts(actual, col.ty);
4345 if !compatible {
4346 return Err(StorageError::TypeMismatch {
4347 column: col.name.clone(),
4348 expected: col.ty,
4349 actual,
4350 position: i,
4351 });
4352 }
4353 }
4354 Ok(())
4355}
4356
4357/// v6.0.4 — re-encode a single cell to the target `VecEncoding`.
4358/// Used by `Table::rebuild_nsw_index` when ALTER INDEX REBUILD
4359/// includes the optional `WITH (encoding = …)` clause. Round-trip
4360/// goes through f32: `current → Vec<f32> → target`, leaving NULL
4361/// cells untouched. Returns `Unsupported` on a non-vector cell —
4362/// the caller should have rejected the schema before reaching this.
4363fn recode_vector_cell(
4364 cell: Value<'static>,
4365 target: VecEncoding,
4366) -> Result<Value<'static>, StorageError> {
4367 if matches!(cell, Value::Null) {
4368 return Ok(cell);
4369 }
4370 // Step 1 — extract the f32 representation of the source cell.
4371 let as_f32: Vec<f32> = match &cell {
4372 Value::Vector(v) => v.to_vec(),
4373 Value::Sq8Vector(q) => quantize::dequantize(q),
4374 Value::HalfVector(h) => h.to_f32_vec(),
4375 other => {
4376 return Err(StorageError::Unsupported(format!(
4377 "ALTER INDEX REBUILD: cannot recode non-vector cell {:?}",
4378 other.data_type()
4379 )));
4380 }
4381 };
4382 // Step 2 — encode into the target shape. `F32` is the identity
4383 // path (saves one alloc round-trip when the source is already
4384 // F32 — but `Value::Vector(as_f32)` is the right answer
4385 // regardless).
4386 Ok(match target {
4387 VecEncoding::F32 => Value::Vector(Cow::Owned(as_f32)),
4388 VecEncoding::Sq8 => Value::Sq8Vector(quantize::quantize(&as_f32)),
4389 VecEncoding::F16 => Value::HalfVector(halfvec::HalfVector::from_f32_slice(&as_f32)),
4390 })
4391}
4392
4393/// v7.39 (round 562) — a cursor over `Table`'s row headers that holds
4394/// the trie leaf it last descended to.
4395///
4396/// See `Table::header_runs` for why. Ask about ascending positions and
4397/// the descent happens once per 32; ask about scattered ones and it
4398/// happens as often as `position_visible` would have done it.
4399#[derive(Debug)]
4400pub struct HeaderRuns<'a> {
4401 table: &'a Table,
4402 /// `(start, run)` — `run[i - start]` is the header for position `i`.
4403 run: Option<(usize, &'a [crate::row_header::RowHeader])>,
4404}
4405
4406impl HeaderRuns<'_> {
4407 /// Is the row at this position visible to the snapshot?
4408 ///
4409 /// Answers exactly as `Table::position_visible` does — same
4410 /// `SKIP LOCKED` handling, same snapshot rules — and the pins in
4411 /// `e2e_index_only_scan_round560` hold both to it.
4412 pub fn visible(&mut self, idx: usize, snapshot: &crate::snapshot::Snapshot) -> bool {
4413 if let Some((start, run)) = self.run
4414 && idx >= start
4415 && idx - start < run.len()
4416 {
4417 return self.table.header_visible(idx, &run[idx - start], snapshot);
4418 }
4419 let Some((start, run)) = self.table.headers.run_containing(idx) else {
4420 return false;
4421 };
4422 self.run = Some((start, run));
4423 self.table.header_visible(idx, &run[idx - start], snapshot)
4424 }
4425}