Skip to main content

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}