Skip to main content

issundb_core/graph/
edge.rs

1use super::*;
2
3impl Graph {
4    // ------------------------------------------------------------------
5    // Edges
6    // ------------------------------------------------------------------
7
8    /// Insert a directed edge `src → dst` with a string type and properties.
9    #[instrument(skip(self, props), fields(src = %src, dst = %dst, etype = %etype))]
10    pub fn add_edge(
11        &self,
12        src: NodeId,
13        dst: NodeId,
14        etype: &str,
15        props: &impl Serialize,
16    ) -> Result<EdgeId, Error> {
17        let _guard = self._write_lock.lock();
18        let mut wtxn = self.storage.env.write_txn()?;
19        let edge_id = self.add_edge_impl(&mut wtxn, src, dst, etype, props)?;
20        wtxn.commit()?;
21        self.csr_cache.record_added_edge(src, dst);
22        self.edge_columns.record_touched(edge_id);
23        self.maybe_spawn_rebuild();
24        Ok(edge_id)
25    }
26
27    pub(super) fn add_edge_impl(
28        &self,
29        wtxn: &mut heed::RwTxn,
30        src: NodeId,
31        dst: NodeId,
32        etype: &str,
33        props: &impl Serialize,
34    ) -> Result<EdgeId, Error> {
35        let type_id = get_or_create_type(&self.storage, wtxn, etype)?;
36        let edge_id = alloc_edge_id(&self.storage, wtxn)?;
37        let encoded_props = props::encode(props)?;
38
39        // Validate constraints and populate indexes
40        self.write_edge_index_entries(wtxn, edge_id, type_id, etype, &encoded_props)?;
41
42        let record = EdgeRecord {
43            src,
44            dst,
45            edge_type: type_id,
46            props: encoded_props,
47        };
48        self.storage
49            .edges
50            .put(wtxn, &edge_id, &props::encode(&record)?)?;
51        self.storage
52            .type_idx
53            .put(wtxn, &composite_key(type_id, edge_id), &())?;
54
55        self.append_adj(wtxn, src, dst, type_id, edge_id, true)?;
56        self.append_adj(wtxn, dst, src, type_id, edge_id, false)?;
57
58        adjust_type_count(&self.storage, wtxn, type_id, 1)?;
59
60        Ok(edge_id)
61    }
62
63    /// Update the properties of an existing edge, preserving src, dst, and type.
64    pub fn update_edge(&self, id: EdgeId, props: &impl serde::Serialize) -> Result<(), Error> {
65        let _guard = self._write_lock.lock();
66        let mut wtxn = self.storage.env.write_txn()?;
67        let existing = self
68            .storage
69            .edges
70            .get(&wtxn, &id)?
71            .ok_or(Error::EdgeNotFound(id))?;
72        let record: EdgeRecord = crate::storage::props::decode(existing)?;
73        let etype = self
74            .type_name_impl(&wtxn, record.edge_type)?
75            .ok_or(Error::Corrupt("edge type name missing"))?;
76
77        // Re-index under the new properties: drop the old entries first so the
78        // unique check never conflicts with the edge against itself. A
79        // constraint violation aborts the uncommitted transaction, so the old
80        // entries survive.
81        self.delete_edge_index_entries(&mut wtxn, id, &record)?;
82        let encoded_props = crate::storage::props::encode(props)?;
83        self.write_edge_index_entries(&mut wtxn, id, record.edge_type, &etype, &encoded_props)?;
84
85        let new_record = EdgeRecord {
86            src: record.src,
87            dst: record.dst,
88            edge_type: record.edge_type,
89            props: encoded_props,
90        };
91        self.storage
92            .edges
93            .put(&mut wtxn, &id, &crate::storage::props::encode(&new_record)?)?;
94        wtxn.commit()?;
95        self.edge_columns.record_touched(id);
96        Ok(())
97    }
98
99    /// Fetch an edge record by id.
100    pub fn get_edge(&self, id: EdgeId) -> Result<Option<EdgeRecord>, Error> {
101        let rtxn = self.storage.env.read_txn()?;
102        self.get_edge_impl(&rtxn, id)
103    }
104
105    pub(super) fn get_edge_impl(
106        &self,
107        txn: &heed::RoTxn,
108        id: EdgeId,
109    ) -> Result<Option<EdgeRecord>, Error> {
110        match self.storage.edges.get(txn, &id)? {
111            Some(bytes) => Ok(Some(props::decode(bytes)?)),
112            None => Ok(None),
113        }
114    }
115
116    /// Delete an edge.
117    #[instrument(skip(self))]
118    pub fn delete_edge(&self, id: EdgeId) -> Result<(), Error> {
119        let _guard = self._write_lock.lock();
120        let mut wtxn = self.storage.env.write_txn()?;
121        let endpoints = self.delete_edge_impl(&mut wtxn, id)?;
122        wtxn.commit()?;
123        if let Some((src, dst)) = endpoints {
124            self.csr_cache.record_removed_edge(src, dst);
125            // The deletion reshuffles the dense edge mapping; force a rebuild.
126            self.edge_columns.record_force_full();
127        }
128        self.maybe_spawn_rebuild();
129        Ok(())
130    }
131
132    /// Delete an edge inside an open write transaction. Returns the deleted
133    /// edge's `(src, dst)` endpoints so the caller can record the adjacency
134    /// removal, or `None` if no such edge existed.
135    pub(crate) fn delete_edge_impl(
136        &self,
137        wtxn: &mut heed::RwTxn,
138        id: EdgeId,
139    ) -> Result<Option<(NodeId, NodeId)>, Error> {
140        let record: EdgeRecord = match self.get_edge_impl(wtxn, id)? {
141            Some(rec) => rec,
142            None => return Ok(None),
143        };
144
145        // 1. Delete from edge property index
146        self.delete_edge_index_entries(wtxn, id, &record)?;
147
148        // 2. Delete the edge record itself
149        self.storage.edges.delete(wtxn, &id)?;
150
151        // 3. Delete from the type index
152        self.storage
153            .type_idx
154            .delete(wtxn, &composite_key(record.edge_type, id))?;
155
156        // 4. Adjust the type count
157        adjust_type_count(&self.storage, wtxn, record.edge_type, -1)?;
158
159        // 5. Delete from out_adj (key is src, other is dst)
160        let out_entry = AdjEntry {
161            edge_type: record.edge_type,
162            other: record.dst,
163            edge_id: id,
164        };
165        self.storage
166            .out_adj
167            .delete_one_duplicate(wtxn, &record.src, out_entry.as_bytes())?;
168
169        // 6. Delete from in_adj (key is dst, other is src)
170        let in_entry = AdjEntry {
171            edge_type: record.edge_type,
172            other: record.src,
173            edge_id: id,
174        };
175        self.storage
176            .in_adj
177            .delete_one_duplicate(wtxn, &record.dst, in_entry.as_bytes())?;
178
179        Ok(Some((record.src, record.dst)))
180    }
181
182    // ------------------------------------------------------------------
183    // Traversal
184    // ------------------------------------------------------------------
185
186    /// Returns neighbor entries for all outgoing edges of `node`.
187    ///
188    /// Reads the `out_adj` store directly through the supplied transaction so
189    /// the result always reflects committed (and, inside a [`WriteTxn`],
190    /// uncommitted) writes. The CSR snapshot is deliberately not consulted here:
191    /// it lags writes until the background rebuild runs, so serving point
192    /// lookups from it would return deleted edges, hide newly added ones, and
193    /// disagree with [`Self::in_neighbors`]. The snapshot remains the basis for
194    /// the GraphBLAS matrix algorithms, which have explicit snapshot semantics.
195    pub fn out_neighbors(&self, node: NodeId) -> Result<Vec<NeighborEntry>, Error> {
196        let rtxn = self.storage.env.read_txn()?;
197        self.out_neighbors_impl(&rtxn, node)
198    }
199
200    pub(super) fn out_neighbors_impl(
201        &self,
202        rtxn: &heed::RoTxn,
203        node: NodeId,
204    ) -> Result<Vec<NeighborEntry>, Error> {
205        self.adj_entries_impl(rtxn, node, true)
206    }
207
208    /// Returns neighbor entries for all incoming edges of `node`.
209    pub fn in_neighbors(&self, node: NodeId) -> Result<Vec<NeighborEntry>, Error> {
210        let rtxn = self.storage.env.read_txn()?;
211        self.in_neighbors_impl(&rtxn, node)
212    }
213
214    pub(super) fn in_neighbors_impl(
215        &self,
216        rtxn: &heed::RoTxn,
217        node: NodeId,
218    ) -> Result<Vec<NeighborEntry>, Error> {
219        self.adj_entries_impl(rtxn, node, false)
220    }
221
222    /// Returns whether the node has any incident relationship, reading the
223    /// adjacency stores directly. Unlike [`Self::out_neighbors`], this never
224    /// consults the CSR snapshot, which lags writes until the background rebuild
225    /// completes. Write-time consistency checks (such as the DELETE connected-node
226    /// guard) must see just-applied edge deletions, so they rely on this method.
227    pub fn node_has_relationships(&self, node: NodeId) -> Result<bool, Error> {
228        let rtxn = self.storage.env.read_txn()?;
229        if !self.adj_entries_impl(&rtxn, node, true)?.is_empty() {
230            return Ok(true);
231        }
232        Ok(!self.adj_entries_impl(&rtxn, node, false)?.is_empty())
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use tempfile::TempDir;
239
240    use super::*;
241
242    fn open_tmp() -> (TempDir, Graph) {
243        let dir = TempDir::new().unwrap();
244        let g = Graph::open(dir.path(), 1).unwrap();
245        (dir, g)
246    }
247
248    /// After a CSR rebuild captures a node into the snapshot, adding an edge to
249    /// that node must be visible through `out_neighbors`. The snapshot lags
250    /// writes, so consulting it for point lookups would hide the new edge.
251    #[test]
252    fn out_neighbors_reflects_edge_added_after_snapshot() {
253        let (_dir, g) = open_tmp();
254        let a = g.add_node("N", &()).unwrap();
255        let b = g.add_node("N", &()).unwrap();
256
257        // Force a snapshot that includes `a` with zero outgoing edges.
258        g.rebuild_csr().unwrap();
259        assert!(g.out_neighbors(a).unwrap().is_empty());
260
261        let eid = g.add_edge(a, b, "E", &()).unwrap();
262
263        let out = g.out_neighbors(a).unwrap();
264        assert_eq!(out.len(), 1, "new edge must be visible despite stale CSR");
265        assert_eq!(out[0].edge, eid);
266        assert_eq!(out[0].node, b);
267    }
268
269    /// After a CSR rebuild captures an edge into the snapshot, deleting that
270    /// edge must remove it from `out_neighbors`. Serving from the stale snapshot
271    /// would return the deleted edge.
272    #[test]
273    fn out_neighbors_reflects_edge_deleted_after_snapshot() {
274        let (_dir, g) = open_tmp();
275        let a = g.add_node("N", &()).unwrap();
276        let b = g.add_node("N", &()).unwrap();
277        let eid = g.add_edge(a, b, "E", &()).unwrap();
278
279        g.rebuild_csr().unwrap();
280        assert_eq!(g.out_neighbors(a).unwrap().len(), 1);
281
282        g.delete_edge(eid).unwrap();
283
284        assert!(
285            g.out_neighbors(a).unwrap().is_empty(),
286            "deleted edge must not appear, even though CSR still holds it"
287        );
288    }
289
290    /// `out_neighbors` and `in_neighbors` must agree on the same edge after a
291    /// mutation that postdates the snapshot. This is the asymmetry the snapshot
292    /// fast path introduced: `in_neighbors` always read LMDB while
293    /// `out_neighbors` trusted the snapshot.
294    #[test]
295    fn out_and_in_neighbors_agree_after_snapshot() {
296        let (_dir, g) = open_tmp();
297        let a = g.add_node("N", &()).unwrap();
298        let b = g.add_node("N", &()).unwrap();
299        g.rebuild_csr().unwrap();
300
301        let eid = g.add_edge(a, b, "E", &()).unwrap();
302
303        let out = g.out_neighbors(a).unwrap();
304        let inc = g.in_neighbors(b).unwrap();
305        assert_eq!(out.len(), 1);
306        assert_eq!(inc.len(), 1);
307        assert_eq!(out[0].edge, eid);
308        assert_eq!(inc[0].edge, eid);
309    }
310
311    /// Inside a write transaction, `out_neighbors` must observe the edge created
312    /// earlier in the same uncommitted transaction (read-your-writes).
313    #[test]
314    fn write_txn_out_neighbors_sees_uncommitted_edge() {
315        let (_dir, g) = open_tmp();
316        let a = g.add_node("N", &()).unwrap();
317        let b = g.add_node("N", &()).unwrap();
318        // Snapshot `a` with no outgoing edges so the stale path would return [].
319        g.rebuild_csr().unwrap();
320
321        g.update(|txn| {
322            let eid = txn.add_edge(a, b, "E", &())?;
323            let out = txn.out_neighbors(a)?;
324            assert_eq!(out.len(), 1, "uncommitted edge must be visible in-txn");
325            assert_eq!(out[0].edge, eid);
326            Ok(())
327        })
328        .unwrap();
329    }
330
331    /// `update_edge` must replace the stored properties and leave the
332    /// endpoints and type untouched.
333    #[test]
334    fn update_edge_replaces_props() {
335        let (_dir, g) = open_tmp();
336        let a = g.add_node("N", &()).unwrap();
337        let b = g.add_node("N", &()).unwrap();
338        let eid = g.add_edge(a, b, "E", &serde_json::json!({"w": 1})).unwrap();
339
340        g.update_edge(eid, &serde_json::json!({"w": 2})).unwrap();
341
342        let rec = g.get_edge(eid).unwrap().expect("edge must still exist");
343        assert_eq!(rec.src, a);
344        assert_eq!(rec.dst, b);
345        let props: serde_json::Value = rmp_serde::from_slice(&rec.props).unwrap();
346        assert_eq!(props["w"], serde_json::json!(2));
347    }
348
349    #[test]
350    fn update_edge_missing_edge_errors() {
351        let (_dir, g) = open_tmp();
352        let err = g
353            .update_edge(999, &serde_json::json!({"w": 1}))
354            .unwrap_err();
355        assert!(matches!(err, Error::EdgeNotFound(999)));
356    }
357
358    /// `node_has_relationships` must reflect both adjacency directions and
359    /// must go back to `false` once the last edge is deleted.
360    #[test]
361    fn node_has_relationships_reflects_adjacency() {
362        let (_dir, g) = open_tmp();
363        let a = g.add_node("N", &()).unwrap();
364        let b = g.add_node("N", &()).unwrap();
365        assert!(!g.node_has_relationships(a).unwrap());
366        assert!(!g.node_has_relationships(b).unwrap());
367
368        let eid = g.add_edge(a, b, "E", &()).unwrap();
369        assert!(g.node_has_relationships(a).unwrap(), "out edge counts");
370        assert!(g.node_has_relationships(b).unwrap(), "in edge counts");
371
372        g.delete_edge(eid).unwrap();
373        assert!(!g.node_has_relationships(a).unwrap());
374        assert!(!g.node_has_relationships(b).unwrap());
375    }
376}