Skip to main content

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;