selene_graph/mutator/factory_reset.rs
1//! `DROP GRAPH` factory-reset for the transaction mutator.
2//!
3//! Extracted from the mutator module to keep `mutator.rs` under the 700-LOC
4//! file cap. The reset reuses the same change-free row-removal cores the
5//! BRIEF-150 truncate path uses (`remove_node_row` / `remove_edge_row`), so the
6//! resulting in-memory state is byte-identical to `MATCH (n) DETACH DELETE n`
7//! plus a full schema drop.
8
9use selene_core::Change;
10
11use crate::Mutator;
12use crate::error::GraphResult;
13use crate::store::RowIndex;
14
15impl<'tx, 'g> Mutator<'tx, 'g> {
16 /// Factory-reset the entire graph: wipe every node and edge and reset the
17 /// schema to open, recording exactly **one** declarative
18 /// [`Change::GraphReset`] (deletion-reclamation audit Item 10, BRIEF-152).
19 ///
20 /// This is the `DROP GRAPH` primitive. Under D1 single-graph it targets the
21 /// one bound graph. Behaviour and invariants:
22 ///
23 /// * **Wipes ALL rows, including untyped ones.** Live rows are enumerated
24 /// from the two alive [`roaring::RoaringBitmap`]s
25 /// ([`crate::SeleneGraph::live_nodes`] / [`crate::SeleneGraph::live_edges`]),
26 /// **never** per label — so nodes/edges whose labels are not declared
27 /// types (legal in an open GG01 graph) are removed too. A per-type
28 /// truncate would silently miss them.
29 /// * **Resets the schema to open.** `meta.bound_type` is set to `None`,
30 /// making a previously closed (GG02) graph open (GG01) again. There are no
31 /// standalone type defs outside `bound_type`, so clearing it is the
32 /// complete schema reset. As a side effect, the commit-time closed-graph
33 /// validation loop (`write_txn`) is skipped entirely once `bound_type` is
34 /// `None`, which is correct — nothing is left to validate.
35 /// * **O(1) WAL.** Exactly one [`Change::GraphReset`] is pushed regardless of
36 /// the number of rows removed. The per-row `NodeDeleted`/`EdgeDeleted`
37 /// tombstones are staged into the fan-out buffer (`truncate_expansions`)
38 /// only, never into the persisted changeset, so derived state (e.g. the
39 /// label/property indexes) is reclaimed without leaks while the WAL stays
40 /// constant-size.
41 /// * **Idempotent.** On an already-empty + open graph the row enumeration is
42 /// empty and `bound_type` is already `None`; a `GraphReset` is still pushed
43 /// with an empty staged expansion (which the fan-out expander drops), so a
44 /// second `DROP GRAPH` is a clean observable no-op, never an error.
45 ///
46 /// The MANIFEST epoch and WAL archive lineage are untouched: this is one
47 /// committed WAL entry on top of the existing snapshot, not a file-level
48 /// wipe.
49 ///
50 /// # Errors
51 ///
52 /// Returns a [`crate::GraphError`] only if the change-free removal cores hit
53 /// a structural inconsistency (e.g. a missing edge row for a live index
54 /// entry) — the same error surface the truncate path exposes.
55 pub fn factory_reset(&mut self) -> GraphResult<()> {
56 // Snapshot every live row BEFORE any removal: removal mutates the alive
57 // bitmaps and adjacency/index structures we are iterating (the same
58 // clone-collect discipline truncate_node_type uses).
59 let node_rows: Vec<u32> = self.txn.read().node_store.alive.iter().collect();
60 let edge_rows: Vec<u32> = self.txn.read().edge_store.alive.iter().collect();
61
62 let mut expansion = Vec::with_capacity(node_rows.len() + edge_rows.len());
63 for row in node_rows {
64 // Every row came from the alive bitmap, so its external id is mapped
65 // (an unmapped row would be a never-committed hole, never alive).
66 let Some(id) = self.txn.read().node_id_for_row(RowIndex::new(row)) else {
67 continue;
68 };
69 // remove_node_row scrubs idx_label, property/composite indexes,
70 // adjacency, and node liveness. Its returned incident-edge set is
71 // discarded here because the alive-edge bitmap below is the
72 // authoritative superset (it also covers untyped edges between
73 // untyped nodes), so we clear every edge row directly.
74 let _ = self.remove_node_row(id, row as usize)?;
75 expansion.push(Change::NodeDeleted { id });
76 }
77
78 // Remove every still-alive edge row. remove_node_row detached incident
79 // edges from adjacency but did NOT clear edge liveness / edge-label
80 // index, so iterate the full alive-edge set captured before removal.
81 for row in edge_rows {
82 // Defensive: a row may already be dead if it shared two truncated
83 // endpoints — remove_edge_row is only called for still-alive rows.
84 if !self.txn.read().edge_store.is_alive(row) {
85 continue;
86 }
87 let Some(id) = self.txn.read().edge_id_for_row(RowIndex::new(row)) else {
88 continue;
89 };
90 debug_assert!(
91 self.txn.read().row_for_edge_id(id) == Some(RowIndex::new(row)),
92 "edge row/id round-trip must hold"
93 );
94 self.remove_edge_row(id, row as usize)?;
95 expansion.push(Change::EdgeDeleted { id });
96 }
97
98 // Reset the schema to open. Different from DROP TYPE (which keeps the
99 // graph closed by setting bound_type = Some(next)); factory-reset clears
100 // the WHOLE bound_type to None.
101 self.txn.guard_mut().meta.bound_type = None;
102
103 // Push EXACTLY ONE declarative change (O(1) WAL) and stage the per-row
104 // tombstones for subscriber fan-out, keyed to this change's index.
105 let index = self.txn.changes.len();
106 self.txn.changes.push(Change::GraphReset {});
107 self.txn.truncate_expansions.push((index, expansion));
108 Ok(())
109 }
110}