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    static GRAPH_INSERT_WRITER: std::cell::Cell<Option<(usize, u64, u64)>> = const { std::cell::Cell::new(None) };
27    static DML_CAPTURE_OWNER: std::cell::Cell<Option<(usize, u64, bool)>> = const { std::cell::Cell::new(None) };
28    static DML_CAPTURE_GRAPH_WRITER: std::cell::RefCell<Option<GraphInsertWriter>> = const { std::cell::RefCell::new(None) };
29}
30
31struct GraphInsertWriter {
32    manager: std::sync::Arc<crate::storage::transaction::snapshot::SnapshotManager>,
33    xid: u64,
34    finished: bool,
35}
36
37impl GraphInsertWriter {
38    fn begin(runtime: &RedDBRuntime) -> Self {
39        let manager = runtime.snapshot_manager();
40        let xid = manager.begin();
41        let identity = std::sync::Arc::as_ptr(&runtime.inner) as usize;
42        GRAPH_INSERT_WRITER.with(|slot| {
43            assert!(slot
44                .replace(Some((identity, current_connection_id(), xid)))
45                .is_none());
46        });
47        Self {
48            manager,
49            xid,
50            finished: false,
51        }
52    }
53
54    fn commit(&mut self) {
55        self.manager.commit(self.xid);
56        self.finished = true;
57    }
58}
59
60impl Drop for GraphInsertWriter {
61    fn drop(&mut self) {
62        GRAPH_INSERT_WRITER.with(|slot| slot.set(None));
63        if !self.finished {
64            self.manager.rollback(self.xid);
65        }
66    }
67}
68
69struct DeferredUpdate {
70    runtime: RedDBRuntime,
71    collection: String,
72    old_id: EntityId,
73    new_id: EntityId,
74    previous_xmax: u64,
75    update: Arc<crate::application::entity::AutocommitUpdate>,
76    committed: bool,
77}
78
79impl Drop for DeferredUpdate {
80    fn drop(&mut self) {
81        if !self.committed {
82            let topology = self
83                .runtime
84                .index_store_ref()
85                .collection_topology_lock(&self.collection);
86            if let Err(error) = self.runtime.revive_versioned_update(
87                &self.collection,
88                self.old_id,
89                self.new_id,
90                self.update.xid,
91                self.previous_xmax,
92                topology.read_recursive(),
93            ) {
94                tracing::error!(%error, "autocommit update rollback failed");
95            }
96        }
97    }
98}
99
100struct DeferredUpdates;
101
102impl DeferredUpdates {
103    fn begin() -> Self {
104        DEFERRED_UPDATES.with(|slot| {
105            assert!(slot.borrow().is_none(), "update WAL scopes cannot nest");
106            *slot.borrow_mut() = Some(Vec::new());
107        });
108        Self
109    }
110
111    fn commit(self) {
112        DEFERRED_UPDATES.with(|slot| {
113            for mut pending in slot.borrow_mut().take().expect("update WAL scope") {
114                let update = &pending.update;
115                update.commit();
116                pending.committed = true;
117            }
118        });
119    }
120
121    fn prepare(&self) -> RedDBResult<()> {
122        DEFERRED_UPDATES.with(|slot| {
123            for pending in slot.borrow().as_ref().expect("update WAL scope") {
124                let store = pending.runtime.db().store();
125                let Some(manager) = store.get_collection(&pending.collection) else {
126                    return Err(RedDBError::Internal(
127                        "updated collection disappeared".into(),
128                    ));
129                };
130                let epoch = manager
131                    .try_get(pending.new_id, usize::MAX)?
132                    .and_then(|entity| entity.branch_version().map(|version| version.origin));
133                let old_owned = manager
134                    .try_get(pending.old_id, usize::MAX)
135                    .map_err(|error| RedDBError::Internal(error.to_string()))?
136                    .is_some_and(|old| {
137                        epoch.map_or(old.xmax, |epoch| old.branch_xmax(epoch)) == pending.update.xid
138                    });
139                let new_owned = manager
140                    .try_get(pending.new_id, usize::MAX)
141                    .map_err(|error| RedDBError::Internal(error.to_string()))?
142                    .is_some_and(|new| new.xmin == pending.update.xid);
143                if !old_owned || !new_owned {
144                    return Err(RedDBRuntime::conflict_error(
145                        &pending.collection,
146                        pending.old_id,
147                        pending.update.xid,
148                    ));
149                }
150            }
151            Ok(())
152        })
153    }
154}
155
156impl Drop for DeferredUpdates {
157    fn drop(&mut self) {
158        let pending = DEFERRED_UPDATES.with(|slot| slot.borrow_mut().take());
159        if let Some(pending) = pending {
160            for update in pending.into_iter().rev() {
161                drop(update);
162            }
163        }
164    }
165}
166
167pub(crate) fn autocommit_update_capture_available() -> bool {
168    DEFERRED_UPDATES.with(|slot| slot.borrow().is_some())
169}
170
171pub(crate) fn publish_autocommit_update(
172    runtime: &RedDBRuntime,
173    item: &crate::application::entity::AppliedEntityMutation,
174    update: &Arc<crate::application::entity::AutocommitUpdate>,
175) {
176    if UnifiedStore::deferred_store_wal_capture_active() {
177        DEFERRED_UPDATES.with(|slot| {
178            slot.borrow_mut()
179                .as_mut()
180                .expect("update WAL scope")
181                .push(DeferredUpdate {
182                    runtime: runtime.clone(),
183                    collection: item.collection.clone(),
184                    old_id: item.replaced_entity.as_ref().expect("versioned update").id,
185                    new_id: item.entity.id,
186                    previous_xmax: item.replaced_entity_previous_xmax,
187                    update: Arc::clone(update),
188                    committed: false,
189                });
190        });
191    } else {
192        update.commit();
193    }
194}
195
196/// An unwinding DML callback must not leave a thread-local capture installed.
197struct DmlWalCapture(bool);
198
199struct CapturedDmlWal {
200    actions: crate::storage::unified::DeferredStoreWalActions,
201    graph_writer: Option<GraphInsertWriter>,
202}
203
204impl DmlWalCapture {
205    fn begin(runtime: &RedDBRuntime, transactional: bool) -> Self {
206        assert!(
207            !UnifiedStore::deferred_store_wal_capture_active(),
208            "DML WAL captures cannot nest"
209        );
210        let identity = std::sync::Arc::as_ptr(&runtime.inner) as usize;
211        DML_CAPTURE_OWNER.with(|slot| {
212            assert!(slot
213                .replace(Some((identity, current_connection_id(), transactional)))
214                .is_none());
215        });
216        UnifiedStore::begin_deferred_store_wal_capture();
217        Self(true)
218    }
219
220    fn finish(mut self) -> CapturedDmlWal {
221        self.0 = false;
222        DML_CAPTURE_OWNER.with(|slot| slot.set(None));
223        CapturedDmlWal {
224            actions: UnifiedStore::take_deferred_store_wal_capture(),
225            graph_writer: DML_CAPTURE_GRAPH_WRITER.with(|slot| slot.borrow_mut().take()),
226        }
227    }
228}
229
230impl Drop for DmlWalCapture {
231    fn drop(&mut self) {
232        if self.0 {
233            DML_CAPTURE_OWNER.with(|slot| slot.set(None));
234            let _ = UnifiedStore::take_deferred_store_wal_capture();
235            DML_CAPTURE_GRAPH_WRITER.with(|slot| drop(slot.borrow_mut().take()));
236        }
237    }
238}
239
240impl RedDBRuntime {
241    /// Refresh an autocommit state mutation after acquiring its publication
242    /// fence. Explicit transactions retain their original read snapshot.
243    pub(super) fn vcs_state_write_snapshot(
244        &self,
245        collection: &str,
246    ) -> RedDBResult<(
247        super::execution_context::SnapshotContext,
248        crate::storage::transaction::snapshot::SnapshotRetentionGuard,
249    )> {
250        self.vcs_writer_epoch(collection)?;
251        let (mut context, retention) = self.native_read_snapshot()?;
252        if self.current_xid().is_none() {
253            context.snapshot = self.snapshot_manager().fresh_read_snapshot();
254            context.branch = self.vcs_branch_read_view(&context.snapshot, None)?;
255        }
256        Ok((context, retention))
257    }
258
259    /// Append one native state version. The caller serializes its logical key
260    /// before taking the publication fence and owns the enclosing WAL capture.
261    pub(crate) fn insert_vcs_state_version(
262        &self,
263        collection: &str,
264        mut entity: UnifiedEntity,
265        previous: Option<EntityId>,
266    ) -> RedDBResult<EntityId> {
267        let store = self.db().store();
268        let epoch = self.vcs_writer_epoch(collection)?.ok_or_else(|| {
269            RedDBError::InvalidOperation("state version requires a versioned collection".into())
270        })?;
271        let xid = self
272            .current_xid()
273            .or_else(|| self.graph_insert_xid())
274            .ok_or_else(|| {
275                RedDBError::InvalidOperation("state version requires a WAL writer".into())
276            })?;
277        entity.id = store.next_entity_id();
278        entity.set_xmin(xid);
279        entity.set_xmax(0);
280        entity.clear_branch_version();
281        entity.set_branch_origin(epoch);
282        let id = entity.id;
283        if let Some(previous) = previous {
284            let mut old = store
285                .try_get(collection, previous, usize::MAX)?
286                .ok_or_else(|| RedDBError::NotFound(previous.to_string()))?;
287            entity.set_logical_id(old.logical_id());
288            if let (
289                crate::storage::EntityKind::TableRow {
290                    row_id: destination,
291                    ..
292                },
293                crate::storage::EntityKind::TableRow { row_id: source, .. },
294            ) = (&mut entity.kind, &old.kind)
295            {
296                *destination = *source;
297            }
298            let previous_xmax = old.branch_xmax(epoch);
299            old.set_branch_xmax(epoch, xid);
300            store.install_versioned_table_row_update(collection, old, entity, None)?;
301            if self.current_xid().is_some() {
302                self.record_pending_versioned_update(
303                    current_connection_id(),
304                    collection,
305                    previous,
306                    id,
307                    xid,
308                    previous_xmax,
309                );
310            }
311        } else {
312            store.insert_auto(collection, entity)?;
313        }
314        Ok(id)
315    }
316
317    pub(crate) fn graph_insert_xid(&self) -> Option<u64> {
318        let identity = std::sync::Arc::as_ptr(&self.inner) as usize;
319        GRAPH_INSERT_WRITER.with(|slot| {
320            slot.get().and_then(|(owner, conn, xid)| {
321                (owner == identity && conn == current_connection_id()).then_some(xid)
322            })
323        })
324    }
325
326    /// Publish a versioned graph or vector insert after its WAL batch.
327    pub(crate) fn with_versioned_graph_insert<T>(
328        &self,
329        f: impl FnOnce() -> RedDBResult<T>,
330    ) -> RedDBResult<T> {
331        let identity = std::sync::Arc::as_ptr(&self.inner) as usize;
332        let connection_id = current_connection_id();
333        if self.current_xid().is_some() {
334            if UnifiedStore::deferred_store_wal_capture_active() {
335                if DML_CAPTURE_OWNER.with(|slot| slot.get())
336                    == Some((identity, connection_id, true))
337                {
338                    return f();
339                }
340                return Err(RedDBError::InvalidOperation(
341                    "versioned entity insertion cannot join an unrelated WAL capture".to_string(),
342                ));
343            }
344            return self.with_deferred_store_wal_if_transaction(f);
345        }
346        if self.graph_insert_xid().is_some() {
347            return f();
348        }
349        if UnifiedStore::deferred_store_wal_capture_active() {
350            if DML_CAPTURE_OWNER.with(|slot| slot.get()) != Some((identity, connection_id, false))
351                || GRAPH_INSERT_WRITER.with(|slot| slot.get().is_some())
352            {
353                return Err(RedDBError::InvalidOperation(
354                    "versioned entity insertion cannot join an unrelated WAL capture".to_string(),
355                ));
356            }
357            let writer = GraphInsertWriter::begin(self);
358            let result = f();
359            DML_CAPTURE_GRAPH_WRITER.with(|slot| {
360                assert!(slot.borrow_mut().replace(writer).is_none());
361            });
362            return result;
363        }
364        self.drain_committed_writes_under_pressure(0)?;
365        let deletions = super::delete_publication::DeletePublicationScope::begin();
366        let updates = DeferredUpdates::begin();
367        let mut writer = GraphInsertWriter::begin(self);
368        let capture = DmlWalCapture::begin(self, false);
369        let result = f();
370        let CapturedDmlWal {
371            actions,
372            mut graph_writer,
373        } = capture.finish();
374        if result.is_err() {
375            self.flush_deferred_cache_invalidation();
376            return result;
377        }
378        let published = deletions.publish(self, || {
379            updates.prepare()?;
380            self.inner
381                .db
382                .store()
383                .append_deferred_store_wal_actions(actions)
384                .map_err(|err| RedDBError::Internal(err.to_string()))?;
385            if let Some(writer) = &mut graph_writer {
386                writer.commit();
387            }
388            writer.commit();
389            updates.commit();
390            Ok(())
391        });
392        self.flush_deferred_cache_invalidation();
393        published?;
394        result
395    }
396
397    pub(crate) fn with_graph_insert_for_dml<T>(
398        &self,
399        query: &InsertQuery,
400        f: impl FnOnce() -> RedDBResult<T>,
401    ) -> RedDBResult<T> {
402        if matches!(
403            query.entity_type,
404            InsertEntityType::Node
405                | InsertEntityType::Edge
406                | InsertEntityType::Vector
407                | InsertEntityType::Document
408        ) && (self.vcs_is_versioned(&query.table)? || self.has_graph_contract(&query.table))
409        {
410            self.with_versioned_graph_insert(f)
411        } else {
412            self.with_deferred_store_wal_for_dml(self.insert_may_emit_events(query), f)
413        }
414    }
415
416    pub(crate) fn rollback_connection(
417        &self,
418        connection_id: u64,
419    ) -> RedDBResult<Option<crate::storage::transaction::snapshot::TxnContext>> {
420        // Keep writer XIDs active until undo has consumed their row fields.
421        // Otherwise GC can reclaim an aborted version while undo waits for
422        // index backfill, leaving the version's secondary-index entries behind.
423        let undo = self.revive_pending_versioned_updates(connection_id);
424        self.revive_pending_tombstones(connection_id);
425        let context = self.inner.transaction_state.rollback(connection_id);
426        self.discard_pending_queue_dedup(connection_id);
427        self.discard_pending_kv_watch_events(connection_id);
428        self.discard_pending_queue_wakes(connection_id);
429        self.discard_pending_store_wal_actions(connection_id);
430        self.release_pending_claim_locks(connection_id);
431        self.discard_pending_vault_writes(connection_id)?;
432        undo?;
433        Ok(context)
434    }
435
436    /// Record that the running transaction has marked `id` in `collection`
437    /// for deletion (Phase 2.3.2b MVCC tombstones). `stamper_xid` is the
438    /// xid that was written into `xmax` — either the parent txn xid or
439    /// the innermost savepoint sub-xid. Savepoint rollback filters by
440    /// this xid to revive only its own tombstones.
441    pub(crate) fn record_pending_tombstone(
442        &self,
443        conn_id: u64,
444        collection: &str,
445        id: crate::storage::unified::entity::EntityId,
446        stamper_xid: crate::storage::transaction::snapshot::Xid,
447        previous_xmax: crate::storage::transaction::snapshot::Xid,
448        branch_epoch: Option<u64>,
449    ) {
450        self.inner
451            .pending_tombstones
452            .write()
453            .entry(conn_id)
454            .or_default()
455            .push((
456                collection.to_string(),
457                id,
458                stamper_xid,
459                previous_xmax,
460                branch_epoch,
461            ));
462    }
463
464    pub(crate) fn record_pending_versioned_update(
465        &self,
466        conn_id: u64,
467        collection: &str,
468        old_id: crate::storage::unified::entity::EntityId,
469        new_id: crate::storage::unified::entity::EntityId,
470        stamper_xid: crate::storage::transaction::snapshot::Xid,
471        previous_xmax: crate::storage::transaction::snapshot::Xid,
472    ) {
473        self.inner
474            .pending_versioned_updates
475            .write()
476            .entry(conn_id)
477            .or_default()
478            .push((
479                collection.to_string(),
480                old_id,
481                new_id,
482                stamper_xid,
483                previous_xmax,
484            ));
485    }
486
487    pub(crate) fn record_pending_queue_dedup(
488        &self,
489        conn_id: u64,
490        queue: &str,
491        dedup_key: &str,
492        metadata_id: crate::storage::unified::entity::EntityId,
493    ) {
494        self.inner
495            .pending_queue_dedup
496            .write()
497            .entry(conn_id)
498            .or_default()
499            .push((queue.to_string(), dedup_key.to_string(), metadata_id));
500    }
501
502    pub(crate) fn with_deferred_store_wal_if_transaction<T>(
503        &self,
504        f: impl FnOnce() -> RedDBResult<T>,
505    ) -> RedDBResult<T> {
506        let conn_id = current_connection_id();
507        if !self.inner.transaction_state.in_transaction(conn_id) {
508            return f();
509        }
510
511        let logical_start = self
512            .inner
513            .pending_logical_records
514            .lock()
515            .get(&conn_id)
516            .map_or(0, |pending| pending.len());
517        let capture = DmlWalCapture::begin(self, true);
518        let result = f();
519        let CapturedDmlWal {
520            actions,
521            graph_writer,
522        } = capture.finish();
523        if graph_writer.is_some() {
524            return Err(RedDBError::InvalidOperation(
525                "autocommit graph writer cannot join a transaction WAL capture".to_string(),
526            ));
527        }
528        self.flush_deferred_cache_invalidation();
529        match result {
530            Ok(value) => {
531                self.record_pending_store_wal_actions(conn_id, actions);
532                Ok(value)
533            }
534            Err(err) => {
535                if let Some(records) = self.inner.pending_logical_records.lock().get_mut(&conn_id) {
536                    records.truncate(logical_start);
537                }
538                Err(err)
539            }
540        }
541    }
542
543    pub(crate) fn with_deferred_store_wal_for_dml<T>(
544        &self,
545        capture_autocommit_events: bool,
546        f: impl FnOnce() -> RedDBResult<T>,
547    ) -> RedDBResult<T> {
548        let conn_id = current_connection_id();
549        if self.inner.transaction_state.in_transaction(conn_id) {
550            return self.with_deferred_store_wal_if_transaction(f);
551        }
552        self.drain_committed_writes_under_pressure(0)?;
553        if !capture_autocommit_events {
554            return f();
555        }
556
557        let deletions = super::delete_publication::DeletePublicationScope::begin();
558        let updates = DeferredUpdates::begin();
559        let capture = DmlWalCapture::begin(self, false);
560        let result = f();
561        let CapturedDmlWal {
562            actions,
563            mut graph_writer,
564        } = capture.finish();
565        if result.is_err() && (deletions.has_pending() || graph_writer.is_some()) {
566            self.flush_deferred_cache_invalidation();
567            return result;
568        }
569        let published = deletions.publish(self, || {
570            updates.prepare()?;
571            self.inner
572                .db
573                .store()
574                .append_deferred_store_wal_actions(actions)
575                .map_err(|err| RedDBError::Internal(err.to_string()))?;
576            if let Some(writer) = &mut graph_writer {
577                writer.commit();
578            }
579            updates.commit();
580            Ok(())
581        });
582        self.flush_deferred_cache_invalidation();
583        published?;
584        result
585    }
586
587    pub(crate) fn insert_may_emit_events(&self, query: &InsertQuery) -> bool {
588        !query.suppress_events
589            && self.collection_has_event_subscriptions_for_operation(
590                &query.table,
591                crate::catalog::SubscriptionOperation::Insert,
592            )
593    }
594
595    pub(crate) fn update_may_emit_events(&self, query: &UpdateQuery) -> bool {
596        !query.suppress_events
597            && self.collection_has_event_subscriptions_for_operation(
598                &query.table,
599                crate::catalog::SubscriptionOperation::Update,
600            )
601    }
602
603    pub(crate) fn delete_may_emit_events(&self, query: &DeleteQuery) -> bool {
604        !query.suppress_events
605            && self.collection_has_event_subscriptions_for_operation(
606                &query.table,
607                crate::catalog::SubscriptionOperation::Delete,
608            )
609    }
610
611    fn collection_has_event_subscriptions_for_operation(
612        &self,
613        collection: &str,
614        operation: crate::catalog::SubscriptionOperation,
615    ) -> bool {
616        let Some(contract) = self.db().collection_contract_arc(collection) else {
617            return false;
618        };
619        contract.subscriptions.iter().any(|subscription| {
620            subscription.enabled
621                && (subscription.ops_filter.is_empty()
622                    || subscription.ops_filter.contains(&operation))
623        })
624    }
625
626    pub(crate) fn record_pending_store_wal_actions(
627        &self,
628        conn_id: u64,
629        actions: crate::storage::unified::DeferredStoreWalActions,
630    ) {
631        if actions.is_empty() {
632            return;
633        }
634        let mut guard = self.inner.pending_store_wal_actions.write();
635        guard
636            .entry(conn_id)
637            .or_default()
638            .push((self.current_xid().unwrap_or(0), actions));
639    }
640
641    pub(crate) fn flush_pending_store_wal_actions(&self, conn_id: u64) -> RedDBResult<()> {
642        let groups = self
643            .inner
644            .pending_store_wal_actions
645            .write()
646            .remove(&conn_id)
647            .unwrap_or_default();
648        let mut actions = crate::storage::unified::DeferredStoreWalActions::default();
649        for (xid, group) in groups {
650            if !self.snapshot_manager().is_aborted(xid) {
651                actions.extend(group);
652            }
653        }
654        self.commit_transactional_replication(conn_id, actions)
655    }
656
657    pub(crate) fn discard_pending_store_wal_actions(&self, conn_id: u64) {
658        self.inner.pending_commit_policies.lock().remove(&conn_id);
659        self.inner.pending_logical_records.lock().remove(&conn_id);
660        self.inner
661            .pending_store_wal_actions
662            .write()
663            .remove(&conn_id);
664    }
665
666    fn xid_conflicts_with_snapshot(
667        &self,
668        xid: crate::storage::transaction::snapshot::Xid,
669        snapshot: &crate::storage::transaction::snapshot::Snapshot,
670        own_xids: &std::collections::HashSet<crate::storage::transaction::snapshot::Xid>,
671    ) -> bool {
672        xid != 0
673            && !own_xids.contains(&xid)
674            && !self
675                .inner
676                .transaction_state
677                .snapshot_manager()
678                .is_aborted(xid)
679            && !self
680                .inner
681                .transaction_state
682                .snapshot_manager()
683                .is_active(xid)
684            && (xid > snapshot.xid || snapshot.in_progress.contains(&xid))
685    }
686
687    pub(super) fn conflict_error(
688        collection: &str,
689        logical_id: crate::storage::unified::entity::EntityId,
690        xid: crate::storage::transaction::snapshot::Xid,
691    ) -> RedDBError {
692        RedDBError::Query(format!(
693            "serialization conflict: table row {collection}/{} was modified by concurrent transaction {xid}",
694            logical_id.raw()
695        ))
696    }
697
698    fn check_logical_row_conflict(
699        &self,
700        collection: &str,
701        logical_id: crate::storage::unified::entity::EntityId,
702        excluded_ids: &[crate::storage::unified::entity::EntityId],
703        snapshot: &crate::storage::transaction::snapshot::Snapshot,
704        own_xids: &std::collections::HashSet<crate::storage::transaction::snapshot::Xid>,
705        branch_epoch: Option<u64>,
706    ) -> RedDBResult<()> {
707        let store = self.inner.db.store();
708        let Some(manager) = store.get_collection(collection) else {
709            return Ok(());
710        };
711
712        for candidate in manager.query_all(|_| true) {
713            if let Some(epoch) = branch_epoch {
714                if candidate
715                    .branch_version()
716                    .is_some_and(|version| version.origin != epoch)
717                {
718                    continue;
719                }
720            }
721            if excluded_ids.contains(&candidate.id) || candidate.logical_id() != logical_id {
722                continue;
723            }
724            if self.xid_conflicts_with_snapshot(candidate.xmin, snapshot, own_xids) {
725                return Err(Self::conflict_error(collection, logical_id, candidate.xmin));
726            }
727            if self.xid_conflicts_with_snapshot(candidate.xmax, snapshot, own_xids) {
728                return Err(Self::conflict_error(collection, logical_id, candidate.xmax));
729            }
730        }
731        Ok(())
732    }
733
734    pub(crate) fn check_table_row_write_conflicts(
735        &self,
736        conn_id: u64,
737        snapshot: &crate::storage::transaction::snapshot::Snapshot,
738        own_xids: &std::collections::HashSet<crate::storage::transaction::snapshot::Xid>,
739        isolation: crate::storage::transaction::IsolationLevel,
740    ) -> RedDBResult<()> {
741        let versioned_updates = self
742            .inner
743            .pending_versioned_updates
744            .read()
745            .get(&conn_id)
746            .cloned()
747            .unwrap_or_default();
748        let tombstones = self
749            .inner
750            .pending_tombstones
751            .read()
752            .get(&conn_id)
753            .cloned()
754            .unwrap_or_default();
755
756        let mut serializable_write_set = std::collections::HashSet::new();
757        let store = self.inner.db.store();
758        for (collection, old_id, new_id, xid, previous_xmax) in versioned_updates {
759            let Some(manager) = store.get_collection(&collection) else {
760                continue;
761            };
762            let Some(old) = manager.get(old_id) else {
763                continue;
764            };
765            let logical_id = old.logical_id();
766            let epoch = manager
767                .get(new_id)
768                .and_then(|entity| entity.branch_version().map(|version| version.origin));
769            let old_xmax = epoch.map_or(old.xmax, |epoch| old.branch_xmax(epoch));
770            serializable_write_set.insert((collection.clone(), logical_id));
771            if self.xid_conflicts_with_snapshot(previous_xmax, snapshot, own_xids) {
772                return Err(Self::conflict_error(&collection, logical_id, previous_xmax));
773            }
774            if old_xmax != xid && self.xid_conflicts_with_snapshot(old_xmax, snapshot, own_xids) {
775                return Err(Self::conflict_error(&collection, logical_id, old_xmax));
776            }
777            self.check_logical_row_conflict(
778                &collection,
779                logical_id,
780                &[old_id, new_id],
781                snapshot,
782                own_xids,
783                epoch,
784            )?;
785        }
786
787        for (collection, id, xid, previous_xmax, epoch) in tombstones {
788            let Some(manager) = store.get_collection(&collection) else {
789                continue;
790            };
791            let Some(entity) = manager.get(id) else {
792                continue;
793            };
794            let logical_id = entity.logical_id();
795            let xmax = epoch.map_or(entity.xmax, |epoch| entity.branch_xmax(epoch));
796            serializable_write_set.insert((collection.clone(), logical_id));
797            if self.xid_conflicts_with_snapshot(previous_xmax, snapshot, own_xids) {
798                return Err(Self::conflict_error(&collection, logical_id, previous_xmax));
799            }
800            if xmax != xid && self.xid_conflicts_with_snapshot(xmax, snapshot, own_xids) {
801                return Err(Self::conflict_error(&collection, logical_id, xmax));
802            }
803            self.check_logical_row_conflict(
804                &collection,
805                logical_id,
806                &[id],
807                snapshot,
808                own_xids,
809                epoch,
810            )?;
811        }
812
813        if isolation == crate::storage::transaction::IsolationLevel::Serializable
814            && self
815                .inner
816                .transaction_state
817                .snapshot_manager()
818                .serializable_commit_would_be_dangerous(
819                    *own_xids.iter().min().unwrap_or(&0),
820                    &serializable_write_set,
821                )
822        {
823            return Err(RedDBError::Query(
824                "serialization conflict: serializable transaction would complete rw-antidependency dangerous structure".to_string(),
825            ));
826        }
827
828        Ok(())
829    }
830
831    pub(crate) fn check_queue_dedup_write_conflicts(
832        &self,
833        conn_id: u64,
834        snapshot: &crate::storage::transaction::snapshot::Snapshot,
835        own_xids: &std::collections::HashSet<crate::storage::transaction::snapshot::Xid>,
836    ) -> RedDBResult<()> {
837        let pending = self
838            .inner
839            .pending_queue_dedup
840            .read()
841            .get(&conn_id)
842            .cloned()
843            .unwrap_or_default();
844        if pending.is_empty() {
845            return Ok(());
846        }
847
848        let store = self.inner.db.store();
849        let Some(manager) = store.get_collection(crate::runtime::impl_queue::QUEUE_META_COLLECTION)
850        else {
851            return Ok(());
852        };
853
854        for (queue, dedup_key, metadata_id) in pending {
855            for candidate in manager.query_all(|entity| {
856                entity.data.as_row().is_some_and(|row| {
857                    crate::runtime::impl_queue::row_text(row, "kind").as_deref()
858                        == Some(crate::runtime::impl_queue::KIND_QUEUE_PUSH_DEDUP)
859                        && crate::runtime::impl_queue::row_text(row, "queue").as_deref()
860                            == Some(&queue)
861                        && crate::runtime::impl_queue::row_text(row, "dedup_key").as_deref()
862                            == Some(&dedup_key)
863                })
864            }) {
865                if candidate.id == metadata_id
866                    || self
867                        .inner
868                        .transaction_state
869                        .snapshot_manager()
870                        .is_aborted(candidate.xmin)
871                {
872                    continue;
873                }
874                if self.xid_conflicts_with_snapshot(candidate.xmin, snapshot, own_xids) {
875                    return Err(RedDBError::Query(format!(
876                        "serialization conflict: queue dedup key {queue}/{dedup_key} was modified by concurrent transaction {}",
877                        candidate.xmin
878                    )));
879                }
880            }
881        }
882        Ok(())
883    }
884
885    pub(crate) fn restore_pending_write_stamps(&self, conn_id: u64) {
886        let versioned_updates = self
887            .inner
888            .pending_versioned_updates
889            .read()
890            .get(&conn_id)
891            .cloned()
892            .unwrap_or_default();
893        let tombstones = self
894            .inner
895            .pending_tombstones
896            .read()
897            .get(&conn_id)
898            .cloned()
899            .unwrap_or_default();
900
901        let store = self.inner.db.store();
902        let _publication = store.vault_publication_lock.lock();
903        for (collection, old_id, new_id, xid, _previous_xmax) in versioned_updates {
904            if let Some(manager) = store.get_collection(&collection) {
905                let epoch = manager
906                    .get(new_id)
907                    .and_then(|entity| entity.branch_version().map(|version| version.origin));
908                if let Some(mut entity) = manager.get(old_id) {
909                    if let Some(epoch) = epoch {
910                        entity.set_branch_xmax(epoch, xid);
911                    } else {
912                        entity.set_xmax(xid);
913                    }
914                    let _ = manager.update(entity);
915                }
916            }
917        }
918        for (collection, id, xid, _previous_xmax, epoch) in tombstones {
919            if let Some(manager) = store.get_collection(&collection) {
920                if let Some(mut entity) = manager.get(id) {
921                    if let Some(epoch) = epoch {
922                        entity.set_branch_xmax(epoch, xid);
923                    } else {
924                        entity.set_xmax(xid);
925                    }
926                    let _ = manager.update(entity);
927                }
928            }
929        }
930    }
931
932    pub(crate) fn finalize_pending_versioned_updates(&self, conn_id: u64) {
933        self.inner
934            .pending_versioned_updates
935            .write()
936            .remove(&conn_id);
937    }
938
939    pub(crate) fn finalize_pending_queue_dedup(&self, conn_id: u64) {
940        self.inner.pending_queue_dedup.write().remove(&conn_id);
941    }
942
943    pub(crate) fn discard_pending_queue_dedup(&self, conn_id: u64) {
944        let Some(pending) = self.inner.pending_queue_dedup.write().remove(&conn_id) else {
945            return;
946        };
947        let store = self.inner.db.store();
948        for (_queue, _dedup_key, metadata_id) in pending {
949            let _ = store.delete(
950                crate::runtime::impl_queue::QUEUE_META_COLLECTION,
951                metadata_id,
952            );
953        }
954    }
955
956    pub(crate) fn revive_pending_versioned_updates(&self, conn_id: u64) -> RedDBResult<()> {
957        let Some(pending) = self
958            .inner
959            .pending_versioned_updates
960            .write()
961            .remove(&conn_id)
962        else {
963            return Ok(());
964        };
965        // Undo newest versions first so repeated writes restore the original chain.
966        for (collection, old_id, new_id, xid, previous_xmax) in pending.into_iter().rev() {
967            let topology_lock = self.index_store_ref().collection_topology_lock(&collection);
968            #[cfg(test)]
969            self.index_store_ref().mutation_test_hook(
970                &collection,
971                super::index_store::MutationTestPhase::BeforeUndoTopology,
972            );
973            self.revive_versioned_update(
974                &collection,
975                old_id,
976                new_id,
977                xid,
978                previous_xmax,
979                topology_lock.read(),
980            )?;
981        }
982        Ok(())
983    }
984
985    pub(crate) fn revive_versioned_update(
986        &self,
987        collection: &str,
988        old_id: crate::storage::EntityId,
989        new_id: crate::storage::EntityId,
990        xid: u64,
991        previous_xmax: u64,
992        _topology_guard: parking_lot::RwLockReadGuard<'_, ()>,
993    ) -> RedDBResult<()> {
994        let _indexed_mutation = self.index_store_ref().begin_indexed_mutation();
995        let store = self.inner.db.store();
996        let _publication = store.vault_publication_lock.lock();
997        let epoch = store
998            .get(collection, new_id)
999            .and_then(|entity| entity.branch_version().map(|version| version.origin));
1000        if let Some(new) = store.get(collection, new_id) {
1001            let fields = crate::application::ports::entity_row_fields_snapshot(&new);
1002            self.index_store_ref()
1003                .index_entity_delete(collection, new_id, &fields)
1004                .map_err(RedDBError::Internal)?;
1005            #[cfg(test)]
1006            self.index_store_ref().mutation_test_hook(
1007                collection,
1008                super::index_store::MutationTestPhase::UndoIndexRemoved,
1009            );
1010        }
1011        store
1012            .delete_batch(collection, &[new_id])
1013            .map_err(|error| RedDBError::Internal(error.to_string()))?;
1014        if let Some(manager) = store.get_collection(collection) {
1015            if let Some(mut old) = manager.get(old_id) {
1016                if epoch.map_or(old.xmax, |epoch| old.branch_xmax(epoch)) == xid {
1017                    if let Some(epoch) = epoch {
1018                        old.set_branch_xmax(epoch, previous_xmax);
1019                    } else {
1020                        old.set_xmax(previous_xmax);
1021                    }
1022                    manager
1023                        .update(old.clone())
1024                        .map_err(|error| RedDBError::Internal(error.to_string()))?;
1025                    store.context_index().index_entity(collection, &old);
1026                    let fields = crate::application::ports::entity_row_fields_snapshot(&old);
1027                    self.index_store_ref()
1028                        .index_entity_insert(collection, old_id, &fields)
1029                        .map_err(RedDBError::Internal)?;
1030                }
1031            }
1032        }
1033        Ok(())
1034    }
1035
1036    /// Flush tombstones on COMMIT. The xmax stamp is already the durable
1037    /// delete marker; commit only drops the rollback journal and emits
1038    /// side effects. Physical reclamation is left for VACUUM so old
1039    /// snapshots can still resolve the pre-delete row version.
1040    pub(crate) fn finalize_pending_tombstones(&self, conn_id: u64) {
1041        let Some(pending) = self.inner.pending_tombstones.write().remove(&conn_id) else {
1042            return;
1043        };
1044        if pending.is_empty() {
1045            return;
1046        }
1047
1048        let store = self.inner.db.store();
1049        for (collection, id, _xid, _previous_xmax, _epoch) in pending {
1050            if let Some(manager) = store.get_collection(&collection) {
1051                manager.remove_metadata_expiry(id);
1052            }
1053            store.context_index().remove_entity(id);
1054            self.invalidate_result_cache_for_table(&collection);
1055            // Primary CDC identities are allocated before the durable outbox
1056            // captures their restart frontier.
1057            if self.inner.db.replication.is_none() {
1058                self.inner.cdc.emit(
1059                    crate::replication::cdc::ChangeOperation::Delete,
1060                    &collection,
1061                    id.raw(),
1062                    "entity",
1063                );
1064            }
1065        }
1066    }
1067
1068    /// Revive tombstones on ROLLBACK — reset `xmax` to 0 so the tuples
1069    /// become visible again to future snapshots. Best-effort: a row
1070    /// already reclaimed by a concurrent VACUUM stays gone, but VACUUM
1071    /// never reclaims tuples whose xmax is still referenced by any
1072    /// active snapshot, so this case is only reachable via external
1073    /// storage corruption.
1074    pub(crate) fn revive_pending_tombstones(&self, conn_id: u64) {
1075        let Some(pending) = self.inner.pending_tombstones.write().remove(&conn_id) else {
1076            return;
1077        };
1078
1079        let store = self.inner.db.store();
1080        let _publication = store.vault_publication_lock.lock();
1081        for (collection, id, xid, previous_xmax, epoch) in pending {
1082            let Some(manager) = store.get_collection(&collection) else {
1083                continue;
1084            };
1085            if let Some(mut entity) = manager.get(id) {
1086                if epoch.map_or(entity.xmax, |epoch| entity.branch_xmax(epoch)) == xid {
1087                    if let Some(epoch) = epoch {
1088                        entity.set_branch_xmax(epoch, previous_xmax);
1089                    } else {
1090                        entity.set_xmax(previous_xmax);
1091                    }
1092                    let _ = manager.update(entity);
1093                }
1094            }
1095        }
1096    }
1097
1098    /// Slice C of PRD #718 — accessor for the local wait registry.
1099    pub fn queue_wait_registry(
1100        &self,
1101    ) -> std::sync::Arc<crate::runtime::queue_wait_registry::QueueWaitRegistry> {
1102        self.inner.queue_wait_registry.clone()
1103    }
1104
1105    /// Buffer a `(scope, queue)` wake on the current connection so it
1106    /// fires post-COMMIT, or notify immediately if no transaction is
1107    /// open (autocommit path). The wait registry only ever observes
1108    /// notifies for committed work — rollback drops the buffer.
1109    pub(crate) fn record_queue_wake(&self, scope: &str, queue: &str) {
1110        if self.current_xid().is_some() {
1111            let conn_id = current_connection_id();
1112            self.inner
1113                .pending_queue_wakes
1114                .write()
1115                .entry(conn_id)
1116                .or_default()
1117                .push((scope.to_string(), queue.to_string()));
1118            return;
1119        }
1120        self.inner.queue_wait_registry.notify(scope, queue);
1121    }
1122
1123    pub(crate) fn finalize_pending_queue_wakes(&self, conn_id: u64) {
1124        let Some(pending) = self.inner.pending_queue_wakes.write().remove(&conn_id) else {
1125            return;
1126        };
1127        for (scope, queue) in pending {
1128            self.inner.queue_wait_registry.notify(&scope, &queue);
1129        }
1130    }
1131
1132    pub(crate) fn discard_pending_queue_wakes(&self, conn_id: u64) {
1133        self.inner.pending_queue_wakes.write().remove(&conn_id);
1134    }
1135
1136    pub(crate) fn release_pending_claim_locks(&self, conn_id: u64) {
1137        self.inner
1138            .pending_claim_locks
1139            .write()
1140            .retain(|_, owner| *owner != conn_id);
1141    }
1142
1143    pub(crate) fn finalize_pending_kv_watch_events(&self, conn_id: u64) {
1144        let Some(pending) = self.inner.pending_kv_watch_events.write().remove(&conn_id) else {
1145            return;
1146        };
1147        for (_, event) in pending {
1148            self.cdc_emit_kv(
1149                event.op,
1150                &event.collection,
1151                &event.key,
1152                0,
1153                event.before,
1154                event.after,
1155            );
1156        }
1157    }
1158
1159    pub(crate) fn discard_pending_kv_watch_events(&self, conn_id: u64) {
1160        self.inner.pending_kv_watch_events.write().remove(&conn_id);
1161    }
1162
1163    /// Phase 1.1 MVCC universal: post-save hook that stamps `xmin` on a
1164    /// freshly-inserted entity when the current connection holds an
1165    /// open transaction. Used by graph / queue / timeseries
1166    /// write paths that go through the DevX builder API (`db.node(...)
1167    /// .save()` and friends) — those live in the storage crate and
1168    /// can't reach `current_xid()` without crossing layers, so the
1169    /// application layer calls this helper right after `save()` to
1170    /// finalise the MVCC stamp.
1171    ///
1172    /// Autocommit (outside BEGIN) is a no-op — no extra lookup or
1173    /// write, so the non-transactional hot path stays untouched.
1174    ///
1175    /// Best-effort: if the collection or entity disappears between
1176    /// the save and the stamp (concurrent DROP), we silently skip.
1177    pub(crate) fn stamp_xmin_if_in_txn(
1178        &self,
1179        collection: &str,
1180        id: crate::storage::unified::entity::EntityId,
1181    ) {
1182        let Some(xid) = self.current_xid() else {
1183            return;
1184        };
1185        let store = self.inner.db.store();
1186        let Some(manager) = store.get_collection(collection) else {
1187            return;
1188        };
1189        if let Some(mut entity) = manager.get(id) {
1190            entity.set_xmin(xid);
1191            let _ = manager.update(entity);
1192        }
1193    }
1194
1195    /// Revive tombstones stamped by `stamper_xid` or any sub-xid
1196    /// allocated after it (Phase 2.3.2e savepoint rollback). Any
1197    /// pending entries with `xid < stamper_xid` stay queued because
1198    /// they belong to the enclosing scope — they'll either flush on
1199    /// COMMIT or revive on an outer ROLLBACK TO SAVEPOINT.
1200    ///
1201    /// Returns the number of tuples whose `xmax` was wiped back to 0.
1202    pub(crate) fn revive_tombstones_since(&self, conn_id: u64, stamper_xid: u64) -> usize {
1203        let store = self.inner.db.store();
1204        let _publication = store.vault_publication_lock.lock();
1205        let mut guard = self.inner.pending_tombstones.write();
1206        let Some(pending) = guard.get_mut(&conn_id) else {
1207            return 0;
1208        };
1209
1210        let mut revived = 0usize;
1211        pending.retain(|(collection, id, xid, previous_xmax, epoch)| {
1212            if *xid < stamper_xid {
1213                // Stamped before the savepoint — keep in queue.
1214                return true;
1215            }
1216            if let Some(manager) = store.get_collection(collection) {
1217                if let Some(mut entity) = manager.get(*id) {
1218                    if epoch.map_or(entity.xmax, |epoch| entity.branch_xmax(epoch)) == *xid {
1219                        if let Some(epoch) = epoch {
1220                            entity.set_branch_xmax(*epoch, *previous_xmax);
1221                        } else {
1222                            entity.set_xmax(*previous_xmax);
1223                        }
1224                        let _ = manager.update(entity);
1225                        revived += 1;
1226                    }
1227                }
1228            }
1229            false
1230        });
1231        if pending.is_empty() {
1232            guard.remove(&conn_id);
1233        }
1234        revived
1235    }
1236
1237    /// Return the snapshot the current connection should use for visibility
1238    /// checks (Phase 2.3 PG parity).
1239    ///
1240    /// * If the connection is inside a READ COMMITTED transaction, capture
1241    ///   a fresh statement snapshot while preserving the transaction's xid
1242    ///   and write-set lifecycle.
1243    /// * If the connection is inside a SNAPSHOT transaction, reuse the
1244    ///   snapshot stored in its `TxnContext`.
1245    /// * Otherwise (autocommit), capture a fresh snapshot tied to an
1246    ///   implicit xid=0 — the read path treats pre-MVCC rows as always
1247    ///   visible so this degrades to "see everything committed".
1248    pub fn current_snapshot(&self) -> crate::storage::transaction::snapshot::Snapshot {
1249        self.inner
1250            .transaction_state
1251            .current_snapshot(current_connection_id())
1252    }
1253
1254    /// Xid of the current connection's active transaction, or `None` when
1255    /// running outside a BEGIN/COMMIT block. Write paths call this to
1256    /// decide whether to stamp `xmin`/`xmax` on tuples.
1257    /// Phase 2.3.2e: when a savepoint is open, `writer_xid` returns the
1258    /// sub-xid so new writes can be selectively rolled back. Otherwise
1259    /// the parent txn's xid is returned, matching pre-savepoint
1260    /// behaviour. Callers that need the enclosing *transaction* xid
1261    /// (e.g. VACUUM min-active calculations) should read `ctx.xid`
1262    /// directly.
1263    pub fn current_xid(&self) -> Option<crate::storage::transaction::snapshot::Xid> {
1264        self.inner
1265            .transaction_state
1266            .writer_xid(current_connection_id())
1267    }
1268
1269    pub(crate) fn own_transaction_xids(&self) -> std::collections::HashSet<u64> {
1270        let mut xids = self
1271            .inner
1272            .transaction_state
1273            .own_xids(current_connection_id());
1274        if let Some(xid) = self.graph_insert_xid() {
1275            xids.insert(xid);
1276        }
1277        xids
1278    }
1279
1280    /// `true` when the given connection id has an open `BEGIN`. Issue
1281    /// #760 — `OpenStream` consults this to refuse output streams that
1282    /// would otherwise collide with an interactive transaction (see
1283    /// ADR 0029 "Transaction interaction"). HTTP requests pre-dating the
1284    /// connection-id plumbing run with id `0`, which never carries a
1285    /// transaction context, so this returns `false` on those paths.
1286    pub fn connection_in_transaction(&self, conn_id: u64) -> bool {
1287        self.inner.transaction_state.in_transaction(conn_id)
1288    }
1289
1290    /// Access the shared `SnapshotManager` — useful for VACUUM to compute
1291    /// the oldest-active xid when reclaiming dead tuples.
1292    pub fn snapshot_manager(&self) -> Arc<crate::storage::transaction::snapshot::SnapshotManager> {
1293        self.inner.transaction_state.snapshot_manager()
1294    }
1295
1296    pub(crate) fn mvcc_vacuum_cutoff_xid(&self) -> crate::storage::transaction::snapshot::Xid {
1297        let manager = self.inner.transaction_state.snapshot_manager();
1298        let next_xid = manager.peek_next_xid();
1299        let mut cutoff = next_xid;
1300        if let Some(oldest_required) = manager.oldest_required_xid() {
1301            cutoff = cutoff.min(oldest_required);
1302        }
1303        let retention_xids = self.config_u64("runtime.mvcc.vacuum_retention_xids", 0);
1304        if retention_xids > 0 {
1305            cutoff = cutoff.min(next_xid.saturating_sub(retention_xids));
1306        }
1307        cutoff
1308    }
1309
1310    /// Own-tx xids (parent + open/released savepoints) for the current
1311    /// connection. Transports + tests that build a `SnapshotContext`
1312    /// manually (outside the `execute_query` scope) need this set so
1313    /// the writer's own uncommitted tuples stay visible to self.
1314    pub fn current_txn_own_xids(
1315        &self,
1316    ) -> std::collections::HashSet<crate::storage::transaction::snapshot::Xid> {
1317        self.inner
1318            .transaction_state
1319            .own_xids(current_connection_id())
1320    }
1321}