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, 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}