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