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}