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, SegmentId};
18use hyperfile::file::ReadTiming;
19use hyperfile::meta_loader::s3::S3BlockLoader;
20use hyperfile::ondisk::InodeRaw;
21use hyperfile::inode::{Inode, OnDiskState};
22use hyperfile::segment::{Segment, SegmentSum, SegmentReadWrite};
23use hyperfile::staging::{Staging, config::StagingConfig, s3::S3Staging};
24use hyperfile::config::HyperFileRuntimeConfig;
25use hyperfile::s3uri::S3Uri;
26use hyperfile::meta_format::BlockPtrFormat;
27use crate::Cleaner;
28
29/// S3-backed cleaner for a single hyperfile rooted at a given
30/// `s3://<bucket>/<prefix>` URI.
31///
32/// The cleaner does not retain or open the file's data. It only
33/// reads the published inode and walks the metadata B+tree to
34/// enumerate referenced segments, then deletes the unreferenced
35/// ones via `DeleteObjects`.
36///
37/// Concurrency: the cleaner is not aware of in-flight writers. If
38/// a writer publishes a new checkpoint while `collect` /
39/// `prune` is running, the result reflects whatever inode was
40/// loaded at the start of the call. New segments published after
41/// that point are outside the configured `[low, high]` range and
42/// are not affected.
43pub struct S3Cleaner {
44 client: Client,
45 bucket: String,
46 root_path: String,
47 staging: S3Staging,
48}
49
50impl S3Cleaner {
51 /// Construct a cleaner for `root_uri`, using the AWS SDK's
52 /// default credential / region resolution
53 /// (`aws_config::load_from_env`).
54 ///
55 /// Returns `InvalidInput` if `root_uri` is not a valid S3 URI.
56 pub async fn new(root_uri: &str) -> Result<Self> {
57 let cfg = aws_config::load_from_env().await;
58 let client = Client::new(&cfg);
59 Self::with_client(&client, root_uri).await
60 }
61
62 /// Construct a cleaner using a caller-supplied `Client`. Use
63 /// this when you need to control region / endpoint / credential
64 /// resolution explicitly (for example, in tests or in a tool
65 /// that already has a configured client).
66 ///
67 /// Returns `InvalidInput` if `root_uri` is not a valid S3 URI.
68 pub async fn with_client(client: &Client, root_uri: &str) -> Result<Self> {
69 let trimmed = root_uri.strip_suffix('/').unwrap_or(root_uri);
70 let s3uri = S3Uri::parse(trimmed).map_err(|e| {
71 Error::new(
72 ErrorKind::InvalidInput,
73 format!("invalid S3 URI {:?}: {}", root_uri, e),
74 )
75 })?;
76 let runtime_config = HyperFileRuntimeConfig::default();
77 let staging_config = StagingConfig::new_s3_staging(s3uri.bucket, "", s3uri.key, None);
78 let staging = S3Staging::from(client, staging_config, runtime_config).await?;
79 Ok(Self {
80 client: client.clone(),
81 bucket: s3uri.bucket.to_owned(),
82 root_path: s3uri.key.to_owned(),
83 staging,
84 })
85 }
86
87 async fn load_segment_sum(&self, segid: SegmentId) -> Result<SegmentSum> {
88 self.staging.open(segid).await
89 }
90
91 async fn load_raw_inode(&self) -> Result<(InodeRaw, Option<OnDiskState>)> {
92 let mut raw_inode = InodeRaw::default();
93 let inode_state = self.staging.load_inode(raw_inode.as_mut_u8_slice()).await?;
94 Ok((raw_inode, inode_state))
95 }
96
97 /// Collect every segment id that `bmap` still points into.
98 ///
99 /// `collect_ptrs` reports every location the map references — its metadata
100 /// nodes and the block pointers stored in its leaves — for both bmap shapes,
101 /// so this needs no shape branch of its own. It returns `Err` rather than a
102 /// short list if any subtree could not be enumerated, which is what makes it
103 /// safe to derive a delete set from: a truncated reachable set is
104 /// indistinguishable from a complete one.
105 ///
106 /// This replaces a hand-written walk that branched on `get_stat().btree` and
107 /// used `nonleafnode_iter` for the B+tree shape. That iterator yields
108 /// nothing for a direct bmap, whose root lives inside the inode and holds
109 /// the data block pointers itself, so the referenced set came back *empty*
110 /// for every file small enough to keep one and `prune` deleted that file's
111 /// only live data. `btree-ondisk 0.20` owns the shape distinction now.
112 fn collect_segment_id<L>(
113 bmap: BMap<'_, BlockIndex, BlockPtr, BlockPtr, L, NullNodeCache>,
114 ) -> Result<BTreeSet<SegmentId>>
115 where
116 L: BlockLoader<BlockPtr> + Clone,
117 {
118 let mut segid_set: BTreeSet<SegmentId> = BTreeSet::new();
119 // `collect_ptrs` is a plain `async fn`, but a bmap built with
120 // btree-ondisk's `rc` feature is `!Send`, so awaiting it directly
121 // would make `collect`/`prune` non-`Send` and break the `Cleaner`
122 // trait. The walk therefore stays on the blocking thread this
123 // function is called from, driven the same way btree-ondisk drives
124 // itself under `tokio-runtime`.
125 let handle = tokio::runtime::Handle::current();
126 for blkptr in handle.block_on(bmap.collect_ptrs())? {
127 if BlockPtrFormat::is_on_staging(&blkptr) {
128 segid_set.insert(BlockPtrFormat::decode_segid(&blkptr));
129 }
130 }
131 Ok(segid_set)
132 }
133
134 /// Delete `delete_keys` from `bucket` in batches of 1000 (the
135 /// `DeleteObjects` per-call max).
136 ///
137 /// Failures are tracked at two levels and merged into the same
138 /// returned error:
139 ///
140 /// 1. **SDK-level (transport / auth / etc.)**: the entire batch
141 /// is recorded as failed. The SDK error is logged at
142 /// `error!` level.
143 /// 2. **Per-key (S3 partial success in `DeleteObjectsOutput`)**:
144 /// `DeleteObjects` is a partial-success API; even when the
145 /// batch returns `Ok`, individual keys may have failed (e.g.
146 /// `AccessDenied`, `NoSuchKey`). The response's `errors`
147 /// list is parsed and each failing key recorded.
148 ///
149 /// Returns `Err(Interrupted)` if any key (at either level)
150 /// failed. The error message includes the failed object count
151 /// and a sample failed key. Successfully-deleted keys stay
152 /// deleted (S3 has no transactional batch delete).
153 async fn do_delete_objects(
154 client: &Client,
155 bucket: &str,
156 delete_keys: Vec<String>,
157 ) -> Result<()> {
158 use aws_sdk_s3::types::ObjectIdentifier;
159 if delete_keys.is_empty() {
160 return Ok(());
161 }
162 let mut failed_keys: Vec<String> = Vec::new();
163 for keys in delete_keys.chunks(1000) {
164 let obj_ids = keys.iter()
165 .map(|k| {
166 ObjectIdentifier::builder()
167 .key(k)
168 .build()
169 .expect("ObjectIdentifier requires non-empty key")
170 })
171 .collect::<Vec<ObjectIdentifier>>();
172 // We use quiet=true so the response only carries the
173 // `errors` list (not the success list); this is what
174 // we want for partial-failure tracking.
175 let delete = aws_sdk_s3::types::Delete::builder()
176 .set_objects(Some(obj_ids))
177 .quiet(true)
178 .build()
179 .expect("Delete::builder requires non-empty object list");
180 match client.delete_objects()
181 .bucket(bucket)
182 .delete(delete)
183 .send()
184 .await
185 {
186 Ok(output) => {
187 // Partial-success path: even though the batch
188 // returned Ok at the SDK level, individual keys
189 // may have failed. Pick those out of the
190 // response.
191 if let Some(errors) = output.errors {
192 for e in errors {
193 let key = e.key.unwrap_or_default();
194 let code = e.code.as_deref().unwrap_or("?");
195 let msg = e.message.as_deref().unwrap_or("");
196 error!("DeleteObjects per-key failure: key={} code={} message={}",
197 key, code, msg);
198 if !key.is_empty() {
199 failed_keys.push(key);
200 }
201 }
202 }
203 },
204 Err(sdk_err) => {
205 // SDK-level failure: treat the whole batch as
206 // failed (we don't know which keys made it).
207 error!("DeleteObjects batch failed (bucket={}, count={}): {}",
208 bucket, keys.len(), sdk_err);
209 failed_keys.extend(keys.iter().cloned());
210 }
211 }
212 }
213 if failed_keys.is_empty() {
214 Ok(())
215 } else {
216 Err(Error::new(
217 ErrorKind::Interrupted,
218 format!(
219 "DeleteObjects failed for {} object(s); first failed key: {:?}",
220 failed_keys.len(),
221 failed_keys.first(),
222 ),
223 ))
224 }
225 }
226
227 /// Inner shared implementation used by both `collect` and
228 /// `prune`. Returns `(referenced, unreferenced)` segment id
229 /// vectors where:
230 /// - `referenced` is the set of segids reachable via the
231 /// latest published inode's metadata tree, intersected with
232 /// `[low, high]`.
233 /// - `unreferenced` is `[low, high]` minus the referenced set.
234 ///
235 /// `high == 0` is a sentinel meaning "use the latest checkpoint
236 /// (`inode.get_last_seq()`)".
237 async fn collect_unref_seg(
238 &self,
239 low: SegmentId,
240 high: SegmentId,
241 ) -> Result<(Vec<SegmentId>, Vec<SegmentId>)> {
242 let high_segid = if high == 0 {
243 let (raw_inode, inode_state) = self.load_raw_inode().await?;
244 let inode = Inode::from_raw(&raw_inode, inode_state);
245 inode.get_last_seq()
246 } else {
247 high
248 };
249
250 debug!("collect unreferenced segment low: {}, high: {}", low, high_segid);
251 validate_segid_range(low, high_segid)?;
252
253 // load segment summary for the high checkpoint
254 let ss = self.load_segment_sum(high_segid).await?;
255 // The whole inode is moved into the walk, not just its `i_bmap` field:
256 // `BMap::read` wants an 8-byte-aligned buffer, and `BMapRawType` is
257 // `[u8; N]`, whose own alignment is 1, so a copy of the field alone
258 // lands wherever the compiler put it. `InodeRaw` is `align(8)` and
259 // `i_bmap` sits at an 8-aligned offset within it, so borrowing the
260 // field out of an owned inode is aligned by construction.
261 let raw_inode = ss.hdr.s_inode;
262 let meta_block_size = 1 << ss.hdr.s_meta_blk_shift;
263
264 // Walk the bmap on a blocking thread: a bmap built with
265 // btree-ondisk's `rc` feature is `!Send`, so it cannot be held
266 // across an await in a `Cleaner` method.
267 // Counters are per-loader and the cleaner has nowhere to report
268 // them, so give this walk its own throwaway ReadTiming rather
269 // than threading one through the public API.
270 let loader = S3BlockLoader::new(
271 &self.client, &self.bucket, &self.root_path,
272 Arc::new(ReadTiming::default()),
273 );
274 let join = tokio::task::spawn_blocking(move || -> Result<BTreeSet<SegmentId>> {
275 let bmap = BMap::<BlockIndex, BlockPtr, BlockPtr, S3BlockLoader, NullNodeCache>
276 ::read(&raw_inode.i_bmap, meta_block_size, loader, NullNodeCache)?;
277 Self::collect_segment_id(bmap)
278 });
279 let set = match join.await {
280 Ok(inner) => inner?,
281 Err(join_err) => {
282 return Err(Error::other(
283 format!("bmap walk task failed to join: {}", join_err),
284 ));
285 }
286 };
287
288 // Build the unreferenced list within [low, high].
289 let mut non_ref = Vec::new();
290 for n in low..=high_segid {
291 if !set.contains(&n) {
292 non_ref.push(n);
293 }
294 }
295 let v_ref = set.into_iter().collect::<Vec<SegmentId>>();
296 Ok((v_ref, non_ref))
297 }
298}
299
300/// Reject a segid range that cannot be walked.
301///
302/// `[low, high]` is inclusive at both ends, so `low == high_segid` is a
303/// well-formed query — "what does checkpoint N reference, and is anything in
304/// `[N, N]` unreferenced" — and is answered normally. It is reached whenever a
305/// caller asks about the entire history of a file with a single checkpoint:
306/// `collect(1, 0)`, the form this crate's own documentation gives as the typical
307/// call, resolves `high` to `get_last_seq() == 1` and so lands on `low == high`.
308/// Rejecting it made `collect` and `prune` unusable on any freshly created file
309/// until it had been flushed at least twice.
310///
311/// `low > high_segid` is genuinely empty and stays an error, which also covers
312/// `high_segid == 0` (no checkpoint has ever been published, so there is no
313/// metadata tree to walk).
314fn validate_segid_range(low: SegmentId, high_segid: SegmentId) -> Result<()> {
315 if low > high_segid {
316 return Err(Error::new(
317 ErrorKind::InvalidInput,
318 format!("invalid segment range [{}, {}]", low, high_segid),
319 ));
320 }
321 Ok(())
322}
323
324impl Cleaner for S3Cleaner {
325 async fn collect(
326 &self,
327 low: SegmentId,
328 high: SegmentId,
329 ) -> Result<(Vec<SegmentId>, Vec<SegmentId>)> {
330 self.collect_unref_seg(low, high).await
331 }
332
333 /// Prune unreferenced segments in `[low, high]`. Implemented
334 /// as `collect` followed by `DeleteObjects` of the
335 /// unreferenced list.
336 async fn prune(&self, low: SegmentId, high: SegmentId) -> Result<()> {
337 let (_, non_ref) = self.collect_unref_seg(low, high).await?;
338 let delete_keys = non_ref.iter()
339 .map(|segid| format!(
340 "{}/{}",
341 self.root_path,
342 Segment::segid_to_staging_file_id(*segid),
343 ))
344 .collect::<Vec<String>>();
345 debug!("unreferenced segments to delete: {:?}", delete_keys);
346 Self::do_delete_objects(&self.client, &self.bucket, delete_keys).await
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 /// Builder calls in `do_delete_objects` panic only on empty
355 /// inputs that we guard against. Verify the empty-input fast
356 /// path.
357 #[tokio::test]
358 async fn delete_empty_keys_is_noop() {
359 let cfg = aws_config::load_from_env().await;
360 let client = Client::new(&cfg);
361 // Empty list -> no SDK call -> Ok regardless of credentials.
362 let r = S3Cleaner::do_delete_objects(&client, "any-bucket", Vec::new()).await;
363 assert!(r.is_ok(), "empty key list should not call S3");
364 }
365
366 /// `[low, high]` is inclusive at both ends, so a single-checkpoint
367 /// file -- where `collect(1, 0)` resolves to `low == high == 1` --
368 /// must be accepted. It used to be rejected, which made the
369 /// documented call form fail on any file flushed only once.
370 #[test]
371 fn segid_range_accepts_a_single_checkpoint() {
372 assert!(validate_segid_range(1, 1).is_ok());
373 }
374
375 #[test]
376 fn segid_range_accepts_a_normal_span() {
377 assert!(validate_segid_range(1, 5).is_ok());
378 }
379
380 /// Only a genuinely empty range is an error. `high_segid == 0` is the
381 /// "no checkpoint published yet" case and falls out of the same rule.
382 #[test]
383 fn segid_range_rejects_an_empty_span() {
384 for (low, high) in [(5u64, 3u64), (2, 1), (1, 0)] {
385 let e = validate_segid_range(low, high)
386 .expect_err("low > high must be rejected");
387 assert_eq!(e.kind(), ErrorKind::InvalidInput);
388 assert!(
389 e.to_string().contains(&format!("[{}, {}]", low, high)),
390 "error should name the range, got {:?}", e.to_string(),
391 );
392 }
393 }
394}