Skip to main content

nodedb_graph/
path_overlay.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! In-transaction (read-your-own-writes) bidirectional shortest path.
4//!
5//! Runs only when a non-empty [`GraphOverlayDelta`] is supplied. Like the
6//! string-keyed BFS in [`crate::traversal_overlay`], the two frontiers are
7//! keyed on the node *string* rather than the u32 CSR id. That choice is what
8//! makes bidirectional search correct across staged edges: a node reachable
9//! only via a staged edge has no durable surrogate, so the durable dense
10//! bidirectional path — which detects a meeting by comparing u32 ids — cannot
11//! represent it. With string keys, a staged-only meeting node is simply a
12//! string present in both the forward and backward parent maps; the
13//! meeting point is a plain string-set intersection and no synthetic
14//! (ephemeral) u32 id is ever minted.
15//!
16//! Each forward frontier node expands its OUT edges (durable CSR out via
17//! `node_to_id`, skipping staged tombstones and bitmap-excluded durable
18//! targets, unioned with `overlay.out_neighbors`). Each backward frontier
19//! node expands its IN edges symmetrically. Staged edges bypass the frontier
20//! bitmap: the transaction's own writes have no durable surrogate to gate on.
21//!
22//! `partition` is `None` for a tenant with no CSR partition. The search then
23//! follows only staged edges.
24
25use std::collections::HashMap;
26use std::collections::hash_map::Entry;
27
28use crate::csr::CsrIndex;
29use crate::csr::index::LabelFilter;
30use crate::overlay_delta::GraphOverlayDelta;
31use crate::path_params::ShortestPathParams;
32use crate::traversal_overlay::node_present;
33
34/// String-keyed bidirectional BFS that merges the transaction's staged
35/// edges/tombstones. Mirrors the durable `shortest_path_dense` loop
36/// structure (alternating one forward then one backward level per depth
37/// step) so that, when the overlay contributes nothing, the result and
38/// path shape match the durable search.
39///
40/// A path from a node to itself exists only when the node exists: the
41/// partition holds it, or a staged edge names it. The dense search answers
42/// the same.
43pub(crate) fn shortest_path_overlay(
44    partition: Option<&CsrIndex>,
45    params: ShortestPathParams<'_>,
46    overlay: &GraphOverlayDelta,
47) -> Option<Vec<String>> {
48    let ShortestPathParams {
49        src,
50        dst,
51        label_filter,
52        max_depth,
53        max_visited,
54        frontier_bitmap,
55    } = params;
56    if src == dst {
57        return node_present(partition, overlay, src).then(|| vec![src.to_string()]);
58    }
59
60    // parent maps: node -> the neighbour it was reached from. The endpoint
61    // maps to itself, marking the reconstruction terminus.
62    let mut fwd_parent: HashMap<String, String> = HashMap::new();
63    let mut bwd_parent: HashMap<String, String> = HashMap::new();
64    fwd_parent.insert(src.to_string(), src.to_string());
65    bwd_parent.insert(dst.to_string(), dst.to_string());
66
67    let mut fwd_frontier: Vec<String> = vec![src.to_string()];
68    let mut bwd_frontier: Vec<String> = vec![dst.to_string()];
69
70    let gate = PathGate::new(partition, label_filter, frontier_bitmap, [src, dst]);
71    let within_depth =
72        |path: Vec<String>| (path.len().saturating_sub(1) <= max_depth).then_some(path);
73
74    // Round `k` meets on a path of `2k - 1` or `2k` edges, so
75    // `max_depth.div_ceil(2)` rounds reach every path within `max_depth`.
76    for _round in 0..max_depth.div_ceil(2) {
77        if fwd_parent.len() + bwd_parent.len() >= max_visited {
78            break;
79        }
80
81        // Each level's edges are relaxed in (neighbour, frontier node)
82        // name order, as the durable search relaxes them.
83        let mut candidates: Vec<(String, String)> = Vec::new();
84        for node in std::mem::take(&mut fwd_frontier) {
85            for neighbor in forward_neighbors(&node, &gate, overlay) {
86                candidates.push((neighbor, node.clone()));
87            }
88        }
89        candidates.sort();
90        let mut next_fwd = Vec::new();
91        for (neighbor, node) in candidates {
92            if let Some(meeting) = relax(
93                &neighbor,
94                &node,
95                &mut fwd_parent,
96                &bwd_parent,
97                &mut next_fwd,
98            ) {
99                return within_depth(reconstruct(&meeting, &fwd_parent, &bwd_parent));
100            }
101        }
102        fwd_frontier = next_fwd;
103
104        let mut candidates: Vec<(String, String)> = Vec::new();
105        for node in std::mem::take(&mut bwd_frontier) {
106            for neighbor in backward_neighbors(&node, &gate, overlay) {
107                candidates.push((neighbor, node.clone()));
108            }
109        }
110        candidates.sort();
111        let mut next_bwd = Vec::new();
112        for (neighbor, node) in candidates {
113            if let Some(meeting) = relax(
114                &neighbor,
115                &node,
116                &mut bwd_parent,
117                &fwd_parent,
118                &mut next_bwd,
119            ) {
120                return within_depth(reconstruct(&meeting, &fwd_parent, &bwd_parent));
121            }
122        }
123        bwd_frontier = next_bwd;
124
125        if fwd_frontier.is_empty() && bwd_frontier.is_empty() {
126            break;
127        }
128    }
129    None
130}
131
132/// OUT neighbours of `node`: durable CSR out edges (skipping staged
133/// tombstones and gated durable targets) unioned with staged out edges.
134fn forward_neighbors(node: &str, gate: &PathGate<'_>, overlay: &GraphOverlayDelta) -> Vec<String> {
135    let mut out = Vec::new();
136    if let Some((csr, durable_labels)) = gate.durable.as_ref()
137        && let Some(&node_id) = csr.node_to_id.get(node)
138    {
139        csr.record_access(node_id);
140        for (lid, dst) in csr.dense_iter_out(node_id) {
141            if !durable_labels.keeps(lid) {
142                continue;
143            }
144            let dst_name = &csr.id_to_node[dst as usize];
145            if overlay.is_tombstoned(node, csr.label_name(lid), dst_name) {
146                continue;
147            }
148            if !gate.admits_durable(csr, dst, dst_name) {
149                continue;
150            }
151            out.push(dst_name.clone());
152        }
153    }
154    out.extend(
155        overlay
156            .out_neighbors(node, gate.labels)
157            .map(|(_, dst)| dst.to_string()),
158    );
159    out
160}
161
162/// IN neighbours of `node`: durable CSR in edges (skipping staged
163/// tombstones and gated durable sources) unioned with staged in edges.
164fn backward_neighbors(node: &str, gate: &PathGate<'_>, overlay: &GraphOverlayDelta) -> Vec<String> {
165    let mut out = Vec::new();
166    if let Some((csr, durable_labels)) = gate.durable.as_ref()
167        && let Some(&node_id) = csr.node_to_id.get(node)
168    {
169        csr.record_access(node_id);
170        for (lid, src) in csr.dense_iter_in(node_id) {
171            if !durable_labels.keeps(lid) {
172                continue;
173            }
174            let src_name = &csr.id_to_node[src as usize];
175            if overlay.is_tombstoned(src_name, csr.label_name(lid), node) {
176                continue;
177            }
178            if !gate.admits_durable(csr, src, src_name) {
179                continue;
180            }
181            out.push(src_name.clone());
182        }
183    }
184    out.extend(
185        overlay
186            .in_neighbors(node, gate.labels)
187            .map(|(_, src)| src.to_string()),
188    );
189    out
190}
191
192/// Which edges and nodes one overlay path search may cross.
193struct PathGate<'a> {
194    /// Staged-edge label names. Empty keeps every label.
195    labels: &'a [&'a str],
196    /// The durable partition, and `labels` resolved against it. A label the
197    /// partition has never seen keeps no durable edge, so it never widens
198    /// the filter. `None` when the tenant has no partition.
199    durable: Option<(&'a CsrIndex, LabelFilter)>,
200    frontier_bitmap: Option<&'a nodedb_types::SurrogateBitmap>,
201    /// The source and destination, which the bitmap never gates.
202    endpoints: [&'a str; 2],
203}
204
205impl<'a> PathGate<'a> {
206    fn new(
207        partition: Option<&'a CsrIndex>,
208        label_filter: &'a [&'a str],
209        frontier_bitmap: Option<&'a nodedb_types::SurrogateBitmap>,
210        endpoints: [&'a str; 2],
211    ) -> Self {
212        Self {
213            labels: label_filter,
214            durable: partition.map(|csr| (csr, csr.label_filter(label_filter))),
215            frontier_bitmap,
216            endpoints,
217        }
218    }
219
220    /// Staged edges bypass the bitmap: the transaction's own writes have no
221    /// durable surrogate to gate on.
222    fn admits_durable(&self, csr: &CsrIndex, id: u32, name: &str) -> bool {
223        self.endpoints.contains(&name)
224            || self.frontier_bitmap.is_none_or(|bm| {
225                bm.contains(nodedb_types::Surrogate::new(csr.node_surrogate_raw(id)))
226            })
227    }
228}
229
230/// Record `neighbor` (reached from `parent`) in `this_parent` if unseen and
231/// queue it for the next level. Returns `Some(neighbor)` when the two searches
232/// have met — i.e. `neighbor` is already present in `other_parent`. Meeting
233/// detection is independent of whether `neighbor` was newly inserted, matching
234/// the durable dense search. The caller reconstructs the path with the fixed
235/// forward/backward maps, so orientation is never ambiguous.
236fn relax(
237    neighbor: &str,
238    parent: &str,
239    this_parent: &mut HashMap<String, String>,
240    other_parent: &HashMap<String, String>,
241    next_frontier: &mut Vec<String>,
242) -> Option<String> {
243    if let Entry::Vacant(e) = this_parent.entry(neighbor.to_string()) {
244        e.insert(parent.to_string());
245        next_frontier.push(neighbor.to_string());
246    }
247    if other_parent.contains_key(neighbor) {
248        return Some(neighbor.to_string());
249    }
250    None
251}
252
253/// Reconstruct the ordered `src -> dst` path through `meeting`. `fwd_parent`
254/// roots at `src`, `bwd_parent` roots at `dst`; `meeting` is present in both
255/// (a caller invariant). Mirrors the durable dense `reconstruct_path`.
256fn reconstruct(
257    meeting: &str,
258    fwd_parent: &HashMap<String, String>,
259    bwd_parent: &HashMap<String, String>,
260) -> Vec<String> {
261    let mut path = walk_to_root(meeting, fwd_parent);
262    path.reverse();
263
264    let suffix_root = &bwd_parent[meeting];
265    if suffix_root != meeting {
266        let mut cur = suffix_root.clone();
267        loop {
268            path.push(cur.clone());
269            let parent = &bwd_parent[&cur];
270            if *parent == cur {
271                break;
272            }
273            cur = parent.clone();
274        }
275    }
276    path
277}
278
279/// Walk the parent chain from `start` to its self-parent root, collecting each
280/// node (root last). Presence is a caller invariant.
281fn walk_to_root(start: &str, parents: &HashMap<String, String>) -> Vec<String> {
282    let mut chain = Vec::new();
283    let mut cur = start.to_string();
284    loop {
285        chain.push(cur.clone());
286        let parent = &parents[&cur];
287        if *parent == cur {
288            break;
289        }
290        cur = parent.clone();
291    }
292    chain
293}
294
295#[cfg(test)]
296mod tests {
297    use crate::csr::CsrIndex;
298    use crate::overlay_delta::GraphOverlayDelta;
299    use crate::path_params::ShortestPathParams;
300    use crate::test_support::test_memory;
301    use crate::traversal::DEFAULT_MAX_VISITED;
302
303    fn params<'a>(
304        src: &'a str,
305        dst: &'a str,
306        label_filter: &'a [&'a str],
307        max_depth: usize,
308    ) -> ShortestPathParams<'a> {
309        ShortestPathParams {
310            src,
311            dst,
312            label_filter,
313            max_depth,
314            max_visited: DEFAULT_MAX_VISITED,
315            frontier_bitmap: None,
316        }
317    }
318
319    /// A bitmap that holds neither endpoint still admits the durable edge
320    /// between them while a staged edge forces the overlay search.
321    #[test]
322    fn the_frontier_bitmap_never_gates_the_endpoints() {
323        use nodedb_types::{Surrogate, SurrogateBitmap};
324
325        let mut csr = CsrIndex::new(test_memory());
326        csr.add_edge("a", "KNOWS", "c").unwrap();
327        csr.set_node_surrogate("a", Surrogate::new(1));
328        csr.set_node_surrogate("c", Surrogate::new(3));
329        let mut bm = SurrogateBitmap::new();
330        bm.insert(Surrogate::new(99));
331        let mut ov = GraphOverlayDelta::new();
332        ov.stage_edge("x", "KNOWS", "y");
333
334        let mut p = params("a", "c", &["KNOWS"], 1);
335        p.frontier_bitmap = Some(&bm);
336        assert_eq!(
337            csr.shortest_path(p, Some(&ov)),
338            Some(vec!["a".to_string(), "c".to_string()])
339        );
340    }
341
342    /// A staged edge completes a path the durable graph lacks: durable A->B,
343    /// staged B->C, so A->B->C must be found only with the overlay.
344    #[test]
345    fn staged_edge_completes_path() {
346        let mut csr = CsrIndex::new(test_memory());
347        csr.add_edge("a", "KNOWS", "b").unwrap();
348        let mut ov = GraphOverlayDelta::new();
349        ov.stage_edge("b", "KNOWS", "c");
350
351        let path = csr
352            .shortest_path(params("a", "c", &["KNOWS"], 5), Some(&ov))
353            .expect("staged edge should complete the path");
354        assert_eq!(path, vec!["a", "b", "c"]);
355
356        // Without the overlay the path does not exist.
357        assert!(
358            csr.shortest_path(params("a", "c", &["KNOWS"], 5), None)
359                .is_none()
360        );
361    }
362
363    /// A path entirely through staged-only intermediate nodes: no durable
364    /// edges touch A, X, or D. String-keyed frontiers let X (no surrogate)
365    /// participate as the meeting point.
366    #[test]
367    fn path_through_staged_only_node() {
368        let csr = CsrIndex::new(test_memory());
369        let mut ov = GraphOverlayDelta::new();
370        ov.stage_edge("a", "KNOWS", "x");
371        ov.stage_edge("x", "KNOWS", "d");
372
373        let path = csr
374            .shortest_path(params("a", "d", &["KNOWS"], 5), Some(&ov))
375            .expect("staged-only path should be found");
376        assert_eq!(path, vec!["a", "x", "d"]);
377    }
378
379    /// A staged tombstone removes the only durable direct edge, forcing a
380    /// longer detour that exists only via other durable edges.
381    #[test]
382    fn tombstone_forces_detour() {
383        // Durable: a->d (direct), a->b, b->d. Tombstone a->d. Detour a->b->d.
384        let mut csr = CsrIndex::new(test_memory());
385        csr.add_edge("a", "KNOWS", "d").unwrap();
386        csr.add_edge("a", "KNOWS", "b").unwrap();
387        csr.add_edge("b", "KNOWS", "d").unwrap();
388        let mut ov = GraphOverlayDelta::new();
389        ov.stage_tombstone("a", "KNOWS", "d");
390
391        let path = csr
392            .shortest_path(params("a", "d", &["KNOWS"], 5), Some(&ov))
393            .expect("detour path should be found");
394        assert_eq!(path, vec!["a", "b", "d"]);
395    }
396
397    /// A staged tombstone that removes the only path yields None.
398    #[test]
399    fn tombstone_removes_only_path() {
400        let mut csr = CsrIndex::new(test_memory());
401        csr.add_edge("a", "KNOWS", "d").unwrap();
402        let mut ov = GraphOverlayDelta::new();
403        ov.stage_tombstone("a", "KNOWS", "d");
404
405        assert!(
406            csr.shortest_path(params("a", "d", &["KNOWS"], 5), Some(&ov))
407                .is_none()
408        );
409    }
410
411    /// Empty overlay yields the same result as the durable dense search on a
412    /// small fixed graph (parity check).
413    #[test]
414    fn empty_overlay_matches_dense() {
415        let mut csr = CsrIndex::new(test_memory());
416        csr.add_edge("a", "KNOWS", "b").unwrap();
417        csr.add_edge("b", "KNOWS", "c").unwrap();
418        csr.add_edge("c", "KNOWS", "d").unwrap();
419        let ov = GraphOverlayDelta::new();
420
421        // Empty overlay dispatches to dense; force the overlay code path by
422        // calling it directly, then compare with dense.
423        let dense = csr
424            .shortest_path(params("a", "d", &["KNOWS"], 10), None)
425            .unwrap();
426        let overlaid =
427            super::shortest_path_overlay(Some(&csr), params("a", "d", &["KNOWS"], 10), &ov);
428        assert_eq!(overlaid, Some(dense));
429    }
430
431    /// Durable `a -FIRST-> b`, staged `b -SECOND-> d` and `b -OTHER-> d`. The
432    /// set follows the staged edge under its second label only.
433    #[test]
434    fn a_staged_edge_under_the_second_label_of_a_set_is_followed() {
435        let mut csr = CsrIndex::new(test_memory());
436        csr.add_edge("a", "FIRST", "b").unwrap();
437        let mut ov = GraphOverlayDelta::new();
438        ov.stage_edge("b", "SECOND", "d");
439        ov.stage_edge("b", "OTHER", "x");
440
441        assert_eq!(
442            csr.shortest_path(params("a", "d", &["FIRST", "SECOND"], 5), Some(&ov)),
443            Some(vec!["a".to_string(), "b".to_string(), "d".to_string()])
444        );
445        assert!(
446            csr.shortest_path(params("a", "x", &["FIRST", "SECOND"], 5), Some(&ov))
447                .is_none()
448        );
449    }
450
451    #[test]
452    fn src_equals_dst() {
453        let mut csr = CsrIndex::new(test_memory());
454        csr.add_edge("a", "KNOWS", "b").unwrap();
455        let mut ov = GraphOverlayDelta::new();
456        ov.stage_edge("a", "KNOWS", "x");
457        let path = csr
458            .shortest_path(params("a", "a", &[], 5), Some(&ov))
459            .unwrap();
460        assert_eq!(path, vec!["a"]);
461    }
462
463    /// A node the partition lacks and no staged edge names has no path to
464    /// itself, as in the dense search. A staged edge naming it makes it
465    /// exist.
466    #[test]
467    fn src_equals_dst_on_an_absent_node_is_none() {
468        let mut csr = CsrIndex::new(test_memory());
469        csr.add_edge("a", "KNOWS", "b").unwrap();
470        let mut ov = GraphOverlayDelta::new();
471        ov.stage_edge("x", "KNOWS", "y");
472        assert!(
473            csr.shortest_path(params("ghost", "ghost", &[], 5), Some(&ov))
474                .is_none()
475        );
476        assert!(
477            csr.shortest_path(params("ghost", "ghost", &[], 5), None)
478                .is_none()
479        );
480        assert_eq!(
481            csr.shortest_path(params("y", "y", &[], 5), Some(&ov)),
482            Some(vec!["y".to_string()])
483        );
484    }
485
486    /// With no partition, the search follows staged edges only.
487    #[test]
488    fn a_missing_partition_walks_staged_edges() {
489        let mut ov = GraphOverlayDelta::new();
490        ov.stage_edge("a", "KNOWS", "x");
491        ov.stage_edge("x", "KNOWS", "d");
492        assert_eq!(
493            CsrIndex::shortest_path_on(None, params("a", "d", &["KNOWS"], 5), Some(&ov)),
494            Some(vec!["a".to_string(), "x".to_string(), "d".to_string()])
495        );
496        assert!(CsrIndex::shortest_path_on(None, params("a", "d", &[], 5), None).is_none());
497        assert!(
498            CsrIndex::shortest_path_on(None, params("ghost", "ghost", &[], 5), Some(&ov)).is_none()
499        );
500    }
501
502    #[test]
503    fn unreachable_is_none() {
504        let mut csr = CsrIndex::new(test_memory());
505        csr.add_edge("a", "KNOWS", "b").unwrap();
506        let mut ov = GraphOverlayDelta::new();
507        ov.stage_edge("m", "KNOWS", "n");
508        assert!(
509            csr.shortest_path(params("a", "n", &["KNOWS"], 5), Some(&ov))
510                .is_none()
511        );
512    }
513}