selene_graph/mutator/delete.rs
1use std::collections::BTreeSet;
2
3use selene_core::{Change, DbString, EdgeId, LabelSet, NodeId, PropertyMap, db_string};
4
5use super::{Mutator, remove_index_row, remove_node_labels};
6use crate::adjacency::AdjacencyEntry;
7use crate::error::{GraphError, GraphResult};
8use crate::id_map::EngineIdMap;
9use crate::store::RowIndex;
10
11impl<'tx, 'g> Mutator<'tx, 'g> {
12 /// Delete an alive node and cascade delete incident edges.
13 pub fn delete_node(&mut self, id: NodeId) -> GraphResult<()> {
14 let row = self.require_live_node(id)?;
15 let incident = self.remove_node_row(id, row)?;
16 self.txn.changes.push(Change::NodeDeleted { id });
17 for edge_id in incident {
18 self.delete_edge_inner(edge_id, true)?;
19 }
20 Ok(())
21 }
22
23 /// Remove one node row from every in-memory structure (label index,
24 /// property/composite indexes, liveness) and return its incident edges.
25 ///
26 /// This is the change-free removal core shared by [`Self::delete_node`] and
27 /// [`Self::truncate_node_type`]; callers own the changeset accounting (one
28 /// `NodeDeleted` for DETACH DELETE, one declarative truncate change plus
29 /// staged per-row tombstones for TRUNCATE). The returned incident set spans
30 /// edges of **every** edge type touching the node (derived from both
31 /// adjacency directions) so no dangling edge can survive.
32 pub(super) fn remove_node_row(
33 &mut self,
34 id: NodeId,
35 row: usize,
36 ) -> GraphResult<BTreeSet<EdgeId>> {
37 let graph = self.txn.read();
38 let labels = graph
39 .node_store
40 .labels
41 .get(row)
42 .cloned()
43 .unwrap_or_default();
44 let props = graph
45 .node_store
46 .properties
47 .get(row)
48 .cloned()
49 .unwrap_or_default();
50 let mut incident = BTreeSet::new();
51 if let Some(outgoing) = graph.adjacency_out.get(&id) {
52 incident.extend(outgoing.iter().map(|edge| edge.edge_id));
53 }
54 if let Some(incoming) = graph.adjacency_in.get(&id) {
55 incident.extend(incoming.iter().map(|edge| edge.edge_id));
56 }
57 {
58 let graph = self.txn.guard_mut();
59 remove_node_labels(&mut graph.idx_label, row as u32, &labels);
60 crate::property_index::apply_node_delete(
61 &mut graph.property_index,
62 &labels,
63 &props,
64 row as u32,
65 )?;
66 crate::composite_property_index::apply_node_delete(
67 &mut graph.composite_property_index,
68 &labels,
69 &props,
70 row as u32,
71 )?;
72 crate::vector_index::apply_node_delete(
73 &mut graph.vector_index,
74 &labels,
75 &props,
76 row as u32,
77 )?;
78 crate::text_index::apply_node_delete(
79 &mut graph.text_index,
80 &labels,
81 &props,
82 row as u32,
83 id,
84 );
85 graph.node_store.labels.set(row, LabelSet::new());
86 graph.node_store.properties.set(row, PropertyMap::new());
87 graph.node_store.alive_mut().remove(row as u32);
88 // BRIEF-Item-4a: KEEP the real external id in row_to_id for the now
89 // dead row (and keep the id -> row map entry). A deleted id stays
90 // resolvable -> its dead row -> NodeNotAlive, identically across the
91 // live and recovery paths: the snapshot persists dead rows with their
92 // id (sections.rs) and STEP 9 encodes that id from this column. Only
93 // never-committed aborted-tx hole rows carry NodeId::TOMBSTONE
94 // (-> None -> NotFound, the accepted refinement).
95
96 // GRAPH-05: drop this node's own adjacency entries wholesale, O(1)
97 // each. The incident set was already captured above, so the
98 // per-edge cascade (`remove_edge_row`) only has to clear the
99 // *neighbor* side of each incident edge; a `get_mut` on this
100 // now-absent hub key no-ops. This is what turns a degree-`D` hub
101 // delete from O(D^2) (clone + linear-scan the shrinking hub entry
102 // once per incident edge) into O(D).
103 graph.adjacency_out.remove(&id);
104 graph.adjacency_in.remove(&id);
105 }
106 Ok(incident)
107 }
108
109 /// Delete an alive edge.
110 pub fn delete_edge(&mut self, id: EdgeId) -> GraphResult<()> {
111 self.delete_edge_inner(id, true)
112 }
113
114 /// Remove every node carrying `label` and all of their incident edges in one
115 /// declarative truncate.
116 ///
117 /// Observationally identical to `MATCH (n:L) DETACH DELETE n`: every matched
118 /// node and every incident edge (of **any** edge type, derived from both
119 /// adjacency directions) is removed via the same change-free in-memory path
120 /// `delete_node`/`delete_edge_inner` use, so the resulting graph state is
121 /// byte-identical. The difference is the changeset: exactly **one**
122 /// declarative [`Change::NodesOfTypeTruncated`] is recorded regardless of the
123 /// number of rows removed (O(1) WAL write, deletion-reclamation audit
124 /// Item 11), while the per-row `NodeDeleted`/`EdgeDeleted` tombstones are
125 /// staged for provider/subscriber fan-out so derived state (e.g. extension
126 /// providers) is reclaimed without leaks. An absent label is a clean no-op
127 /// (no change is recorded), matching DETACH DELETE of zero matches; a second
128 /// truncate of the same label is therefore idempotent.
129 ///
130 /// All logic lives in the mutator (the single write funnel, hard rule 11) so
131 /// future `DROP NODE TYPE CASCADE` and `DROP GRAPH` factory-reset paths can
132 /// reuse it without an N+1 change storm.
133 pub fn truncate_node_type(&mut self, label: DbString) -> GraphResult<()> {
134 // Snapshot the matched node rows and derive every incident edge BEFORE
135 // any removal, exactly as delete_node does — removal mutates the
136 // adjacency/label structures we are iterating.
137 let matched_rows: Vec<u32> = match self.txn.read().nodes_with_label(&label) {
138 Some(bitmap) => bitmap.iter().collect(),
139 None => return Ok(()),
140 };
141 if matched_rows.is_empty() {
142 return Ok(());
143 }
144 let mut node_tombstones = Vec::with_capacity(matched_rows.len());
145 let mut incident_edges = BTreeSet::new();
146 for row in matched_rows {
147 // Skip rows that are not alive (defensive: idx_label is kept in
148 // lockstep with liveness, but a dead row must never be re-removed).
149 if !self.txn.read().node_store.is_alive(row) {
150 continue;
151 }
152 let Some(id) = self.txn.read().node_id_for_row(RowIndex::new(row)) else {
153 continue;
154 };
155 incident_edges.append(&mut self.remove_node_row(id, row as usize)?);
156 node_tombstones.push(Change::NodeDeleted { id });
157 }
158 if node_tombstones.is_empty() {
159 return Ok(());
160 }
161 let mut expansion = node_tombstones;
162 for edge_id in incident_edges {
163 let row = self
164 .txn
165 .read()
166 .row_for_edge_id(edge_id)
167 .ok_or(GraphError::EdgeNotFound { id: edge_id })?
168 .get();
169 // An incident edge may already be gone if two truncated endpoints
170 // shared it; remove_edge_row is only called for still-alive rows.
171 if self.txn.read().edge_store.is_alive(row) {
172 self.remove_edge_row(edge_id, row as usize)?;
173 expansion.push(Change::EdgeDeleted { id: edge_id });
174 }
175 }
176 let index = self.txn.changes.len();
177 self.txn
178 .changes
179 .push(Change::NodesOfTypeTruncated { label });
180 self.txn.truncate_expansions.push((index, expansion));
181 Ok(())
182 }
183
184 /// Remove every edge carrying `label` in one declarative truncate.
185 ///
186 /// Observationally identical to `MATCH ()-[e:L]->() DELETE e`: each matched
187 /// edge is removed via the same change-free path `delete_edge` uses, leaving
188 /// the graph dangling-free. Records exactly **one** declarative
189 /// [`Change::EdgesOfTypeTruncated`] (O(1) WAL) and stages per-row
190 /// `EdgeDeleted` tombstones for fan-out. Absent label is a clean idempotent
191 /// no-op.
192 pub fn truncate_edge_type(&mut self, label: DbString) -> GraphResult<()> {
193 let matched_rows: Vec<u32> = match self.txn.read().edges_with_label(&label) {
194 Some(bitmap) => bitmap.iter().collect(),
195 None => return Ok(()),
196 };
197 if matched_rows.is_empty() {
198 return Ok(());
199 }
200 let mut expansion = Vec::with_capacity(matched_rows.len());
201 for row in matched_rows {
202 if !self.txn.read().edge_store.is_alive(row) {
203 continue;
204 }
205 let Some(id) = self.txn.read().edge_id_for_row(RowIndex::new(row)) else {
206 continue;
207 };
208 self.remove_edge_row(id, row as usize)?;
209 expansion.push(Change::EdgeDeleted { id });
210 }
211 if expansion.is_empty() {
212 return Ok(());
213 }
214 let index = self.txn.changes.len();
215 self.txn
216 .changes
217 .push(Change::EdgesOfTypeTruncated { label });
218 self.txn.truncate_expansions.push((index, expansion));
219 Ok(())
220 }
221
222 fn delete_edge_inner(&mut self, id: EdgeId, record_change: bool) -> GraphResult<()> {
223 let row = self.require_live_edge(id)?;
224 self.remove_edge_row(id, row)?;
225 if record_change {
226 self.txn.changes.push(Change::EdgeDeleted { id });
227 }
228 Ok(())
229 }
230
231 /// Remove one edge row from every in-memory structure (liveness, edge-label
232 /// index, both adjacency directions) without recording any change.
233 ///
234 /// Shared change-free core for [`Self::delete_edge_inner`] and the truncate
235 /// paths; callers own changeset accounting.
236 pub(super) fn remove_edge_row(&mut self, id: EdgeId, row: usize) -> GraphResult<()> {
237 let graph = self.txn.read();
238 let label = graph
239 .edge_store
240 .label
241 .get(row)
242 .cloned()
243 .ok_or(GraphError::EdgeNotFound { id })?;
244 let source = *graph
245 .edge_store
246 .source
247 .get(row)
248 .ok_or(GraphError::EdgeNotFound { id })?;
249 let target = *graph
250 .edge_store
251 .target
252 .get(row)
253 .ok_or(GraphError::EdgeNotFound { id })?;
254 let props = graph
255 .edge_store
256 .properties
257 .get(row)
258 .cloned()
259 .unwrap_or_default();
260 let graph = self.txn.guard_mut();
261 crate::property_index::apply_edge_delete(
262 &mut graph.edge_property_index,
263 &label,
264 &props,
265 row as u32,
266 )?;
267 graph.edge_store.alive_mut().remove(row as u32);
268 // BRIEF-Item-4a: keep the real id in row_to_id for the dead row (see
269 // remove_node_row); only never-committed holes carry EdgeId::TOMBSTONE.
270 remove_index_row(&mut graph.idx_edge_label, &label, row as u32);
271 // GRAPH-05: remove the edge from each endpoint's adjacency entry in
272 // place (no full-`SmallVec` clone), dropping the map key only when the
273 // entry becomes empty. In a node-delete cascade the hub endpoint's
274 // entry has already been dropped wholesale by `remove_node_row`, so its
275 // lookup no-ops and only the neighbor side is touched.
276 remove_edge_from_adjacency(&mut graph.adjacency_out, source, id);
277 remove_edge_from_adjacency(&mut graph.adjacency_in, target, id);
278 graph.edge_store.label.set(row, db_string("")?);
279 graph.edge_store.source.set(row, NodeId::TOMBSTONE);
280 graph.edge_store.target.set(row, NodeId::TOMBSTONE);
281 graph.edge_store.properties.set(row, PropertyMap::new());
282 Ok(())
283 }
284}
285
286/// Remove edge `edge_id` from one direction's adjacency `map` in place,
287/// dropping the node's entry only when it becomes empty.
288///
289/// In-place via `imbl::HashMap::get_mut` (no full-`SmallVec` clone), so a
290/// degree-`D` hub-delete cascade is O(D) rather than O(D^2). A missing key is a
291/// no-op — e.g. the hub endpoint whose whole entry `remove_node_row` already
292/// dropped. The empty-key removal preserves the "no present-but-empty entry"
293/// invariant asserted by the consistency checks.
294fn remove_edge_from_adjacency(
295 map: &mut EngineIdMap<NodeId, AdjacencyEntry>,
296 node: NodeId,
297 edge_id: EdgeId,
298) {
299 // The `get_mut` borrow ends with the match expression (it yields a bool),
300 // so the conditional `remove` below is a fresh, non-overlapping borrow.
301 let now_empty = match map.get_mut(&node) {
302 Some(entry) => {
303 entry.remove(edge_id);
304 entry.is_empty()
305 }
306 None => false,
307 };
308 if now_empty {
309 map.remove(&node);
310 }
311}