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::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 = trim_root_uri(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    /// The URI this cleaner was built for, as a writer would need it.
92    pub(crate) fn root_uri(&self) -> String {
93        format!("s3://{}/{}", self.bucket, self.root_path)
94    }
95
96    /// The client, for an operation that needs to open the file itself.
97    pub(crate) fn client(&self) -> &Client {
98        &self.client
99    }
100
101    async fn load_raw_inode(&self) -> Result<(InodeRaw, Option<OnDiskState>)> {
102        let mut raw_inode = InodeRaw::default();
103        let inode_state = self.staging.load_inode(raw_inode.as_mut_u8_slice()).await?;
104        Ok((raw_inode, inode_state))
105    }
106
107    /// Every location the bmap still points into, split by what holds it.
108    ///
109    /// `collect_ptrs` reports every location the map references — its metadata
110    /// nodes and the block pointers stored in its leaves — for both bmap shapes,
111    /// so this needs no shape branch of its own. It returns `Err` rather than a
112    /// short list if any subtree could not be enumerated, which is what makes it
113    /// safe to derive a delete set from: a truncated reachable set is
114    /// indistinguishable from a complete one.
115    ///
116    /// `collect_meta_ptrs` is the metadata-node subset of the same walk, and
117    /// reads no leaf node to answer. Having both is what lets an object's live
118    /// bytes be counted — a metadata node is `meta_block_size`, a data block is
119    /// `data_block_size`, and a pointer does not say which it is — and it is what
120    /// tells a compactor which objects it cannot free: a live metadata node is
121    /// not relocatable by rewriting data blocks.
122    fn walk_live_ptrs<L>(
123        bmap: BMap<'_, BlockIndex, BlockPtr, BlockPtr, L, NullNodeCache>,
124    ) -> Result<LivePtrs>
125    where
126        L: BlockLoader<BlockPtr> + Clone,
127    {
128        // `collect_ptrs` is a plain `async fn`, but a bmap built with
129        // btree-ondisk's `rc` feature is `!Send`, so awaiting it directly
130        // would make `collect`/`prune` non-`Send` and break the `Cleaner`
131        // trait. The walk therefore stays on the blocking thread this
132        // function is called from, driven the same way btree-ondisk drives
133        // itself under `tokio-runtime`.
134        let handle = tokio::runtime::Handle::current();
135        let on_staging = |v: Vec<BlockPtr>| -> BTreeSet<BlockPtr> {
136            v.into_iter().filter(BlockPtrFormat::is_on_staging).collect()
137        };
138        let all = on_staging(handle.block_on(bmap.collect_ptrs())?);
139        let meta = on_staging(handle.block_on(bmap.collect_meta_ptrs())?);
140
141        // Where in the file the mapped blocks are, as windows of at most
142        // `DATA_WINDOW_BLOCKS`. `compact` asks `block_placement` about these
143        // rather than about every index from 0 to EOF: a file can be sparse on a
144        // scale that sweep cannot cover — one with blocks at `1 << 56` has 2^44
145        // indices below them. `seek_key` skips a hole in O(log n), and the walk
146        // above has already loaded every node, so this costs no request.
147        //
148        // Done here, on the map this walk already holds, rather than through
149        // `Hyper::fs_seek_data` on the compacting handle: that takes `&self`,
150        // and holding a `&Hyper` across an await keeps a future from being
151        // `Send`, since a `Hyper` is not `Sync`.
152        let mut windows: Vec<(BlockIndex, usize)> = Vec::new();
153        let mut pos: BlockIndex = 0;
154        loop {
155            let key = match handle.block_on(bmap.seek_key(&pos)) {
156                Ok(key) => key,
157                Err(e) if e.kind() == ErrorKind::NotFound => break,
158                Err(e) => return Err(e),
159            };
160            match windows.last_mut() {
161                Some((start, n)) if *start + *n as BlockIndex == key
162                    && *n + DATA_WINDOW_BLOCKS <= 4 * DATA_WINDOW_BLOCKS =>
163                {
164                    *n += DATA_WINDOW_BLOCKS;
165                },
166                _ => windows.push((key, DATA_WINDOW_BLOCKS)),
167            }
168            match key.checked_add(DATA_WINDOW_BLOCKS as BlockIndex) {
169                Some(next) => pos = next,
170                None => break,
171            }
172        }
173        Ok(LivePtrs { all, meta, windows })
174    }
175
176    /// Every segment object that exists under the container's prefix, with its
177    /// size, and whatever else is there.
178    ///
179    /// A segment object is named by [`Segment::segid_to_staging_file_id`]: ten
180    /// digits, and a `.<part>` suffix when the checkpoint was streamed. Anything
181    /// else under the prefix — the inode object, a nested WAL prefix — is not one
182    /// and is skipped, as is any key with a further `/` in it. What was skipped is
183    /// reported rather than discarded: a key under this prefix that is neither the
184    /// inode nor a segment object is most likely a WAL, and a compaction must not
185    /// run against a container whose log it is not reading.
186    ///
187    /// The delete set is derived from what is *here*, not from arithmetic over
188    /// `[low, high]`. Two reasons, both new in `hyperfile 0.7.0`: a checkpoint's
189    /// object names are no longer derivable from its number, since a streamed one
190    /// is `<cno>.0`, `<cno>.1`, … with no bound the caller knows; and a partial
191    /// whose checkpoint was abandoned is referenced by nothing and named by
192    /// nothing, so a walk that only visits computed names cannot see it at all.
193    async fn list_segment_objects(&self) -> Result<ObjectListing> {
194        let prefix = format!("{}/", self.root_path);
195        let mut listing = ObjectListing::default();
196        let mut pages = self.client.list_objects_v2()
197            .bucket(&self.bucket)
198            .prefix(&prefix)
199            .into_paginator()
200            .send();
201        while let Some(page) = pages.next().await {
202            let page = page.map_err(|e| Error::new(
203                s3_error_kind(&e),
204                format!("listing segment objects under s3://{}/{} failed: {}",
205                    self.bucket, prefix, e),
206            ))?;
207            for obj in page.contents() {
208                let Some(key) = obj.key() else { continue };
209                if key == self.staging.inode_file {
210                    continue;
211                }
212                let Some(name) = key.strip_prefix(&prefix) else { continue };
213                match parse_segment_object_name(name) {
214                    Some(segid) => {
215                        listing.objects.push((segid, obj.size().unwrap_or(0).max(0) as u64));
216                    },
217                    None => {
218                        listing.foreign += 1;
219                        if listing.foreign_sample.is_none() {
220                            listing.foreign_sample = Some(key.to_string());
221                        }
222                    },
223                }
224            }
225        }
226        listing.objects.sort();
227        Ok(listing)
228    }
229
230    /// Delete `delete_keys` from `bucket` in batches of 1000 (the
231    /// `DeleteObjects` per-call max).
232    ///
233    /// Failures are tracked at two levels and merged into the same
234    /// returned error:
235    ///
236    /// 1. **SDK-level (transport / auth / etc.)**: the entire batch
237    ///    is recorded as failed. The SDK error is logged at
238    ///    `error!` level.
239    /// 2. **Per-key (S3 partial success in `DeleteObjectsOutput`)**:
240    ///    `DeleteObjects` is a partial-success API; even when the
241    ///    batch returns `Ok`, individual keys may have failed (e.g.
242    ///    `AccessDenied`, `NoSuchKey`). The response's `errors`
243    ///    list is parsed and each failing key recorded.
244    ///
245    /// Returns an error if any key (at either level) failed, whose kind is
246    /// that of the **first** failure, classified by `hyperfile`'s own
247    /// [`s3_error_kind`] and [`s3_code_kind`] — the same rule its batch delete
248    /// follows, so a caller decides whether to retry `prune` by the kind alone
249    /// (`hyperfile::is_retryable`): a throttled or unreachable store is worth
250    /// another try, a denied key is not. The message includes the
251    /// failed object count and a sample failed key. Successfully-deleted keys
252    /// stay deleted (S3 has no transactional batch delete).
253    async fn do_delete_objects(
254        client: &Client,
255        bucket: &str,
256        delete_keys: Vec<String>,
257    ) -> Result<()> {
258        use aws_sdk_s3::types::ObjectIdentifier;
259        if delete_keys.is_empty() {
260            return Ok(());
261        }
262        let mut failed_keys: Vec<String> = Vec::new();
263        let mut first_kind: Option<ErrorKind> = None;
264        for keys in delete_keys.chunks(1000) {
265            let obj_ids = keys.iter()
266                .map(|k| {
267                    ObjectIdentifier::builder()
268                        .key(k)
269                        .build()
270                        .expect("ObjectIdentifier requires non-empty key")
271                })
272                .collect::<Vec<ObjectIdentifier>>();
273            // We use quiet=true so the response only carries the
274            // `errors` list (not the success list); this is what
275            // we want for partial-failure tracking.
276            let delete = aws_sdk_s3::types::Delete::builder()
277                .set_objects(Some(obj_ids))
278                .quiet(true)
279                .build()
280                .expect("Delete::builder requires non-empty object list");
281            match client.delete_objects()
282                .bucket(bucket)
283                .delete(delete)
284                .send()
285                .await
286            {
287                Ok(output) => {
288                    // Partial-success path: even though the batch
289                    // returned Ok at the SDK level, individual keys
290                    // may have failed. Pick those out of the
291                    // response.
292                    if let Some(errors) = output.errors {
293                        for e in errors {
294                            let key = e.key.unwrap_or_default();
295                            let code = e.code.as_deref().unwrap_or("?");
296                            let msg = e.message.as_deref().unwrap_or("");
297                            error!("DeleteObjects per-key failure: key={} code={} message={}",
298                                key, code, msg);
299                            first_kind.get_or_insert(s3_code_kind(e.code.as_deref()));
300                            if !key.is_empty() {
301                                failed_keys.push(key);
302                            }
303                        }
304                    }
305                },
306                Err(sdk_err) => {
307                    // SDK-level failure: treat the whole batch as
308                    // failed (we don't know which keys made it).
309                    error!("DeleteObjects batch failed (bucket={}, count={}): {}",
310                        bucket, keys.len(), sdk_err);
311                    first_kind.get_or_insert(s3_error_kind(&sdk_err));
312                    failed_keys.extend(keys.iter().cloned());
313                }
314            }
315        }
316        if failed_keys.is_empty() {
317            Ok(())
318        } else {
319            Err(Error::new(
320                first_kind.unwrap_or(ErrorKind::Other),
321                format!(
322                    "DeleteObjects failed for {} object(s); first failed key: {:?}",
323                    failed_keys.len(),
324                    failed_keys.first(),
325                ),
326            ))
327        }
328    }
329
330    /// Everything `collect`, `report` and `compact` need from storage, gathered
331    /// once: the checkpoint being walked, the container's pointer format and
332    /// block sizes, every live location, and every object that exists.
333    ///
334    /// `high == 0` is a sentinel meaning "use the latest checkpoint
335    /// (`inode.get_last_cno()`)".
336    pub(crate) async fn view(&self, low: Cno, high: Cno) -> Result<ContainerView> {
337        // Read unconditionally, not only for the `high == 0` sentinel: the
338        // container's pointer format lives in the inode, and it decides both
339        // which object holds a checkpoint's summary and how a pointer decodes.
340        let (raw_inode, inode_state) = self.load_raw_inode().await?;
341        refuse_unsegmented(&raw_inode)?;
342        let fmt = container_ptr_format(&raw_inode);
343        let latest_compacted = raw_inode.is_compacted();
344        let high_cno = if high == 0 {
345            let inode = Inode::from_raw(&raw_inode, inode_state);
346            inode.get_last_cno()
347        } else {
348            high
349        };
350
351        debug!("view of container low: {}, high: {}, format: {:?}", low, high_cno, fmt);
352        validate_segid_range(low, high_cno)?;
353
354        // Which object holds this checkpoint's summary, metadata and inode:
355        // part 0 when the container streams its checkpoints, the bare name
356        // otherwise. `Staging::open` would find it either way — given a bare
357        // number with no object of that name it tries the summary part too —
358        // but naming it outright saves a 404 on every parted container, which
359        // since `hyperfile 0.7.0` is every container it creates.
360        let high_summary = summary_obj(high_cno, &fmt);
361        let ss = self.load_segment_sum(high_summary).await?;
362        // The whole inode is moved into the walk, not just its `i_bmap` field:
363        // `BMap::read` wants an 8-byte-aligned buffer, and `BMapRawType` is
364        // `[u8; N]`, whose own alignment is 1, so a copy of the field alone
365        // lands wherever the compiler put it. `InodeRaw` is `align(8)` and
366        // `i_bmap` sits at an 8-aligned offset within it, so borrowing the
367        // field out of an owned inode is aligned by construction.
368        let ss_inode = ss.hdr.s_inode;
369        // Both sizes come from the segment header rather than from the inode's
370        // `i_meta_config`: they have been there since the first release, while
371        // that field was zero on containers old enough that it cannot be
372        // decoded at all.
373        let meta_block_size = 1usize << ss.hdr.s_meta_blk_shift;
374        let data_block_size = 1usize << ss.hdr.s_data_blk_shift;
375
376        // Walk the bmap on a blocking thread: a bmap built with
377        // btree-ondisk's `rc` feature is `!Send`, so it cannot be held
378        // across an await in a `Cleaner` method.
379        // Counters are per-loader and the cleaner has nowhere to report
380        // them, so give this walk its own throwaway ReadTiming rather
381        // than threading one through the public API.
382        let loader = S3BlockLoader::new(
383            &self.client, &self.bucket, &self.root_path,
384            Arc::new(ReadTiming::default()),
385        );
386        let join = tokio::task::spawn_blocking(move || -> Result<LivePtrs> {
387            let bmap = BMap::<BlockIndex, BlockPtr, BlockPtr, S3BlockLoader, NullNodeCache>
388                ::read(&ss_inode.i_bmap, meta_block_size, loader, NullNodeCache)?;
389            Self::walk_live_ptrs(bmap)
390        });
391        let live = match join.await {
392            Ok(inner) => inner?,
393            Err(join_err) => {
394                return Err(Error::other(
395                    format!("bmap walk task failed to join: {}", join_err),
396                ));
397            }
398        };
399
400        let listing = self.list_segment_objects().await?;
401        Ok(ContainerView {
402            low,
403            high_cno,
404            latest_compacted,
405            fmt,
406            high_summary,
407            data_block_size,
408            meta_block_size,
409            live,
410            listing,
411        })
412    }
413
414    /// Inner shared implementation used by both `collect` and
415    /// `prune`. Returns `(referenced, unreferenced)` lists of segment
416    /// *objects* where:
417    ///  - `referenced` is every object the latest published inode's metadata
418    ///    tree still points into, whether or not it falls in `[low, high]`.
419    ///  - `unreferenced` is every object that exists under the container's
420    ///    prefix whose checkpoint number falls in `[low, high]` and which
421    ///    nothing points into, less the two kinds this keeps regardless (see
422    ///    [`partition_objects`]).
423    async fn collect_unref_seg(
424        &self,
425        low: Cno,
426        high: Cno,
427    ) -> Result<(Vec<SegmentId>, Vec<SegmentId>)> {
428        let view = self.view(low, high).await?;
429        Ok(partition_objects(
430            &view.object_ids(),
431            &view.referenced_objects(),
432            view.low,
433            view.high_cno,
434            view.high_summary,
435        ))
436    }
437}
438
439/// Every live location of a container, and the metadata-node subset of it.
440pub(crate) struct LivePtrs {
441    pub(crate) all: BTreeSet<BlockPtr>,
442    pub(crate) meta: BTreeSet<BlockPtr>,
443    /// `(first, count)` ranges of block indices that together cover every
444    /// mapped block, ascending. They may also cover holes; they skip the large
445    /// ones.
446    pub(crate) windows: Vec<(BlockIndex, usize)>,
447}
448
449/// The granularity of [`LivePtrs::windows`]: a mapped block starts a window this
450/// long, and windows that touch are merged up to four of these.
451pub(crate) const DATA_WINDOW_BLOCKS: usize = 64 * 1024;
452
453/// What a listing of the container's prefix found.
454#[derive(Default)]
455pub(crate) struct ObjectListing {
456    /// Segment objects and their sizes, ascending.
457    pub(crate) objects: Vec<(SegmentId, u64)>,
458    /// How many keys under the prefix were neither the inode nor a segment
459    /// object. A WAL root nested under the container is the usual reason.
460    pub(crate) foreign: usize,
461    pub(crate) foreign_sample: Option<String>,
462}
463
464/// One pass over a container's storage, shared by every operation.
465pub(crate) struct ContainerView {
466    pub(crate) low: Cno,
467    pub(crate) high_cno: Cno,
468    /// Whether the published inode marks its checkpoint as a compaction
469    /// (`InodeRaw::FLAG_COMPACTED`): it moved blocks and changed no content.
470    pub(crate) latest_compacted: bool,
471    pub(crate) fmt: BlockPtrFormat,
472    pub(crate) high_summary: SegmentId,
473    pub(crate) data_block_size: usize,
474    pub(crate) meta_block_size: usize,
475    pub(crate) live: LivePtrs,
476    pub(crate) listing: ObjectListing,
477}
478
479impl ContainerView {
480    pub(crate) fn object_ids(&self) -> Vec<SegmentId> {
481        self.listing.objects.iter().map(|(segid, _)| *segid).collect()
482    }
483
484    /// The objects at least one live location names.
485    pub(crate) fn referenced_objects(&self) -> BTreeSet<SegmentId> {
486        self.live.all.iter()
487            .map(|p| BlockPtrFormat::decode(p, &self.fmt).0)
488            .collect()
489    }
490
491    /// Which object a live location is in.
492    pub(crate) fn object_of(&self, blkptr: &BlockPtr) -> SegmentId {
493        BlockPtrFormat::decode(blkptr, &self.fmt).0
494    }
495}
496
497/// `root_uri` without one trailing `/`, unless that `/` is part of the scheme.
498///
499/// Stripping it unconditionally turned `s3://` into `s3:/`, which the parse then
500/// refused for not starting with `s3://` — the opposite of what was wrong with it.
501/// `hyperfile 0.10.3` fixed the same thing in `StagingConfig::new_s3_uri`.
502fn trim_root_uri(root_uri: &str) -> &str {
503    match root_uri.strip_suffix('/') {
504        Some(r) if !r.ends_with(":/") => r,
505        _ => root_uri,
506    }
507}
508
509/// `S_IFMT` and the two device types, as the inode stores its mode. POSIX fixes
510/// these values, so they are the same on every host.
511const S_IFMT: u32 = 0o170000;
512const S_IFCHR: u32 = 0o020000;
513const S_IFBLK: u32 = 0o060000;
514
515/// Refuse an inode whose checkpoint fields do not hold checkpoint numbers.
516///
517/// An inline file keeps its data in the inode's tail, over `i_last_seq`,
518/// `i_last_cno` and the bmap root; a device node keeps its `rdev` in
519/// `i_last_cno`. Read as a checkpoint number either is whatever the bytes
520/// happen to be. Nothing would be deleted on the strength of it — the summary
521/// it named would not be found — but the error would point at a missing segment
522/// rather than at what is actually wrong.
523fn refuse_unsegmented(raw: &InodeRaw) -> Result<()> {
524    let fmt = raw.i_mode & S_IFMT;
525    let what = if raw.is_inline() {
526        Some("an inline file, whose data lives in the inode")
527    } else if fmt == S_IFCHR || fmt == S_IFBLK {
528        Some("a device node, which stores rdev where a checkpoint number would be")
529    } else {
530        None
531    };
532    match what {
533        Some(what) => Err(Error::new(
534            ErrorKind::InvalidInput,
535            format!("this container is {}: it has no segments to collect, prune or compact", what),
536        )),
537        None => Ok(()),
538    }
539}
540
541/// The pointer format the container was written with, as its inode records it.
542///
543/// Falls back to the pre-parting format rather than failing. A container written
544/// before `i_meta_config` was populated carries zero there, which decodes to
545/// nothing usable — and a container that old cannot be parted, because parting
546/// did not exist. The only thing this decides is how a pointer's low bits are
547/// read and which name holds a summary, and for every format but
548/// `PartedSegment` those are what they have always been.
549fn container_ptr_format(raw_inode: &InodeRaw) -> BlockPtrFormat {
550    match HyperFileMetaConfig::try_from_u32(raw_inode.i_meta_config) {
551        Ok(cfg) => cfg.block_ptr_format,
552        Err(e) => {
553            debug!("inode meta config {:#x} is not one this build can read ({}); \
554                reading pointers as {:?}", raw_inode.i_meta_config, e,
555                BlockPtrFormat::MicroGroup);
556            BlockPtrFormat::MicroGroup
557        },
558    }
559}
560
561/// The object holding a checkpoint's summary, metadata blocks and inode.
562fn summary_obj(cno: Cno, fmt: &BlockPtrFormat) -> SegmentId {
563    let segid = SegmentId::new_from_cno(cno);
564    if fmt.is_parted() {
565        segid.at_part(Segment::SUMMARY_PART)
566    } else {
567        segid
568    }
569}
570
571/// A segment object name, or `None` for a key that is not one.
572///
573/// The names are what [`Segment::segid_to_staging_file_id`] produces: ten digits
574/// zero-padded, optionally `.<part>`. `None` and `Some(0)` are different names
575/// on purpose upstream, so they stay different objects here.
576fn parse_segment_object_name(name: &str) -> Option<SegmentId> {
577    // Nested prefixes — a WAL root under the container, say — are not segment
578    // objects of this container.
579    if name.contains('/') {
580        return None;
581    }
582    let (seq_str, part_str) = match name.split_once('.') {
583        Some((seq, part)) => (seq, Some(part)),
584        None => (name, None),
585    };
586    if seq_str.len() != 10 || !seq_str.bytes().all(|b| b.is_ascii_digit()) {
587        return None;
588    }
589    // Ten digits can name more than a `u32` holds; such an object cannot have
590    // been written by this format, so it is not one of ours.
591    let seq_id = seq_str.parse::<u32>().ok()?;
592    match part_str {
593        None => Some(SegmentId::new(seq_id)),
594        Some(p) => {
595            if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) {
596                return None;
597            }
598            Some(SegmentId::with_part(seq_id, p.parse::<u16>().ok()?))
599        },
600    }
601}
602
603/// Split what exists into what is still pointed into and what can go.
604///
605/// `referenced` is object-granular, which is what makes a parted container
606/// reclaimable at all. Two kinds of object are kept even when no pointer names
607/// them:
608///
609/// - **The summary of `high`.** It is the object this cleaner just read to find
610///   the checkpoint's bmap, and the one it would read on the next run. A
611///   container whose newest checkpoint holds no live block — an empty file, or
612///   one whose every block was superseded within the same checkpoint — would
613///   otherwise have its own head deleted, and the next `collect` would fail with
614///   `NoSuchKey`.
615/// - **The summary part of a checkpoint that still has a live part.** Only
616///   reachable under `PartedSegment`: the parts carry data, part 0 carries the
617///   summary and metadata that describe them. Deleting it would leave a
618///   checkpoint whose blocks are live but which nothing can parse, which is what
619///   `open_cno` and a repair tool need. "Not a partial" is upstream's own
620///   predicate for that object — a partial is a part above zero, and an unparted
621///   checkpoint is never one.
622///
623/// Everything else in `[low, high]` that nothing points into is reclaimable,
624/// including the partials of a checkpoint that was abandoned — those share their
625/// checkpoint number with the retry that replaced them, so they are invisible to
626/// anything reasoning in whole checkpoints.
627fn partition_objects(
628    existing: &[SegmentId],
629    referenced: &BTreeSet<SegmentId>,
630    low: Cno,
631    high: Cno,
632    high_summary: SegmentId,
633) -> (Vec<SegmentId>, Vec<SegmentId>) {
634    let live_cnos: BTreeSet<Cno> = referenced.iter().map(|s| s.as_cno()).collect();
635    let mut unref = Vec::new();
636    for obj in existing {
637        let cno = obj.as_cno();
638        if cno < low || cno > high {
639            continue;
640        }
641        if referenced.contains(obj) {
642            continue;
643        }
644        if *obj == high_summary {
645            continue;
646        }
647        if !obj.is_partial() && live_cnos.contains(&cno) {
648            continue;
649        }
650        unref.push(*obj);
651    }
652    let v_ref = referenced.iter().copied().collect::<Vec<SegmentId>>();
653    (v_ref, unref)
654}
655
656/// Reject a checkpoint range that cannot be walked.
657///
658/// `[low, high]` is inclusive at both ends, so `low == high_cno` is a
659/// well-formed query — "what does checkpoint N reference, and is anything in
660/// `[N, N]` unreferenced" — and is answered normally. It is reached whenever a
661/// caller asks about the entire history of a file with a single checkpoint:
662/// `collect(1, 0)`, the form this crate's own documentation gives as the typical
663/// call, resolves `high` to `get_last_cno() == 1` and so lands on `low == high`.
664/// Rejecting it made `collect` and `prune` unusable on any freshly created file
665/// until it had been flushed at least twice.
666///
667/// `low > high_cno` is genuinely empty and stays an error, which also covers
668/// `high_cno == 0` (no checkpoint has ever been published, so there is no
669/// metadata tree to walk).
670///
671/// A checkpoint number past `u32::MAX` is refused rather than truncated: that is
672/// what a block pointer can name, so no object could carry the number, and
673/// `SegmentId::new_from_cno` would assert on it in a debug build.
674fn validate_segid_range(low: Cno, high_cno: Cno) -> Result<()> {
675    if low > high_cno {
676        return Err(Error::new(
677            ErrorKind::InvalidInput,
678            format!("invalid segment range [{}, {}]", low, high_cno),
679        ));
680    }
681    if high_cno > u32::MAX as Cno {
682        return Err(Error::new(
683            ErrorKind::InvalidInput,
684            format!("checkpoint {} is past what a block pointer can name ({})",
685                high_cno, u32::MAX),
686        ));
687    }
688    Ok(())
689}
690
691impl Cleaner for S3Cleaner {
692    async fn collect(
693        &self,
694        low: Cno,
695        high: Cno,
696    ) -> Result<(Vec<SegmentId>, Vec<SegmentId>)> {
697        self.collect_unref_seg(low, high).await
698    }
699
700    /// Prune unreferenced segment objects in `[low, high]`. Implemented
701    /// as `collect` followed by `DeleteObjects` of the
702    /// unreferenced list.
703    async fn prune(&self, low: Cno, high: Cno) -> Result<()> {
704        let (_, non_ref) = self.collect_unref_seg(low, high).await?;
705        let delete_keys = non_ref.iter()
706            .map(|segid| format!(
707                "{}/{}",
708                self.root_path,
709                Segment::segid_to_staging_file_id(*segid),
710            ))
711            .collect::<Vec<String>>();
712        debug!("unreferenced segments to delete: {:?}", delete_keys);
713        Self::do_delete_objects(&self.client, &self.bucket, delete_keys).await
714    }
715}
716
717#[cfg(test)]
718mod tests {
719    use super::*;
720
721    /// Builder calls in `do_delete_objects` panic only on empty
722    /// inputs that we guard against. Verify the empty-input fast
723    /// path.
724    #[tokio::test]
725    async fn delete_empty_keys_is_noop() {
726        let cfg = aws_config::load_from_env().await;
727        let client = Client::new(&cfg);
728        // Empty list -> no SDK call -> Ok regardless of credentials.
729        let r = S3Cleaner::do_delete_objects(&client, "any-bucket", Vec::new()).await;
730        assert!(r.is_ok(), "empty key list should not call S3");
731    }
732
733    /// `[low, high]` is inclusive at both ends, so a single-checkpoint
734    /// file -- where `collect(1, 0)` resolves to `low == high == 1` --
735    /// must be accepted. It used to be rejected, which made the
736    /// documented call form fail on any file flushed only once.
737    #[test]
738    fn segid_range_accepts_a_single_checkpoint() {
739        assert!(validate_segid_range(1, 1).is_ok());
740    }
741
742    #[test]
743    fn segid_range_accepts_a_normal_span() {
744        assert!(validate_segid_range(1, 5).is_ok());
745    }
746
747    /// Only a genuinely empty range is an error. `high_segid == 0` is the
748    /// "no checkpoint published yet" case and falls out of the same rule.
749    #[test]
750    fn segid_range_rejects_an_empty_span() {
751        for (low, high) in [(5u64, 3u64), (2, 1), (1, 0)] {
752            let e = validate_segid_range(low, high)
753                .expect_err("low > high must be rejected");
754            assert_eq!(e.kind(), ErrorKind::InvalidInput);
755            assert!(
756                e.to_string().contains(&format!("[{}, {}]", low, high)),
757                "error should name the range, got {:?}", e.to_string(),
758            );
759        }
760    }
761
762    #[test]
763    fn a_trailing_slash_is_trimmed_but_not_the_schemes() {
764        assert_eq!(trim_root_uri("s3://b/k/"), "s3://b/k");
765        assert_eq!(trim_root_uri("s3://b/k"), "s3://b/k");
766        assert_eq!(trim_root_uri("s3://"), "s3://");
767        assert_eq!(trim_root_uri("S3://"), "S3://");
768        assert_eq!(trim_root_uri(""), "");
769    }
770
771    /// An inline file's checkpoint fields hold its data, and a device node's hold
772    /// its rdev. Neither is a checkpoint number, so the walk refuses up front
773    /// rather than looking for a segment the bytes happen to name.
774    #[test]
775    fn an_inode_without_segments_is_refused() {
776        let mut inline = InodeRaw { i_mode: 0o100644, ..Default::default() };
777        inline.set_inline(b"hello");
778        let e = refuse_unsegmented(&inline).expect_err("inline must be refused");
779        assert_eq!(e.kind(), ErrorKind::InvalidInput);
780        assert!(e.to_string().contains("inline"), "{}", e);
781
782        for mode in [0o020644u32, 0o060644] {
783            let dev = InodeRaw { i_mode: mode, ..Default::default() };
784            let e = refuse_unsegmented(&dev).expect_err("a device node must be refused");
785            assert!(e.to_string().contains("device"), "{}", e);
786        }
787
788        for mode in [0o100644u32, 0o040755, 0o120777] {
789            let ok = InodeRaw { i_mode: mode, ..Default::default() };
790            assert!(refuse_unsegmented(&ok).is_ok(), "mode {:o} has segments", mode);
791        }
792    }
793
794    /// A checkpoint number no pointer could name is refused rather than
795    /// truncated into one that names a different object.
796    #[test]
797    fn segid_range_rejects_a_checkpoint_past_a_pointer() {
798        let e = validate_segid_range(1, u32::MAX as Cno + 1)
799            .expect_err("a cno past u32::MAX must be rejected");
800        assert_eq!(e.kind(), ErrorKind::InvalidInput);
801        assert!(validate_segid_range(1, u32::MAX as Cno).is_ok());
802    }
803
804    // --- object names ---
805
806    #[test]
807    fn object_names_round_trip_through_the_parser() {
808        for segid in [
809            SegmentId::new(1),
810            SegmentId::new(4294967295),
811            SegmentId::with_part(7, 0),
812            SegmentId::with_part(7, 16383),
813        ] {
814            let name = Segment::segid_to_staging_file_id(segid);
815            assert_eq!(parse_segment_object_name(&name), Some(segid),
816                "name {:?} did not parse back", name);
817        }
818    }
819
820    /// An unsuffixed name and part 0 are different objects upstream, so they
821    /// have to stay different here — sharing them would mean deleting the
822    /// summary of one container shape while reporting the other.
823    #[test]
824    fn a_bare_name_is_not_part_zero() {
825        assert_eq!(parse_segment_object_name("0000000007"), Some(SegmentId::new(7)));
826        assert_eq!(parse_segment_object_name("0000000007.0"),
827            Some(SegmentId::with_part(7, 0)));
828    }
829
830    #[test]
831    fn keys_that_are_not_segment_objects_are_skipped() {
832        for name in [
833            "inode",              // the inode object
834            "wal/0000000001",     // a nested prefix, not an object of ours
835            "0000000001/0_0_4096",
836            "000000001",          // nine digits
837            "00000000012",        // eleven
838            "9999999999",         // ten digits, past what a u32 can hold
839            "0000000001.",        // empty part
840            "0000000001.x",
841            "0000000001.65536",   // past what a u16 can hold
842            "0000000001.0.1",
843            "",
844        ] {
845            assert_eq!(parse_segment_object_name(name), None,
846                "{:?} should not be read as a segment object", name);
847        }
848    }
849
850    // --- what gets reclaimed ---
851
852    fn partition(
853        existing: &[SegmentId],
854        referenced: &[SegmentId],
855        low: Cno,
856        high: Cno,
857        high_summary: SegmentId,
858    ) -> Vec<SegmentId> {
859        let set: BTreeSet<SegmentId> = referenced.iter().copied().collect();
860        let (_, unref) = partition_objects(existing, &set, low, high, high_summary);
861        unref
862    }
863
864    /// The unparted case, which is every container written before 0.7.0: an
865    /// object is a checkpoint, and one nothing points into goes.
866    #[test]
867    fn an_unreferenced_checkpoint_is_reclaimable() {
868        let existing = [SegmentId::new(1), SegmentId::new(2), SegmentId::new(3)];
869        let unref = partition(&existing, &[SegmentId::new(3)], 1, 3, SegmentId::new(3));
870        assert_eq!(unref, vec![SegmentId::new(1), SegmentId::new(2)]);
871    }
872
873    /// Objects outside `[low, high]` are not candidates, which is what makes a
874    /// prune safe against a writer publishing underneath it.
875    #[test]
876    fn objects_outside_the_range_are_left_alone() {
877        let existing = [
878            SegmentId::new(1), SegmentId::new(2), SegmentId::new(3), SegmentId::new(4),
879        ];
880        let unref = partition(&existing, &[], 2, 3, SegmentId::new(9));
881        assert_eq!(unref, vec![SegmentId::new(2), SegmentId::new(3)]);
882    }
883
884    /// A checkpoint whose newest state holds no live block would otherwise have
885    /// its own head deleted, and the next `collect` would fail to read it.
886    #[test]
887    fn the_summary_of_high_is_kept_even_when_nothing_points_into_it() {
888        let existing = [SegmentId::new(1), SegmentId::new(2)];
889        let unref = partition(&existing, &[], 1, 2, SegmentId::new(2));
890        assert_eq!(unref, vec![SegmentId::new(1)]);
891
892        // Same for a streamed container, where the head is part 0.
893        let existing = [SegmentId::with_part(2, 0), SegmentId::with_part(2, 1)];
894        let unref = partition(&existing, &[], 1, 2, SegmentId::with_part(2, 0));
895        assert_eq!(unref, vec![SegmentId::with_part(2, 1)]);
896    }
897
898    /// The partials of an abandoned checkpoint share their number with the
899    /// retry that replaced them. Reclaiming them is the whole reason the
900    /// referenced set is object-granular.
901    #[test]
902    fn an_abandoned_partial_is_reclaimable_while_its_checkpoint_lives() {
903        let existing = [
904            SegmentId::with_part(5, 0), // summary of the checkpoint that landed
905            SegmentId::with_part(5, 1), // its data
906            SegmentId::with_part(5, 2), // written, then abandoned by a rollback
907        ];
908        let referenced = [SegmentId::with_part(5, 0), SegmentId::with_part(5, 1)];
909        let unref = partition(&existing, &referenced, 1, 9, SegmentId::with_part(9, 0));
910        assert_eq!(unref, vec![SegmentId::with_part(5, 2)]);
911    }
912
913    /// A live checkpoint has to stay parseable: its data parts are named by
914    /// pointers, its summary is what describes them.
915    #[test]
916    fn the_summary_part_of_a_live_checkpoint_is_kept() {
917        let existing = [SegmentId::with_part(4, 0), SegmentId::with_part(4, 3)];
918        let unref = partition(&existing, &[SegmentId::with_part(4, 3)], 1, 9,
919            SegmentId::with_part(9, 0));
920        assert!(unref.is_empty(), "part 0 describes the live part 3, got {:?}", unref);
921    }
922
923    /// With no live part left, the summary goes too — otherwise a parted
924    /// container would never give any storage back.
925    #[test]
926    fn a_wholly_dead_parted_checkpoint_goes_including_its_summary() {
927        let existing = [
928            SegmentId::with_part(4, 0), SegmentId::with_part(4, 1),
929            SegmentId::with_part(6, 0),
930        ];
931        let unref = partition(&existing, &[SegmentId::with_part(6, 0)], 1, 9,
932            SegmentId::with_part(6, 0));
933        assert_eq!(unref, vec![SegmentId::with_part(4, 0), SegmentId::with_part(4, 1)]);
934    }
935}