Skip to main content

hyperfile_cleaner/
s3.rs

1//! S3-backed cleaner implementation.
2//!
3//! `S3Cleaner` enumerates the segments referenced by the latest
4//! published inode of a hyperfile and (optionally) deletes anything
5//! in a configured `[low, high]` segid range that isn't referenced.
6//!
7//! See the crate-level [`Cleaner`] trait for the public API shape.
8
9use std::io::{Result, Error, ErrorKind};
10use std::collections::BTreeSet;
11use std::sync::Arc;
12use aws_sdk_s3::Client;
13use log::{debug, error};
14use btree_ondisk::bmap::BMap;
15use btree_ondisk::BlockLoader;
16use btree_ondisk::NullNodeCache;
17use hyperfile::{BlockIndex, BlockPtr, Cno, SegmentId};
18use hyperfile::file::ReadTiming;
19use hyperfile::meta_loader::s3::S3BlockLoader;
20use hyperfile::ondisk::InodeRaw;
21use hyperfile::inode::{Inode, OnDiskState};
22use hyperfile::segment::{Segment, SegmentSum, SegmentReadWrite};
23use hyperfile::staging::{Staging, config::StagingConfig, s3::S3Staging};
24use hyperfile::config::{HyperFileMetaConfig, HyperFileRuntimeConfig};
25use hyperfile::s3uri::S3Uri;
26use hyperfile::meta_format::BlockPtrFormat;
27use crate::Cleaner;
28
29/// S3-backed cleaner for a single hyperfile rooted at a given
30/// `s3://<bucket>/<prefix>` URI.
31///
32/// The cleaner does not retain or open the file's data. It only
33/// reads the published inode and walks the metadata B+tree to
34/// enumerate referenced segments, then deletes the unreferenced
35/// ones via `DeleteObjects`.
36///
37/// Concurrency: the cleaner is not aware of in-flight writers. If
38/// a writer publishes a new checkpoint while `collect` /
39/// `prune` is running, the result reflects whatever inode was
40/// loaded at the start of the call. New segments published after
41/// that point are outside the configured `[low, high]` range and
42/// are not affected.
43pub struct S3Cleaner {
44    client: Client,
45    bucket: String,
46    root_path: String,
47    staging: S3Staging,
48}
49
50impl S3Cleaner {
51    /// Construct a cleaner for `root_uri`, using the AWS SDK's
52    /// default credential / region resolution
53    /// (`aws_config::load_from_env`).
54    ///
55    /// Returns `InvalidInput` if `root_uri` is not a valid S3 URI.
56    pub async fn new(root_uri: &str) -> Result<Self> {
57        let cfg = aws_config::load_from_env().await;
58        let client = Client::new(&cfg);
59        Self::with_client(&client, root_uri).await
60    }
61
62    /// Construct a cleaner using a caller-supplied `Client`. Use
63    /// this when you need to control region / endpoint / credential
64    /// resolution explicitly (for example, in tests or in a tool
65    /// that already has a configured client).
66    ///
67    /// Returns `InvalidInput` if `root_uri` is not a valid S3 URI.
68    pub async fn with_client(client: &Client, root_uri: &str) -> Result<Self> {
69        let trimmed = root_uri.strip_suffix('/').unwrap_or(root_uri);
70        let s3uri = S3Uri::parse(trimmed).map_err(|e| {
71            Error::new(
72                ErrorKind::InvalidInput,
73                format!("invalid S3 URI {:?}: {}", root_uri, e),
74            )
75        })?;
76        let runtime_config = HyperFileRuntimeConfig::default();
77        let staging_config = StagingConfig::new_s3_staging(s3uri.bucket, "", s3uri.key, None);
78        let staging = S3Staging::from(client, staging_config, runtime_config).await?;
79        Ok(Self {
80            client: client.clone(),
81            bucket: s3uri.bucket.to_owned(),
82            root_path: s3uri.key.to_owned(),
83            staging,
84        })
85    }
86
87    async fn load_segment_sum(&self, segid: SegmentId) -> Result<SegmentSum> {
88        self.staging.open(segid).await
89    }
90
91    async fn load_raw_inode(&self) -> Result<(InodeRaw, Option<OnDiskState>)> {
92        let mut raw_inode = InodeRaw::default();
93        let inode_state = self.staging.load_inode(raw_inode.as_mut_u8_slice()).await?;
94        Ok((raw_inode, inode_state))
95    }
96
97    /// Every location the bmap still points into, as the object that holds it.
98    ///
99    /// `collect_ptrs` reports every location the map references — its metadata
100    /// nodes and the block pointers stored in its leaves — for both bmap shapes,
101    /// so this needs no shape branch of its own. It returns `Err` rather than a
102    /// short list if any subtree could not be enumerated, which is what makes it
103    /// safe to derive a delete set from: a truncated reachable set is
104    /// indistinguishable from a complete one.
105    ///
106    /// The pointers are decoded with the container's own format, so under
107    /// `PartedSegment` the result names `<cno>.<part>` objects rather than
108    /// checkpoints. That granularity is not a refinement, it is required: a
109    /// checkpoint abandoned by a rollback leaves its partials behind and the
110    /// retry reuses the same checkpoint number, so the dead partials and the
111    /// live checkpoint share a cno and nothing keyed by cno alone could tell
112    /// them apart.
113    fn collect_segment_id<L>(
114        bmap: BMap<'_, BlockIndex, BlockPtr, BlockPtr, L, NullNodeCache>,
115        fmt: BlockPtrFormat,
116    ) -> Result<BTreeSet<SegmentId>>
117    where
118        L: BlockLoader<BlockPtr> + Clone,
119    {
120        let mut segid_set: BTreeSet<SegmentId> = BTreeSet::new();
121        // `collect_ptrs` is a plain `async fn`, but a bmap built with
122        // btree-ondisk's `rc` feature is `!Send`, so awaiting it directly
123        // would make `collect`/`prune` non-`Send` and break the `Cleaner`
124        // trait. The walk therefore stays on the blocking thread this
125        // function is called from, driven the same way btree-ondisk drives
126        // itself under `tokio-runtime`.
127        let handle = tokio::runtime::Handle::current();
128        for blkptr in handle.block_on(bmap.collect_ptrs())? {
129            if BlockPtrFormat::is_on_staging(&blkptr) {
130                let (segid, _) = BlockPtrFormat::decode(&blkptr, &fmt);
131                segid_set.insert(segid);
132            }
133        }
134        Ok(segid_set)
135    }
136
137    /// Every segment object that exists under the container's prefix.
138    ///
139    /// A segment object is named by [`Segment::segid_to_staging_file_id`]: ten
140    /// digits, and a `.<part>` suffix when the checkpoint was streamed. Anything
141    /// else under the prefix — the inode object, a nested WAL prefix — is not one
142    /// and is skipped, as is any key with a further `/` in it.
143    ///
144    /// The delete set is derived from what is *here*, not from arithmetic over
145    /// `[low, high]`. Two reasons, both new in `hyperfile 0.7.0`: a checkpoint's
146    /// object names are no longer derivable from its number, since a streamed one
147    /// is `<cno>.0`, `<cno>.1`, … with no bound the caller knows; and a partial
148    /// whose checkpoint was abandoned is referenced by nothing and named by
149    /// nothing, so a walk that only visits computed names cannot see it at all.
150    async fn list_segment_objects(&self) -> Result<Vec<SegmentId>> {
151        let prefix = format!("{}/", self.root_path);
152        let mut out = Vec::new();
153        let mut pages = self.client.list_objects_v2()
154            .bucket(&self.bucket)
155            .prefix(&prefix)
156            .into_paginator()
157            .send();
158        while let Some(page) = pages.next().await {
159            let page = page.map_err(|e| Error::other(
160                format!("listing segment objects under s3://{}/{} failed: {}",
161                    self.bucket, prefix, e),
162            ))?;
163            for obj in page.contents() {
164                let Some(key) = obj.key() else { continue };
165                let Some(name) = key.strip_prefix(&prefix) else { continue };
166                if let Some(segid) = parse_segment_object_name(name) {
167                    out.push(segid);
168                }
169            }
170        }
171        out.sort();
172        Ok(out)
173    }
174
175    /// Delete `delete_keys` from `bucket` in batches of 1000 (the
176    /// `DeleteObjects` per-call max).
177    ///
178    /// Failures are tracked at two levels and merged into the same
179    /// returned error:
180    ///
181    /// 1. **SDK-level (transport / auth / etc.)**: the entire batch
182    ///    is recorded as failed. The SDK error is logged at
183    ///    `error!` level.
184    /// 2. **Per-key (S3 partial success in `DeleteObjectsOutput`)**:
185    ///    `DeleteObjects` is a partial-success API; even when the
186    ///    batch returns `Ok`, individual keys may have failed (e.g.
187    ///    `AccessDenied`, `NoSuchKey`). The response's `errors`
188    ///    list is parsed and each failing key recorded.
189    ///
190    /// Returns `Err(Interrupted)` if any key (at either level)
191    /// failed. The error message includes the failed object count
192    /// and a sample failed key. Successfully-deleted keys stay
193    /// deleted (S3 has no transactional batch delete).
194    async fn do_delete_objects(
195        client: &Client,
196        bucket: &str,
197        delete_keys: Vec<String>,
198    ) -> Result<()> {
199        use aws_sdk_s3::types::ObjectIdentifier;
200        if delete_keys.is_empty() {
201            return Ok(());
202        }
203        let mut failed_keys: Vec<String> = Vec::new();
204        for keys in delete_keys.chunks(1000) {
205            let obj_ids = keys.iter()
206                .map(|k| {
207                    ObjectIdentifier::builder()
208                        .key(k)
209                        .build()
210                        .expect("ObjectIdentifier requires non-empty key")
211                })
212                .collect::<Vec<ObjectIdentifier>>();
213            // We use quiet=true so the response only carries the
214            // `errors` list (not the success list); this is what
215            // we want for partial-failure tracking.
216            let delete = aws_sdk_s3::types::Delete::builder()
217                .set_objects(Some(obj_ids))
218                .quiet(true)
219                .build()
220                .expect("Delete::builder requires non-empty object list");
221            match client.delete_objects()
222                .bucket(bucket)
223                .delete(delete)
224                .send()
225                .await
226            {
227                Ok(output) => {
228                    // Partial-success path: even though the batch
229                    // returned Ok at the SDK level, individual keys
230                    // may have failed. Pick those out of the
231                    // response.
232                    if let Some(errors) = output.errors {
233                        for e in errors {
234                            let key = e.key.unwrap_or_default();
235                            let code = e.code.as_deref().unwrap_or("?");
236                            let msg = e.message.as_deref().unwrap_or("");
237                            error!("DeleteObjects per-key failure: key={} code={} message={}",
238                                key, code, msg);
239                            if !key.is_empty() {
240                                failed_keys.push(key);
241                            }
242                        }
243                    }
244                },
245                Err(sdk_err) => {
246                    // SDK-level failure: treat the whole batch as
247                    // failed (we don't know which keys made it).
248                    error!("DeleteObjects batch failed (bucket={}, count={}): {}",
249                        bucket, keys.len(), sdk_err);
250                    failed_keys.extend(keys.iter().cloned());
251                }
252            }
253        }
254        if failed_keys.is_empty() {
255            Ok(())
256        } else {
257            Err(Error::new(
258                ErrorKind::Interrupted,
259                format!(
260                    "DeleteObjects failed for {} object(s); first failed key: {:?}",
261                    failed_keys.len(),
262                    failed_keys.first(),
263                ),
264            ))
265        }
266    }
267
268    /// Inner shared implementation used by both `collect` and
269    /// `prune`. Returns `(referenced, unreferenced)` lists of segment
270    /// *objects* where:
271    ///  - `referenced` is every object the latest published inode's metadata
272    ///    tree still points into, whether or not it falls in `[low, high]`.
273    ///  - `unreferenced` is every object that exists under the container's
274    ///    prefix whose checkpoint number falls in `[low, high]` and which
275    ///    nothing points into, less the two kinds this keeps regardless (see
276    ///    [`partition_objects`]).
277    ///
278    /// `high == 0` is a sentinel meaning "use the latest checkpoint
279    /// (`inode.get_last_cno()`)".
280    async fn collect_unref_seg(
281        &self,
282        low: Cno,
283        high: Cno,
284    ) -> Result<(Vec<SegmentId>, Vec<SegmentId>)> {
285        // Read unconditionally, not only for the `high == 0` sentinel: the
286        // container's pointer format lives in the inode, and it decides both
287        // which object holds a checkpoint's summary and how a pointer decodes.
288        let (raw_inode, inode_state) = self.load_raw_inode().await?;
289        let fmt = container_ptr_format(&raw_inode);
290        let high_cno = if high == 0 {
291            let inode = Inode::from_raw(&raw_inode, inode_state);
292            inode.get_last_cno()
293        } else {
294            high
295        };
296
297        debug!("collect unreferenced segment low: {}, high: {}, format: {:?}",
298            low, high_cno, fmt);
299        validate_segid_range(low, high_cno)?;
300
301        // Which object holds this checkpoint's summary, metadata and inode:
302        // part 0 when the container streams its checkpoints, the bare name
303        // otherwise. `Staging::open` would find it either way — given a bare
304        // number with no object of that name it tries the summary part too —
305        // but naming it outright saves a 404 on every parted container, which
306        // since `hyperfile 0.7.0` is every container it creates.
307        let high_segid = summary_obj(high_cno, &fmt);
308        let ss = self.load_segment_sum(high_segid).await?;
309        // The whole inode is moved into the walk, not just its `i_bmap` field:
310        // `BMap::read` wants an 8-byte-aligned buffer, and `BMapRawType` is
311        // `[u8; N]`, whose own alignment is 1, so a copy of the field alone
312        // lands wherever the compiler put it. `InodeRaw` is `align(8)` and
313        // `i_bmap` sits at an 8-aligned offset within it, so borrowing the
314        // field out of an owned inode is aligned by construction.
315        let ss_inode = ss.hdr.s_inode;
316        let meta_block_size = 1 << ss.hdr.s_meta_blk_shift;
317
318        // Walk the bmap on a blocking thread: a bmap built with
319        // btree-ondisk's `rc` feature is `!Send`, so it cannot be held
320        // across an await in a `Cleaner` method.
321        // Counters are per-loader and the cleaner has nowhere to report
322        // them, so give this walk its own throwaway ReadTiming rather
323        // than threading one through the public API.
324        let loader = S3BlockLoader::new(
325            &self.client, &self.bucket, &self.root_path,
326            Arc::new(ReadTiming::default()),
327        );
328        let join = tokio::task::spawn_blocking(move || -> Result<BTreeSet<SegmentId>> {
329            let bmap = BMap::<BlockIndex, BlockPtr, BlockPtr, S3BlockLoader, NullNodeCache>
330                ::read(&ss_inode.i_bmap, meta_block_size, loader, NullNodeCache)?;
331            Self::collect_segment_id(bmap, fmt)
332        });
333        let set = match join.await {
334            Ok(inner) => inner?,
335            Err(join_err) => {
336                return Err(Error::other(
337                    format!("bmap walk task failed to join: {}", join_err),
338                ));
339            }
340        };
341
342        let existing = self.list_segment_objects().await?;
343        Ok(partition_objects(&existing, &set, low, high_cno, high_segid))
344    }
345}
346
347/// The pointer format the container was written with, as its inode records it.
348///
349/// Falls back to the pre-parting format rather than failing. A container written
350/// before `i_meta_config` was populated carries zero there, which decodes to
351/// nothing usable — and a container that old cannot be parted, because parting
352/// did not exist. The only thing this decides is how a pointer's low bits are
353/// read and which name holds a summary, and for every format but
354/// `PartedSegment` those are what they have always been.
355fn container_ptr_format(raw_inode: &InodeRaw) -> BlockPtrFormat {
356    match HyperFileMetaConfig::try_from_u32(raw_inode.i_meta_config) {
357        Ok(cfg) => cfg.block_ptr_format,
358        Err(e) => {
359            debug!("inode meta config {:#x} is not one this build can read ({}); \
360                reading pointers as {:?}", raw_inode.i_meta_config, e,
361                BlockPtrFormat::MicroGroup);
362            BlockPtrFormat::MicroGroup
363        },
364    }
365}
366
367/// The object holding a checkpoint's summary, metadata blocks and inode.
368fn summary_obj(cno: Cno, fmt: &BlockPtrFormat) -> SegmentId {
369    let segid = SegmentId::new_from_cno(cno);
370    if fmt.is_parted() {
371        segid.at_part(Segment::SUMMARY_PART)
372    } else {
373        segid
374    }
375}
376
377/// A segment object name, or `None` for a key that is not one.
378///
379/// The names are what [`Segment::segid_to_staging_file_id`] produces: ten digits
380/// zero-padded, optionally `.<part>`. `None` and `Some(0)` are different names
381/// on purpose upstream, so they stay different objects here.
382fn parse_segment_object_name(name: &str) -> Option<SegmentId> {
383    // Nested prefixes — a WAL root under the container, say — are not segment
384    // objects of this container.
385    if name.contains('/') {
386        return None;
387    }
388    let (seq_str, part_str) = match name.split_once('.') {
389        Some((seq, part)) => (seq, Some(part)),
390        None => (name, None),
391    };
392    if seq_str.len() != 10 || !seq_str.bytes().all(|b| b.is_ascii_digit()) {
393        return None;
394    }
395    // Ten digits can name more than a `u32` holds; such an object cannot have
396    // been written by this format, so it is not one of ours.
397    let seq_id = seq_str.parse::<u32>().ok()?;
398    match part_str {
399        None => Some(SegmentId::new(seq_id)),
400        Some(p) => {
401            if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) {
402                return None;
403            }
404            Some(SegmentId::with_part(seq_id, p.parse::<u16>().ok()?))
405        },
406    }
407}
408
409/// Split what exists into what is still pointed into and what can go.
410///
411/// `referenced` is object-granular, which is what makes a parted container
412/// reclaimable at all. Two kinds of object are kept even when no pointer names
413/// them:
414///
415/// - **The summary of `high`.** It is the object this cleaner just read to find
416///   the checkpoint's bmap, and the one it would read on the next run. A
417///   container whose newest checkpoint holds no live block — an empty file, or
418///   one whose every block was superseded within the same checkpoint — would
419///   otherwise have its own head deleted, and the next `collect` would fail with
420///   `NoSuchKey`.
421/// - **The summary part of a checkpoint that still has a live part.** Only
422///   reachable under `PartedSegment`: the parts carry data, part 0 carries the
423///   summary and metadata that describe them. Deleting it would leave a
424///   checkpoint whose blocks are live but which nothing can parse, which is what
425///   `open_cno` and a repair tool need. "Not a partial" is upstream's own
426///   predicate for that object — a partial is a part above zero, and an unparted
427///   checkpoint is never one.
428///
429/// Everything else in `[low, high]` that nothing points into is reclaimable,
430/// including the partials of a checkpoint that was abandoned — those share their
431/// checkpoint number with the retry that replaced them, so they are invisible to
432/// anything reasoning in whole checkpoints.
433fn partition_objects(
434    existing: &[SegmentId],
435    referenced: &BTreeSet<SegmentId>,
436    low: Cno,
437    high: Cno,
438    high_summary: SegmentId,
439) -> (Vec<SegmentId>, Vec<SegmentId>) {
440    let live_cnos: BTreeSet<Cno> = referenced.iter().map(|s| s.as_cno()).collect();
441    let mut unref = Vec::new();
442    for obj in existing {
443        let cno = obj.as_cno();
444        if cno < low || cno > high {
445            continue;
446        }
447        if referenced.contains(obj) {
448            continue;
449        }
450        if *obj == high_summary {
451            continue;
452        }
453        if !obj.is_partial() && live_cnos.contains(&cno) {
454            continue;
455        }
456        unref.push(*obj);
457    }
458    let v_ref = referenced.iter().copied().collect::<Vec<SegmentId>>();
459    (v_ref, unref)
460}
461
462/// Reject a checkpoint range that cannot be walked.
463///
464/// `[low, high]` is inclusive at both ends, so `low == high_cno` is a
465/// well-formed query — "what does checkpoint N reference, and is anything in
466/// `[N, N]` unreferenced" — and is answered normally. It is reached whenever a
467/// caller asks about the entire history of a file with a single checkpoint:
468/// `collect(1, 0)`, the form this crate's own documentation gives as the typical
469/// call, resolves `high` to `get_last_cno() == 1` and so lands on `low == high`.
470/// Rejecting it made `collect` and `prune` unusable on any freshly created file
471/// until it had been flushed at least twice.
472///
473/// `low > high_cno` is genuinely empty and stays an error, which also covers
474/// `high_cno == 0` (no checkpoint has ever been published, so there is no
475/// metadata tree to walk).
476///
477/// A checkpoint number past `u32::MAX` is refused rather than truncated: that is
478/// what a block pointer can name, so no object could carry the number, and
479/// `SegmentId::new_from_cno` would assert on it in a debug build.
480fn validate_segid_range(low: Cno, high_cno: Cno) -> Result<()> {
481    if low > high_cno {
482        return Err(Error::new(
483            ErrorKind::InvalidInput,
484            format!("invalid segment range [{}, {}]", low, high_cno),
485        ));
486    }
487    if high_cno > u32::MAX as Cno {
488        return Err(Error::new(
489            ErrorKind::InvalidInput,
490            format!("checkpoint {} is past what a block pointer can name ({})",
491                high_cno, u32::MAX),
492        ));
493    }
494    Ok(())
495}
496
497impl Cleaner for S3Cleaner {
498    async fn collect(
499        &self,
500        low: Cno,
501        high: Cno,
502    ) -> Result<(Vec<SegmentId>, Vec<SegmentId>)> {
503        self.collect_unref_seg(low, high).await
504    }
505
506    /// Prune unreferenced segment objects in `[low, high]`. Implemented
507    /// as `collect` followed by `DeleteObjects` of the
508    /// unreferenced list.
509    async fn prune(&self, low: Cno, high: Cno) -> Result<()> {
510        let (_, non_ref) = self.collect_unref_seg(low, high).await?;
511        let delete_keys = non_ref.iter()
512            .map(|segid| format!(
513                "{}/{}",
514                self.root_path,
515                Segment::segid_to_staging_file_id(*segid),
516            ))
517            .collect::<Vec<String>>();
518        debug!("unreferenced segments to delete: {:?}", delete_keys);
519        Self::do_delete_objects(&self.client, &self.bucket, delete_keys).await
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    /// Builder calls in `do_delete_objects` panic only on empty
528    /// inputs that we guard against. Verify the empty-input fast
529    /// path.
530    #[tokio::test]
531    async fn delete_empty_keys_is_noop() {
532        let cfg = aws_config::load_from_env().await;
533        let client = Client::new(&cfg);
534        // Empty list -> no SDK call -> Ok regardless of credentials.
535        let r = S3Cleaner::do_delete_objects(&client, "any-bucket", Vec::new()).await;
536        assert!(r.is_ok(), "empty key list should not call S3");
537    }
538
539    /// `[low, high]` is inclusive at both ends, so a single-checkpoint
540    /// file -- where `collect(1, 0)` resolves to `low == high == 1` --
541    /// must be accepted. It used to be rejected, which made the
542    /// documented call form fail on any file flushed only once.
543    #[test]
544    fn segid_range_accepts_a_single_checkpoint() {
545        assert!(validate_segid_range(1, 1).is_ok());
546    }
547
548    #[test]
549    fn segid_range_accepts_a_normal_span() {
550        assert!(validate_segid_range(1, 5).is_ok());
551    }
552
553    /// Only a genuinely empty range is an error. `high_segid == 0` is the
554    /// "no checkpoint published yet" case and falls out of the same rule.
555    #[test]
556    fn segid_range_rejects_an_empty_span() {
557        for (low, high) in [(5u64, 3u64), (2, 1), (1, 0)] {
558            let e = validate_segid_range(low, high)
559                .expect_err("low > high must be rejected");
560            assert_eq!(e.kind(), ErrorKind::InvalidInput);
561            assert!(
562                e.to_string().contains(&format!("[{}, {}]", low, high)),
563                "error should name the range, got {:?}", e.to_string(),
564            );
565        }
566    }
567
568    /// A checkpoint number no pointer could name is refused rather than
569    /// truncated into one that names a different object.
570    #[test]
571    fn segid_range_rejects_a_checkpoint_past_a_pointer() {
572        let e = validate_segid_range(1, u32::MAX as Cno + 1)
573            .expect_err("a cno past u32::MAX must be rejected");
574        assert_eq!(e.kind(), ErrorKind::InvalidInput);
575        assert!(validate_segid_range(1, u32::MAX as Cno).is_ok());
576    }
577
578    // --- object names ---
579
580    #[test]
581    fn object_names_round_trip_through_the_parser() {
582        for segid in [
583            SegmentId::new(1),
584            SegmentId::new(4294967295),
585            SegmentId::with_part(7, 0),
586            SegmentId::with_part(7, 16383),
587        ] {
588            let name = Segment::segid_to_staging_file_id(segid);
589            assert_eq!(parse_segment_object_name(&name), Some(segid),
590                "name {:?} did not parse back", name);
591        }
592    }
593
594    /// An unsuffixed name and part 0 are different objects upstream, so they
595    /// have to stay different here — sharing them would mean deleting the
596    /// summary of one container shape while reporting the other.
597    #[test]
598    fn a_bare_name_is_not_part_zero() {
599        assert_eq!(parse_segment_object_name("0000000007"), Some(SegmentId::new(7)));
600        assert_eq!(parse_segment_object_name("0000000007.0"),
601            Some(SegmentId::with_part(7, 0)));
602    }
603
604    #[test]
605    fn keys_that_are_not_segment_objects_are_skipped() {
606        for name in [
607            "inode",              // the inode object
608            "wal/0000000001",     // a nested prefix, not an object of ours
609            "0000000001/0_0_4096",
610            "000000001",          // nine digits
611            "00000000012",        // eleven
612            "9999999999",         // ten digits, past what a u32 can hold
613            "0000000001.",        // empty part
614            "0000000001.x",
615            "0000000001.65536",   // past what a u16 can hold
616            "0000000001.0.1",
617            "",
618        ] {
619            assert_eq!(parse_segment_object_name(name), None,
620                "{:?} should not be read as a segment object", name);
621        }
622    }
623
624    // --- what gets reclaimed ---
625
626    fn partition(
627        existing: &[SegmentId],
628        referenced: &[SegmentId],
629        low: Cno,
630        high: Cno,
631        high_summary: SegmentId,
632    ) -> Vec<SegmentId> {
633        let set: BTreeSet<SegmentId> = referenced.iter().copied().collect();
634        let (_, unref) = partition_objects(existing, &set, low, high, high_summary);
635        unref
636    }
637
638    /// The unparted case, which is every container written before 0.7.0: an
639    /// object is a checkpoint, and one nothing points into goes.
640    #[test]
641    fn an_unreferenced_checkpoint_is_reclaimable() {
642        let existing = [SegmentId::new(1), SegmentId::new(2), SegmentId::new(3)];
643        let unref = partition(&existing, &[SegmentId::new(3)], 1, 3, SegmentId::new(3));
644        assert_eq!(unref, vec![SegmentId::new(1), SegmentId::new(2)]);
645    }
646
647    /// Objects outside `[low, high]` are not candidates, which is what makes a
648    /// prune safe against a writer publishing underneath it.
649    #[test]
650    fn objects_outside_the_range_are_left_alone() {
651        let existing = [
652            SegmentId::new(1), SegmentId::new(2), SegmentId::new(3), SegmentId::new(4),
653        ];
654        let unref = partition(&existing, &[], 2, 3, SegmentId::new(9));
655        assert_eq!(unref, vec![SegmentId::new(2), SegmentId::new(3)]);
656    }
657
658    /// A checkpoint whose newest state holds no live block would otherwise have
659    /// its own head deleted, and the next `collect` would fail to read it.
660    #[test]
661    fn the_summary_of_high_is_kept_even_when_nothing_points_into_it() {
662        let existing = [SegmentId::new(1), SegmentId::new(2)];
663        let unref = partition(&existing, &[], 1, 2, SegmentId::new(2));
664        assert_eq!(unref, vec![SegmentId::new(1)]);
665
666        // Same for a streamed container, where the head is part 0.
667        let existing = [SegmentId::with_part(2, 0), SegmentId::with_part(2, 1)];
668        let unref = partition(&existing, &[], 1, 2, SegmentId::with_part(2, 0));
669        assert_eq!(unref, vec![SegmentId::with_part(2, 1)]);
670    }
671
672    /// The partials of an abandoned checkpoint share their number with the
673    /// retry that replaced them. Reclaiming them is the whole reason the
674    /// referenced set is object-granular.
675    #[test]
676    fn an_abandoned_partial_is_reclaimable_while_its_checkpoint_lives() {
677        let existing = [
678            SegmentId::with_part(5, 0), // summary of the checkpoint that landed
679            SegmentId::with_part(5, 1), // its data
680            SegmentId::with_part(5, 2), // written, then abandoned by a rollback
681        ];
682        let referenced = [SegmentId::with_part(5, 0), SegmentId::with_part(5, 1)];
683        let unref = partition(&existing, &referenced, 1, 9, SegmentId::with_part(9, 0));
684        assert_eq!(unref, vec![SegmentId::with_part(5, 2)]);
685    }
686
687    /// A live checkpoint has to stay parseable: its data parts are named by
688    /// pointers, its summary is what describes them.
689    #[test]
690    fn the_summary_part_of_a_live_checkpoint_is_kept() {
691        let existing = [SegmentId::with_part(4, 0), SegmentId::with_part(4, 3)];
692        let unref = partition(&existing, &[SegmentId::with_part(4, 3)], 1, 9,
693            SegmentId::with_part(9, 0));
694        assert!(unref.is_empty(), "part 0 describes the live part 3, got {:?}", unref);
695    }
696
697    /// With no live part left, the summary goes too — otherwise a parted
698    /// container would never give any storage back.
699    #[test]
700    fn a_wholly_dead_parted_checkpoint_goes_including_its_summary() {
701        let existing = [
702            SegmentId::with_part(4, 0), SegmentId::with_part(4, 1),
703            SegmentId::with_part(6, 0),
704        ];
705        let unref = partition(&existing, &[SegmentId::with_part(6, 0)], 1, 9,
706            SegmentId::with_part(6, 0));
707        assert_eq!(unref, vec![SegmentId::with_part(4, 0), SegmentId::with_part(4, 1)]);
708    }
709}