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}