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