Skip to main content

velesdb_core/collection/graph/
streaming.rs

1//! Streaming BFS iterator for memory-bounded graph traversal (EPIC-019 US-005).
2//!
3//! This module provides lazy iterators that yield traversal results one at a time,
4//! avoiding the need to load all visited nodes into memory at once.
5
6use super::edge_concurrent::ConcurrentEdgeStore;
7use super::traversal::{deadline_reached, reconstruct_path, BfsState, DEADLINE_CHECK_INTERVAL};
8use super::{EdgeStore, TraversalResult, DEFAULT_MAX_DEPTH};
9use rustc_hash::{FxHashMap, FxHashSet};
10use std::collections::VecDeque;
11use std::time::Instant;
12
13/// Default upper bound on the visited-set / parent-map size for a single
14/// traversal (issue #906).
15///
16/// ~800 KB for an `FxHashSet<u64>` of this size. The streaming iterators
17/// switch to approximate mode at this bound; the eager BFS/DFS helpers stop
18/// expanding and return the bounded result they have accumulated so far.
19pub const MAX_VISITED_SIZE: usize = 100_000;
20
21/// Configuration for streaming traversal.
22///
23/// Unlike `TraversalConfig`, this is optimized for memory-bounded streaming
24/// where results are yielded lazily via an iterator.
25#[derive(Debug, Clone)]
26pub struct StreamingConfig {
27    /// Maximum depth for traversal.
28    pub max_depth: u32,
29    /// Maximum number of results to yield (None = unlimited).
30    pub limit: Option<usize>,
31    /// Maximum size of visited set before switching to approximate mode.
32    /// When exceeded, the iterator stops tracking visited nodes exactly,
33    /// which may cause some nodes to be visited multiple times in cyclic graphs.
34    pub max_visited_size: usize,
35    /// Filter by relationship types (empty = all types).
36    pub rel_types: Vec<String>,
37    /// Optional wall-clock deadline. When set and reached, the iterator stops
38    /// expanding and terminates (returns `None`), yielding the partial result
39    /// accumulated so far. `None` (the default) disables the time bound.
40    pub deadline: Option<Instant>,
41}
42
43impl Default for StreamingConfig {
44    fn default() -> Self {
45        Self {
46            max_depth: DEFAULT_MAX_DEPTH,
47            limit: None,
48            max_visited_size: MAX_VISITED_SIZE, // ~800KB for FxHashSet<u64>
49            rel_types: Vec::new(),
50            deadline: None,
51        }
52    }
53}
54
55impl StreamingConfig {
56    /// Creates a config with a result limit.
57    #[must_use]
58    pub fn with_limit(mut self, limit: usize) -> Self {
59        self.limit = Some(limit);
60        self
61    }
62
63    /// Sets the maximum depth.
64    #[must_use]
65    pub fn with_max_depth(mut self, max_depth: u32) -> Self {
66        self.max_depth = max_depth;
67        self
68    }
69
70    /// Sets the maximum visited set size.
71    #[must_use]
72    pub fn with_max_visited(mut self, max_visited: usize) -> Self {
73        self.max_visited_size = max_visited;
74        self
75    }
76
77    /// Filters by relationship types.
78    #[must_use]
79    pub fn with_rel_types(mut self, types: Vec<String>) -> Self {
80        self.rel_types = types;
81        self
82    }
83
84    /// Sets a wall-clock deadline after which the iterator terminates,
85    /// yielding only the results accumulated so far.
86    #[must_use]
87    pub fn with_deadline(mut self, deadline: Instant) -> Self {
88        self.deadline = Some(deadline);
89        self
90    }
91}
92
93/// Shared BFS bookkeeping for the streaming iterators.
94///
95/// Owns the traversal frontier, the visited set (with overflow handling), the
96/// rel-type filter, the parent-pointer map, and the pending-result buffer. Both
97/// [`BfsIterator`] and [`ConcurrentBfsIterator`] delegate per-edge processing
98/// and result pumping here, so the filter/visit/record logic lives in exactly
99/// one place regardless of the underlying edge store.
100struct BfsBookkeeping {
101    config: StreamingConfig,
102    queue: VecDeque<BfsState>,
103    visited: FxHashSet<u64>,
104    rel_types_set: FxHashSet<String>,
105    visited_overflow: bool,
106    pending_results: VecDeque<TraversalResult>,
107    parent_map: FxHashMap<u64, (u64, u64)>,
108    source_id: u64,
109    yielded: usize,
110    /// Pops since the last wall-clock deadline check (see `drive`).
111    nodes_since_check: u32,
112}
113
114impl BfsBookkeeping {
115    /// Seeds the frontier and visited set with `start_id`.
116    fn new(start_id: u64, config: StreamingConfig) -> Self {
117        let rel_types_set: FxHashSet<String> = config.rel_types.iter().cloned().collect();
118        let mut visited = FxHashSet::default();
119        visited.insert(start_id);
120        let mut queue = VecDeque::new();
121        queue.push_back(BfsState {
122            node_id: start_id,
123            depth: 0,
124        });
125        Self {
126            config,
127            queue,
128            visited,
129            rel_types_set,
130            visited_overflow: false,
131            pending_results: VecDeque::new(),
132            parent_map: FxHashMap::default(),
133            source_id: start_id,
134            yielded: 0,
135            // Start at the threshold so an already-expired deadline aborts on
136            // the first pop; otherwise the clock is read every N pops.
137            nodes_since_check: DEADLINE_CHECK_INTERVAL,
138        }
139    }
140
141    /// Checks whether `label` passes the rel-type filter (empty filter = all).
142    #[inline]
143    fn label_passes_filter(&self, label: &str) -> bool {
144        self.rel_types_set.is_empty() || self.rel_types_set.contains(label)
145    }
146
147    /// Records a visited target, handling overflow when the visited set exceeds
148    /// `max_visited_size`. Returns `true` if the target should be processed.
149    #[inline]
150    fn try_visit(&mut self, target: u64) -> bool {
151        if self.visited_overflow {
152            return true;
153        }
154        if self.visited.contains(&target) {
155            return false;
156        }
157        if self.visited.len() >= self.config.max_visited_size {
158            self.visited_overflow = true;
159            self.visited.clear();
160            return true;
161        }
162        self.visited.insert(target);
163        true
164    }
165
166    /// Processes one candidate edge: applies the rel-type (when `label` is
167    /// `Some`), depth, and visited filters, then on acceptance records the
168    /// parent pointer, enqueues the target, and buffers a pending result.
169    fn process_candidate(
170        &mut self,
171        parent_id: u64,
172        target: u64,
173        edge_id: u64,
174        parent_depth: u32,
175        label: Option<&str>,
176    ) {
177        if let Some(label) = label {
178            if !self.label_passes_filter(label) {
179                return;
180            }
181        }
182        let new_depth = parent_depth + 1;
183        if new_depth > self.config.max_depth {
184            return;
185        }
186        if !self.try_visit(target) {
187            return;
188        }
189        self.parent_map.insert(target, (parent_id, edge_id));
190        if new_depth < self.config.max_depth {
191            self.queue.push_back(BfsState {
192                node_id: target,
193                depth: new_depth,
194            });
195        }
196        let path = reconstruct_path(target, self.source_id, &self.parent_map);
197        self.pending_results
198            .push_back(TraversalResult::new(target, path, new_depth));
199    }
200
201    /// Pops the next buffered result, incrementing the yielded counter.
202    #[inline]
203    fn next_pending(&mut self) -> Option<TraversalResult> {
204        let result = self.pending_results.pop_front()?;
205        self.yielded += 1;
206        Some(result)
207    }
208
209    /// Pumps the BFS: yields any buffered result, otherwise expands queued
210    /// nodes (via `expand`) until one yields. `expand` supplies the edge-store
211    /// access, keeping this driver independent of the store type.
212    fn drive(&mut self, mut expand: impl FnMut(&mut Self, &BfsState)) -> Option<TraversalResult> {
213        if self.config.limit.is_some_and(|limit| self.yielded >= limit) {
214            return None;
215        }
216        if let Some(result) = self.next_pending() {
217            return Some(result);
218        }
219        let deadline = self.config.deadline;
220        while let Some(state) = self.queue.pop_front() {
221            if deadline_reached(deadline, &mut self.nodes_since_check) {
222                // Clear the frontier so the iterator is permanently terminated.
223                self.queue.clear();
224                return None;
225            }
226            expand(self, &state);
227            if let Some(result) = self.next_pending() {
228                return Some(result);
229            }
230        }
231        None
232    }
233}
234
235/// Expands a node over the CSR zero-copy path (contiguous `&[u64]` neighbours).
236fn expand_csr(edge_store: &EdgeStore, core: &mut BfsBookkeeping, state: &BfsState) {
237    let Some(snapshot) = edge_store.csr_snapshot() else {
238        return;
239    };
240    let targets = snapshot.neighbors(state.node_id);
241    let edge_ids = snapshot.edge_ids(state.node_id);
242    for (i, (&target, &eid)) in targets.iter().zip(edge_ids.iter()).enumerate() {
243        let label = snapshot.label_at(state.node_id, i);
244        core.process_candidate(state.node_id, target, eid, state.depth, label);
245    }
246}
247
248/// Expands a node over the legacy `EdgeStore` path (owned `GraphEdge` values).
249fn expand_legacy(edge_store: &EdgeStore, core: &mut BfsBookkeeping, state: &BfsState) {
250    for edge in edge_store.get_outgoing(state.node_id) {
251        core.process_candidate(
252            state.node_id,
253            edge.target(),
254            edge.id(),
255            state.depth,
256            Some(edge.label()),
257        );
258    }
259}
260
261/// Expands a node over a [`ConcurrentEdgeStore`] (per-shard locked reads).
262fn expand_concurrent(
263    edge_store: &ConcurrentEdgeStore,
264    core: &mut BfsBookkeeping,
265    state: &BfsState,
266) {
267    for edge in &edge_store.get_outgoing(state.node_id) {
268        core.process_candidate(
269            state.node_id,
270            edge.target(),
271            edge.id(),
272            state.depth,
273            Some(edge.label()),
274        );
275    }
276}
277
278/// Streaming BFS iterator that yields results lazily.
279///
280/// This iterator provides memory-bounded traversal by:
281/// 1. Yielding results one at a time instead of collecting all
282/// 2. Limiting the visited set size to prevent OOM
283/// 3. Early termination when limit is reached
284///
285/// # Memory Characteristics
286///
287/// - Queue: O(width × depth) - typically small for sparse graphs
288/// - Visited: O(min(nodes_traversed, max_visited_size))
289/// - Total: Bounded by `max_visited_size` configuration
290///
291/// # Example
292///
293/// ```rust,ignore
294/// use velesdb_core::collection::graph::{EdgeStore, BfsIterator, StreamingConfig};
295///
296/// let store = EdgeStore::new();
297/// // ... add edges ...
298///
299/// // Stream up to 1000 results with max 10 depth
300/// let config = StreamingConfig::default()
301///     .with_limit(1000)
302///     .with_max_depth(10);
303///
304/// for result in BfsIterator::new(&store, start_id, config) {
305///     println!("Reached node {} at depth {}", result.target_id, result.depth);
306/// }
307/// ```
308pub struct BfsIterator<'a> {
309    edge_store: &'a EdgeStore,
310    core: BfsBookkeeping,
311}
312
313impl<'a> BfsIterator<'a> {
314    /// Creates a new BFS iterator starting from the given node.
315    #[must_use]
316    pub fn new(edge_store: &'a EdgeStore, start_id: u64, config: StreamingConfig) -> Self {
317        Self {
318            edge_store,
319            core: BfsBookkeeping::new(start_id, config),
320        }
321    }
322
323    /// Returns the number of results yielded so far.
324    #[must_use]
325    pub fn yielded_count(&self) -> usize {
326        self.core.yielded
327    }
328
329    /// Returns true if the visited set has overflowed its limit.
330    ///
331    /// When overflowed, cycle detection is disabled and some nodes
332    /// may be visited multiple times.
333    #[must_use]
334    pub fn is_visited_overflow(&self) -> bool {
335        self.core.visited_overflow
336    }
337
338    /// Returns the current size of the visited set.
339    #[must_use]
340    pub fn visited_size(&self) -> usize {
341        self.core.visited.len()
342    }
343}
344
345impl Iterator for BfsIterator<'_> {
346    type Item = TraversalResult;
347
348    fn next(&mut self) -> Option<Self::Item> {
349        let edge_store = self.edge_store;
350        // Dispatch: CSR zero-copy path when a snapshot exists, legacy otherwise.
351        self.core.drive(|core, state| {
352            if edge_store.has_csr_snapshot() {
353                expand_csr(edge_store, core, state);
354            } else {
355                expand_legacy(edge_store, core, state);
356            }
357        })
358    }
359}
360
361/// Convenience function to create a streaming BFS iterator.
362#[must_use]
363pub fn bfs_stream(
364    edge_store: &EdgeStore,
365    start_id: u64,
366    config: StreamingConfig,
367) -> BfsIterator<'_> {
368    BfsIterator::new(edge_store, start_id, config)
369}
370
371// ---------------------------------------------------------------------------
372// ConcurrentBfsIterator — BFS over ConcurrentEdgeStore (sharded)
373// ---------------------------------------------------------------------------
374
375/// Streaming BFS iterator that works with [`ConcurrentEdgeStore`].
376///
377/// Unlike [`BfsIterator`] (which borrows `&EdgeStore` and returns edge
378/// references), this iterator acquires per-shard read locks on each
379/// `get_outgoing()` call and works with owned `GraphEdge` values.
380/// No shard lock is held across iterations, maximising concurrency.
381/// Uses parent-pointer map for zero-clone path reconstruction.
382pub struct ConcurrentBfsIterator<'a> {
383    edge_store: &'a ConcurrentEdgeStore,
384    core: BfsBookkeeping,
385}
386
387impl<'a> ConcurrentBfsIterator<'a> {
388    /// Creates a new concurrent BFS iterator starting from the given node.
389    #[must_use]
390    pub fn new(
391        edge_store: &'a ConcurrentEdgeStore,
392        start_id: u64,
393        config: StreamingConfig,
394    ) -> Self {
395        Self {
396            edge_store,
397            core: BfsBookkeeping::new(start_id, config),
398        }
399    }
400}
401
402impl Iterator for ConcurrentBfsIterator<'_> {
403    type Item = TraversalResult;
404
405    fn next(&mut self) -> Option<Self::Item> {
406        let edge_store = self.edge_store;
407        self.core
408            .drive(|core, state| expand_concurrent(edge_store, core, state))
409    }
410}
411
412/// Convenience function to create a streaming BFS iterator over a
413/// [`ConcurrentEdgeStore`].
414#[must_use]
415pub fn concurrent_bfs_stream(
416    edge_store: &ConcurrentEdgeStore,
417    start_id: u64,
418    config: StreamingConfig,
419) -> ConcurrentBfsIterator<'_> {
420    ConcurrentBfsIterator::new(edge_store, start_id, config)
421}
422
423// Tests moved to streaming_tests.rs per project rules