Skip to main content

linkrs_storage/edge/mutable_csr/
compaction.rs

1use super::super::csr_shared::is_reclaimable_cold;
2use super::super::{ColdStamps, EdgeId, HotNbr, Nbr, Timestamp};
3use super::overflow::{OverflowChunk, OverflowStorage};
4use super::row::PACKED_CSR_DENSITY;
5use super::MutableCsr;
6
7impl MutableCsr {
8    /// Compact with per-edge removal reporting (`on_edge_removed` receives
9    /// each dropped edge id and delete timestamp).
10    ///
11    /// Two read-only passes over the old layout, one allocation for the new
12    /// primary list: the first pass counts kept entries per row and sizes
13    /// capacities, the second copies kept entries straight into place.
14    /// No intermediate staging copy of every kept edge is built, so the
15    /// transient peak is one new list instead of a staging copy plus a new
16    /// list. Overflow chains are eliminated: every kept entry lands in its
17    /// row's primary block.
18    pub fn compact_with_ts_reporting(
19        &mut self,
20        cutoff: Timestamp,
21        reserve_ratio: f32,
22        on_edge_removed: &mut dyn FnMut(EdgeId, Timestamp),
23    ) -> usize {
24        // Without an active snapshot cutoff no deletion may be dropped.
25        let removals_enabled = cutoff < Timestamp::MAX;
26        let rows = self.vertex_capacity();
27
28        // Pass 1 (read-only): kept count and target capacity per row.
29        let mut new_degrees = Vec::with_capacity(rows);
30        let mut new_capacities = Vec::with_capacity(rows);
31        let mut removed_count = 0usize;
32        for vid in 0..rows {
33            let (hot, cold) = self.primary_pair(vid);
34            let mut kept = 0usize;
35            for (h, c) in hot.iter().zip(cold.iter()) {
36                if removals_enabled && is_reclaimable_cold(c, cutoff) {
37                    on_edge_removed(h.edge_id, c.delete_ts);
38                    removed_count += 1;
39                } else {
40                    kept += 1;
41                }
42            }
43            if let Some(chunks) = self.overflow_chunks.get(vid as u32) {
44                for chunk in chunks {
45                    for (hot, cold) in chunk.hot_slice().iter().zip(chunk.cold_slice()) {
46                        if removals_enabled && is_reclaimable_cold(cold, cutoff) {
47                            on_edge_removed(hot.edge_id, cold.delete_ts);
48                            removed_count += 1;
49                        } else {
50                            kept += 1;
51                        }
52                    }
53                }
54            }
55            new_degrees.push(kept as u32);
56            new_capacities.push(Self::compact_row_capacity(kept, reserve_ratio) as u32);
57        }
58
59        // Pass 2: copy kept entries straight into the single new lists.
60        let new_total_edge_capacity: usize = new_capacities.iter().map(|&c| c as usize).sum();
61        let mut new_hot_list = Vec::with_capacity(new_total_edge_capacity);
62        let mut new_cold_list = Vec::with_capacity(new_total_edge_capacity);
63        let mut final_offsets = Vec::with_capacity(rows);
64        for vid in 0..rows {
65            final_offsets.push(new_hot_list.len() as u32);
66            let (hot, cold) = self.primary_pair(vid);
67            for (h, c) in hot.iter().zip(cold.iter()) {
68                if removals_enabled && is_reclaimable_cold(c, cutoff) {
69                    continue;
70                }
71                new_hot_list.push(*h);
72                new_cold_list.push(*c);
73            }
74            if let Some(chunks) = self.overflow_chunks.get(vid as u32) {
75                for chunk in chunks {
76                    for (hot, cold) in chunk.hot_slice().iter().zip(chunk.cold_slice()) {
77                        if removals_enabled && is_reclaimable_cold(cold, cutoff) {
78                            continue;
79                        }
80                        new_hot_list.push(*hot);
81                        new_cold_list.push(*cold);
82                    }
83                }
84            }
85            // Fill remaining capacity with the never-alive gap sentinel so
86            // an overrun scan reports absence instead of a ghost live edge.
87            let cap = new_capacities[vid] as usize;
88            let deg = new_degrees[vid] as usize;
89            debug_assert_eq!(new_hot_list.len() - final_offsets[vid] as usize, deg);
90            let remaining = cap - deg;
91            if remaining > 0 {
92                new_hot_list.resize(new_hot_list.len() + remaining, HotNbr::dead_gap());
93                new_cold_list.resize(new_cold_list.len() + remaining, ColdStamps::dead_gap());
94            }
95        }
96
97        self.hot_list = new_hot_list;
98        self.cold_list = new_cold_list;
99        self.rows.adj_offsets = final_offsets;
100        self.rows.degrees = new_degrees;
101        self.rows.primary_capacities = new_capacities;
102        self.total_edge_capacity = new_total_edge_capacity;
103
104        self.overflow_chunks = OverflowStorage::new();
105        self.live_sets.clear();
106        self.rebuild_live_sets();
107        self.reset_reuse_hints();
108        self.reset_primary_sorted();
109
110        removed_count
111    }
112
113    /// Target primary capacity for a compacted row holding `kept` entries.
114    ///
115    /// Shared sizing rule for full rebuilds: guard against
116    /// reserve_ratio >= 1.0 (division by zero would yield infinity,
117    /// saturating the cast to u32::MAX and exploding the rebuilt CSR
118    /// allocation) by treating it as "no reserve". At the packed density
119    /// target rows size through the shared helper so rebuild gaps match
120    /// steady-state write gaps.
121    pub(crate) fn compact_row_capacity(kept: usize, reserve_ratio: f32) -> usize {
122        if kept == 0 {
123            return 0;
124        }
125        if reserve_ratio < 1.0 {
126            let density = 1.0 - reserve_ratio;
127            if (density - PACKED_CSR_DENSITY).abs() < 1e-6 {
128                return Self::sized_row_capacity(kept);
129            }
130            return ((kept as f32 / density).ceil() as usize).max(1);
131        }
132        kept.max(1)
133    }
134
135    /// Count entries of one vertex reclaimable at `cutoff`.
136    ///
137    /// Only tombstones eligible under the shared collection predicate
138    /// count; tombstones pinned by older snapshots are left alone.
139    pub fn reclaimable_count(&self, vid: u32, cutoff: Timestamp) -> usize {
140        if cutoff == Timestamp::MAX {
141            return 0;
142        }
143        let idx = vid as usize;
144        if idx >= self.vertex_capacity() {
145            return 0;
146        }
147        let mut count = 0;
148        let (_, cold) = self.primary_pair(idx);
149        for c in cold.iter() {
150            if is_reclaimable_cold(c, cutoff) {
151                count += 1;
152            }
153        }
154        if let Some(chunks) = self.overflow_chunks.get(vid) {
155            for chunk in chunks {
156                for cold in chunk.cold_slice() {
157                    if is_reclaimable_cold(cold, cutoff) {
158                        count += 1;
159                    }
160                }
161            }
162        }
163        count
164    }
165
166    /// Whether one vertex holds anything reclaimable at `cutoff`.
167    pub fn vertex_needs_compact(&self, vid: u32, cutoff: Timestamp) -> bool {
168        self.reclaimable_count(vid, cutoff) > 0
169    }
170
171    /// Single-walk reclaim probe of one vertex: `(dead, reclaimable)`.
172    ///
173    /// Fuses the census and eligibility walks so maintenance passes pay one
174    /// row scan instead of two. `dead` counts every tombstoned entry
175    /// regardless of eligibility; `reclaimable` counts only entries the
176    /// current cutoff already covers.
177    pub fn vertex_reclaim_probe(&self, vid: u32, cutoff: Timestamp) -> (usize, usize) {
178        let idx = vid as usize;
179        if idx >= self.vertex_capacity() {
180            return (0, 0);
181        }
182        let eligible = cutoff != Timestamp::MAX;
183        let mut dead = 0usize;
184        let mut reclaimable = 0usize;
185        let (_, cold) = self.primary_pair(idx);
186        for c in cold.iter() {
187            if !c.is_live() {
188                dead += 1;
189                if eligible && is_reclaimable_cold(c, cutoff) {
190                    reclaimable += 1;
191                }
192            }
193        }
194        if let Some(chunks) = self.overflow_chunks.get(vid) {
195            for chunk in chunks {
196                for cold in chunk.cold_slice() {
197                    if !cold.is_live() {
198                        dead += 1;
199                        if eligible && is_reclaimable_cold(cold, cutoff) {
200                            reclaimable += 1;
201                        }
202                    }
203                }
204            }
205        }
206        (dead, reclaimable)
207    }
208
209    /// Physical entry census of one vertex: `(live, dead, capacity)`.
210    ///
211    /// `live` counts entries with no deletion stamp, `dead` counts
212    /// tombstoned entries regardless of eligibility, and `capacity` is the
213    /// reserved row capacity plus allocated overflow chunks.
214    pub fn vertex_census(&self, vid: u32) -> (usize, usize, usize) {
215        let idx = vid as usize;
216        if idx >= self.vertex_capacity() {
217            return (0, 0, 0);
218        }
219        let mut live = 0usize;
220        let mut dead = 0usize;
221        let (_, cold) = self.primary_pair(idx);
222        for c in cold.iter() {
223            if c.is_live() {
224                live += 1;
225            } else {
226                dead += 1;
227            }
228        }
229        let mut capacity = self.rows.primary_capacities[idx] as usize;
230        if let Some(chunks) = self.overflow_chunks.get(vid) {
231            capacity += chunks.iter().map(|chunk| chunk.capacity()).sum::<usize>();
232            for chunk in chunks {
233                for cold in chunk.cold_slice() {
234                    if cold.is_live() {
235                        live += 1;
236                    } else {
237                        dead += 1;
238                    }
239                }
240            }
241        }
242        (live, dead, capacity)
243    }
244
245    /// Reclaim one vertex in place, leaving every other row untouched.
246    ///
247    /// Eligible tombstones are dropped and reported through
248    /// `on_edge_removed`; live entries and pinned tombstones are tightened
249    /// to the front of their current slots. Row offsets and capacities of
250    /// other vertices never move, so the work stays proportional to the
251    /// degree of `vid` instead of the size of the table.
252    pub fn compact_vertex_with_reporting(
253        &mut self,
254        vid: u32,
255        cutoff: Timestamp,
256        on_edge_removed: &mut dyn FnMut(EdgeId, Timestamp),
257    ) -> usize {
258        if cutoff == Timestamp::MAX {
259            return 0;
260        }
261        let idx = vid as usize;
262        if idx >= self.vertex_capacity() {
263            return 0;
264        }
265        // Probe before allocating: rows without reclaimable tombstones skip
266        // the overflow clone, the kept buffer and the live-set rebuild, so
267        // maintenance sweeps over clean rows stay allocation-free.
268        if self.vertex_reclaim_probe(vid, cutoff).1 == 0 {
269            return 0;
270        }
271        let mut removed = 0usize;
272
273        let degree = self.rows.degrees[idx] as usize;
274        let offset = self.rows.adj_offsets[idx] as usize;
275        let mut keep = 0usize;
276        for i in 0..degree {
277            let drop = self
278                .cold_at(offset + i)
279                .is_some_and(|cold| is_reclaimable_cold(&cold, cutoff));
280            if drop {
281                let hot = self.hot_list[offset + i];
282                let cold = self.cold_list[offset + i];
283                on_edge_removed(hot.edge_id, cold.delete_ts);
284                removed += 1;
285            } else {
286                if keep != i {
287                    let kept_slot = self.slot_at(offset + i).unwrap();
288                    self.set_slot(offset + keep, kept_slot);
289                }
290                keep += 1;
291            }
292        }
293        self.rows.degrees[idx] = keep as u32;
294        // Tightening shifts kept entries forward, duplicating them into
295        // the vacated tail outside the shrunk window; the tail stays live
296        // unless killed here.
297        for slot in offset + keep..offset + degree {
298            self.hot_list[slot] = HotNbr::dead_gap();
299            self.cold_list[slot] = ColdStamps::dead_gap();
300        }
301        self.invalidate_reuse_hint(idx);
302
303        if self.overflow_chunks.get(vid).is_some() {
304            // Take ownership of the chunk list instead of cloning it: the
305            // entry is reinserted below, so the filter runs on owned chunks
306            // with no duplicate allocation.
307            let chunks = self.overflow_chunks.remove(vid).unwrap_or_default();
308            let freed: usize = chunks.iter().map(|chunk| chunk.capacity()).sum();
309            self.sub_capacity(freed);
310            let mut kept: Vec<Nbr> = Vec::new();
311            for chunk in &chunks {
312                for i in 0..chunk.len() {
313                    let nbr = chunk.slot_at(i).unwrap();
314                    if is_reclaimable_cold(&nbr.cold(), cutoff) {
315                        on_edge_removed(nbr.edge_id, nbr.delete_ts);
316                        removed += 1;
317                    } else {
318                        kept.push(nbr);
319                    }
320                }
321            }
322            if kept.is_empty() {
323                self.rebuild_live_set_for_vertex(vid);
324            } else {
325                // Same single-block consolidation as the repack path.
326                let single = OverflowChunk::consolidated(&kept);
327                let added: usize = single.capacity();
328                self.add_capacity(added);
329                self.overflow_chunks.insert(vid, vec![single]);
330                self.rebuild_live_set_for_vertex(vid);
331            }
332        } else if removed > 0 {
333            self.rebuild_live_set_for_vertex(vid);
334        }
335
336        removed
337    }
338}