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        // A real writer advances beyond the reserved autocommit range. Retire
226        // its unused slots so later inserts cannot receive older identities
227        // and pin fresh statements' vacuum horizons behind committed deletes.
228        let mut pool = self.autocommit_pool.lock();
229        pool.next = pool.end;
230        let xid = self.next_xid.fetch_add(1, Ordering::Relaxed);
231        state.active.insert(xid);
232        self.visibility_revision.fetch_add(1, Ordering::Release);
233        xid
234    }
235
236    /// Capture a fresh *read-committed* snapshot at this instant: sees every
237    /// transaction that has committed so far and hides every still-active
238    /// one. Unlike [`snapshot`], the caller supplies no xid — the snapshot
239    /// id is the current allocator high-water mark, read *under the same
240    /// state lock* that [`begin`] holds while it bumps the counter and
241    /// inserts into `active`. That mutual exclusion is what makes the read
242    /// atomic: a concurrent `begin()` is either wholly visible (counter
243    /// bumped AND present in `active`, hence hidden as in-progress) or
244    /// wholly invisible (neither) — never the torn state where its xid sits
245    /// below the high-water mark yet is missing from `active`, which would
246    /// leak an uncommitted writer as visible.
247    ///
248    /// Used for RMW pre-image reads that must always observe the latest
249    /// committed row version rather than a pinned statement snapshot.
250    pub fn fresh_read_snapshot(&self) -> Snapshot {
251        let state = self.state.read();
252        self.fresh_read_snapshot_locked(&state)
253    }
254
255    fn fresh_read_snapshot_locked(&self, state: &ManagerState) -> Snapshot {
256        let pool = self.autocommit_pool.lock();
257        // Visibility uses an inclusive bound. The allocator points at the
258        // next, not the last, xid; including it admits the next future writer.
259        let xid = self.next_xid.load(Ordering::Relaxed).saturating_sub(1);
260        let mut in_progress: HashSet<Xid> = state.active.iter().copied().collect();
261        // Reserved-but-unissued pool slots are future writers even though
262        // their numbers can be below this snapshot's high-water mark.
263        in_progress.extend(pool.next..pool.end);
264        Snapshot { xid, in_progress }
265    }
266
267    /// Capture and register under the same lock used by GC cutoff selection.
268    /// Registering only when the snapshot is installed leaves a capture-to-read
269    /// interval in which a committed writer's old versions can be reclaimed.
270    pub fn fresh_retained_snapshot(self: &Arc<Self>) -> (Snapshot, SnapshotRetentionGuard) {
271        let mut state = self.state.write();
272        let snapshot = self.fresh_read_snapshot_locked(&state);
273        let guard = self.retain_snapshot_locked(&snapshot, &mut state);
274        (snapshot, guard)
275    }
276
277    /// Retain a snapshot already protected by its parent transaction, outer
278    /// statement frame, or historical reference while transferring ownership.
279    pub fn retain_snapshot(
280        self: &Arc<Self>,
281        snapshot: Snapshot,
282    ) -> (Snapshot, SnapshotRetentionGuard) {
283        let mut state = self.state.write();
284        let guard = self.retain_snapshot_locked(&snapshot, &mut state);
285        (snapshot, guard)
286    }
287
288    fn retain_snapshot_locked(
289        self: &Arc<Self>,
290        snapshot: &Snapshot,
291        state: &mut ManagerState,
292    ) -> SnapshotRetentionGuard {
293        // Deletes already visible to the snapshot need no old version. An
294        // excluded writer can be older than its inclusive high-water mark.
295        let horizon = snapshot
296            .in_progress
297            .iter()
298            .copied()
299            .fold(snapshot.xid.saturating_add(1), Xid::min);
300        *state.statement_horizons.entry(horizon).or_default() += 1;
301        SnapshotRetentionGuard {
302            manager: Arc::clone(self),
303            horizon,
304        }
305    }
306
307    /// Capture a point-in-time snapshot. Must be called after `begin()`
308    /// when using SnapshotIsolation/Serializable. ReadCommitted refreshes
309    /// this per statement via the same call.
310    pub fn snapshot(&self, xid: Xid) -> Snapshot {
311        let mut state = self.state.write();
312        // Active xids other than our own appear as "in-progress" to us.
313        let pool = self.autocommit_pool.lock();
314        let mut in_progress: HashSet<Xid> =
315            state.active.iter().copied().filter(|&x| x != xid).collect();
316        in_progress.extend(pool.next..pool.end);
317        if state.active.contains(&xid) {
318            let horizon = in_progress.iter().copied().fold(xid, Xid::min);
319            state
320                .snapshot_horizons
321                .entry(xid)
322                .and_modify(|previous| *previous = (*previous).min(horizon))
323                .or_insert(horizon);
324        }
325        Snapshot { xid, in_progress }
326    }
327
328    /// Mark a transaction as committed. Its writes become visible to
329    /// future snapshots; earlier snapshots keep their own view.
330    pub fn commit(&self, xid: Xid) {
331        let mut state = self.state.write();
332        let was_active = state.active.remove(&xid);
333        state.snapshot_horizons.remove(&xid);
334        // Also clear from aborted set in case of prior rollback_to call
335        // that touched this xid (defensive; normally a no-op).
336        let was_aborted = state.aborted.remove(&xid);
337        if was_active || was_aborted {
338            self.visibility_revision.fetch_add(1, Ordering::Release);
339        }
340        if let Some(ssi) = state.serializable.get_mut(&xid) {
341            ssi.committed = true;
342        }
343        Self::prune_serializable_state(&mut state);
344    }
345
346    /// Allocate an xid that is *born committed* — for autocommit
347    /// callers (`MutationEngine::append_one`) that previously paid two
348    /// `state.write()` lock acquisitions per row to insert-then-remove
349    /// from the active set.
350    ///
351    /// The returned xid is never inserted into `active` and never into
352    /// `aborted`, which matches the steady state of the legacy
353    /// `begin()/commit()` pair when called back-to-back: the xid leaves
354    /// the manager's tracking sets unobservably. Concurrent readers
355    /// therefore see it as an already-committed transaction once
356    /// `xmin <= snapshot.xid`, which is exactly the semantics the
357    /// autocommit path needs.
358    ///
359    /// Implementation: a small reservation pool (`AUTOCOMMIT_POOL_BATCH`
360    /// xids) is reserved with one `fetch_add`. Each caller hands itself
361    /// the next xid via a single atomic. When the pool drains, the
362    /// next caller serialises briefly through `autocommit_pool_refill`
363    /// to bump the window, then falls back into the lock-free hot path.
364    ///
365    /// Durability note: this method does NOT make the row durable —
366    /// it only allocates the identifier. The caller must complete the
367    /// usual WAL-append + fsync cycle before acknowledging the write.
368    /// Pre-allocating the xid is safe because the xid carries no
369    /// promise that any row exists; it's just a number for `xmin`.
370    pub fn allocate_committed_xid(&self) -> Xid {
371        let mut pool = self.autocommit_pool.lock();
372        if pool.next >= pool.end {
373            // Reserve the next contiguous range. A single
374            // `fetch_add(BATCH)` on the global counter — equivalent to
375            // BATCH back-to-back `begin()` calls in terms of xid
376            // numbering, but with zero `state.write()` traffic.
377            let start = self
378                .next_xid
379                .fetch_add(AUTOCOMMIT_POOL_BATCH, Ordering::Relaxed);
380            pool.next = start;
381            pool.end = start + AUTOCOMMIT_POOL_BATCH;
382        }
383        let xid = pool.next;
384        pool.next += 1;
385        xid
386    }
387
388    /// Mark a transaction as rolled back. Its writes MUST stay hidden
389    /// from every future read — `is_visible` consults the aborted set
390    /// before honouring a row's `xmin`.
391    pub fn rollback(&self, xid: Xid) {
392        let mut state = self.state.write();
393        state.snapshot_horizons.remove(&xid);
394        let was_active = state.active.remove(&xid);
395        let newly_aborted = state.aborted.insert(xid);
396        if newly_aborted && !was_active {
397            state.untracked_visibility_revision = state
398                .untracked_visibility_revision
399                .checked_add(1)
400                .expect("visibility invalidation counter must not overflow");
401        }
402        if was_active || newly_aborted {
403            self.visibility_revision.fetch_add(1, Ordering::Release);
404        }
405        state.serializable.remove(&xid);
406        for ssi in state.serializable.values_mut() {
407            ssi.incoming_rw.remove(&xid);
408            ssi.outgoing_rw.remove(&xid);
409        }
410        Self::prune_serializable_state(&mut state);
411    }
412
413    pub fn begin_serializable(&self, xid: Xid) {
414        self.state.write().serializable.entry(xid).or_default();
415    }
416
417    pub fn record_serializable_read(&self, reader: Xid, collection: &str, logical_id: EntityId) {
418        let mut state = self.state.write();
419        if let Some(ssi) = state.serializable.get_mut(&reader) {
420            ssi.read_set.insert((collection.to_string(), logical_id));
421        }
422    }
423
424    pub fn record_serializable_vault_read(
425        &self,
426        reader: Xid,
427        collection: &str,
428        tenant: Option<&str>,
429        key: Option<&str>,
430    ) {
431        if let Some(ssi) = self.state.write().serializable.get_mut(&reader) {
432            ssi.vault_reads.insert((
433                collection.into(),
434                tenant.map(str::to_owned),
435                key.map(str::to_owned),
436            ));
437        }
438    }
439
440    pub fn serializable_vault_commit_would_be_dangerous(
441        &self,
442        writer: Xid,
443        write_set: &HashSet<(String, Option<String>, String)>,
444    ) -> bool {
445        let mut state = self.state.write();
446        if !state.serializable.contains_key(&writer) {
447            return false;
448        }
449        let readers = state
450            .serializable
451            .iter()
452            .filter_map(|(&reader, ssi)| {
453                (reader != writer
454                    && ssi.vault_reads.iter().any(|(collection, tenant, key)| {
455                        write_set
456                            .iter()
457                            .any(|(written_collection, written_tenant, written_key)| {
458                                collection == written_collection
459                                    && tenant == written_tenant
460                                    && key.as_ref().is_none_or(|key| key == written_key)
461                            })
462                    }))
463                .then_some(reader)
464            })
465            .collect();
466        Self::serializable_readers_would_be_dangerous(&mut state, writer, readers)
467    }
468
469    pub fn serializable_commit_would_be_dangerous(
470        &self,
471        writer: Xid,
472        write_set: &HashSet<LogicalRowKey>,
473    ) -> bool {
474        if write_set.is_empty() {
475            return false;
476        }
477
478        let mut state = self.state.write();
479        if !state.serializable.contains_key(&writer) {
480            return false;
481        }
482
483        let readers: Vec<Xid> = state
484            .serializable
485            .iter()
486            .filter_map(|(&reader, ssi)| {
487                if reader != writer && !ssi.read_set.is_disjoint(write_set) {
488                    Some(reader)
489                } else {
490                    None
491                }
492            })
493            .collect();
494
495        Self::serializable_readers_would_be_dangerous(&mut state, writer, readers)
496    }
497
498    fn serializable_readers_would_be_dangerous(
499        state: &mut ManagerState,
500        writer: Xid,
501        readers: Vec<Xid>,
502    ) -> bool {
503        for reader in readers {
504            if let Some(reader_state) = state.serializable.get_mut(&reader) {
505                reader_state.outgoing_rw.insert(writer);
506            }
507            if let Some(writer_state) = state.serializable.get_mut(&writer) {
508                writer_state.incoming_rw.insert(reader);
509            }
510        }
511
512        let Some(writer_state) = state.serializable.get(&writer) else {
513            return false;
514        };
515        let has_incoming = !writer_state.incoming_rw.is_empty();
516        let has_outgoing_to_committed = writer_state
517            .outgoing_rw
518            .iter()
519            .any(|xid| state.serializable.get(xid).is_some_and(|ssi| ssi.committed));
520        has_incoming && has_outgoing_to_committed
521    }
522
523    fn prune_serializable_state(state: &mut ManagerState) {
524        if state.active.is_empty() {
525            state.serializable.clear();
526        }
527    }
528
529    /// Is this xid known to have rolled back? Called by the read path to
530    /// skip tuples whose creator never committed.
531    pub fn is_aborted(&self, xid: Xid) -> bool {
532        self.state.read().aborted.contains(&xid)
533    }
534
535    /// Unfinished writers must be rolled back in a crash-recoverable checkpoint,
536    /// without changing their live transaction state or their readers.
537    pub fn checkpoint_excluded_state(&self) -> (u64, HashSet<Xid>) {
538        let (revision, excluded, _) = self.checkpoint_projection_state();
539        (revision, excluded)
540    }
541
542    pub fn checkpoint_projection_state(&self) -> (u64, HashSet<Xid>, u64) {
543        let state = self.state.read();
544        let mut excluded = state.aborted.clone();
545        excluded.extend(state.active.iter().copied());
546        (
547            self.visibility_revision.load(Ordering::Acquire),
548            excluded,
549            state.untracked_visibility_revision,
550        )
551    }
552
553    /// Is this xid still active?
554    pub fn is_active(&self, xid: Xid) -> bool {
555        self.state.read().active.contains(&xid)
556    }
557
558    /// Unique-key admission uses current transaction outcomes, not the
559    /// reader's snapshot. An invisible active/newly committed writer still
560    /// reserves its key; an aborted creator or completed replacement does not.
561    pub fn row_reserves_unique_key(&self, xmin: Xid, xmax: Xid, own_xids: &HashSet<Xid>) -> bool {
562        let state = self.state.read();
563        if state.aborted.contains(&xmin) {
564            return false;
565        }
566        if state.active.contains(&xmin) && !own_xids.contains(&xmin) {
567            return true;
568        }
569        if xmax == 0 || state.aborted.contains(&xmax) {
570            return true;
571        }
572        !own_xids.contains(&xmax) && state.active.contains(&xmax)
573    }
574
575    /// Snapshot of every still-active xid (for VACUUM oldest-active-xid
576    /// calculation — any row with `xmax < min(active)` is reclaimable).
577    pub fn oldest_active_xid(&self) -> Option<Xid> {
578        self.state.read().active.iter().copied().min()
579    }
580
581    /// GC must preserve writers excluded by an active reader's snapshot, even
582    /// when those writers have since committed and left the active set.
583    pub fn oldest_required_xid(&self) -> Option<Xid> {
584        let state = self.state.read();
585        state
586            .active
587            .iter()
588            .chain(state.snapshot_horizons.values())
589            .chain(state.statement_horizons.keys())
590            .chain(state.pinned.keys())
591            .copied()
592            .min()
593    }
594
595    /// Oldest externally pinned xid. Pinned snapshots behave like active
596    /// snapshots for VACUUM: any tuple visible to that xid must survive even
597    /// when no SQL transaction is currently active.
598    pub fn oldest_pinned_xid(&self) -> Option<Xid> {
599        self.state.read().pinned.keys().copied().min()
600    }
601
602    /// Return the next xid that would be allocated. Useful for diagnostics
603    /// and for VACUUM to know the upper bound of aborted-xid retention.
604    pub fn peek_next_xid(&self) -> Xid {
605        self.next_xid.load(Ordering::Relaxed)
606    }
607
608    /// Advance the allocator so future snapshots consider an xid
609    /// recovered from storage/WAL to be in the committed past.
610    pub fn observe_committed_xid(&self, xid: Xid) {
611        if xid == XID_NONE {
612            return;
613        }
614        // Replay can import an xid from our still-unused reservation range.
615        // Retire those slots before publishing the floor: they must neither
616        // hide the recovered row in fresh snapshots nor be allocated again.
617        // Captured snapshots retain their original exclusions.
618        let mut pool = self.autocommit_pool.lock();
619        let target = xid.saturating_add(1);
620        pool.next = pool.next.max(target.min(pool.end));
621        let mut current = self.next_xid.load(Ordering::Relaxed);
622        while current < target {
623            match self.next_xid.compare_exchange(
624                current,
625                target,
626                Ordering::Relaxed,
627                Ordering::Relaxed,
628            ) {
629                Ok(_) => return,
630                Err(actual) => current = actual,
631            }
632        }
633    }
634
635    /// Prune outcomes only after the caller has removed or normalized every
636    /// physical version that references them across all collections and models.
637    /// A snapshot cutoff alone is insufficient: an aborted insert with xmin=x
638    /// becomes visible again if x is forgotten while that version still exists.
639    /// Pinned xids remain retained for higher-level references.
640    pub fn prune_aborted(&self, below: Xid) {
641        let mut state = self.state.write();
642        let ManagerState {
643            aborted, pinned, ..
644        } = &mut *state;
645        let previous = aborted.len();
646        aborted.retain(|&x| x >= below || pinned.contains_key(&x));
647        if previous != aborted.len() {
648            self.visibility_revision.fetch_add(1, Ordering::Release);
649        }
650    }
651
652    /// Pin an xid so its row versions stay reclaim-safe across VACUUM.
653    /// Reference-counted — call `unpin` once per `pin` to release.
654    pub fn pin(&self, xid: Xid) {
655        if xid == XID_NONE {
656            return;
657        }
658        let mut state = self.state.write();
659        *state.pinned.entry(xid).or_insert(0) += 1;
660    }
661
662    /// Decrement an xid's pin count. At zero it is removed and becomes
663    /// VACUUM-eligible again. No-op if the xid was never pinned.
664    pub fn unpin(&self, xid: Xid) {
665        if xid == XID_NONE {
666            return;
667        }
668        let mut state = self.state.write();
669        if let Some(count) = state.pinned.get_mut(&xid) {
670            if *count <= 1 {
671                state.pinned.remove(&xid);
672            } else {
673                *count -= 1;
674            }
675        }
676    }
677
678    /// Is this xid currently pinned?
679    pub fn is_pinned(&self, xid: Xid) -> bool {
680        self.state.read().pinned.contains_key(&xid)
681    }
682
683    /// Current pin count for an xid (0 if not pinned). Diagnostic only.
684    pub fn pin_count(&self, xid: Xid) -> u32 {
685        self.state.read().pinned.get(&xid).copied().unwrap_or(0)
686    }
687}
688
689impl Default for SnapshotManager {
690    fn default() -> Self {
691        Self::new()
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698
699    #[test]
700    fn xids_are_monotonic() {
701        let m = SnapshotManager::new();
702        let a = m.begin();
703        let b = m.begin();
704        let c = m.begin();
705        assert!(a < b && b < c);
706    }
707
708    #[test]
709    fn snapshot_excludes_concurrent_writers() {
710        let m = SnapshotManager::new();
711        let writer = m.begin();
712        let reader = m.begin();
713        let snap = m.snapshot(reader);
714        // Writer is active from reader's perspective → in_progress set.
715        assert!(snap.in_progress.contains(&writer));
716        // A row written by `writer` with xmin=writer must be invisible.
717        assert!(!snap.sees(writer, XID_NONE));
718    }
719
720    #[test]
721    fn fresh_read_snapshot_sees_latest_committed_hides_active() {
722        let m = SnapshotManager::new();
723        // A committed writer must be visible to a fresh read snapshot; an
724        // xid still active must be hidden. This is the RMW pre-image
725        // contract: latest committed, never an uncommitted sibling.
726        let committed = m.begin();
727        m.commit(committed);
728        let active = m.begin();
729
730        let snap = m.fresh_read_snapshot();
731        assert!(snap.sees(committed, XID_NONE));
732        assert!(!snap.sees(active, XID_NONE));
733        assert!(snap.in_progress.contains(&active));
734    }
735
736    #[test]
737    fn fresh_read_snapshot_refreshes_between_calls() {
738        let m = SnapshotManager::new();
739        // Value committed *after* a first read must be visible to a second
740        // fresh read — proving the snapshot is re-evaluated, not pinned.
741        let later = m.begin();
742        let first = m.fresh_read_snapshot();
743        assert!(!first.sees(later, XID_NONE));
744        m.commit(later);
745        let second = m.fresh_read_snapshot();
746        assert!(second.sees(later, XID_NONE));
747    }
748
749    #[test]
750    fn committed_rows_become_visible() {
751        let m = SnapshotManager::new();
752        let writer = m.begin();
753        m.commit(writer);
754        let reader = m.begin();
755        let snap = m.snapshot(reader);
756        // Row stamped with writer's xid is now visible (writer < reader & committed).
757        assert!(snap.sees(writer, XID_NONE));
758    }
759
760    #[test]
761    fn rolled_back_writers_stay_hidden() {
762        let m = SnapshotManager::new();
763        let writer = m.begin();
764        m.rollback(writer);
765        assert!(m.is_aborted(writer));
766        // Future callers skip tuples with xmin == writer by also consulting is_aborted.
767    }
768
769    #[test]
770    fn pre_mvcc_rows_always_visible() {
771        let m = SnapshotManager::new();
772        let reader = m.begin();
773        let snap = m.snapshot(reader);
774        assert!(snap.sees(XID_NONE, XID_NONE));
775    }
776
777    #[test]
778    fn deletion_xmax_respected() {
779        let m = SnapshotManager::new();
780        let creator = m.begin();
781        m.commit(creator);
782        let deleter = m.begin();
783        m.commit(deleter);
784        let reader = m.begin();
785        let snap = m.snapshot(reader);
786        // Reader opens *after* delete → row must be hidden.
787        assert!(!snap.sees(creator, deleter));
788    }
789
790    #[test]
791    fn pin_blocks_prune_of_aborted_xid() {
792        let m = SnapshotManager::new();
793        let writer = m.begin();
794        m.rollback(writer);
795        assert!(m.is_aborted(writer));
796        m.pin(writer);
797        // Even with a high watermark, pinned xid survives prune.
798        m.prune_aborted(writer + 1);
799        assert!(m.is_aborted(writer));
800        m.unpin(writer);
801        m.prune_aborted(writer + 1);
802        assert!(!m.is_aborted(writer));
803    }
804
805    #[test]
806    fn pin_is_reference_counted() {
807        let m = SnapshotManager::new();
808        let x = m.begin();
809        m.pin(x);
810        m.pin(x);
811        assert_eq!(m.pin_count(x), 2);
812        m.unpin(x);
813        assert_eq!(m.pin_count(x), 1);
814        assert!(m.is_pinned(x));
815        m.unpin(x);
816        assert_eq!(m.pin_count(x), 0);
817        assert!(!m.is_pinned(x));
818        // Extra unpin is a no-op.
819        m.unpin(x);
820        assert_eq!(m.pin_count(x), 0);
821    }
822
823    #[test]
824    fn pin_xid_none_is_noop() {
825        let m = SnapshotManager::new();
826        m.pin(XID_NONE);
827        assert!(!m.is_pinned(XID_NONE));
828        assert_eq!(m.pin_count(XID_NONE), 0);
829    }
830
831    #[test]
832    fn allocate_committed_xid_is_monotonic_and_unique() {
833        let m = SnapshotManager::new();
834        let mut seen = HashSet::new();
835        let mut last = 0u64;
836        // Drive at least three pool refills (BATCH=16 → 50 covers it).
837        for _ in 0..50 {
838            let x = m.allocate_committed_xid();
839            assert!(x > last, "xids must be strictly increasing: {x} > {last}");
840            assert!(seen.insert(x), "duplicate xid handed out: {x}");
841            last = x;
842        }
843    }
844
845    #[test]
846    fn mixed_allocations_preserve_transaction_order_and_reclaim_horizon() {
847        let manager = Arc::new(SnapshotManager::new());
848        let old = manager.allocate_committed_xid();
849        let deletion = manager.begin();
850        manager.commit(deletion);
851        let next_insert = manager.allocate_committed_xid();
852        assert!(
853            next_insert > deletion,
854            "a later insert cannot reuse an older reserved xid"
855        );
856        let (snapshot, _retention) = manager.fresh_retained_snapshot();
857        assert!(!snapshot.sees(old, deletion));
858        assert!(snapshot.sees(next_insert, XID_NONE));
859        assert!(
860            manager.oldest_required_xid().expect("retained statement") > deletion,
861            "unused allocator slots cannot keep already visible deletes resident"
862        );
863    }
864
865    #[test]
866    fn allocate_committed_xid_skips_active_set() {
867        let m = SnapshotManager::new();
868        let _x = m.allocate_committed_xid();
869        // Pool xids must never appear in the active set — they are
870        // born committed. `oldest_active_xid` reflects only `begin()`
871        // callers (real BEGIN-wrapped transactions).
872        assert_eq!(m.oldest_active_xid(), None);
873    }
874
875    #[test]
876    fn allocate_committed_xid_visible_to_subsequent_snapshots() {
877        let m = SnapshotManager::new();
878        let writer = m.allocate_committed_xid();
879        let reader = m.begin();
880        let snap = m.snapshot(reader);
881        // Pool xid must be invisible to in_progress/aborted (it's in
882        // neither) and visible because writer < reader. This matches
883        // the legacy begin()+commit() pair's visibility exactly.
884        assert!(!snap.in_progress.contains(&writer));
885        assert!(!m.is_aborted(writer));
886        assert!(snap.sees(writer, XID_NONE));
887    }
888
889    #[test]
890    fn allocate_committed_xid_does_not_block_concurrent_begin() {
891        // Smoke test: an open BEGIN-wrapped tx coexists with pool
892        // allocation; pool xids end up between the begin and commit
893        // without being added to `active`.
894        let m = SnapshotManager::new();
895        let tx = m.begin();
896        let auto1 = m.allocate_committed_xid();
897        let auto2 = m.allocate_committed_xid();
898        m.commit(tx);
899        assert!(tx < auto1 && auto1 < auto2);
900        // `active` should be empty after commit.
901        assert_eq!(m.oldest_active_xid(), None);
902    }
903
904    #[test]
905    fn oldest_active_is_min_live_xid() {
906        let m = SnapshotManager::new();
907        let a = m.begin();
908        let b = m.begin();
909        assert_eq!(m.oldest_active_xid(), Some(a));
910        m.commit(a);
911        assert_eq!(m.oldest_active_xid(), Some(b));
912        m.commit(b);
913        assert_eq!(m.oldest_active_xid(), None);
914    }
915    #[test]
916    fn read_snapshot_excludes_first_future_writer_and_deleter() {
917        let manager = SnapshotManager::new();
918        let old = manager.begin();
919        manager.commit(old);
920        let snapshot = manager.fresh_read_snapshot();
921        let future = manager.begin();
922        manager.commit(future);
923        assert!(!snapshot.sees(future, XID_NONE));
924        assert!(snapshot.sees(old, future));
925        let fresh = manager.fresh_read_snapshot();
926        assert!(fresh.sees(future, XID_NONE));
927        assert!(!fresh.sees(old, future));
928    }
929
930    #[test]
931    fn snapshots_hide_autocommit_writers_allocated_after_begin() {
932        let manager = SnapshotManager::new();
933        let old = manager.allocate_committed_xid();
934        let reader = manager.begin();
935        let transaction_snapshot = manager.snapshot(reader);
936        let statement_snapshot = manager.fresh_read_snapshot();
937        let future = manager.allocate_committed_xid();
938        assert!(future > reader, "begin retires the older reserved range");
939        for snapshot in [transaction_snapshot, statement_snapshot] {
940            assert!(snapshot.sees(old, XID_NONE));
941            assert!(!snapshot.sees(future, XID_NONE));
942            assert!(snapshot.sees(old, future));
943        }
944        assert!(manager.fresh_read_snapshot().sees(future, XID_NONE));
945        manager.rollback(reader);
946    }
947
948    #[test]
949    fn retiring_unused_slots_preserves_an_older_readers_history() {
950        let manager = Arc::new(SnapshotManager::new());
951        let old = manager.allocate_committed_xid();
952        let (snapshot, retention) = manager.fresh_retained_snapshot();
953        let deletion = manager.begin();
954        manager.commit(deletion);
955        let next_insert = manager.allocate_committed_xid();
956        let (_current, _current_retention) = manager.fresh_retained_snapshot();
957        assert!(snapshot.sees(old, deletion));
958        assert!(!snapshot.sees(next_insert, XID_NONE));
959        assert!(manager.oldest_required_xid().expect("old reader retained") <= deletion);
960        drop(retention);
961        assert!(
962            manager
963                .oldest_required_xid()
964                .expect("current reader retained")
965                > deletion
966        );
967    }
968
969    #[test]
970    fn replay_retires_reserved_xids_without_changing_captured_snapshots() {
971        let manager = SnapshotManager::new();
972        let first = manager.allocate_committed_xid();
973        let captured = manager.fresh_read_snapshot();
974        let replayed = first + 2;
975        manager.observe_committed_xid(replayed);
976        assert!(!captured.sees(replayed, XID_NONE));
977        assert!(manager.fresh_read_snapshot().sees(replayed, XID_NONE));
978        assert!(manager.allocate_committed_xid() > replayed);
979
980        let beyond_pool = manager.peek_next_xid() + 10;
981        let captured = manager.fresh_read_snapshot();
982        manager.observe_committed_xid(beyond_pool);
983        assert!(!captured.sees(beyond_pool, XID_NONE));
984        assert!(manager.fresh_read_snapshot().sees(beyond_pool, XID_NONE));
985        assert!(manager.allocate_committed_xid() > beyond_pool);
986    }
987}