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