uni_store/storage/adjacency_manager.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! Unified adjacency manager orchestrating Main CSR, L0-csr overlay, and Shadow CSR.
5//!
6//! Implements a dual-CSR architecture where:
7//! - **Main CSR**: packed adjacency for all alive edges (one per edge_type + direction)
8//! - **L0-csr overlay**: concurrent insert/delete buffer that survives data flush
9//! - **Shadow CSR**: tracks deleted edges with version ranges for time-travel queries
10//!
11//! Regular queries read Main CSR + overlay with zero version filtering.
12//! Snapshot queries additionally filter by version and resurrect shadow entries.
13//!
14//! # Read-path cost model
15//!
16//! Every read consults three layers in order:
17//! 1. **Main CSR** — single `DashMap` lookup keyed by `(edge_type, direction)` →
18//! O(out-degree) entry scan.
19//! 2. **Frozen segments** — a `Vec<Arc<FrozenCsrSegment>>`. Each frozen segment is a
20//! plain `HashMap` (lock-free read). The read iterates this vec; each segment is
21//! short-circuited via [`FrozenCsrSegment::has_entries_for`] when it contributed
22//! no edges of the queried `(edge_type, direction)`, AND its tombstones map is
23//! empty. Both checks are O(1).
24//! 3. **Active overlay** — single `RwLock<L0CsrSegment>` taken once per read; same
25//! short-circuits applied as for frozen segments.
26//!
27//! Frozen segments are pure RAM. The on-disk Lance L1 delta is a write-through redo
28//! log, not consulted on the read hot path. Frozen segments are merged back into the
29//! Main CSR by [`AdjacencyManager::compact`], spawned from the writer's flush path
30//! once the segment count exceeds a small threshold.
31//!
32//! When extending the read path, preserve the invariant that any segment carrying a
33//! tombstone — even for an unrelated edge type — must run the `retain` pass against
34//! `result`, because tombstones can shadow edges materialized from layers below.
35
36use crate::storage::adjacency_overlay::{FrozenCsrSegment, L0CsrSegment};
37use crate::storage::csr::MainCsr;
38use crate::storage::direction::Direction;
39use crate::storage::manager::StorageManager;
40use crate::storage::shadow_csr::{ShadowCsr, ShadowEdge};
41use dashmap::DashMap;
42use parking_lot::RwLock;
43use std::collections::{HashMap, HashSet};
44use std::sync::Arc;
45use std::sync::atomic::{AtomicUsize, Ordering};
46use uni_common::core::id::{Eid, Vid};
47
48/// Versions currently pinned by in-flight snapshot readers.
49///
50/// Exists so shadow-CSR entries can be reclaimed without dropping any a live
51/// reader still resolves. The bound is subtle and worth stating once:
52///
53/// * `StorageManager::pinned()` and `at_fork` each build a **fresh**
54/// [`AdjacencyManager`] with its own empty `ShadowCsr`, so those readers
55/// never consult the live instance.
56/// * `StorageManager::pinned_at_version` **shares** the live manager, and is
57/// the path every read-write transaction takes.
58///
59/// So the only readers of the live shadow are in-flight `pinned_at_version`
60/// views, and the safe floor is the minimum version among them.
61/// `SnapshotManager` cannot supply this — it is a manifest reader-writer and
62/// tracks no live readers.
63///
64/// Refcounted rather than a set: several transactions routinely pin the same
65/// version, and the floor must not rise until the last of them is gone.
66#[derive(Debug, Default)]
67pub struct PinnedVersions {
68 counts: parking_lot::Mutex<std::collections::BTreeMap<u64, usize>>,
69}
70
71impl PinnedVersions {
72 /// Register `version` as pinned until the returned guard drops.
73 pub fn pin(self: &Arc<Self>, version: u64) -> PinGuard {
74 *self.counts.lock().entry(version).or_insert(0) += 1;
75 PinGuard {
76 versions: Arc::clone(self),
77 version,
78 }
79 }
80
81 /// The lowest version any in-flight reader is pinned at.
82 pub fn min_pinned(&self) -> Option<u64> {
83 self.counts.lock().keys().next().copied()
84 }
85
86 /// Number of distinct pinned versions; for tests and diagnostics.
87 pub fn distinct_pinned(&self) -> usize {
88 self.counts.lock().len()
89 }
90}
91
92/// Releases its version from [`PinnedVersions`] on drop.
93#[derive(Debug)]
94pub struct PinGuard {
95 versions: Arc<PinnedVersions>,
96 version: u64,
97}
98
99impl Drop for PinGuard {
100 fn drop(&mut self) {
101 let mut counts = self.versions.counts.lock();
102 if let Some(n) = counts.get_mut(&self.version) {
103 *n -= 1;
104 if *n == 0 {
105 counts.remove(&self.version);
106 }
107 }
108 }
109}
110
111/// Deduplicate `(offset, neighbor, eid, version)` entries by `Eid`, keeping the
112/// entry with the highest version for each. Multiple versions of the same edge
113/// can coexist in L2+L1, across L1 runs, and across frozen overlay segments.
114fn dedup_entries_by_eid(entries: &mut Vec<(u64, Vid, Eid, u64)>) {
115 use std::collections::hash_map::Entry;
116
117 let mut best: HashMap<Eid, usize> = HashMap::new();
118 for (idx, (_, _, eid, ver)) in entries.iter().enumerate() {
119 match best.entry(*eid) {
120 Entry::Vacant(e) => {
121 e.insert(idx);
122 }
123 Entry::Occupied(mut e) => {
124 if *ver > entries[*e.get()].3 {
125 e.insert(idx);
126 }
127 }
128 }
129 }
130 let keep: HashSet<usize> = best.into_values().collect();
131 let mut idx = 0;
132 entries.retain(|_| {
133 let k = keep.contains(&idx);
134 idx += 1;
135 k
136 });
137}
138
139/// Unified adjacency manager for the dual-CSR architecture.
140///
141/// Orchestrates Main CSR (packed alive edges), L0-csr overlay
142/// (in-memory mutations), and Shadow CSR (deleted edges for time-travel).
143/// Data flush never invalidates or rebuilds the CSR.
144pub struct AdjacencyManager {
145 /// Main CSR per `(edge_type, direction)` — all alive edges.
146 /// Edge type is u32 with bit 31 = 0 for schema'd, 1 for schemaless.
147 main_csr: DashMap<(u32, Direction), Arc<MainCsr>>,
148
149 /// Freshness stamp for each cached Main CSR: the summed Lance versions of
150 /// the L2 adjacency and L1 delta tables it was built from.
151 ///
152 /// The CSR is complete-by-construction only for the handle that owns the
153 /// writer, whose own commits land in `active_overlay` and so never need a
154 /// re-read. A second, independent handle — a read-only reader alongside a
155 /// writer — receives no `insert_edge` calls, so a presence-only cache gate
156 /// pinned it to whatever the edge set was on first traversal, forever.
157 /// Node reads never had this problem because every scan reopens the Lance
158 /// dataset and picks up the newest manifest; the CSR is the one place that
159 /// materialises a *derived* structure and so has to re-implement the
160 /// invalidation it would otherwise inherit. Issue #168.
161 csr_source_stamp: DashMap<(u32, Direction), u64>,
162
163 /// Active L0-csr segment (current writes go here).
164 active_overlay: Arc<RwLock<L0CsrSegment>>,
165
166 /// Frozen segments awaiting compaction (oldest first).
167 frozen_segments: RwLock<Vec<Arc<FrozenCsrSegment>>>,
168
169 /// Shadow CSR for time-travel deleted edge tracking.
170 shadow: ShadowCsr,
171 /// Versions pinned by in-flight `pinned_at_version` readers; the floor for
172 /// shadow GC. See [`PinnedVersions`].
173 pinned_versions: Arc<PinnedVersions>,
174
175 /// Current approximate memory usage in bytes.
176 current_bytes: AtomicUsize,
177
178 /// Maximum memory budget in bytes.
179 max_bytes: usize,
180
181 /// Coalescing locks for warm() operations — prevents cache stampede.
182 /// Key: (edge_type_id, Direction), Value: Mutex guard for that warm operation.
183 warm_guards: DashMap<(u32, Direction), Arc<tokio::sync::Mutex<()>>>,
184
185 /// Serializes `compact()` so two compactions can't interleave their
186 /// freeze→snapshot→clear sequences and lose a frozen segment. (review H12)
187 compact_lock: parking_lot::Mutex<()>,
188}
189
190impl AdjacencyManager {
191 /// Creates a new adjacency manager with the given memory budget.
192 pub fn new(max_bytes: usize) -> Self {
193 Self {
194 main_csr: DashMap::new(),
195 csr_source_stamp: DashMap::new(),
196 active_overlay: Arc::new(RwLock::new(L0CsrSegment::new())),
197 frozen_segments: RwLock::new(Vec::new()),
198 shadow: ShadowCsr::new(),
199 pinned_versions: Arc::new(PinnedVersions::default()),
200 current_bytes: AtomicUsize::new(0),
201 max_bytes,
202 warm_guards: DashMap::new(),
203 compact_lock: parking_lot::Mutex::new(()),
204 }
205 }
206
207 /// Returns neighbors for the current state (hot path, no version filtering).
208 ///
209 /// Reads Main CSR + frozen segments + active overlay, minus tombstones.
210 /// Tombstones from any layer remove edges from all lower layers.
211 pub fn get_neighbors(&self, vid: Vid, edge_type: u32, direction: Direction) -> Vec<(Vid, Eid)> {
212 let mut result: HashMap<Eid, Vid> = HashMap::new();
213
214 for &dir in direction.expand() {
215 // 1. Main CSR
216 if let Some(csr) = self.main_csr.get(&(edge_type, dir)) {
217 for entry in csr.get_entries(vid) {
218 result.insert(entry.eid, entry.neighbor_vid);
219 }
220 }
221
222 // 2. Frozen segments (oldest first) — add inserts, then remove tombstones.
223 // Skip whole segment when it has no inserts for this (edge_type, dir)
224 // AND no tombstones at all — both is_empty checks are O(1) on plain HashMaps,
225 // so this is a strict speedup that scales away with frozen-segment count
226 // when most segments don't touch the queried edge type. See issue #55.
227 for segment in self.frozen_segments.read().iter() {
228 let has_inserts = segment.has_entries_for(edge_type, dir);
229 let has_tombstones = !segment.tombstones.is_empty();
230 if !has_inserts && !has_tombstones {
231 continue;
232 }
233 if has_inserts
234 && let Some(adj) = segment.inserts.get(&(edge_type, dir))
235 && let Some(neighbors) = adj.get(&vid)
236 {
237 for &(neighbor, eid, _version) in neighbors {
238 result.insert(eid, neighbor);
239 }
240 }
241 // Apply tombstones against ALL prior results (Main CSR + older segments).
242 // Skip the retain pass entirely when there are no tombstones — it would
243 // be a no-op but still O(result_size) due to the closure call.
244 if has_tombstones {
245 result.retain(|eid, _| !segment.tombstones.contains_key(eid));
246 }
247 }
248
249 // 3. Active overlay — add inserts, then remove tombstones.
250 // Same short-circuits as the frozen branch, plus we hold the read lock
251 // for the whole branch so DashMap atomic-op cost is paid once, not twice.
252 let active = self.active_overlay.read();
253 let active_has_inserts = active.has_entries_for(edge_type, dir);
254 let active_has_tombstones = !active.tombstones.is_empty();
255 if active_has_inserts
256 && let Some(adj) = active.inserts.get(&(edge_type, dir))
257 && let Some(neighbors) = adj.get(&vid)
258 {
259 for &(neighbor, eid, _version) in neighbors {
260 result.insert(eid, neighbor);
261 }
262 }
263 if active_has_tombstones {
264 result.retain(|eid, _| !active.tombstones.contains_key(eid));
265 }
266 }
267
268 result.into_iter().map(|(e, n)| (n, e)).collect()
269 }
270
271 /// Returns neighbors visible at a specific snapshot version.
272 ///
273 /// Filters Main CSR entries by `created_version`, applies frozen/active
274 /// overlay with version filtering, and resurrects Shadow CSR entries
275 /// that were alive at the given version.
276 pub fn get_neighbors_at_version(
277 &self,
278 vid: Vid,
279 edge_type: u32,
280 direction: Direction,
281 version: u64,
282 ) -> Vec<(Vid, Eid)> {
283 let mut result: HashMap<Eid, Vid> = HashMap::new();
284
285 for &dir in direction.expand() {
286 // 1. Main CSR — filter by created_version
287 if let Some(csr) = self.main_csr.get(&(edge_type, dir)) {
288 for entry in csr.get_entries(vid) {
289 if entry.created_version <= version {
290 result.insert(entry.eid, entry.neighbor_vid);
291 }
292 }
293 }
294
295 // 2. Frozen segments — filter inserts by version, apply tombstones.
296 // Same skip-irrelevant-segment short-circuit as get_neighbors. See issue #55.
297 for segment in self.frozen_segments.read().iter() {
298 let has_inserts = segment.has_entries_for(edge_type, dir);
299 let has_tombstones = !segment.tombstones.is_empty();
300 if !has_inserts && !has_tombstones {
301 continue;
302 }
303 if has_inserts
304 && let Some(adj) = segment.inserts.get(&(edge_type, dir))
305 && let Some(neighbors) = adj.get(&vid)
306 {
307 for &(neighbor, eid, ver) in neighbors {
308 if ver <= version {
309 result.insert(eid, neighbor);
310 }
311 }
312 }
313 if has_tombstones {
314 result.retain(|eid, _| {
315 segment
316 .tombstones
317 .get(eid)
318 .is_none_or(|ts| ts.version > version)
319 });
320 }
321 }
322
323 // 3. Active overlay — add version-filtered inserts, then apply tombstones
324 let active = self.active_overlay.read();
325 let active_has_inserts = active.has_entries_for(edge_type, dir);
326 let active_has_tombstones = !active.tombstones.is_empty();
327 if active_has_inserts
328 && let Some(adj) = active.inserts.get(&(edge_type, dir))
329 && let Some(neighbors) = adj.get(&vid)
330 {
331 for &(neighbor, eid, ver) in neighbors {
332 let not_tombstoned = active
333 .tombstones
334 .get(&eid)
335 .is_none_or(|ts| ts.version > version);
336 if ver <= version && not_tombstoned {
337 result.insert(eid, neighbor);
338 }
339 }
340 }
341 if active_has_tombstones {
342 result.retain(|eid, _| {
343 active
344 .tombstones
345 .get(eid)
346 .is_none_or(|ts| ts.version > version)
347 });
348 }
349
350 // 4. Shadow CSR — resurrect edges alive at version
351 for (neighbor, eid) in self
352 .shadow
353 .get_entries_at_version(vid, edge_type, dir, version)
354 {
355 result.insert(eid, neighbor);
356 }
357 }
358
359 result.into_iter().map(|(e, n)| (n, e)).collect()
360 }
361
362 /// Records an edge insertion into the L0-csr overlay (both directions).
363 pub fn insert_edge(&self, src: Vid, dst: Vid, eid: Eid, edge_type: u32, version: u64) {
364 let active = self.active_overlay.read();
365 active.insert_edge(src, dst, eid, edge_type, version, Direction::Outgoing);
366 active.insert_edge(dst, src, eid, edge_type, version, Direction::Incoming);
367 }
368
369 /// Records a tombstone for a deleted edge in the L0-csr overlay.
370 pub fn add_tombstone(&self, eid: Eid, src: Vid, dst: Vid, edge_type: u32, version: u64) {
371 let active = self.active_overlay.read();
372 active.add_tombstone(eid, src, dst, edge_type, version);
373 }
374
375 /// Sets the Main CSR for a specific edge type and direction.
376 ///
377 /// Used by `warm()` to install a freshly built CSR from storage.
378 pub fn set_main_csr(&self, edge_type: u32, direction: Direction, csr: MainCsr) {
379 let size = csr.memory_usage();
380 self.main_csr.insert((edge_type, direction), Arc::new(csr));
381 self.current_bytes.fetch_add(size, Ordering::Relaxed);
382 }
383
384 /// Checks whether a Main CSR exists for the given edge type and direction.
385 pub fn has_csr(&self, edge_type: u32, direction: Direction) -> bool {
386 self.main_csr.contains_key(&(edge_type, direction))
387 }
388
389 /// Checks whether the cached Main CSR is still current.
390 ///
391 /// `true` when there is no CSR (nothing to be stale) or when its stamp
392 /// still matches the source tables. See `csr_source_stamp` for why a
393 /// presence-only check was not enough (issue #168).
394 pub async fn csr_is_fresh(
395 &self,
396 storage: &StorageManager,
397 edge_type: u32,
398 direction: Direction,
399 ) -> bool {
400 let key = (edge_type, direction);
401 if !self.main_csr.contains_key(&key) {
402 return true;
403 }
404 let Some(stamped) = self.csr_source_stamp.get(&key).map(|v| *v) else {
405 // Warmed before stamping existed, or the stamp could not be taken.
406 // Treat as stale rather than trusting it: a needless re-warm costs
407 // a scan, a missed one silently drops edges from every later read.
408 return false;
409 };
410 match Self::source_version(storage, edge_type, direction).await {
411 Some(current) => current == stamped,
412 // The source version is unavailable (a transient manifest read
413 // failure). Fail towards a re-warm for the same reason as above.
414 None => false,
415 }
416 }
417
418 /// Drops the cached Main CSR so the next warm re-reads from storage.
419 pub fn invalidate_csr(&self, edge_type: u32, direction: Direction) {
420 let key = (edge_type, direction);
421 self.main_csr.remove(&key);
422 self.csr_source_stamp.remove(&key);
423 self.warm_guards.remove(&key);
424 }
425
426 /// Sums the Lance versions of every table a warm for this
427 /// `(edge_type, direction)` reads: the L2 adjacency dataset per
428 /// participating label, plus the L1 delta dataset.
429 ///
430 /// Summed rather than maxed so that an advance in *any* contributing table
431 /// changes the stamp — a max would miss a bump in one table masked by a
432 /// higher version in another. Returns `None` if the edge type is unknown or
433 /// a version read fails, which callers treat as "assume stale".
434 async fn source_version(
435 storage: &StorageManager,
436 edge_type: u32,
437 direction: Direction,
438 ) -> Option<u64> {
439 let schema = storage.schema_manager().schema();
440 let edge_type_name = schema.edge_type_name_by_id_unified(edge_type)?;
441 let labels: Vec<String> = {
442 let meta = schema.edge_types.get(&edge_type_name);
443 match (direction, meta) {
444 (Direction::Outgoing, Some(m)) => m.src_labels.clone(),
445 (Direction::Incoming, Some(m)) => m.dst_labels.clone(),
446 (Direction::Both, Some(m)) => {
447 let mut l = m.src_labels.clone();
448 l.extend(m.dst_labels.iter().cloned());
449 l.sort();
450 l.dedup();
451 l
452 }
453 _ => Vec::new(),
454 }
455 };
456
457 let backend = storage.backend();
458 let mut total: u64 = 0;
459 for &read_dir in direction.expand() {
460 let dir_str = read_dir.as_str();
461 for label in &labels {
462 if let Ok(ds) = storage.adjacency_dataset(&edge_type_name, label, dir_str)
463 && let Ok(Some(v)) = backend.get_table_version(&ds.table_name()).await
464 {
465 total = total.wrapping_add(v);
466 }
467 }
468 if let Ok(ds) = storage.delta_dataset(&edge_type_name, dir_str)
469 && let Ok(Some(v)) = backend.get_table_version(&ds.table_name()).await
470 {
471 total = total.wrapping_add(v);
472 }
473 }
474 Some(total)
475 }
476
477 /// Checks whether this manager has been activated for the given edge type.
478 ///
479 /// Returns `true` if a Main CSR exists or the overlay has entries for
480 /// this edge type and direction.
481 pub fn is_active_for(&self, edge_type: u32, direction: Direction) -> bool {
482 let active = self.active_overlay.read();
483 direction.expand().iter().any(|&d| {
484 self.main_csr.contains_key(&(edge_type, d)) || active.has_entries_for(edge_type, d)
485 })
486 }
487
488 /// Returns the distinct edge type ids known to this manager.
489 ///
490 /// Spans the Main CSR plus the active and frozen overlay segments, so it
491 /// covers both warmed (L1/L2-loaded) edge types and live overlay-resident
492 /// types (e.g. recently committed edges that a flush moved out of L0 but kept
493 /// in the dual-write overlay). Used when an endpoint resolver knows an edge
494 /// id but not its type: this is the small set of types this query has touched,
495 /// so it bounds an eid-orientation probe to a short candidate list rather than
496 /// the whole schema.
497 ///
498 /// # Examples
499 ///
500 /// ```ignore
501 /// for etype in adjacency_manager.known_edge_type_ids() {
502 /// // probe etype for the edge of interest
503 /// }
504 /// ```
505 #[must_use]
506 pub fn known_edge_type_ids(&self) -> Vec<u32> {
507 let mut ids: Vec<u32> = self.main_csr.iter().map(|entry| entry.key().0).collect();
508 for entry in self.active_overlay.read().inserts.iter() {
509 ids.push(entry.key().0);
510 }
511 for segment in self.frozen_segments.read().iter() {
512 ids.extend(segment.inserts.keys().map(|&(etype, _dir)| etype));
513 }
514 ids.sort_unstable();
515 ids.dedup();
516 ids
517 }
518
519 /// Returns the number of frozen segments awaiting compaction.
520 pub fn frozen_segment_count(&self) -> usize {
521 self.frozen_segments.read().len()
522 }
523
524 /// Returns whether compaction should be triggered based on segment count.
525 pub fn should_compact(&self, threshold: usize) -> bool {
526 self.frozen_segment_count() >= threshold
527 }
528
529 /// Compacts frozen overlay segments into the Main CSR.
530 ///
531 /// Freezes the active overlay, merges all frozen segments with the
532 /// existing Main CSR, moves tombstoned edges to Shadow CSR, and
533 /// atomically swaps in the new Main CSR.
534 ///
535 /// CRITICAL: Frozen segments remain readable until the new CSR is installed,
536 /// eliminating the visibility gap where edges would be invisible.
537 pub fn compact(&self) {
538 // Serialize compaction: two concurrent compacts would each freeze, take
539 // their own snapshot, then clear — the second clear wiping segments the
540 // first had not yet merged. (review H12)
541 let _compact_guard = self.compact_lock.lock();
542
543 // Step 1: Freeze active overlay and push to frozen list
544 let frozen = {
545 let mut active = self.active_overlay.write();
546 let old = std::mem::take(&mut *active);
547 Arc::new(old.freeze())
548 };
549 self.frozen_segments.write().push(frozen);
550
551 // Step 2: CLONE frozen segments for building (DON'T drain yet)
552 // This ensures they remain readable during CSR construction
553 let segments = self.frozen_segments.read().clone();
554
555 // Step 3: Collect all (edge_type, direction) keys from segments + existing CSRs
556 let mut all_keys: HashSet<(u32, Direction)> = HashSet::new();
557 for segment in &segments {
558 for key in segment.inserts.keys() {
559 all_keys.insert(*key);
560 }
561 }
562 for entry in self.main_csr.iter() {
563 all_keys.insert(*entry.key());
564 }
565
566 // Step 4: For each key, merge
567 for (edge_type, direction) in all_keys {
568 let mut entries: Vec<(u64, Vid, Eid, u64)> = Vec::new();
569 let mut max_offset: u64 = 0;
570
571 // Collect all tombstone EIDs
572 let mut tombstoned_eids: HashSet<Eid> = HashSet::new();
573 for segment in &segments {
574 for (eid, ts) in &segment.tombstones {
575 if ts.edge_type == edge_type {
576 tombstoned_eids.insert(*eid);
577
578 // Move to shadow CSR. ShadowCsr is keyed by the queried
579 // vid for the direction, exactly like the CSRs themselves
580 // (Incoming is keyed by dst with neighbor src — see the
581 // insert at `insert_edge(dst, src, .., Incoming)` and the
582 // get_neighbors_at_version swap). Key by src for Outgoing,
583 // by dst for Incoming — otherwise a time-travel read after
584 // compaction looks up the wrong vid and the tombstone is
585 // invisible (deleted edge resurrected).
586 let (key_vid, neighbor_vid) = if direction == Direction::Incoming {
587 (ts.dst_vid, ts.src_vid)
588 } else {
589 (ts.src_vid, ts.dst_vid)
590 };
591 self.shadow.add_deleted_edge(
592 key_vid,
593 ShadowEdge {
594 neighbor_vid,
595 eid: *eid,
596 edge_type,
597 created_version: 0, // unknown; overlay tombstones don't track creation version
598 deleted_version: ts.version,
599 },
600 direction,
601 );
602 }
603 }
604 }
605
606 // Add entries from old Main CSR
607 if let Some(old_csr) = self.main_csr.get(&(edge_type, direction)) {
608 for vid_offset in 0..old_csr.num_vertices() {
609 let vid = Vid::new(vid_offset as u64);
610 for entry in old_csr.get_entries(vid) {
611 if !tombstoned_eids.contains(&entry.eid) {
612 entries.push((
613 vid_offset as u64,
614 entry.neighbor_vid,
615 entry.eid,
616 entry.created_version,
617 ));
618 max_offset = max_offset.max(vid_offset as u64);
619 }
620 }
621 }
622 }
623
624 // Overlay frozen segments (oldest first)
625 for segment in &segments {
626 if let Some(adj) = segment.inserts.get(&(edge_type, direction)) {
627 for (vid, neighbors) in adj {
628 for &(neighbor, eid, version) in neighbors {
629 if !tombstoned_eids.contains(&eid) {
630 let offset = vid.as_u64();
631 entries.push((offset, neighbor, eid, version));
632 max_offset = max_offset.max(offset);
633 }
634 }
635 }
636 }
637 }
638
639 dedup_entries_by_eid(&mut entries);
640
641 // Build new Main CSR and install
642 let new_csr = MainCsr::from_edge_entries(max_offset as usize, entries);
643 let size = new_csr.memory_usage();
644
645 // Remove old size, add new
646 if let Some(old) = self.main_csr.get(&(edge_type, direction)) {
647 self.current_bytes
648 .fetch_sub(old.memory_usage(), Ordering::Relaxed);
649 }
650
651 self.main_csr
652 .insert((edge_type, direction), Arc::new(new_csr));
653 self.current_bytes.fetch_add(size, Ordering::Relaxed);
654 }
655
656 // Step 5: drain EXACTLY the segments we snapshotted in Step 2 — not a
657 // blanket clear(). A concurrent `freeze()` may have pushed a new frozen
658 // segment after the snapshot; that segment was NOT merged into the new
659 // CSR, so clearing it would silently lose its topology. Retain anything
660 // not in the snapshot (compared by Arc identity). (review H12)
661 let snapshot_ptrs: HashSet<*const FrozenCsrSegment> =
662 segments.iter().map(Arc::as_ptr).collect();
663 self.frozen_segments
664 .write()
665 .retain(|s| !snapshot_ptrs.contains(&Arc::as_ptr(s)));
666 }
667
668 /// Warms the Main CSR from storage (L2 adjacency + L1 delta) for a specific edge type and direction.
669 ///
670 /// Reads L2 adjacency datasets and L1 delta entries from Lance,
671 /// builds a [`MainCsr`] with version metadata, and populates the
672 /// [`ShadowCsr`] with L1 tombstones. Called once at startup or
673 /// lazily on first access per edge type.
674 pub async fn warm(
675 &self,
676 storage: &StorageManager,
677 edge_type_id: u32,
678 direction: Direction,
679 version: Option<u64>,
680 ) -> anyhow::Result<()> {
681 // Stamp *before* reading. A write landing mid-warm then leaves the CSR
682 // looking stale and it is re-read on the next query; stamping after the
683 // read would record the post-write version against a pre-write CSR and
684 // the new edges would be missed for good. Issue #168.
685 let source_stamp = Self::source_version(storage, edge_type_id, direction).await;
686
687 let schema = storage.schema_manager().schema();
688
689 // Use unified lookup to support both schema'd and schemaless edge types
690 let edge_type_name = schema
691 .edge_type_name_by_id_unified(edge_type_id)
692 .ok_or_else(|| anyhow::anyhow!("Edge type {} not found", edge_type_id))?;
693
694 // Determine which labels to load adjacency for based on edge type metadata
695 let labels_to_load: Vec<String> = {
696 let edge_meta = schema.edge_types.get(&edge_type_name);
697 match (direction, edge_meta) {
698 (Direction::Outgoing, Some(meta)) => meta.src_labels.clone(),
699 (Direction::Incoming, Some(meta)) => meta.dst_labels.clone(),
700 (Direction::Both, Some(meta)) => {
701 let mut labels = meta.src_labels.clone();
702 labels.extend(meta.dst_labels.iter().cloned());
703 labels.sort();
704 labels.dedup();
705 labels
706 }
707 _ => Vec::new(),
708 }
709 };
710
711 use arrow_array::{ListArray, UInt8Array, UInt64Array};
712
713 let mut entries: Vec<(u64, Vid, Eid, u64)> = Vec::new();
714 let mut deleted_eids = HashSet::new();
715
716 for &read_dir in direction.expand() {
717 let dir_str = read_dir.as_str();
718 for label_name in &labels_to_load {
719 // 1. Read L2 (Adjacency Dataset)
720 let adj_ds = storage.adjacency_dataset(&edge_type_name, label_name, dir_str);
721 let backend = storage.backend();
722
723 if let Ok(adj_ds) = adj_ds {
724 let adj_table_name = adj_ds.table_name();
725 let adj_exists = backend.table_exists(&adj_table_name).await.unwrap_or(false);
726
727 if adj_exists {
728 let mut request = crate::backend::types::ScanRequest::all(&adj_table_name);
729 if let Some(hwm) = version {
730 request = request.with_filter(
731 crate::backend::types::FilterExpr::version_at_most(hwm),
732 );
733 }
734
735 // Fail closed: a transient scan error must abort the warm,
736 // not `unwrap_or_default()` into an empty L2 read that then
737 // gets cached as the adjacency CSR — that silently drops
738 // every base edge for this type until restart (review #3b).
739 let batches: Vec<arrow_array::RecordBatch> = backend.scan(request).await?;
740
741 for batch in batches {
742 let src_col = batch
743 .column_by_name("src_vid")
744 .unwrap()
745 .as_any()
746 .downcast_ref::<UInt64Array>()
747 .unwrap();
748 let neighbors_list = batch
749 .column_by_name("neighbors")
750 .unwrap()
751 .as_any()
752 .downcast_ref::<ListArray>()
753 .unwrap();
754 let eids_list = batch
755 .column_by_name("edge_ids")
756 .unwrap()
757 .as_any()
758 .downcast_ref::<ListArray>()
759 .unwrap();
760
761 for i in 0..batch.num_rows() {
762 let src_offset = src_col.value(i);
763 let neighbors_array_ref = neighbors_list.value(i);
764 let neighbors = neighbors_array_ref
765 .as_any()
766 .downcast_ref::<UInt64Array>()
767 .unwrap();
768 let eids_array_ref = eids_list.value(i);
769 let eids = eids_array_ref
770 .as_any()
771 .downcast_ref::<UInt64Array>()
772 .unwrap();
773
774 for j in 0..neighbors.len() {
775 // L2 adjacency rows don't carry per-edge _version.
776 // Version 0 means "from base storage" — the `_version <= hwm` filter on
777 // the query already ensures we only load rows within the snapshot window.
778 // At query time, get_neighbors_at_version() uses created_version to filter,
779 // so version=0 edges are always visible (which is correct for compacted L2 data).
780 entries.push((
781 src_offset,
782 Vid::from(neighbors.value(j)),
783 Eid::from(eids.value(j)),
784 0,
785 ));
786 }
787 }
788 }
789 }
790 }
791 }
792
793 // 2. Read L1 (Delta)
794 let delta_ds = storage.delta_dataset(&edge_type_name, dir_str)?;
795 let backend = storage.backend();
796 let delta_table_name = delta_ds.table_name();
797
798 if backend
799 .table_exists(&delta_table_name)
800 .await
801 .unwrap_or(false)
802 {
803 let mut request = crate::backend::types::ScanRequest::all(&delta_table_name);
804 if let Some(hwm) = version {
805 request = request
806 .with_filter(crate::backend::types::FilterExpr::version_at_most(hwm));
807 }
808
809 // Fail closed: propagate a delta scan error rather than silently
810 // skipping it, which would drop unflushed edges from the cached
811 // adjacency CSR until restart (review #3b).
812 let batches = backend.scan(request).await?;
813 {
814 for batch in batches {
815 let src_col = batch
816 .column_by_name("src_vid")
817 .unwrap()
818 .as_any()
819 .downcast_ref::<UInt64Array>()
820 .unwrap();
821 let dst_col = batch
822 .column_by_name("dst_vid")
823 .unwrap()
824 .as_any()
825 .downcast_ref::<UInt64Array>()
826 .unwrap();
827 let eid_col = batch
828 .column_by_name("eid")
829 .unwrap()
830 .as_any()
831 .downcast_ref::<UInt64Array>()
832 .unwrap();
833 let op_col = batch
834 .column_by_name("op")
835 .unwrap()
836 .as_any()
837 .downcast_ref::<UInt8Array>()
838 .unwrap();
839
840 // Optionally read _version column
841 let version_col = batch
842 .column_by_name("_version")
843 .and_then(|c| c.as_any().downcast_ref::<UInt64Array>().cloned());
844
845 for i in 0..batch.num_rows() {
846 let src_vid = Vid::from(src_col.value(i));
847 let dst_vid = Vid::from(dst_col.value(i));
848 let eid = Eid::from(eid_col.value(i));
849 let op = op_col.value(i); // 0=Insert, 1=Delete
850 let row_version = version_col.as_ref().map_or(0, |vc| vc.value(i));
851
852 // For incoming edges, the CSR key is dst (the vertex
853 // receiving the edge) and the neighbor is src.
854 let is_incoming = read_dir == Direction::Incoming;
855 let (key_vid, neighbor_vid) = if is_incoming {
856 (dst_vid, src_vid)
857 } else {
858 (src_vid, dst_vid)
859 };
860
861 if op == 0 {
862 entries.push((key_vid.as_u64(), neighbor_vid, eid, row_version));
863 } else {
864 deleted_eids.insert(eid);
865 self.shadow.add_deleted_edge(
866 key_vid,
867 ShadowEdge {
868 neighbor_vid,
869 eid,
870 edge_type: edge_type_id,
871 created_version: 0,
872 deleted_version: row_version,
873 },
874 read_dir,
875 );
876 }
877 }
878 }
879 }
880 }
881 }
882
883 // Filter out deleted edges
884 if !deleted_eids.is_empty() {
885 entries.retain(|(_, _, eid, _)| !deleted_eids.contains(eid));
886 }
887
888 dedup_entries_by_eid(&mut entries);
889
890 // Build MainCsr
891 let max_offset = entries.iter().map(|(o, _, _, _)| *o).max().unwrap_or(0);
892 let csr = MainCsr::from_edge_entries(max_offset as usize, entries);
893 self.set_main_csr(edge_type_id, direction, csr);
894 match source_stamp {
895 Some(v) => {
896 self.csr_source_stamp.insert((edge_type_id, direction), v);
897 }
898 // No stamp means no freshness claim: leave the entry absent so
899 // `csr_is_fresh` reports stale and the next query re-warms.
900 None => {
901 self.csr_source_stamp.remove(&(edge_type_id, direction));
902 }
903 }
904
905 Ok(())
906 }
907
908 /// Coalesced warm() operation to prevent cache stampede (Issue #13).
909 ///
910 /// Uses double-checked locking: fast-path checks if CSR already loaded,
911 /// then acquires per-(edge_type, direction) lock to ensure only one concurrent
912 /// warm() per adjacency key. Other readers wait for the first warm() to complete.
913 pub async fn warm_coalesced(
914 &self,
915 storage: &StorageManager,
916 edge_type_id: u32,
917 direction: Direction,
918 version: Option<u64>,
919 ) -> anyhow::Result<()> {
920 // Fast path: already loaded
921 if self.has_csr(edge_type_id, direction) {
922 return Ok(());
923 }
924
925 // Coalesce: only one concurrent warm per (type, dir)
926 let guard = self
927 .warm_guards
928 .entry((edge_type_id, direction))
929 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
930 .value()
931 .clone();
932 let _lock = guard.lock().await;
933
934 // Double-check after acquiring lock
935 if self.has_csr(edge_type_id, direction) {
936 return Ok(());
937 }
938
939 self.warm(storage, edge_type_id, direction, version).await
940 }
941
942 /// Returns the current approximate memory usage in bytes.
943 pub fn memory_usage(&self) -> usize {
944 // `current_bytes` accounts only for the main CSR — every mutation of it
945 // is on a `main_csr` path. Shadow retention was therefore invisible to
946 // the budget and could never trip `max_bytes`, which is how an
947 // unbounded leak there went unnoticed. Counted approximately rather
948 // than tracked incrementally: the shadow is small when healthy, and an
949 // exact counter would need hooks on every retain in `gc`.
950 self.current_bytes.load(Ordering::Relaxed) + self.shadow.approx_bytes()
951 }
952
953 /// The pinned-version registry backing shadow GC.
954 pub fn pinned_versions(&self) -> &Arc<PinnedVersions> {
955 &self.pinned_versions
956 }
957
958 /// Reclaim shadow entries no in-flight reader can reach.
959 ///
960 /// The floor is the minimum pinned version, or `current_version` when
961 /// nothing is pinned — a reader starting now pins at the current version,
962 /// so entries deleted at or below it are unreachable. `current_version` is
963 /// passed in because the manager does not track it; the writer does.
964 ///
965 /// Called after compaction. Safe to call at any time: it only ever removes
966 /// entries whose `deleted_version` is at or below the floor, which is
967 /// exactly the set `get_entries_at_version` can no longer return.
968 pub fn gc_shadow(&self, current_version: u64) {
969 let floor = self
970 .pinned_versions
971 .min_pinned()
972 .map_or(current_version, |pinned| pinned.min(current_version));
973 self.shadow.gc(floor);
974 }
975
976 /// Shadow-CSR entries currently retained.
977 ///
978 /// Exposed for retention tests and diagnostics; see
979 /// [`ShadowCsr::add_deleted_edge`] for why this can grow.
980 pub fn shadow_entry_count(&self) -> usize {
981 self.shadow.entry_count()
982 }
983
984 /// Returns the maximum memory budget in bytes.
985 pub fn max_bytes(&self) -> usize {
986 self.max_bytes
987 }
988
989 /// Provides access to the shadow CSR for time-travel queries.
990 pub fn shadow(&self) -> &ShadowCsr {
991 &self.shadow
992 }
993}
994
995impl std::fmt::Debug for AdjacencyManager {
996 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
997 f.debug_struct("AdjacencyManager")
998 .field("main_csr_count", &self.main_csr.len())
999 .field("frozen_segments", &self.frozen_segments.read().len())
1000 .field("current_bytes", &self.current_bytes.load(Ordering::Relaxed))
1001 .field("max_bytes", &self.max_bytes)
1002 .finish()
1003 }
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008 use super::*;
1009
1010 #[test]
1011 fn test_insert_and_get_neighbors() {
1012 let am = AdjacencyManager::new(1024 * 1024);
1013 let src = Vid::new(1);
1014 let dst = Vid::new(2);
1015 let eid = Eid::new(100);
1016
1017 am.insert_edge(src, dst, eid, 1, 1);
1018
1019 let neighbors = am.get_neighbors(src, 1, Direction::Outgoing);
1020 assert_eq!(neighbors.len(), 1);
1021 assert_eq!(neighbors[0], (dst, eid));
1022
1023 // Incoming direction
1024 let incoming = am.get_neighbors(dst, 1, Direction::Incoming);
1025 assert_eq!(incoming.len(), 1);
1026 assert_eq!(incoming[0], (src, eid));
1027 }
1028
1029 #[test]
1030 fn test_main_csr_lookup() {
1031 let am = AdjacencyManager::new(1024 * 1024);
1032
1033 let csr = MainCsr::from_edge_entries(
1034 1,
1035 vec![
1036 (0, Vid::new(10), Eid::new(100), 1),
1037 (1, Vid::new(20), Eid::new(101), 2),
1038 ],
1039 );
1040 am.set_main_csr(1, Direction::Outgoing, csr);
1041
1042 let n = am.get_neighbors(Vid::new(0), 1, Direction::Outgoing);
1043 assert_eq!(n.len(), 1);
1044 assert_eq!(n[0], (Vid::new(10), Eid::new(100)));
1045 }
1046
1047 #[test]
1048 fn test_overlay_on_top_of_main_csr() {
1049 let am = AdjacencyManager::new(1024 * 1024);
1050
1051 // Main CSR has one edge
1052 let csr = MainCsr::from_edge_entries(0, vec![(0, Vid::new(10), Eid::new(100), 1)]);
1053 am.set_main_csr(1, Direction::Outgoing, csr);
1054
1055 // Overlay adds another
1056 am.insert_edge(Vid::new(0), Vid::new(20), Eid::new(101), 1, 2);
1057
1058 let n = am.get_neighbors(Vid::new(0), 1, Direction::Outgoing);
1059 assert_eq!(n.len(), 2);
1060
1061 let eids: HashSet<Eid> = n.iter().map(|(_, e)| *e).collect();
1062 assert!(eids.contains(&Eid::new(100)));
1063 assert!(eids.contains(&Eid::new(101)));
1064 }
1065
1066 #[test]
1067 fn test_tombstone_removes_edge() {
1068 let am = AdjacencyManager::new(1024 * 1024);
1069
1070 am.insert_edge(Vid::new(0), Vid::new(10), Eid::new(100), 1, 1);
1071 am.add_tombstone(Eid::new(100), Vid::new(0), Vid::new(10), 1, 2);
1072
1073 let n = am.get_neighbors(Vid::new(0), 1, Direction::Outgoing);
1074 assert!(n.is_empty());
1075 }
1076
1077 #[test]
1078 fn test_version_filtered_query() {
1079 let am = AdjacencyManager::new(1024 * 1024);
1080
1081 // Main CSR with two edges at different versions
1082 let csr = MainCsr::from_edge_entries(
1083 0,
1084 vec![
1085 (0, Vid::new(10), Eid::new(100), 1),
1086 (0, Vid::new(20), Eid::new(101), 5),
1087 ],
1088 );
1089 am.set_main_csr(1, Direction::Outgoing, csr);
1090
1091 // At version 3: only first edge visible
1092 let n = am.get_neighbors_at_version(Vid::new(0), 1, Direction::Outgoing, 3);
1093 assert_eq!(n.len(), 1);
1094 assert_eq!(n[0], (Vid::new(10), Eid::new(100)));
1095
1096 // At version 5: both visible
1097 let n = am.get_neighbors_at_version(Vid::new(0), 1, Direction::Outgoing, 5);
1098 assert_eq!(n.len(), 2);
1099 }
1100
1101 #[test]
1102 fn test_shadow_csr_resurrects_deleted_edges() {
1103 let am = AdjacencyManager::new(1024 * 1024);
1104
1105 // Add a deleted edge to shadow: created at v1, deleted at v5
1106 am.shadow().add_deleted_edge(
1107 Vid::new(0),
1108 ShadowEdge {
1109 neighbor_vid: Vid::new(10),
1110 eid: Eid::new(100),
1111 edge_type: 1,
1112 created_version: 1,
1113 deleted_version: 5,
1114 },
1115 Direction::Outgoing,
1116 );
1117
1118 // At version 3: shadow edge should be visible
1119 let n = am.get_neighbors_at_version(Vid::new(0), 1, Direction::Outgoing, 3);
1120 assert_eq!(n.len(), 1);
1121 assert_eq!(n[0], (Vid::new(10), Eid::new(100)));
1122
1123 // At version 5: deleted, not visible
1124 let n = am.get_neighbors_at_version(Vid::new(0), 1, Direction::Outgoing, 5);
1125 assert!(n.is_empty());
1126 }
1127
1128 /// H12: concurrent compaction must never drop an edge. `compact()` is the
1129 /// only writer of `frozen_segments`, now serialized under `compact_lock`,
1130 /// and Step 5 drains exactly the snapshotted segments rather than clearing
1131 /// all — so a segment frozen by an interleaving compact survives. Asserts
1132 /// edge conservation under many concurrent compacts racing inserts.
1133 #[test]
1134 fn test_concurrent_compaction_conserves_edges() {
1135 let am = std::sync::Arc::new(AdjacencyManager::new(64 * 1024 * 1024));
1136 let n: u64 = 150;
1137
1138 let inserter = {
1139 let am = am.clone();
1140 std::thread::spawn(move || {
1141 for i in 1..=n {
1142 am.insert_edge(Vid::new(0), Vid::new(i), Eid::new(i), 1, i);
1143 if i % 8 == 0 {
1144 am.compact();
1145 }
1146 }
1147 })
1148 };
1149 let compactor = {
1150 let am = am.clone();
1151 std::thread::spawn(move || {
1152 for _ in 0..40 {
1153 am.compact();
1154 std::thread::yield_now();
1155 }
1156 })
1157 };
1158 inserter.join().unwrap();
1159 compactor.join().unwrap();
1160 am.compact();
1161
1162 let neighbors = am.get_neighbors(Vid::new(0), 1, Direction::Outgoing);
1163 let got: HashSet<u64> = neighbors.iter().map(|(v, _)| v.as_u64()).collect();
1164 for i in 1..=n {
1165 assert!(
1166 got.contains(&i),
1167 "edge to {i} was lost under concurrent compaction"
1168 );
1169 }
1170 assert_eq!(got.len(), n as usize, "no spurious or duplicate neighbors");
1171 }
1172
1173 #[test]
1174 fn test_compact_merges_into_main_csr() {
1175 let am = AdjacencyManager::new(1024 * 1024);
1176
1177 // Insert edges into overlay
1178 am.insert_edge(Vid::new(0), Vid::new(10), Eid::new(100), 1, 1);
1179 am.insert_edge(Vid::new(0), Vid::new(20), Eid::new(101), 1, 2);
1180
1181 // Compact: overlay → Main CSR
1182 am.compact();
1183
1184 // Frozen segments should be empty after compaction
1185 assert_eq!(am.frozen_segment_count(), 0);
1186
1187 // Edges should still be accessible via Main CSR
1188 let n = am.get_neighbors(Vid::new(0), 1, Direction::Outgoing);
1189 assert_eq!(n.len(), 2);
1190
1191 assert!(am.has_csr(1, Direction::Outgoing));
1192 }
1193
1194 #[test]
1195 fn test_compact_removes_tombstoned_edges() {
1196 let am = AdjacencyManager::new(1024 * 1024);
1197
1198 // Set up Main CSR with one edge
1199 let csr = MainCsr::from_edge_entries(0, vec![(0, Vid::new(10), Eid::new(100), 1)]);
1200 am.set_main_csr(1, Direction::Outgoing, csr);
1201
1202 // Add new edge + tombstone for old edge in overlay
1203 am.insert_edge(Vid::new(0), Vid::new(20), Eid::new(101), 1, 2);
1204 am.add_tombstone(Eid::new(100), Vid::new(0), Vid::new(10), 1, 3);
1205
1206 am.compact();
1207
1208 // Only the new edge should remain
1209 let n = am.get_neighbors(Vid::new(0), 1, Direction::Outgoing);
1210 assert_eq!(n.len(), 1);
1211 assert_eq!(n[0], (Vid::new(20), Eid::new(101)));
1212 }
1213
1214 #[test]
1215 fn test_should_compact() {
1216 let am = AdjacencyManager::new(1024 * 1024);
1217 assert!(!am.should_compact(4));
1218
1219 // Manually freeze the active overlay multiple times
1220 for _ in 0..4 {
1221 let frozen = {
1222 let mut active = am.active_overlay.write();
1223 let old = std::mem::take(&mut *active);
1224 Arc::new(old.freeze())
1225 };
1226 am.frozen_segments.write().push(frozen);
1227 }
1228
1229 assert!(am.should_compact(4));
1230 }
1231
1232 #[test]
1233 fn test_empty_manager() {
1234 let am = AdjacencyManager::new(1024 * 1024);
1235 assert!(
1236 am.get_neighbors(Vid::new(0), 1, Direction::Outgoing)
1237 .is_empty()
1238 );
1239 assert!(!am.has_csr(1, Direction::Outgoing));
1240 }
1241
1242 #[test]
1243 fn test_overlay_tombstone_removes_main_csr_edge() {
1244 // Simulates: insert edge → flush/compact into Main CSR → delete edge (tombstone in overlay)
1245 let am = AdjacencyManager::new(1024 * 1024);
1246
1247 // Edge already compacted into Main CSR
1248 let csr = MainCsr::from_edge_entries(0, vec![(0, Vid::new(10), Eid::new(100), 1)]);
1249 am.set_main_csr(1, Direction::Outgoing, csr);
1250
1251 // Verify edge is visible before deletion
1252 let n = am.get_neighbors(Vid::new(0), 1, Direction::Outgoing);
1253 assert_eq!(n.len(), 1);
1254
1255 // Delete via overlay tombstone (simulates Writer::delete_edge dual-write)
1256 am.add_tombstone(Eid::new(100), Vid::new(0), Vid::new(10), 1, 2);
1257
1258 // Tombstone in overlay must remove edge from Main CSR results
1259 let n = am.get_neighbors(Vid::new(0), 1, Direction::Outgoing);
1260 assert!(
1261 n.is_empty(),
1262 "Edge should be removed by overlay tombstone, got {:?}",
1263 n
1264 );
1265 }
1266
1267 #[test]
1268 fn test_overlay_tombstone_removes_main_csr_edge_versioned() {
1269 // Same scenario but via get_neighbors_at_version
1270 let am = AdjacencyManager::new(1024 * 1024);
1271
1272 let csr = MainCsr::from_edge_entries(0, vec![(0, Vid::new(10), Eid::new(100), 1)]);
1273 am.set_main_csr(1, Direction::Outgoing, csr);
1274
1275 am.add_tombstone(Eid::new(100), Vid::new(0), Vid::new(10), 1, 5);
1276
1277 // At version 3: edge created at v1, tombstone at v5 → visible
1278 let n = am.get_neighbors_at_version(Vid::new(0), 1, Direction::Outgoing, 3);
1279 assert_eq!(n.len(), 1);
1280
1281 // At version 5: tombstone applies → not visible
1282 let n = am.get_neighbors_at_version(Vid::new(0), 1, Direction::Outgoing, 5);
1283 assert!(
1284 n.is_empty(),
1285 "Edge should be removed by overlay tombstone at version 5"
1286 );
1287 }
1288
1289 #[test]
1290 fn test_frozen_tombstone_removes_main_csr_edge() {
1291 // Edge in Main CSR, tombstone in a frozen segment
1292 let am = AdjacencyManager::new(1024 * 1024);
1293
1294 let csr = MainCsr::from_edge_entries(0, vec![(0, Vid::new(10), Eid::new(100), 1)]);
1295 am.set_main_csr(1, Direction::Outgoing, csr);
1296
1297 // Add tombstone to active overlay, then compact to freeze it
1298 am.add_tombstone(Eid::new(100), Vid::new(0), Vid::new(10), 1, 2);
1299
1300 // Freeze the overlay manually
1301 {
1302 let mut active = am.active_overlay.write();
1303 let old = std::mem::take(&mut *active);
1304 let frozen = std::sync::Arc::new(old.freeze());
1305 am.frozen_segments.write().push(frozen);
1306 }
1307
1308 // The frozen segment's tombstone should remove the Main CSR edge
1309 let n = am.get_neighbors(Vid::new(0), 1, Direction::Outgoing);
1310 assert!(n.is_empty(), "Frozen tombstone should remove Main CSR edge");
1311 }
1312
1313 #[test]
1314 fn test_per_edge_version_filtering() {
1315 // Test that edges inserted at different versions are correctly filtered
1316 // by get_neighbors_at_version()
1317 let am = AdjacencyManager::new(1024 * 1024);
1318
1319 let src = Vid::new(0);
1320 let dst_a = Vid::new(10);
1321 let dst_b = Vid::new(20);
1322 let eid_a = Eid::new(100);
1323 let eid_b = Eid::new(200);
1324 let etype = 1;
1325
1326 // Insert edge A at version 3
1327 am.insert_edge(src, dst_a, eid_a, etype, 3);
1328
1329 // Insert edge B at version 7
1330 am.insert_edge(src, dst_b, eid_b, etype, 7);
1331
1332 // Query at version 2 → neither edge visible
1333 let neighbors_v2 = am.get_neighbors_at_version(src, etype, Direction::Outgoing, 2);
1334 assert!(
1335 neighbors_v2.is_empty(),
1336 "No edges should be visible at version 2"
1337 );
1338
1339 // Query at version 5 → only edge A visible
1340 let neighbors_v5 = am.get_neighbors_at_version(src, etype, Direction::Outgoing, 5);
1341 assert_eq!(
1342 neighbors_v5.len(),
1343 1,
1344 "Only edge A should be visible at version 5"
1345 );
1346 assert_eq!(neighbors_v5[0].0, dst_a, "Edge A destination should match");
1347 assert_eq!(neighbors_v5[0].1, eid_a, "Edge A ID should match");
1348
1349 // Query at version 7 → both edges visible
1350 let neighbors_v7 = am.get_neighbors_at_version(src, etype, Direction::Outgoing, 7);
1351 assert_eq!(
1352 neighbors_v7.len(),
1353 2,
1354 "Both edges should be visible at version 7"
1355 );
1356
1357 // Query at version 10 → both edges visible
1358 let neighbors_v10 = am.get_neighbors_at_version(src, etype, Direction::Outgoing, 10);
1359 assert_eq!(
1360 neighbors_v10.len(),
1361 2,
1362 "Both edges should be visible at version 10"
1363 );
1364 }
1365
1366 #[test]
1367 fn test_duplicate_edges_deduplicated_by_eid() {
1368 // Test Issue #41: Same Eid in MainCsr (v1) and overlay (v3) → only 1 result from get_neighbors
1369 let am = AdjacencyManager::new(1024 * 1024);
1370
1371 let src = Vid::new(0);
1372 let dst = Vid::new(10);
1373 let eid = Eid::new(100);
1374 let etype = 1;
1375
1376 // Set up Main CSR with edge at version 1
1377 let csr = MainCsr::from_edge_entries(0, vec![(0, dst, eid, 1)]);
1378 am.set_main_csr(etype, Direction::Outgoing, csr);
1379
1380 // Insert same Eid into overlay at version 3 (update scenario)
1381 am.insert_edge(src, dst, eid, etype, 3);
1382
1383 // get_neighbors should return only 1 edge (HashMap<Eid, Vid> deduplicates)
1384 let neighbors = am.get_neighbors(src, etype, Direction::Outgoing);
1385 assert_eq!(
1386 neighbors.len(),
1387 1,
1388 "Duplicate Eid should result in single entry"
1389 );
1390 assert_eq!(neighbors[0], (dst, eid));
1391 }
1392
1393 #[test]
1394 fn test_compact_deduplicates_edges_keeps_highest_version() {
1395 // Test Issue #41: Same Eid at v1 in CSR and v5 in overlay
1396 // After compact: get_neighbors_at_version(v5) → visible
1397 // get_neighbors_at_version(v1) → NOT visible (compaction kept v5)
1398 let am = AdjacencyManager::new(1024 * 1024);
1399
1400 let src = Vid::new(0);
1401 let dst = Vid::new(10);
1402 let eid = Eid::new(100);
1403 let etype = 1;
1404
1405 // Set up Main CSR with edge at version 1
1406 let csr = MainCsr::from_edge_entries(0, vec![(0, dst, eid, 1)]);
1407 am.set_main_csr(etype, Direction::Outgoing, csr);
1408
1409 // Insert same Eid into overlay at version 5 (newer version)
1410 am.insert_edge(src, dst, eid, etype, 5);
1411
1412 // Before compact: both versions exist in different layers
1413 // After compact: only highest version (v5) should remain
1414
1415 am.compact();
1416
1417 // At version 5: edge should be visible (highest version kept)
1418 let neighbors_v5 = am.get_neighbors_at_version(src, etype, Direction::Outgoing, 5);
1419 assert_eq!(neighbors_v5.len(), 1, "Edge should be visible at version 5");
1420 assert_eq!(neighbors_v5[0], (dst, eid));
1421
1422 // At version 4: edge should still be visible (v5 edge has created_version=5)
1423 // Actually, the edge at v5 replaces v1, so the edge has version 5
1424 // So at version 4, we should NOT see it
1425 let neighbors_v4 = am.get_neighbors_at_version(src, etype, Direction::Outgoing, 4);
1426 assert_eq!(
1427 neighbors_v4.len(),
1428 0,
1429 "After compaction, only version 5 exists; version 4 should not see it"
1430 );
1431
1432 // At version 1: edge should NOT be visible (old version discarded)
1433 let neighbors_v1 = am.get_neighbors_at_version(src, etype, Direction::Outgoing, 1);
1434 assert_eq!(
1435 neighbors_v1.len(),
1436 0,
1437 "Old version discarded during compaction deduplication"
1438 );
1439
1440 // At version 6: edge should be visible (v5 edge still exists)
1441 let neighbors_v6 = am.get_neighbors_at_version(src, etype, Direction::Outgoing, 6);
1442 assert_eq!(neighbors_v6.len(), 1, "Edge should be visible at version 6");
1443 }
1444
1445 /// Test that tombstone filtering is O(result_size), not O(tombstone_count).
1446 /// This verifies fix for issue #140 (inverted tombstone scan).
1447 #[test]
1448 fn test_tombstone_scan_performance() {
1449 let am = AdjacencyManager::new(1024 * 1024);
1450 let vertex_a = Vid::new(1);
1451 let vertex_b = Vid::new(2);
1452 let etype = 1;
1453
1454 // Create 5 edges from vertex_a
1455 let mut a_edges = Vec::new();
1456 for i in 0..5 {
1457 let dst = Vid::new(100 + i);
1458 let eid = Eid::new(1000 + i);
1459 am.insert_edge(vertex_a, dst, eid, etype, 1);
1460 a_edges.push((dst, eid));
1461 }
1462
1463 // Create 100 deleted edges from vertex_b (creates 100 tombstones)
1464 for i in 0..100 {
1465 let dst = Vid::new(200 + i);
1466 let eid = Eid::new(2000 + i);
1467 am.insert_edge(vertex_b, dst, eid, etype, 1);
1468 am.add_tombstone(eid, vertex_b, dst, etype, 2);
1469 }
1470
1471 // Query neighbors of vertex_a
1472 // With O(T) scan, this would iterate 100 tombstones
1473 // With O(result) scan, this only checks 5 edges against tombstone map
1474 let neighbors = am.get_neighbors(vertex_a, etype, Direction::Outgoing);
1475
1476 // Verify all 5 edges are returned correctly
1477 assert_eq!(
1478 neighbors.len(),
1479 5,
1480 "Should return all 5 edges from vertex_a"
1481 );
1482 for (dst, eid) in &a_edges {
1483 assert!(
1484 neighbors.contains(&(*dst, *eid)),
1485 "Edge {:?} should be in results",
1486 (dst, eid)
1487 );
1488 }
1489
1490 // Verify vertex_b has no neighbors (all tombstoned)
1491 let b_neighbors = am.get_neighbors(vertex_b, etype, Direction::Outgoing);
1492 assert_eq!(
1493 b_neighbors.len(),
1494 0,
1495 "Vertex B should have no neighbors (all deleted)"
1496 );
1497 }
1498
1499 /// Verify that the irrelevant-segment short-circuit (issue #55) doesn't
1500 /// change observable behavior: with many frozen segments where only one
1501 /// holds the queried edge, `get_neighbors` returns exactly that edge.
1502 ///
1503 /// Also covers `get_neighbors_at_version` with the same short-circuit.
1504 #[test]
1505 fn test_get_neighbors_skips_irrelevant_segments() {
1506 let am = AdjacencyManager::new(1024 * 1024);
1507 let participant = Vid::new(1);
1508 let session = Vid::new(2);
1509 let link_eid = Eid::new(100);
1510 let link_etype: u32 = 1;
1511 let unrelated_etype: u32 = 2;
1512
1513 // Build up 50 frozen segments. Only segment #17 carries the LINK
1514 // edge from `participant`. The others are populated with unrelated
1515 // edges that share neither edge_type nor vid with the query.
1516 for i in 0..50 {
1517 if i == 17 {
1518 am.insert_edge(participant, session, link_eid, link_etype, i as u64 + 1);
1519 } else {
1520 // Unrelated traffic: different edge_type, different vids.
1521 let src = Vid::new(1000 + i as u64);
1522 let dst = Vid::new(2000 + i as u64);
1523 let eid = Eid::new(10_000 + i as u64);
1524 am.insert_edge(src, dst, eid, unrelated_etype, i as u64 + 1);
1525 }
1526 // Freeze the active overlay into a new frozen segment.
1527 let frozen = {
1528 let mut active = am.active_overlay.write();
1529 let old = std::mem::take(&mut *active);
1530 Arc::new(old.freeze())
1531 };
1532 am.frozen_segments.write().push(frozen);
1533 }
1534
1535 // Sanity: 50 frozen segments accumulated, none compacted yet.
1536 assert_eq!(am.frozen_segment_count(), 50);
1537
1538 // Hot path: returns exactly the one LINK edge.
1539 let n = am.get_neighbors(participant, link_etype, Direction::Outgoing);
1540 assert_eq!(n.len(), 1);
1541 assert_eq!(n[0], (session, link_eid));
1542
1543 // Snapshot path: same answer at a version that includes segment #17.
1544 let n_at = am.get_neighbors_at_version(participant, link_etype, Direction::Outgoing, 100);
1545 assert_eq!(n_at.len(), 1);
1546 assert_eq!(n_at[0], (session, link_eid));
1547
1548 // Snapshot path: at a version BEFORE segment #17 was created (#17's
1549 // version is 18), the edge must not be visible.
1550 let n_before =
1551 am.get_neighbors_at_version(participant, link_etype, Direction::Outgoing, 17);
1552 assert!(n_before.is_empty());
1553
1554 // The unrelated `unrelated_etype` edges must still be reachable —
1555 // short-circuiting must not have hidden them from their own queries.
1556 // i=18 was an unrelated insert (i=17 was the LINK), so Vid(1018) is
1557 // a valid source for an unrelated edge.
1558 let unrelated = am.get_neighbors(Vid::new(1018), unrelated_etype, Direction::Outgoing);
1559 assert_eq!(unrelated.len(), 1);
1560 }
1561
1562 /// Verify that the tombstone short-circuit (issue #55) doesn't drop
1563 /// transitively-shadowed edges: a frozen segment that has a tombstone
1564 /// for an edge present in Main CSR must still apply that tombstone,
1565 /// even if the segment has no inserts of its own for the queried type.
1566 #[test]
1567 fn test_tombstone_in_unrelated_segment_still_applied() {
1568 let am = AdjacencyManager::new(1024 * 1024);
1569 let src = Vid::new(0);
1570 let dst = Vid::new(10);
1571 let eid = Eid::new(100);
1572 let etype: u32 = 1;
1573
1574 // Edge exists in Main CSR.
1575 let csr = MainCsr::from_edge_entries(0, vec![(0, dst, eid, 1)]);
1576 am.set_main_csr(etype, Direction::Outgoing, csr);
1577
1578 // Add a tombstone in the active overlay deleting the Main CSR edge.
1579 am.add_tombstone(eid, src, dst, etype, 2);
1580
1581 // Freeze the active overlay so the tombstone now lives in a frozen
1582 // segment whose `inserts` is empty for `etype`. The short-circuit
1583 // for "no inserts AND no tombstones" must NOT skip this segment —
1584 // it has a tombstone we still need to honour.
1585 let frozen = {
1586 let mut active = am.active_overlay.write();
1587 let old = std::mem::take(&mut *active);
1588 Arc::new(old.freeze())
1589 };
1590 am.frozen_segments.write().push(frozen);
1591
1592 let n = am.get_neighbors(src, etype, Direction::Outgoing);
1593 assert!(
1594 n.is_empty(),
1595 "tombstone in frozen segment must still hide Main CSR edge"
1596 );
1597 }
1598}