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