Skip to main content

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};