Skip to main content

velesdb_core/collection/graph/edge_concurrent/
query.rs

1//! Read-only query and traversal methods for `ConcurrentEdgeStore`.
2//!
3//! Extracted from the main module for single-responsibility:
4//! - Edge lookups (by node, by label, by ID)
5//! - BFS traversal
6//! - Edge count
7
8use super::super::csr_snapshot::{CsrSnapshot, EdgePredicate};
9use super::super::traversal::{TraversalConfig, TraversalResult};
10use super::super::traversal_csr::{bfs_traverse_csr, bfs_traverse_csr_filtered};
11use super::{ConcurrentEdgeStore, GraphEdge};
12use arc_swap::Guard;
13use rustc_hash::FxHashSet;
14use std::collections::VecDeque;
15use std::sync::Arc;
16
17impl ConcurrentEdgeStore {
18    /// Gets all outgoing edges from a node (thread-safe).
19    #[must_use]
20    pub fn get_outgoing(&self, node_id: u64) -> Vec<GraphEdge> {
21        let shard = &self.shards[self.shard_index(node_id)];
22        let guard = shard.read();
23        guard.get_outgoing(node_id).into_iter().cloned().collect()
24    }
25
26    /// Gets all incoming edges to a node (thread-safe).
27    #[must_use]
28    pub fn get_incoming(&self, node_id: u64) -> Vec<GraphEdge> {
29        let shard = &self.shards[self.shard_index(node_id)];
30        let guard = shard.read();
31        guard.get_incoming(node_id).into_iter().cloned().collect()
32    }
33
34    /// Gets neighbors (target nodes) of a given node.
35    ///
36    /// When a CSR read snapshot is available (see
37    /// [`build_read_snapshot()`](Self::build_read_snapshot)), this returns
38    /// a copy from contiguous memory without resolving individual edges.
39    /// Falls back to per-shard edge lookup otherwise.
40    #[must_use]
41    pub fn get_neighbors(&self, node_id: u64) -> Vec<u64> {
42        let snapshot = self.clustered_snapshot.read();
43        if let Some(idx) = snapshot.as_ref() {
44            return idx.get_neighbors(node_id).to_vec();
45        }
46        drop(snapshot);
47        self.get_outgoing(node_id)
48            .iter()
49            .map(GraphEdge::target)
50            .collect()
51    }
52
53    /// Invokes `f` with a borrowed slice of outgoing neighbor IDs.
54    ///
55    /// When the CSR snapshot is available, `f` receives a zero-copy `&[u64]`
56    /// from contiguous memory. Otherwise, a temporary `Vec<u64>` is built
57    /// from per-shard edge lookup.
58    ///
59    /// Prefer this over [`get_neighbors`](Self::get_neighbors) in tight
60    /// loops (BFS frontiers) where the caller processes IDs inline.
61    #[inline]
62    pub fn with_neighbors<F, R>(&self, node_id: u64, f: F) -> R
63    where
64        F: FnOnce(&[u64]) -> R,
65    {
66        let snapshot = self.clustered_snapshot.read();
67        if let Some(idx) = snapshot.as_ref() {
68            return f(idx.get_neighbors(node_id));
69        }
70        drop(snapshot);
71        let fallback: Vec<u64> = self
72            .get_outgoing(node_id)
73            .iter()
74            .map(GraphEdge::target)
75            .collect();
76        f(&fallback)
77    }
78
79    /// Gets outgoing edges filtered by label (thread-safe).
80    ///
81    /// # Performance Note
82    ///
83    /// This method delegates to the underlying `EdgeStore::get_outgoing_by_label`
84    /// which uses the composite index `(source_id, label) -> edge_ids` for O(1) lookup
85    /// when available (EPIC-019 US-003). Falls back to filtering if index not populated.
86    #[must_use]
87    pub fn get_outgoing_by_label(&self, node_id: u64, label: &str) -> Vec<GraphEdge> {
88        let shard_idx = self.shard_index(node_id);
89        let shard = self.shards[shard_idx].read();
90        shard
91            .get_outgoing_by_label(node_id, label)
92            .into_iter()
93            .cloned()
94            .collect()
95    }
96
97    /// Gets incoming edges filtered by label (thread-safe).
98    #[must_use]
99    pub fn get_incoming_by_label(&self, node_id: u64, label: &str) -> Vec<GraphEdge> {
100        self.get_incoming(node_id)
101            .into_iter()
102            .filter(|e| e.label() == label)
103            .collect()
104    }
105
106    /// Gets all edges with a specific label across all shards.
107    ///
108    /// # Performance Warning
109    ///
110    /// This method iterates through ALL shards and aggregates results.
111    /// For large graphs with many shards, this can be expensive.
112    /// Consider using `get_outgoing_by_label(node_id, label)` if you know
113    /// the source node, which is O(k) instead of O(shards × edges_per_label).
114    #[must_use]
115    pub fn get_edges_by_label(&self, label: &str) -> Vec<GraphEdge> {
116        self.shards
117            .iter()
118            .flat_map(|shard| {
119                shard
120                    .read()
121                    .get_edges_by_label(label)
122                    .into_iter()
123                    .cloned()
124                    .collect::<Vec<_>>()
125            })
126            .collect()
127    }
128
129    /// Checks if an edge with the given ID exists.
130    #[must_use]
131    /// Returns the highest edge id in the store, if any.
132    ///
133    /// O(edges) over the id registry — no edge cloning.
134    pub fn max_edge_id(&self) -> Option<u64> {
135        self.edge_ids.read().keys().max().copied()
136    }
137
138    /// Returns `true` when an edge with `edge_id` exists.
139    pub fn contains_edge(&self, edge_id: u64) -> bool {
140        self.edge_ids.read().contains_key(&edge_id)
141    }
142
143    /// Gets an edge by ID using optimized source shard lookup.
144    ///
145    /// Returns `None` if the edge doesn't exist.
146    #[must_use]
147    pub fn get_edge(&self, edge_id: u64) -> Option<GraphEdge> {
148        // Get source_id from registry for direct shard lookup
149        let source_id = *self.edge_ids.read().get(&edge_id)?;
150        let shard_idx = self.shard_index(source_id);
151        self.shards[shard_idx].read().get_edge(edge_id).cloned()
152    }
153
154    /// Traverses the graph using BFS from a starting node.
155    ///
156    /// Returns all nodes reachable within `max_depth` hops.
157    ///
158    /// When a CSR read snapshot is available, neighbor lookups are zero-copy
159    /// slices from contiguous memory. Otherwise uses Read-Copy-Drop pattern
160    /// with per-shard locks.
161    #[must_use]
162    pub fn traverse_bfs(&self, start: u64, max_depth: u32) -> Vec<u64> {
163        let mut visited = FxHashSet::default();
164        let mut queue = VecDeque::new();
165        queue.push_back((start, 0u32));
166
167        while let Some((node, depth)) = queue.pop_front() {
168            if depth > max_depth || !visited.insert(node) {
169                continue;
170            }
171
172            self.with_neighbors(node, |neighbors| {
173                for &neighbor in neighbors {
174                    if !visited.contains(&neighbor) {
175                        queue.push_back((neighbor, depth + 1));
176                    }
177                }
178            });
179        }
180
181        visited.into_iter().collect()
182    }
183
184    /// Returns the total edge count across all shards.
185    ///
186    /// Uses outgoing edge count to avoid double-counting edges that span shards.
187    #[must_use]
188    pub fn edge_count(&self) -> usize {
189        self.shards
190            .iter()
191            .map(|s| s.read().outgoing_edge_count())
192            .sum()
193    }
194
195    /// Returns `len()` — alias for `edge_count()` for API parity with `EdgeStore`.
196    #[must_use]
197    pub fn len(&self) -> usize {
198        self.edge_count()
199    }
200
201    /// Returns `true` if the store contains no edges.
202    #[must_use]
203    pub fn is_empty(&self) -> bool {
204        self.edge_ids.read().is_empty()
205    }
206
207    /// Returns the number of distinct edge labels in the graph.
208    ///
209    /// Reads from the CSR snapshot's interned label table, triggering a
210    /// lazy rebuild if dirty. Returns 0 when the store has no edges.
211    #[must_use]
212    pub fn label_count(&self) -> usize {
213        self.ensure_csr_fresh();
214        let snapshot = self.csr_snapshot.load();
215        snapshot.distinct_label_count()
216    }
217
218    /// Returns all edges across all shards (cloned).
219    ///
220    /// Uses the `edge_ids` registry to look up each edge exactly once in its
221    /// source shard, avoiding double-counting for cross-shard edges.
222    ///
223    /// # Performance Warning
224    ///
225    /// Iterates all edges and clones each one. For large graphs, prefer
226    /// targeted queries (`get_outgoing`, `get_edges_by_label`).
227    #[must_use]
228    pub fn all_edges(&self) -> Vec<GraphEdge> {
229        let ids = self.edge_ids.read();
230        let mut result = Vec::with_capacity(ids.len());
231        for (&edge_id, &source_id) in ids.iter() {
232            let shard_idx = self.shard_index(source_id);
233            let guard = self.shards[shard_idx].read();
234            if let Some(edge) = guard.get_edge(edge_id) {
235                result.push(edge.clone());
236            }
237        }
238        result
239    }
240
241    /// Returns the out-degree of a node without materializing edge vectors.
242    ///
243    /// Uses CSR snapshot when available for O(1) lookup without shard locking.
244    #[must_use]
245    #[inline]
246    pub fn outgoing_degree(&self, node_id: u64) -> usize {
247        let snapshot = self.clustered_snapshot.read();
248        if let Some(idx) = snapshot.as_ref() {
249            return idx.neighbor_count(node_id);
250        }
251        drop(snapshot);
252        let shard_idx = self.shard_index(node_id);
253        self.shards[shard_idx].read().outgoing_degree(node_id)
254    }
255
256    /// Returns the in-degree of a node without materializing edge vectors.
257    #[must_use]
258    #[inline]
259    pub fn incoming_degree(&self, node_id: u64) -> usize {
260        let shard_idx = self.shard_index(node_id);
261        self.shards[shard_idx].read().incoming_degree(node_id)
262    }
263
264    /// Rebuilds the CSR snapshot if the dirty flag is set.
265    ///
266    /// Uses `swap(false, AcqRel)` to atomically clear the flag and check
267    /// the previous value. Only one thread performs the rebuild; concurrent
268    /// readers see the stale-but-valid snapshot until the swap completes.
269    ///
270    /// This is the **correctness-first** entry point used by CSR consumers
271    /// that have no per-shard fallback (`traverse_bfs_csr`,
272    /// `traverse_bfs_filtered`, `label_count`, `get_csr_snapshot`). It always
273    /// rebuilds when dirty so the returned snapshot reflects every prior
274    /// write. The write-count debounce (issue #905) is applied one level up in
275    /// `traverse_bfs_config`, which prefers the per-shard path while the CSR
276    /// is stale and only reaches this method once a rebuild is actually wanted.
277    #[inline]
278    fn ensure_csr_fresh(&self) {
279        if self
280            .csr_dirty
281            .swap(false, std::sync::atomic::Ordering::AcqRel)
282        {
283            // Snapshot the write count *before* reading the shards. The
284            // rebuild below reflects exactly these writes; any writer that
285            // bumps the counter while we walk the shards must stay counted so
286            // the next reader still rebuilds (issue #905 follow-up — a blind
287            // `store(0)` after the rebuild would silently drop that increment).
288            let observed = self
289                .pending_writes
290                .load(std::sync::atomic::Ordering::Acquire);
291            if let Err(e) = self.rebuild_snapshot() {
292                // Restore dirty flag so the next caller retries the rebuild.
293                self.csr_dirty
294                    .store(true, std::sync::atomic::Ordering::Release);
295                tracing::warn!("lazy CSR snapshot rebuild failed: {e}");
296                return;
297            }
298            // Rebuild succeeded: subtract only the writes we accounted for, so
299            // a concurrent `fetch_add` between the load above and here is
300            // preserved instead of being clobbered to zero. Saturating at zero
301            // so two concurrent reader-triggered rebuilds cannot underflow the
302            // counter — a plain `fetch_sub` could wrap to ~`u64::MAX` and wedge
303            // `csr_rebuild_due` permanently true (forcing a rebuild on every read).
304            let _ = self.pending_writes.fetch_update(
305                std::sync::atomic::Ordering::AcqRel,
306                std::sync::atomic::Ordering::Acquire,
307                |cur| Some(cur.saturating_sub(observed)),
308            );
309        }
310    }
311
312    /// Returns `true` when the CSR snapshot reflects every committed write,
313    /// i.e. it is safe to traverse without first rebuilding.
314    ///
315    /// A clean snapshot (no pending writes) is always authoritative. When the
316    /// snapshot is dirty this returns `false` **without** triggering a
317    /// rebuild, so callers with an authoritative per-shard fallback (issue
318    /// #905 debounce) can avoid the O(N+E) rebuild on every read after a
319    /// write.
320    #[inline]
321    #[must_use]
322    pub(crate) fn csr_is_authoritative(&self) -> bool {
323        !self.csr_dirty.load(std::sync::atomic::Ordering::Acquire)
324    }
325
326    /// Returns `true` when enough writes have accumulated that the next CSR
327    /// read should pay for a rebuild rather than continue serving from the
328    /// per-shard fallback (issue #905 debounce threshold reached).
329    #[inline]
330    #[must_use]
331    pub(crate) fn csr_rebuild_due(&self) -> bool {
332        self.pending_writes
333            .load(std::sync::atomic::Ordering::Acquire)
334            >= super::CSR_REBUILD_WRITE_THRESHOLD
335    }
336
337    /// Returns the current CSR snapshot (lock-free read).
338    ///
339    /// The returned `Guard` dereferences to `Arc<CsrSnapshot>` and keeps
340    /// the snapshot alive for the duration of the borrow. No locks are
341    /// acquired — this is a single atomic load.
342    ///
343    /// If the snapshot is dirty (mutation occurred since last rebuild),
344    /// triggers a lazy rebuild before returning.
345    #[must_use]
346    pub fn get_csr_snapshot(&self) -> Guard<Arc<CsrSnapshot>> {
347        self.ensure_csr_fresh();
348        self.csr_snapshot.load()
349    }
350
351    /// BFS traversal on the CSR snapshot (lock-free, zero-copy).
352    ///
353    /// Loads the current snapshot atomically and delegates to
354    /// [`bfs_traverse_csr`] for the actual traversal.
355    /// Triggers a lazy CSR rebuild if dirty.
356    #[must_use]
357    pub fn traverse_bfs_csr(&self, source: u64, config: &TraversalConfig) -> Vec<TraversalResult> {
358        self.ensure_csr_fresh();
359        let snapshot = self.csr_snapshot.load();
360        bfs_traverse_csr(&snapshot, source, config)
361    }
362
363    /// BFS traversal with predicate pushdown on the CSR snapshot.
364    ///
365    /// Loads the current snapshot atomically and delegates to
366    /// [`bfs_traverse_csr_filtered`] which applies the predicate at the
367    /// CSR level, avoiding materialisation of non-matching edges.
368    /// Triggers a lazy CSR rebuild if dirty.
369    #[must_use]
370    pub fn traverse_bfs_filtered<P: EdgePredicate>(
371        &self,
372        source: u64,
373        config: &TraversalConfig,
374        predicate: &P,
375    ) -> Vec<TraversalResult> {
376        self.ensure_csr_fresh();
377        let snapshot = self.csr_snapshot.load();
378        bfs_traverse_csr_filtered(&snapshot, source, config, predicate)
379    }
380}