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}