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}