Skip to main content

nodedb_array/sync/
gc.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Log compaction: collapse ops below the min-ack frontier into snapshots.
4//!
5//! [`collapse_below`] is the pure GC entry point. It consults an
6//! [`AckVector`] to find the min-ack frontier, writes one snapshot per
7//! affected array via a [`SnapshotSink`], then drops the compacted ops from
8//! the log. No I/O beyond the abstract traits.
9
10use std::collections::HashSet;
11use std::sync::Mutex;
12
13use crate::error::ArrayResult;
14use crate::sync::ack::AckVector;
15use crate::sync::hlc::Hlc;
16use crate::sync::op_log::OpLog;
17use crate::sync::snapshot::{SnapshotSink, TileSnapshot};
18
19// ─── GcReport ────────────────────────────────────────────────────────────────
20
21/// Summary produced by [`collapse_below`].
22#[derive(Clone, Debug, PartialEq)]
23pub struct GcReport {
24    /// Number of arrays for which a snapshot was successfully written.
25    pub snapshots_written: u64,
26    /// Number of ops dropped from the log below the frontier.
27    pub ops_dropped: u64,
28    /// The GC frontier HLC used for this run.
29    pub frontier: Hlc,
30}
31
32// ─── collapse_below ───────────────────────────────────────────────────────────
33
34/// Compact ops below the min-ack frontier into snapshots, then prune the log.
35///
36/// Algorithm:
37/// 1. Determine frontier = `acks.min_ack_hlc()`. If `None`, return early with
38///    an empty report — GC must not proceed without knowing every peer's
39///    progress.
40/// 2. Collect distinct array names whose ops have `hlc < frontier`.
41/// 3. For each such array, call `snapshot_for_array(name, frontier)`.
42///    - `Some(snap)` → write via `sink.write_snapshot(&snap)` and increment
43///      `snapshots_written`.
44///    - `None` → fail without pruning: no operation may be discarded unless
45///      a snapshot represents that array through the frontier.
46///    - `Err` → propagate immediately; the log is **not** mutated.
47/// 4. After all snapshots succeed, call `log.drop_below(frontier)` and record
48///    the count in `ops_dropped`.
49/// 5. Return the report.
50pub fn collapse_below(
51    log: &dyn OpLog,
52    acks: &AckVector,
53    sink: &dyn SnapshotSink,
54    snapshot_for_array: impl Fn(&str, Hlc) -> ArrayResult<Option<TileSnapshot>>,
55) -> ArrayResult<GcReport> {
56    let frontier = match acks.min_ack_hlc() {
57        None => {
58            return Ok(GcReport {
59                snapshots_written: 0,
60                ops_dropped: 0,
61                frontier: Hlc::ZERO,
62            });
63        }
64        Some(h) => h,
65    };
66
67    // Collect distinct array names with ops below the frontier.
68    let mut arrays_to_snapshot: HashSet<String> = HashSet::new();
69    for item in log.scan_from(Hlc::ZERO)? {
70        let op = item?;
71        if op.header.hlc < frontier {
72            arrays_to_snapshot.insert(op.header.array.clone());
73        }
74    }
75
76    // Write snapshots — abort on the first error; do NOT mutate the log yet.
77    let mut snapshots_written: u64 = 0;
78    for array in &arrays_to_snapshot {
79        let snap = snapshot_for_array(array, frontier)?.ok_or_else(|| {
80            crate::error::ArrayError::SegmentCorruption {
81                detail: format!(
82                    "array GC refused to prune '{array}' without a snapshot through {frontier:?}"
83                ),
84            }
85        })?;
86        if snap.array != *array {
87            return Err(crate::error::ArrayError::SegmentCorruption {
88                detail: format!(
89                    "array GC snapshot array mismatch: expected '{array}', got '{}'",
90                    snap.array
91                ),
92            });
93        }
94        if snap.snapshot_hlc < frontier {
95            return Err(crate::error::ArrayError::SegmentCorruption {
96                detail: format!(
97                    "array GC snapshot for '{array}' is stale: {:?} < {frontier:?}",
98                    snap.snapshot_hlc
99                ),
100            });
101        }
102        sink.write_snapshot(&snap)?;
103        snapshots_written += 1;
104    }
105
106    // All snapshots succeeded — now prune the log.
107    let ops_dropped = log.drop_below(frontier)?;
108
109    Ok(GcReport {
110        snapshots_written,
111        ops_dropped,
112        frontier,
113    })
114}
115
116// ─── MockSnapshotSink ────────────────────────────────────────────────────────
117
118/// In-memory [`SnapshotSink`] suitable for tests in any crate that depends
119/// on `nodedb-array`.
120pub struct MockSnapshotSink {
121    snapshots: Mutex<Vec<TileSnapshot>>,
122}
123
124impl MockSnapshotSink {
125    /// Create an empty sink.
126    pub fn new() -> Self {
127        Self {
128            snapshots: Mutex::new(Vec::new()),
129        }
130    }
131
132    /// Return the number of snapshots written so far.
133    pub fn snapshot_count(&self) -> usize {
134        self.snapshots
135            .lock()
136            .expect("invariant: MockSnapshotSink mutex is not poisoned")
137            .len()
138    }
139
140    /// Consume the sink, returning all written snapshots.
141    pub fn into_snapshots(self) -> Vec<TileSnapshot> {
142        self.snapshots
143            .into_inner()
144            .expect("invariant: MockSnapshotSink mutex is not poisoned")
145    }
146}
147
148impl Default for MockSnapshotSink {
149    fn default() -> Self {
150        Self::new()
151    }
152}
153
154impl SnapshotSink for MockSnapshotSink {
155    fn write_snapshot(&self, snapshot: &TileSnapshot) -> crate::error::ArrayResult<()> {
156        self.snapshots
157            .lock()
158            .expect("invariant: MockSnapshotSink mutex is not poisoned")
159            .push(snapshot.clone());
160        Ok(())
161    }
162}
163
164// ─── Tests ───────────────────────────────────────────────────────────────────
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::error::ArrayError;
170    use crate::sync::hlc::Hlc;
171    use crate::sync::op::{ArrayOp, ArrayOpHeader, ArrayOpKind};
172    use crate::sync::op_log::InMemoryOpLog;
173    use crate::sync::replica_id::ReplicaId;
174    use crate::sync::snapshot::CoordRange;
175    use crate::types::cell_value::value::CellValue;
176    use crate::types::coord::value::CoordValue;
177
178    fn replica() -> ReplicaId {
179        ReplicaId::new(1)
180    }
181
182    fn hlc(ms: u64) -> Hlc {
183        Hlc::new(ms, 0, replica()).unwrap()
184    }
185
186    fn make_op(array: &str, ms: u64) -> ArrayOp {
187        ArrayOp {
188            header: ArrayOpHeader {
189                array: array.into(),
190                hlc: hlc(ms),
191                schema_hlc: hlc(1),
192                valid_from_ms: 0,
193                valid_until_ms: -1,
194                system_from_ms: ms as i64,
195            },
196            kind: ArrayOpKind::Put,
197            coord: vec![CoordValue::Int64(ms as i64)],
198            attrs: Some(vec![CellValue::Null]),
199        }
200    }
201
202    fn dummy_snapshot(array: &str, frontier: Hlc) -> TileSnapshot {
203        TileSnapshot {
204            array: array.into(),
205            coord_range: CoordRange {
206                lo: vec![CoordValue::Int64(0)],
207                hi: vec![CoordValue::Int64(100)],
208            },
209            tile_blob: vec![0xAA; 16],
210            snapshot_hlc: frontier,
211            schema_hlc: hlc(1),
212        }
213    }
214
215    #[test]
216    fn gc_with_no_acks_is_noop() {
217        let log = InMemoryOpLog::new();
218        log.append(&make_op("arr", 10)).unwrap();
219        let acks = AckVector::new();
220        let sink = MockSnapshotSink::new();
221
222        let report = collapse_below(&log, &acks, &sink, |_, _| Ok(None)).unwrap();
223
224        assert_eq!(report.snapshots_written, 0);
225        assert_eq!(report.ops_dropped, 0);
226        assert_eq!(report.frontier, Hlc::ZERO);
227        assert_eq!(log.len().unwrap(), 1);
228    }
229
230    #[test]
231    fn gc_collapses_below_min_ack() {
232        let log = InMemoryOpLog::new();
233        for ms in [10, 20, 30, 40] {
234            log.append(&make_op("arr", ms)).unwrap();
235        }
236        let mut acks = AckVector::new();
237        acks.record(replica(), hlc(25));
238
239        let sink = MockSnapshotSink::new();
240        let report = collapse_below(&log, &acks, &sink, |array, frontier| {
241            Ok(Some(dummy_snapshot(array, frontier)))
242        })
243        .unwrap();
244
245        // frontier = 25; ops at 10 and 20 are below it
246        assert_eq!(report.frontier, hlc(25));
247        assert_eq!(report.ops_dropped, 2);
248        // One distinct array ("arr") had ops below frontier
249        assert_eq!(report.snapshots_written, 1);
250        assert_eq!(sink.snapshot_count(), 1);
251        // Remaining ops: ms=25 (excluded by strict <), ms=30, ms=40
252        // drop_below(25) drops strictly < 25 → drops 10, 20 → 2 remain (30 and 40)
253        assert_eq!(log.len().unwrap(), 2);
254    }
255
256    #[test]
257    fn gc_refuses_to_prune_when_any_array_has_no_live_snapshot() {
258        let log = InMemoryOpLog::new();
259        log.append(&make_op("alive", 10)).unwrap();
260        log.append(&make_op("dead", 10)).unwrap();
261
262        let mut acks = AckVector::new();
263        acks.record(replica(), hlc(50));
264
265        let sink = MockSnapshotSink::new();
266        let result = collapse_below(&log, &acks, &sink, |array, frontier| {
267            if array == "alive" {
268                Ok(Some(dummy_snapshot(array, frontier)))
269            } else {
270                Ok(None)
271            }
272        });
273
274        assert!(result.is_err());
275        assert_eq!(log.len().unwrap(), 2);
276    }
277
278    #[test]
279    fn gc_propagates_snapshot_error() {
280        let log = InMemoryOpLog::new();
281        log.append(&make_op("arr", 10)).unwrap();
282
283        let mut acks = AckVector::new();
284        acks.record(replica(), hlc(50));
285
286        let sink = MockSnapshotSink::new();
287        let result = collapse_below(&log, &acks, &sink, |_, _| {
288            Err(ArrayError::SegmentCorruption {
289                detail: "simulated snapshot error".into(),
290            })
291        });
292
293        assert!(result.is_err());
294        // Log must be unchanged.
295        assert_eq!(log.len().unwrap(), 1);
296    }
297
298    #[test]
299    fn gc_rejects_snapshot_for_wrong_array_without_pruning() {
300        let log = InMemoryOpLog::new();
301        log.append(&make_op("arr", 10)).unwrap();
302        let mut acks = AckVector::new();
303        acks.record(replica(), hlc(50));
304        let sink = MockSnapshotSink::new();
305
306        let result = collapse_below(&log, &acks, &sink, |_, frontier| {
307            Ok(Some(dummy_snapshot("other", frontier)))
308        });
309
310        assert!(matches!(result, Err(ArrayError::SegmentCorruption { .. })));
311        assert_eq!(log.len().unwrap(), 1);
312        assert_eq!(sink.snapshot_count(), 0);
313    }
314
315    #[test]
316    fn gc_min_ack_with_two_replicas() {
317        let log = InMemoryOpLog::new();
318        for ms in [10, 20, 30, 40] {
319            log.append(&make_op("arr", ms)).unwrap();
320        }
321
322        let r2 = ReplicaId::new(2);
323        let mut acks = AckVector::new();
324        acks.record(replica(), hlc(50)); // replica 1 ack at 50
325        acks.record(r2, hlc(30)); // replica 2 ack at 30 → frontier = 30
326
327        let sink = MockSnapshotSink::new();
328        let report = collapse_below(&log, &acks, &sink, |array, frontier| {
329            Ok(Some(dummy_snapshot(array, frontier)))
330        })
331        .unwrap();
332
333        assert_eq!(report.frontier, hlc(30));
334        // Ops at 10, 20 are strictly < 30 → 2 dropped; 30, 40 survive.
335        assert_eq!(report.ops_dropped, 2);
336        assert_eq!(log.len().unwrap(), 2);
337    }
338}