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}