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}