spg_storage/snapshot.rs
1//! v7.37.15 (Phase A) — per-statement / per-transaction snapshot.
2//!
3//! A `Snapshot` captures **which other transactions had committed**
4//! at the moment the reader took the snapshot. Combined with the
5//! row's [`crate::row_header::RowHeader`] it answers the central
6//! MVCC question: "should THIS reader see THIS row?"
7//!
8//! ## Compared to PG
9//!
10//! PG `SnapshotData` carries `xmin / xmax / xip[] / xcnt /
11//! suboverflowed / takenDuringRecovery / curcid / speculativeToken /
12//! whenTaken / lsn`. SPG strips that to the four fields the
13//! visibility rule actually consumes:
14//!
15//! - `version` — the upper bound: any row whose `xmin` exceeds
16//! this didn't exist at snapshot time.
17//! - `in_progress` — the bitset of transactions that were ALREADY
18//! ALLOCATED (i.e. `xmin <= version`) but had NOT YET committed
19//! at snapshot time. Their writes are invisible to this reader.
20//! - `oldest_active` — the floor used by vacuum to safely reclaim
21//! tombstones: any row whose `xmax < oldest_active` is dead to
22//! every live snapshot.
23//! - `tx_id` — the reader's OWN transaction id, so the snapshot
24//! can implement the "see your own writes" rule (READ COMMITTED
25//! sees its own UPDATE result).
26//!
27//! That is enough for READ COMMITTED + REPEATABLE READ +
28//! SERIALIZABLE (SSI conflict tracking lives in a sidecar — see
29//! Phase E).
30
31extern crate alloc;
32use alloc::vec::Vec;
33
34use crate::row_header::{HEAP_XMIN_FROZEN, RowHeader, XMAX_ALIVE};
35
36/// v7.37.15 (Phase C.2) — terminal state of a transaction / row
37/// version, as seen by the visibility oracle.
38///
39/// The current [`Snapshot::visible`] rule assumes "not in the
40/// snapshot's `in_progress` set ⟹ committed". That holds while every
41/// write is committed-and-alive or frozen. Once Phase C.3's in-place
42/// write path leaves ABORTED xmin/xmax stamps physically present
43/// (rolled-back or crash-orphaned versions vacuum hasn't reclaimed
44/// yet), a version can be `<= snapshot.version` and `∉ in_progress`
45/// yet aborted — and the two-state rule would wrongly show its rows.
46/// [`Snapshot::visible_with_status`] adds the third state.
47///
48/// The state is **monotonic and immutable once terminal**: a version
49/// only ever moves InProgress → {Committed, Aborted} and never back,
50/// so a live oracle lookup for a version outside the snapshot's frozen
51/// `in_progress` set returns the same answer forever — which is why
52/// the snapshot stays a value type and only the oracle is shared.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum XactStatus {
55 /// Allocated but not yet committed or aborted.
56 InProgress,
57 /// Committed — its inserts are real, its deletes took effect.
58 Committed,
59 /// Rolled back / crash-orphaned — its inserts never happened, its
60 /// deletes never took effect.
61 Aborted,
62}
63
64/// v7.37.15 (Phase C.2) — the visibility oracle: maps a version id to
65/// its terminal [`XactStatus`].
66///
67/// `spg-storage` (`no_std`) defines the contract; the real registry
68/// (a sharded `DashMap<u64, XactStatus>`) lives in `spg-engine` and
69/// implements this. Kept as a trait so the visibility rule stays in
70/// storage next to [`Snapshot::visible`] without storage depending on
71/// the engine's concurrency primitives.
72pub trait XactStatusOracle {
73 /// Terminal status of `version`. Implementations return
74 /// [`XactStatus::Committed`] for any version they no longer track
75 /// (pruned below `oldest_active`, or frozen) — those are, by
76 /// definition, committed-and-old.
77 fn status(&self, version: u64) -> XactStatus;
78}
79
80/// An oracle that reports every version as committed. Equivalent to
81/// the pre-Phase-C.2 two-state world; lets a caller that does not yet
82/// track aborts reuse [`Snapshot::visible_with_status`] and get
83/// behaviour identical to [`Snapshot::visible`].
84#[derive(Debug, Clone, Copy, Default)]
85pub struct AllCommitted;
86
87impl XactStatusOracle for AllCommitted {
88 #[inline]
89 fn status(&self, _version: u64) -> XactStatus {
90 XactStatus::Committed
91 }
92}
93
94/// Compact in-progress set. Stored as a sorted `Vec<u64>` so the
95/// `contains` check is a binary search — O(log n) and zero
96/// allocation per lookup. We expect `n` to be tens at most (the
97/// active transaction count); for that range bsearch beats a
98/// hashset by a wide margin on both wall-clock and cache.
99///
100/// When `n` blows past ~1k (which would mean a runaway leak — every
101/// real OLTP workload caps at a few dozen concurrent writers) we
102/// would consider a roaring-bitmap-style sparse representation;
103/// not needed at v7.37.15.0 scale.
104#[derive(Debug, Clone, Default, PartialEq, Eq)]
105pub struct InProgressSet {
106 sorted: Vec<u64>,
107}
108
109impl InProgressSet {
110 /// Construct from a pre-sorted slice. The caller must verify
111 /// monotonic order; debug builds assert.
112 #[must_use]
113 pub fn from_sorted(sorted: Vec<u64>) -> Self {
114 debug_assert!(
115 sorted.windows(2).all(|w| w[0] < w[1]),
116 "InProgressSet::from_sorted requires strictly monotonic input"
117 );
118 Self { sorted }
119 }
120
121 /// Empty set — no transactions in flight. The default for
122 /// a snapshot taken in a quiescent moment.
123 #[must_use]
124 pub const fn empty() -> Self {
125 Self { sorted: Vec::new() }
126 }
127
128 /// v7.39 (round 518) — the ids, in order. `txid_current_snapshot()`
129 /// renders them as PG's `xip_list`.
130 #[must_use]
131 pub fn ids(&self) -> &[u64] {
132 &self.sorted
133 }
134
135 /// True iff `xid` is one of the in-flight transactions.
136 /// Binary search; O(log n).
137 #[must_use]
138 pub fn contains(&self, xid: u64) -> bool {
139 self.sorted.binary_search(&xid).is_ok()
140 }
141
142 /// Number of in-flight transactions captured.
143 #[must_use]
144 pub fn len(&self) -> usize {
145 self.sorted.len()
146 }
147
148 /// True iff no transactions were in flight at capture time.
149 #[must_use]
150 pub fn is_empty(&self) -> bool {
151 self.sorted.is_empty()
152 }
153}
154
155/// Per-statement / per-transaction snapshot.
156///
157/// Cheap to clone (Vec inside an InProgressSet is the only
158/// non-Copy field; bounded at active-tx count which is small).
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct Snapshot {
161 /// The upper bound. A row whose `xmin` exceeds this is
162 /// in the snapshot's future — invisible.
163 pub version: u64,
164 /// In-flight transactions at snapshot time.
165 pub in_progress: InProgressSet,
166 /// Floor used by vacuum. Any row whose `xmax < oldest_active`
167 /// is dead to every live snapshot.
168 pub oldest_active: u64,
169 /// The reading transaction's OWN id. Used to implement "see
170 /// your own writes" — a row your transaction inserted is
171 /// visible to you even before commit. `0` for non-
172 /// transactional reads (autocommit SELECT).
173 pub tx_id: u64,
174 /// v7.39 (round 297, E3 Phase 1b) — rows a `SKIP LOCKED` pre-pass
175 /// found held by another transaction, as `(relation, row indices)`.
176 ///
177 /// It rides the SNAPSHOT because that is the only channel every row
178 /// source already threads. Adding the filter at individual scan
179 /// sites missed the real path three times running — `index_access`
180 /// alone performs the visibility test in ten places. A row that
181 /// someone else holds is, for this statement, exactly as
182 /// unavailable as a row the snapshot cannot see.
183 pub locked_out: Option<(
184 crate::row_header::RelId,
185 alloc::collections::BTreeSet<usize>,
186 )>,
187}
188
189impl Snapshot {
190 /// A "see everything visible" snapshot — version at the
191 /// current upper-bound u64, in-progress empty. Equivalent to
192 /// the pre-v7.37.15 "Arc-snapshot reads the entire catalog"
193 /// behaviour; useful for phase-A migration where the engine
194 /// doesn't yet track per-tx state.
195 #[must_use]
196 pub const fn unbounded() -> Self {
197 Self {
198 locked_out: None,
199 version: u64::MAX,
200 in_progress: InProgressSet::empty(),
201 oldest_active: u64::MAX,
202 tx_id: 0,
203 }
204 }
205
206 /// Construct from explicit fields. The version cursor and
207 /// in-progress set come from the engine's per-process
208 /// version counter at snapshot time; oldest_active is the
209 /// MIN of every live snapshot's version (vacuum reads it).
210 #[must_use]
211 pub fn new(version: u64, in_progress: InProgressSet, oldest_active: u64, tx_id: u64) -> Self {
212 Self {
213 locked_out: None,
214 version,
215 in_progress,
216 oldest_active,
217 tx_id,
218 }
219 }
220
221 /// Should the row be visible to a reader holding this
222 /// snapshot? The five-step rule mirrors PG's HeapTupleSatisfiesMVCC.
223 ///
224 /// 1. Self-write: if the row's writer is THIS reader's own tx,
225 /// the row is visible (READ COMMITTED sees its own writes).
226 /// 2. xmin in the future: invisible.
227 /// 3. xmin still in-progress: invisible.
228 /// 4. Alive (xmax == ALIVE): visible.
229 /// 5. xmax in the future or in-progress: visible (the delete
230 /// hasn't committed yet from this reader's point of view).
231 /// 6. xmax in the past + committed: invisible (deleted before
232 /// this reader's snapshot).
233 #[must_use]
234 pub fn visible(&self, h: &RowHeader) -> bool {
235 // Step 1: your own writes. A row THIS transaction deleted is
236 // invisible to it (whatever inserted it) — PG's "you don't see
237 // what you deleted"; a row this transaction inserted and has
238 // not deleted is visible. (If `xmin == tx_id` and it isn't the
239 // deleter, `xmax` can only be ALIVE — no other transaction can
240 // delete a row this uncommitted tx inserted.)
241 if self.tx_id != 0 {
242 if h.xmax == self.tx_id {
243 return false;
244 }
245 if h.xmin == self.tx_id {
246 return true;
247 }
248 }
249 // Step 2: future.
250 if h.xmin > self.version {
251 return false;
252 }
253 // Step 3: in-flight at snapshot time.
254 if self.in_progress.contains(h.xmin) {
255 return false;
256 }
257 // Step 4: still alive.
258 if h.xmax == XMAX_ALIVE {
259 return true;
260 }
261 // Step 5: deletion is future or in-flight → still visible.
262 if h.xmax > self.version || self.in_progress.contains(h.xmax) {
263 return true;
264 }
265 // Step 6: deletion committed before our snapshot.
266 false
267 }
268
269 /// v7.37.15 (Phase C.2) — abort-aware visibility. Same as
270 /// [`Self::visible`] but consults a [`XactStatusOracle`] to
271 /// distinguish a *committed* version from an *aborted* one when
272 /// neither is in the snapshot's `in_progress` set. Phase C.3's
273 /// in-place write path needs this: a rolled-back or crash-orphaned
274 /// version leaves its xmin/xmax stamp physically present until
275 /// vacuum reclaims it, and the two-state [`Self::visible`] would
276 /// wrongly treat it as committed.
277 ///
278 /// Two extra branches vs `visible` (marked NEW):
279 /// - xmin aborted → the insert never happened → invisible.
280 /// - xmax aborted → the delete never happened → still visible.
281 ///
282 /// The frozen-and-alive fast path returns before any oracle call,
283 /// so steady-state scans over old data pay no oracle cost —
284 /// preserving the Phase B "≤5% overhead" result. Passing
285 /// [`AllCommitted`] makes this behave exactly like `visible`.
286 #[must_use]
287 pub fn visible_with_status<O: XactStatusOracle + ?Sized>(
288 &self,
289 h: &RowHeader,
290 xact: &O,
291 ) -> bool {
292 // Step 1: your own writes (see `visible` for the rationale). A
293 // row this tx deleted is invisible; a row it inserted and has
294 // not deleted is visible.
295 if self.tx_id != 0 {
296 if h.xmax == self.tx_id {
297 return false;
298 }
299 if h.xmin == self.tx_id {
300 return true;
301 }
302 }
303 // Frozen fast path: frozen + alive is visible to everyone and
304 // never consults the oracle (a frozen xmin is committed-and-old
305 // by definition).
306 if h.flags & HEAP_XMIN_FROZEN != 0 && h.xmax == XMAX_ALIVE {
307 return true;
308 }
309 // Step 2: future.
310 if h.xmin > self.version {
311 return false;
312 }
313 // Step 3: in-flight at snapshot time.
314 if self.in_progress.contains(h.xmin) {
315 return false;
316 }
317 // Step 4 (NEW): xmin aborted → the insert never committed.
318 if xact.status(h.xmin) == XactStatus::Aborted {
319 return false;
320 }
321 // Step 5: still alive.
322 if h.xmax == XMAX_ALIVE {
323 return true;
324 }
325 // Step 6: deletion is future or in-flight → still visible.
326 if h.xmax > self.version || self.in_progress.contains(h.xmax) {
327 return true;
328 }
329 // Step 7 (NEW): xmax aborted → the delete never took effect.
330 if xact.status(h.xmax) == XactStatus::Aborted {
331 return true;
332 }
333 // Step 8: deletion committed before our snapshot.
334 false
335 }
336}
337
338impl Default for Snapshot {
339 fn default() -> Self {
340 Self::unbounded()
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use crate::row_header::RowHeader;
348
349 fn ips(xs: &[u64]) -> InProgressSet {
350 InProgressSet::from_sorted(xs.to_vec())
351 }
352
353 #[test]
354 fn unbounded_snapshot_sees_everything() {
355 let s = Snapshot::unbounded();
356 let frozen = RowHeader::frozen();
357 let alive = RowHeader::alive(7);
358 assert!(s.visible(&frozen));
359 assert!(s.visible(&alive));
360 }
361
362 #[test]
363 fn snapshot_hides_future_writes() {
364 let s = Snapshot::new(100, ips(&[]), 100, 0);
365 let row = RowHeader::alive(150); // written after snapshot
366 assert!(!s.visible(&row));
367 }
368
369 #[test]
370 fn snapshot_hides_in_progress_writes() {
371 let s = Snapshot::new(200, ips(&[50, 60, 70]), 50, 0);
372 let row = RowHeader::alive(60); // tx 60 still in flight
373 assert!(!s.visible(&row));
374 let row2 = RowHeader::alive(55); // tx 55 not in in_progress => committed
375 assert!(s.visible(&row2));
376 }
377
378 #[test]
379 fn snapshot_hides_committed_deletions() {
380 let s = Snapshot::new(200, ips(&[]), 100, 0);
381 let row = RowHeader {
382 xmin: 50,
383 xmax: 100, // deleted before our snapshot
384 flags: 0,
385 };
386 assert!(!s.visible(&row));
387 }
388
389 #[test]
390 fn snapshot_keeps_pending_deletions_visible() {
391 let s = Snapshot::new(200, ips(&[150]), 100, 0);
392 let row = RowHeader {
393 xmin: 50,
394 xmax: 150, // delete by an in-flight tx
395 flags: 0,
396 };
397 assert!(s.visible(&row));
398 }
399
400 #[test]
401 fn reader_sees_its_own_insert_but_not_its_own_delete() {
402 let s = Snapshot::new(100, ips(&[]), 100, 42);
403 // A row I inserted and have not deleted: visible.
404 let own_insert = RowHeader::alive(42);
405 assert!(s.visible(&own_insert));
406 // A row I inserted AND deleted (BEGIN; INSERT; DELETE; SELECT):
407 // invisible — you don't see what you deleted (PG semantics).
408 let own_insert_then_delete = RowHeader {
409 xmin: 42,
410 xmax: 42,
411 flags: 0,
412 };
413 assert!(!s.visible(&own_insert_then_delete));
414 // A committed row I deleted (xmin other, xmax me): invisible.
415 let other_insert_i_deleted = RowHeader {
416 xmin: 7,
417 xmax: 42,
418 flags: 0,
419 };
420 assert!(!s.visible(&other_insert_i_deleted));
421 }
422
423 #[test]
424 fn snapshot_hides_future_deletion_done_by_in_flight_tx() {
425 // Edge: tx 150 in-flight AND xmin = 30 (not in in_progress)
426 // → row is alive to us even though xmax is set.
427 let s = Snapshot::new(200, ips(&[150]), 30, 0);
428 let row = RowHeader {
429 xmin: 30,
430 xmax: 150,
431 flags: 0,
432 };
433 assert!(s.visible(&row));
434 }
435
436 /// Test oracle: every version in the set is Aborted, all others
437 /// Committed. Mirrors what the engine's real registry reports for
438 /// a version whose tx rolled back.
439 struct AbortedSet(alloc::vec::Vec<u64>);
440 impl XactStatusOracle for AbortedSet {
441 fn status(&self, v: u64) -> XactStatus {
442 if self.0.contains(&v) {
443 XactStatus::Aborted
444 } else {
445 XactStatus::Committed
446 }
447 }
448 }
449
450 #[test]
451 fn all_committed_oracle_matches_plain_visible() {
452 // With every version committed, visible_with_status must
453 // agree with visible on a spread of header shapes.
454 let s = Snapshot::new(200, ips(&[150]), 50, 42);
455 let headers = [
456 RowHeader::frozen(),
457 RowHeader::alive(60),
458 RowHeader::alive(250),
459 RowHeader {
460 xmin: 50,
461 xmax: 100,
462 flags: 0,
463 },
464 RowHeader {
465 xmin: 50,
466 xmax: 150,
467 flags: 0,
468 },
469 RowHeader {
470 xmin: 42,
471 xmax: XMAX_ALIVE,
472 flags: 0,
473 },
474 ];
475 for h in &headers {
476 assert_eq!(
477 s.visible(h),
478 s.visible_with_status(h, &AllCommitted),
479 "mismatch on {h:?}"
480 );
481 }
482 }
483
484 #[test]
485 fn aborted_xmin_hides_the_row() {
486 // Row inserted by version 60, which then aborted. It is NOT in
487 // in_progress (it reached a terminal state), so plain visible
488 // would wrongly show it; the oracle hides it.
489 let s = Snapshot::new(200, ips(&[]), 50, 0);
490 let row = RowHeader::alive(60);
491 assert!(s.visible(&row), "two-state rule shows the orphan");
492 assert!(
493 !s.visible_with_status(&row, &AbortedSet(alloc::vec![60])),
494 "abort oracle hides the never-committed insert"
495 );
496 }
497
498 #[test]
499 fn aborted_xmax_revives_the_row() {
500 // Row inserted by (committed) 50, deleted by 90 which aborted.
501 // The delete never took effect → the row is still visible.
502 let s = Snapshot::new(200, ips(&[]), 50, 0);
503 let row = RowHeader {
504 xmin: 50,
505 xmax: 90,
506 flags: 0,
507 };
508 assert!(
509 !s.visible(&row),
510 "two-state rule treats delete as committed"
511 );
512 assert!(
513 s.visible_with_status(&row, &AbortedSet(alloc::vec![90])),
514 "abort oracle keeps the row whose delete was rolled back"
515 );
516 }
517
518 #[test]
519 fn frozen_row_skips_the_oracle() {
520 // A frozen+alive row is visible without ever consulting the
521 // oracle — even a (nonsensical) oracle that would abort xmin=1.
522 struct Panicking;
523 impl XactStatusOracle for Panicking {
524 fn status(&self, _v: u64) -> XactStatus {
525 panic!("oracle must not be consulted for a frozen+alive row");
526 }
527 }
528 let s = Snapshot::new(200, ips(&[]), 50, 0);
529 assert!(s.visible_with_status(&RowHeader::frozen(), &Panicking));
530 }
531
532 #[test]
533 fn in_progress_set_binary_search_correctness() {
534 let s = ips(&[10, 20, 30, 40, 50]);
535 assert!(s.contains(10));
536 assert!(s.contains(30));
537 assert!(s.contains(50));
538 assert!(!s.contains(0));
539 assert!(!s.contains(25));
540 assert!(!s.contains(60));
541 }
542}