Skip to main content

linkrs_storage/edge/node_group/
compaction.rs

1//! Reclaim and compaction surface: per-region physical observation, merge
2//! scope selection, and the compaction passes driven by the reclaim hint.
3//!
4//! The trigger is always the per-row reclaimable count; density only widens
5//! the merge scope, so sparse spans never drag clean rows along.
6
7use linkrs_core::types::{EdgeId, Timestamp};
8
9use super::super::MutableCsrTrait;
10use super::{group_id_for, region_local_range, CsrShardSet};
11
12/// Region density at or above which a dirty region merges as a whole.
13/// Below it only rows holding reclaimable entries are visited.
14///
15/// Source: 0.4 keeps region-wide merges for regions where at least two in
16/// five slots are live, so mostly-dead regions compact row by row instead of
17/// rewriting clean rows. Recommended range 0.3..=0.5. Retuning requires a
18/// region-compaction benchmark proving the wider scope pays for itself.
19pub const REGION_MERGE_MIN_DENSITY: f32 = 0.4;
20/// Group density at or above which a multi-region dirty span merges at
21/// group scope. Below it merges stay region-scoped.
22///
23/// Source: 0.65 reserves group-wide merges for spans that are nearly all
24/// live, where per-region passes would revisit the same dense rows.
25/// Recommended range 0.5..=0.8. Retuning requires a group-compaction
26/// benchmark proving the wider scope pays for itself.
27pub const GROUP_MERGE_MIN_DENSITY: f32 = 0.65;
28
29/// Merge scope selected for a dirty region. The trigger is always the
30/// per-row reclaimable count; density only widens the scope. Larger scopes
31/// require higher density so sparse spans never drag clean rows along.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum RegionMergeScope {
34    /// Only rows holding reclaimable entries are visited.
35    Row,
36    /// The whole dirty region is compacted row by row.
37    Region,
38    /// The dirty span merges at group scope.
39    Group,
40}
41
42impl CsrShardSet {
43    /// Set the reclaim hint for the group owning `vid`.
44    ///
45    /// Used by remap to preserve the hint explicitly instead of relying on
46    /// delete side effects.
47    pub fn mark_reclaim_hint_for(&mut self, vid: u32) {
48        let gid = group_id_for(vid, self.group_bits);
49        if let Some(shard) = self.shards.get_mut(&gid) {
50            shard.reclaim_hint = true;
51        }
52    }
53
54    /// Whether a group may hold tombstones worth a reclaim scan.
55    pub fn group_needs_reclaim_scan(&self, gid: usize) -> bool {
56        self.shards
57            .get(&gid)
58            .map(|shard| shard.reclaim_hint)
59            .unwrap_or(false)
60    }
61
62    /// Physical tombstone entries cached for one group. Zero guarantees the
63    /// group holds no tombstones, so reclaimable walks can stop at O(1).
64    pub fn group_dead_entries(&self, gid: usize) -> usize {
65        self.shards
66            .get(&gid)
67            .map(|shard| shard.dead_entries)
68            .unwrap_or(0)
69    }
70
71    /// Clear the reclaim hint after a pass visited the whole group.
72    pub fn clear_reclaim_hint(&mut self, gid: usize) {
73        if let Some(shard) = self.shards.get_mut(&gid) {
74            shard.reclaim_hint = false;
75        }
76    }
77
78    /// Account for tombstones dropped by a compaction pass. Clears the
79    /// reclaim hint once the cached count reaches zero, so later passes skip
80    /// the group without paying one audit walk to rediscover it is clean.
81    fn note_group_reclaimed(&mut self, gid: usize, removed: usize) {
82        if removed == 0 {
83            return;
84        }
85        if let Some(shard) = self.shards.get_mut(&gid) {
86            shard.dead_entries = shard.dead_entries.saturating_sub(removed);
87            if shard.dead_entries == 0 {
88                shard.reclaim_hint = false;
89            }
90        }
91    }
92
93    /// Physical census of one leaf region: `(live, dead, capacity)` summed
94    /// over its rows in global-vid order.
95    pub fn region_census(&self, gid: usize, region: usize) -> (usize, usize, usize) {
96        let Some(shard) = self.shards.get(&gid) else {
97            return (0, 0, 0);
98        };
99        let (start, end) = region_local_range(region, self.group_size());
100        let mut live = 0usize;
101        let mut dead = 0usize;
102        let mut capacity = 0usize;
103        for local in start..end {
104            let (l, d, c) = shard.variant.vertex_census(local);
105            live += l;
106            dead += d;
107            capacity += c;
108        }
109        (live, dead, capacity)
110    }
111
112    /// Live entries per unit of reserved capacity of one leaf region.
113    pub fn region_density(&self, gid: usize, region: usize) -> f32 {
114        let (live, _, capacity) = self.region_census(gid, region);
115        if capacity == 0 {
116            1.0
117        } else {
118            live as f32 / capacity as f32
119        }
120    }
121
122    /// Live entries of one leaf region summed over its rows.
123    pub fn region_live(&self, gid: usize, region: usize) -> usize {
124        self.region_census(gid, region).0
125    }
126
127    /// Reserved capacity minus live entries of one leaf region.
128    ///
129    /// Steady-state write room under the current per-row reservation; the
130    /// region-tail gap model derives the same room from the live count alone.
131    pub fn region_gap(&self, gid: usize, region: usize) -> usize {
132        let (live, _, capacity) = self.region_census(gid, region);
133        capacity.saturating_sub(live)
134    }
135
136    /// Entries of one leaf region reclaimable at `cutoff`.
137    pub fn region_reclaimable_count(&self, gid: usize, region: usize, cutoff: Timestamp) -> usize {
138        if cutoff == Timestamp::MAX {
139            return 0;
140        }
141        let Some(shard) = self.shards.get(&gid) else {
142            return 0;
143        };
144        // A zero cached tombstone count guarantees no reclaimable entry, so
145        // merge-scope selection over clean groups pays O(1) per region
146        // instead of one cold-line walk per row.
147        if shard.dead_entries == 0 {
148            return 0;
149        }
150        let (start, end) = region_local_range(region, self.group_size());
151        (start..end)
152            .map(|local| shard.variant.reclaimable_count(local, cutoff))
153            .sum()
154    }
155
156    /// Merge scope for one leaf region. The trigger is always the per-row
157    /// reclaimable count; density only widens the scope, and larger scopes
158    /// require higher density. Returns `None` when the region holds nothing
159    /// reclaimable and carries no delete dirt. Thresholds come from the
160    /// table configuration so measured write amplification can guide them;
161    /// the module constants back the configuration defaults.
162    pub fn select_merge_scope(
163        &self,
164        gid: usize,
165        region: usize,
166        cutoff: Timestamp,
167        region_min_density: f32,
168        group_min_density: f32,
169    ) -> Option<RegionMergeScope> {
170        if self.region_reclaimable_count(gid, region, cutoff) == 0
171            && !self.region_needs_rebalance(gid, region)
172        {
173            return None;
174        }
175        let region_density = self.region_density(gid, region);
176        if region_density < region_min_density {
177            return Some(RegionMergeScope::Row);
178        }
179        let group_live = self
180            .group_stats(gid)
181            .map(|stats| {
182                if stats.capacity == 0 {
183                    1.0
184                } else {
185                    stats.live_edges as f32 / stats.capacity as f32
186                }
187            })
188            .unwrap_or(1.0);
189        if self.dirty_region_ids(gid).len() > 1 && group_live >= group_min_density {
190            return Some(RegionMergeScope::Group);
191        }
192        Some(RegionMergeScope::Region)
193    }
194
195    /// Compact one leaf region in place, reporting removals. Only rows in
196    /// the region window are visited; other regions never move. Marks the
197    /// region deleted when entries were dropped so the next checkpoint
198    /// merges the rebuilt rows. Rows holding overflow are rebalanced into
199    /// primary gaps on the same pass. Frozen groups bypass the per-row walk
200    /// and merge once at group scope so one region request never pays one
201    /// trailing memmove per row.
202    pub fn compact_region_with_reporting(
203        &mut self,
204        gid: usize,
205        region: usize,
206        cutoff: Timestamp,
207        on_edge_removed: &mut dyn FnMut(EdgeId, Timestamp),
208    ) -> usize {
209        if self.is_frozen(gid) {
210            return self.compact_group_with_reporting(gid, cutoff, 0.0, on_edge_removed);
211        }
212        let group_size = self.group_size();
213        let (start, end) = region_local_range(region, group_size);
214        let Some(shard) = self.shards.get_mut(&gid) else {
215            return 0;
216        };
217        let mut removed = 0usize;
218        for local in start..end {
219            removed += shard
220                .variant
221                .compact_vertex_with_reporting(local, cutoff, on_edge_removed);
222            if shard.variant.row_gap(local) == 0 {
223                shard.variant.rebalance_row(local);
224            }
225        }
226        if removed > 0 {
227            shard.dirty.deleted = true;
228            if let Some(slot) = shard.regions.get_mut(region) {
229                slot.deleted = true;
230            }
231            shard.dead_entries = shard.dead_entries.saturating_sub(removed);
232            if shard.dead_entries == 0 {
233                shard.reclaim_hint = false;
234            }
235        }
236        removed
237    }
238
239    /// Reclaim a listed local-row set of one frozen group in one pass.
240    ///
241    /// Collect-then-merge entry for frozen groups: callers gather reclaimable
242    /// rows across regions and merge once instead of paying one trailing
243    /// memmove per row. Mutable groups report zero; their rows use the
244    /// regular per-row path. Marks the group deleted when entries drop so
245    /// the next checkpoint persists the rebuilt base.
246    pub fn compact_frozen_rows_batched(
247        &mut self,
248        gid: usize,
249        locals: &[u32],
250        cutoff: Timestamp,
251        on_edge_removed: &mut dyn FnMut(EdgeId, Timestamp),
252    ) -> usize {
253        let removed = match self.shards.get_mut(&gid) {
254            Some(shard) => {
255                let removed =
256                    shard
257                        .variant
258                        .compact_frozen_rows_batched(locals, cutoff, on_edge_removed);
259                if removed > 0 {
260                    shard.dirty.deleted = true;
261                    for region in shard.regions.iter_mut() {
262                        region.deleted = true;
263                    }
264                }
265                removed
266            }
267            None => return 0,
268        };
269        self.note_group_reclaimed(gid, removed);
270        removed
271    }
272
273    /// Compact one group in place, reporting removals. Marks the group
274    /// dirty so the next checkpoint persists the rebuilt rows.
275    pub fn compact_group_with_reporting(
276        &mut self,
277        gid: usize,
278        cutoff: Timestamp,
279        reserve_ratio: f32,
280        on_edge_removed: &mut dyn FnMut(EdgeId, Timestamp),
281    ) -> usize {
282        let removed = match self.shards.get_mut(&gid) {
283            Some(shard) => {
284                let removed =
285                    shard
286                        .variant
287                        .compact_with_ts_reporting(cutoff, reserve_ratio, on_edge_removed);
288                if removed > 0 {
289                    shard.dirty.deleted = true;
290                    for region in shard.regions.iter_mut() {
291                        region.deleted = true;
292                    }
293                }
294                removed
295            }
296            None => return 0,
297        };
298        self.note_group_reclaimed(gid, removed);
299        removed
300    }
301
302    /// Compact with per-edge removal reporting across all groups.
303    /// Only groups that actually dropped entries are marked dirty so clean
304    /// groups are never dragged into the next checkpoint.
305    pub fn compact_with_ts_reporting(
306        &mut self,
307        cutoff: Timestamp,
308        reserve_ratio: f32,
309        on_edge_removed: &mut dyn FnMut(EdgeId, Timestamp),
310    ) -> usize {
311        let mut removed = 0usize;
312        for shard in self.shards.values_mut() {
313            let before = removed;
314            removed +=
315                shard
316                    .variant
317                    .compact_with_ts_reporting(cutoff, reserve_ratio, on_edge_removed);
318            let delta = removed - before;
319            if delta > 0 {
320                shard.dirty.deleted = true;
321                for region in shard.regions.iter_mut() {
322                    region.deleted = true;
323                }
324                shard.dead_entries = shard.dead_entries.saturating_sub(delta);
325                if shard.dead_entries == 0 {
326                    shard.reclaim_hint = false;
327                }
328            }
329        }
330        removed
331    }
332}