Skip to main content

linkrs_storage/edge/edge_table/
compaction.rs

1//! Compaction: per-region CSR rebuilds and property slot reclamation.
2//!
3//! All cutoffs derive from one per-pass `MvccWatermarks` capture plus margin;
4//! eligibility uses `Visibility::is_gc_eligible`. Reclaim passes skip groups
5//! without delete history; flush-time rebuilds touch only dirty regions at a
6//! density-tiered scope (row, region, group). Whole-table and whole-group
7//! fragmentation ratios stay observation metrics; only group-scope merges
8//! consult the caller fragmentation gate.
9
10use super::core::EdgeStore;
11use super::stats::DeletionStats;
12use crate::edge::csr_trait::{CsrBase, MutableCsrTrait};
13use linkrs_core::types::Timestamp;
14use linkrs_core::StorageResult;
15
16/// Upper bound of rows reclaimed in one write-path maintenance pass, so a
17/// small write never triggers a large rebuild pause. Recommended range
18/// 16..=64 from the reclaim pause versus throughput tradeoff; tune against
19/// checkpoint flushed bytes per live edge, see `docs/plan/csr_baseline_notes.md`.
20pub(crate) const MAX_VERTEX_RECLAIM_PER_PASS: usize = 32;
21
22impl EdgeStore {
23    /// Whether any group is fragmented enough to justify a rebuild.
24    ///
25    /// Scans each group with its own capacity/live ratio; the whole-table
26    /// ratio stays an observation metric and never triggers collection.
27    /// A fragmented clean group still triggers, so a dirty group cannot
28    /// drag clean groups into the rebuild set.
29    pub fn has_fragmented_group(&self, threshold: f32) -> bool {
30        for gid in self.out_csr.existing_group_ids() {
31            if self
32                .out_csr
33                .group_variant(gid)
34                .is_some_and(|variant| variant.fragmentation_ratio() >= threshold)
35            {
36                return true;
37            }
38        }
39        for gid in self.in_csr.existing_group_ids() {
40            if self
41                .in_csr
42                .group_variant(gid)
43                .is_some_and(|variant| variant.fragmentation_ratio() >= threshold)
44            {
45                return true;
46            }
47        }
48        false
49    }
50
51    /// Compact every group, sharing one pass cutoff across all sub-systems.
52    /// Deletions are promoted into the tombstone layer. Explicit maintenance
53    /// only; the write path uses the bounded reclaim pass below.
54    ///
55    /// Fail-closed on authority drift like the load and freeze gates: a
56    /// nonzero audit refuses the pass instead of compacting a diverged table
57    /// into new files.
58    pub fn compact_csr_only_with_watermarks(
59        &mut self,
60        watermarks: &linkrs_transaction::MvccWatermarks,
61        margin: Timestamp,
62        reserve_ratio: f32,
63    ) -> StorageResult<usize> {
64        let cutoff = watermarks.safe_gc_timestamp_with_margin(margin);
65        // The explicit pass refreshes the reuse hint from its own fresh
66        // capture; a disabled sentinel propagates as disabled.
67        if cutoff != Timestamp::MAX {
68            self.out_csr.refresh_tombstone_reuse_cutoff(cutoff);
69            self.in_csr.refresh_tombstone_reuse_cutoff(cutoff);
70        } else {
71            self.out_csr.clear_tombstone_reuse_cutoff();
72            self.in_csr.clear_tombstone_reuse_cutoff();
73        }
74        let mut removed_edges = crate::edge::csr_shared::EdgeIdBitSet::default();
75        for gid in self.out_csr.existing_group_ids() {
76            self.out_csr.compact_group_with_reporting(
77                gid,
78                cutoff,
79                reserve_ratio,
80                &mut |edge_id, delete_ts| {
81                    removed_edges.insert(edge_id);
82                    self.mvcc.record_deletion(edge_id, delete_ts);
83                },
84            );
85        }
86        for gid in self.in_csr.existing_group_ids() {
87            self.in_csr.compact_group_with_reporting(
88                gid,
89                cutoff,
90                reserve_ratio,
91                &mut |edge_id, delete_ts| {
92                    removed_edges.insert(edge_id);
93                    self.mvcc.record_deletion(edge_id, delete_ts);
94                },
95            );
96        }
97        let drift = self.audit_copy_drift();
98        if !drift.is_empty() {
99            return Err(linkrs_core::StorageError::data_corruption(format!(
100                "compact_csr_only drift after rebuild: {}",
101                drift.join("; ")
102            )));
103        }
104        log::debug!(
105            "compact_csr_only reclaimed {} edges (removal bitmap {} bytes)",
106            removed_edges.len(),
107            removed_edges.memory_bytes()
108        );
109        Ok(removed_edges.len())
110    }
111
112    /// Reclaim rows holding entries eligible at `bound`, visiting at most
113    /// `max_vertices` rows.
114    ///
115    /// Groups without delete history are skipped without per-row
116    /// inspection, so the pause stays proportional to the dirty groups
117    /// rather than the size of the table. Each visited row pays a single
118    /// fused dead/reclaimable probe instead of a census plus an eligibility
119    /// walk. Frozen groups merge once per direction through the batched
120    /// frozen path instead of one trailing memmove per row.
121    pub fn compact_reclaimable_vertices(&mut self, bound: Timestamp, max_vertices: usize) -> usize {
122        if bound == Timestamp::MAX || max_vertices == 0 {
123            return 0;
124        }
125        let mut gids: Vec<usize> = self.out_csr.existing_group_ids();
126        for gid in self.in_csr.existing_group_ids() {
127            if !gids.contains(&gid) {
128                gids.push(gid);
129            }
130        }
131        gids.sort_unstable();
132        let group_bits = self.out_csr.group_bits();
133        let mut removed_edges = crate::edge::csr_shared::EdgeIdBitSet::default();
134        let mut visited = 0usize;
135        for gid in gids {
136            if visited >= max_vertices {
137                break;
138            }
139            let out_scan = self.out_csr.group_needs_reclaim_scan(gid);
140            let in_scan = self.in_csr.group_needs_reclaim_scan(gid);
141            if !out_scan && !in_scan {
142                continue;
143            }
144            let out_frozen = self.out_csr.is_frozen(gid);
145            let in_frozen = self.in_csr.is_frozen(gid);
146            if out_frozen || in_frozen {
147                let reclaimed = self.compact_frozen_group_rows(
148                    gid,
149                    bound,
150                    max_vertices - visited,
151                    out_scan && out_frozen,
152                    in_scan && in_frozen,
153                    &mut removed_edges,
154                );
155                visited += reclaimed;
156                let mutable_work_left = (out_scan && !out_frozen) || (in_scan && !in_frozen);
157                if !mutable_work_left {
158                    if self.frozen_group_has_no_dead(gid, bound) {
159                        if out_scan && out_frozen {
160                            self.out_csr.clear_reclaim_hint(gid);
161                        }
162                        if in_scan && in_frozen {
163                            self.in_csr.clear_reclaim_hint(gid);
164                        }
165                    }
166                    continue;
167                }
168            }
169            let rows = crate::edge::node_group::group_size(group_bits);
170            let base = crate::edge::node_group::group_base(gid, group_bits);
171            let mut group_done = true;
172            let mut any_dead = false;
173            let out_row_scan = out_scan && !self.out_csr.is_frozen(gid);
174            let in_row_scan = in_scan && !self.in_csr.is_frozen(gid);
175            for local in 0..rows {
176                if visited >= max_vertices {
177                    group_done = false;
178                    break;
179                }
180                let vid = base.saturating_add(local as u32);
181                let (out_dead, out_reclaimable) = if out_row_scan {
182                    self.out_csr.vertex_reclaim_probe(vid, bound)
183                } else {
184                    (0, 0)
185                };
186                let (in_dead, in_reclaimable) = if in_row_scan {
187                    self.in_csr.vertex_reclaim_probe(vid, bound)
188                } else {
189                    (0, 0)
190                };
191                any_dead |= out_dead + in_dead > 0;
192                if out_reclaimable + in_reclaimable == 0 {
193                    continue;
194                }
195                visited += 1;
196                self.out_csr.compact_vertex_with_reporting(
197                    vid,
198                    bound,
199                    &mut |edge_id, delete_ts| {
200                        removed_edges.insert(edge_id);
201                        self.mvcc.record_deletion(edge_id, delete_ts);
202                    },
203                );
204                self.in_csr
205                    .compact_vertex_with_reporting(vid, bound, &mut |edge_id, delete_ts| {
206                        removed_edges.insert(edge_id);
207                        self.mvcc.record_deletion(edge_id, delete_ts);
208                    });
209            }
210            // Keep the hint while any tombstone physically remains, even when
211            // the current bound covers nothing yet. Clearing early would drop
212            // tail groups that a later watermark could still reclaim.
213            if group_done && !any_dead {
214                if out_scan {
215                    self.out_csr.clear_reclaim_hint(gid);
216                }
217                if in_scan {
218                    self.in_csr.clear_reclaim_hint(gid);
219                }
220            }
221        }
222        log::debug!(
223            "compact_reclaimable_vertices reclaimed {} edges (removal bitmap {} bytes)",
224            removed_edges.len(),
225            removed_edges.memory_bytes()
226        );
227        removed_edges.len()
228    }
229
230    /// Collect-then-merge reclaim for frozen groups owning `gid`.
231    ///
232    /// Gathers up to `budget` reclaimable local rows across the requested
233    /// frozen directions and merges each direction once through the batched
234    /// frozen path. Returns the collected row count against the caller row
235    /// budget, not the removed edge count. Mutable directions are ignored;
236    /// their rows use the regular per-row path.
237    fn compact_frozen_group_rows(
238        &mut self,
239        gid: usize,
240        bound: Timestamp,
241        budget: usize,
242        do_out: bool,
243        do_in: bool,
244        removed_edges: &mut crate::edge::csr_shared::EdgeIdBitSet,
245    ) -> usize {
246        if bound == Timestamp::MAX || budget == 0 || (!do_out && !do_in) {
247            return 0;
248        }
249        let group_bits = self.out_csr.group_bits();
250        let rows = crate::edge::node_group::group_size(group_bits);
251        let base = crate::edge::node_group::group_base(gid, group_bits);
252        let mut collected = 0usize;
253        if do_out {
254            let mut locals = Vec::new();
255            for local in 0..rows {
256                if collected >= budget {
257                    break;
258                }
259                let vid = base.saturating_add(local as u32);
260                let (_, reclaimable) = self.out_csr.vertex_reclaim_probe(vid, bound);
261                if reclaimable > 0 {
262                    locals.push(local as u32);
263                    collected += 1;
264                }
265            }
266            if !locals.is_empty() {
267                self.out_csr.compact_frozen_rows_batched(
268                    gid,
269                    &locals,
270                    bound,
271                    &mut |edge_id, delete_ts| {
272                        removed_edges.insert(edge_id);
273                        self.mvcc.record_deletion(edge_id, delete_ts);
274                    },
275                );
276            }
277        }
278        if do_in {
279            let mut locals = Vec::new();
280            for local in 0..rows {
281                if collected >= budget {
282                    break;
283                }
284                let vid = base.saturating_add(local as u32);
285                let (_, reclaimable) = self.in_csr.vertex_reclaim_probe(vid, bound);
286                if reclaimable > 0 {
287                    locals.push(local as u32);
288                    collected += 1;
289                }
290            }
291            if !locals.is_empty() {
292                self.in_csr.compact_frozen_rows_batched(
293                    gid,
294                    &locals,
295                    bound,
296                    &mut |edge_id, delete_ts| {
297                        removed_edges.insert(edge_id);
298                        self.mvcc.record_deletion(edge_id, delete_ts);
299                    },
300                );
301            }
302        }
303        collected
304    }
305
306    /// Whether frozen groups owning `gid` hold no physical tombstones.
307    ///
308    /// Hint-clearing probe for the frozen path: scans the group rows for any
309    /// dead entry, eligible or not, so the reclaim hint survives while any
310    /// tombstone physically remains.
311    fn frozen_group_has_no_dead(&self, gid: usize, bound: Timestamp) -> bool {
312        let group_bits = self.out_csr.group_bits();
313        let rows = crate::edge::node_group::group_size(group_bits);
314        let base = crate::edge::node_group::group_base(gid, group_bits);
315        for local in 0..rows {
316            let vid = base.saturating_add(local as u32);
317            if self.out_csr.is_frozen(gid) {
318                let (dead, _) = self.out_csr.vertex_reclaim_probe(vid, bound);
319                if dead > 0 {
320                    return false;
321                }
322            }
323            if self.in_csr.is_frozen(gid) {
324                let (dead, _) = self.in_csr.vertex_reclaim_probe(vid, bound);
325                if dead > 0 {
326                    return false;
327                }
328            }
329        }
330        true
331    }
332
333    /// Write-path incremental reclaim pass.
334    ///
335    /// Gated by tombstone count and watermark movement: below the count
336    /// threshold with an unchanged watermark there is nothing newly
337    /// reclaimable, so the commit skips the group scan entirely. A watermark
338    /// advance or heap growth past the last pass re-arms it, and a full heap
339    /// always scans. Insert-only workloads therefore pay no scan cost.
340    ///
341    /// This pass never refreshes the hot-path reuse hint: `bound` may come
342    /// from the table-local pin cache, which can sit above the global safe
343    /// point. Only watermark captures refresh the hint (see
344    /// [`Self::maybe_run_auto_maintenance_with_watermarks`],
345    /// [`Self::compact_csr_only_with_watermarks`] and
346    /// [`Self::maybe_compact_for_flush_with_watermarks`]); otherwise a stale
347    /// local bound would widen reuse past what reclaim has proven.
348    pub(crate) fn run_vertex_reclaim_pass(&mut self, bound: Timestamp) -> bool {
349        if bound == Timestamp::MAX {
350            return false;
351        }
352        let threshold = self.config.auto_maintenance.reclaim_tombstone_threshold;
353        let tombstones = self.mvcc.total_tombstone_count();
354        if tombstones == 0
355            || (tombstones < threshold
356                && bound == self.last_reclaim_bound
357                && tombstones <= self.last_reclaim_tombstones)
358        {
359            return false;
360        }
361        let reclaimed = self.compact_reclaimable_vertices(bound, MAX_VERTEX_RECLAIM_PER_PASS) > 0;
362        self.last_reclaim_bound = bound;
363        self.last_reclaim_tombstones = tombstones;
364        reclaimed
365    }
366
367    /// Rebuild dense dirty regions before flush, sharing the pass cutoff.
368    /// Clean regions are skipped, so a small dirty set never triggers a
369    /// large rebuild pause.
370    ///
371    /// Scope is tiered per region: rows holding reclaimable entries are the
372    /// only trigger, density only widens the scope from row to region to
373    /// group, and larger scopes require higher density. Row gaps kept at the
374    /// packed density target never trigger a rebuild on their own; only
375    /// tombstone dirt does.
376    pub fn maybe_compact_for_flush_with_watermarks(
377        &mut self,
378        watermarks: &linkrs_transaction::MvccWatermarks,
379        margin: Timestamp,
380        threshold: f32,
381    ) {
382        // Row-gap reserve derived from the packed-row density target, so
383        // rebuilt rows keep everyday-write gaps instead of packing full.
384        const RESERVE_RATIO: f32 = 1.0 - crate::edge::mutable_csr::PACKED_CSR_DENSITY;
385        let cutoff = watermarks.safe_gc_timestamp_with_margin(margin);
386        // The flush entry also refreshes the reuse hint from its own fresh
387        // capture: flush-time compactions may run without a preceding
388        // write-path reclaim pass. A disabled sentinel clears reuse instead
389        // of running on an expired bound.
390        if cutoff != Timestamp::MAX {
391            self.out_csr.refresh_tombstone_reuse_cutoff(cutoff);
392            self.in_csr.refresh_tombstone_reuse_cutoff(cutoff);
393        } else {
394            self.out_csr.clear_tombstone_reuse_cutoff();
395            self.in_csr.clear_tombstone_reuse_cutoff();
396        }
397        // Group-scope merges stay behind the caller fragmentation gate so a
398        // small flush never triggers an unbounded rebuild; row and region
399        // scopes trigger on per-row reclaimable counts alone.
400        let group_merge_allowed = |ratio: f32| ratio >= threshold;
401        for outgoing in [true, false] {
402            let existing: Vec<usize> = if outgoing {
403                self.out_csr.existing_group_ids()
404            } else {
405                self.in_csr.existing_group_ids()
406            };
407            let group_size = if outgoing {
408                self.out_csr.group_size()
409            } else {
410                self.in_csr.group_size()
411            };
412            let regions = crate::edge::node_group::regions_per_group(group_size);
413            for gid in existing {
414                for rid in 0..regions {
415                    let shards = if outgoing {
416                        &self.out_csr
417                    } else {
418                        &self.in_csr
419                    };
420                    let scope = shards.select_merge_scope(
421                        gid,
422                        rid,
423                        cutoff,
424                        self.config.region_merge_min_density,
425                        self.config.group_merge_min_density,
426                    );
427                    match scope {
428                        None => continue,
429                        Some(crate::edge::node_group::RegionMergeScope::Group) => {
430                            let ratio = if outgoing {
431                                self.out_csr
432                                    .group_variant(gid)
433                                    .map(|variant| {
434                                        variant
435                                            .fragmentation_stats()
436                                            .map_or(0.0, |stats| stats.fragmentation_ratio())
437                                    })
438                                    .unwrap_or(0.0)
439                            } else {
440                                self.in_csr
441                                    .group_variant(gid)
442                                    .map(|variant| {
443                                        variant
444                                            .fragmentation_stats()
445                                            .map_or(0.0, |stats| stats.fragmentation_ratio())
446                                    })
447                                    .unwrap_or(0.0)
448                            };
449                            if !group_merge_allowed(ratio) {
450                                let shards = if outgoing {
451                                    &mut self.out_csr
452                                } else {
453                                    &mut self.in_csr
454                                };
455                                shards.compact_region_with_reporting(
456                                    gid,
457                                    rid,
458                                    cutoff,
459                                    &mut |edge_id, delete_ts| {
460                                        self.mvcc.record_deletion(edge_id, delete_ts)
461                                    },
462                                );
463                                continue;
464                            }
465                            let shards = if outgoing {
466                                &mut self.out_csr
467                            } else {
468                                &mut self.in_csr
469                            };
470                            shards.compact_group_with_reporting(
471                                gid,
472                                cutoff,
473                                RESERVE_RATIO,
474                                &mut |edge_id, delete_ts| {
475                                    self.mvcc.record_deletion(edge_id, delete_ts)
476                                },
477                            );
478                            break;
479                        }
480                        Some(crate::edge::node_group::RegionMergeScope::Region) => {
481                            let shards = if outgoing {
482                                &mut self.out_csr
483                            } else {
484                                &mut self.in_csr
485                            };
486                            shards.compact_region_with_reporting(
487                                gid,
488                                rid,
489                                cutoff,
490                                &mut |edge_id, delete_ts| {
491                                    self.mvcc.record_deletion(edge_id, delete_ts)
492                                },
493                            );
494                        }
495                        Some(crate::edge::node_group::RegionMergeScope::Row) => {
496                            let (start, end) =
497                                crate::edge::node_group::region_local_range(rid, group_size);
498                            let group_bits = if outgoing {
499                                self.out_csr.group_bits()
500                            } else {
501                                self.in_csr.group_bits()
502                            };
503                            let base = crate::edge::node_group::group_base(gid, group_bits);
504                            let frozen = if outgoing {
505                                self.out_csr.is_frozen(gid)
506                            } else {
507                                self.in_csr.is_frozen(gid)
508                            };
509                            if frozen {
510                                let mut locals = Vec::new();
511                                for local in start..end {
512                                    let vid = base.saturating_add(local);
513                                    let needs = if outgoing {
514                                        self.out_csr.vertex_needs_compact(vid, cutoff)
515                                    } else {
516                                        self.in_csr.vertex_needs_compact(vid, cutoff)
517                                    };
518                                    if needs {
519                                        locals.push(local);
520                                    }
521                                }
522                                if !locals.is_empty() {
523                                    if outgoing {
524                                        self.out_csr.compact_frozen_rows_batched(
525                                            gid,
526                                            &locals,
527                                            cutoff,
528                                            &mut |edge_id, delete_ts| {
529                                                self.mvcc.record_deletion(edge_id, delete_ts)
530                                            },
531                                        );
532                                    } else {
533                                        self.in_csr.compact_frozen_rows_batched(
534                                            gid,
535                                            &locals,
536                                            cutoff,
537                                            &mut |edge_id, delete_ts| {
538                                                self.mvcc.record_deletion(edge_id, delete_ts)
539                                            },
540                                        );
541                                    }
542                                }
543                                continue;
544                            }
545                            for local in start..end {
546                                let vid = base.saturating_add(local);
547                                let needs = if outgoing {
548                                    self.out_csr.vertex_needs_compact(vid, cutoff)
549                                } else {
550                                    self.in_csr.vertex_needs_compact(vid, cutoff)
551                                };
552                                if !needs {
553                                    continue;
554                                }
555                                if outgoing {
556                                    self.out_csr.compact_vertex_with_reporting(
557                                        vid,
558                                        cutoff,
559                                        &mut |edge_id, delete_ts| {
560                                            self.mvcc.record_deletion(edge_id, delete_ts)
561                                        },
562                                    );
563                                } else {
564                                    self.in_csr.compact_vertex_with_reporting(
565                                        vid,
566                                        cutoff,
567                                        &mut |edge_id, delete_ts| {
568                                            self.mvcc.record_deletion(edge_id, delete_ts)
569                                        },
570                                    );
571                                }
572                            }
573                        }
574                    }
575                }
576            }
577        }
578    }
579
580    /// Compact properties by reclaiming slots of unreferenced dead records.
581    ///
582    /// Live rows keep their positions, so edge-to-row mappings never need
583    /// remapping.
584    pub fn compact_properties_with_watermarks(
585        &mut self,
586        watermarks: &linkrs_transaction::MvccWatermarks,
587        margin: Timestamp,
588    ) {
589        let bound = watermarks.safe_gc_timestamp_with_margin(margin);
590        self.compact_properties(bound);
591    }
592
593    /// Reclaim authority tombstones below a global watermark.
594    ///
595    /// Only entries whose topology rows are gone in both directions and whose
596    /// property row is gone are removed, and only when the deletion timestamp
597    /// is below the watermark-derived cutoff. The cutoff comes from the
598    /// shared watermark capture, never from the table-local pin. A nonzero
599    /// cross-copy audit fails closed so dangling references never lose
600    /// their authority record.
601    pub fn reclaim_authority_with_watermarks(
602        &mut self,
603        watermarks: &linkrs_transaction::MvccWatermarks,
604        margin: Timestamp,
605    ) -> StorageResult<usize> {
606        use linkrs_core::StorageError;
607        let cutoff = watermarks.safe_gc_timestamp_with_margin(margin);
608        if cutoff == Timestamp::MAX {
609            return Ok(0);
610        }
611        let (orphan_mappings, orphan_csr_rows, live_orphans) = self.copy_audit();
612        if orphan_mappings + orphan_csr_rows + live_orphans > 0 {
613            // The refusal is observable, not just logged: the same three
614            // counters the load path reports are emitted here so the
615            // watermark-driven background pass doubles as the periodic
616            // authority-versus-projection audit.
617            if let Some(stats) = &self.stats_manager {
618                stats.add_value_with_amount(
619                    linkrs_metrics::MetricType::EdgeOrphanMappings,
620                    orphan_mappings as u64,
621                );
622                stats.add_value_with_amount(
623                    linkrs_metrics::MetricType::EdgeOrphanRows,
624                    orphan_csr_rows as u64,
625                );
626                stats.add_value_with_amount(
627                    linkrs_metrics::MetricType::EdgeLiveAuthorityOrphans,
628                    live_orphans as u64,
629                );
630            }
631            return Err(StorageError::data_corruption(format!(
632                "reclaim_authority: audit nonzero (mappings={}, csr_rows={}, live_orphans={}), refusing reclaim",
633                orphan_mappings, orphan_csr_rows, live_orphans
634            )));
635        }
636        // One bit per allocated id instead of a hash set per live edge:
637        // the same single CSR pass feeds the check at a fraction of the
638        // peak memory.
639        let mut live_topology =
640            crate::edge::csr_shared::EdgeIdBitSet::with_max_id(self.next_edge_id.0);
641        for (_, nbr) in self.out_csr.iter_all().chain(self.in_csr.iter_all()) {
642            live_topology.insert(nbr.edge_id);
643        }
644        let properties = &self.properties;
645        Ok(self.mvcc.reclaim_below(cutoff, |edge_id| {
646            !live_topology.contains(&edge_id) && !properties.contains_edge(edge_id)
647        }))
648    }
649
650    pub fn compact_properties(&mut self, bound: Timestamp) {
651        let mut valid_edge_ids =
652            crate::edge::csr_shared::EdgeIdBitSet::with_max_id(self.next_edge_id.0);
653        for (edge_id, _pos) in self.properties.edge_mappings() {
654            // Authoritative visibility, not the tombstone table alone: a
655            // tombstone reclaimed by an earlier GC round must not resurrect
656            // its edge as live here.
657            if self.mvcc.is_edge_visible(edge_id, bound) {
658                valid_edge_ids.insert(edge_id);
659            }
660        }
661
662        let reclaimed = self.properties.reclaim_slots(&valid_edge_ids, bound);
663        if reclaimed > 0 {
664            // Reclaimed rows span owner groups: trace every owner so the
665            // shrunken row sets reach their shards instead of relying on
666            // unrelated write traces.
667            self.trace_all_owner_groups_for_columns(&[]);
668            log::debug!("Property slot reclaim recycled {} row(s)", reclaimed);
669        }
670    }
671
672    /// Get deletion statistics for the sharded table.
673    pub fn deletion_stats(&self) -> DeletionStats {
674        let stats = self.mvcc.tombstone_stats();
675        DeletionStats {
676            total_live_edges: self.out_csr.edge_count(),
677            total_deleted_edges: stats.count as u64,
678        }
679    }
680}