linkrs_storage/edge/csr_trait.rs
1//! CSR Trait Definitions
2//!
3//! Unified trait interface for different CSR implementations.
4//! Supports runtime polymorphism for edge storage selection.
5//!
6//! Vertex lifecycle is owned above this layer: vertex tables tombstone
7//! vertex records and propagate survivors through edge remapping, so this
8//! trait exposes no vertex-scoped bulk delete. Incident-edge cleanup
9//! composes the row-scoped deletes below with the table-level reclaim and
10//! remap passes.
11//!
12//! Row sort contract: mutable rows sort by `(endpoint, rank, edge_id)` on
13//! the maintenance path only. Sorting moves slots, so every previously
14//! issued `EdgePosition` for the row goes stale like after a compaction or
15//! rebalance, and callers relocate through the edge-id key first. The
16//! primary block holds the sorted prefix while overflow chunks stay in
17//! insertion order as the unsorted suffix; new writes fill primary gaps or
18//! spill to the overflow tail and therefore mark the row unsorted again as
19//! observed by `is_row_sorted`. No sort watermark is persisted: order is
20//! cached in a memory-resident per-row flag and the live index is rebuilt
21//! on sort and on load. Threshold scans route internally on the cached
22//! flag: sorted primaries bisect the key window, unsorted rows and every
23//! overflow suffix scan linearly.
24
25use linkrs_core::StorageResult;
26
27use super::mutable_csr::EdgePosition;
28use super::{EdgeId, Nbr, Timestamp, VertexId};
29
30pub trait CsrBase: std::fmt::Debug + Send + Sync {
31 /// Concurrency contract: row stores carry no internal locks. Concurrent
32 /// reads are safe while no writer holds exclusive access, but any
33 /// concurrent mutation requires caller-side mutual exclusion. The
34 /// `Send + Sync` bounds express single-writer transfer across threads,
35 /// not lock-free concurrent writes. Multi-core scaling comes from
36 /// shard-level parallelism over `NodeGroup` shards above this trait;
37 /// the CSR lock model itself stays single-writer with no row locks.
38 fn vertex_capacity(&self) -> usize;
39
40 fn edge_count(&self) -> u64;
41
42 fn dump(&self) -> Vec<u8>;
43
44 fn load(&mut self, data: &[u8]) -> StorageResult<()>;
45
46 /// Append the same bytes as `dump` into `out` without an intermediate
47 /// owned buffer, so checkpoint writes borrow the live topology instead
48 /// of cloning it for serialization.
49 fn dump_into(&self, out: &mut Vec<u8>) {
50 out.extend_from_slice(&self.dump());
51 }
52}
53
54pub trait MutableCsrTrait: CsrBase {
55 /// Insert an edge.
56 ///
57 /// Topology and properties are decoupled: the CSR stores only the
58 /// topology (neighbor, edge_id, timestamps). Properties are stored
59 /// separately indexed by `EdgeId`.
60 ///
61 /// Physical uniqueness only, without timestamp ordering checks. Snapshot
62 /// visibility is decided by the version authority above this layer.
63 ///
64 /// Returns `Ok(())` on success, or an error explaining why insertion failed:
65 ///
66 /// - `MutableCsr`: checks for duplicate live (neighbor) keys across primary and overflow,
67 /// writes to primary if space available, otherwise spills to overflow with auto-expansion.
68 /// Returns `EdgeAlreadyExists` on duplicate.
69 /// - `SingleMutableCsr`: rejects a second live edge in an occupied slot
70 /// with `Conflict`, matching the table-layer Single contract. A
71 /// tombstoned slot accepts a rebuild at any timestamp. No silent
72 /// overwrite exists.
73 fn insert_edge(
74 &mut self,
75 src_vid: u32,
76 dst: VertexId,
77 edge_id: EdgeId,
78 ts: Timestamp,
79 ) -> StorageResult<()>;
80
81 /// Delete an edge by edge_id.
82 ///
83 /// Returns:
84 /// - `Ok(true)` when the edge was deleted.
85 /// - `Ok(false)` when the edge does not exist or cannot be deleted at `ts`
86 /// (e.g. `create_ts > ts`), or when it was already deleted at exactly
87 /// `ts` (idempotent re-delete).
88 /// - `Err(StorageError::write_write_conflict)` when the edge already exists
89 /// but was deleted at a **different** timestamp.
90 ///
91 /// - `MutableCsr`: uses `edge_id` to locate and delete the specific edge.
92 /// - `SingleMutableCsr`: the stored edge id must match exactly. No wildcard
93 /// edge id is supported; callers pass the precise id or use endpoint
94 /// addressing.
95 fn delete_edge(&mut self, src_vid: u32, edge_id: EdgeId, ts: Timestamp) -> StorageResult<bool>;
96
97 /// Delete all edges matching (src, dst) with full-match semantics.
98 ///
99 /// One call deletes every live match; no first-only variant exists.
100 /// Returns the deleted count so table rollback can reconcile by count and
101 /// tombstone metrics can observe multi-match rows.
102 ///
103 /// - `MutableCsr`: scans primary and overflow, deletes **all** matching edges.
104 /// - `SingleMutableCsr`: deletes the single edge if dst matches (0 or 1).
105 fn delete_edge_by_dst(&mut self, src_vid: u32, dst: VertexId, ts: Timestamp) -> usize;
106
107 /// Delete all edges matching (src, dst), reporting every stamped id.
108 ///
109 /// Same single-pass semantics as `delete_edge_by_dst`, plus one
110 /// `on_deleted` call per stamped edge so callers needing the ids (append
111 /// logs, audits) skip their own collection scan. The default routes
112 /// through `delete_edge_by_dst` and reports nothing; row stores override
113 /// it with an in-place reporting pass. A zero return covers both no match
114 /// and read-only refusal without invoking the callback; table write paths
115 /// reject frozen groups before reaching here so the two cases never merge
116 /// into one silent miss.
117 fn delete_edge_by_dst_reporting(
118 &mut self,
119 src_vid: u32,
120 dst: VertexId,
121 ts: Timestamp,
122 _on_deleted: &mut dyn FnMut(EdgeId),
123 ) -> usize {
124 self.delete_edge_by_dst(src_vid, dst, ts)
125 }
126
127 /// Delete all edges matching (src, dst), reporting each stamped id with
128 /// its row position.
129 ///
130 /// Same single-pass semantics as `delete_edge_by_dst_reporting`, plus the
131 /// position of each stamped entry so the caller can revert or re-delete
132 /// the exact slot without rescanning the row. Positions are row-local and
133 /// valid only until the next compaction, rebalance, repack or removal of
134 /// the row; every positional write revalidates the edge id. Stores
135 /// without positional addressing report `None` and the caller falls back
136 /// to the edge-id path. The default adapts the id-only pass.
137 fn delete_edge_by_dst_reporting_positioned(
138 &mut self,
139 src_vid: u32,
140 dst: VertexId,
141 ts: Timestamp,
142 on_deleted: &mut dyn FnMut(EdgeId, Option<EdgePosition>),
143 ) -> usize {
144 let mut count = 0usize;
145 let deleted = self.delete_edge_by_dst_reporting(src_vid, dst, ts, &mut |edge_id| {
146 on_deleted(edge_id, None);
147 count += 1;
148 });
149 let _ = count;
150 deleted
151 }
152
153 /// Locate one stored edge, returning its row position and copy.
154 ///
155 /// The default reports no position; row stores override it with a single
156 /// scan shared by delete and revert callers.
157 fn locate_edge(&self, _src_vid: u32, _edge_id: EdgeId) -> Option<(EdgePosition, Nbr)> {
158 None
159 }
160
161 /// Delete the edge at `position` when it still holds `expected` id.
162 ///
163 /// Stale positions are refused with `Ok(false)`. Position-less stores
164 /// fail closed with an error so a cross-variant position can never fall
165 /// back to an id scan silently; positional stores override it with a
166 /// direct slot write.
167 fn delete_edge_at_position(
168 &mut self,
169 src_vid: u32,
170 _position: EdgePosition,
171 expected: EdgeId,
172 ts: Timestamp,
173 ) -> StorageResult<bool> {
174 let _ = (src_vid, expected, ts);
175 Err(linkrs_core::StorageError::invalid_operation(
176 "row position is not supported by this CSR form; re-resolve by edge id".to_string(),
177 ))
178 }
179
180 /// Revert the deletion at `position` when it still holds `expected` id.
181 ///
182 /// Stale positions are refused with `false`. Position-less stores report
183 /// `false` so cross-form reuse is observable instead of silently
184 /// succeeding through another path; positional stores override it.
185 fn revert_delete_at_position(
186 &mut self,
187 src_vid: u32,
188 _position: EdgePosition,
189 expected: EdgeId,
190 ts: Timestamp,
191 ) -> bool {
192 let _ = (src_vid, _position, expected, ts);
193 false
194 }
195
196 /// Delete an edge by its offset position in the primary block.
197 ///
198 /// Offset indexes into the live row degree, never into reserved capacity.
199 /// Out-of-degree offsets fail without touching any slot. Conflict errors
200 /// are propagated instead of folded into a failure value.
201 fn delete_edge_by_offset(
202 &mut self,
203 src_vid: u32,
204 offset: i32,
205 ts: Timestamp,
206 ) -> StorageResult<bool>;
207
208 /// Revert a deleted edge by its offset position.
209 ///
210 /// - `MutableCsr`: offset indexes into the primary block degree.
211 /// - `SingleMutableCsr`: only offset == 0 is valid.
212 fn revert_delete_by_offset(&mut self, src_vid: u32, offset: i32, ts: Timestamp) -> bool;
213
214 /// Read-only view of one primary slot for offset identity checks.
215 fn nbr_at_offset(&self, _src_vid: u32, _offset: i32) -> Option<Nbr> {
216 None
217 }
218
219 /// Locate the first live edge by endpoint without consulting snapshots.
220 ///
221 /// Physical addressing only; tombstoned and gap slots are skipped.
222 /// Snapshot visibility still needs the version authority above this
223 /// layer, while tombstone access uses the full physical views.
224 fn get_edge_physical(&self, _src_vid: u32, _dst: VertexId) -> Option<Nbr> {
225 None
226 }
227
228 /// Every physically stored entry of one vertex without timestamp filtering.
229 ///
230 /// Test and offline use; production scans use `fill_physical_into` or
231 /// the visitor paths instead of this allocating accessor.
232 fn physical_edges_of(&self, _src_vid: u32) -> Vec<Nbr> {
233 Vec::new()
234 }
235
236 /// Fill a caller buffer with every physically stored entry of one vertex.
237 ///
238 /// Same content as the allocating accessor above, without the per-vertex
239 /// allocation. The default forwards to the allocating accessor; row
240 /// stores override it with a direct fill.
241 fn fill_physical_into(&self, src_vid: u32, out: &mut Vec<Nbr>) {
242 out.clear();
243 out.extend_from_slice(&self.physical_edges_of(src_vid));
244 }
245
246 /// Whether one vertex holds any physically stored entry.
247 fn has_physical_entries(&self, _vid: u32) -> bool {
248 false
249 }
250
251 /// Whether the primary row of one vertex holds `edge_id`.
252 fn primary_contains(&self, _src_vid: u32, _edge_id: EdgeId) -> bool {
253 false
254 }
255
256 /// Erase one just-inserted edge for insert rollback (no tombstone trace).
257 ///
258 /// Insert-rollback only: erases the slot as if the edge never existed.
259 /// Never use for MVCC deletes, which must go through `delete_edge` so
260 /// the tombstone stays visible to the reclaim machinery. Reclaims the
261 /// slot and updates the edge count. Only implemented by writable
262 /// strategies; other strategies default to no-op.
263 fn rollback_insert(&mut self, _src_vid: u32, _edge_id: EdgeId) -> bool {
264 false
265 }
266
267 /// Revert a deletion by edge id if it happened at or before `ts`.
268 ///
269 /// Restores the entry as live. Only implemented by `MutableCsr`; other
270 /// strategies default to no-op.
271 fn revert_delete_by_edge_id(
272 &mut self,
273 _src_vid: u32,
274 _edge_id: EdgeId,
275 _ts: Timestamp,
276 ) -> bool {
277 false
278 }
279
280 /// Get a specific edge by source and destination.
281 /// Test-only row-stamp filter; production reads go through the version authority.
282 fn get_edge(&self, src_vid: u32, dst: VertexId, ts: Timestamp) -> Option<Nbr>;
283
284 /// Get all valid edges of a vertex at the given timestamp.
285 /// Test-only row-stamp filter; production reads go through the version authority.
286 /// Test and offline use; production traversals use the visitor or
287 /// caller-buffer fill paths instead of this allocating accessor.
288 fn edges_of(&self, src_vid: u32, ts: Timestamp) -> Vec<Nbr>;
289
290 /// Reclaim one vertex in place, dropping entries eligible for collection
291 /// at `cutoff` and tightening live entries without touching other rows.
292 ///
293 /// Returns the number of removed entries. Reported removals flow through
294 /// `on_edge_removed` so tombstone promotion stays centralized.
295 /// Strategies without per-row fragmentation default to no-op.
296 ///
297 /// Mutable-row entry only: production reclaim of frozen rows must go
298 /// through the group-batched entries (one linear pass for any number of
299 /// rows) instead of one call per row. Frozen implementations reject the
300 /// single-row entry; every production trigger dispatches frozen groups
301 /// to the batched path before reaching here.
302 fn compact_vertex_with_reporting(
303 &mut self,
304 _vid: u32,
305 _cutoff: Timestamp,
306 _on_edge_removed: &mut dyn FnMut(EdgeId, Timestamp),
307 ) -> usize {
308 0
309 }
310
311 /// Count entries of one vertex that are reclaimable at `cutoff`.
312 ///
313 /// The write-path trigger consults this per-vertex count instead of a
314 /// whole-table ratio, so small writes never cause large rebuilds.
315 fn reclaimable_count(&self, _vid: u32, _cutoff: Timestamp) -> usize {
316 0
317 }
318
319 /// Whether one vertex holds anything reclaimable at `cutoff`.
320 fn vertex_needs_compact(&self, vid: u32, cutoff: Timestamp) -> bool {
321 self.reclaimable_count(vid, cutoff) > 0
322 }
323
324 /// Physical entry census of one vertex: `(live, dead, capacity)`.
325 ///
326 /// Backs the per-vertex fragmentation view; strategies without rows
327 /// report zeros.
328 fn vertex_census(&self, _vid: u32) -> (usize, usize, usize) {
329 (0, 0, 0)
330 }
331
332 /// Single-walk reclaim probe of one vertex: `(dead, reclaimable)`.
333 ///
334 /// Fuses the census and eligibility walks so maintenance passes pay one
335 /// row scan instead of two. The default derives both from the separate
336 /// primitives; row stores override it with one fused walk.
337 fn vertex_reclaim_probe(&self, vid: u32, cutoff: Timestamp) -> (usize, usize) {
338 let (_, dead, _) = self.vertex_census(vid);
339 (dead, self.reclaimable_count(vid, cutoff))
340 }
341
342 /// Reserved primary slots minus live primary entries of one row.
343 ///
344 /// Steady-state write gaps; strategies without rows report zero.
345 fn row_gap(&self, _vid: u32) -> usize {
346 0
347 }
348
349 /// Live primary entries per unit of reserved primary capacity.
350 ///
351 /// Strategies without rows report full density.
352 fn row_density(&self, _vid: u32) -> f32 {
353 1.0
354 }
355
356 /// Rebalance one row in place, pulling overflow entries into primary
357 /// gaps and repacking leftover overflow. Returns true when the row no
358 /// longer holds overflow. Strategies without rows report true.
359 fn rebalance_row(&mut self, _vid: u32) -> bool {
360 true
361 }
362
363 /// Return the approximate memory usage in bytes.
364 fn used_memory_size(&self) -> usize;
365}
366
367#[cfg(test)]
368mod tests {}