Skip to main content

reddb_server/runtime/
mvcc_lifecycle.rs

1//! MVCC pending-action lifecycle & commit-time conflict detection.
2//!
3//! Extracted from `impl_core.rs` (impl_core slice 3/10, issue #1624). Houses
4//! the five MVCC families that PRD #1620 (TM v2) will extend:
5//!
6//! - **Pending write-set records** — deferred tombstones, versioned updates,
7//!   store-WAL actions, and event-emission gating for DML.
8//! - **First-committer-wins conflict checks** — snapshot conflict detection
9//!   and logical/table-row write-conflict guards.
10//! - **Commit/rollback finalization** — stamp restore, tombstone/versioned
11//!   update finalize/revive, and savepoint-scoped revival.
12//! - **Transactional side-effect queues** — queue wakes, claim-lock release,
13//!   and KV watch events flushed at commit / dropped at rollback.
14//! - **Snapshot / xid accessors** — the per-connection snapshot, xid, and
15//!   vacuum-cutoff surface used by transports and tests.
16//!
17//! Behaviour-preserving move: every item keeps its name, signature and
18//! visibility so `execute_query_inner` and sibling-file callers need no
19//! call-site edits.
20
21use super::execution_context::current_connection_id;
22use super::*;
23
24/// An unwinding DML callback must not leave a thread-local capture installed.
25struct DmlWalCapture(bool);
26
27impl DmlWalCapture {
28    fn begin() -> Self {
29        assert!(
30            !UnifiedStore::deferred_store_wal_capture_active(),
31            "DML WAL captures cannot nest"
32        );
33        UnifiedStore::begin_deferred_store_wal_capture();
34        Self(true)
35    }
36
37    fn finish(mut self) -> crate::storage::unified::DeferredStoreWalActions {
38        self.0 = false;
39        UnifiedStore::take_deferred_store_wal_capture()
40    }
41}
42
43impl Drop for DmlWalCapture {
44    fn drop(&mut self) {
45        if self.0 {
46            let _ = UnifiedStore::take_deferred_store_wal_capture();
47        }
48    }
49}
50
51impl RedDBRuntime {
52    pub(crate) fn rollback_connection(
53        &self,
54        connection_id: u64,
55    ) -> RedDBResult<Option<crate::storage::transaction::snapshot::TxnContext>> {
56        // Keep writer XIDs active until undo has consumed their row fields.
57        // Otherwise GC can reclaim an aborted version while undo waits for
58        // index backfill, leaving the version's secondary-index entries behind.
59        let undo = self.revive_pending_versioned_updates(connection_id);
60        self.revive_pending_tombstones(connection_id);
61        let context = self.inner.transaction_state.rollback(connection_id);
62        self.discard_pending_queue_dedup(connection_id);
63        self.discard_pending_kv_watch_events(connection_id);
64        self.discard_pending_queue_wakes(connection_id);
65        self.discard_pending_store_wal_actions(connection_id);
66        self.release_pending_claim_locks(connection_id);
67        self.discard_pending_vault_writes(connection_id)?;
68        undo?;
69        Ok(context)
70    }
71
72    /// Record that the running transaction has marked `id` in `collection`
73    /// for deletion (Phase 2.3.2b MVCC tombstones). `stamper_xid` is the
74    /// xid that was written into `xmax` — either the parent txn xid or
75    /// the innermost savepoint sub-xid. Savepoint rollback filters by
76    /// this xid to revive only its own tombstones.
77    pub(crate) fn record_pending_tombstone(
78        &self,
79        conn_id: u64,
80        collection: &str,
81        id: crate::storage::unified::entity::EntityId,
82        stamper_xid: crate::storage::transaction::snapshot::Xid,
83        previous_xmax: crate::storage::transaction::snapshot::Xid,
84    ) {
85        self.inner
86            .pending_tombstones
87            .write()
88            .entry(conn_id)
89            .or_default()
90            .push((collection.to_string(), id, stamper_xid, previous_xmax));
91    }
92
93    pub(crate) fn record_pending_versioned_update(
94        &self,
95        conn_id: u64,
96        collection: &str,
97        old_id: crate::storage::unified::entity::EntityId,
98        new_id: crate::storage::unified::entity::EntityId,
99        stamper_xid: crate::storage::transaction::snapshot::Xid,
100        previous_xmax: crate::storage::transaction::snapshot::Xid,
101    ) {
102        self.inner
103            .pending_versioned_updates
104            .write()
105            .entry(conn_id)
106            .or_default()
107            .push((
108                collection.to_string(),
109                old_id,
110                new_id,
111                stamper_xid,
112                previous_xmax,
113            ));
114    }
115
116    pub(crate) fn record_pending_queue_dedup(
117        &self,
118        conn_id: u64,
119        queue: &str,
120        dedup_key: &str,
121        metadata_id: crate::storage::unified::entity::EntityId,
122    ) {
123        self.inner
124            .pending_queue_dedup
125            .write()
126            .entry(conn_id)
127            .or_default()
128            .push((queue.to_string(), dedup_key.to_string(), metadata_id));
129    }
130
131    pub(crate) fn with_deferred_store_wal_if_transaction<T>(
132        &self,
133        f: impl FnOnce() -> RedDBResult<T>,
134    ) -> RedDBResult<T> {
135        let conn_id = current_connection_id();
136        if !self.inner.transaction_state.in_transaction(conn_id) {
137            return f();
138        }
139
140        let capture = DmlWalCapture::begin();
141        let result = f();
142        let captured = capture.finish();
143        match result {
144            Ok(value) => {
145                self.record_pending_store_wal_actions(conn_id, captured);
146                Ok(value)
147            }
148            Err(err) => Err(err),
149        }
150    }
151
152    pub(crate) fn with_deferred_store_wal_for_dml<T>(
153        &self,
154        capture_autocommit_events: bool,
155        f: impl FnOnce() -> RedDBResult<T>,
156    ) -> RedDBResult<T> {
157        let conn_id = current_connection_id();
158        if self.inner.transaction_state.in_transaction(conn_id) {
159            return self.with_deferred_store_wal_if_transaction(f);
160        }
161        self.drain_committed_writes_under_pressure(0)?;
162        if !capture_autocommit_events {
163            return f();
164        }
165
166        let deletions = super::delete_publication::DeletePublicationScope::begin();
167        let capture = DmlWalCapture::begin();
168        let result = f();
169        let captured = capture.finish();
170        if result.is_err() && deletions.has_pending() {
171            return result;
172        }
173        deletions.publish(self, || {
174            self.inner
175                .db
176                .store()
177                .append_deferred_store_wal_actions(captured)
178                .map_err(|err| RedDBError::Internal(err.to_string()))
179        })?;
180        result
181    }
182
183    pub(crate) fn insert_may_emit_events(&self, query: &InsertQuery) -> bool {
184        !query.suppress_events
185            && self.collection_has_event_subscriptions_for_operation(
186                &query.table,
187                crate::catalog::SubscriptionOperation::Insert,
188            )
189    }
190
191    pub(crate) fn update_may_emit_events(&self, query: &UpdateQuery) -> bool {
192        !query.suppress_events
193            && self.collection_has_event_subscriptions_for_operation(
194                &query.table,
195                crate::catalog::SubscriptionOperation::Update,
196            )
197    }
198
199    pub(crate) fn delete_may_emit_events(&self, query: &DeleteQuery) -> bool {
200        !query.suppress_events
201            && self.collection_has_event_subscriptions_for_operation(
202                &query.table,
203                crate::catalog::SubscriptionOperation::Delete,
204            )
205    }
206
207    fn collection_has_event_subscriptions_for_operation(
208        &self,
209        collection: &str,
210        operation: crate::catalog::SubscriptionOperation,
211    ) -> bool {
212        let Some(contract) = self.db().collection_contract_arc(collection) else {
213            return false;
214        };
215        contract.subscriptions.iter().any(|subscription| {
216            subscription.enabled
217                && (subscription.ops_filter.is_empty()
218                    || subscription.ops_filter.contains(&operation))
219        })
220    }
221
222    pub(crate) fn record_pending_store_wal_actions(
223        &self,
224        conn_id: u64,
225        actions: crate::storage::unified::DeferredStoreWalActions,
226    ) {
227        if actions.is_empty() {
228            return;
229        }
230        let mut guard = self.inner.pending_store_wal_actions.write();
231        guard.entry(conn_id).or_default().extend(actions);
232    }
233
234    pub(crate) fn flush_pending_store_wal_actions(&self, conn_id: u64) -> RedDBResult<()> {
235        let Some(actions) = self
236            .inner
237            .pending_store_wal_actions
238            .write()
239            .remove(&conn_id)
240        else {
241            return Ok(());
242        };
243        self.inner
244            .db
245            .store()
246            .append_deferred_store_wal_actions(actions)
247            .map_err(|err| RedDBError::Internal(err.to_string()))
248    }
249
250    pub(crate) fn discard_pending_store_wal_actions(&self, conn_id: u64) {
251        self.inner
252            .pending_store_wal_actions
253            .write()
254            .remove(&conn_id);
255    }
256
257    fn xid_conflicts_with_snapshot(
258        &self,
259        xid: crate::storage::transaction::snapshot::Xid,
260        snapshot: &crate::storage::transaction::snapshot::Snapshot,
261        own_xids: &std::collections::HashSet<crate::storage::transaction::snapshot::Xid>,
262    ) -> bool {
263        xid != 0
264            && !own_xids.contains(&xid)
265            && !self
266                .inner
267                .transaction_state
268                .snapshot_manager()
269                .is_aborted(xid)
270            && !self
271                .inner
272                .transaction_state
273                .snapshot_manager()
274                .is_active(xid)
275            && (xid > snapshot.xid || snapshot.in_progress.contains(&xid))
276    }
277
278    pub(super) fn conflict_error(
279        collection: &str,
280        logical_id: crate::storage::unified::entity::EntityId,
281        xid: crate::storage::transaction::snapshot::Xid,
282    ) -> RedDBError {
283        RedDBError::Query(format!(
284            "serialization conflict: table row {collection}/{} was modified by concurrent transaction {xid}",
285            logical_id.raw()
286        ))
287    }
288
289    fn check_logical_row_conflict(
290        &self,
291        collection: &str,
292        logical_id: crate::storage::unified::entity::EntityId,
293        excluded_ids: &[crate::storage::unified::entity::EntityId],
294        snapshot: &crate::storage::transaction::snapshot::Snapshot,
295        own_xids: &std::collections::HashSet<crate::storage::transaction::snapshot::Xid>,
296    ) -> RedDBResult<()> {
297        let store = self.inner.db.store();
298        let Some(manager) = store.get_collection(collection) else {
299            return Ok(());
300        };
301
302        for candidate in manager.query_all(|_| true) {
303            if excluded_ids.contains(&candidate.id) || candidate.logical_id() != logical_id {
304                continue;
305            }
306            if self.xid_conflicts_with_snapshot(candidate.xmin, snapshot, own_xids) {
307                return Err(Self::conflict_error(collection, logical_id, candidate.xmin));
308            }
309            if self.xid_conflicts_with_snapshot(candidate.xmax, snapshot, own_xids) {
310                return Err(Self::conflict_error(collection, logical_id, candidate.xmax));
311            }
312        }
313        Ok(())
314    }
315
316    pub(crate) fn check_table_row_write_conflicts(
317        &self,
318        conn_id: u64,
319        snapshot: &crate::storage::transaction::snapshot::Snapshot,
320        own_xids: &std::collections::HashSet<crate::storage::transaction::snapshot::Xid>,
321        isolation: crate::storage::transaction::IsolationLevel,
322    ) -> RedDBResult<()> {
323        let versioned_updates = self
324            .inner
325            .pending_versioned_updates
326            .read()
327            .get(&conn_id)
328            .cloned()
329            .unwrap_or_default();
330        let tombstones = self
331            .inner
332            .pending_tombstones
333            .read()
334            .get(&conn_id)
335            .cloned()
336            .unwrap_or_default();
337
338        let mut serializable_write_set = std::collections::HashSet::new();
339        let store = self.inner.db.store();
340        for (collection, old_id, new_id, xid, previous_xmax) in versioned_updates {
341            let Some(manager) = store.get_collection(&collection) else {
342                continue;
343            };
344            let Some(old) = manager.get(old_id) else {
345                continue;
346            };
347            let logical_id = old.logical_id();
348            serializable_write_set.insert((collection.clone(), logical_id));
349            if self.xid_conflicts_with_snapshot(previous_xmax, snapshot, own_xids) {
350                return Err(Self::conflict_error(&collection, logical_id, previous_xmax));
351            }
352            if old.xmax != xid && self.xid_conflicts_with_snapshot(old.xmax, snapshot, own_xids) {
353                return Err(Self::conflict_error(&collection, logical_id, old.xmax));
354            }
355            self.check_logical_row_conflict(
356                &collection,
357                logical_id,
358                &[old_id, new_id],
359                snapshot,
360                own_xids,
361            )?;
362        }
363
364        for (collection, id, xid, previous_xmax) in tombstones {
365            let Some(manager) = store.get_collection(&collection) else {
366                continue;
367            };
368            let Some(entity) = manager.get(id) else {
369                continue;
370            };
371            let logical_id = entity.logical_id();
372            serializable_write_set.insert((collection.clone(), logical_id));
373            if self.xid_conflicts_with_snapshot(previous_xmax, snapshot, own_xids) {
374                return Err(Self::conflict_error(&collection, logical_id, previous_xmax));
375            }
376            if entity.xmax != xid
377                && self.xid_conflicts_with_snapshot(entity.xmax, snapshot, own_xids)
378            {
379                return Err(Self::conflict_error(&collection, logical_id, entity.xmax));
380            }
381            self.check_logical_row_conflict(&collection, logical_id, &[id], snapshot, own_xids)?;
382        }
383
384        if isolation == crate::storage::transaction::IsolationLevel::Serializable
385            && self
386                .inner
387                .transaction_state
388                .snapshot_manager()
389                .serializable_commit_would_be_dangerous(
390                    *own_xids.iter().min().unwrap_or(&0),
391                    &serializable_write_set,
392                )
393        {
394            return Err(RedDBError::Query(
395                "serialization conflict: serializable transaction would complete rw-antidependency dangerous structure".to_string(),
396            ));
397        }
398
399        Ok(())
400    }
401
402    pub(crate) fn check_queue_dedup_write_conflicts(
403        &self,
404        conn_id: u64,
405        snapshot: &crate::storage::transaction::snapshot::Snapshot,
406        own_xids: &std::collections::HashSet<crate::storage::transaction::snapshot::Xid>,
407    ) -> RedDBResult<()> {
408        let pending = self
409            .inner
410            .pending_queue_dedup
411            .read()
412            .get(&conn_id)
413            .cloned()
414            .unwrap_or_default();
415        if pending.is_empty() {
416            return Ok(());
417        }
418
419        let store = self.inner.db.store();
420        let Some(manager) = store.get_collection(crate::runtime::impl_queue::QUEUE_META_COLLECTION)
421        else {
422            return Ok(());
423        };
424
425        for (queue, dedup_key, metadata_id) in pending {
426            for candidate in manager.query_all(|entity| {
427                entity.data.as_row().is_some_and(|row| {
428                    crate::runtime::impl_queue::row_text(row, "kind").as_deref()
429                        == Some(crate::runtime::impl_queue::KIND_QUEUE_PUSH_DEDUP)
430                        && crate::runtime::impl_queue::row_text(row, "queue").as_deref()
431                            == Some(&queue)
432                        && crate::runtime::impl_queue::row_text(row, "dedup_key").as_deref()
433                            == Some(&dedup_key)
434                })
435            }) {
436                if candidate.id == metadata_id
437                    || self
438                        .inner
439                        .transaction_state
440                        .snapshot_manager()
441                        .is_aborted(candidate.xmin)
442                {
443                    continue;
444                }
445                if self.xid_conflicts_with_snapshot(candidate.xmin, snapshot, own_xids) {
446                    return Err(RedDBError::Query(format!(
447                        "serialization conflict: queue dedup key {queue}/{dedup_key} was modified by concurrent transaction {}",
448                        candidate.xmin
449                    )));
450                }
451            }
452        }
453        Ok(())
454    }
455
456    pub(crate) fn restore_pending_write_stamps(&self, conn_id: u64) {
457        let versioned_updates = self
458            .inner
459            .pending_versioned_updates
460            .read()
461            .get(&conn_id)
462            .cloned()
463            .unwrap_or_default();
464        let tombstones = self
465            .inner
466            .pending_tombstones
467            .read()
468            .get(&conn_id)
469            .cloned()
470            .unwrap_or_default();
471
472        let store = self.inner.db.store();
473        for (collection, old_id, _new_id, xid, _previous_xmax) in versioned_updates {
474            if let Some(manager) = store.get_collection(&collection) {
475                if let Some(mut entity) = manager.get(old_id) {
476                    entity.set_xmax(xid);
477                    let _ = manager.update(entity);
478                }
479            }
480        }
481        for (collection, id, xid, _previous_xmax) in tombstones {
482            if let Some(manager) = store.get_collection(&collection) {
483                if let Some(mut entity) = manager.get(id) {
484                    entity.set_xmax(xid);
485                    let _ = manager.update(entity);
486                }
487            }
488        }
489    }
490
491    pub(crate) fn finalize_pending_versioned_updates(&self, conn_id: u64) {
492        self.inner
493            .pending_versioned_updates
494            .write()
495            .remove(&conn_id);
496    }
497
498    pub(crate) fn finalize_pending_queue_dedup(&self, conn_id: u64) {
499        self.inner.pending_queue_dedup.write().remove(&conn_id);
500    }
501
502    pub(crate) fn discard_pending_queue_dedup(&self, conn_id: u64) {
503        let Some(pending) = self.inner.pending_queue_dedup.write().remove(&conn_id) else {
504            return;
505        };
506        let store = self.inner.db.store();
507        for (_queue, _dedup_key, metadata_id) in pending {
508            let _ = store.delete(
509                crate::runtime::impl_queue::QUEUE_META_COLLECTION,
510                metadata_id,
511            );
512        }
513    }
514
515    pub(crate) fn revive_pending_versioned_updates(&self, conn_id: u64) -> RedDBResult<()> {
516        let Some(pending) = self
517            .inner
518            .pending_versioned_updates
519            .write()
520            .remove(&conn_id)
521        else {
522            return Ok(());
523        };
524        // Undo newest versions first so repeated writes restore the original chain.
525        for (collection, old_id, new_id, xid, previous_xmax) in pending.into_iter().rev() {
526            let topology_lock = self.index_store_ref().collection_topology_lock(&collection);
527            #[cfg(test)]
528            self.index_store_ref().mutation_test_hook(
529                &collection,
530                super::index_store::MutationTestPhase::BeforeUndoTopology,
531            );
532            self.revive_versioned_update(
533                &collection,
534                old_id,
535                new_id,
536                xid,
537                previous_xmax,
538                topology_lock.read(),
539            )?;
540        }
541        Ok(())
542    }
543
544    pub(crate) fn revive_versioned_update(
545        &self,
546        collection: &str,
547        old_id: crate::storage::EntityId,
548        new_id: crate::storage::EntityId,
549        xid: u64,
550        previous_xmax: u64,
551        _topology_guard: parking_lot::RwLockReadGuard<'_, ()>,
552    ) -> RedDBResult<()> {
553        let store = self.inner.db.store();
554        if let Some(new) = store.get(collection, new_id) {
555            let fields = crate::application::ports::entity_row_fields_snapshot(&new);
556            self.index_store_ref()
557                .index_entity_delete(collection, new_id, &fields)
558                .map_err(RedDBError::Internal)?;
559        }
560        store
561            .delete_batch(collection, &[new_id])
562            .map_err(|error| RedDBError::Internal(error.to_string()))?;
563        if let Some(manager) = store.get_collection(collection) {
564            if let Some(mut old) = manager.get(old_id) {
565                if old.xmax == xid {
566                    old.set_xmax(previous_xmax);
567                    manager
568                        .update(old.clone())
569                        .map_err(|error| RedDBError::Internal(error.to_string()))?;
570                    store.context_index().index_entity(collection, &old);
571                    let fields = crate::application::ports::entity_row_fields_snapshot(&old);
572                    self.index_store_ref()
573                        .index_entity_insert(collection, old_id, &fields)
574                        .map_err(RedDBError::Internal)?;
575                }
576            }
577        }
578        Ok(())
579    }
580
581    /// Flush tombstones on COMMIT. The xmax stamp is already the durable
582    /// delete marker; commit only drops the rollback journal and emits
583    /// side effects. Physical reclamation is left for VACUUM so old
584    /// snapshots can still resolve the pre-delete row version.
585    pub(crate) fn finalize_pending_tombstones(&self, conn_id: u64) {
586        let Some(pending) = self.inner.pending_tombstones.write().remove(&conn_id) else {
587            return;
588        };
589        if pending.is_empty() {
590            return;
591        }
592
593        let store = self.inner.db.store();
594        for (collection, id, _xid, _previous_xmax) in pending {
595            store.context_index().remove_entity(id);
596            self.cdc_emit(
597                crate::replication::cdc::ChangeOperation::Delete,
598                &collection,
599                id.raw(),
600                "entity",
601            );
602        }
603    }
604
605    /// Revive tombstones on ROLLBACK — reset `xmax` to 0 so the tuples
606    /// become visible again to future snapshots. Best-effort: a row
607    /// already reclaimed by a concurrent VACUUM stays gone, but VACUUM
608    /// never reclaims tuples whose xmax is still referenced by any
609    /// active snapshot, so this case is only reachable via external
610    /// storage corruption.
611    pub(crate) fn revive_pending_tombstones(&self, conn_id: u64) {
612        let Some(pending) = self.inner.pending_tombstones.write().remove(&conn_id) else {
613            return;
614        };
615
616        let store = self.inner.db.store();
617        for (collection, id, xid, previous_xmax) in pending {
618            let Some(manager) = store.get_collection(&collection) else {
619                continue;
620            };
621            if let Some(mut entity) = manager.get(id) {
622                if entity.xmax == xid {
623                    entity.set_xmax(previous_xmax);
624                    let _ = manager.update(entity);
625                }
626            }
627        }
628    }
629
630    /// Slice C of PRD #718 — accessor for the local wait registry.
631    pub fn queue_wait_registry(
632        &self,
633    ) -> std::sync::Arc<crate::runtime::queue_wait_registry::QueueWaitRegistry> {
634        self.inner.queue_wait_registry.clone()
635    }
636
637    /// Buffer a `(scope, queue)` wake on the current connection so it
638    /// fires post-COMMIT, or notify immediately if no transaction is
639    /// open (autocommit path). The wait registry only ever observes
640    /// notifies for committed work — rollback drops the buffer.
641    pub(crate) fn record_queue_wake(&self, scope: &str, queue: &str) {
642        if self.current_xid().is_some() {
643            let conn_id = current_connection_id();
644            self.inner
645                .pending_queue_wakes
646                .write()
647                .entry(conn_id)
648                .or_default()
649                .push((scope.to_string(), queue.to_string()));
650            return;
651        }
652        self.inner.queue_wait_registry.notify(scope, queue);
653    }
654
655    pub(crate) fn finalize_pending_queue_wakes(&self, conn_id: u64) {
656        let Some(pending) = self.inner.pending_queue_wakes.write().remove(&conn_id) else {
657            return;
658        };
659        for (scope, queue) in pending {
660            self.inner.queue_wait_registry.notify(&scope, &queue);
661        }
662    }
663
664    pub(crate) fn discard_pending_queue_wakes(&self, conn_id: u64) {
665        self.inner.pending_queue_wakes.write().remove(&conn_id);
666    }
667
668    pub(crate) fn release_pending_claim_locks(&self, conn_id: u64) {
669        self.inner
670            .pending_claim_locks
671            .write()
672            .retain(|_, owner| *owner != conn_id);
673    }
674
675    pub(crate) fn finalize_pending_kv_watch_events(&self, conn_id: u64) {
676        let Some(pending) = self.inner.pending_kv_watch_events.write().remove(&conn_id) else {
677            return;
678        };
679        for (_, event) in pending {
680            self.cdc_emit_kv(
681                event.op,
682                &event.collection,
683                &event.key,
684                0,
685                event.before,
686                event.after,
687            );
688        }
689    }
690
691    pub(crate) fn discard_pending_kv_watch_events(&self, conn_id: u64) {
692        self.inner.pending_kv_watch_events.write().remove(&conn_id);
693    }
694
695    /// Phase 1.1 MVCC universal: post-save hook that stamps `xmin` on a
696    /// freshly-inserted entity when the current connection holds an
697    /// open transaction. Used by graph / queue / timeseries
698    /// write paths that go through the DevX builder API (`db.node(...)
699    /// .save()` and friends) — those live in the storage crate and
700    /// can't reach `current_xid()` without crossing layers, so the
701    /// application layer calls this helper right after `save()` to
702    /// finalise the MVCC stamp.
703    ///
704    /// Autocommit (outside BEGIN) is a no-op — no extra lookup or
705    /// write, so the non-transactional hot path stays untouched.
706    ///
707    /// Best-effort: if the collection or entity disappears between
708    /// the save and the stamp (concurrent DROP), we silently skip.
709    pub(crate) fn stamp_xmin_if_in_txn(
710        &self,
711        collection: &str,
712        id: crate::storage::unified::entity::EntityId,
713    ) {
714        let Some(xid) = self.current_xid() else {
715            return;
716        };
717        let store = self.inner.db.store();
718        let Some(manager) = store.get_collection(collection) else {
719            return;
720        };
721        if let Some(mut entity) = manager.get(id) {
722            entity.set_xmin(xid);
723            let _ = manager.update(entity);
724        }
725    }
726
727    /// Revive tombstones stamped by `stamper_xid` or any sub-xid
728    /// allocated after it (Phase 2.3.2e savepoint rollback). Any
729    /// pending entries with `xid < stamper_xid` stay queued because
730    /// they belong to the enclosing scope — they'll either flush on
731    /// COMMIT or revive on an outer ROLLBACK TO SAVEPOINT.
732    ///
733    /// Returns the number of tuples whose `xmax` was wiped back to 0.
734    pub(crate) fn revive_tombstones_since(&self, conn_id: u64, stamper_xid: u64) -> usize {
735        let mut guard = self.inner.pending_tombstones.write();
736        let Some(pending) = guard.get_mut(&conn_id) else {
737            return 0;
738        };
739
740        let store = self.inner.db.store();
741        let mut revived = 0usize;
742        pending.retain(|(collection, id, xid, previous_xmax)| {
743            if *xid < stamper_xid {
744                // Stamped before the savepoint — keep in queue.
745                return true;
746            }
747            if let Some(manager) = store.get_collection(collection) {
748                if let Some(mut entity) = manager.get(*id) {
749                    if entity.xmax == *xid {
750                        entity.set_xmax(*previous_xmax);
751                        let _ = manager.update(entity);
752                        revived += 1;
753                    }
754                }
755            }
756            false
757        });
758        if pending.is_empty() {
759            guard.remove(&conn_id);
760        }
761        revived
762    }
763
764    /// Return the snapshot the current connection should use for visibility
765    /// checks (Phase 2.3 PG parity).
766    ///
767    /// * If the connection is inside a READ COMMITTED transaction, capture
768    ///   a fresh statement snapshot while preserving the transaction's xid
769    ///   and write-set lifecycle.
770    /// * If the connection is inside a SNAPSHOT transaction, reuse the
771    ///   snapshot stored in its `TxnContext`.
772    /// * Otherwise (autocommit), capture a fresh snapshot tied to an
773    ///   implicit xid=0 — the read path treats pre-MVCC rows as always
774    ///   visible so this degrades to "see everything committed".
775    pub fn current_snapshot(&self) -> crate::storage::transaction::snapshot::Snapshot {
776        self.inner
777            .transaction_state
778            .current_snapshot(current_connection_id())
779    }
780
781    /// Xid of the current connection's active transaction, or `None` when
782    /// running outside a BEGIN/COMMIT block. Write paths call this to
783    /// decide whether to stamp `xmin`/`xmax` on tuples.
784    /// Phase 2.3.2e: when a savepoint is open, `writer_xid` returns the
785    /// sub-xid so new writes can be selectively rolled back. Otherwise
786    /// the parent txn's xid is returned, matching pre-savepoint
787    /// behaviour. Callers that need the enclosing *transaction* xid
788    /// (e.g. VACUUM min-active calculations) should read `ctx.xid`
789    /// directly.
790    pub fn current_xid(&self) -> Option<crate::storage::transaction::snapshot::Xid> {
791        self.inner
792            .transaction_state
793            .writer_xid(current_connection_id())
794    }
795
796    pub(crate) fn own_transaction_xids(&self) -> std::collections::HashSet<u64> {
797        self.inner
798            .transaction_state
799            .own_xids(current_connection_id())
800    }
801
802    /// `true` when the given connection id has an open `BEGIN`. Issue
803    /// #760 — `OpenStream` consults this to refuse output streams that
804    /// would otherwise collide with an interactive transaction (see
805    /// ADR 0029 "Transaction interaction"). HTTP requests pre-dating the
806    /// connection-id plumbing run with id `0`, which never carries a
807    /// transaction context, so this returns `false` on those paths.
808    pub fn connection_in_transaction(&self, conn_id: u64) -> bool {
809        self.inner.transaction_state.in_transaction(conn_id)
810    }
811
812    /// Access the shared `SnapshotManager` — useful for VACUUM to compute
813    /// the oldest-active xid when reclaiming dead tuples.
814    pub fn snapshot_manager(&self) -> Arc<crate::storage::transaction::snapshot::SnapshotManager> {
815        self.inner.transaction_state.snapshot_manager()
816    }
817
818    pub(crate) fn mvcc_vacuum_cutoff_xid(&self) -> crate::storage::transaction::snapshot::Xid {
819        let manager = self.inner.transaction_state.snapshot_manager();
820        let next_xid = manager.peek_next_xid();
821        let mut cutoff = next_xid;
822        if let Some(oldest_required) = manager.oldest_required_xid() {
823            cutoff = cutoff.min(oldest_required);
824        }
825        let retention_xids = self.config_u64("runtime.mvcc.vacuum_retention_xids", 0);
826        if retention_xids > 0 {
827            cutoff = cutoff.min(next_xid.saturating_sub(retention_xids));
828        }
829        cutoff
830    }
831
832    /// Own-tx xids (parent + open/released savepoints) for the current
833    /// connection. Transports + tests that build a `SnapshotContext`
834    /// manually (outside the `execute_query` scope) need this set so
835    /// the writer's own uncommitted tuples stay visible to self.
836    pub fn current_txn_own_xids(
837        &self,
838    ) -> std::collections::HashSet<crate::storage::transaction::snapshot::Xid> {
839        self.inner
840            .transaction_state
841            .own_xids(current_connection_id())
842    }
843}