Skip to main content

uni_store/storage/
shadow_csr.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! Shadow CSR for time-travel deleted edge tracking.
5//!
6//! Stores edges that have been deleted from the Main CSR along with
7//! their version lifecycle (created_version, deleted_version). Only
8//! queried during snapshot/time-travel reads — never on the hot path.
9
10use crate::storage::direction::Direction;
11use dashmap::DashMap;
12use std::collections::HashMap;
13use uni_common::core::id::{Eid, Vid};
14
15/// A deleted edge with version range for time-travel reconstruction.
16#[derive(Clone, Debug)]
17pub struct ShadowEdge {
18    /// Neighbor vertex ID.
19    pub neighbor_vid: Vid,
20    /// Edge ID.
21    pub eid: Eid,
22    /// Edge type ID (bit 31 = 0 for schema'd, 1 for schemaless).
23    pub edge_type: u32,
24    /// Version at which this edge was created.
25    pub created_version: u64,
26    /// Version at which this edge was deleted.
27    pub deleted_version: u64,
28}
29
30/// Shadow CSR storing deleted edges with their version lifecycle.
31///
32/// Only queried during snapshot/time-travel reads. Uses `HashMap`
33/// rather than packed CSR because deleted edges are typically few,
34/// append-heavy, and never on the regular query hot path.
35pub struct ShadowCsr {
36    /// `(edge_type, direction) -> vid -> Vec<ShadowEdge>`.
37    /// Edge type is u32 with bit 31 = 0 for schema'd, 1 for schemaless.
38    entries: DashMap<(u32, Direction), HashMap<Vid, Vec<ShadowEdge>>>,
39}
40
41impl ShadowCsr {
42    /// Creates an empty shadow CSR.
43    pub fn new() -> Self {
44        Self {
45            entries: DashMap::new(),
46        }
47    }
48
49    /// Record a deleted edge with its version lifecycle, ignoring a repeat of
50    /// one already recorded.
51    ///
52    /// The dedup is load-bearing, not tidiness. `AdjacencyManager::warm` pushes
53    /// a shadow entry for every `op == 1` row it scans out of the L1 delta, and
54    /// unlike `warm_coalesced` it has no `has_csr` short-circuit — so each warm
55    /// of the same `(edge_type, direction)` re-pushed the *entire* delete
56    /// history. Growth was therefore unbounded in the number of warms rather
57    /// than in the number of deletes, and invisible to the cache budget.
58    ///
59    /// `Eid` identifies the edge, and a given edge has one deletion, so a
60    /// same-eid repeat carries no information. Reads dedupe by `Eid` downstream
61    /// already, which is why this only ever showed up as memory rather than as
62    /// wrong answers.
63    pub fn add_deleted_edge(&self, src_vid: Vid, edge: ShadowEdge, direction: Direction) {
64        let mut bucket = self.entries.entry((edge.edge_type, direction)).or_default();
65        let edges = bucket.entry(src_vid).or_default();
66        if edges.iter().any(|e| e.eid == edge.eid) {
67            return;
68        }
69        edges.push(edge);
70    }
71
72    /// Total shadow entries retained, across every key.
73    ///
74    /// Exposed so retention is observable directly. `current_bytes` in
75    /// `AdjacencyManager` tracks only the main CSR, so `memory_usage` folds in
76    /// [`Self::approx_bytes`] rather than counting shadow entries
77    /// incrementally.
78    pub fn entry_count(&self) -> usize {
79        self.entries
80            .iter()
81            .map(|kv| kv.value().values().map(Vec::len).sum::<usize>())
82            .sum()
83    }
84
85    /// Approximate retained bytes, for the same observability reason.
86    pub fn approx_bytes(&self) -> usize {
87        self.entry_count() * std::mem::size_of::<ShadowEdge>()
88    }
89
90    /// Returns edges that were alive at the given `version`.
91    ///
92    /// An edge is considered alive at `version` when
93    /// `created_version <= version < deleted_version`.
94    pub fn get_entries_at_version(
95        &self,
96        vid: Vid,
97        edge_type: u32,
98        direction: Direction,
99        version: u64,
100    ) -> Vec<(Vid, Eid)> {
101        let mut result = Vec::new();
102
103        if let Some(map) = self.entries.get(&(edge_type, direction))
104            && let Some(edges) = map.get(&vid)
105        {
106            for edge in edges {
107                if edge.created_version <= version && edge.deleted_version > version {
108                    result.push((edge.neighbor_vid, edge.eid));
109                }
110            }
111        }
112
113        result
114    }
115
116    /// Returns raw shadow entries for a vertex (all versions).
117    pub fn get_entries(&self, vid: Vid, edge_type: u32, direction: Direction) -> Vec<ShadowEdge> {
118        if let Some(map) = self.entries.get(&(edge_type, direction))
119            && let Some(edges) = map.get(&vid)
120        {
121            return edges.clone();
122        }
123        Vec::new()
124    }
125
126    /// Garbage-collects shadow entries no longer needed.
127    ///
128    /// Removes entries where `deleted_version <= oldest_active_snapshot_version`,
129    /// since no active snapshot can reference those edges.
130    ///
131    /// # Choosing the bound
132    ///
133    /// Callers must pass the floor computed by
134    /// [`AdjacencyManager::gc_shadow`](crate::storage::adjacency_manager::AdjacencyManager::gc_shadow),
135    /// never a raw version. The bound is narrower than "the oldest snapshot",
136    /// and getting it wrong drops entries a live reader still resolves through
137    /// [`Self::get_entries_at_version`]:
138    ///
139    /// * `StorageManager::pinned()` and `at_fork` each build a **fresh**
140    ///   `AdjacencyManager` with its own empty `ShadowCsr`, so those readers
141    ///   never consult this instance.
142    /// * `StorageManager::pinned_at_version` **shares** the live
143    ///   `AdjacencyManager`, and is the path every read-write transaction
144    ///   takes.
145    ///
146    /// So the floor is the minimum version among in-flight `pinned_at_version`
147    /// views — tracked by `PinnedVersions`, refcounted so it cannot rise while
148    /// another reader holds the same version — falling back to the current
149    /// version when nothing is pinned. `SnapshotManager` cannot supply this: it
150    /// is a manifest reader-writer and tracks no live readers.
151    pub fn gc(&self, oldest_active_snapshot_version: u64) {
152        for mut entry in self.entries.iter_mut() {
153            let map = entry.value_mut();
154            for edge_list in map.values_mut() {
155                edge_list.retain(|e| e.deleted_version > oldest_active_snapshot_version);
156            }
157            // Remove empty vertex entries
158            map.retain(|_, edges| !edges.is_empty());
159        }
160    }
161}
162
163impl Default for ShadowCsr {
164    fn default() -> Self {
165        Self::new()
166    }
167}
168
169impl std::fmt::Debug for ShadowCsr {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        let total_edges: usize = self
172            .entries
173            .iter()
174            .map(|e| e.value().values().map(|v| v.len()).sum::<usize>())
175            .sum();
176        f.debug_struct("ShadowCsr")
177            .field("buckets", &self.entries.len())
178            .field("total_edges", &total_edges)
179            .finish()
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    fn make_shadow_edge(neighbor: u64, eid: u64, created: u64, deleted: u64) -> ShadowEdge {
188        ShadowEdge {
189            neighbor_vid: Vid::new(neighbor),
190            eid: Eid::new(eid),
191            edge_type: 1,
192            created_version: created,
193            deleted_version: deleted,
194        }
195    }
196
197    #[test]
198    fn test_add_and_query() {
199        let shadow = ShadowCsr::new();
200        let src = Vid::new(1);
201
202        // Edge created at v1, deleted at v5
203        shadow.add_deleted_edge(src, make_shadow_edge(2, 100, 1, 5), Direction::Outgoing);
204
205        // At v3 the edge should be visible
206        let result = shadow.get_entries_at_version(src, 1, Direction::Outgoing, 3);
207        assert_eq!(result.len(), 1);
208        assert_eq!(result[0], (Vid::new(2), Eid::new(100)));
209
210        // At v5 the edge is deleted
211        let result = shadow.get_entries_at_version(src, 1, Direction::Outgoing, 5);
212        assert!(result.is_empty());
213
214        // At v0 the edge doesn't exist yet
215        let result = shadow.get_entries_at_version(src, 1, Direction::Outgoing, 0);
216        assert!(result.is_empty());
217    }
218
219    #[test]
220    fn test_gc_removes_old_entries() {
221        let shadow = ShadowCsr::new();
222        let src = Vid::new(1);
223
224        shadow.add_deleted_edge(src, make_shadow_edge(2, 100, 1, 3), Direction::Outgoing);
225        shadow.add_deleted_edge(src, make_shadow_edge(3, 101, 2, 10), Direction::Outgoing);
226
227        // GC with oldest snapshot at v5 — first entry (deleted_version=3) gets removed
228        shadow.gc(5);
229
230        let entries = shadow.get_entries(src, 1, Direction::Outgoing);
231        assert_eq!(entries.len(), 1);
232        assert_eq!(entries[0].eid, Eid::new(101));
233    }
234
235    #[test]
236    fn test_empty_shadow() {
237        let shadow = ShadowCsr::new();
238        let result = shadow.get_entries_at_version(Vid::new(0), 1, Direction::Outgoing, 5);
239        assert!(result.is_empty());
240    }
241
242    #[test]
243    fn test_multiple_edges_same_vertex() {
244        let shadow = ShadowCsr::new();
245        let src = Vid::new(1);
246
247        shadow.add_deleted_edge(src, make_shadow_edge(2, 100, 1, 5), Direction::Outgoing);
248        shadow.add_deleted_edge(src, make_shadow_edge(3, 101, 2, 8), Direction::Outgoing);
249
250        // At v4: both alive
251        let result = shadow.get_entries_at_version(src, 1, Direction::Outgoing, 4);
252        assert_eq!(result.len(), 2);
253
254        // At v6: only second alive
255        let result = shadow.get_entries_at_version(src, 1, Direction::Outgoing, 6);
256        assert_eq!(result.len(), 1);
257        assert_eq!(result[0].1, Eid::new(101));
258    }
259}