Skip to main content

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}