Skip to main content

linkrs_storage/edge/
fragmentation_stats.rs

1//! Fragmentation statistics and observability for CSR structures.
2//!
3//! Tracks waste from row gaps and tombstoned entries:
4//! - Primary rows reserve gaps for everyday writes; the unused slots are waste.
5//! - Deleted entries stay physically present until the collection cutoff
6//!   passes; those tombstone slots are waste as well.
7//! - Overflow chunks are appended per vertex and repacked per vertex; empty
8//!   chunks are detached immediately, so no unreachable-block accounting
9//!   remains.
10//!
11//! # Collection
12//!
13//! Per-vertex counts drive incremental collection: a vertex is a candidate
14//! exactly when it holds entries eligible at the current cutoff. Whole-table
15//! ratios below are observation metrics, not collection triggers.
16
17/// Group merge gate on the wasted-share caliber: a group whose wasted share
18/// reaches this level is worth a group-scope merge. Row and region scopes
19/// trigger on per-vertex reclaimable counts alone.
20pub const GROUP_FRAGMENTATION_THRESHOLD: f32 = 0.5;
21
22#[derive(Debug, Clone, Copy)]
23pub struct FragmentationStats {
24    /// Total reserved capacity (primary rows plus overflow chunks).
25    pub total_capacity: usize,
26    /// Number of live edges.
27    pub reachable_edges: usize,
28    /// Number of dead entries awaiting collection (primary tombstones plus
29    /// overflow dead entries).
30    pub dead_entries: usize,
31    /// Reserved capacity minus live edges (row gaps plus tombstone slots).
32    pub wasted_capacity: usize,
33}
34
35impl FragmentationStats {
36    /// Compute detailed fragmentation stats from edge counts.
37    pub fn with_dead_info(
38        total_capacity: usize,
39        reachable_edges: usize,
40        dead_entries: usize,
41        wasted_capacity: usize,
42    ) -> Self {
43        Self {
44            total_capacity,
45            reachable_edges,
46            dead_entries,
47            wasted_capacity,
48        }
49    }
50
51    /// Fragmentation ratio: wasted_capacity / total_capacity.
52    ///
53    /// - 0.0 = no fragmentation (perfect packing)
54    /// - 0.5 = 50% wasted space
55    /// - 1.0 = all reserved capacity is waste
56    ///
57    /// Observation metric for dashboards; collection triggers use the
58    /// per-vertex reclaimable counts instead.
59    pub fn fragmentation_ratio(&self) -> f32 {
60        if self.total_capacity == 0 {
61            0.0
62        } else {
63            self.wasted_capacity as f32 / self.total_capacity as f32
64        }
65    }
66
67    /// Measured bytes per edge with no fallback.
68    ///
69    /// Empty tables report zero; degenerate zero measurements report zero.
70    /// Callers handle zero explicitly instead of relying on estimates.
71    pub fn measured_bytes_per_edge(used_bytes: usize, edges: u64) -> usize {
72        if edges == 0 {
73            return 0;
74        }
75        let bpe = used_bytes / edges as usize;
76        if bpe == 0 {
77            log::debug!(
78                "bytes_per_edge: measured zero ({} bytes / {} edges), reporting zero",
79                used_bytes,
80                edges,
81            );
82        }
83        bpe
84    }
85}
86
87/// Per-vertex fragmentation view driving incremental collection.
88///
89/// A vertex is a collection candidate exactly when `reclaimable` is
90/// nonzero: it holds tombstones the current cutoff already covers.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub struct VertexFragmentation {
93    /// Row under observation.
94    pub vertex: u32,
95    /// Live entries of the row.
96    pub live_edges: usize,
97    /// Tombstoned entries of the row, regardless of eligibility.
98    pub dead_entries: usize,
99    /// Reserved row capacity (primary slots plus overflow chunks).
100    pub capacity: usize,
101    /// Entries of the row eligible at the current cutoff.
102    pub reclaimable: usize,
103}
104
105impl VertexFragmentation {
106    /// Reserved slots minus live entries (gaps plus tombstone slots).
107    pub fn waste(&self) -> usize {
108        self.capacity.saturating_sub(self.live_edges)
109    }
110
111    /// Live entries per unit of reserved capacity.
112    pub fn density(&self) -> f32 {
113        if self.capacity == 0 {
114            1.0
115        } else {
116            self.live_edges as f32 / self.capacity as f32
117        }
118    }
119
120    /// Whether incremental collection should visit this row.
121    pub fn needs_compact(&self) -> bool {
122        self.reclaimable > 0
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn test_fragmentation_stats_no_fragmentation() {
132        let stats = FragmentationStats {
133            total_capacity: 100,
134            reachable_edges: 100,
135            dead_entries: 0,
136            wasted_capacity: 0,
137        };
138
139        assert_eq!(stats.fragmentation_ratio(), 0.0);
140        assert!(stats.fragmentation_ratio() < GROUP_FRAGMENTATION_THRESHOLD);
141    }
142
143    #[test]
144    fn test_fragmentation_stats_with_fragmentation() {
145        let stats = FragmentationStats {
146            total_capacity: 100,
147            reachable_edges: 30,
148            dead_entries: 2,
149            wasted_capacity: 50,
150        };
151
152        assert_eq!(stats.fragmentation_ratio(), 0.5);
153        assert!(stats.fragmentation_ratio() >= GROUP_FRAGMENTATION_THRESHOLD);
154    }
155
156    #[test]
157    fn test_fragmentation_stats_severe_fragmentation() {
158        let stats = FragmentationStats {
159            total_capacity: 100,
160            reachable_edges: 20,
161            dead_entries: 3,
162            wasted_capacity: 80,
163        };
164
165        assert_eq!(stats.fragmentation_ratio(), 0.8);
166        assert!(stats.fragmentation_ratio() >= GROUP_FRAGMENTATION_THRESHOLD);
167    }
168
169    #[test]
170    fn test_fragmentation_stats_empty() {
171        let stats = FragmentationStats::with_dead_info(0, 0, 0, 0);
172
173        assert_eq!(stats.fragmentation_ratio(), 0.0);
174    }
175
176    #[test]
177    fn test_fragmentation_ratio_threshold() {
178        let stats = FragmentationStats {
179            total_capacity: 100,
180            reachable_edges: 50,
181            dead_entries: 1,
182            wasted_capacity: 60,
183        };
184
185        assert!(stats.fragmentation_ratio() >= GROUP_FRAGMENTATION_THRESHOLD);
186        assert!(stats.fragmentation_ratio() <= 1.0);
187    }
188
189    #[test]
190    fn test_measured_bytes_per_edge() {
191        assert_eq!(FragmentationStats::measured_bytes_per_edge(0, 0), 0);
192        assert_eq!(FragmentationStats::measured_bytes_per_edge(100, 0), 0);
193        assert_eq!(FragmentationStats::measured_bytes_per_edge(100, 10), 10);
194        assert_eq!(FragmentationStats::measured_bytes_per_edge(5, 10), 0);
195    }
196
197    #[test]
198    fn test_vertex_fragmentation_view() {
199        let view = VertexFragmentation {
200            vertex: 3,
201            live_edges: 4,
202            dead_entries: 1,
203            capacity: 8,
204            reclaimable: 1,
205        };
206        assert_eq!(view.waste(), 4);
207        assert_eq!(view.density(), 0.5);
208        assert!(view.needs_compact());
209
210        let clean = VertexFragmentation {
211            vertex: 4,
212            live_edges: 4,
213            dead_entries: 0,
214            capacity: 8,
215            reclaimable: 0,
216        };
217        assert!(!clean.needs_compact());
218    }
219}