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, s3_code_kind, s3_error_kind};
18use hyperfile::file::ReadTiming;
19use hyperfile::meta_loader::s3::S3BlockLoader;
20use hyperfile::ondisk::InodeRaw;
21use hyperfile::compaction::{PendingCompaction, StoredCompaction};
22use hyperfile::inode::{Inode, OnDiskState};
23use hyperfile::segment::{Segment, SegmentSum, SegmentReadWrite};
24use hyperfile::staging::{Staging, config::StagingConfig, s3::S3Staging};
25use hyperfile::config::{HyperFileMetaConfig, HyperFileRuntimeConfig};
26use hyperfile::s3uri::S3Uri;
27use hyperfile::meta_format::BlockPtrFormat;
28use crate::Cleaner;
29
30/// S3-backed cleaner for a single hyperfile rooted at a given
31/// `s3://<bucket>/<prefix>` URI.
32///
33/// The cleaner does not retain or open the file's data. It only
34/// reads the published inode and walks the metadata B+tree to
35/// enumerate referenced segments, then deletes the unreferenced
36/// ones via `DeleteObjects`.
37///
38/// Concurrency: the cleaner is not aware of in-flight writers. If
39/// a writer publishes a new checkpoint while `collect` /
40/// `prune` is running, the result reflects whatever inode was
41/// loaded at the start of the call. New segments published after
42/// that point are outside the configured `[low, high]` range and
43/// are not affected.
44pub struct S3Cleaner {
45    client: Client,
46    bucket: String,
47    root_path: String,
48    staging: S3Staging,
49}
50
51impl S3Cleaner {
52    /// Construct a cleaner for `root_uri`, using the AWS SDK's
53    /// default credential / region resolution
54    /// (`aws_config::load_from_env`).
55    ///
56    /// Returns `InvalidInput` if `root_uri` is not a valid S3 URI.
57    pub async fn new(root_uri: &str) -> Result<Self> {
58        let cfg = aws_config::load_from_env().await;
59        let client = Client::new(&cfg);
60        Self::with_client(&client, root_uri).await
61    }
62
63    /// Construct a cleaner using a caller-supplied `Client`. Use
64    /// this when you need to control region / endpoint / credential
65    /// resolution explicitly (for example, in tests or in a tool
66    /// that already has a configured client).
67    ///
68    /// Returns `InvalidInput` if `root_uri` is not a valid S3 URI.
69    pub async fn with_client(client: &Client, root_uri: &str) -> Result<Self> {
70        let trimmed = trim_root_uri(root_uri);
71        let s3uri = S3Uri::parse(trimmed).map_err(|e| {
72            Error::new(
73                ErrorKind::InvalidInput,
74                format!("invalid S3 URI {:?}: {}", root_uri, e),
75            )
76        })?;
77        let runtime_config = HyperFileRuntimeConfig::default();
78        let staging_config = StagingConfig::new_s3_staging(s3uri.bucket, "", s3uri.key, None);
79        let staging = S3Staging::from(client, staging_config, runtime_config).await?;
80        Ok(Self {
81            client: client.clone(),
82            bucket: s3uri.bucket.to_owned(),
83            root_path: s3uri.key.to_owned(),
84            staging,
85        })
86    }
87
88    async fn load_segment_sum(&self, segid: SegmentId) -> Result<SegmentSum> {
89        self.staging.open(segid).await
90    }
91
92    /// The URI this cleaner was built for, as a writer would need it.
93    pub(crate) fn root_uri(&self) -> String {
94        format!("s3://{}/{}", self.bucket, self.root_path)
95    }
96
97    /// The client, for an operation that needs to open the file itself.
98    pub(crate) fn client(&self) -> &Client {
99        &self.client
100    }
101
102    async fn load_raw_inode(&self) -> Result<(InodeRaw, Option<OnDiskState>)> {
103        let mut raw_inode = InodeRaw::default();
104        let inode_state = self.staging.load_inode(raw_inode.as_mut_u8_slice()).await?;
105        Ok((raw_inode, inode_state))
106    }
107
108    /// Every location the bmap still points into, split by what holds it.
109    ///
110    /// `collect_ptrs` reports every location the map references — its metadata
111    /// nodes and the block pointers stored in its leaves — for both bmap shapes,
112    /// so this needs no shape branch of its own. It returns `Err` rather than a
113    /// short list if any subtree could not be enumerated, which is what makes it
114    /// safe to derive a delete set from: a truncated reachable set is
115    /// indistinguishable from a complete one.
116    ///
117    /// `collect_meta_ptrs` is the metadata-node subset of the same walk, and
118    /// reads no leaf node to answer. Having both is what lets an object's live
119    /// bytes be counted — a metadata node is `meta_block_size`, a data block is
120    /// `data_block_size`, and a pointer does not say which it is — and it is what
121    /// tells a compactor which objects it cannot free: a live metadata node is
122    /// not relocatable by rewriting data blocks.
123    fn walk_live_ptrs<L>(
124        bmap: BMap<'_, BlockIndex, BlockPtr, BlockPtr, L, NullNodeCache>,
125    ) -> Result<LivePtrs>
126    where
127        L: BlockLoader<BlockPtr> + Clone,
128    {
129        // `collect_ptrs` is a plain `async fn`, but a bmap built with
130        // btree-ondisk's `rc` feature is `!Send`, so awaiting it directly
131        // would make `collect`/`prune` non-`Send` and break the `Cleaner`
132        // trait. The walk therefore stays on the blocking thread this
133        // function is called from, driven the same way btree-ondisk drives
134        // itself under `tokio-runtime`.
135        let handle = tokio::runtime::Handle::current();
136        let on_staging = |v: Vec<BlockPtr>| -> BTreeSet<BlockPtr> {
137            v.into_iter().filter(BlockPtrFormat::is_on_staging).collect()
138        };
139        let all = on_staging(handle.block_on(bmap.collect_ptrs())?);
140        let meta = on_staging(handle.block_on(bmap.collect_meta_ptrs())?);
141
142        // Where in the file the mapped blocks are, as windows of at most
143        // `DATA_WINDOW_BLOCKS`. `compact` asks `block_placement` about these
144        // rather than about every index from 0 to EOF: a file can be sparse on a
145        // scale that sweep cannot cover — one with blocks at `1 << 56` has 2^44
146        // indices below them. `seek_key` skips a hole in O(log n), and the walk
147        // above has already loaded every node, so this costs no request.
148        //
149        // Done here, on the map this walk already holds, rather than through
150        // `Hyper::fs_seek_data` on the compacting handle: that takes `&self`,
151        // and holding a `&Hyper` across an await keeps a future from being
152        // `Send`, since a `Hyper` is not `Sync`.
153        let mut windows: Vec<(BlockIndex, usize)> = Vec::new();
154        let mut pos: BlockIndex = 0;
155        loop {
156            let key = match handle.block_on(bmap.seek_key(&pos)) {
157                Ok(key) => key,
158                Err(e) if e.kind() == ErrorKind::NotFound => break,
159                Err(e) => return Err(e),
160            };
161            match windows.last_mut() {
162                Some((start, n)) if *start + *n as BlockIndex == key
163                    && *n + DATA_WINDOW_BLOCKS <= 4 * DATA_WINDOW_BLOCKS =>
164                {
165                    *n += DATA_WINDOW_BLOCKS;
166                },
167                _ => windows.push((key, DATA_WINDOW_BLOCKS)),
168            }
169            match key.checked_add(DATA_WINDOW_BLOCKS as BlockIndex) {
170                Some(next) => pos = next,
171                None => break,
172            }
173        }
174        Ok(LivePtrs { all, meta, windows })
175    }
176
177    /// Every segment object that exists under the container's prefix, with its
178    /// size, and whatever else is there.
179    ///
180    /// A segment object is named by [`Segment::segid_to_staging_file_id`]: ten
181    /// digits, and a `.<part>` suffix when the checkpoint was streamed. Anything
182    /// else under the prefix — the inode object, a nested WAL prefix — is not one
183    /// and is skipped, as is any key with a further `/` in it. What was skipped is
184    /// reported rather than discarded: a key under this prefix that is neither the
185    /// inode nor a segment object is most likely a WAL, and a compaction must not
186    /// run against a container whose log it is not reading.
187    ///
188    /// The delete set is derived from what is *here*, not from arithmetic over
189    /// `[low, high]`. Two reasons, both new in `hyperfile 0.7.0`: a checkpoint's
190    /// object names are no longer derivable from its number, since a streamed one
191    /// is `<cno>.0`, `<cno>.1`, … with no bound the caller knows; and a partial
192    /// whose checkpoint was abandoned is referenced by nothing and named by
193    /// nothing, so a walk that only visits computed names cannot see it at all.
194    async fn list_segment_objects(&self) -> Result<ObjectListing> {
195        let prefix = format!("{}/", self.root_path);
196        let mut listing = ObjectListing::default();
197        let mut pages = self.client.list_objects_v2()
198            .bucket(&self.bucket)
199            .prefix(&prefix)
200            .into_paginator()
201            .send();
202        while let Some(page) = pages.next().await {
203            let page = page.map_err(|e| Error::new(
204                s3_error_kind(&e),
205                format!("listing segment objects under s3://{}/{} failed: {}",
206                    self.bucket, prefix, e),
207            ))?;
208            for obj in page.contents() {
209                let Some(key) = obj.key() else { continue };
210                if key == self.staging.inode_file {
211                    continue;
212                }
213                let Some(name) = key.strip_prefix(&prefix) else { continue };
214                match parse_segment_object_name(name) {
215                    Some(segid) => {
216                        listing.objects.push((segid, obj.size().unwrap_or(0).max(0) as u64));
217                        if let Some(t) = obj.last_modified() {
218                            listing.modified.insert(segid, t.secs());
219                        }
220                    },
221                    None => {
222                        listing.foreign += 1;
223                        if listing.foreign_sample.is_none() {
224                            listing.foreign_sample = Some(key.to_string());
225                        }
226                    },
227                }
228            }
229        }
230        listing.objects.sort();
231        Ok(listing)
232    }
233
234    /// Delete `delete_keys` from `bucket` in batches of 1000 (the
235    /// `DeleteObjects` per-call max).
236    ///
237    /// Failures are tracked at two levels and merged into the same
238    /// returned error:
239    ///
240    /// 1. **SDK-level (transport / auth / etc.)**: the entire batch
241    ///    is recorded as failed. The SDK error is logged at
242    ///    `error!` level.
243    /// 2. **Per-key (S3 partial success in `DeleteObjectsOutput`)**:
244    ///    `DeleteObjects` is a partial-success API; even when the
245    ///    batch returns `Ok`, individual keys may have failed (e.g.
246    ///    `AccessDenied`, `NoSuchKey`). The response's `errors`
247    ///    list is parsed and each failing key recorded.
248    ///
249    /// Returns an error if any key (at either level) failed, whose kind is
250    /// that of the **first** failure, classified by `hyperfile`'s own
251    /// [`s3_error_kind`] and [`s3_code_kind`] — the same rule its batch delete
252    /// follows, so a caller decides whether to retry `prune` by the kind alone
253    /// (`hyperfile::is_retryable`): a throttled or unreachable store is worth
254    /// another try, a denied key is not. The message includes the
255    /// failed object count and a sample failed key. Successfully-deleted keys
256    /// stay deleted (S3 has no transactional batch delete).
257    async fn do_delete_objects(
258        client: &Client,
259        bucket: &str,
260        delete_keys: Vec<String>,
261    ) -> Result<()> {
262        use aws_sdk_s3::types::ObjectIdentifier;
263        if delete_keys.is_empty() {
264            return Ok(());
265        }
266        let mut failed_keys: Vec<String> = Vec::new();
267        let mut first_kind: Option<ErrorKind> = None;
268        for keys in delete_keys.chunks(1000) {
269            let obj_ids = keys.iter()
270                .map(|k| {
271                    ObjectIdentifier::builder()
272                        .key(k)
273                        .build()
274                        .expect("ObjectIdentifier requires non-empty key")
275                })
276                .collect::<Vec<ObjectIdentifier>>();
277            // We use quiet=true so the response only carries the
278            // `errors` list (not the success list); this is what
279            // we want for partial-failure tracking.
280            let delete = aws_sdk_s3::types::Delete::builder()
281                .set_objects(Some(obj_ids))
282                .quiet(true)
283                .build()
284                .expect("Delete::builder requires non-empty object list");
285            match client.delete_objects()
286                .bucket(bucket)
287                .delete(delete)
288                .send()
289                .await
290            {
291                Ok(output) => {
292                    // Partial-success path: even though the batch
293                    // returned Ok at the SDK level, individual keys
294                    // may have failed. Pick those out of the
295                    // response.
296                    if let Some(errors) = output.errors {
297                        for e in errors {
298                            let key = e.key.unwrap_or_default();
299                            let code = e.code.as_deref().unwrap_or("?");
300                            let msg = e.message.as_deref().unwrap_or("");
301                            error!("DeleteObjects per-key failure: key={} code={} message={}",
302                                key, code, msg);
303                            first_kind.get_or_insert(s3_code_kind(e.code.as_deref()));
304                            if !key.is_empty() {
305                                failed_keys.push(key);
306                            }
307                        }
308                    }
309                },
310                Err(sdk_err) => {
311                    // SDK-level failure: treat the whole batch as
312                    // failed (we don't know which keys made it).
313                    error!("DeleteObjects batch failed (bucket={}, count={}): {}",
314                        bucket, keys.len(), sdk_err);
315                    first_kind.get_or_insert(s3_error_kind(&sdk_err));
316                    failed_keys.extend(keys.iter().cloned());
317                }
318            }
319        }
320        if failed_keys.is_empty() {
321            Ok(())
322        } else {
323            Err(Error::new(
324                first_kind.unwrap_or(ErrorKind::Other),
325                format!(
326                    "DeleteObjects failed for {} object(s); first failed key: {:?}",
327                    failed_keys.len(),
328                    failed_keys.first(),
329                ),
330            ))
331        }
332    }
333
334    /// Everything `collect`, `report` and `compact` need from storage, gathered
335    /// once: the checkpoint being walked, the container's pointer format and
336    /// block sizes, every live location, and every object that exists.
337    ///
338    /// `high == 0` is a sentinel meaning "use the latest checkpoint
339    /// (`inode.get_last_cno()`)".
340    pub(crate) async fn view(&self, low: Cno, high: Cno) -> Result<ContainerView> {
341        // Read unconditionally, not only for the `high == 0` sentinel: the
342        // container's pointer format lives in the inode, and it decides both
343        // which object holds a checkpoint's summary and how a pointer decodes.
344        let (raw_inode, inode_state) = self.load_raw_inode().await?;
345        refuse_unsegmented(&raw_inode)?;
346        let fmt = container_ptr_format(&raw_inode)?;
347        let latest_compacted = raw_inode.is_compacted();
348        let compaction = inode_state.as_ref().map(|s| s.compaction.clone()).unwrap_or_default();
349        let latest_cno = Inode::from_raw(&raw_inode, inode_state).get_last_cno();
350        let high_cno = if high == 0 { latest_cno } else { high };
351
352        debug!("view of container low: {}, high: {}, format: {:?}", low, high_cno, fmt);
353        validate_segid_range(low, high_cno)?;
354
355        // Which object holds this checkpoint's summary, metadata and inode:
356        // part 0 when the container streams its checkpoints, the bare name
357        // otherwise. `Staging::open` would find it either way — given a bare
358        // number with no object of that name it tries the summary part too —
359        // but naming it outright saves a 404 on every parted container, which
360        // since `hyperfile 0.7.0` is every container it creates.
361        let high_summary = summary_obj(high_cno, &fmt);
362        let ss = self.load_segment_sum(high_summary).await?;
363        // The summary has to be the checkpoint asked for. Every segment this
364        // crate's hyperfile writes carries its checkpoint number, in either
365        // layout; a header that does not is not one, and walking a bmap read out
366        // of it would report whatever the bytes happen to point at.
367        if !ss.hdr.has_magic() || ss.hdr.s_cno != high_cno {
368            return Err(Error::new(
369                ErrorKind::InvalidData,
370                format!(
371                    "{} does not read as checkpoint {}'s summary (magic {:#x}, checkpoint {})",
372                    high_summary, high_cno, ss.hdr.s_magic, ss.hdr.s_cno,
373                ),
374            ));
375        }
376        // The whole inode is moved into the walk, not just its `i_bmap` field:
377        // `BMap::read` wants an 8-byte-aligned buffer, and `BMapRawType` is
378        // `[u8; N]`, whose own alignment is 1, so a copy of the field alone
379        // lands wherever the compiler put it. `InodeRaw` is `align(8)` and
380        // `i_bmap` sits at an 8-aligned offset within it, so borrowing the
381        // field out of an owned inode is aligned by construction.
382        // The newest checkpoint is walked as the stored inode has it, the map
383        // every reader opens, rather than as its summary does. An older
384        // checkpoint asked for is walked as its summary has it.
385        let walked_stored = high_cno == latest_cno;
386        let ss_inode = if walked_stored { raw_inode } else { ss.hdr.s_inode };
387        // Both sizes come from the segment header rather than from the inode's
388        // `i_meta_config`: they have been there since the first release, while
389        // that field was zero on containers old enough that it cannot be
390        // decoded at all.
391        let meta_block_size = 1usize << ss.hdr.s_meta_blk_shift;
392        let data_block_size = 1usize << ss.hdr.s_data_blk_shift;
393
394        // Walk the bmap on a blocking thread: a bmap built with
395        // btree-ondisk's `rc` feature is `!Send`, so it cannot be held
396        // across an await in a `Cleaner` method.
397        // Counters are per-loader and the cleaner has nowhere to report
398        // them, so give this walk its own throwaway ReadTiming rather
399        // than threading one through the public API.
400        let loader = S3BlockLoader::new(
401            &self.client, &self.bucket, &self.root_path,
402            Arc::new(ReadTiming::default()),
403        );
404        let join = tokio::task::spawn_blocking(move || -> Result<LivePtrs> {
405            let bmap = BMap::<BlockIndex, BlockPtr, BlockPtr, S3BlockLoader, NullNodeCache>
406                ::read(&ss_inode.i_bmap, meta_block_size, loader, NullNodeCache)?;
407            Self::walk_live_ptrs(bmap)
408        });
409        let live = match join.await {
410            Ok(inner) => inner?,
411            Err(join_err) => {
412                return Err(Error::other(
413                    format!("bmap walk task failed to join: {}", join_err),
414                ));
415            }
416        };
417
418        let listing = self.list_segment_objects().await?;
419        // The object the walk read has to be one the listing names, under that
420        // name. Otherwise this cleaner and the store disagree about what the
421        // objects are called — and `prune` keeps `high_summary` by its name.
422        if !listing.objects.iter().any(|(segid, _)| *segid == high_summary) {
423            return Err(Error::new(
424                ErrorKind::InvalidData,
425                format!(
426                    "the listing has no object named {}, the summary just read; \
427                     refusing to judge what is referenced",
428                    high_summary,
429                ),
430            ));
431        }
432        Ok(ContainerView {
433            low,
434            high_cno,
435            stored: raw_inode,
436            walked_stored,
437            walked: ss_inode,
438            compaction,
439            latest_compacted,
440            fmt,
441            high_summary,
442            data_block_size,
443            meta_block_size,
444            live,
445            listing,
446        })
447    }
448
449    /// Inner shared implementation used by both `collect` and
450    /// `prune`. Returns `(referenced, unreferenced)` lists of segment
451    /// *objects* where:
452    ///  - `referenced` is every object the latest published inode's metadata
453    ///    tree still points into, whether or not it falls in `[low, high]`.
454    ///  - `unreferenced` is every object that exists under the container's
455    ///    prefix whose checkpoint number falls in `[low, high]` and which
456    ///    nothing points into, less the two kinds this keeps regardless (see
457    ///    [`partition_objects`]).
458    async fn collect_unref_seg(
459        &self,
460        low: Cno,
461        high: Cno,
462    ) -> Result<(Vec<SegmentId>, Vec<SegmentId>)> {
463        self.collect_within(low, high, std::time::Duration::ZERO).await
464    }
465
466    /// [`Self::collect_unref_seg`], keeping what a reader may still read within
467    /// `grace`: everything a version current at any time in the last `grace`
468    /// points into, and every object written in it. See [`PruneOptions`].
469    async fn collect_within(
470        &self,
471        low: Cno,
472        high: Cno,
473        grace: std::time::Duration,
474    ) -> Result<(Vec<SegmentId>, Vec<SegmentId>)> {
475        let view = self.view(low, high).await?;
476        // What the newest version reads through is never reclaimable, whatever
477        // `high` says.
478        let mut roots = Vec::new();
479        if !view.walked_stored {
480            roots.push(view.stored);
481        }
482        // Nor what a compaction attached to it reads through: the writer's next
483        // publish of data takes its copies in, whatever the grace.
484        if let Some(root) = view.attached_root()? {
485            roots.push(root);
486        }
487        let mut young = BTreeSet::new();
488        if !grace.is_zero() {
489            // The store's clock, not this host's: what is compared with it is the
490            // store's own times.
491            let (now, _) = self.staging.load_segment_timestamp(SegmentId::new_from_cno(view.high_cno)).await?;
492            let cutoff = now.saturating_sub(grace.as_secs().min(i64::MAX as u64) as i64);
493            young = view.listing.modified.iter().filter(|(_, t)| **t >= cutoff).map(|(s, _)| *s).collect();
494            roots.extend(self.versions_since(&view, cutoff).await?);
495        }
496        let more = self.walk_more(&view, roots).await?;
497        let mut referenced = view.referenced_objects();
498        referenced.extend(more.iter().map(|p| view.object_of(p)));
499        let (v_ref, mut unref) = partition_objects(
500            &view.object_ids(),
501            &referenced,
502            view.low,
503            view.high_cno,
504            view.high_summary,
505        );
506        unref.retain(|o| !young.contains(o));
507        Ok((v_ref, unref))
508    }
509
510    /// The inodes of every version current at some time since `cutoff`: each
511    /// published since, and the one current at `cutoff`.
512    ///
513    /// A checkpoint is its summary object, and its inode is in that object's
514    /// header; a version's time is when that object was written. A compaction
515    /// attached to the stored inode is not a version: no reader opens it until
516    /// the writer has taken it in and published.
517    async fn versions_since(&self, view: &ContainerView, cutoff: i64) -> Result<Vec<InodeRaw>> {
518        let stream = view.fmt.is_stream();
519        let modified = |id: &SegmentId| view.listing.modified.get(id).copied();
520        let summaries: Vec<(SegmentId, i64)> = view.listing.objects.iter()
521            .map(|(id, _)| *id)
522            .filter(|id| *id == summary_obj(id.as_cno(), &view.fmt))
523            .filter_map(|id| modified(&id).map(|t| (id, t)))
524            .collect();
525        // From the newest checkpoint written before the window: the version
526        // current when it opened is that one or a compaction of it.
527        let from = summaries.iter().rposition(|(_, t)| *t < cutoff).unwrap_or(0);
528        let mut versions: Vec<(i64, InodeRaw)> = Vec::new();
529        for (id, t) in &summaries[from..] {
530            match self.staging.load_segment_header(*id, stream).await {
531                Ok(hdr) => versions.push((*t, hdr.s_inode)),
532                // Gone since the listing, or never a checkpoint: nothing to keep.
533                Err(e) if matches!(e.kind(), ErrorKind::NotFound | ErrorKind::InvalidData) => {
534                    debug!("prune: {} has no readable summary ({}); not a version to keep", id, e);
535                },
536                Err(e) => return Err(e),
537            }
538        }
539        // The window: every version from the last one written before it.
540        let opened = versions.iter().filter(|(t, _)| *t < cutoff).map(|(t, _)| *t).max();
541        Ok(versions.into_iter()
542            .filter(|(t, _)| opened.is_none_or(|o| *t >= o))
543            .map(|(_, v)| v)
544            .collect())
545    }
546
547    /// `prune`, keeping what readers may still be reading; see [`PruneOptions`].
548    /// Returns the objects deleted, or with `dry_run` the objects it would delete.
549    pub async fn prune_with(&self, low: Cno, high: Cno, opts: &PruneOptions) -> Result<Vec<SegmentId>> {
550        let (_, unref) = self.collect_within(low, high, opts.grace).await?;
551        if opts.dry_run {
552            return Ok(unref);
553        }
554        let delete_keys = unref.iter()
555            .map(|segid| format!("{}/{}", self.root_path, Segment::segid_to_staging_file_id(*segid)))
556            .collect::<Vec<String>>();
557        debug!("unreferenced segments to delete: {:?}", delete_keys);
558        Self::do_delete_objects(&self.client, &self.bucket, delete_keys).await?;
559        Ok(unref)
560    }
561
562    /// Every object the map of `root` points into, read whole.
563    pub(crate) async fn reachable_objects(&self, view: &ContainerView, root: InodeRaw) -> Result<BTreeSet<SegmentId>> {
564        let loader = S3BlockLoader::new(
565            &self.client, &self.bucket, &self.root_path,
566            Arc::new(ReadTiming::default()),
567        );
568        let meta_block_size = view.meta_block_size;
569        let join = tokio::task::spawn_blocking(move || -> Result<BTreeSet<BlockPtr>> {
570            walk_roots(&loader, meta_block_size, &[root], BTreeSet::new())
571        });
572        let ptrs = match join.await {
573            Ok(inner) => inner?,
574            Err(join_err) => return Err(Error::other(format!("bmap walk task failed to join: {}", join_err))),
575        };
576        Ok(ptrs.iter().map(|p| view.object_of(p)).collect())
577    }
578
579    /// Every location the maps of `roots` reach that `view` did not: the nodes
580    /// under a pointer `view` already walked are the same nodes, since an object
581    /// is never changed once written, so only what differs is read.
582    async fn walk_more(&self, view: &ContainerView, roots: Vec<InodeRaw>) -> Result<BTreeSet<BlockPtr>> {
583        let roots: Vec<InodeRaw> = roots.into_iter()
584            .filter(|r| r.i_bmap != view.walked_root().i_bmap)
585            .collect();
586        if roots.is_empty() {
587            return Ok(BTreeSet::new());
588        }
589        let loader = S3BlockLoader::new(
590            &self.client, &self.bucket, &self.root_path,
591            Arc::new(ReadTiming::default()),
592        );
593        let known = view.live.meta.clone();
594        let meta_block_size = view.meta_block_size;
595        let join = tokio::task::spawn_blocking(move || -> Result<BTreeSet<BlockPtr>> {
596            walk_roots(&loader, meta_block_size, &roots, known)
597        });
598        match join.await {
599            Ok(inner) => inner,
600            Err(join_err) => Err(Error::other(format!("bmap walk task failed to join: {}", join_err))),
601        }
602    }
603}
604
605/// Every location on staging reachable from the maps of `roots`, less the
606/// subtrees under node pointers in `known`. Driven on the calling thread, as
607/// [`S3Cleaner::walk_live_ptrs`] is.
608fn walk_roots(loader: &S3BlockLoader, meta_block_size: usize, roots: &[InodeRaw], mut known: BTreeSet<BlockPtr>)
609    -> Result<BTreeSet<BlockPtr>>
610{
611    use btree_ondisk::node::{BtreeNode, DirectNode, BTREE_NODE_LEVEL_MIN};
612    let handle = tokio::runtime::Handle::current();
613    let mut found = BTreeSet::new();
614    for root in roots {
615        let node = BtreeNode::<BlockIndex, BlockPtr, BlockPtr>::from_slice_ref(&root.i_bmap)?;
616        let ud = node.get_userdata();
617        // Child node pointers to read, level by level.
618        let mut frontier: Vec<BlockPtr> = Vec::new();
619        if !node.is_large() {
620            let direct = DirectNode::<BlockPtr>::from_slice_ref(&root.i_bmap)?;
621            for i in 0..direct.get_capacity() {
622                found.insert(*direct.get_val(i));
623            }
624        } else {
625            let leaf = node.get_level() == BTREE_NODE_LEVEL_MIN;
626            for i in 0..node.get_nchild() {
627                let v = *node.get_val::<BlockPtr>(i);
628                if leaf {
629                    found.insert(v);
630                } else if known.insert(v) {
631                    found.insert(v);
632                    frontier.push(v);
633                }
634            }
635        }
636        let mut side: std::collections::HashMap<BlockPtr, Vec<u8>> = std::collections::HashMap::new();
637        while !frontier.is_empty() {
638            let mut bufs: Vec<Vec<u8>> = Vec::with_capacity(frontier.len());
639            let mut ask: Vec<BlockPtr> = Vec::new();
640            let mut have: Vec<(BlockPtr, Vec<u8>)> = Vec::new();
641            for p in frontier.drain(..) {
642                match side.remove(&p) {
643                    Some(b) => have.push((p, b)),
644                    None => { ask.push(p); bufs.push(vec![0u8; meta_block_size]); },
645                }
646            }
647            if !ask.is_empty() {
648                for (p, b) in handle.block_on(loader.read_batch(&ask, &mut bufs, ud))? {
649                    side.insert(p, b);
650                }
651                have.extend(ask.into_iter().zip(bufs));
652            }
653            for (_, bytes) in have {
654                let node = BtreeNode::<BlockIndex, BlockPtr, BlockPtr>::from_slice_ref(&bytes)?;
655                let leaf = node.get_level() == BTREE_NODE_LEVEL_MIN;
656                for i in 0..node.get_nchild() {
657                    let v = *node.get_val::<BlockPtr>(i);
658                    if leaf {
659                        found.insert(v);
660                    } else if known.insert(v) {
661                        found.insert(v);
662                        frontier.push(v);
663                    }
664                }
665            }
666        }
667    }
668    found.retain(BlockPtrFormat::is_on_staging);
669    Ok(found)
670}
671
672/// How [`S3Cleaner::prune_with`] decides what nothing reads any more.
673///
674/// [`Cleaner::prune`] takes everything the newest version does not point into,
675/// less what a compaction attached to it points into (and refuses, deleting
676/// nothing, where the inode says one is attached and its metadata does not
677/// read). A reader that opened an older version, or a plan made under one and
678/// fetched outside the handle, reads objects the newest version no longer points
679/// at, and a compaction beside the writer writes copies nothing points at until
680/// it attaches them. `grace` keeps both:
681///
682/// - every object that a version current at any time in the last `grace` points
683///   into: each checkpoint published in it, and the version current when it
684///   began;
685/// - every object written in the last `grace`.
686///
687/// So `grace` has to be longer than any reader holds a version, and a
688/// compaction's `max_age` shorter than `grace`. Times are the store's. Nothing
689/// above the newest published number is taken either way: the writer's next
690/// checkpoint is being written there.
691#[derive(Clone, Debug, Default)]
692pub struct PruneOptions {
693    /// How long a version is kept after it stops being the newest, and an object
694    /// after it is written. Zero is [`Cleaner::prune`].
695    pub grace: std::time::Duration,
696    /// Decide and report, delete nothing.
697    pub dry_run: bool,
698}
699
700/// Every live location of a container, and the metadata-node subset of it.
701pub(crate) struct LivePtrs {
702    pub(crate) all: BTreeSet<BlockPtr>,
703    pub(crate) meta: BTreeSet<BlockPtr>,
704    /// `(first, count)` ranges of block indices that together cover every
705    /// mapped block, ascending. They may also cover holes; they skip the large
706    /// ones.
707    pub(crate) windows: Vec<(BlockIndex, usize)>,
708}
709
710/// The granularity of [`LivePtrs::windows`]: a mapped block starts a window this
711/// long, and windows that touch are merged up to four of these.
712pub(crate) const DATA_WINDOW_BLOCKS: usize = 64 * 1024;
713
714/// What a listing of the container's prefix found.
715#[derive(Default)]
716pub(crate) struct ObjectListing {
717    /// Segment objects and their sizes, ascending.
718    pub(crate) objects: Vec<(SegmentId, u64)>,
719    /// When each was written, in seconds since the epoch, as the store reports.
720    pub(crate) modified: std::collections::BTreeMap<SegmentId, i64>,
721    /// How many keys under the prefix were neither the inode nor a segment
722    /// object. A WAL root nested under the container is the usual reason.
723    pub(crate) foreign: usize,
724    pub(crate) foreign_sample: Option<String>,
725}
726
727/// One pass over a container's storage, shared by every operation.
728pub(crate) struct ContainerView {
729    pub(crate) low: Cno,
730    pub(crate) high_cno: Cno,
731    /// The inode as stored: the newest version, which readers open.
732    pub(crate) stored: InodeRaw,
733    /// Whether `live` is the stored inode's map. Not when an older `high` was
734    /// asked for, whose own map was walked instead.
735    pub(crate) walked_stored: bool,
736    /// The inode whose map was walked when it is not the stored one.
737    pub(crate) walked: InodeRaw,
738    /// What the stored inode object's metadata says of an attached compaction.
739    pub(crate) compaction: StoredCompaction,
740    /// Whether the published inode marks its checkpoint as a compaction
741    /// (`InodeRaw::FLAG_COMPACTED`): it moved blocks and changed no content.
742    pub(crate) latest_compacted: bool,
743    pub(crate) fmt: BlockPtrFormat,
744    pub(crate) high_summary: SegmentId,
745    pub(crate) data_block_size: usize,
746    pub(crate) meta_block_size: usize,
747    pub(crate) live: LivePtrs,
748    pub(crate) listing: ObjectListing,
749}
750
751impl ContainerView {
752    /// The inode whose map `live` is.
753    /// The map of the compaction attached to the stored inode, as an inode to
754    /// walk, or `None` where none is. The attached-compaction bit without
755    /// metadata that reads is refused rather than guessed at: what the
756    /// compaction's copies are cannot then be known, and they may be all that
757    /// the writer's next publish reads through.
758    pub(crate) fn attached_root(&self) -> Result<Option<InodeRaw>> {
759        let root = |p: &PendingCompaction| {
760            let mut r = self.stored;
761            r.i_bmap = p.root;
762            r
763        };
764        match &self.compaction {
765            StoredCompaction::Pending(p) => Ok(Some(root(p))),
766            StoredCompaction::Unreadable(why) => Err(Error::new(ErrorKind::InvalidData, format!(
767                "the inode object's compaction metadata does not read ({}); refusing to judge what \
768                 an attached compaction reads through", why))),
769            StoredCompaction::None if self.stored.is_compaction_pending() => Err(Error::new(ErrorKind::InvalidData,
770                "the inode says a compaction is attached and its object has no compaction metadata; \
771                 refusing to judge what that compaction reads through")),
772            StoredCompaction::None => Ok(None),
773        }
774    }
775
776    pub(crate) fn walked_root(&self) -> &InodeRaw {
777        if self.walked_stored { &self.stored } else { &self.walked }
778    }
779
780    pub(crate) fn object_ids(&self) -> Vec<SegmentId> {
781        self.listing.objects.iter().map(|(segid, _)| *segid).collect()
782    }
783
784    /// The objects at least one live location names.
785    pub(crate) fn referenced_objects(&self) -> BTreeSet<SegmentId> {
786        self.live.all.iter()
787            .map(|p| BlockPtrFormat::decode(p, &self.fmt).0)
788            .collect()
789    }
790
791    /// Which object a live location is in.
792    pub(crate) fn object_of(&self, blkptr: &BlockPtr) -> SegmentId {
793        BlockPtrFormat::decode(blkptr, &self.fmt).0
794    }
795}
796
797/// `root_uri` without one trailing `/`, unless that `/` is part of the scheme.
798///
799/// Stripping it unconditionally turned `s3://` into `s3:/`, which the parse then
800/// refused for not starting with `s3://` — the opposite of what was wrong with it.
801/// `hyperfile 0.10.3` fixed the same thing in `StagingConfig::new_s3_uri`.
802fn trim_root_uri(root_uri: &str) -> &str {
803    match root_uri.strip_suffix('/') {
804        Some(r) if !r.ends_with(":/") => r,
805        _ => root_uri,
806    }
807}
808
809/// `S_IFMT` and the two device types, as the inode stores its mode. POSIX fixes
810/// these values, so they are the same on every host.
811const S_IFMT: u32 = 0o170000;
812const S_IFCHR: u32 = 0o020000;
813const S_IFBLK: u32 = 0o060000;
814
815/// Refuse an inode whose checkpoint fields do not hold checkpoint numbers.
816///
817/// An inline file keeps its data in the inode's tail, over `i_last_seq`,
818/// `i_last_cno` and the bmap root; a device node keeps its `rdev` in
819/// `i_last_cno`. Read as a checkpoint number either is whatever the bytes
820/// happen to be. Nothing would be deleted on the strength of it — the summary
821/// it named would not be found — but the error would point at a missing segment
822/// rather than at what is actually wrong.
823fn refuse_unsegmented(raw: &InodeRaw) -> Result<()> {
824    let fmt = raw.i_mode & S_IFMT;
825    let what = if raw.is_inline() {
826        Some("an inline file, whose data lives in the inode")
827    } else if fmt == S_IFCHR || fmt == S_IFBLK {
828        Some("a device node, which stores rdev where a checkpoint number would be")
829    } else {
830        None
831    };
832    match what {
833        Some(what) => Err(Error::new(
834            ErrorKind::InvalidInput,
835            format!("this container is {}: it has no segments to collect, prune or compact", what),
836        )),
837        None => Ok(()),
838    }
839}
840
841/// The pointer format the container was written with, as its inode records it.
842///
843/// A container written before `i_meta_config` was populated carries zero there;
844/// it predates every format with parts, so its pointers read as `MicroGroup`.
845/// That is the only case read on a guess.
846///
847/// Anything else that does not decode is refused. Up to 0.10.5 every such value
848/// fell back to `MicroGroup`, and on a `StreamSegment` container — format byte 4,
849/// new in `hyperfile 0.11.0` — that made a 0.10.5 cleaner decode no pointer into
850/// any of its `<cno>.<part>` objects and report every one of them unreferenced,
851/// the newest checkpoint included. Measured: a container of two checkpoints in
852/// twelve objects came back `referenced=0`, all twelve offered to `prune`. A
853/// format this build does not know is a reason to stop, not to guess.
854fn container_ptr_format(raw_inode: &InodeRaw) -> Result<BlockPtrFormat> {
855    if raw_inode.i_meta_config == 0 {
856        return Ok(BlockPtrFormat::MicroGroup);
857    }
858    HyperFileMetaConfig::try_from_u32(raw_inode.i_meta_config)
859        .map(|cfg| cfg.block_ptr_format)
860        .map_err(|e| Error::new(
861            ErrorKind::Unsupported,
862            format!(
863                "the container's inode records a format this build cannot read \
864                 (meta config {:#x}: {}); a cleaner built against an older hyperfile \
865                 must not walk it, since it would misread every pointer",
866                raw_inode.i_meta_config, e,
867            ),
868        ))
869}
870
871/// The object holding a checkpoint's summary, metadata blocks and inode.
872fn summary_obj(cno: Cno, fmt: &BlockPtrFormat) -> SegmentId {
873    let segid = SegmentId::new_from_cno(cno);
874    if fmt.is_parted() {
875        segid.at_part(Segment::SUMMARY_PART)
876    } else {
877        segid
878    }
879}
880
881/// A segment object name, or `None` for a key that is not one.
882///
883/// The names are what [`Segment::segid_to_staging_file_id`] produces: ten digits
884/// zero-padded, optionally `.<part>`. `None` and `Some(0)` are different names
885/// on purpose upstream, so they stay different objects here.
886fn parse_segment_object_name(name: &str) -> Option<SegmentId> {
887    // Nested prefixes — a WAL root under the container, say — are not segment
888    // objects of this container.
889    if name.contains('/') {
890        return None;
891    }
892    let (seq_str, part_str) = match name.split_once('.') {
893        Some((seq, part)) => (seq, Some(part)),
894        None => (name, None),
895    };
896    if seq_str.len() != 10 || !seq_str.bytes().all(|b| b.is_ascii_digit()) {
897        return None;
898    }
899    // Ten digits can name more than a `u32` holds; such an object cannot have
900    // been written by this format, so it is not one of ours.
901    let seq_id = seq_str.parse::<u32>().ok()?;
902    match part_str {
903        None => Some(SegmentId::new(seq_id)),
904        Some(p) => {
905            if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) {
906                return None;
907            }
908            Some(SegmentId::with_part(seq_id, p.parse::<u16>().ok()?))
909        },
910    }
911}
912
913/// Split what exists into what is still pointed into and what can go.
914///
915/// `referenced` is object-granular, which is what makes a parted container
916/// reclaimable at all. Two kinds of object are kept even when no pointer names
917/// them:
918///
919/// - **The summary of `high`.** It is the object this cleaner just read to find
920///   the checkpoint's bmap, and the one it would read on the next run. A
921///   container whose newest checkpoint holds no live block — an empty file, or
922///   one whose every block was superseded within the same checkpoint — would
923///   otherwise have its own head deleted, and the next `collect` would fail with
924///   `NoSuchKey`.
925/// - **The summary part of a checkpoint that still has a live part.** Only
926///   reachable under `PartedSegment`: the parts carry data, part 0 carries the
927///   summary and metadata that describe them. Deleting it would leave a
928///   checkpoint whose blocks are live but which nothing can parse, which is what
929///   `open_cno` and a repair tool need. "Not a partial" is upstream's own
930///   predicate for that object — a partial is a part above zero, and an unparted
931///   checkpoint is never one.
932///
933/// Everything else in `[low, high]` that nothing points into is reclaimable,
934/// including the partials of a checkpoint that was abandoned — those share their
935/// checkpoint number with the retry that replaced them, so they are invisible to
936/// anything reasoning in whole checkpoints.
937fn partition_objects(
938    existing: &[SegmentId],
939    referenced: &BTreeSet<SegmentId>,
940    low: Cno,
941    high: Cno,
942    high_summary: SegmentId,
943) -> (Vec<SegmentId>, Vec<SegmentId>) {
944    let live_cnos: BTreeSet<Cno> = referenced.iter().map(|s| s.as_cno()).collect();
945    let mut unref = Vec::new();
946    for obj in existing {
947        let cno = obj.as_cno();
948        if cno < low || cno > high {
949            continue;
950        }
951        if referenced.contains(obj) {
952            continue;
953        }
954        if *obj == high_summary {
955            continue;
956        }
957        if !obj.is_partial() && live_cnos.contains(&cno) {
958            continue;
959        }
960        unref.push(*obj);
961    }
962    let v_ref = referenced.iter().copied().collect::<Vec<SegmentId>>();
963    (v_ref, unref)
964}
965
966/// Reject a checkpoint range that cannot be walked.
967///
968/// `[low, high]` is inclusive at both ends, so `low == high_cno` is a
969/// well-formed query — "what does checkpoint N reference, and is anything in
970/// `[N, N]` unreferenced" — and is answered normally. It is reached whenever a
971/// caller asks about the entire history of a file with a single checkpoint:
972/// `collect(1, 0)`, the form this crate's own documentation gives as the typical
973/// call, resolves `high` to `get_last_cno() == 1` and so lands on `low == high`.
974/// Rejecting it made `collect` and `prune` unusable on any freshly created file
975/// until it had been flushed at least twice.
976///
977/// `low > high_cno` is genuinely empty and stays an error, which also covers
978/// `high_cno == 0` (no checkpoint has ever been published, so there is no
979/// metadata tree to walk).
980///
981/// A checkpoint number past `u32::MAX` is refused rather than truncated: that is
982/// what a block pointer can name, so no object could carry the number, and
983/// `SegmentId::new_from_cno` would assert on it in a debug build.
984fn validate_segid_range(low: Cno, high_cno: Cno) -> Result<()> {
985    if low > high_cno {
986        return Err(Error::new(
987            ErrorKind::InvalidInput,
988            format!("invalid segment range [{}, {}]", low, high_cno),
989        ));
990    }
991    if high_cno > u32::MAX as Cno {
992        return Err(Error::new(
993            ErrorKind::InvalidInput,
994            format!("checkpoint {} is past what a block pointer can name ({})",
995                high_cno, u32::MAX),
996        ));
997    }
998    Ok(())
999}
1000
1001impl Cleaner for S3Cleaner {
1002    async fn collect(
1003        &self,
1004        low: Cno,
1005        high: Cno,
1006    ) -> Result<(Vec<SegmentId>, Vec<SegmentId>)> {
1007        self.collect_unref_seg(low, high).await
1008    }
1009
1010    /// Prune unreferenced segment objects in `[low, high]`. Implemented
1011    /// as `collect` followed by `DeleteObjects` of the
1012    /// unreferenced list.
1013    async fn prune(&self, low: Cno, high: Cno) -> Result<()> {
1014        let (_, non_ref) = self.collect_unref_seg(low, high).await?;
1015        let delete_keys = non_ref.iter()
1016            .map(|segid| format!(
1017                "{}/{}",
1018                self.root_path,
1019                Segment::segid_to_staging_file_id(*segid),
1020            ))
1021            .collect::<Vec<String>>();
1022        debug!("unreferenced segments to delete: {:?}", delete_keys);
1023        Self::do_delete_objects(&self.client, &self.bucket, delete_keys).await
1024    }
1025}
1026
1027#[cfg(test)]
1028mod tests {
1029    use super::*;
1030
1031    /// Builder calls in `do_delete_objects` panic only on empty
1032    /// inputs that we guard against. Verify the empty-input fast
1033    /// path.
1034    #[tokio::test]
1035    async fn delete_empty_keys_is_noop() {
1036        let cfg = aws_config::load_from_env().await;
1037        let client = Client::new(&cfg);
1038        // Empty list -> no SDK call -> Ok regardless of credentials.
1039        let r = S3Cleaner::do_delete_objects(&client, "any-bucket", Vec::new()).await;
1040        assert!(r.is_ok(), "empty key list should not call S3");
1041    }
1042
1043    /// `[low, high]` is inclusive at both ends, so a single-checkpoint
1044    /// file -- where `collect(1, 0)` resolves to `low == high == 1` --
1045    /// must be accepted. It used to be rejected, which made the
1046    /// documented call form fail on any file flushed only once.
1047    #[test]
1048    fn segid_range_accepts_a_single_checkpoint() {
1049        assert!(validate_segid_range(1, 1).is_ok());
1050    }
1051
1052    #[test]
1053    fn segid_range_accepts_a_normal_span() {
1054        assert!(validate_segid_range(1, 5).is_ok());
1055    }
1056
1057    /// Only a genuinely empty range is an error. `high_segid == 0` is the
1058    /// "no checkpoint published yet" case and falls out of the same rule.
1059    #[test]
1060    fn segid_range_rejects_an_empty_span() {
1061        for (low, high) in [(5u64, 3u64), (2, 1), (1, 0)] {
1062            let e = validate_segid_range(low, high)
1063                .expect_err("low > high must be rejected");
1064            assert_eq!(e.kind(), ErrorKind::InvalidInput);
1065            assert!(
1066                e.to_string().contains(&format!("[{}, {}]", low, high)),
1067                "error should name the range, got {:?}", e.to_string(),
1068            );
1069        }
1070    }
1071
1072    #[test]
1073    fn a_trailing_slash_is_trimmed_but_not_the_schemes() {
1074        assert_eq!(trim_root_uri("s3://b/k/"), "s3://b/k");
1075        assert_eq!(trim_root_uri("s3://b/k"), "s3://b/k");
1076        assert_eq!(trim_root_uri("s3://"), "s3://");
1077        assert_eq!(trim_root_uri("S3://"), "S3://");
1078        assert_eq!(trim_root_uri(""), "");
1079    }
1080
1081    /// Only a zero meta config is read on a guess; a value that does not decode is
1082    /// refused rather than read as `MicroGroup`, which is how a 0.10.5 cleaner
1083    /// misread every pointer of a `StreamSegment` container.
1084    #[test]
1085    fn an_unknown_format_is_refused_not_guessed() {
1086        let legacy = InodeRaw { i_meta_config: 0, ..Default::default() };
1087        assert_eq!(container_ptr_format(&legacy).unwrap(), BlockPtrFormat::MicroGroup);
1088
1089        for fmt in [BlockPtrFormat::MicroGroup, BlockPtrFormat::PartedSegment,
1090                    BlockPtrFormat::StreamSegment] {
1091            let cfg = HyperFileMetaConfig::new(56, 4096, 4096, fmt);
1092            let raw = InodeRaw { i_meta_config: cfg.as_u32(), ..Default::default() };
1093            assert_eq!(container_ptr_format(&raw).unwrap(), fmt);
1094        }
1095
1096        let future = HyperFileMetaConfig::new(56, 4096, 4096, BlockPtrFormat::PartedSegment)
1097            .as_u32() & !0xFF | 0x7F;
1098        let raw = InodeRaw { i_meta_config: future, ..Default::default() };
1099        let e = container_ptr_format(&raw).expect_err("an unknown format byte must be refused");
1100        assert_eq!(e.kind(), ErrorKind::Unsupported);
1101    }
1102
1103    /// An inline file's checkpoint fields hold its data, and a device node's hold
1104    /// its rdev. Neither is a checkpoint number, so the walk refuses up front
1105    /// rather than looking for a segment the bytes happen to name.
1106    #[test]
1107    fn an_inode_without_segments_is_refused() {
1108        let mut inline = InodeRaw { i_mode: 0o100644, ..Default::default() };
1109        inline.set_inline(b"hello");
1110        let e = refuse_unsegmented(&inline).expect_err("inline must be refused");
1111        assert_eq!(e.kind(), ErrorKind::InvalidInput);
1112        assert!(e.to_string().contains("inline"), "{}", e);
1113
1114        for mode in [0o020644u32, 0o060644] {
1115            let dev = InodeRaw { i_mode: mode, ..Default::default() };
1116            let e = refuse_unsegmented(&dev).expect_err("a device node must be refused");
1117            assert!(e.to_string().contains("device"), "{}", e);
1118        }
1119
1120        for mode in [0o100644u32, 0o040755, 0o120777] {
1121            let ok = InodeRaw { i_mode: mode, ..Default::default() };
1122            assert!(refuse_unsegmented(&ok).is_ok(), "mode {:o} has segments", mode);
1123        }
1124    }
1125
1126    /// A checkpoint number no pointer could name is refused rather than
1127    /// truncated into one that names a different object.
1128    #[test]
1129    fn segid_range_rejects_a_checkpoint_past_a_pointer() {
1130        let e = validate_segid_range(1, u32::MAX as Cno + 1)
1131            .expect_err("a cno past u32::MAX must be rejected");
1132        assert_eq!(e.kind(), ErrorKind::InvalidInput);
1133        assert!(validate_segid_range(1, u32::MAX as Cno).is_ok());
1134    }
1135
1136    // --- object names ---
1137
1138    #[test]
1139    fn object_names_round_trip_through_the_parser() {
1140        for segid in [
1141            SegmentId::new(1),
1142            SegmentId::new(4294967295),
1143            SegmentId::with_part(7, 0),
1144            SegmentId::with_part(7, 16383),
1145        ] {
1146            let name = Segment::segid_to_staging_file_id(segid);
1147            assert_eq!(parse_segment_object_name(&name), Some(segid),
1148                "name {:?} did not parse back", name);
1149        }
1150    }
1151
1152    /// An unsuffixed name and part 0 are different objects upstream, so they
1153    /// have to stay different here — sharing them would mean deleting the
1154    /// summary of one container shape while reporting the other.
1155    #[test]
1156    fn a_bare_name_is_not_part_zero() {
1157        assert_eq!(parse_segment_object_name("0000000007"), Some(SegmentId::new(7)));
1158        assert_eq!(parse_segment_object_name("0000000007.0"),
1159            Some(SegmentId::with_part(7, 0)));
1160    }
1161
1162    #[test]
1163    fn keys_that_are_not_segment_objects_are_skipped() {
1164        for name in [
1165            "inode",              // the inode object
1166            "wal/0000000001",     // a nested prefix, not an object of ours
1167            "0000000001/0_0_4096",
1168            "000000001",          // nine digits
1169            "00000000012",        // eleven
1170            "9999999999",         // ten digits, past what a u32 can hold
1171            "0000000001.",        // empty part
1172            "0000000001.x",
1173            "0000000001.65536",   // past what a u16 can hold
1174            "0000000001.0.1",
1175            "",
1176        ] {
1177            assert_eq!(parse_segment_object_name(name), None,
1178                "{:?} should not be read as a segment object", name);
1179        }
1180    }
1181
1182    // --- what gets reclaimed ---
1183
1184    fn partition(
1185        existing: &[SegmentId],
1186        referenced: &[SegmentId],
1187        low: Cno,
1188        high: Cno,
1189        high_summary: SegmentId,
1190    ) -> Vec<SegmentId> {
1191        let set: BTreeSet<SegmentId> = referenced.iter().copied().collect();
1192        let (_, unref) = partition_objects(existing, &set, low, high, high_summary);
1193        unref
1194    }
1195
1196    /// The unparted case, which is every container written before 0.7.0: an
1197    /// object is a checkpoint, and one nothing points into goes.
1198    #[test]
1199    fn an_unreferenced_checkpoint_is_reclaimable() {
1200        let existing = [SegmentId::new(1), SegmentId::new(2), SegmentId::new(3)];
1201        let unref = partition(&existing, &[SegmentId::new(3)], 1, 3, SegmentId::new(3));
1202        assert_eq!(unref, vec![SegmentId::new(1), SegmentId::new(2)]);
1203    }
1204
1205    /// Objects outside `[low, high]` are not candidates, which is what makes a
1206    /// prune safe against a writer publishing underneath it.
1207    #[test]
1208    fn objects_outside_the_range_are_left_alone() {
1209        let existing = [
1210            SegmentId::new(1), SegmentId::new(2), SegmentId::new(3), SegmentId::new(4),
1211        ];
1212        let unref = partition(&existing, &[], 2, 3, SegmentId::new(9));
1213        assert_eq!(unref, vec![SegmentId::new(2), SegmentId::new(3)]);
1214    }
1215
1216    /// A checkpoint whose newest state holds no live block would otherwise have
1217    /// its own head deleted, and the next `collect` would fail to read it.
1218    #[test]
1219    fn the_summary_of_high_is_kept_even_when_nothing_points_into_it() {
1220        let existing = [SegmentId::new(1), SegmentId::new(2)];
1221        let unref = partition(&existing, &[], 1, 2, SegmentId::new(2));
1222        assert_eq!(unref, vec![SegmentId::new(1)]);
1223
1224        // Same for a streamed container, where the head is part 0.
1225        let existing = [SegmentId::with_part(2, 0), SegmentId::with_part(2, 1)];
1226        let unref = partition(&existing, &[], 1, 2, SegmentId::with_part(2, 0));
1227        assert_eq!(unref, vec![SegmentId::with_part(2, 1)]);
1228    }
1229
1230    /// The partials of an abandoned checkpoint share their number with the
1231    /// retry that replaced them. Reclaiming them is the whole reason the
1232    /// referenced set is object-granular.
1233    #[test]
1234    fn an_abandoned_partial_is_reclaimable_while_its_checkpoint_lives() {
1235        let existing = [
1236            SegmentId::with_part(5, 0), // summary of the checkpoint that landed
1237            SegmentId::with_part(5, 1), // its data
1238            SegmentId::with_part(5, 2), // written, then abandoned by a rollback
1239        ];
1240        let referenced = [SegmentId::with_part(5, 0), SegmentId::with_part(5, 1)];
1241        let unref = partition(&existing, &referenced, 1, 9, SegmentId::with_part(9, 0));
1242        assert_eq!(unref, vec![SegmentId::with_part(5, 2)]);
1243    }
1244
1245    /// A live checkpoint has to stay parseable: its data parts are named by
1246    /// pointers, its summary is what describes them.
1247    #[test]
1248    fn the_summary_part_of_a_live_checkpoint_is_kept() {
1249        let existing = [SegmentId::with_part(4, 0), SegmentId::with_part(4, 3)];
1250        let unref = partition(&existing, &[SegmentId::with_part(4, 3)], 1, 9,
1251            SegmentId::with_part(9, 0));
1252        assert!(unref.is_empty(), "part 0 describes the live part 3, got {:?}", unref);
1253    }
1254
1255    /// With no live part left, the summary goes too — otherwise a parted
1256    /// container would never give any storage back.
1257    #[test]
1258    fn a_wholly_dead_parted_checkpoint_goes_including_its_summary() {
1259        let existing = [
1260            SegmentId::with_part(4, 0), SegmentId::with_part(4, 1),
1261            SegmentId::with_part(6, 0),
1262        ];
1263        let unref = partition(&existing, &[SegmentId::with_part(6, 0)], 1, 9,
1264            SegmentId::with_part(6, 0));
1265        assert_eq!(unref, vec![SegmentId::with_part(4, 0), SegmentId::with_part(4, 1)]);
1266    }
1267}