Skip to main content

linkrs_query/executor/traversal/
runtime.rs

1use std::cmp::Ordering;
2use std::collections::{BinaryHeap, HashSet, VecDeque};
3
4use crate::executor::streaming::query_registry::CancelToken;
5use crate::executor::traversal::config::{TraversalConfig, TraversalOrder, VisitedPolicy};
6use crate::executor::traversal::graph_reader::{edge_key, EdgeKey, TraversalGraphReader};
7use crate::executor::traversal::stats::TraversalStats;
8use crate::parser::ast::pattern::PathSemantic;
9use linkrs_core::error::QueryError;
10use linkrs_core::types::storage_ids::VertexId;
11use linkrs_core::{Edge, Vertex};
12
13#[derive(Debug, Clone)]
14pub struct TraversalItem {
15    pub vertex_id: VertexId,
16    pub vertex: Vertex,
17    pub depth: u32,
18    pub edge: Option<Edge>,
19    path_vertices: HashSet<VertexId>,
20    path_edges: HashSet<EdgeKey>,
21}
22
23#[derive(Debug, Clone)]
24pub struct TraversalEvent {
25    pub vertex: Vertex,
26    pub depth: u32,
27    pub edge: Option<Edge>,
28}
29
30pub struct TraversalRuntime<'a> {
31    pub reader: TraversalGraphReader<'a>,
32    pub config: TraversalConfig,
33    pub stats: TraversalStats,
34
35    frontier: VecDeque<TraversalItem>,
36    visited: HashSet<VertexId>,
37    results: VecDeque<TraversalEvent>,
38    exhausted: bool,
39    total_emitted: usize,
40
41    /// Optional cancel token for cooperative cancellation.
42    /// When set, `expand_frontier` checks the token at each iteration
43    /// boundary and returns early if cancelled.
44    cancel_token: Option<CancelToken>,
45}
46
47impl<'a> TraversalRuntime<'a> {
48    pub fn new(reader: TraversalGraphReader<'a>, config: TraversalConfig) -> Self {
49        Self {
50            reader,
51            config,
52            stats: TraversalStats::default(),
53            frontier: VecDeque::new(),
54            visited: HashSet::new(),
55            results: VecDeque::new(),
56            exhausted: false,
57            total_emitted: 0,
58            cancel_token: None,
59        }
60    }
61
62    /// Attach an optional cancel token for cooperative cancellation.
63    pub fn with_cancel_token(mut self, token: CancelToken) -> Self {
64        self.cancel_token = Some(token);
65        self
66    }
67
68    /// Set the cancel token after creation.
69    pub fn set_cancel_token(&mut self, token: CancelToken) {
70        self.cancel_token = Some(token);
71    }
72
73    /// Check whether a cancellation has been requested.
74    /// Returns `QueryError::execution` if cancelled.
75    pub fn check_cancel(&self) -> Result<(), QueryError> {
76        if let Some(ref token) = self.cancel_token {
77            if token.is_cancelled() {
78                return Err(QueryError::execution(
79                    "Query cancelled during traversal".to_string(),
80                ));
81            }
82        }
83        Ok(())
84    }
85
86    pub fn reset(&mut self) {
87        self.frontier.clear();
88        self.visited.clear();
89        self.results.clear();
90        self.exhausted = false;
91        self.total_emitted = 0;
92        self.stats = TraversalStats::default();
93    }
94
95    fn check_limit(&self) -> bool {
96        self.total_emitted >= self.config.limit
97    }
98
99    fn should_emit(&self, depth: u32) -> bool {
100        depth >= self.config.min_depth && depth <= self.config.max_depth
101    }
102
103    fn should_visit(&self, vertex_id: &VertexId) -> bool {
104        match self.config.visited_policy {
105            VisitedPolicy::None => true,
106            VisitedPolicy::PerSeed | VisitedPolicy::Global => !self.visited.contains(vertex_id),
107        }
108    }
109
110    pub fn seed_from_vertex(&mut self, vertex: Vertex) {
111        let vid = *vertex.vid();
112
113        if self.config.visited_policy != VisitedPolicy::None {
114            self.visited.insert(vid);
115        }
116
117        self.frontier.push_back(TraversalItem {
118            vertex_id: vid,
119            vertex,
120            depth: 0,
121            edge: None,
122            path_vertices: HashSet::from([vid]),
123            path_edges: HashSet::new(),
124        });
125    }
126
127    fn expand_frontier(&mut self) -> Result<(), QueryError> {
128        if matches!(
129            self.config.path_semantic,
130            Some(PathSemantic::WeightedShortest(_))
131        ) {
132            return self.expand_weighted();
133        }
134        while let Some(item) = match self.config.order {
135            TraversalOrder::Bfs => self.frontier.pop_front(),
136            TraversalOrder::Dfs => self.frontier.pop_back(),
137        } {
138            // Check cancel at each frontier item boundary
139            self.check_cancel()?;
140
141            if self.exhausted || self.check_limit() {
142                self.exhausted = true;
143                return Ok(());
144            }
145
146            if item.depth >= self.config.max_depth {
147                continue;
148            }
149
150            let edges = self.reader.get_edges(
151                &self.config.space_name,
152                &item.vertex_id,
153                self.config.direction,
154                &self.config.edge_types,
155            );
156            self.stats.record_edge_scan(edges.len());
157
158            for edge in &edges {
159                self.check_cancel()?;
160
161                if self.check_limit() {
162                    self.exhausted = true;
163                    return Ok(());
164                }
165
166                let neighbor_id =
167                    self.reader
168                        .get_neighbor_id(edge, &item.vertex_id, self.config.direction);
169
170                let parent_vertices = &item.path_vertices;
171                let parent_edges = &item.path_edges;
172                // Per-path repeat rules: Trail forbids reusing an edge
173                // within the same path, Acyclic forbids revisiting a vertex
174                // within the same path. Walk and Shortest impose no
175                // per-path check (Shortest relies on BFS global dedup).
176                let path_rejected = is_path_rejected(
177                    self.config.path_semantic.clone(),
178                    parent_vertices,
179                    parent_edges,
180                    &neighbor_id,
181                    &edge_key(edge),
182                );
183                if path_rejected || !self.should_visit(&neighbor_id) {
184                    continue;
185                }
186
187                if self.config.visited_policy != VisitedPolicy::None {
188                    self.visited.insert(neighbor_id);
189                }
190
191                if let Some(vertex) = self.reader.get_neighbor_vertex(
192                    &self.config.space_name,
193                    edge,
194                    &neighbor_id,
195                    &self.config.vertex_tag,
196                ) {
197                    self.stats.record_vertex_visit();
198                    let new_depth = item.depth + 1;
199                    self.stats.update_depth(new_depth);
200
201                    if self.should_emit(new_depth) {
202                        self.results.push_back(TraversalEvent {
203                            vertex: vertex.clone(),
204                            depth: new_depth,
205                            edge: Some(edge.clone()),
206                        });
207                        self.total_emitted += 1;
208                        self.stats.record_path_emitted();
209                    }
210
211                    let mut path_vertices = item.path_vertices.clone();
212                    path_vertices.insert(neighbor_id);
213                    let mut path_edges = item.path_edges.clone();
214                    path_edges.insert(edge_key(edge));
215                    self.frontier.push_back(TraversalItem {
216                        vertex_id: neighbor_id,
217                        vertex,
218                        depth: new_depth,
219                        edge: Some(edge.clone()),
220                        path_vertices,
221                        path_edges,
222                    });
223                }
224            }
225
226            self.stats.update_frontier(self.frontier.len());
227        }
228
229        self.exhausted = true;
230        Ok(())
231    }
232
233    /// Weighted-shortest expansion (Dijkstra).
234    ///
235    /// Runs from each seeded vertex, finalizing the cheapest path to
236    /// every reachable vertex within `[min_depth, max_depth]` hops. The edge
237    /// weight is read from the property named by the semantic; a missing or
238    /// non-numeric property is treated as weight `1.0`, and negative weights
239    /// are clamped to `0.0` so Dijkstra's non-negative assumption holds.
240    fn expand_weighted(&mut self) -> Result<(), QueryError> {
241        let weight_prop = match &self.config.path_semantic {
242            Some(PathSemantic::WeightedShortest(p)) => p.clone(),
243            _ => String::new(),
244        };
245
246        // Drain every seeded source. Callers normally seed exactly one
247        // vertex per runtime, but draining avoids silently dropping
248        // additional seeds when several are queued.
249        let seeds: Vec<TraversalItem> = std::mem::take(&mut self.frontier).into_iter().collect();
250        if seeds.is_empty() {
251            self.exhausted = true;
252            return Ok(());
253        }
254
255        for seed in seeds {
256            self.check_cancel()?;
257            if self.exhausted || self.check_limit() {
258                self.exhausted = true;
259                return Ok(());
260            }
261            // Seeds are pre-marked in `visited` by `seed_from_vertex`;
262            // allow the source itself to be processed so its neighborhood
263            // is expanded instead of being skipped as already finalized.
264            let seed_id = seed.vertex_id;
265            self.visited.remove(&seed_id);
266
267            let mut dist: std::collections::HashMap<VertexId, f64> =
268                std::collections::HashMap::new();
269            dist.insert(seed_id, 0.0);
270            let mut heap: BinaryHeap<WeightedItem> = BinaryHeap::new();
271            heap.push(WeightedItem {
272                cost: 0.0,
273                depth: 0,
274                vertex: seed.vertex,
275                vertex_id: seed_id,
276                edge: None,
277            });
278
279            while let Some(top) = heap.pop() {
280                self.check_cancel()?;
281                if self.exhausted || self.check_limit() {
282                    self.exhausted = true;
283                    return Ok(());
284                }
285                // Skip stale heap entries superseded by a cheaper finalized cost.
286                if self.visited.contains(&top.vertex_id) {
287                    continue;
288                }
289                self.visited.insert(top.vertex_id);
290
291                if self.should_emit(top.depth) {
292                    self.results.push_back(TraversalEvent {
293                        vertex: top.vertex.clone(),
294                        depth: top.depth,
295                        edge: top.edge.clone(),
296                    });
297                    self.total_emitted += 1;
298                    self.stats.record_path_emitted();
299                }
300
301                if top.depth >= self.config.max_depth {
302                    continue;
303                }
304
305                let edges = self.reader.get_edges(
306                    &self.config.space_name,
307                    &top.vertex_id,
308                    self.config.direction,
309                    &self.config.edge_types,
310                );
311                self.stats.record_edge_scan(edges.len());
312
313                for edge in &edges {
314                    self.check_cancel()?;
315                    let neighbor_id =
316                        self.reader
317                            .get_neighbor_id(edge, &top.vertex_id, self.config.direction);
318                    let weight = edge_weight(edge, &weight_prop);
319                    let next_cost = top.cost + weight;
320                    if self.visited.contains(&neighbor_id) {
321                        continue;
322                    }
323                    let better = match dist.get(&neighbor_id) {
324                        Some(old) => next_cost < *old,
325                        None => true,
326                    };
327                    if !better {
328                        continue;
329                    }
330                    dist.insert(neighbor_id, next_cost);
331                    if let Some(vertex) = self.reader.get_neighbor_vertex(
332                        &self.config.space_name,
333                        edge,
334                        &neighbor_id,
335                        &self.config.vertex_tag,
336                    ) {
337                        self.stats.record_vertex_visit();
338                        let new_depth = top.depth + 1;
339                        self.stats.update_depth(new_depth);
340                        heap.push(WeightedItem {
341                            cost: next_cost,
342                            depth: new_depth,
343                            vertex,
344                            vertex_id: neighbor_id,
345                            edge: Some(edge.clone()),
346                        });
347                    }
348                }
349                self.stats.update_frontier(heap.len());
350            }
351        }
352
353        self.exhausted = true;
354        Ok(())
355    }
356
357    pub fn next_event(&mut self) -> Option<TraversalEvent> {
358        if let Some(event) = self.results.pop_front() {
359            return Some(event);
360        }
361
362        if self.exhausted {
363            return None;
364        }
365
366        if self.expand_frontier().is_err() {
367            self.exhausted = true;
368            return None;
369        }
370
371        self.results.pop_front()
372    }
373
374    pub fn stats(&self) -> &TraversalStats {
375        &self.stats
376    }
377}
378
379/// Heap entry for the weighted (Dijkstra) traversal. Ordered by ascending
380/// cost; the `Ord` impl inverts cost so `BinaryHeap` (a max-heap) pops the
381/// cheapest entry first.
382#[derive(Debug, Clone)]
383struct WeightedItem {
384    cost: f64,
385    depth: u32,
386    vertex: Vertex,
387    vertex_id: VertexId,
388    edge: Option<Edge>,
389}
390
391impl PartialEq for WeightedItem {
392    fn eq(&self, other: &Self) -> bool {
393        self.cost == other.cost
394    }
395}
396
397impl Eq for WeightedItem {}
398
399impl PartialOrd for WeightedItem {
400    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
401        Some(self.cmp(other))
402    }
403}
404
405impl Ord for WeightedItem {
406    fn cmp(&self, other: &Self) -> Ordering {
407        other
408            .cost
409            .partial_cmp(&self.cost)
410            .unwrap_or(Ordering::Equal)
411    }
412}
413
414/// Read a numeric edge weight from `prop`, defaulting to `1.0` when the
415/// property is absent or non-numeric, and clamping negatives to `0.0`.
416fn edge_weight(edge: &Edge, prop: &str) -> f64 {
417    super::weighted::read_edge_weight(edge, prop)
418}
419
420/// Trail semantics forbid repeating an edge; acyclic semantics forbid
421/// repeating a vertex (which also forbids edge repeats).
422fn is_path_rejected(
423    semantic: Option<PathSemantic>,
424    parent_vertices: &HashSet<VertexId>,
425    parent_edges: &HashSet<EdgeKey>,
426    neighbor: &VertexId,
427    edge_key: &EdgeKey,
428) -> bool {
429    match semantic {
430        Some(PathSemantic::Trail) => parent_edges.contains(edge_key),
431        Some(PathSemantic::Acyclic) => parent_vertices.contains(neighbor),
432        _ => false,
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    fn vid(n: i64) -> VertexId {
441        VertexId::try_from_int64(n).expect("valid vertex id")
442    }
443
444    fn edge_key(src: i64, dst: i64) -> EdgeKey {
445        (vid(src), vid(dst), "KNOWS".to_string(), 0)
446    }
447
448    #[test]
449    fn trail_rejects_repeated_edge() {
450        let parents: HashSet<VertexId> = HashSet::from([vid(1), vid(2)]);
451        let edges: HashSet<EdgeKey> = HashSet::from([edge_key(2, 3)]);
452        // Reusing an edge is rejected even though the neighbor is new.
453        assert!(is_path_rejected(
454            Some(PathSemantic::Trail),
455            &parents,
456            &edges,
457            &vid(3),
458            &edge_key(2, 3)
459        ));
460        // Revisiting a vertex through a fresh edge is allowed under Trail.
461        assert!(!is_path_rejected(
462            Some(PathSemantic::Trail),
463            &parents,
464            &edges,
465            &vid(1),
466            &edge_key(3, 1)
467        ));
468    }
469
470    #[test]
471    fn acyclic_rejects_repeated_vertex() {
472        let parents: HashSet<VertexId> = HashSet::from([vid(1), vid(2)]);
473        let edges: HashSet<EdgeKey> = HashSet::new();
474        // Revisiting a vertex is rejected even through a fresh edge.
475        assert!(is_path_rejected(
476            Some(PathSemantic::Acyclic),
477            &parents,
478            &edges,
479            &vid(2),
480            &edge_key(3, 2)
481        ));
482        assert!(!is_path_rejected(
483            Some(PathSemantic::Acyclic),
484            &parents,
485            &edges,
486            &vid(3),
487            &edge_key(2, 3)
488        ));
489    }
490
491    #[test]
492    fn walk_and_shortest_impose_no_per_path_check() {
493        let parents: HashSet<VertexId> = HashSet::from([vid(1)]);
494        let edges: HashSet<EdgeKey> = HashSet::from([edge_key(1, 2)]);
495        for semantic in [
496            None,
497            Some(PathSemantic::Walk),
498            Some(PathSemantic::Shortest),
499            Some(PathSemantic::AllShortest),
500        ] {
501            assert!(
502                !is_path_rejected(semantic.clone(), &parents, &edges, &vid(1), &edge_key(1, 2)),
503                "unexpected per-path rejection for {semantic:?}"
504            );
505        }
506    }
507}