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