linkrs_storage/edge/node_group.rs
1//! Node-group sharding for edge topology.
2//!
3//! Each edge direction is partitioned by bound-endpoint interval:
4//! `group = vid >> group_bits`, `local = vid & (group_size - 1)`.
5//! Every group owns one `CsrVariant` holding only the rows of its interval,
6//! plus insert/delete/column-update dirt markers driving incremental
7//! checkpoints. Neighbor keys keep global endpoint values; only row
8//! addressing is local.
9//!
10//! Inside a group, fixed row windows form leaf regions carrying their own
11//! three-kind dirt. Group dirt is the OR over its regions; reclaim passes
12//! decide per region so unchanged regions are skipped, while checkpoints skip
13//! clean groups at group granularity. Whole-group and whole-table ratios stay
14//! observability only.
15//!
16//! Groups are sparse: only existing groups are materialized, the manifest
17//! records existing group ids rather than a contiguous count, missing groups
18//! read as empty and writes create them on demand, and no files are written
19//! for missing groups. Endpoints are expected dense; sparse large endpoints
20//! must be densified offline via vertex remapping before bulk load, otherwise
21//! the group span stays wide while only existing groups consume memory and
22//! files. The address width is locked at table creation; the only adjustment
23//! outlet is the offline reshard tool, there is no online width-change branch.
24//!
25//! Timestamps and properties are sharded on disk by owner group and fall with
26//! the same dirt as their group: small writes rewrite only dirty groups'
27//! timestamp and property shards, never the whole table. The in-memory
28//! visibility authority stays global by edge id; sharding changes only the
29//! flush unit.
30//!
31//! Each group additionally holds a committed append log: the write-through
32//! delta (inserts plus tombstone markers) since the last base rewrite.
33//! Insert-only groups checkpoint by persisting the append log alone; groups
34//! with delete dirt rewrite the base and discard the log. Append row indexes
35//! are dropped after the flush that persists them.
36//!
37//! Concurrency: this container adds no locking of its own and follows the
38//! canonical contract in [`super::mutable_csr`]. All mutation takes `&mut
39//! self`; the route cache is the only shared mutable state and stays
40//! correct under relaxed atomics because removals invalidate before they
41//! unlink.
42//!
43//! Layout by responsibility (`node_group/` subdirectory):
44//! - `address` holds group/region address arithmetic and density calibration.
45//! - `dirt` holds the region/group dirt model and checkpoint classification.
46//! - `manifest` holds the per-table existing-group layout record.
47//! - `append_log` holds the per-group write-through delta and its codec.
48//! - `core` holds construction, group materialization, routing and bulk insert.
49//! - `freeze` holds explicit group freeze and unfreeze between mutable and
50//! packed forms.
51//! - `read` holds routed physical read helpers.
52//! - `iter` holds the cross-group scan iterator.
53//! - `compaction` holds region observation, merge-scope selection and
54//! tombstone compaction passes.
55//! - `stats` holds observability views summed across groups.
56//! - `trait_impl` adapts the set to the CSR traits.
57
58use linkrs_core::types::{EdgeStrategy, Timestamp};
59use std::collections::BTreeMap;
60use std::fmt;
61use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
62
63use super::{CsrVariant, MappedFrozen, RecordForm};
64
65pub(crate) mod address;
66pub(crate) mod append_log;
67pub(crate) mod compaction;
68pub(crate) mod core;
69pub(crate) mod dirt;
70pub(crate) mod freeze;
71pub(crate) mod iter;
72pub(crate) mod manifest;
73pub(crate) mod read;
74pub(crate) mod stats;
75pub(crate) mod trait_impl;
76
77pub use address::{
78 calibrator_max_density, calibrator_tree_height, group_base, group_id_for, group_size,
79 local_vid, region_id_for_local, region_local_range, region_tail_gap, regions_per_group,
80 validate_group_bits, DEFAULT_NODE_GROUP_BITS, LEAF_HIGH_CSR_DENSITY, LEAF_REGION_ROWS,
81};
82pub use compaction::{RegionMergeScope, GROUP_MERGE_MIN_DENSITY, REGION_MERGE_MIN_DENSITY};
83pub use dirt::{EdgeCheckpointKind, GroupDirty, RegionDirty};
84pub use freeze::{FreezeBlockReason, FreezeFeasibility};
85pub use iter::ShardCsrIterator;
86pub use manifest::TableShardManifest;
87pub use stats::NodeGroupStats;
88
89pub(crate) use append_log::{decode_append_ops, encode_append_ops, ShardAppendLog};
90
91#[derive(Debug, Clone)]
92struct Shard {
93 variant: CsrVariant,
94 /// Derived serving cache for frozen groups. The heap variant stays
95 /// authoritative: the mapping only accelerates reads and is dropped on
96 /// every mutation of the variant (freeze, unfreeze, clear, load). It is
97 /// only ever populated for frozen groups by the checkpoint load path.
98 mapped: Option<MappedFrozen>,
99 dirty: GroupDirty,
100 regions: Vec<RegionDirty>,
101 append: ShardAppendLog,
102 /// Whether the group may hold tombstones. Set on deletes and on load,
103 /// cleared after a reclaim scan visits the whole group. Lets reclaim
104 /// passes skip groups that never held a deletion.
105 reclaim_hint: bool,
106 /// Physical tombstone entries in the group, kept as an over-estimate.
107 ///
108 /// Every tombstone-creating path increments it and every exact removal
109 /// (compaction reporting, revert) decrements it, so zero guarantees the
110 /// group holds no tombstones and reclaim walks can stop at O(1).
111 /// Tombstone reuse on insert is not tracked and only over-estimates,
112 /// which costs an extra scan but never skips needed work.
113 dead_entries: usize,
114}
115
116/// Hot-row routing cache slots. Power of two so the slot is a mask, no
117/// division. Sized to cover the working set of repeated point lookups while
118/// staying small enough to fit a few cache lines.
119const ROUTE_CACHE_SLOTS: usize = 128;
120/// Empty-slot sentinel: no vertex id ever routes here since the id space is
121/// dense from zero and the sentinel is the maximum value.
122const ROUTE_CACHE_EMPTY: u32 = u32::MAX;
123
124/// Lock-free hot-row address cache fronting the group map lookup.
125///
126/// Point lookups and single-edge writes pay a group-shift plus a `BTreeMap`
127/// search per row; hot vertices repeat the same address, so a direct-mapped
128/// cache of recent `(vertex, group, local)` triples lets repeats skip the map
129/// search. Atomics keep `&self` reads lock-free; ordering stays relaxed
130/// because a stale entry only costs a re-lookup on the invalidation paths,
131/// never a wrong answer: entries are only populated for existing groups and
132/// every group-removal path invalidates its entries first.
133struct RouteCache {
134 vids: Box<[AtomicU32]>,
135 gids: Box<[AtomicUsize]>,
136 locals: Box<[AtomicU32]>,
137 hits: AtomicUsize,
138 misses: AtomicUsize,
139 collisions: AtomicUsize,
140}
141
142impl RouteCache {
143 fn new() -> Self {
144 Self {
145 vids: (0..ROUTE_CACHE_SLOTS)
146 .map(|_| AtomicU32::new(ROUTE_CACHE_EMPTY))
147 .collect(),
148 gids: (0..ROUTE_CACHE_SLOTS)
149 .map(|_| AtomicUsize::new(0))
150 .collect(),
151 locals: (0..ROUTE_CACHE_SLOTS).map(|_| AtomicU32::new(0)).collect(),
152 hits: AtomicUsize::new(0),
153 misses: AtomicUsize::new(0),
154 collisions: AtomicUsize::new(0),
155 }
156 }
157
158 #[inline]
159 fn slot(vid: u32) -> usize {
160 vid as usize & (ROUTE_CACHE_SLOTS - 1)
161 }
162
163 fn lookup(&self, vid: u32) -> Option<(usize, u32)> {
164 let slot = Self::slot(vid);
165 let resident = self.vids[slot].load(Ordering::Relaxed);
166 if resident != vid {
167 self.misses.fetch_add(1, Ordering::Relaxed);
168 if resident != ROUTE_CACHE_EMPTY {
169 self.collisions.fetch_add(1, Ordering::Relaxed);
170 }
171 return None;
172 }
173 self.hits.fetch_add(1, Ordering::Relaxed);
174 Some((
175 self.gids[slot].load(Ordering::Relaxed),
176 self.locals[slot].load(Ordering::Relaxed),
177 ))
178 }
179
180 fn insert(&self, vid: u32, gid: usize, local: u32) {
181 let slot = Self::slot(vid);
182 self.gids[slot].store(gid, Ordering::Relaxed);
183 self.locals[slot].store(local, Ordering::Relaxed);
184 self.vids[slot].store(vid, Ordering::Relaxed);
185 }
186
187 fn invalidate_gid(&self, gid: usize) {
188 for slot in 0..ROUTE_CACHE_SLOTS {
189 if self.vids[slot].load(Ordering::Relaxed) != ROUTE_CACHE_EMPTY
190 && self.gids[slot].load(Ordering::Relaxed) == gid
191 {
192 self.vids[slot].store(ROUTE_CACHE_EMPTY, Ordering::Relaxed);
193 }
194 }
195 }
196
197 fn clear(&self) {
198 for slot in 0..ROUTE_CACHE_SLOTS {
199 self.vids[slot].store(ROUTE_CACHE_EMPTY, Ordering::Relaxed);
200 }
201 }
202
203 fn hit_rate(&self) -> f32 {
204 let hits = self.hits.load(Ordering::Relaxed) as f64;
205 let misses = self.misses.load(Ordering::Relaxed) as f64;
206 if hits + misses == 0.0 {
207 0.0
208 } else {
209 (hits / (hits + misses)) as f32
210 }
211 }
212
213 fn collision_count(&self) -> usize {
214 self.collisions.load(Ordering::Relaxed)
215 }
216}
217
218impl Clone for RouteCache {
219 fn clone(&self) -> Self {
220 // Clones start cold: copying hot addresses would also copy
221 // potentially stale group spacing, so the new set warms its own.
222 Self::new()
223 }
224}
225
226impl fmt::Debug for RouteCache {
227 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228 let live = (0..ROUTE_CACHE_SLOTS)
229 .filter(|slot| self.vids[*slot].load(Ordering::Relaxed) != ROUTE_CACHE_EMPTY)
230 .count();
231 f.debug_struct("RouteCache")
232 .field("slots", &ROUTE_CACHE_SLOTS)
233 .field("live", &live)
234 .field("hit_rate", &self.hit_rate())
235 .field("collisions", &self.collision_count())
236 .finish()
237 }
238}
239
240/// Sharded topology container for one edge direction.
241///
242/// Routes every row-addressed operation to the group owning the bound
243/// endpoint. Read paths never create groups; write paths create missing
244/// groups on demand.
245pub struct CsrShardSet {
246 strategy: EdgeStrategy,
247 group_bits: u32,
248 overflow_chunk_edges: usize,
249 /// Record form locked at construction; determines the CsrVariant kind
250 /// created by `fresh_variant`. Immutable after construction.
251 record_form: RecordForm,
252 /// Current hot-path tombstone reuse cutoff, refreshed by the table
253 /// maintenance pass and seeded into every freshly created group.
254 tombstone_reuse_cutoff: Timestamp,
255 shards: BTreeMap<usize, Shard>,
256 /// Hot-row address cache skipping the group map search on repeats.
257 /// Addressing accelerator only: group creation, removal and resharding
258 /// invalidate the affected entries, and misses never populate for
259 /// missing groups, so absent rows still read as empty.
260 route_cache: RouteCache,
261}
262
263impl Clone for CsrShardSet {
264 fn clone(&self) -> Self {
265 Self {
266 strategy: self.strategy,
267 group_bits: self.group_bits,
268 overflow_chunk_edges: self.overflow_chunk_edges,
269 record_form: self.record_form,
270 tombstone_reuse_cutoff: self.tombstone_reuse_cutoff,
271 shards: self.shards.clone(),
272 route_cache: RouteCache::new(),
273 }
274 }
275}
276
277impl fmt::Debug for CsrShardSet {
278 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
279 f.debug_struct("CsrShardSet")
280 .field("strategy", &self.strategy)
281 .field("group_bits", &self.group_bits)
282 .field("overflow_chunk_edges", &self.overflow_chunk_edges)
283 .field("record_form", &self.record_form)
284 .field("tombstone_reuse_cutoff", &self.tombstone_reuse_cutoff)
285 .field("shards", &self.shards)
286 .field("route_cache", &self.route_cache)
287 .finish_non_exhaustive()
288 }
289}
290
291#[cfg(test)]
292mod tests;