Skip to main content

nodedb_graph/
overlay_delta.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! In-transaction graph overlay, expressed purely as node/label strings.
4//!
5//! A traversal running inside a transaction must observe that transaction's
6//! own not-yet-durable edge writes and deletes (read-your-own-writes),
7//! including through nodes that are reachable only via a staged edge and thus
8//! have no durable CSR surrogate. This type carries exactly that staged state
9//! for one traversal scope, built from plain strings so the shared crate
10//! stays free of any host-specific overlay type.
11//!
12//! It is consumed by [`crate::csr::CsrIndex::traverse_bfs`] and
13//! [`crate::csr::CsrIndex::subgraph`]: staged out/in edges are unioned into
14//! the discovered set, and staged tombstones subtract durable edges.
15
16use std::collections::{HashMap, HashSet};
17
18/// Staged, not-yet-durable graph edges and deletes for one traversal scope.
19///
20/// `out_edges`/`in_edges` mirror each other: [`Self::stage_edge`] inserts into
21/// both so out- and in-neighbour lookups are each O(1) on the node string.
22#[derive(Debug, Default, Clone)]
23pub struct GraphOverlayDelta {
24    /// Staged out-edges keyed by source node: `src -> [(label, dst)]`.
25    out_edges: HashMap<String, Vec<(String, String)>>,
26    /// Staged in-edges keyed by destination node: `dst -> [(label, src)]`.
27    in_edges: HashMap<String, Vec<(String, String)>>,
28    /// Staged deleted edges as `(src, label, dst)`.
29    tombstones: HashSet<(String, String, String)>,
30}
31
32impl GraphOverlayDelta {
33    /// An empty overlay.
34    pub fn new() -> Self {
35        Self::default()
36    }
37
38    /// Record a staged edge `src -[label]-> dst`, indexing it for both
39    /// out-neighbour (`src`) and in-neighbour (`dst`) lookups.
40    pub fn stage_edge(&mut self, src: &str, label: &str, dst: &str) {
41        self.out_edges
42            .entry(src.to_string())
43            .or_default()
44            .push((label.to_string(), dst.to_string()));
45        self.in_edges
46            .entry(dst.to_string())
47            .or_default()
48            .push((label.to_string(), src.to_string()));
49    }
50
51    /// Record a staged deletion of `src -[label]-> dst`.
52    pub fn stage_tombstone(&mut self, src: &str, label: &str, dst: &str) {
53        self.tombstones
54            .insert((src.to_string(), label.to_string(), dst.to_string()));
55    }
56
57    /// True when there is no staged state at all — the caller then takes the
58    /// durable-only fast path.
59    pub fn is_empty(&self) -> bool {
60        self.out_edges.is_empty() && self.in_edges.is_empty() && self.tombstones.is_empty()
61    }
62
63    /// Staged out-neighbours of `src` as `(label, dst)`, honouring
64    /// `label_filter` (when `Some`, only edges with that exact label).
65    pub fn out_neighbors<'a>(
66        &'a self,
67        src: &str,
68        label_filter: Option<&'a str>,
69    ) -> impl Iterator<Item = (&'a str, &'a str)> {
70        self.out_edges.get(src).into_iter().flat_map(move |edges| {
71            edges
72                .iter()
73                .filter_map(move |(label, dst)| match label_filter {
74                    Some(f) if f != label => None,
75                    _ => Some((label.as_str(), dst.as_str())),
76                })
77        })
78    }
79
80    /// Staged in-neighbours of `dst` as `(label, src)`, honouring
81    /// `label_filter` (when `Some`, only edges with that exact label).
82    pub fn in_neighbors<'a>(
83        &'a self,
84        dst: &str,
85        label_filter: Option<&'a str>,
86    ) -> impl Iterator<Item = (&'a str, &'a str)> {
87        self.in_edges.get(dst).into_iter().flat_map(move |edges| {
88            edges
89                .iter()
90                .filter_map(move |(label, src)| match label_filter {
91                    Some(f) if f != label => None,
92                    _ => Some((label.as_str(), src.as_str())),
93                })
94        })
95    }
96
97    /// Every node name that appears as an endpoint of a staged edge (as a
98    /// source in `out_edges` or a destination in `in_edges`).
99    ///
100    /// A node created purely by an in-transaction edge write has no durable CSR
101    /// id, so a pattern engine that enumerates free-ranging MATCH anchors from
102    /// the CSR alone would never admit it. This lets such staged-only nodes be
103    /// offered as candidate anchors. Names may repeat (a node that is both a
104    /// staged source and a staged destination); callers dedup as needed.
105    pub fn staged_endpoint_names(&self) -> impl Iterator<Item = &str> {
106        self.out_edges
107            .keys()
108            .chain(self.in_edges.keys())
109            .map(String::as_str)
110    }
111
112    /// True when `src -[label]-> dst` has been staged-deleted.
113    pub fn is_tombstoned(&self, src: &str, label: &str, dst: &str) -> bool {
114        self.tombstones
115            .contains(&(src.to_string(), label.to_string(), dst.to_string()))
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn empty_by_default() {
125        assert!(GraphOverlayDelta::new().is_empty());
126    }
127
128    #[test]
129    fn staged_edge_visible_both_directions() {
130        let mut d = GraphOverlayDelta::new();
131        d.stage_edge("a", "knows", "b");
132        assert!(!d.is_empty());
133        let out: Vec<_> = d.out_neighbors("a", None).collect();
134        assert_eq!(out, vec![("knows", "b")]);
135        let inn: Vec<_> = d.in_neighbors("b", None).collect();
136        assert_eq!(inn, vec![("knows", "a")]);
137    }
138
139    #[test]
140    fn label_filter_applied() {
141        let mut d = GraphOverlayDelta::new();
142        d.stage_edge("a", "knows", "b");
143        d.stage_edge("a", "likes", "c");
144        let out: Vec<_> = d.out_neighbors("a", Some("likes")).collect();
145        assert_eq!(out, vec![("likes", "c")]);
146    }
147
148    #[test]
149    fn tombstone_only_is_not_empty() {
150        let mut d = GraphOverlayDelta::new();
151        d.stage_tombstone("a", "knows", "b");
152        assert!(!d.is_empty());
153        assert!(d.is_tombstoned("a", "knows", "b"));
154        assert!(!d.is_tombstoned("a", "knows", "z"));
155    }
156}