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