Skip to main content

lfsx_server/storage/s3/
collect.rs

1use std::collections::{HashMap, HashSet};
2use std::time::Duration;
3
4use super::{S3Store, refs, sizes};
5use crate::error::Error;
6use crate::namespace::Namespace;
7use crate::storage::SweepReport;
8use crate::storage::s3::keyspace;
9
10// Collection, and only collection. It left `s3.rs` because it had become the
11// longest thing in it and the least like the rest: everything else there is one
12// request about one object, and this is a policy about the whole keyspace, with
13// two paths through it and three indexes to keep straight.
14
15impl S3Store {
16    // Collection, with the marker keyspace standing in for the link count a
17    // filesystem keeps. A repository's marker is its claim on the bytes, and the
18    // bytes go when the last claim does.
19    //
20    // Everything hard here is one question: does any *other* repository still
21    // claim this object? A marker is `{org}/{repo}/.../{oid}`, so the oid is the
22    // suffix and the org and repo that would make a prefix are exactly what is
23    // unknown. The claim index turns that into one prefix listing per object. A
24    // bucket that predates the index has to be read whole instead, and that pass
25    // builds the index as it goes, so it is paid once rather than every sweep.
26    pub async fn sweep(
27        &self,
28        ns: &Namespace,
29        retained: &HashSet<String>,
30        grace: Duration,
31        dry_run: bool,
32    ) -> Result<SweepReport, Error> {
33        if refs::ready(&self.keys).await {
34            self.sweep_indexed(ns, retained, grace, dry_run).await
35        } else {
36            self.sweep_whole_bucket(ns, retained, grace, dry_run).await
37        }
38    }
39
40    // The last question asked before bytes go, and the reason the index is read
41    // twice for one object.
42    //
43    // Between deciding an object is unclaimed and deleting it, another repository
44    // can push the same digest. It finds the content already there, skips the
45    // upload, and writes a claim, so deleting now leaves it holding a marker
46    // pointing at nothing, which its client meets as a missing object on the next
47    // pull.
48    //
49    // A push writes its ref before it so much as looks at the content, so a claim
50    // that landed at any moment before this question is one this sees. What is
51    // left is the width of a single request, between reading this answer and the
52    // delete that follows it. Closing that needs a lease the deleting side takes
53    // and every push waits on, which is a round trip on the hot path bought
54    // against a window this narrow, and it is not obviously the right trade.
55    async fn claimed_since(&self, ns: &Namespace, oid: &str) -> bool {
56        if refs::claimed_by_another(&self.keys, ns, oid).await {
57            tracing::info!(
58                oid,
59                "another repository claimed this object while it was being collected, so its bytes \
60                 stay"
61            );
62
63            return true;
64        }
65
66        false
67    }
68
69    // The markers this repository is allowed to drop. Retained is what the client
70    // says it still needs; the grace window is what keeps a push still in flight
71    // from being read as an abandoned object.
72    fn droppable(
73        mine: Vec<(keyspace::Entry, String)>,
74        retained: &HashSet<String>,
75        grace: Duration,
76        report: &mut SweepReport,
77    ) -> Vec<(keyspace::Entry, String)> {
78        mine.into_iter()
79            .filter(|(entry, oid)| {
80                if retained.contains(oid) {
81                    return false;
82                }
83
84                if entry.age().is_none_or(|age| age < grace) {
85                    report.within_grace += 1;
86                    return false;
87                }
88
89                report.swept += 1;
90                true
91            })
92            .collect()
93    }
94
95    // The cost this exists to avoid: one listing of this repository's own prefix,
96    // then one listing of a short index prefix per object actually being dropped.
97    // Nothing here is proportional to the size of the bucket.
98    async fn sweep_indexed(
99        &self,
100        ns: &Namespace,
101        retained: &HashSet<String>,
102        grace: Duration,
103        dry_run: bool,
104    ) -> Result<SweepReport, Error> {
105        let listing = self.keys.listing(&Self::own_prefix(ns)).await;
106        let mut report = SweepReport {
107            dry_run,
108            incomplete: !listing.complete,
109            ..Default::default()
110        };
111
112        // The size index shares this prefix, so it arrives in the same listing.
113        // Kept rather than discarded, because dropping a marker should take its
114        // entry with it and the key carries a number this sweep has no other way
115        // of knowing.
116        let mut sized = HashMap::new();
117        let mut mine = Vec::new();
118
119        for entry in listing.entries {
120            if sizes::is_one(&entry.key) {
121                if let Some((oid, _)) = sizes::read(&entry.key) {
122                    sized.insert(oid, entry.key);
123                }
124                continue;
125            }
126
127            let Some(oid) = entry.key.rsplit('/').next().map(str::to_owned) else {
128                continue;
129            };
130            if crate::storage::LocalStore::validate_oid(&oid).is_ok() {
131                mine.push((entry, oid));
132            }
133        }
134
135        for (entry, oid) in Self::droppable(mine, retained, grace, &mut report) {
136            let frees = !refs::claimed_by_another(&self.keys, ns, &oid).await;
137
138            if dry_run {
139                if frees {
140                    report.bytes += self.size_of(&oid).await.unwrap_or_default();
141                }
142                continue;
143            }
144
145            self.keys.delete(&entry.key).await?;
146
147            // After the marker, never before. A failure between the two has to
148            // leave a ref with no claim behind it, which costs an object nobody
149            // reads, rather than a claim with no ref, which would let the next
150            // sweep free bytes this repository still holds.
151            if let Err(error) = self.keys.delete(&refs::key(ns, &oid)).await {
152                tracing::warn!(%error, oid, "a dropped marker left its index entry behind");
153            }
154
155            // Tidiness rather than correctness: a size whose marker has gone is
156            // counted by nobody, because only a marker says this repository holds
157            // anything.
158            if let Some(key) = sized.get(&oid)
159                && let Err(error) = self.keys.delete(key).await
160            {
161                tracing::warn!(%error, oid, "a dropped marker left its size behind");
162            }
163
164            if frees && !self.claimed_since(ns, &oid).await {
165                // Asked before the delete, because afterwards there is nothing
166                // left to ask.
167                let size = self.size_of(&oid).await.unwrap_or_default();
168
169                if self.keys.delete(&Self::content_key(&oid)).await? {
170                    report.bytes += size;
171                }
172            }
173        }
174
175        Ok(report)
176    }
177
178    // What a bucket with no index costs, and what builds one.
179    //
180    // One listing of the whole bucket answers all three questions at once: which
181    // markers this repository holds, which oids any other repository still
182    // claims, and how big each content object is. Asked separately they would
183    // cost a request per object, which on a bucket is the difference between a
184    // collection an operator runs and one they read about.
185    //
186    // A listing that did not finish is the dangerous case. It cannot be used to
187    // conclude that nothing references an object, because the reference may sit
188    // in the pages that never arrived. So an incomplete listing still drops this
189    // repository's markers, which the retained set alone decides, and leaves
190    // every content key exactly where it is.
191    async fn sweep_whole_bucket(
192        &self,
193        ns: &Namespace,
194        retained: &HashSet<String>,
195        grace: Duration,
196        dry_run: bool,
197    ) -> Result<SweepReport, Error> {
198        let listing = self.keys.listing("").await;
199        let mut report = SweepReport {
200            dry_run,
201            incomplete: !listing.complete,
202            ..Default::default()
203        };
204
205        let ours = Self::own_prefix(ns);
206        let mut markers = Vec::new();
207        let mut sized = HashMap::new();
208        let mut mine = Vec::new();
209        let mut claimed_elsewhere = HashSet::new();
210        let mut content_sizes = HashMap::new();
211
212        for entry in listing.entries {
213            if let Some(rest) = entry.key.strip_prefix(".content/") {
214                if let Some(oid) = rest.rsplit('/').next() {
215                    content_sizes.insert(oid.to_owned(), entry.size);
216                }
217                continue;
218            }
219
220            // Locks live at `.locks/{org}/{repo}/{id}`, so they never match the
221            // marker prefix and are never swept. Skipped explicitly all the same:
222            // falling through would file every lock id in the claimed set, and an
223            // object whose digest happened to equal a lock id would then never be
224            // collected. The odds are absurd today and the line costs nothing,
225            // but the code should not depend on ids and digests never colliding.
226            //
227            // The index is skipped for a sharper reason than caution:
228            // `.refs/{oid}/{org}/{repo}` ends in a repository name, so reading one
229            // as a marker would file that name as an oid somebody claims.
230            if entry.key.starts_with(".incoming/")
231                || entry.key.starts_with(".locks/")
232                || entry.key.starts_with(".refs/")
233                || entry.key.starts_with(".probe/")
234            {
235                continue;
236            }
237
238            // The size index shares a repository's prefix, so it arrives here
239            // among the markers. Read as one, an entry of it is a claim on an
240            // object whose name ends in a number. Kept rather than dropped,
241            // because a marker this sweep removes should take its size along and
242            // the key is the only place that number is written down.
243            if sizes::is_one(&entry.key) {
244                if entry.key.starts_with(&ours)
245                    && let Some((oid, _)) = sizes::read(&entry.key)
246                {
247                    sized.insert(oid, entry.key);
248                }
249
250                continue;
251            }
252
253            let Some(oid) = entry.key.rsplit('/').next().map(str::to_owned) else {
254                continue;
255            };
256
257            markers.push(entry.key.clone());
258
259            if entry.key.starts_with(&ours) {
260                mine.push((entry, oid));
261            } else {
262                claimed_elsewhere.insert(oid);
263            }
264        }
265
266        // Before anything is deleted, so the index never gains a ref for a marker
267        // this sweep is about to drop. Built from the listing already paid for,
268        // and only when that listing finished: an index built from half a bucket
269        // would be missing holders, which is the one direction it must never
270        // drift in.
271        //
272        // A failure is not fatal. The listing above has already answered the
273        // question correctly on its own, so collection proceeds and the next
274        // sweep reads the bucket again.
275        if !dry_run
276            && listing.complete
277            && let Err(error) = refs::backfill(&self.keys, &markers).await
278        {
279            tracing::warn!(
280                %error,
281                "the claim index could not be built, so the next sweep reads the bucket again"
282            );
283        }
284
285        for (entry, oid) in Self::droppable(mine, retained, grace, &mut report) {
286            // Only what this call actually frees is counted. Another repository
287            // holding the same bytes means dropping this marker frees nothing,
288            // and a dry run that said otherwise would promise space it cannot
289            // deliver.
290            let frees = listing.complete && !claimed_elsewhere.contains(&oid);
291            let size = content_sizes.get(&oid).copied().unwrap_or_default();
292
293            if dry_run {
294                if frees {
295                    report.bytes += size;
296                }
297                continue;
298            }
299
300            self.keys.delete(&entry.key).await?;
301
302            if let Err(error) = self.keys.delete(&refs::key(ns, &oid)).await {
303                tracing::warn!(%error, oid, "a dropped marker left its index entry behind");
304            }
305
306            if let Some(key) = sized.get(&oid)
307                && let Err(error) = self.keys.delete(key).await
308            {
309                tracing::warn!(%error, oid, "a dropped marker left its size behind");
310            }
311
312            // Counted only when this call is the one that removed them, so two
313            // repositories letting go at once cannot each claim the same space.
314            // The listing that decided `frees` was taken before any of these
315            // deletes, so it is the stalest answer there is and the index gets
316            // the last word.
317            if frees
318                && !self.claimed_since(ns, &oid).await
319                && self.keys.delete(&Self::content_key(&oid)).await?
320            {
321                report.bytes += size;
322            }
323        }
324
325        Ok(report)
326    }
327}