Skip to main content

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}