1use 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#[derive(Clone, Debug, PartialEq)]
23pub struct GcReport {
24 pub snapshots_written: u64,
26 pub ops_dropped: u64,
28 pub frontier: Hlc,
30}
31
32pub 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 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 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 let ops_dropped = log.drop_below(frontier)?;
108
109 Ok(GcReport {
110 snapshots_written,
111 ops_dropped,
112 frontier,
113 })
114}
115
116pub struct MockSnapshotSink {
121 snapshots: Mutex<Vec<TileSnapshot>>,
122}
123
124impl MockSnapshotSink {
125 pub fn new() -> Self {
127 Self {
128 snapshots: Mutex::new(Vec::new()),
129 }
130 }
131
132 pub fn snapshot_count(&self) -> usize {
134 self.snapshots
135 .lock()
136 .expect("invariant: MockSnapshotSink mutex is not poisoned")
137 .len()
138 }
139
140 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#[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 assert_eq!(report.frontier, hlc(25));
247 assert_eq!(report.ops_dropped, 2);
248 assert_eq!(report.snapshots_written, 1);
250 assert_eq!(sink.snapshot_count(), 1);
251 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 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)); acks.record(r2, hlc(30)); 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 assert_eq!(report.ops_dropped, 2);
336 assert_eq!(log.len().unwrap(), 2);
337 }
338}