1use crate::{
12 BlobEntry, BlobStore, Body, ContentHash, KeyPrefix, Manifest, Record, RecordKind, Result, Store,
13};
14use std::collections::{BTreeMap, BTreeSet};
15use std::time::{Duration, SystemTime};
16
17#[derive(Clone, Debug, Default, PartialEq, Eq)]
19pub struct GcReport {
20 pub freed: Vec<ContentHash>,
24 pub retained: usize,
26 pub deferred: usize,
30}
31
32pub fn live_blob_hashes<'a>(
56 records: impl IntoIterator<Item = &'a Record>,
57) -> Result<BTreeSet<ContentHash>> {
58 let mut live = BTreeSet::new();
59 for record in records {
60 if let Body::Blob { hash, .. } = &record.body {
61 live.insert(hash.clone());
62 }
63 if let Some(hash) = &record.deleted_blob {
64 live.insert(hash.clone());
65 }
66 if record.kind == RecordKind::GraphManifest {
67 live.extend(Manifest::from_body(&record.body)?.entries.into_values());
68 }
69 if record.kind == RecordKind::VectorManifest {
73 live.extend(
74 crate::VectorManifest::from_body(&record.body)?
75 .entries
76 .into_values(),
77 );
78 }
79 if record.kind == RecordKind::Tombstone {
85 match record.deleted_kind {
86 Some(RecordKind::VectorManifest) => live.extend(
87 crate::VectorManifest::from_body(&record.body)?
88 .entries
89 .into_values(),
90 ),
91 Some(RecordKind::GraphManifest) => {
92 live.extend(Manifest::from_body(&record.body)?.entries.into_values())
93 }
94 _ => {}
95 }
96 }
97 }
98 Ok(live)
99}
100
101pub const DEFAULT_MIN_AGE: Duration = Duration::from_secs(3600);
109
110#[derive(Clone, Copy, Debug)]
115pub struct SweepPolicy {
116 pub min_age: Duration,
117 pub now: SystemTime,
118}
119
120impl Default for SweepPolicy {
121 fn default() -> Self {
122 Self {
123 min_age: DEFAULT_MIN_AGE,
124 now: SystemTime::now(),
125 }
126 }
127}
128
129pub async fn sweep_blobs<B>(blobs: &B, live: &BTreeSet<ContentHash>) -> Result<GcReport>
137where
138 B: BlobStore + ?Sized,
139{
140 sweep_blobs_with(blobs, live, SweepPolicy::default()).await
141}
142
143pub async fn sweep_blobs_with<B>(
145 blobs: &B,
146 live: &BTreeSet<ContentHash>,
147 policy: SweepPolicy,
148) -> Result<GcReport>
149where
150 B: BlobStore + ?Sized,
151{
152 let mut newest: BTreeMap<ContentHash, i64> = BTreeMap::new();
156 for entry in blobs.list_blobs().await? {
157 newest
158 .entry(entry.hash)
159 .and_modify(|ms| *ms = (*ms).max(entry.modified_unix_ms))
160 .or_insert(entry.modified_unix_ms);
161 }
162
163 let mut freed = Vec::new();
164 let mut deferred = 0usize;
165 let mut retained = 0usize;
166 for (hash, modified_unix_ms) in &newest {
167 if live.contains(hash) {
168 retained += 1;
169 continue;
170 }
171 let entry = BlobEntry {
172 hash: hash.clone(),
173 modified_unix_ms: *modified_unix_ms,
174 };
175 match entry.age(policy.now) {
179 Some(age) if age >= policy.min_age => freed.push(hash.clone()),
180 _ => deferred += 1,
181 }
182 }
183
184 for hash in &freed {
185 blobs.delete_blob(hash).await?;
186 }
187 Ok(GcReport {
188 freed,
189 retained,
190 deferred,
191 })
192}
193
194pub async fn gc_blobs<T>(store: &T) -> Result<GcReport>
202where
203 T: Store + BlobStore + ?Sized,
204{
205 gc_blobs_with(store, SweepPolicy::default()).await
206}
207
208pub async fn gc_blobs_with<T>(store: &T, policy: SweepPolicy) -> Result<GcReport>
210where
211 T: Store + BlobStore + ?Sized,
212{
213 let keys = store.list_raw(&KeyPrefix::default()).await?;
217 let mut records = Vec::with_capacity(keys.len());
218 for key in &keys {
219 if let Some(record) = store.get_raw(key).await? {
220 records.push(record);
221 }
222 }
223 sweep_blobs_with(store, &live_blob_hashes(&records)?, policy).await
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::{BlobEntry, Identity, Meta, RecordKey};
230 use std::time::SystemTime;
231
232 fn h(s: &str) -> ContentHash {
233 ContentHash::of(s.as_bytes())
234 }
235
236 fn immediate() -> SweepPolicy {
237 SweepPolicy {
238 min_age: Duration::ZERO,
239 now: SystemTime::now(),
240 }
241 }
242
243 fn meta() -> Meta {
244 Meta::new(Identity::new("tester"), "test")
245 }
246
247 fn record(id: &str, kind: RecordKind, body: Body) -> Record {
248 Record::create(RecordKey::new("ns", "coll", id), kind, body, meta())
249 }
250
251 fn blob_body(content: &str) -> Body {
252 Body::blob(content.as_bytes())
253 }
254
255 #[test]
256 fn mark_set_keeps_a_records_own_blob_body() {
257 let r = record("doc", RecordKind::Checkpoint, blob_body("payload"));
258 assert_eq!(
259 live_blob_hashes([&r]).unwrap(),
260 BTreeSet::from([h("payload")])
261 );
262 }
263
264 #[test]
265 fn mark_set_ignores_inline_bodies() {
266 let r = record("t", RecordKind::Topic, Body::Inline(b"inline".to_vec()));
267 assert!(live_blob_hashes([&r]).unwrap().is_empty());
268 }
269
270 #[test]
271 fn mark_set_keeps_the_blob_a_tombstone_pins() {
272 let mut t = record("doc", RecordKind::Tombstone, Body::Inline(Vec::new()));
273 t.deleted_at = Some(1);
274 t.deleted_blob = Some(h("payload"));
275 assert_eq!(
276 live_blob_hashes([&t]).unwrap(),
277 BTreeSet::from([h("payload")])
278 );
279 }
280
281 #[test]
282 fn mark_set_keeps_slices_a_manifest_names() {
283 let mut m = Manifest::new();
286 m.insert("src/lib.rs", h("slice"));
287 let r = record("view", RecordKind::GraphManifest, m.to_body());
288 assert_eq!(
289 live_blob_hashes([&r]).unwrap(),
290 BTreeSet::from([h("slice")])
291 );
292 }
293
294 #[test]
295 fn mark_set_unions_bodies_pins_and_slices() {
296 let live_record = record("doc", RecordKind::Checkpoint, blob_body("body"));
297 let mut tombstone = record("gone", RecordKind::Tombstone, Body::Inline(Vec::new()));
298 tombstone.deleted_at = Some(1);
299 tombstone.deleted_blob = Some(h("pinned"));
300 let mut m = Manifest::new();
301 m.insert("src/lib.rs", h("slice"));
302 let manifest = record("view", RecordKind::GraphManifest, m.to_body());
303
304 let live = live_blob_hashes([&live_record, &tombstone, &manifest]).unwrap();
305 assert_eq!(live, BTreeSet::from([h("body"), h("pinned"), h("slice")]));
306 }
307
308 #[test]
309 fn mark_set_reports_an_undecodable_manifest_rather_than_sweeping_it() {
310 let r = record(
313 "view",
314 RecordKind::GraphManifest,
315 Body::Inline(b"{[".to_vec()),
316 );
317 assert!(live_blob_hashes([&r]).is_err());
318 }
319
320 #[test]
329 fn mark_set_reports_an_undecodable_vector_manifest_rather_than_sweeping_it() {
330 let r = record(
331 "memories",
332 RecordKind::VectorManifest,
333 Body::Inline(b"not json at all".to_vec()),
334 );
335 assert!(live_blob_hashes([&r]).is_err());
336 }
337
338 #[test]
341 fn live_set_includes_vector_manifest_shards() {
342 use crate::VectorManifest;
343
344 let mut vm = VectorManifest::new("bge-small-en-v1.5", 384, 256);
345 vm.entries.insert(0, h("shard-0"));
346 vm.entries.insert(9, h("shard-9"));
347
348 let rec = record("memories", RecordKind::VectorManifest, vm.to_body());
349
350 let live = live_blob_hashes([&rec]).unwrap();
351 assert!(live.contains(&h("shard-0")));
352 assert!(live.contains(&h("shard-9")));
353 }
354
355 #[test]
356 fn a_graph_manifest_and_a_vector_manifest_both_mark() {
357 use crate::VectorManifest;
358
359 let mut gm = Manifest::new();
360 gm.insert("src/lib.rs", h("slice"));
361 let graph = record("view", RecordKind::GraphManifest, gm.to_body());
362
363 let mut vm = VectorManifest::new("space", 8, 4);
364 vm.entries.insert(1, h("shard"));
365 let vector = record("memories", RecordKind::VectorManifest, vm.to_body());
366
367 let live = live_blob_hashes([&graph, &vector]).unwrap();
368 assert_eq!(live, BTreeSet::from([h("slice"), h("shard")]));
369 }
370
371 #[derive(Default)]
374 struct FakeBlobs {
375 listed: Vec<ContentHash>,
376 deleted: std::sync::Mutex<Vec<ContentHash>>,
377 }
378
379 #[async_trait::async_trait]
380 impl BlobStore for FakeBlobs {
381 async fn put_blob(&self, content: &[u8]) -> Result<ContentHash> {
382 Ok(ContentHash::of(content))
383 }
384 async fn get_blob(&self, _hash: &ContentHash) -> Result<Option<Vec<u8>>> {
385 Ok(None)
386 }
387 async fn list_blobs(&self) -> Result<Vec<BlobEntry>> {
388 Ok(self
389 .listed
390 .iter()
391 .cloned()
392 .map(|h| BlobEntry::from_system_time(h, UNIX_EPOCH + Duration::from_secs(1)))
393 .collect())
394 }
395 async fn delete_blob(&self, hash: &ContentHash) -> Result<()> {
396 self.deleted.lock().unwrap().push(hash.clone());
397 Ok(())
398 }
399 }
400
401 #[tokio::test]
402 async fn retained_counts_distinct_blobs_despite_duplicate_listing() {
403 let blobs = FakeBlobs {
407 listed: vec![h("keep"), h("keep"), h("drop")],
408 deleted: Default::default(),
409 };
410
411 let report = sweep_blobs_with(&blobs, &BTreeSet::from([h("keep")]), immediate())
412 .await
413 .unwrap();
414
415 assert_eq!(report.freed, vec![h("drop")]);
416 assert_eq!(report.retained, 1);
417 assert_eq!(*blobs.deleted.lock().unwrap(), vec![h("drop")]);
418 }
419
420 #[tokio::test]
421 async fn sweep_deletes_nothing_when_every_blob_is_live() {
422 let blobs = FakeBlobs {
423 listed: vec![h("a"), h("b")],
424 deleted: Default::default(),
425 };
426
427 let report = sweep_blobs_with(&blobs, &BTreeSet::from([h("a"), h("b")]), immediate())
428 .await
429 .unwrap();
430
431 assert!(report.freed.is_empty());
432 assert_eq!(report.retained, 2);
433 assert!(blobs.deleted.lock().unwrap().is_empty());
434 }
435
436 use std::time::{Duration, UNIX_EPOCH};
437
438 fn at(secs: u64) -> SystemTime {
439 UNIX_EPOCH + Duration::from_secs(secs)
440 }
441
442 struct AgedBlobs(std::sync::Mutex<Vec<BlobEntry>>);
444
445 #[async_trait::async_trait]
446 impl BlobStore for AgedBlobs {
447 async fn put_blob(&self, _content: &[u8]) -> Result<ContentHash> {
448 unreachable!("tests seed blobs directly")
449 }
450 async fn get_blob(&self, _hash: &ContentHash) -> Result<Option<Vec<u8>>> {
451 Ok(None)
452 }
453 async fn list_blobs(&self) -> Result<Vec<BlobEntry>> {
454 Ok(self.0.lock().unwrap().clone())
455 }
456 async fn delete_blob(&self, hash: &ContentHash) -> Result<()> {
457 self.0.lock().unwrap().retain(|e| &e.hash != hash);
458 Ok(())
459 }
460 }
461
462 fn aged(entries: Vec<(ContentHash, SystemTime)>) -> AgedBlobs {
463 AgedBlobs(std::sync::Mutex::new(
464 entries
465 .into_iter()
466 .map(|(h, t)| BlobEntry::from_system_time(h, t))
467 .collect(),
468 ))
469 }
470
471 fn policy(now: u64, min_age_secs: u64) -> SweepPolicy {
472 SweepPolicy {
473 min_age: Duration::from_secs(min_age_secs),
474 now: at(now),
475 }
476 }
477
478 #[tokio::test]
481 async fn a_young_unreferenced_blob_is_deferred_not_freed() {
482 let blobs = aged(vec![(h("fresh"), at(3_600))]);
483 let report = sweep_blobs_with(&blobs, &BTreeSet::new(), policy(3_630, 3_600))
484 .await
485 .unwrap();
486 assert!(report.freed.is_empty());
487 assert_eq!(report.deferred, 1);
488 assert_eq!(report.retained, 0);
489 }
490
491 #[tokio::test]
492 async fn an_old_unreferenced_blob_is_freed() {
493 let blobs = aged(vec![(h("stale"), at(0))]);
494 let report = sweep_blobs_with(&blobs, &BTreeSet::new(), policy(7_200, 3_600))
495 .await
496 .unwrap();
497 assert_eq!(report.freed, vec![h("stale")]);
498 assert_eq!(report.deferred, 0);
499 }
500
501 #[tokio::test]
504 async fn a_blob_exactly_min_age_old_is_freed() {
505 let blobs = aged(vec![(h("edge"), at(0))]);
506 let report = sweep_blobs_with(&blobs, &BTreeSet::new(), policy(3_600, 3_600))
507 .await
508 .unwrap();
509 assert_eq!(report.freed, vec![h("edge")]);
510 }
511
512 #[tokio::test]
515 async fn a_zero_min_age_sweeps_immediately() {
516 let blobs = aged(vec![(h("now"), at(1_000))]);
517 let report = sweep_blobs_with(
518 &blobs,
519 &BTreeSet::new(),
520 SweepPolicy {
521 min_age: Duration::ZERO,
522 now: at(1_000),
523 },
524 )
525 .await
526 .unwrap();
527 assert_eq!(report.freed, vec![h("now")]);
528 }
529
530 #[tokio::test]
531 async fn a_referenced_blob_is_retained_however_old() {
532 let blobs = aged(vec![(h("live"), at(0))]);
533 let live = BTreeSet::from([h("live")]);
534 let report = sweep_blobs_with(&blobs, &live, policy(100_000, 3_600))
535 .await
536 .unwrap();
537 assert!(report.freed.is_empty());
538 assert_eq!(report.retained, 1);
539 assert_eq!(report.deferred, 0);
540 }
541
542 #[tokio::test]
545 async fn a_future_dated_blob_is_deferred_not_freed() {
546 let blobs = aged(vec![(h("ahead"), at(9_000))]);
547 let report = sweep_blobs_with(&blobs, &BTreeSet::new(), policy(1_000, 0))
548 .await
549 .unwrap();
550 assert!(report.freed.is_empty());
551 assert_eq!(report.deferred, 1);
552 }
553
554 #[tokio::test]
557 async fn a_repeated_hash_resolves_to_its_newest_timestamp() {
558 let blobs = aged(vec![(h("dup"), at(0)), (h("dup"), at(3_600))]);
559 let report = sweep_blobs_with(&blobs, &BTreeSet::new(), policy(3_630, 3_600))
560 .await
561 .unwrap();
562 assert!(report.freed.is_empty(), "the newest timestamp is young");
563 assert_eq!(report.deferred, 1);
564 assert_eq!(report.freed.len() + report.retained + report.deferred, 1);
565 }
566
567 #[tokio::test]
568 async fn the_three_outcomes_partition_the_distinct_blobs() {
569 let blobs = aged(vec![
570 (h("live"), at(0)),
571 (h("old"), at(0)),
572 (h("young"), at(3_600)),
573 ]);
574 let live = BTreeSet::from([h("live")]);
575 let report = sweep_blobs_with(&blobs, &live, policy(3_630, 3_600))
576 .await
577 .unwrap();
578 assert_eq!(report.freed.len() + report.retained + report.deferred, 3);
579 }
580
581 fn vector_manifest_body(entries: &[(u16, &str)]) -> Body {
582 let mut m = crate::VectorManifest::new("space-a", 3, 256);
583 for (id, content) in entries {
584 m.entries.insert(*id, h(content));
585 }
586 m.to_body()
587 }
588
589 #[test]
592 fn a_vector_manifest_tombstone_pins_its_shards() {
593 let live = record(
594 "memories",
595 RecordKind::VectorManifest,
596 vector_manifest_body(&[(0, "shard-zero"), (9, "shard-nine")]),
597 );
598 let t = crate::tombstone_of(&live, 1_000, 8, None);
599
600 let marked = live_blob_hashes([&t]).unwrap();
601 assert!(marked.contains(&h("shard-zero")));
602 assert!(marked.contains(&h("shard-nine")));
603 }
604
605 #[test]
606 fn a_graph_manifest_tombstone_pins_its_slices() {
607 let mut gm = Manifest::new();
608 gm.insert("src/lib.rs", h("slice"));
609 let live = record("main", RecordKind::GraphManifest, gm.to_body());
610 let t = crate::tombstone_of(&live, 1_000, 8, None);
611
612 assert!(live_blob_hashes([&t]).unwrap().contains(&h("slice")));
613 }
614
615 #[test]
618 fn a_tombstone_whose_retained_body_will_not_parse_is_an_error() {
619 let mut t = record(
620 "memories",
621 RecordKind::Tombstone,
622 Body::Inline(b"not json".to_vec()),
623 );
624 t.deleted_kind = Some(RecordKind::VectorManifest);
625 assert!(live_blob_hashes([&t]).is_err());
626 }
627
628 #[test]
631 fn a_tombstone_without_deleted_kind_marks_only_its_pinned_blob() {
632 let mut t = record("doc", RecordKind::Tombstone, Body::Inline(Vec::new()));
633 t.deleted_blob = Some(h("pinned"));
634 let marked = live_blob_hashes([&t]).unwrap();
635 assert_eq!(marked, BTreeSet::from([h("pinned")]));
636 }
637
638 #[tokio::test]
642 async fn marking_a_hash_the_store_does_not_hold_is_harmless() {
643 let blobs = aged(vec![(h("present"), at(0))]);
644 let live = BTreeSet::from([h("present"), h("never-stored")]);
645 let report = sweep_blobs_with(&blobs, &live, policy(100_000, 0))
646 .await
647 .unwrap();
648 assert!(report.freed.is_empty());
649 assert_eq!(report.retained, 1, "only the hash actually listed counts");
650 assert_eq!(report.deferred, 0);
651 }
652}