Skip to main content

gonzalo_core/
gc.rs

1//! Mark-sweep garbage collection for blobs (ADR 0012, gonzalo#292).
2//!
3//! A blob is *live* iff some stored record still points at it: as a record's
4//! own [`Body::Blob`], as the blob a tombstone pins, as a slice a graph
5//! manifest names, or as a shard a vector manifest names. GC marks that union
6//! and sweeps every stored blob outside it. Liveness is derived from the
7//! records themselves rather than a maintained refcount, so it is
8//! self-correcting: a missed event can leave a blob briefly un-swept, never
9//! wrongly deleted, and never leaked forever the way a drifted refcount would.
10
11use 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/// What a GC sweep did.
18#[derive(Clone, Debug, Default, PartialEq, Eq)]
19pub struct GcReport {
20    /// Hashes of blobs deleted because no record referenced them, in
21    /// [`ContentHash`] order — not listing order (the sweep walks a `BTreeMap`
22    /// to collapse duplicate listings). That order is intentional.
23    pub freed: Vec<ContentHash>,
24    /// Count of blobs kept because they are still referenced.
25    pub retained: usize,
26    /// Count of blobs no record references that were kept anyway, because they
27    /// are younger than the policy's `min_age`. Without this an operator cannot
28    /// tell "nothing to reclaim" from "reclaiming held back".
29    pub deferred: usize,
30}
31
32/// The mark set: every blob hash these records still need. `records` must be
33/// the store's **raw** records, tombstones included.
34///
35/// Five things reference a blob (gonzalo#292, gonzalo#323, gonzalo#327):
36///
37/// - a record whose body is a [`Body::Blob`] — the bytes are its content;
38/// - a tombstone's [`deleted_blob`](Record::deleted_blob). **A tombstone pins
39///   its blob**, so a delete never destroys content that a peer can still
40///   resurrect by syncing the record back, and re-putting the same content
41///   after a delete does not have to re-upload it. The pin lasts exactly as
42///   long as the tombstone: collecting it past the horizon releases the blob to
43///   the next sweep;
44/// - every slice a graph manifest names (ADR 0012) — blobs referenced from a
45///   record's *contents* rather than from its body;
46/// - every shard a vector manifest names (ADR 0027) — the same
47///   contents-not-body reference, for a durable vector index's shard blobs;
48/// - every blob named by the **retained body of a manifest tombstone**
49///   ([`deleted_kind`](Record::deleted_kind), ADR 0029) — the same references
50///   as the two manifest arms above, kept alive after the delete so the index
51///   can still be restored. Released when the tombstone is collected.
52///
53/// Marking any one of the five alone deletes the other four's blobs, so this
54/// unions them from the records rather than from a caller's idea of liveness.
55pub 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        // A vector manifest's shards are live blobs. Without this the next sweep
70        // deletes every vector in the index while the manifest still names them,
71        // and — unlike a graph slice — they cannot be regenerated from source.
72        if record.kind == RecordKind::VectorManifest {
73            live.extend(
74                crate::VectorManifest::from_body(&record.body)?
75                    .entries
76                    .into_values(),
77            );
78        }
79        // A manifest tombstone retains its body, so the references inside it
80        // are still live: the bytes survive until the tombstone is collected,
81        // exactly like `deleted_blob` for a blob-bodied record (ADR 0029).
82        // `deleted_kind` names the parser; guessing by trying both would make an
83        // unrelated inline body that happens to parse look like a manifest.
84        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
101/// How long a blob must have gone untouched before a sweep may delete it.
102///
103/// A writer uploads its blobs *before* committing the manifest that names them,
104/// so a sweep that runs in between sees them as unreferenced. One hour exceeds
105/// the longest upload phase in the workspace (a full graph walk, or a bulk
106/// vector load of 256 shards over S3 — both minutes) and absorbs clock skew
107/// between the GC host and the store. See ADR 0028.
108pub const DEFAULT_MIN_AGE: Duration = Duration::from_secs(3600);
109
110/// When a sweep runs and how old a blob must be to qualify.
111///
112/// `now` is a parameter rather than read from the clock so the grace period is
113/// testable without backdating files across three substrates.
114#[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
129/// Delete every blob in `blobs` outside the `live` mark set **and** at least
130/// [`DEFAULT_MIN_AGE`] old, and report what was freed, retained, and deferred.
131///
132/// Split from [`gc_blobs`] so a caller that already knows its live set — a
133/// single view's manifests, say — can sweep without re-listing the store. The
134/// mark set is the dangerous half: pass one that misses a reference and this
135/// deletes content that no longer exists anywhere else.
136pub 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
143/// As [`sweep_blobs`], with an explicit policy.
144pub 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    // Listings promise neither order nor uniqueness, so collapse duplicates to
153    // the NEWEST timestamp: a stale duplicate must never make a young blob look
154    // collectable.
155    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        // `None` means dated in the future — treated as too young, because
176        // deleting on the strength of a clock disagreement is the one outcome
177        // that loses data.
178        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
194/// Mark-sweep every blob in `store`: delete the ones no record references.
195///
196/// Explicit and operator-run, for the same reason collection is (ADR 0021): a
197/// blob swept while a peer still holds the record naming it cannot be restored
198/// by a later sync. Deleting a record does **not** free its blob — the
199/// tombstone pins it — so reclaiming a deleted record's bytes is three steps:
200/// `delete`, then `collect` past the horizon, then this.
201pub 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
208/// As [`gc_blobs`], with an explicit policy.
209pub async fn gc_blobs_with<T>(store: &T, policy: SweepPolicy) -> Result<GcReport>
210where
211    T: Store + BlobStore + ?Sized,
212{
213    // Raw, because a tombstone is what pins a deleted record's blob. A consumer
214    // listing hides tombstones, and sweeping against it would free exactly the
215    // blobs the pin exists to keep.
216    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        // The slices are referenced by the manifest's *contents*, not by any
284        // record body — marking bodies alone would sweep every code slice.
285        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        // Silently treating a corrupt manifest as "references nothing" would
311        // delete every slice it named.
312        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    // Guard test, symmetric to
321    // `mark_set_reports_an_undecodable_manifest_rather_than_sweeping_it`
322    // above: silently treating an undecodable `VectorManifest` body as
323    // "references nothing" would sweep every shard it actually named. Passes
324    // against current code by design -- the `VectorManifest` arm already
325    // propagates `VectorManifest::from_body`'s error via `?` rather than
326    // swallowing it with `.ok()`; this guards against someone changing that
327    // later.
328    #[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    // Red-first guard on real data loss: before the VectorManifest arm exists,
339    // the mark set misses every shard blob and a sweep deletes live vectors.
340    #[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    /// A `BlobStore` whose `list_blobs` returns a fixed, possibly-duplicated
372    /// list of hashes and records which hashes `delete_blob` was called on.
373    #[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        // `list_blobs` reports `keep` twice and one unreferenced `drop`. There
404        // are two distinct blobs; one is freed, so exactly one is retained — the
405        // duplicate listing must not inflate `retained` to 2 (regression: #156).
406        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    /// A store whose blobs carry the timestamps a test chooses.
443    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    // The bug this ticket exists for: a blob uploaded moments ago, whose
479    // manifest has not committed yet, must survive the sweep.
480    #[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    // Review Focus 2. `>=`, not `>` — otherwise a blob sits one tick short of
502    // collectable forever.
503    #[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    // Review Focus 3. The migration path for every pre-existing test, and the
513    // way an operator asks for the old behaviour.
514    #[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    // The GC host's clock behind the store's. Deleting on the strength of a
543    // clock disagreement is the one outcome that loses data.
544    #[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    // Review Focus 4. Listings promise neither order nor uniqueness; a
555    // duplicate must not let a young blob read as old.
556    #[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    // The bug. A deleted vector index's shards must survive until the tombstone
590    // is collected, exactly as a deleted blob-bodied record's blob does.
591    #[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    // A tombstone claiming a manifest kind over a body that will not parse must
616    // error, never sweep. Same rule as the live arms, which assert `is_err()`.
617    #[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    // A tombstone of an ordinary kind marks nothing new, and one written before
629    // the field existed has deleted_kind: None.
630    #[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    // A manifest tombstone synced to a peer that never received the blobs makes
639    // the mark set name hashes the store does not hold. That must not error or
640    // distort the counts.
641    #[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}