reddb_engine/transaction/snapshot.rs
1//! Live optimistic MVCC snapshot, outcome and SSI tracking.
2//!
3//! This in-process state allocates transaction IDs, captures transaction and
4//! statement snapshots, retains vacuum horizons, tracks aborted writers and
5//! detects dangerous serializable read/write structures. The host runtime owns
6//! write conflict validation, WAL publication and recovery of transaction outcomes.
7//! Moving this state into the shared engine does not itself provide durability.
8
9use std::collections::{HashMap, HashSet};
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::Arc;
12
13use super::IsolationLevel;
14use crate::storage::entity_id::EntityId;
15
16/// Default autocommit-xid pool batch size. Each refill reserves this
17/// many xids in a single `fetch_add` so back-to-back autocommit inserts
18/// share one atomic op instead of paying it per row. Sized small to
19/// keep a pristine `peek_next_xid()` close to the truth — VACUUM and
20/// diagnostics observe the allocator high-water mark. Snapshots exclude
21/// reserved-but-unused slots from visibility until a later snapshot.
22pub(crate) const AUTOCOMMIT_POOL_BATCH: u64 = 16;
23
24/// A transaction identifier. Monotonic across the lifetime of the process.
25pub type Xid = u64;
26
27/// Reserved xid meaning "not inside a transaction" — pre-MVCC rows stamp
28/// this value so they stay visible to every snapshot.
29pub const XID_NONE: Xid = 0;
30
31/// Immutable snapshot taken at transaction start or statement start.
32///
33/// Callers evaluate `UnifiedEntity::is_visible(snapshot.xid)` on every
34/// row returned from storage to filter out rows created by concurrent
35/// transactions that hadn't committed when the snapshot was taken.
36#[derive(Debug, Clone)]
37pub struct Snapshot {
38 /// The snapshot's xid — every row with `xmin <= xid` created before
39 /// the snapshot is visible (assuming `xmax` hasn't passed).
40 pub xid: Xid,
41 /// Active transactions and reserved-but-unissued pool xids at capture.
42 /// Their writes must be *hidden* even when `xmin <= xid`, because
43 /// the writer hadn't committed yet from this snapshot's point of view.
44 pub in_progress: HashSet<Xid>,
45}
46
47impl Snapshot {
48 /// Is a row with this xmin/xmax visible under this snapshot?
49 ///
50 /// Delegates to [`super::visibility::is_visible`] — the deep
51 /// module that owns the full MVCC visibility predicate. The
52 /// `aborted` argument is empty here because `Snapshot` does not
53 /// carry the manager-level aborted set; callers that need the
54 /// rolled-back-writer rule should consult [`SnapshotManager`]
55 /// directly, or evolve `Snapshot` to embed an aborted view.
56 pub fn sees(&self, xmin: Xid, xmax: Xid) -> bool {
57 super::visibility::is_visible(xmin, xmax, self.xid, &self.in_progress, &HashSet::new())
58 }
59}
60
61/// Per-transaction state tracked on the runtime while BEGIN/COMMIT/ROLLBACK
62/// is active. Attached to a connection by the runtime Transaction State module.
63#[derive(Debug, Clone)]
64pub struct TxnContext {
65 pub xid: Xid,
66 pub isolation: IsolationLevel,
67 /// Snapshot captured at BEGIN (SnapshotIsolation / Serializable) or
68 /// refreshed per-statement (ReadCommitted).
69 pub snapshot: Snapshot,
70 /// Ordered list of `(savepoint_name, sub_xid)` entries (Phase
71 /// 2.3.2e savepoints). Each SAVEPOINT pushes a freshly-allocated
72 /// xid onto this stack; writes stamp xmin/xmax with the top entry
73 /// so ROLLBACK TO SAVEPOINT can mark only those writes as aborted.
74 /// RELEASE SAVEPOINT pops the named level plus everything above it
75 /// without aborting — the sub-xids keep their effects and commit
76 /// together with the parent. Empty stack means "writes use `xid`
77 /// directly", matching pre-savepoint behaviour.
78 pub savepoints: Vec<(String, Xid)>,
79 /// Sub-xids popped by `RELEASE SAVEPOINT` that should still commit
80 /// alongside the parent. PG semantics: released subtxns keep their
81 /// writes — they're promoted to parent-visible at COMMIT. Stored
82 /// separately from `savepoints` so their names are gone (cannot be
83 /// rolled back or released again) while their xids remain trackable.
84 pub released_sub_xids: Vec<Xid>,
85}
86
87impl TxnContext {
88 /// Xid new writes in this connection should stamp onto tuples — the
89 /// innermost open savepoint, or the parent xid when no savepoint is
90 /// active.
91 pub fn writer_xid(&self) -> Xid {
92 self.savepoints.last().map(|(_, x)| *x).unwrap_or(self.xid)
93 }
94}
95
96/// Central allocator and liveness tracker.
97///
98/// Uses an atomic counter for xid allocation and a parking_lot-guarded
99/// HashSet for in-progress/aborted bookkeeping. The sets stay small —
100/// only unfinished transactions plus a finite rollback history — so a
101/// plain HashSet outperforms more complex data structures here.
102///
103/// # Autocommit xid pool
104///
105/// Single-row autocommit writes (`MutationEngine::append_one`) need an
106/// xid that's "born committed" — they call `begin()` then `commit()`
107/// back-to-back before the row is even durable. The pre-commit pool
108/// (`autocommit_pool_*`) batches the reservation: one
109/// `next_xid.fetch_add(BATCH)` reserves a contiguous range of xids,
110/// each handed out via a single atomic without touching the
111/// `RwLock<ManagerState>`. Pool xids are never inserted into `active`
112/// or `aborted` so they look like already-committed transactions to
113/// every snapshot — identical visibility semantics to the legacy
114/// `begin()/commit()` pair (which also leaves the xid in neither set).
115pub struct SnapshotManager {
116 next_xid: AtomicU64,
117 visibility_revision: AtomicU64,
118 state: parking_lot::RwLock<ManagerState>,
119 /// Reservation window for the autocommit pool. A single
120 /// `parking_lot::Mutex` protects two `u64`s — `next` (next xid to
121 /// hand out) and `end` (exclusive upper bound). When `next == end`
122 /// the next caller refills by reserving `AUTOCOMMIT_POOL_BATCH`
123 /// xids in a single `next_xid.fetch_add`, dropping the lock cost
124 /// from one acquire-per-xid (the legacy `begin()`+`commit()` pair)
125 /// to one acquire-per-`AUTOCOMMIT_POOL_BATCH` xids. A plain Mutex
126 /// is enough here — the critical section is two stores and an
127 /// atomic add, and contention is bounded by the writer count.
128 autocommit_pool: parking_lot::Mutex<AutocommitPool>,
129}
130
131pub type LogicalRowKey = (String, EntityId);
132
133#[derive(Default)]
134struct SerializableTxnState {
135 read_set: HashSet<LogicalRowKey>,
136 vault_reads: HashSet<(String, Option<String>, Option<String>)>,
137 incoming_rw: HashSet<Xid>,
138 outgoing_rw: HashSet<Xid>,
139 committed: bool,
140}
141
142#[derive(Default)]
143struct AutocommitPool {
144 next: Xid,
145 end: Xid,
146}
147
148#[derive(Default)]
149struct ManagerState {
150 /// xids that have started but not yet committed/rolled back.
151 active: HashSet<Xid>,
152 /// Oldest version required by each active transaction's captured snapshot.
153 /// A newer reader can exclude an older writer even after that writer commits.
154 snapshot_horizons: HashMap<Xid, Xid>,
155 /// Captured statement frames also need history without an active SQL
156 /// transaction. Counts let nested frames retain the same horizon safely.
157 statement_horizons: HashMap<Xid, usize>,
158 /// xids that rolled back. `is_visible` MUST treat these as invisible
159 /// (the writer never committed). The set is pruned lazily by VACUUM.
160 aborted: HashSet<Xid>,
161 /// A low-level rollback of a non-active xid may invalidate already stored
162 /// rows without a new row mutation. Checkpoint caches must validate fully.
163 untracked_visibility_revision: u64,
164 /// xids that must NOT be reclaimed by VACUUM because some higher-level
165 /// object (a VCS commit, a long-lived replica snapshot) still points
166 /// at them. Reference-counted so multiple pins coexist; decrementing
167 /// to zero removes the entry. `prune_aborted` skips any xid present
168 /// here so its row versions stay readable.
169 pinned: HashMap<Xid, u32>,
170 serializable: HashMap<Xid, SerializableTxnState>,
171}
172
173/// Releases a statement's history requirement on every frame exit path.
174pub struct SnapshotRetentionGuard {
175 manager: Arc<SnapshotManager>,
176 horizon: Xid,
177}
178
179impl Drop for SnapshotRetentionGuard {
180 fn drop(&mut self) {
181 let mut state = self.manager.state.write();
182 let count = state
183 .statement_horizons
184 .get_mut(&self.horizon)
185 .expect("a live snapshot retention guard has a registered horizon");
186 if *count == 1 {
187 state.statement_horizons.remove(&self.horizon);
188 } else {
189 *count -= 1;
190 }
191 }
192}
193
194impl SnapshotManager {
195 pub fn new() -> Self {
196 Self {
197 // Start at 1 so xid=0 keeps its pre-MVCC "everyone sees it" meaning.
198 next_xid: AtomicU64::new(1),
199 visibility_revision: AtomicU64::new(0),
200 state: parking_lot::RwLock::new(ManagerState::default()),
201 // Pool starts empty — first caller triggers a refill.
202 autocommit_pool: parking_lot::Mutex::new(AutocommitPool::default()),
203 }
204 }
205
206 /// Allocate a new xid and mark it active. Returns the xid for
207 /// stamping onto `UnifiedEntity::xmin/xmax`.
208 ///
209 /// Allocation and the `active` insert happen together under the state
210 /// write lock. This is load-bearing for first-committer-wins: a
211 /// concurrent snapshot (state read lock) must never observe a
212 /// *lower-numbered* still-running xid as absent from `active`. Were the
213 /// `fetch_add` done outside the lock, a writer that grabbed the smaller
214 /// xid could still be mid-`begin()` while a later transaction snapshots
215 /// a larger xid — the smaller xid would be missing from `in_progress`,
216 /// yet `xmin <= snapshot.xid`, so the snapshot would treat that
217 /// concurrent (later possibly-committed) writer as visible. That both
218 /// leaks an uncommitted row into a peer's read and lets two writers on
219 /// the same row both commit. Serialising allocation under the lock
220 /// guarantees any xid smaller than a taken snapshot's xid is already in
221 /// `active` (or already finished), so `in_progress` is never short a
222 /// concurrent writer.
223 pub fn begin(&self) -> Xid {
224 let mut state = self.state.write();
225 let xid = self.next_xid.fetch_add(1, Ordering::Relaxed);
226 state.active.insert(xid);
227 self.visibility_revision.fetch_add(1, Ordering::Release);
228 xid
229 }
230
231 /// Capture a fresh *read-committed* snapshot at this instant: sees every
232 /// transaction that has committed so far and hides every still-active
233 /// one. Unlike [`snapshot`], the caller supplies no xid — the snapshot
234 /// id is the current allocator high-water mark, read *under the same
235 /// state lock* that [`begin`] holds while it bumps the counter and
236 /// inserts into `active`. That mutual exclusion is what makes the read
237 /// atomic: a concurrent `begin()` is either wholly visible (counter
238 /// bumped AND present in `active`, hence hidden as in-progress) or
239 /// wholly invisible (neither) — never the torn state where its xid sits
240 /// below the high-water mark yet is missing from `active`, which would
241 /// leak an uncommitted writer as visible.
242 ///
243 /// Used for RMW pre-image reads that must always observe the latest
244 /// committed row version rather than a pinned statement snapshot.
245 pub fn fresh_read_snapshot(&self) -> Snapshot {
246 let state = self.state.read();
247 self.fresh_read_snapshot_locked(&state)
248 }
249
250 fn fresh_read_snapshot_locked(&self, state: &ManagerState) -> Snapshot {
251 let pool = self.autocommit_pool.lock();
252 // Visibility uses an inclusive bound. The allocator points at the
253 // next, not the last, xid; including it admits the next future writer.
254 let xid = self.next_xid.load(Ordering::Relaxed).saturating_sub(1);
255 let mut in_progress: HashSet<Xid> = state.active.iter().copied().collect();
256 // Reserved-but-unissued pool slots are future writers even though
257 // their numbers can be below this snapshot's high-water mark.
258 in_progress.extend(pool.next..pool.end);
259 Snapshot { xid, in_progress }
260 }
261
262 /// Capture and register under the same lock used by GC cutoff selection.
263 /// Registering only when the snapshot is installed leaves a capture-to-read
264 /// interval in which a committed writer's old versions can be reclaimed.
265 pub fn fresh_retained_snapshot(self: &Arc<Self>) -> (Snapshot, SnapshotRetentionGuard) {
266 let mut state = self.state.write();
267 let snapshot = self.fresh_read_snapshot_locked(&state);
268 let guard = self.retain_snapshot_locked(&snapshot, &mut state);
269 (snapshot, guard)
270 }
271
272 /// Retain a snapshot already protected by its parent transaction, outer
273 /// statement frame, or historical reference while transferring ownership.
274 pub fn retain_snapshot(
275 self: &Arc<Self>,
276 snapshot: Snapshot,
277 ) -> (Snapshot, SnapshotRetentionGuard) {
278 let mut state = self.state.write();
279 let guard = self.retain_snapshot_locked(&snapshot, &mut state);
280 (snapshot, guard)
281 }
282
283 fn retain_snapshot_locked(
284 self: &Arc<Self>,
285 snapshot: &Snapshot,
286 state: &mut ManagerState,
287 ) -> SnapshotRetentionGuard {
288 // Deletes already visible to the snapshot need no old version. An
289 // excluded writer can be older than its inclusive high-water mark.
290 let horizon = snapshot
291 .in_progress
292 .iter()
293 .copied()
294 .fold(snapshot.xid.saturating_add(1), Xid::min);
295 *state.statement_horizons.entry(horizon).or_default() += 1;
296 SnapshotRetentionGuard {
297 manager: Arc::clone(self),
298 horizon,
299 }
300 }
301
302 /// Capture a point-in-time snapshot. Must be called after `begin()`
303 /// when using SnapshotIsolation/Serializable. ReadCommitted refreshes
304 /// this per statement via the same call.
305 pub fn snapshot(&self, xid: Xid) -> Snapshot {
306 let mut state = self.state.write();
307 // Active xids other than our own appear as "in-progress" to us.
308 let pool = self.autocommit_pool.lock();
309 let mut in_progress: HashSet<Xid> =
310 state.active.iter().copied().filter(|&x| x != xid).collect();
311 in_progress.extend(pool.next..pool.end);
312 if state.active.contains(&xid) {
313 let horizon = in_progress.iter().copied().fold(xid, Xid::min);
314 state
315 .snapshot_horizons
316 .entry(xid)
317 .and_modify(|previous| *previous = (*previous).min(horizon))
318 .or_insert(horizon);
319 }
320 Snapshot { xid, in_progress }
321 }
322
323 /// Mark a transaction as committed. Its writes become visible to
324 /// future snapshots; earlier snapshots keep their own view.
325 pub fn commit(&self, xid: Xid) {
326 let mut state = self.state.write();
327 let was_active = state.active.remove(&xid);
328 state.snapshot_horizons.remove(&xid);
329 // Also clear from aborted set in case of prior rollback_to call
330 // that touched this xid (defensive; normally a no-op).
331 let was_aborted = state.aborted.remove(&xid);
332 if was_active || was_aborted {
333 self.visibility_revision.fetch_add(1, Ordering::Release);
334 }
335 if let Some(ssi) = state.serializable.get_mut(&xid) {
336 ssi.committed = true;
337 }
338 Self::prune_serializable_state(&mut state);
339 }
340
341 /// Allocate an xid that is *born committed* — for autocommit
342 /// callers (`MutationEngine::append_one`) that previously paid two
343 /// `state.write()` lock acquisitions per row to insert-then-remove
344 /// from the active set.
345 ///
346 /// The returned xid is never inserted into `active` and never into
347 /// `aborted`, which matches the steady state of the legacy
348 /// `begin()/commit()` pair when called back-to-back: the xid leaves
349 /// the manager's tracking sets unobservably. Concurrent readers
350 /// therefore see it as an already-committed transaction once
351 /// `xmin <= snapshot.xid`, which is exactly the semantics the
352 /// autocommit path needs.
353 ///
354 /// Implementation: a small reservation pool (`AUTOCOMMIT_POOL_BATCH`
355 /// xids) is reserved with one `fetch_add`. Each caller hands itself
356 /// the next xid via a single atomic. When the pool drains, the
357 /// next caller serialises briefly through `autocommit_pool_refill`
358 /// to bump the window, then falls back into the lock-free hot path.
359 ///
360 /// Durability note: this method does NOT make the row durable —
361 /// it only allocates the identifier. The caller must complete the
362 /// usual WAL-append + fsync cycle before acknowledging the write.
363 /// Pre-allocating the xid is safe because the xid carries no
364 /// promise that any row exists; it's just a number for `xmin`.
365 pub fn allocate_committed_xid(&self) -> Xid {
366 let mut pool = self.autocommit_pool.lock();
367 if pool.next >= pool.end {
368 // Reserve the next contiguous range. A single
369 // `fetch_add(BATCH)` on the global counter — equivalent to
370 // BATCH back-to-back `begin()` calls in terms of xid
371 // numbering, but with zero `state.write()` traffic.
372 let start = self
373 .next_xid
374 .fetch_add(AUTOCOMMIT_POOL_BATCH, Ordering::Relaxed);
375 pool.next = start;
376 pool.end = start + AUTOCOMMIT_POOL_BATCH;
377 }
378 let xid = pool.next;
379 pool.next += 1;
380 xid
381 }
382
383 /// Mark a transaction as rolled back. Its writes MUST stay hidden
384 /// from every future read — `is_visible` consults the aborted set
385 /// before honouring a row's `xmin`.
386 pub fn rollback(&self, xid: Xid) {
387 let mut state = self.state.write();
388 state.snapshot_horizons.remove(&xid);
389 let was_active = state.active.remove(&xid);
390 let newly_aborted = state.aborted.insert(xid);
391 if newly_aborted && !was_active {
392 state.untracked_visibility_revision = state
393 .untracked_visibility_revision
394 .checked_add(1)
395 .expect("visibility invalidation counter must not overflow");
396 }
397 if was_active || newly_aborted {
398 self.visibility_revision.fetch_add(1, Ordering::Release);
399 }
400 state.serializable.remove(&xid);
401 for ssi in state.serializable.values_mut() {
402 ssi.incoming_rw.remove(&xid);
403 ssi.outgoing_rw.remove(&xid);
404 }
405 Self::prune_serializable_state(&mut state);
406 }
407
408 pub fn begin_serializable(&self, xid: Xid) {
409 self.state.write().serializable.entry(xid).or_default();
410 }
411
412 pub fn record_serializable_read(&self, reader: Xid, collection: &str, logical_id: EntityId) {
413 let mut state = self.state.write();
414 if let Some(ssi) = state.serializable.get_mut(&reader) {
415 ssi.read_set.insert((collection.to_string(), logical_id));
416 }
417 }
418
419 pub fn record_serializable_vault_read(
420 &self,
421 reader: Xid,
422 collection: &str,
423 tenant: Option<&str>,
424 key: Option<&str>,
425 ) {
426 if let Some(ssi) = self.state.write().serializable.get_mut(&reader) {
427 ssi.vault_reads.insert((
428 collection.into(),
429 tenant.map(str::to_owned),
430 key.map(str::to_owned),
431 ));
432 }
433 }
434
435 pub fn serializable_vault_commit_would_be_dangerous(
436 &self,
437 writer: Xid,
438 write_set: &HashSet<(String, Option<String>, String)>,
439 ) -> bool {
440 let mut state = self.state.write();
441 if !state.serializable.contains_key(&writer) {
442 return false;
443 }
444 let readers = state
445 .serializable
446 .iter()
447 .filter_map(|(&reader, ssi)| {
448 (reader != writer
449 && ssi.vault_reads.iter().any(|(collection, tenant, key)| {
450 write_set
451 .iter()
452 .any(|(written_collection, written_tenant, written_key)| {
453 collection == written_collection
454 && tenant == written_tenant
455 && key.as_ref().is_none_or(|key| key == written_key)
456 })
457 }))
458 .then_some(reader)
459 })
460 .collect();
461 Self::serializable_readers_would_be_dangerous(&mut state, writer, readers)
462 }
463
464 pub fn serializable_commit_would_be_dangerous(
465 &self,
466 writer: Xid,
467 write_set: &HashSet<LogicalRowKey>,
468 ) -> bool {
469 if write_set.is_empty() {
470 return false;
471 }
472
473 let mut state = self.state.write();
474 if !state.serializable.contains_key(&writer) {
475 return false;
476 }
477
478 let readers: Vec<Xid> = state
479 .serializable
480 .iter()
481 .filter_map(|(&reader, ssi)| {
482 if reader != writer && !ssi.read_set.is_disjoint(write_set) {
483 Some(reader)
484 } else {
485 None
486 }
487 })
488 .collect();
489
490 Self::serializable_readers_would_be_dangerous(&mut state, writer, readers)
491 }
492
493 fn serializable_readers_would_be_dangerous(
494 state: &mut ManagerState,
495 writer: Xid,
496 readers: Vec<Xid>,
497 ) -> bool {
498 for reader in readers {
499 if let Some(reader_state) = state.serializable.get_mut(&reader) {
500 reader_state.outgoing_rw.insert(writer);
501 }
502 if let Some(writer_state) = state.serializable.get_mut(&writer) {
503 writer_state.incoming_rw.insert(reader);
504 }
505 }
506
507 let Some(writer_state) = state.serializable.get(&writer) else {
508 return false;
509 };
510 let has_incoming = !writer_state.incoming_rw.is_empty();
511 let has_outgoing_to_committed = writer_state
512 .outgoing_rw
513 .iter()
514 .any(|xid| state.serializable.get(xid).is_some_and(|ssi| ssi.committed));
515 has_incoming && has_outgoing_to_committed
516 }
517
518 fn prune_serializable_state(state: &mut ManagerState) {
519 if state.active.is_empty() {
520 state.serializable.clear();
521 }
522 }
523
524 /// Is this xid known to have rolled back? Called by the read path to
525 /// skip tuples whose creator never committed.
526 pub fn is_aborted(&self, xid: Xid) -> bool {
527 self.state.read().aborted.contains(&xid)
528 }
529
530 /// Unfinished writers must be rolled back in a crash-recoverable checkpoint,
531 /// without changing their live transaction state or their readers.
532 pub fn checkpoint_excluded_state(&self) -> (u64, HashSet<Xid>) {
533 let (revision, excluded, _) = self.checkpoint_projection_state();
534 (revision, excluded)
535 }
536
537 pub fn checkpoint_projection_state(&self) -> (u64, HashSet<Xid>, u64) {
538 let state = self.state.read();
539 let mut excluded = state.aborted.clone();
540 excluded.extend(state.active.iter().copied());
541 (
542 self.visibility_revision.load(Ordering::Acquire),
543 excluded,
544 state.untracked_visibility_revision,
545 )
546 }
547
548 /// Is this xid still active?
549 pub fn is_active(&self, xid: Xid) -> bool {
550 self.state.read().active.contains(&xid)
551 }
552
553 /// Unique-key admission uses current transaction outcomes, not the
554 /// reader's snapshot. An invisible active/newly committed writer still
555 /// reserves its key; an aborted creator or completed replacement does not.
556 pub fn row_reserves_unique_key(&self, xmin: Xid, xmax: Xid, own_xids: &HashSet<Xid>) -> bool {
557 let state = self.state.read();
558 if state.aborted.contains(&xmin) {
559 return false;
560 }
561 if state.active.contains(&xmin) && !own_xids.contains(&xmin) {
562 return true;
563 }
564 if xmax == 0 || state.aborted.contains(&xmax) {
565 return true;
566 }
567 !own_xids.contains(&xmax) && state.active.contains(&xmax)
568 }
569
570 /// Snapshot of every still-active xid (for VACUUM oldest-active-xid
571 /// calculation — any row with `xmax < min(active)` is reclaimable).
572 pub fn oldest_active_xid(&self) -> Option<Xid> {
573 self.state.read().active.iter().copied().min()
574 }
575
576 /// GC must preserve writers excluded by an active reader's snapshot, even
577 /// when those writers have since committed and left the active set.
578 pub fn oldest_required_xid(&self) -> Option<Xid> {
579 let state = self.state.read();
580 state
581 .active
582 .iter()
583 .chain(state.snapshot_horizons.values())
584 .chain(state.statement_horizons.keys())
585 .chain(state.pinned.keys())
586 .copied()
587 .min()
588 }
589
590 /// Oldest externally pinned xid. Pinned snapshots behave like active
591 /// snapshots for VACUUM: any tuple visible to that xid must survive even
592 /// when no SQL transaction is currently active.
593 pub fn oldest_pinned_xid(&self) -> Option<Xid> {
594 self.state.read().pinned.keys().copied().min()
595 }
596
597 /// Return the next xid that would be allocated. Useful for diagnostics
598 /// and for VACUUM to know the upper bound of aborted-xid retention.
599 pub fn peek_next_xid(&self) -> Xid {
600 self.next_xid.load(Ordering::Relaxed)
601 }
602
603 /// Advance the allocator so future snapshots consider an xid
604 /// recovered from storage/WAL to be in the committed past.
605 pub fn observe_committed_xid(&self, xid: Xid) {
606 if xid == XID_NONE {
607 return;
608 }
609 // Replay can import an xid from our still-unused reservation range.
610 // Retire those slots before publishing the floor: they must neither
611 // hide the recovered row in fresh snapshots nor be allocated again.
612 // Captured snapshots retain their original exclusions.
613 let mut pool = self.autocommit_pool.lock();
614 let target = xid.saturating_add(1);
615 pool.next = pool.next.max(target.min(pool.end));
616 let mut current = self.next_xid.load(Ordering::Relaxed);
617 while current < target {
618 match self.next_xid.compare_exchange(
619 current,
620 target,
621 Ordering::Relaxed,
622 Ordering::Relaxed,
623 ) {
624 Ok(_) => return,
625 Err(actual) => current = actual,
626 }
627 }
628 }
629
630 /// Prune outcomes only after the caller has removed or normalized every
631 /// physical version that references them across all collections and models.
632 /// A snapshot cutoff alone is insufficient: an aborted insert with xmin=x
633 /// becomes visible again if x is forgotten while that version still exists.
634 /// Pinned xids remain retained for higher-level references.
635 pub fn prune_aborted(&self, below: Xid) {
636 let mut state = self.state.write();
637 let ManagerState {
638 aborted, pinned, ..
639 } = &mut *state;
640 let previous = aborted.len();
641 aborted.retain(|&x| x >= below || pinned.contains_key(&x));
642 if previous != aborted.len() {
643 self.visibility_revision.fetch_add(1, Ordering::Release);
644 }
645 }
646
647 /// Pin an xid so its row versions stay reclaim-safe across VACUUM.
648 /// Reference-counted — call `unpin` once per `pin` to release.
649 pub fn pin(&self, xid: Xid) {
650 if xid == XID_NONE {
651 return;
652 }
653 let mut state = self.state.write();
654 *state.pinned.entry(xid).or_insert(0) += 1;
655 }
656
657 /// Decrement an xid's pin count. At zero it is removed and becomes
658 /// VACUUM-eligible again. No-op if the xid was never pinned.
659 pub fn unpin(&self, xid: Xid) {
660 if xid == XID_NONE {
661 return;
662 }
663 let mut state = self.state.write();
664 if let Some(count) = state.pinned.get_mut(&xid) {
665 if *count <= 1 {
666 state.pinned.remove(&xid);
667 } else {
668 *count -= 1;
669 }
670 }
671 }
672
673 /// Is this xid currently pinned?
674 pub fn is_pinned(&self, xid: Xid) -> bool {
675 self.state.read().pinned.contains_key(&xid)
676 }
677
678 /// Current pin count for an xid (0 if not pinned). Diagnostic only.
679 pub fn pin_count(&self, xid: Xid) -> u32 {
680 self.state.read().pinned.get(&xid).copied().unwrap_or(0)
681 }
682}
683
684impl Default for SnapshotManager {
685 fn default() -> Self {
686 Self::new()
687 }
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693
694 #[test]
695 fn xids_are_monotonic() {
696 let m = SnapshotManager::new();
697 let a = m.begin();
698 let b = m.begin();
699 let c = m.begin();
700 assert!(a < b && b < c);
701 }
702
703 #[test]
704 fn snapshot_excludes_concurrent_writers() {
705 let m = SnapshotManager::new();
706 let writer = m.begin();
707 let reader = m.begin();
708 let snap = m.snapshot(reader);
709 // Writer is active from reader's perspective → in_progress set.
710 assert!(snap.in_progress.contains(&writer));
711 // A row written by `writer` with xmin=writer must be invisible.
712 assert!(!snap.sees(writer, XID_NONE));
713 }
714
715 #[test]
716 fn fresh_read_snapshot_sees_latest_committed_hides_active() {
717 let m = SnapshotManager::new();
718 // A committed writer must be visible to a fresh read snapshot; an
719 // xid still active must be hidden. This is the RMW pre-image
720 // contract: latest committed, never an uncommitted sibling.
721 let committed = m.begin();
722 m.commit(committed);
723 let active = m.begin();
724
725 let snap = m.fresh_read_snapshot();
726 assert!(snap.sees(committed, XID_NONE));
727 assert!(!snap.sees(active, XID_NONE));
728 assert!(snap.in_progress.contains(&active));
729 }
730
731 #[test]
732 fn fresh_read_snapshot_refreshes_between_calls() {
733 let m = SnapshotManager::new();
734 // Value committed *after* a first read must be visible to a second
735 // fresh read — proving the snapshot is re-evaluated, not pinned.
736 let later = m.begin();
737 let first = m.fresh_read_snapshot();
738 assert!(!first.sees(later, XID_NONE));
739 m.commit(later);
740 let second = m.fresh_read_snapshot();
741 assert!(second.sees(later, XID_NONE));
742 }
743
744 #[test]
745 fn committed_rows_become_visible() {
746 let m = SnapshotManager::new();
747 let writer = m.begin();
748 m.commit(writer);
749 let reader = m.begin();
750 let snap = m.snapshot(reader);
751 // Row stamped with writer's xid is now visible (writer < reader & committed).
752 assert!(snap.sees(writer, XID_NONE));
753 }
754
755 #[test]
756 fn rolled_back_writers_stay_hidden() {
757 let m = SnapshotManager::new();
758 let writer = m.begin();
759 m.rollback(writer);
760 assert!(m.is_aborted(writer));
761 // Future callers skip tuples with xmin == writer by also consulting is_aborted.
762 }
763
764 #[test]
765 fn pre_mvcc_rows_always_visible() {
766 let m = SnapshotManager::new();
767 let reader = m.begin();
768 let snap = m.snapshot(reader);
769 assert!(snap.sees(XID_NONE, XID_NONE));
770 }
771
772 #[test]
773 fn deletion_xmax_respected() {
774 let m = SnapshotManager::new();
775 let creator = m.begin();
776 m.commit(creator);
777 let deleter = m.begin();
778 m.commit(deleter);
779 let reader = m.begin();
780 let snap = m.snapshot(reader);
781 // Reader opens *after* delete → row must be hidden.
782 assert!(!snap.sees(creator, deleter));
783 }
784
785 #[test]
786 fn pin_blocks_prune_of_aborted_xid() {
787 let m = SnapshotManager::new();
788 let writer = m.begin();
789 m.rollback(writer);
790 assert!(m.is_aborted(writer));
791 m.pin(writer);
792 // Even with a high watermark, pinned xid survives prune.
793 m.prune_aborted(writer + 1);
794 assert!(m.is_aborted(writer));
795 m.unpin(writer);
796 m.prune_aborted(writer + 1);
797 assert!(!m.is_aborted(writer));
798 }
799
800 #[test]
801 fn pin_is_reference_counted() {
802 let m = SnapshotManager::new();
803 let x = m.begin();
804 m.pin(x);
805 m.pin(x);
806 assert_eq!(m.pin_count(x), 2);
807 m.unpin(x);
808 assert_eq!(m.pin_count(x), 1);
809 assert!(m.is_pinned(x));
810 m.unpin(x);
811 assert_eq!(m.pin_count(x), 0);
812 assert!(!m.is_pinned(x));
813 // Extra unpin is a no-op.
814 m.unpin(x);
815 assert_eq!(m.pin_count(x), 0);
816 }
817
818 #[test]
819 fn pin_xid_none_is_noop() {
820 let m = SnapshotManager::new();
821 m.pin(XID_NONE);
822 assert!(!m.is_pinned(XID_NONE));
823 assert_eq!(m.pin_count(XID_NONE), 0);
824 }
825
826 #[test]
827 fn allocate_committed_xid_is_monotonic_and_unique() {
828 let m = SnapshotManager::new();
829 let mut seen = HashSet::new();
830 let mut last = 0u64;
831 // Drive at least three pool refills (BATCH=16 → 50 covers it).
832 for _ in 0..50 {
833 let x = m.allocate_committed_xid();
834 assert!(x > last, "xids must be strictly increasing: {x} > {last}");
835 assert!(seen.insert(x), "duplicate xid handed out: {x}");
836 last = x;
837 }
838 }
839
840 #[test]
841 fn allocate_committed_xid_skips_active_set() {
842 let m = SnapshotManager::new();
843 let _x = m.allocate_committed_xid();
844 // Pool xids must never appear in the active set — they are
845 // born committed. `oldest_active_xid` reflects only `begin()`
846 // callers (real BEGIN-wrapped transactions).
847 assert_eq!(m.oldest_active_xid(), None);
848 }
849
850 #[test]
851 fn allocate_committed_xid_visible_to_subsequent_snapshots() {
852 let m = SnapshotManager::new();
853 let writer = m.allocate_committed_xid();
854 let reader = m.begin();
855 let snap = m.snapshot(reader);
856 // Pool xid must be invisible to in_progress/aborted (it's in
857 // neither) and visible because writer < reader. This matches
858 // the legacy begin()+commit() pair's visibility exactly.
859 assert!(!snap.in_progress.contains(&writer));
860 assert!(!m.is_aborted(writer));
861 assert!(snap.sees(writer, XID_NONE));
862 }
863
864 #[test]
865 fn allocate_committed_xid_does_not_block_concurrent_begin() {
866 // Smoke test: an open BEGIN-wrapped tx coexists with pool
867 // allocation; pool xids end up between the begin and commit
868 // without being added to `active`.
869 let m = SnapshotManager::new();
870 let tx = m.begin();
871 let auto1 = m.allocate_committed_xid();
872 let auto2 = m.allocate_committed_xid();
873 m.commit(tx);
874 assert!(tx < auto1 && auto1 < auto2);
875 // `active` should be empty after commit.
876 assert_eq!(m.oldest_active_xid(), None);
877 }
878
879 #[test]
880 fn oldest_active_is_min_live_xid() {
881 let m = SnapshotManager::new();
882 let a = m.begin();
883 let b = m.begin();
884 assert_eq!(m.oldest_active_xid(), Some(a));
885 m.commit(a);
886 assert_eq!(m.oldest_active_xid(), Some(b));
887 m.commit(b);
888 assert_eq!(m.oldest_active_xid(), None);
889 }
890 #[test]
891 fn read_snapshot_excludes_first_future_writer_and_deleter() {
892 let manager = SnapshotManager::new();
893 let old = manager.begin();
894 manager.commit(old);
895 let snapshot = manager.fresh_read_snapshot();
896 let future = manager.begin();
897 manager.commit(future);
898 assert!(!snapshot.sees(future, XID_NONE));
899 assert!(snapshot.sees(old, future));
900 let fresh = manager.fresh_read_snapshot();
901 assert!(fresh.sees(future, XID_NONE));
902 assert!(!fresh.sees(old, future));
903 }
904
905 #[test]
906 fn snapshots_exclude_unissued_pool_slots_even_below_reader_xid() {
907 let manager = SnapshotManager::new();
908 let old = manager.allocate_committed_xid();
909 let reader = manager.begin();
910 let transaction_snapshot = manager.snapshot(reader);
911 let statement_snapshot = manager.fresh_read_snapshot();
912 let future = manager.allocate_committed_xid();
913 assert!(future < reader, "fixture must exercise the reserved range");
914 for snapshot in [transaction_snapshot, statement_snapshot] {
915 assert!(snapshot.sees(old, XID_NONE));
916 assert!(!snapshot.sees(future, XID_NONE));
917 assert!(snapshot.sees(old, future));
918 }
919 assert!(manager.fresh_read_snapshot().sees(future, XID_NONE));
920 manager.rollback(reader);
921 }
922
923 #[test]
924 fn replay_retires_reserved_xids_without_changing_captured_snapshots() {
925 let manager = SnapshotManager::new();
926 let first = manager.allocate_committed_xid();
927 let captured = manager.fresh_read_snapshot();
928 let replayed = first + 2;
929 manager.observe_committed_xid(replayed);
930 assert!(!captured.sees(replayed, XID_NONE));
931 assert!(manager.fresh_read_snapshot().sees(replayed, XID_NONE));
932 assert!(manager.allocate_committed_xid() > replayed);
933
934 let beyond_pool = manager.peek_next_xid() + 10;
935 let captured = manager.fresh_read_snapshot();
936 manager.observe_committed_xid(beyond_pool);
937 assert!(!captured.sees(beyond_pool, XID_NONE));
938 assert!(manager.fresh_read_snapshot().sees(beyond_pool, XID_NONE));
939 assert!(manager.allocate_committed_xid() > beyond_pool);
940 }
941}