Skip to main content

spg_storage/
row_header.rs

1//! v7.37.15 (Phase A) — per-row MVCC visibility header.
2//!
3//! ## Why this exists
4//!
5//! Pre-v7.37.15 SPG's MVCC story was at the **catalog level**:
6//! `CatalogSnapshot` Arc-clones the catalog trie roots, readers see a
7//! coherent prior-committed view, writers serialise. That works for
8//! mailrs / sentori's SELECT-heavy IMAP workload, but caps multi-
9//! writer throughput at "one writer at a time" forever and forces an
10//! Arc clone of the entire catalog on each readonly statement —
11//! mailrs measured a ~50% perf gap vs PG18 traceable to this.
12//!
13//! v7.37.15 adds **per-row** visibility on top of that catalog-level
14//! Arc snapshot model. Both coexist:
15//!
16//! - The Arc snapshot path remains for SELECT-only fast reads — the
17//!   catalog trie clones are still O(1) Arc bumps.
18//! - Each row in the table now carries a `RowHeader { xmin, xmax,
19//!   flags }`; scans filter rows against a `Snapshot { version,
20//!   in_progress }`.
21//! - Writers update `xmin` on insert / `xmax` on delete or update,
22//!   so concurrent writers to different rows no longer block one
23//!   another (granularity = per-row, not per-catalog).
24//!
25//! ## Why u64 instead of PG's 32-bit Xid
26//!
27//! PG carries the historical scars of a 32-bit transaction id — epoch
28//! advancement, FrozenTransactionId, VACUUM FREEZE, the entire
29//! anti-wraparound machinery. SPG starts fresh: `xmin` and `xmax`
30//! are `u64`. At 1ns per transaction (a contemporary CPU's L1 cycle
31//! budget) u64 wraps in 584 years; we explicitly do NOT implement
32//! wraparound handling because we will never hit it.
33//!
34//! ## Layout: parallel `PersistentVec`, not embedded in `Row`
35//!
36//! The header lives in `Table::headers: PersistentVec<RowHeader>`
37//! **parallel** to `Table::rows: PersistentVec<Row<'static>>`. Why
38//! not embedded in the Row struct?
39//!
40//! 1. **Cache locality on visibility-only scans.** A scan that only
41//!    needs to count visible rows (think `SELECT COUNT(*)`) walks
42//!    headers without touching row bodies. With the header inline
43//!    every cache line carries one row's worth of payload; with the
44//!    header in a separate Vec the scan only loads 24-byte headers,
45//!    yielding ~10x throughput on wide-row tables.
46//! 2. **Public API stability.** `pub struct Row { pub values }` is
47//!    the shape every caller — eval / sort / agg / projection —
48//!    already pattern-matches. Adding a header field would break
49//!    every match arm in the codebase for a field most call sites
50//!    don't care about.
51//! 3. **Per-row freeze**. The visibility map (per-segment
52//!    `all_visible` bitmap) is an `&[bool]` slice over headers —
53//!    parallel storage makes the bitmap construction zero-copy.
54//!
55//! ## Backward compatibility
56//!
57//! Rows that come from a pre-v7.37.15 envelope (V1-V5) have no
58//! header on disk. On load, every such row gets a default
59//! `RowHeader::frozen()` (`xmin = 1`, `xmax = 0`,
60//! `flags = HEAP_XMIN_FROZEN`). Visibility checks against any
61//! valid snapshot return `true` — so old data is fully visible to
62//! everyone, matching the pre-v7.37.15 contract.
63
64use core::sync::atomic::{AtomicU64, Ordering};
65
66/// Bit 0 of `RowHeader.flags`: `xmin` is conceptually-`FrozenXid` — the row
67/// existed at process start (loaded from a V1-V5 envelope) and is
68/// unconditionally visible to every snapshot.
69pub const HEAP_XMIN_FROZEN: u8 = 1 << 0;
70/// Bit 1: the row is the head of a HOT chain (v7.37.15 Phase D).
71/// Hot-tier in-place UPDATE optimisation; cold tier never sets this.
72pub const HEAP_HOT_UPDATED: u8 = 1 << 1;
73/// Bit 2: the row is a HOT chain non-head element (v7.37.15 Phase D).
74pub const HEAP_ONLY_TUPLE: u8 = 1 << 2;
75/// Bit 3: the row's `xmax` is conceptually-frozen — the delete is
76/// older than any live snapshot, so vacuum may reclaim it on the
77/// next pass.
78pub const HEAP_XMAX_FROZEN: u8 = 1 << 3;
79
80/// Sentinel value used for `xmax` when the row has NOT been deleted.
81/// PG uses `InvalidTransactionId = 0`; SPG matches.
82pub const XMAX_ALIVE: u64 = 0;
83
84/// Sentinel used for `xmin` on rows loaded from a pre-v7.37.15
85/// envelope. Any non-zero value < every real transaction id works
86/// — we pick 1 (PG uses `FrozenTransactionId = 2`).
87pub const XMIN_FROZEN: u64 = 1;
88
89/// Per-row MVCC visibility header.
90///
91/// 24 bytes after alignment (8 + 8 + 1 + 7 padding). The padding
92/// is intentional: a power-of-two stride keeps array indexing
93/// cheap and matches the cache-line layout PG uses for
94/// `HeapTupleHeaderData`.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96#[repr(C, align(8))]
97pub struct RowHeader {
98    /// Version that wrote this row (= `TxId` of the inserting tx
99    /// at commit time). Compared against the reader's snapshot
100    /// version to decide visibility.
101    ///
102    /// Default `XMIN_FROZEN = 1` on rows loaded from a pre-
103    /// v7.37.15 envelope.
104    pub xmin: u64,
105    /// Version that deleted / updated this row, or `XMAX_ALIVE = 0`
106    /// when still alive. UPDATE writes `xmax` on the old row +
107    /// `xmin` on the new one inside the same transaction.
108    pub xmax: u64,
109    /// Bit-packed flags. See module-level constants. The most
110    /// common state (`xmin frozen, xmax alive`) sets only
111    /// `HEAP_XMIN_FROZEN` so visibility checks can short-circuit
112    /// on the `flags & HEAP_XMIN_FROZEN == HEAP_XMIN_FROZEN &&
113    /// xmax == 0` fast path.
114    pub flags: u8,
115}
116
117impl RowHeader {
118    /// The canonical "frozen, alive" header. Use on rows loaded
119    /// from a pre-v7.37.15 envelope and on rows inserted before
120    /// the writer transaction is assigned a TxId.
121    #[must_use]
122    pub const fn frozen() -> Self {
123        Self {
124            xmin: XMIN_FROZEN,
125            xmax: XMAX_ALIVE,
126            flags: HEAP_XMIN_FROZEN,
127        }
128    }
129
130    /// A header for a row inserted by transaction `xmin`, still
131    /// alive. Default for fresh INSERTs.
132    #[must_use]
133    pub const fn alive(xmin: u64) -> Self {
134        Self {
135            xmin,
136            xmax: XMAX_ALIVE,
137            flags: 0,
138        }
139    }
140
141    /// True iff this header is the all-visible fast path
142    /// (frozen + alive). The visibility-map bit is `true` exactly
143    /// when EVERY header in a segment satisfies this — letting
144    /// scans skip the per-row check entirely for cold segments.
145    #[must_use]
146    pub const fn is_all_visible_fast(&self) -> bool {
147        self.flags & HEAP_XMIN_FROZEN == HEAP_XMIN_FROZEN && self.xmax == XMAX_ALIVE
148    }
149
150    /// Was this row deleted? `false` when `xmax == XMAX_ALIVE`.
151    #[must_use]
152    pub const fn is_deleted(&self) -> bool {
153        self.xmax != XMAX_ALIVE
154    }
155}
156
157impl Default for RowHeader {
158    fn default() -> Self {
159        Self::frozen()
160    }
161}
162
163/// Process-wide monotonic version counter shared by every Database
164/// instance in this process. `RowHeader.xmin / xmax` values + the
165/// reader's `Snapshot.version` all draw from here.
166///
167/// `u64` so we never wrap. Starts at `XMIN_FROZEN + 1 = 2` so a
168/// fresh transaction can never collide with frozen rows.
169///
170/// Process-wide (not per-Database) so concurrent databases in the
171/// same process share a coherent view of "is tx 17 still alive" —
172/// the BitSet `Snapshot.in_progress` keys off the same numbering.
173static GLOBAL_VERSION: AtomicU64 = AtomicU64::new(XMIN_FROZEN + 1);
174
175/// Allocate the next transaction id / row version. Caller stores
176/// it in the row's `xmin` (for insert) or `xmax` (for delete /
177/// update). Threadsafe; no lock involved.
178#[must_use]
179pub fn next_version() -> u64 {
180    GLOBAL_VERSION.fetch_add(1, Ordering::AcqRel)
181}
182
183/// Read the current version cursor without advancing. Snapshots
184/// use this as their `Snapshot.version`.
185#[must_use]
186pub fn current_version() -> u64 {
187    GLOBAL_VERSION.load(Ordering::Acquire)
188}
189
190/// v7.38 — recover the version cursor past a version read off a durable
191/// image. `GLOBAL_VERSION` lives in process memory and restarts at
192/// `XMIN_FROZEN + 1`, but rows persisted by an earlier process carry the
193/// versions *that* process allocated. A fresh process must not hand out a
194/// version any restored row already uses, and — because `Snapshot::visible`
195/// rejects `xmin > version` as "written by a future transaction" — must take
196/// snapshots at a version above every restored `xmin`, or committed rows
197/// silently vanish from reads. The same applies to `xmax`: a delete that
198/// looks like the future would resurrect the deleted row.
199///
200/// This is the version-cursor twin of the `next_rowid` recovery in
201/// `codec::read_mvcc_header_appendix`, and mirrors PG recovering `nextXid`
202/// from `pg_control` rather than restarting the counter at zero.
203///
204/// `XMAX_ALIVE` (0) carries no version and is ignored.
205pub fn observe_persisted_version(v: u64) {
206    if v == XMAX_ALIVE {
207        return;
208    }
209    GLOBAL_VERSION.fetch_max(v.saturating_add(1), Ordering::AcqRel);
210}
211
212/// v7.37.15 (Phase C.1) — stable per-relation row identity.
213///
214/// ## Why a stable id, separate from the physical index
215///
216/// Pre-Phase-C a row was addressed by its **physical index** into
217/// `Table::rows`. That index is invalidated the moment a delete /
218/// vacuum compacts the survivor vec — every surviving row after the
219/// hole shifts down. Physical indices therefore cannot serve as:
220///
221/// 1. a **row-lock key** (Phase C.4: `(RelId, RowId)` must survive
222///    concurrent compaction while a lock is held),
223/// 2. a **HOT-chain pointer** (Phase D: chain head → new version
224///    must not dangle after vacuum),
225/// 3. a **WAL redo identity** (Epic W: `RowChange` UPDATE/DELETE
226///    must name the row by a key that survives replay, not a slot
227///    that shifted — closing the position-fragility caveat on the
228///    `RowChange` doc).
229///
230/// `RowId` is per-relation, monotonic, and **never reused**. It
231/// lives in `Table::rowids: PersistentVec<RowId>` parallel to
232/// `rows` / `headers`, so `rowids[i]` is the stable id of the row
233/// physically at slot `i`. Compaction rebuilds all three vecs
234/// together, so the id travels with the row while the slot shifts.
235///
236/// Phase C.1 introduces the id additively (allocated + kept
237/// lock-step, but indices still address by physical slot); later
238/// phases migrate index locators, the lock table, and the WAL to
239/// address by `RowId`.
240///
241/// `u64`, never wraps (same rationale as `xmin`/`xmax`). Starts at
242/// 1 per relation; 0 is reserved as an "unassigned" sentinel.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
244#[repr(transparent)]
245pub struct RowId(pub u64);
246
247impl RowId {
248    /// The "unassigned" sentinel. A real appended row never gets 0;
249    /// the per-relation allocator starts at 1.
250    pub const UNASSIGNED: RowId = RowId(0);
251}
252
253/// v7.37.15 (Phase C.1) — stable per-catalog relation identity.
254///
255/// ## Why a stable id, separate from the table's `Vec` position
256///
257/// A `Catalog` stores tables in a `Vec<Table>`; a `DROP TABLE`
258/// removes one, shifting every later table's position down. So the
259/// physical `tables[i]` index cannot key:
260///
261/// 1. the **row-lock table** — Phase C.4 keys locks by
262///    `(RelId, RowId)`; a lock held across a concurrent `DROP TABLE`
263///    of an *unrelated* table must keep naming the same relation,
264/// 2. the **`RelationStore` shard map** — Phase C.5 splits the
265///    single catalog latch into a `DashMap<RelId, _>` of per-relation
266///    locks; the key must survive catalog mutation,
267/// 3. a **replication relation mapping** — Epic R maps a change to
268///    its relation by a stable id, not a shifting slot.
269///
270/// `RelId` is per-catalog, monotonic, and **never reused** even after
271/// the table is dropped, so a stale lock / redo reference is
272/// detectable rather than silently aliasing a table that reused the
273/// slot. It pairs with [`RowId`] to form the `(RelId, RowId)` tuple
274/// identity Phase C.4's lock table needs.
275///
276/// Introduced additively in Phase C.1: assigned at `CREATE TABLE`
277/// and stored on the table, but nothing consumes it yet. `u64`,
278/// never wraps; 0 is the `RelId::UNASSIGNED` sentinel, real ids start
279/// at 1.
280#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
281#[repr(transparent)]
282pub struct RelId(pub u64);
283
284impl RelId {
285    /// The "unassigned" sentinel. A table created through
286    /// `Catalog::create_table` always gets a real id ≥ 1; a bare
287    /// `Table::new` (test helpers, interim construction) starts
288    /// unassigned until the catalog stamps it.
289    pub const UNASSIGNED: RelId = RelId(0);
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn frozen_header_is_all_visible() {
298        let h = RowHeader::frozen();
299        assert_eq!(h.xmin, XMIN_FROZEN);
300        assert_eq!(h.xmax, XMAX_ALIVE);
301        assert!(h.is_all_visible_fast());
302        assert!(!h.is_deleted());
303    }
304
305    #[test]
306    fn alive_header_is_not_fast_visible_until_frozen() {
307        let h = RowHeader::alive(7);
308        assert_eq!(h.xmin, 7);
309        assert_eq!(h.xmax, XMAX_ALIVE);
310        // Not on the fast path because HEAP_XMIN_FROZEN not set.
311        // Visibility against a snapshot is decided by the
312        // snapshot-aware path in the engine, not by this fast
313        // check.
314        assert!(!h.is_all_visible_fast());
315        assert!(!h.is_deleted());
316    }
317
318    #[test]
319    fn version_counter_is_monotonic() {
320        let a = next_version();
321        let b = next_version();
322        let c = next_version();
323        assert!(a < b);
324        assert!(b < c);
325        // Reading does not advance.
326        let d = current_version();
327        assert!(d >= c);
328        let e = current_version();
329        assert_eq!(d, e);
330    }
331
332    #[test]
333    fn version_counter_starts_above_frozen() {
334        // First-ever next_version() must return at least
335        // XMIN_FROZEN + 1 so a fresh transaction can never collide
336        // with a frozen row's xmin.
337        let v = current_version();
338        assert!(v > XMIN_FROZEN);
339    }
340
341    #[test]
342    fn deleted_row_header_reports_deletion() {
343        let mut h = RowHeader::alive(7);
344        assert!(!h.is_deleted());
345        h.xmax = 13;
346        assert!(h.is_deleted());
347    }
348
349    #[test]
350    fn observe_persisted_version_advances_cursor_past_restored_rows() {
351        // v7.38 — a restored row's version must never look like the future to a
352        // snapshot this process takes, or `Snapshot::visible` drops it. The
353        // cursor is process-global and monotonic, so observing a large restored
354        // version must leave `current_version()` strictly above it.
355        let restored_xmin = 1_000_000_007u64;
356        observe_persisted_version(restored_xmin);
357        assert!(
358            current_version() > restored_xmin,
359            "cursor must sit above every restored version"
360        );
361        // Idempotent: observing an older version never rewinds the cursor.
362        let after = current_version();
363        observe_persisted_version(42);
364        assert_eq!(current_version(), after);
365        // XMAX_ALIVE is the "not deleted" sentinel, not a version.
366        observe_persisted_version(XMAX_ALIVE);
367        assert_eq!(current_version(), after);
368    }
369}