Skip to main content

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}