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 summary has to be the checkpoint asked for. Every segment this
363 // crate's hyperfile writes carries its checkpoint number, in either
364 // layout; a header that does not is not one, and walking a bmap read out
365 // of it would report whatever the bytes happen to point at.
366 if !ss.hdr.has_magic() || ss.hdr.s_cno != high_cno {
367 return Err(Error::new(
368 ErrorKind::InvalidData,
369 format!(
370 "{} does not read as checkpoint {}'s summary (magic {:#x}, checkpoint {})",
371 high_summary, high_cno, ss.hdr.s_magic, ss.hdr.s_cno,
372 ),
373 ));
374 }
375 // The whole inode is moved into the walk, not just its `i_bmap` field:
376 // `BMap::read` wants an 8-byte-aligned buffer, and `BMapRawType` is
377 // `[u8; N]`, whose own alignment is 1, so a copy of the field alone
378 // lands wherever the compiler put it. `InodeRaw` is `align(8)` and
379 // `i_bmap` sits at an 8-aligned offset within it, so borrowing the
380 // field out of an owned inode is aligned by construction.
381 let ss_inode = ss.hdr.s_inode;
382 // Both sizes come from the segment header rather than from the inode's
383 // `i_meta_config`: they have been there since the first release, while
384 // that field was zero on containers old enough that it cannot be
385 // decoded at all.
386 let meta_block_size = 1usize << ss.hdr.s_meta_blk_shift;
387 let data_block_size = 1usize << ss.hdr.s_data_blk_shift;
388
389 // Walk the bmap on a blocking thread: a bmap built with
390 // btree-ondisk's `rc` feature is `!Send`, so it cannot be held
391 // across an await in a `Cleaner` method.
392 // Counters are per-loader and the cleaner has nowhere to report
393 // them, so give this walk its own throwaway ReadTiming rather
394 // than threading one through the public API.
395 let loader = S3BlockLoader::new(
396 &self.client, &self.bucket, &self.root_path,
397 Arc::new(ReadTiming::default()),
398 );
399 let join = tokio::task::spawn_blocking(move || -> Result<LivePtrs> {
400 let bmap = BMap::<BlockIndex, BlockPtr, BlockPtr, S3BlockLoader, NullNodeCache>
401 ::read(&ss_inode.i_bmap, meta_block_size, loader, NullNodeCache)?;
402 Self::walk_live_ptrs(bmap)
403 });
404 let live = match join.await {
405 Ok(inner) => inner?,
406 Err(join_err) => {
407 return Err(Error::other(
408 format!("bmap walk task failed to join: {}", join_err),
409 ));
410 }
411 };
412
413 let listing = self.list_segment_objects().await?;
414 // The object the walk read has to be one the listing names, under that
415 // name. Otherwise this cleaner and the store disagree about what the
416 // objects are called — and `prune` keeps `high_summary` by its name.
417 if !listing.objects.iter().any(|(segid, _)| *segid == high_summary) {
418 return Err(Error::new(
419 ErrorKind::InvalidData,
420 format!(
421 "the listing has no object named {}, the summary just read; \
422 refusing to judge what is referenced",
423 high_summary,
424 ),
425 ));
426 }
427 Ok(ContainerView {
428 low,
429 high_cno,
430 latest_compacted,
431 fmt,
432 high_summary,
433 data_block_size,
434 meta_block_size,
435 live,
436 listing,
437 })
438 }
439
440 /// Inner shared implementation used by both `collect` and
441 /// `prune`. Returns `(referenced, unreferenced)` lists of segment
442 /// *objects* where:
443 /// - `referenced` is every object the latest published inode's metadata
444 /// tree still points into, whether or not it falls in `[low, high]`.
445 /// - `unreferenced` is every object that exists under the container's
446 /// prefix whose checkpoint number falls in `[low, high]` and which
447 /// nothing points into, less the two kinds this keeps regardless (see
448 /// [`partition_objects`]).
449 async fn collect_unref_seg(
450 &self,
451 low: Cno,
452 high: Cno,
453 ) -> Result<(Vec<SegmentId>, Vec<SegmentId>)> {
454 let view = self.view(low, high).await?;
455 Ok(partition_objects(
456 &view.object_ids(),
457 &view.referenced_objects(),
458 view.low,
459 view.high_cno,
460 view.high_summary,
461 ))
462 }
463}
464
465/// Every live location of a container, and the metadata-node subset of it.
466pub(crate) struct LivePtrs {
467 pub(crate) all: BTreeSet<BlockPtr>,
468 pub(crate) meta: BTreeSet<BlockPtr>,
469 /// `(first, count)` ranges of block indices that together cover every
470 /// mapped block, ascending. They may also cover holes; they skip the large
471 /// ones.
472 pub(crate) windows: Vec<(BlockIndex, usize)>,
473}
474
475/// The granularity of [`LivePtrs::windows`]: a mapped block starts a window this
476/// long, and windows that touch are merged up to four of these.
477pub(crate) const DATA_WINDOW_BLOCKS: usize = 64 * 1024;
478
479/// What a listing of the container's prefix found.
480#[derive(Default)]
481pub(crate) struct ObjectListing {
482 /// Segment objects and their sizes, ascending.
483 pub(crate) objects: Vec<(SegmentId, u64)>,
484 /// How many keys under the prefix were neither the inode nor a segment
485 /// object. A WAL root nested under the container is the usual reason.
486 pub(crate) foreign: usize,
487 pub(crate) foreign_sample: Option<String>,
488}
489
490/// One pass over a container's storage, shared by every operation.
491pub(crate) struct ContainerView {
492 pub(crate) low: Cno,
493 pub(crate) high_cno: Cno,
494 /// Whether the published inode marks its checkpoint as a compaction
495 /// (`InodeRaw::FLAG_COMPACTED`): it moved blocks and changed no content.
496 pub(crate) latest_compacted: bool,
497 pub(crate) fmt: BlockPtrFormat,
498 pub(crate) high_summary: SegmentId,
499 pub(crate) data_block_size: usize,
500 pub(crate) meta_block_size: usize,
501 pub(crate) live: LivePtrs,
502 pub(crate) listing: ObjectListing,
503}
504
505impl ContainerView {
506 pub(crate) fn object_ids(&self) -> Vec<SegmentId> {
507 self.listing.objects.iter().map(|(segid, _)| *segid).collect()
508 }
509
510 /// The objects at least one live location names.
511 pub(crate) fn referenced_objects(&self) -> BTreeSet<SegmentId> {
512 self.live.all.iter()
513 .map(|p| BlockPtrFormat::decode(p, &self.fmt).0)
514 .collect()
515 }
516
517 /// Which object a live location is in.
518 pub(crate) fn object_of(&self, blkptr: &BlockPtr) -> SegmentId {
519 BlockPtrFormat::decode(blkptr, &self.fmt).0
520 }
521}
522
523/// `root_uri` without one trailing `/`, unless that `/` is part of the scheme.
524///
525/// Stripping it unconditionally turned `s3://` into `s3:/`, which the parse then
526/// refused for not starting with `s3://` — the opposite of what was wrong with it.
527/// `hyperfile 0.10.3` fixed the same thing in `StagingConfig::new_s3_uri`.
528fn trim_root_uri(root_uri: &str) -> &str {
529 match root_uri.strip_suffix('/') {
530 Some(r) if !r.ends_with(":/") => r,
531 _ => root_uri,
532 }
533}
534
535/// `S_IFMT` and the two device types, as the inode stores its mode. POSIX fixes
536/// these values, so they are the same on every host.
537const S_IFMT: u32 = 0o170000;
538const S_IFCHR: u32 = 0o020000;
539const S_IFBLK: u32 = 0o060000;
540
541/// Refuse an inode whose checkpoint fields do not hold checkpoint numbers.
542///
543/// An inline file keeps its data in the inode's tail, over `i_last_seq`,
544/// `i_last_cno` and the bmap root; a device node keeps its `rdev` in
545/// `i_last_cno`. Read as a checkpoint number either is whatever the bytes
546/// happen to be. Nothing would be deleted on the strength of it — the summary
547/// it named would not be found — but the error would point at a missing segment
548/// rather than at what is actually wrong.
549fn refuse_unsegmented(raw: &InodeRaw) -> Result<()> {
550 let fmt = raw.i_mode & S_IFMT;
551 let what = if raw.is_inline() {
552 Some("an inline file, whose data lives in the inode")
553 } else if fmt == S_IFCHR || fmt == S_IFBLK {
554 Some("a device node, which stores rdev where a checkpoint number would be")
555 } else {
556 None
557 };
558 match what {
559 Some(what) => Err(Error::new(
560 ErrorKind::InvalidInput,
561 format!("this container is {}: it has no segments to collect, prune or compact", what),
562 )),
563 None => Ok(()),
564 }
565}
566
567/// The pointer format the container was written with, as its inode records it.
568///
569/// A container written before `i_meta_config` was populated carries zero there;
570/// it predates every format with parts, so its pointers read as `MicroGroup`.
571/// That is the only case read on a guess.
572///
573/// Anything else that does not decode is refused. Up to 0.10.5 every such value
574/// fell back to `MicroGroup`, and on a `StreamSegment` container — format byte 4,
575/// new in `hyperfile 0.11.0` — that made a 0.10.5 cleaner decode no pointer into
576/// any of its `<cno>.<part>` objects and report every one of them unreferenced,
577/// the newest checkpoint included. Measured: a container of two checkpoints in
578/// twelve objects came back `referenced=0`, all twelve offered to `prune`. A
579/// format this build does not know is a reason to stop, not to guess.
580fn container_ptr_format(raw_inode: &InodeRaw) -> Result<BlockPtrFormat> {
581 if raw_inode.i_meta_config == 0 {
582 return Ok(BlockPtrFormat::MicroGroup);
583 }
584 HyperFileMetaConfig::try_from_u32(raw_inode.i_meta_config)
585 .map(|cfg| cfg.block_ptr_format)
586 .map_err(|e| Error::new(
587 ErrorKind::Unsupported,
588 format!(
589 "the container's inode records a format this build cannot read \
590 (meta config {:#x}: {}); a cleaner built against an older hyperfile \
591 must not walk it, since it would misread every pointer",
592 raw_inode.i_meta_config, e,
593 ),
594 ))
595}
596
597/// The object holding a checkpoint's summary, metadata blocks and inode.
598fn summary_obj(cno: Cno, fmt: &BlockPtrFormat) -> SegmentId {
599 let segid = SegmentId::new_from_cno(cno);
600 if fmt.is_parted() {
601 segid.at_part(Segment::SUMMARY_PART)
602 } else {
603 segid
604 }
605}
606
607/// A segment object name, or `None` for a key that is not one.
608///
609/// The names are what [`Segment::segid_to_staging_file_id`] produces: ten digits
610/// zero-padded, optionally `.<part>`. `None` and `Some(0)` are different names
611/// on purpose upstream, so they stay different objects here.
612fn parse_segment_object_name(name: &str) -> Option<SegmentId> {
613 // Nested prefixes — a WAL root under the container, say — are not segment
614 // objects of this container.
615 if name.contains('/') {
616 return None;
617 }
618 let (seq_str, part_str) = match name.split_once('.') {
619 Some((seq, part)) => (seq, Some(part)),
620 None => (name, None),
621 };
622 if seq_str.len() != 10 || !seq_str.bytes().all(|b| b.is_ascii_digit()) {
623 return None;
624 }
625 // Ten digits can name more than a `u32` holds; such an object cannot have
626 // been written by this format, so it is not one of ours.
627 let seq_id = seq_str.parse::<u32>().ok()?;
628 match part_str {
629 None => Some(SegmentId::new(seq_id)),
630 Some(p) => {
631 if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) {
632 return None;
633 }
634 Some(SegmentId::with_part(seq_id, p.parse::<u16>().ok()?))
635 },
636 }
637}
638
639/// Split what exists into what is still pointed into and what can go.
640///
641/// `referenced` is object-granular, which is what makes a parted container
642/// reclaimable at all. Two kinds of object are kept even when no pointer names
643/// them:
644///
645/// - **The summary of `high`.** It is the object this cleaner just read to find
646/// the checkpoint's bmap, and the one it would read on the next run. A
647/// container whose newest checkpoint holds no live block — an empty file, or
648/// one whose every block was superseded within the same checkpoint — would
649/// otherwise have its own head deleted, and the next `collect` would fail with
650/// `NoSuchKey`.
651/// - **The summary part of a checkpoint that still has a live part.** Only
652/// reachable under `PartedSegment`: the parts carry data, part 0 carries the
653/// summary and metadata that describe them. Deleting it would leave a
654/// checkpoint whose blocks are live but which nothing can parse, which is what
655/// `open_cno` and a repair tool need. "Not a partial" is upstream's own
656/// predicate for that object — a partial is a part above zero, and an unparted
657/// checkpoint is never one.
658///
659/// Everything else in `[low, high]` that nothing points into is reclaimable,
660/// including the partials of a checkpoint that was abandoned — those share their
661/// checkpoint number with the retry that replaced them, so they are invisible to
662/// anything reasoning in whole checkpoints.
663fn partition_objects(
664 existing: &[SegmentId],
665 referenced: &BTreeSet<SegmentId>,
666 low: Cno,
667 high: Cno,
668 high_summary: SegmentId,
669) -> (Vec<SegmentId>, Vec<SegmentId>) {
670 let live_cnos: BTreeSet<Cno> = referenced.iter().map(|s| s.as_cno()).collect();
671 let mut unref = Vec::new();
672 for obj in existing {
673 let cno = obj.as_cno();
674 if cno < low || cno > high {
675 continue;
676 }
677 if referenced.contains(obj) {
678 continue;
679 }
680 if *obj == high_summary {
681 continue;
682 }
683 if !obj.is_partial() && live_cnos.contains(&cno) {
684 continue;
685 }
686 unref.push(*obj);
687 }
688 let v_ref = referenced.iter().copied().collect::<Vec<SegmentId>>();
689 (v_ref, unref)
690}
691
692/// Reject a checkpoint range that cannot be walked.
693///
694/// `[low, high]` is inclusive at both ends, so `low == high_cno` is a
695/// well-formed query — "what does checkpoint N reference, and is anything in
696/// `[N, N]` unreferenced" — and is answered normally. It is reached whenever a
697/// caller asks about the entire history of a file with a single checkpoint:
698/// `collect(1, 0)`, the form this crate's own documentation gives as the typical
699/// call, resolves `high` to `get_last_cno() == 1` and so lands on `low == high`.
700/// Rejecting it made `collect` and `prune` unusable on any freshly created file
701/// until it had been flushed at least twice.
702///
703/// `low > high_cno` is genuinely empty and stays an error, which also covers
704/// `high_cno == 0` (no checkpoint has ever been published, so there is no
705/// metadata tree to walk).
706///
707/// A checkpoint number past `u32::MAX` is refused rather than truncated: that is
708/// what a block pointer can name, so no object could carry the number, and
709/// `SegmentId::new_from_cno` would assert on it in a debug build.
710fn validate_segid_range(low: Cno, high_cno: Cno) -> Result<()> {
711 if low > high_cno {
712 return Err(Error::new(
713 ErrorKind::InvalidInput,
714 format!("invalid segment range [{}, {}]", low, high_cno),
715 ));
716 }
717 if high_cno > u32::MAX as Cno {
718 return Err(Error::new(
719 ErrorKind::InvalidInput,
720 format!("checkpoint {} is past what a block pointer can name ({})",
721 high_cno, u32::MAX),
722 ));
723 }
724 Ok(())
725}
726
727impl Cleaner for S3Cleaner {
728 async fn collect(
729 &self,
730 low: Cno,
731 high: Cno,
732 ) -> Result<(Vec<SegmentId>, Vec<SegmentId>)> {
733 self.collect_unref_seg(low, high).await
734 }
735
736 /// Prune unreferenced segment objects in `[low, high]`. Implemented
737 /// as `collect` followed by `DeleteObjects` of the
738 /// unreferenced list.
739 async fn prune(&self, low: Cno, high: Cno) -> Result<()> {
740 let (_, non_ref) = self.collect_unref_seg(low, high).await?;
741 let delete_keys = non_ref.iter()
742 .map(|segid| format!(
743 "{}/{}",
744 self.root_path,
745 Segment::segid_to_staging_file_id(*segid),
746 ))
747 .collect::<Vec<String>>();
748 debug!("unreferenced segments to delete: {:?}", delete_keys);
749 Self::do_delete_objects(&self.client, &self.bucket, delete_keys).await
750 }
751}
752
753#[cfg(test)]
754mod tests {
755 use super::*;
756
757 /// Builder calls in `do_delete_objects` panic only on empty
758 /// inputs that we guard against. Verify the empty-input fast
759 /// path.
760 #[tokio::test]
761 async fn delete_empty_keys_is_noop() {
762 let cfg = aws_config::load_from_env().await;
763 let client = Client::new(&cfg);
764 // Empty list -> no SDK call -> Ok regardless of credentials.
765 let r = S3Cleaner::do_delete_objects(&client, "any-bucket", Vec::new()).await;
766 assert!(r.is_ok(), "empty key list should not call S3");
767 }
768
769 /// `[low, high]` is inclusive at both ends, so a single-checkpoint
770 /// file -- where `collect(1, 0)` resolves to `low == high == 1` --
771 /// must be accepted. It used to be rejected, which made the
772 /// documented call form fail on any file flushed only once.
773 #[test]
774 fn segid_range_accepts_a_single_checkpoint() {
775 assert!(validate_segid_range(1, 1).is_ok());
776 }
777
778 #[test]
779 fn segid_range_accepts_a_normal_span() {
780 assert!(validate_segid_range(1, 5).is_ok());
781 }
782
783 /// Only a genuinely empty range is an error. `high_segid == 0` is the
784 /// "no checkpoint published yet" case and falls out of the same rule.
785 #[test]
786 fn segid_range_rejects_an_empty_span() {
787 for (low, high) in [(5u64, 3u64), (2, 1), (1, 0)] {
788 let e = validate_segid_range(low, high)
789 .expect_err("low > high must be rejected");
790 assert_eq!(e.kind(), ErrorKind::InvalidInput);
791 assert!(
792 e.to_string().contains(&format!("[{}, {}]", low, high)),
793 "error should name the range, got {:?}", e.to_string(),
794 );
795 }
796 }
797
798 #[test]
799 fn a_trailing_slash_is_trimmed_but_not_the_schemes() {
800 assert_eq!(trim_root_uri("s3://b/k/"), "s3://b/k");
801 assert_eq!(trim_root_uri("s3://b/k"), "s3://b/k");
802 assert_eq!(trim_root_uri("s3://"), "s3://");
803 assert_eq!(trim_root_uri("S3://"), "S3://");
804 assert_eq!(trim_root_uri(""), "");
805 }
806
807 /// Only a zero meta config is read on a guess; a value that does not decode is
808 /// refused rather than read as `MicroGroup`, which is how a 0.10.5 cleaner
809 /// misread every pointer of a `StreamSegment` container.
810 #[test]
811 fn an_unknown_format_is_refused_not_guessed() {
812 let legacy = InodeRaw { i_meta_config: 0, ..Default::default() };
813 assert_eq!(container_ptr_format(&legacy).unwrap(), BlockPtrFormat::MicroGroup);
814
815 for fmt in [BlockPtrFormat::MicroGroup, BlockPtrFormat::PartedSegment,
816 BlockPtrFormat::StreamSegment] {
817 let cfg = HyperFileMetaConfig::new(56, 4096, 4096, fmt);
818 let raw = InodeRaw { i_meta_config: cfg.as_u32(), ..Default::default() };
819 assert_eq!(container_ptr_format(&raw).unwrap(), fmt);
820 }
821
822 let future = HyperFileMetaConfig::new(56, 4096, 4096, BlockPtrFormat::PartedSegment)
823 .as_u32() & !0xFF | 0x7F;
824 let raw = InodeRaw { i_meta_config: future, ..Default::default() };
825 let e = container_ptr_format(&raw).expect_err("an unknown format byte must be refused");
826 assert_eq!(e.kind(), ErrorKind::Unsupported);
827 }
828
829 /// An inline file's checkpoint fields hold its data, and a device node's hold
830 /// its rdev. Neither is a checkpoint number, so the walk refuses up front
831 /// rather than looking for a segment the bytes happen to name.
832 #[test]
833 fn an_inode_without_segments_is_refused() {
834 let mut inline = InodeRaw { i_mode: 0o100644, ..Default::default() };
835 inline.set_inline(b"hello");
836 let e = refuse_unsegmented(&inline).expect_err("inline must be refused");
837 assert_eq!(e.kind(), ErrorKind::InvalidInput);
838 assert!(e.to_string().contains("inline"), "{}", e);
839
840 for mode in [0o020644u32, 0o060644] {
841 let dev = InodeRaw { i_mode: mode, ..Default::default() };
842 let e = refuse_unsegmented(&dev).expect_err("a device node must be refused");
843 assert!(e.to_string().contains("device"), "{}", e);
844 }
845
846 for mode in [0o100644u32, 0o040755, 0o120777] {
847 let ok = InodeRaw { i_mode: mode, ..Default::default() };
848 assert!(refuse_unsegmented(&ok).is_ok(), "mode {:o} has segments", mode);
849 }
850 }
851
852 /// A checkpoint number no pointer could name is refused rather than
853 /// truncated into one that names a different object.
854 #[test]
855 fn segid_range_rejects_a_checkpoint_past_a_pointer() {
856 let e = validate_segid_range(1, u32::MAX as Cno + 1)
857 .expect_err("a cno past u32::MAX must be rejected");
858 assert_eq!(e.kind(), ErrorKind::InvalidInput);
859 assert!(validate_segid_range(1, u32::MAX as Cno).is_ok());
860 }
861
862 // --- object names ---
863
864 #[test]
865 fn object_names_round_trip_through_the_parser() {
866 for segid in [
867 SegmentId::new(1),
868 SegmentId::new(4294967295),
869 SegmentId::with_part(7, 0),
870 SegmentId::with_part(7, 16383),
871 ] {
872 let name = Segment::segid_to_staging_file_id(segid);
873 assert_eq!(parse_segment_object_name(&name), Some(segid),
874 "name {:?} did not parse back", name);
875 }
876 }
877
878 /// An unsuffixed name and part 0 are different objects upstream, so they
879 /// have to stay different here — sharing them would mean deleting the
880 /// summary of one container shape while reporting the other.
881 #[test]
882 fn a_bare_name_is_not_part_zero() {
883 assert_eq!(parse_segment_object_name("0000000007"), Some(SegmentId::new(7)));
884 assert_eq!(parse_segment_object_name("0000000007.0"),
885 Some(SegmentId::with_part(7, 0)));
886 }
887
888 #[test]
889 fn keys_that_are_not_segment_objects_are_skipped() {
890 for name in [
891 "inode", // the inode object
892 "wal/0000000001", // a nested prefix, not an object of ours
893 "0000000001/0_0_4096",
894 "000000001", // nine digits
895 "00000000012", // eleven
896 "9999999999", // ten digits, past what a u32 can hold
897 "0000000001.", // empty part
898 "0000000001.x",
899 "0000000001.65536", // past what a u16 can hold
900 "0000000001.0.1",
901 "",
902 ] {
903 assert_eq!(parse_segment_object_name(name), None,
904 "{:?} should not be read as a segment object", name);
905 }
906 }
907
908 // --- what gets reclaimed ---
909
910 fn partition(
911 existing: &[SegmentId],
912 referenced: &[SegmentId],
913 low: Cno,
914 high: Cno,
915 high_summary: SegmentId,
916 ) -> Vec<SegmentId> {
917 let set: BTreeSet<SegmentId> = referenced.iter().copied().collect();
918 let (_, unref) = partition_objects(existing, &set, low, high, high_summary);
919 unref
920 }
921
922 /// The unparted case, which is every container written before 0.7.0: an
923 /// object is a checkpoint, and one nothing points into goes.
924 #[test]
925 fn an_unreferenced_checkpoint_is_reclaimable() {
926 let existing = [SegmentId::new(1), SegmentId::new(2), SegmentId::new(3)];
927 let unref = partition(&existing, &[SegmentId::new(3)], 1, 3, SegmentId::new(3));
928 assert_eq!(unref, vec![SegmentId::new(1), SegmentId::new(2)]);
929 }
930
931 /// Objects outside `[low, high]` are not candidates, which is what makes a
932 /// prune safe against a writer publishing underneath it.
933 #[test]
934 fn objects_outside_the_range_are_left_alone() {
935 let existing = [
936 SegmentId::new(1), SegmentId::new(2), SegmentId::new(3), SegmentId::new(4),
937 ];
938 let unref = partition(&existing, &[], 2, 3, SegmentId::new(9));
939 assert_eq!(unref, vec![SegmentId::new(2), SegmentId::new(3)]);
940 }
941
942 /// A checkpoint whose newest state holds no live block would otherwise have
943 /// its own head deleted, and the next `collect` would fail to read it.
944 #[test]
945 fn the_summary_of_high_is_kept_even_when_nothing_points_into_it() {
946 let existing = [SegmentId::new(1), SegmentId::new(2)];
947 let unref = partition(&existing, &[], 1, 2, SegmentId::new(2));
948 assert_eq!(unref, vec![SegmentId::new(1)]);
949
950 // Same for a streamed container, where the head is part 0.
951 let existing = [SegmentId::with_part(2, 0), SegmentId::with_part(2, 1)];
952 let unref = partition(&existing, &[], 1, 2, SegmentId::with_part(2, 0));
953 assert_eq!(unref, vec![SegmentId::with_part(2, 1)]);
954 }
955
956 /// The partials of an abandoned checkpoint share their number with the
957 /// retry that replaced them. Reclaiming them is the whole reason the
958 /// referenced set is object-granular.
959 #[test]
960 fn an_abandoned_partial_is_reclaimable_while_its_checkpoint_lives() {
961 let existing = [
962 SegmentId::with_part(5, 0), // summary of the checkpoint that landed
963 SegmentId::with_part(5, 1), // its data
964 SegmentId::with_part(5, 2), // written, then abandoned by a rollback
965 ];
966 let referenced = [SegmentId::with_part(5, 0), SegmentId::with_part(5, 1)];
967 let unref = partition(&existing, &referenced, 1, 9, SegmentId::with_part(9, 0));
968 assert_eq!(unref, vec![SegmentId::with_part(5, 2)]);
969 }
970
971 /// A live checkpoint has to stay parseable: its data parts are named by
972 /// pointers, its summary is what describes them.
973 #[test]
974 fn the_summary_part_of_a_live_checkpoint_is_kept() {
975 let existing = [SegmentId::with_part(4, 0), SegmentId::with_part(4, 3)];
976 let unref = partition(&existing, &[SegmentId::with_part(4, 3)], 1, 9,
977 SegmentId::with_part(9, 0));
978 assert!(unref.is_empty(), "part 0 describes the live part 3, got {:?}", unref);
979 }
980
981 /// With no live part left, the summary goes too — otherwise a parted
982 /// container would never give any storage back.
983 #[test]
984 fn a_wholly_dead_parted_checkpoint_goes_including_its_summary() {
985 let existing = [
986 SegmentId::with_part(4, 0), SegmentId::with_part(4, 1),
987 SegmentId::with_part(6, 0),
988 ];
989 let unref = partition(&existing, &[SegmentId::with_part(6, 0)], 1, 9,
990 SegmentId::with_part(6, 0));
991 assert_eq!(unref, vec![SegmentId::with_part(4, 0), SegmentId::with_part(4, 1)]);
992 }
993}