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}