velesdb_core/collection/graph/edge_concurrent/mod.rs
1//! Concurrent edge store with sharded locking.
2//!
3//! This module provides `ConcurrentEdgeStore`, a thread-safe wrapper around
4//! `EdgeStore` that uses sharding to reduce lock contention.
5//!
6//! Read-only queries and traversal are in `query.rs`.
7
8// Reason: Numeric casts in edge store sharding are intentional:
9// - u64->usize for node ID hashing: Node IDs are generated sequentially and fit in usize
10// - Used for sharding only, actual storage uses u64 for persistence
11#![allow(clippy::cast_possible_truncation)]
12
13mod cascade;
14mod persistence;
15mod query;
16mod snapshot;
17
18use super::clustered_index::ClusteredIndex;
19use super::csr_snapshot::{CsrSnapshot, SnapshotBuilder};
20use super::edge::{EdgeStore, GraphEdge};
21use super::label_table::LabelTable;
22use super::metrics::GraphMetrics;
23use crate::error::{Error, Result};
24use arc_swap::ArcSwap;
25use parking_lot::RwLock;
26use rustc_hash::FxHashMap;
27use std::sync::atomic::{AtomicBool, AtomicU64};
28use std::time::Instant;
29
30/// Number of pending edge mutations that may accumulate before the lazy CSR
31/// rebuild is forced (issue #905 debounce).
32///
33/// Below this threshold a dirty CSR snapshot is **not** rebuilt on read;
34/// callers fall back to the always-correct per-shard traversal path instead
35/// (see `traverse_bfs_config`). This amortises the O(N+E) clone-and-rebuild
36/// across many writes rather than paying it on every single read that follows
37/// a write. A pure write-count threshold (no wall-clock) keeps the behaviour
38/// deterministic for single-threaded tests.
39pub(crate) const CSR_REBUILD_WRITE_THRESHOLD: u64 = 64;
40
41/// Default number of shards for concurrent edge store.
42/// Increased from 64 to 256 for better scalability with 10M+ edges (EPIC-019 US-001).
43const DEFAULT_NUM_SHARDS: usize = 256;
44
45/// Minimum edges per shard for efficiency.
46/// Below this threshold, having more shards adds overhead without benefit.
47const MIN_EDGES_PER_SHARD: usize = 1000;
48
49/// Maximum recommended shards to limit memory overhead from RwLock + EdgeStore structures.
50const MAX_SHARDS: usize = 512;
51
52/// A thread-safe edge store using sharded locking.
53///
54/// Distributes edges across multiple shards based on source node ID
55/// to reduce lock contention in multi-threaded scenarios.
56///
57/// # Cross-Shard Edge Storage Pattern
58///
59/// Edges that span different shards (source and target in different shards) are stored
60/// in BOTH shards:
61/// - **Source shard**: Full edge with outgoing + label indices (`add_edge`)
62/// - **Target shard**: Edge copy with incoming index only (`add_edge_incoming_only`)
63///
64/// # Lock Ordering
65///
66/// When acquiring multiple shard locks, always acquire in ascending
67/// shard index order to prevent deadlocks.
68#[repr(C, align(64))]
69pub struct ConcurrentEdgeStore {
70 pub(super) shards: Vec<RwLock<EdgeStore>>,
71 pub(super) num_shards: usize,
72 /// Global registry of edge IDs with source node for optimized removal.
73 /// Maps edge_id -> source_node_id for O(1) shard lookup during removal.
74 /// F-19: FxHashMap ~2x faster than std HashMap for u64 keys (no SipHash).
75 pub(super) edge_ids: RwLock<FxHashMap<u64, u64>>,
76 /// CSR-like read snapshot for zero-copy neighbor lookups during BFS/DFS.
77 ///
78 /// Built on demand via [`build_read_snapshot()`](Self::build_read_snapshot).
79 /// Invalidated to `None` on every write (`add_edge`, `remove_edge`,
80 /// `remove_node_edges`). Read methods fall back to shard lookup when
81 /// the snapshot is absent.
82 clustered_snapshot: RwLock<Option<ClusteredIndex>>,
83 /// Lock-free CSR snapshot for zero-copy reads via `ArcSwap`.
84 ///
85 /// Rebuilt lazily on the next read after a mutation sets `csr_dirty`.
86 /// Readers load the current `Arc<CsrSnapshot>` without contention.
87 csr_snapshot: ArcSwap<CsrSnapshot>,
88 /// Dirty flag for lazy CSR snapshot rebuild.
89 ///
90 /// Set to `true` by every mutation (`add_edge`, `remove_edge`,
91 /// `remove_node_edges`). The next read via `get_csr_snapshot()` or
92 /// `traverse_bfs_csr()` rebuilds the snapshot and clears the flag.
93 /// This eliminates O(N+E) rebuilds on every mutation, deferring the
94 /// cost to the next read.
95 csr_dirty: AtomicBool,
96 /// Number of edge mutations accumulated since the last CSR rebuild.
97 ///
98 /// Used to debounce the lazy rebuild (issue #905): the next reader only
99 /// pays for a full O(N+E) rebuild once this reaches
100 /// [`CSR_REBUILD_WRITE_THRESHOLD`]. While dirty-but-below-threshold the
101 /// CSR snapshot is intentionally stale and callers must consult the
102 /// authoritative per-shard data (see [`Self::csr_is_authoritative`]).
103 pending_writes: AtomicU64,
104 /// Shared label table for interning edge labels during snapshot builds.
105 label_table: RwLock<LabelTable>,
106 /// Lock-free operational metrics (edge inserts/deletes, traversals).
107 ///
108 /// Atomic counters and histograms only; observed on the Ok tail of each
109 /// mutation after all shard locks have been released.
110 metrics: GraphMetrics,
111}
112
113impl ConcurrentEdgeStore {
114 /// Creates a new concurrent edge store with the default number of shards.
115 ///
116 /// Uses `DEFAULT_NUM_SHARDS` (compile-time constant > 0), so this
117 /// constructor cannot fail in practice.
118 #[must_use]
119 pub fn new() -> Self {
120 match Self::with_shards(DEFAULT_NUM_SHARDS) {
121 Ok(store) => store,
122 Err(_) => unreachable!("DEFAULT_NUM_SHARDS must be greater than zero"),
123 }
124 }
125
126 /// Creates a new concurrent edge store with a specific number of shards.
127 ///
128 /// # Errors
129 ///
130 /// Returns `Error::Config` if `num_shards` is 0 (would cause
131 /// division-by-zero in shard_index).
132 pub fn with_shards(num_shards: usize) -> crate::error::Result<Self> {
133 if num_shards == 0 {
134 return Err(crate::error::Error::Config(
135 "num_shards must be at least 1".to_string(),
136 ));
137 }
138 let shards = (0..num_shards)
139 .map(|_| RwLock::new(EdgeStore::new()))
140 .collect();
141 Ok(Self {
142 shards,
143 num_shards,
144 edge_ids: RwLock::new(FxHashMap::default()),
145 clustered_snapshot: RwLock::new(None),
146 csr_snapshot: ArcSwap::from_pointee(SnapshotBuilder::empty()),
147 csr_dirty: AtomicBool::new(false),
148 pending_writes: AtomicU64::new(0),
149 label_table: RwLock::new(LabelTable::new()),
150 metrics: GraphMetrics::new(),
151 })
152 }
153
154 /// Returns the operational metrics for this edge store.
155 ///
156 /// Counters/histograms cover edge inserts, deletes, and traversals.
157 #[must_use]
158 pub fn metrics(&self) -> &GraphMetrics {
159 &self.metrics
160 }
161
162 /// Creates a concurrent edge store with optimal shard count for estimated edge count.
163 ///
164 /// **FLAG-6: Uses integer bit manipulation for ceiling log2.**
165 #[must_use]
166 pub fn with_estimated_edges(estimated_edges: usize) -> Self {
167 let optimal_shards = if estimated_edges < MIN_EDGES_PER_SHARD {
168 1
169 } else {
170 let target_shards = estimated_edges / MIN_EDGES_PER_SHARD;
171 let power_of_2 = if target_shards <= 1 {
172 0
173 } else {
174 usize::BITS - (target_shards - 1).leading_zeros()
175 };
176 (1usize << power_of_2).clamp(1, MAX_SHARDS)
177 };
178 match Self::with_shards(optimal_shards) {
179 Ok(store) => store,
180 Err(_) => unreachable!("optimal_shards must be greater than zero"),
181 }
182 }
183
184 /// Returns the shard index for a given node ID.
185 #[inline]
186 pub(super) fn shard_index(&self, node_id: u64) -> usize {
187 (node_id as usize) % self.num_shards
188 }
189
190 /// Adds an edge to the store (thread-safe).
191 ///
192 /// Edges are stored in BOTH source and target shards:
193 /// - Source shard: for outgoing index lookups
194 /// - Target shard: for incoming index lookups
195 ///
196 /// When source and target are in different shards, locks are acquired
197 /// in ascending shard index order to prevent deadlocks.
198 ///
199 /// # Errors
200 ///
201 /// Returns `Error::EdgeExists` if an edge with the same ID already exists.
202 pub fn add_edge(&self, edge: GraphEdge) -> Result<()> {
203 let edge_id = edge.id();
204 let start = Instant::now();
205
206 {
207 // CRITICAL: Hold edge_ids lock throughout the entire operation to prevent race
208 // condition where remove_edge could free an ID while we're still inserting.
209 // Lock ordering: edge_ids FIRST, then shards in ascending order.
210 let mut ids = self.edge_ids.write();
211 if ids.contains_key(&edge_id) {
212 return Err(Error::EdgeExists(edge_id));
213 }
214
215 let source_id = edge.source();
216 let source_shard = self.shard_index(source_id);
217 let target_shard = self.shard_index(edge.target());
218
219 if source_shard == target_shard {
220 // Same shard: single lock, EdgeStore handles both indices
221 let mut guard = self.shards[source_shard].write();
222 guard.add_edge(edge)?;
223 ids.insert(edge_id, source_id);
224 } else {
225 // Different shards: acquire locks in ascending order to prevent deadlock
226 let (first_idx, second_idx) = if source_shard < target_shard {
227 (source_shard, target_shard)
228 } else {
229 (target_shard, source_shard)
230 };
231
232 let mut first_guard = self.shards[first_idx].write();
233 let mut second_guard = self.shards[second_idx].write();
234
235 if source_shard < target_shard {
236 first_guard.add_edge_outgoing_only(edge.clone())?;
237 if let Err(e) = second_guard.add_edge_incoming_only(edge) {
238 first_guard.remove_edge_outgoing_only(edge_id);
239 return Err(e);
240 }
241 } else {
242 second_guard.add_edge_outgoing_only(edge.clone())?;
243 if let Err(e) = first_guard.add_edge_incoming_only(edge) {
244 second_guard.remove_edge_outgoing_only(edge_id);
245 return Err(e);
246 }
247 }
248 ids.insert(edge_id, source_id);
249 }
250 } // All locks dropped here.
251 self.invalidate_snapshot();
252 self.rebuild_snapshot_best_effort();
253 // Record after all shard locks drop so the atomic ops never overlap a held lock.
254 self.metrics.record_edge_insert(start.elapsed());
255 Ok(())
256 }
257
258 /// Adds multiple edges in batch with a single lock acquisition cycle.
259 ///
260 /// Acquires the `edge_ids` write lock once for the entire batch,
261 /// inserts all edges into their respective shards, then invalidates
262 /// the CSR snapshot once at the end. This is **10-50x faster** than
263 /// calling `add_edge` in a loop for large batches.
264 ///
265 /// Edges that already exist (duplicate IDs) are silently skipped.
266 ///
267 /// # Returns
268 ///
269 /// Number of edges successfully added.
270 pub fn add_edges_batch(&self, edges: Vec<GraphEdge>) -> usize {
271 if edges.is_empty() {
272 return 0;
273 }
274
275 let start = Instant::now();
276 let mut count = 0usize;
277 {
278 let mut ids = self.edge_ids.write();
279
280 for edge in edges {
281 let edge_id = edge.id();
282 if ids.contains_key(&edge_id) {
283 continue;
284 }
285
286 let source_id = edge.source();
287 let ok = self.insert_edge_into_shards(edge);
288
289 if ok {
290 ids.insert(edge_id, source_id);
291 count += 1;
292 }
293 }
294 }
295
296 if count > 0 {
297 self.invalidate_snapshot();
298 // Count every inserted edge toward the CSR rebuild debounce
299 // (issue #905 follow-up). Reporting a flat `1` per batch would
300 // keep a bulk-loaded graph permanently below the rebuild
301 // threshold, so the CSR fast path would never engage.
302 self.record_pending_writes(count as u64);
303 // Counters bumped by count, batch latency observed once (no
304 // per-edge Instant::now()).
305 self.metrics
306 .record_edge_inserts_batch(count as u64, start.elapsed());
307 }
308 count
309 }
310
311 /// Inserts a single edge into the correct shard(s), handling cross-shard locking.
312 ///
313 /// Returns `true` if the edge was successfully inserted.
314 fn insert_edge_into_shards(&self, edge: GraphEdge) -> bool {
315 let source_shard = self.shard_index(edge.source());
316 let target_shard = self.shard_index(edge.target());
317
318 if source_shard == target_shard {
319 return self.shards[source_shard].write().add_edge(edge).is_ok();
320 }
321
322 // Cross-shard: acquire locks in ascending order to prevent deadlock.
323 let (first_idx, second_idx) = if source_shard < target_shard {
324 (source_shard, target_shard)
325 } else {
326 (target_shard, source_shard)
327 };
328 let mut first = self.shards[first_idx].write();
329 let mut second = self.shards[second_idx].write();
330
331 let (outgoing_guard, incoming_guard) = if source_shard < target_shard {
332 (&mut first, &mut second)
333 } else {
334 (&mut second, &mut first)
335 };
336
337 let edge_id = edge.id();
338 if outgoing_guard.add_edge_outgoing_only(edge.clone()).is_ok() {
339 if incoming_guard.add_edge_incoming_only(edge).is_err() {
340 outgoing_guard.remove_edge_outgoing_only(edge_id);
341 return false;
342 }
343 true
344 } else {
345 false
346 }
347 }
348
349 /// Removes an edge by ID using optimized 2-shard lookup.
350 ///
351 /// # Concurrency Safety
352 ///
353 /// Lock ordering: edge_ids FIRST, then shards in ascending order.
354 pub fn remove_edge(&self, edge_id: u64) -> bool {
355 let start = Instant::now();
356 {
357 let mut ids = self.edge_ids.write();
358
359 let Some(&source_id) = ids.get(&edge_id) else {
360 return false;
361 };
362
363 let source_shard_idx = self.shard_index(source_id);
364 let target_id = {
365 let guard = self.shards[source_shard_idx].read();
366 if let Some(edge) = guard.get_edge(edge_id) {
367 edge.target()
368 } else {
369 ids.remove(&edge_id);
370 return false;
371 }
372 };
373
374 let target_shard_idx = self.shard_index(target_id);
375
376 if source_shard_idx == target_shard_idx {
377 self.shards[source_shard_idx].write().remove_edge(edge_id);
378 } else {
379 let (first_idx, second_idx) = if source_shard_idx < target_shard_idx {
380 (source_shard_idx, target_shard_idx)
381 } else {
382 (target_shard_idx, source_shard_idx)
383 };
384 let mut first = self.shards[first_idx].write();
385 let mut second = self.shards[second_idx].write();
386
387 if source_shard_idx < target_shard_idx {
388 first.remove_edge(edge_id);
389 second.remove_edge_incoming_only(edge_id);
390 } else {
391 first.remove_edge_incoming_only(edge_id);
392 second.remove_edge(edge_id);
393 }
394 }
395
396 ids.remove(&edge_id);
397 } // All locks dropped here.
398 self.invalidate_snapshot();
399 self.rebuild_snapshot_best_effort();
400 // Record after all shard locks drop (true path only).
401 self.metrics.record_edge_delete(start.elapsed());
402 true
403 }
404
405 /// Removes all edges connected to a node (cascade delete, thread-safe).
406 ///
407 /// # Concurrency Safety
408 ///
409 /// Lock ordering: edge_ids FIRST, then shards in ascending order.
410 pub fn remove_node_edges(&self, node_id: u64) {
411 {
412 let mut ids = self.edge_ids.write();
413 let node_shard = self.shard_index(node_id);
414
415 let (outgoing_edges, incoming_edges) = self.collect_node_edges(node_shard, node_id);
416
417 let shards_to_clean =
418 self.gather_affected_shards(node_shard, &outgoing_edges, &incoming_edges);
419
420 let mut guards: Vec<_> = shards_to_clean
421 .iter()
422 .map(|&idx| (idx, self.shards[idx].write()))
423 .collect();
424
425 self.cleanup_shard_edges(
426 &mut guards,
427 node_shard,
428 node_id,
429 &outgoing_edges,
430 &incoming_edges,
431 );
432
433 self.deregister_edge_ids(&mut ids, &outgoing_edges, &incoming_edges);
434 }
435 self.invalidate_snapshot();
436 self.rebuild_snapshot_best_effort();
437 }
438}
439
440// Node-cascade helpers are in cascade.rs
441// Persistence (from_edge_store, save_to_file, load_from_file) is in persistence.rs
442// CSR snapshot management (invalidate, rebuild, build) is in snapshot.rs
443
444impl Default for ConcurrentEdgeStore {
445 fn default() -> Self {
446 Self::new()
447 }
448}
449
450// Compile-time check: ConcurrentEdgeStore must be Send + Sync
451const _: () = {
452 const fn assert_send_sync<T: Send + Sync>() {}
453 assert_send_sync::<ConcurrentEdgeStore>();
454};