Skip to main content

reddb_server/runtime/
mvcc_lifecycle.rs

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