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
18use crate::csr::index::LabelFilter;
19
20/// Staged, not-yet-durable graph edges and deletes for one traversal scope.
21///
22/// `out_edges`/`in_edges` mirror each other: [`Self::stage_edge`] inserts into
23/// both so out- and in-neighbour lookups are each O(1) on the node string.
24#[derive(Debug, Default, Clone)]
25pub struct GraphOverlayDelta {
26    /// Staged out-edges keyed by source node: `src -> [(label, dst)]`.
27    out_edges: HashMap<String, Vec<(String, String)>>,
28    /// Staged in-edges keyed by destination node: `dst -> [(label, src)]`.
29    in_edges: HashMap<String, Vec<(String, String)>>,
30    /// Staged deleted edges as `(src, label, dst)`.
31    tombstones: HashSet<(String, String, String)>,
32}
33
34impl GraphOverlayDelta {
35    /// An empty overlay.
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// Record a staged edge `src -[label]-> dst`, indexing it for both
41    /// out-neighbour (`src`) and in-neighbour (`dst`) lookups.
42    pub fn stage_edge(&mut self, src: &str, label: &str, dst: &str) {
43        self.out_edges
44            .entry(src.to_string())
45            .or_default()
46            .push((label.to_string(), dst.to_string()));
47        self.in_edges
48            .entry(dst.to_string())
49            .or_default()
50            .push((label.to_string(), src.to_string()));
51    }
52
53    /// Record a staged deletion of `src -[label]-> dst`.
54    pub fn stage_tombstone(&mut self, src: &str, label: &str, dst: &str) {
55        self.tombstones
56            .insert((src.to_string(), label.to_string(), dst.to_string()));
57    }
58
59    /// True when there is no staged state at all — the caller then takes the
60    /// durable-only fast path.
61    pub fn is_empty(&self) -> bool {
62        self.out_edges.is_empty() && self.in_edges.is_empty() && self.tombstones.is_empty()
63    }
64
65    /// Staged out-neighbours of `src` as `(label, dst)`. `label_filter` empty
66    /// keeps every staged edge. Otherwise an edge whose label is any listed
67    /// label passes.
68    pub fn out_neighbors<'a>(
69        &'a self,
70        src: &str,
71        label_filter: &'a [&'a str],
72    ) -> impl Iterator<Item = (&'a str, &'a str)> {
73        self.out_edges.get(src).into_iter().flat_map(move |edges| {
74            edges
75                .iter()
76                .filter(move |(label, _)| LabelFilter::keeps_name(label_filter, label))
77                .map(|(label, dst)| (label.as_str(), dst.as_str()))
78        })
79    }
80
81    /// Staged in-neighbours of `dst` as `(label, src)`. `label_filter` empty
82    /// keeps every staged edge. Otherwise an edge whose label is any listed
83    /// label passes.
84    pub fn in_neighbors<'a>(
85        &'a self,
86        dst: &str,
87        label_filter: &'a [&'a str],
88    ) -> impl Iterator<Item = (&'a str, &'a str)> {
89        self.in_edges.get(dst).into_iter().flat_map(move |edges| {
90            edges
91                .iter()
92                .filter(move |(label, _)| LabelFilter::keeps_name(label_filter, label))
93                .map(|(label, src)| (label.as_str(), src.as_str()))
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 a staged edge has `node` as its source or destination.
113    pub fn names_node(&self, node: &str) -> bool {
114        self.out_edges.contains_key(node) || self.in_edges.contains_key(node)
115    }
116
117    /// True when `src -[label]-> dst` has been staged-deleted.
118    pub fn is_tombstoned(&self, src: &str, label: &str, dst: &str) -> bool {
119        self.tombstones
120            .contains(&(src.to_string(), label.to_string(), dst.to_string()))
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn empty_by_default() {
130        assert!(GraphOverlayDelta::new().is_empty());
131    }
132
133    #[test]
134    fn staged_edge_visible_both_directions() {
135        let mut d = GraphOverlayDelta::new();
136        d.stage_edge("a", "knows", "b");
137        assert!(!d.is_empty());
138        let out: Vec<_> = d.out_neighbors("a", &[]).collect();
139        assert_eq!(out, vec![("knows", "b")]);
140        let inn: Vec<_> = d.in_neighbors("b", &[]).collect();
141        assert_eq!(inn, vec![("knows", "a")]);
142    }
143
144    #[test]
145    fn label_filter_applied() {
146        let mut d = GraphOverlayDelta::new();
147        d.stage_edge("a", "knows", "b");
148        d.stage_edge("a", "likes", "c");
149        let out: Vec<_> = d.out_neighbors("a", &["likes"]).collect();
150        assert_eq!(out, vec![("likes", "c")]);
151    }
152
153    #[test]
154    fn a_label_set_keeps_every_listed_label() {
155        let mut d = GraphOverlayDelta::new();
156        d.stage_edge("a", "knows", "b");
157        d.stage_edge("a", "likes", "c");
158        d.stage_edge("a", "hates", "d");
159        let out: Vec<_> = d.out_neighbors("a", &["likes", "knows", "nope"]).collect();
160        assert_eq!(out, vec![("knows", "b"), ("likes", "c")]);
161        let inn: Vec<_> = d.in_neighbors("d", &["knows", "likes"]).collect();
162        assert!(inn.is_empty());
163        let none: Vec<_> = d.out_neighbors("a", &["nope"]).collect();
164        assert!(none.is_empty());
165    }
166
167    #[test]
168    fn tombstone_only_is_not_empty() {
169        let mut d = GraphOverlayDelta::new();
170        d.stage_tombstone("a", "knows", "b");
171        assert!(!d.is_empty());
172        assert!(d.is_tombstoned("a", "knows", "b"));
173        assert!(!d.is_tombstoned("a", "knows", "z"));
174    }
175
176    #[test]
177    fn names_node_covers_staged_endpoints_only() {
178        let mut d = GraphOverlayDelta::new();
179        d.stage_edge("a", "knows", "b");
180        d.stage_tombstone("x", "knows", "y");
181        assert!(d.names_node("a"));
182        assert!(d.names_node("b"));
183        assert!(!d.names_node("x"));
184        assert!(!d.names_node("z"));
185    }
186}