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