Skip to main content

lfsx_server/storage/
s3.rs

1pub(crate) mod keyspace;
2pub(crate) mod multipart;
3pub(crate) mod probe;
4pub(crate) mod refs;
5
6use std::time::Duration;
7
8use axum::body::Bytes;
9use futures_util::{Stream, StreamExt};
10
11use base64::Engine;
12
13use crate::error::Error;
14use crate::namespace::Namespace;
15use crate::storage::Reclaimed;
16
17pub use keyspace::{Keyspace, Presigned};
18
19const CHECKSUM: &str = "x-amz-checksum-sha256";
20
21// Enough to hide the round trips a bucket charges for without becoming a burst
22// the store answers with 503, and the same figure the batch endpoint settled on
23// for the same reason.
24const SIZES_AT_ONCE: usize = 16;
25
26pub struct S3Config {
27    pub endpoint: String,
28    pub bucket: String,
29    pub region: String,
30    pub access_key: String,
31    pub secret_key: String,
32    pub path_style: bool,
33    // How long a signature is good for. It is the same number the batch
34    // response advertises as `expires_in`, because a client told it has half an
35    // hour and handed a URL that dies in five minutes will fail a resume it had
36    // every reason to expect to work.
37    pub lifetime: Duration,
38}
39
40// The same layout as the local store, for the same reasons. The bytes live once
41// under a key derived from their digest, and a repository that holds them owns
42// an empty marker beside it — the object store's answer to a hard link. It is
43// what keeps two projects sharing an asset pack from paying twice, and what
44// stops a repository reading an object it never pushed: the marker is the proof
45// of possession, and it is the only thing the permission check consults.
46//
47// Everything below is object semantics. What it takes to talk to the store at
48// all — signing, retrying, listing, the client — is the keyspace underneath.
49#[derive(Clone)]
50pub struct S3Store {
51    keys: Keyspace,
52    redirect: bool,
53}
54
55impl S3Store {
56    pub fn new(keys: Keyspace, redirect: bool) -> Self {
57        Self { keys, redirect }
58    }
59
60    fn content_key(oid: &str) -> String {
61        format!(".content/{}/{}/{oid}", &oid[0..2], &oid[2..4])
62    }
63
64    // Where a client uploads to when the bytes never pass through this server.
65    // Per repository on purpose: the shared content key would take bytes from
66    // anyone allowed to write, and then nothing distinguishes a repository that
67    // uploaded an object from one that merely knew its digest. A key only this
68    // repository was handed a signature for is the proof of possession that the
69    // marker stands for everywhere else.
70    fn incoming_key(ns: &Namespace, oid: &str) -> String {
71        format!(
72            ".incoming/{}/{}/{}/{}/{oid}",
73            ns.org(),
74            ns.repo(),
75            &oid[0..2],
76            &oid[2..4]
77        )
78    }
79
80    fn marker_key(ns: &Namespace, oid: &str) -> String {
81        format!(
82            "{}/{}/{}/{}/{oid}",
83            ns.org(),
84            ns.repo(),
85            &oid[0..2],
86            &oid[2..4]
87        )
88    }
89
90    fn own_prefix(ns: &Namespace) -> String {
91        format!("{}/{}/", ns.org(), ns.repo())
92    }
93
94    pub async fn reachable(&self) -> Result<(), Error> {
95        self.keys.reachable().await
96    }
97
98    pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
99        if crate::storage::LocalStore::validate_oid(oid).is_err() {
100            return false;
101        }
102
103        self.keys.head(&Self::marker_key(ns, oid)).await.is_ok()
104    }
105
106    pub async fn size_of(&self, oid: &str) -> Result<u64, Error> {
107        // Every entry point validates before slicing an oid into a key: the
108        // fanout takes the first four characters, so a short one is a panic
109        // rather than a refusal, and a panic is a 500 for something that should
110        // have been a 422.
111        crate::storage::LocalStore::validate_oid(oid)?;
112
113        self.keys.head(&Self::content_key(oid)).await
114    }
115
116    // A download is streamed through this server rather than redirected, so the
117    // features that live in the byte path — the counters, the ranges, and the
118    // compression that will follow — keep working. The pre-signed redirect is a
119    // separate mode for operators who would rather spend the object store's
120    // bandwidth than their own.
121    pub async fn read(
122        &self,
123        oid: &str,
124        start: u64,
125        length: u64,
126    ) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>> + use<>, Error> {
127        crate::storage::LocalStore::validate_oid(oid)?;
128
129        self.keys
130            .get_range(&Self::content_key(oid), start, length)
131            .await
132    }
133
134    // A URL the client fetches from the bucket directly, so the bytes never
135    // cross this server. Whether the caller is entitled to them has already been
136    // settled by the marker before this is called: the signature is scoped to
137    // one content key and expires, and it grants nothing the batch response was
138    // not about to grant anyway.
139    pub fn presigned_download(&self, oid: &str) -> Option<String> {
140        if !self.redirect || crate::storage::LocalStore::validate_oid(oid).is_err() {
141            return None;
142        }
143
144        Some(self.keys.signed_download(&Self::content_key(oid)))
145    }
146
147    // A URL the client PUTs the object to, and the headers it has to send with
148    // it. The digest is bound into the signature, so the store refuses anything
149    // that does not hash to the object it was signed for: a client with this URL
150    // cannot put arbitrary bytes anywhere, which is what makes handing one out
151    // safe at all.
152    //
153    // None above the single-request ceiling, and that is not a refusal: the
154    // object falls back to coming through this server, which sends it in parts.
155    // A client cannot do the same, because the `basic` transfer adapter every
156    // git-lfs speaks does one PUT to one href and has nowhere to put a second.
157    // So the ceiling multipart removes for the streamed path is real and
158    // permanent for this one, and the only question is whether the client learns
159    // it now or after uploading five gigabytes.
160    //
161    // It also keeps `adopt` honest: `CopyObject` stops at the same 5 GiB, and
162    // nothing can reach `.incoming/` above it while this holds.
163    pub fn presigned_upload(&self, ns: &Namespace, oid: &str, size: u64) -> Option<Presigned> {
164        if !self.redirect
165            || size > multipart::SINGLE_PUT_CEILING
166            || crate::storage::LocalStore::validate_oid(oid).is_err()
167        {
168            return None;
169        }
170
171        let digest = base64::engine::general_purpose::STANDARD.encode(hex::decode(oid).ok()?);
172
173        Some(self.keys.signed_upload(
174            &Self::incoming_key(ns, oid),
175            vec![(CHECKSUM.to_owned(), digest)],
176        ))
177    }
178
179    // How big the object a client uploaded actually is, which is the first thing
180    // this server learns about it: nothing measured the bytes on the way past.
181    pub async fn uploaded_size(&self, ns: &Namespace, oid: &str) -> Result<u64, Error> {
182        crate::storage::LocalStore::validate_oid(oid)?;
183
184        self.keys.head(&Self::incoming_key(ns, oid)).await
185    }
186
187    // Take an upload that landed under this repository's own key into the shared
188    // keyspace. The bytes are already known to hash to the oid, because the store
189    // refused everything else.
190    pub async fn adopt(&self, ns: &Namespace, oid: &str) -> Result<(), Error> {
191        crate::storage::LocalStore::validate_oid(oid)?;
192
193        let incoming = Self::incoming_key(ns, oid);
194        let content = Self::content_key(oid);
195
196        // First, before anything here so much as looks at the content.
197        //
198        // The marker is the claim and the ref is the index of it, so a crash
199        // between the two has to leave a ref nobody claims rather than a claim
200        // nothing indexes: the first leaks an object, the second lets a later
201        // sweep free bytes this repository holds.
202        //
203        // Writing it up here rather than beside the marker costs nothing and buys
204        // the race below. A sweep asks the index one last time before deleting
205        // bytes, so a claim recorded before this repository even checked whether
206        // the content exists is a claim that sweep will see.
207        refs::write(&self.keys, ns, oid).await?;
208
209        // Already there means another repository pushed the same object, and the
210        // bytes are identical by construction.
211        if self.keys.head(&content).await.is_err() {
212            self.keys.copy(&incoming, &content).await?;
213        }
214
215        self.keys
216            .put(
217                &Self::marker_key(ns, oid),
218                reqwest::Body::from(Vec::new()),
219                0,
220            )
221            .await?;
222
223        // Leaving it would pay for the object twice. A failure here is not worth
224        // failing the push over: the object is adopted, and what is left is a key
225        // the operator can see.
226        if let Err(error) = self.keys.delete(&incoming).await {
227            tracing::warn!(%error, key = incoming, "an adopted upload could not be cleaned up");
228        }
229
230        Ok(())
231    }
232
233    // The upload has already been streamed to a staging file, hashed and checked
234    // against everything the server enforces, so that file is what goes up —
235    // streamed from disk rather than read into memory, because an object here is
236    // measured in gigabytes and the whole storage layer is built on holding at
237    // most a few megabytes of one at a time.
238    //
239    // The bytes go up once, keyed by their digest, and the marker records that
240    // this repository holds them. Content that is already there is skipped: the
241    // key would receive the same bytes it already has.
242    pub async fn store(
243        &self,
244        ns: &Namespace,
245        oid: &str,
246        staged: &std::path::Path,
247    ) -> Result<(), Error> {
248        crate::storage::LocalStore::validate_oid(oid)?;
249
250        // Before the content is even looked at, for the reason `adopt` gives:
251        // this is what a sweep re-reads before deleting bytes, so a claim
252        // recorded here cannot be missed by one that is already deciding.
253        refs::write(&self.keys, ns, oid).await?;
254
255        if self.keys.head(&Self::content_key(oid)).await.is_err() {
256            let file = tokio::fs::File::open(staged).await?;
257            let length = file.metadata().await?.len();
258
259            // One request while one request will carry it, which is every
260            // object a store normally sees, and parts when it will not. The
261            // split is here rather than always going in parts because the
262            // single write is one round trip and needs no cleanup if it fails.
263            if length > multipart::SINGLE_PUT_CEILING {
264                drop(file);
265                multipart::put(&self.keys, &Self::content_key(oid), staged, length).await?;
266            } else {
267                let stream = tokio_util::io::ReaderStream::new(file);
268
269                self.keys
270                    .put(
271                        &Self::content_key(oid),
272                        reqwest::Body::wrap_stream(stream),
273                        length,
274                    )
275                    .await?;
276            }
277        }
278
279        self.keys
280            .put(
281                &Self::marker_key(ns, oid),
282                reqwest::Body::from(Vec::new()),
283                0,
284            )
285            .await
286    }
287
288    // What an interrupted upload leaves behind. A client can negotiate, PUT the
289    // object, and never report it: the bytes sit under its own upload key and
290    // nothing else will ever look at them. The local path has had a reclaimer for
291    // this since the beginning, and a bucket had none, so the cost was unbounded
292    // over time and invisible.
293    pub async fn reclaim_incoming(&self, older_than: Duration) -> Result<Reclaimed, Error> {
294        let mut reclaimed = Reclaimed::default();
295
296        // `.probe/` too. A startup probe draws a key nothing else uses so that no
297        // run can read another's leftovers, which means a run that dies before
298        // cleaning up leaves one behind rather than overwriting it. They are
299        // empty or nearly so, and this is already the sweep for writes nobody
300        // will ever come back for.
301        for prefix in [".incoming/", ".probe/"] {
302            for entry in self.keys.entries(prefix).await? {
303                // A slow client on a bad connection is not an abandoned one.
304                if entry.age().is_none_or(|age| age < older_than) {
305                    continue;
306                }
307
308                if self.keys.delete(&entry.key).await.is_ok() {
309                    reclaimed.files += 1;
310                    reclaimed.bytes += entry.size;
311                }
312            }
313        }
314
315        Ok(reclaimed)
316    }
317
318    // Collection, with the marker keyspace standing in for the link count a
319    // filesystem keeps. A repository's marker is its claim on the bytes, and the
320    // bytes go when the last claim does.
321    //
322    // Everything hard here is one question: does any *other* repository still
323    // claim this object? A marker is `{org}/{repo}/.../{oid}`, so the oid is the
324    // suffix and the org and repo that would make a prefix are exactly what is
325    // unknown. The claim index turns that into one prefix listing per object. A
326    // bucket that predates the index has to be read whole instead, and that pass
327    // builds the index as it goes, so it is paid once rather than every sweep.
328    pub async fn sweep(
329        &self,
330        ns: &Namespace,
331        retained: &std::collections::HashSet<String>,
332        grace: Duration,
333        dry_run: bool,
334    ) -> Result<crate::storage::SweepReport, Error> {
335        if refs::ready(&self.keys).await {
336            self.sweep_indexed(ns, retained, grace, dry_run).await
337        } else {
338            self.sweep_whole_bucket(ns, retained, grace, dry_run).await
339        }
340    }
341
342    // The last question asked before bytes go, and the reason the index is read
343    // twice for one object.
344    //
345    // Between deciding an object is unclaimed and deleting it, another repository
346    // can push the same digest. It finds the content already there, skips the
347    // upload, and writes a claim, so deleting now leaves it holding a marker
348    // pointing at nothing, which its client meets as a missing object on the next
349    // pull.
350    //
351    // A push writes its ref before it so much as looks at the content, so a claim
352    // that landed at any moment before this question is one this sees. What is
353    // left is the width of a single request, between reading this answer and the
354    // delete that follows it. Closing that needs a lease the deleting side takes
355    // and every push waits on, which is a round trip on the hot path bought
356    // against a window this narrow, and it is not obviously the right trade.
357    async fn claimed_since(&self, ns: &Namespace, oid: &str) -> bool {
358        if refs::claimed_by_another(&self.keys, ns, oid).await {
359            tracing::info!(
360                oid,
361                "another repository claimed this object while it was being collected, so its bytes \
362                 stay"
363            );
364
365            return true;
366        }
367
368        false
369    }
370
371    // The markers this repository is allowed to drop. Retained is what the client
372    // says it still needs; the grace window is what keeps a push still in flight
373    // from being read as an abandoned object.
374    fn droppable(
375        mine: Vec<(keyspace::Entry, String)>,
376        retained: &std::collections::HashSet<String>,
377        grace: Duration,
378        report: &mut crate::storage::SweepReport,
379    ) -> Vec<(keyspace::Entry, String)> {
380        mine.into_iter()
381            .filter(|(entry, oid)| {
382                if retained.contains(oid) {
383                    return false;
384                }
385
386                if entry.age().is_none_or(|age| age < grace) {
387                    report.within_grace += 1;
388                    return false;
389                }
390
391                report.swept += 1;
392                true
393            })
394            .collect()
395    }
396
397    // The cost this exists to avoid: one listing of this repository's own prefix,
398    // then one listing of a short index prefix per object actually being dropped.
399    // Nothing here is proportional to the size of the bucket.
400    async fn sweep_indexed(
401        &self,
402        ns: &Namespace,
403        retained: &std::collections::HashSet<String>,
404        grace: Duration,
405        dry_run: bool,
406    ) -> Result<crate::storage::SweepReport, Error> {
407        let listing = self.keys.listing(&Self::own_prefix(ns)).await;
408        let mut report = crate::storage::SweepReport {
409            dry_run,
410            incomplete: !listing.complete,
411            ..Default::default()
412        };
413
414        let mine = listing
415            .entries
416            .into_iter()
417            .filter_map(|entry| {
418                let oid = entry.key.rsplit('/').next()?.to_owned();
419                crate::storage::LocalStore::validate_oid(&oid).ok()?;
420                Some((entry, oid))
421            })
422            .collect();
423
424        for (entry, oid) in Self::droppable(mine, retained, grace, &mut report) {
425            let frees = !refs::claimed_by_another(&self.keys, ns, &oid).await;
426
427            if dry_run {
428                if frees {
429                    report.bytes += self.size_of(&oid).await.unwrap_or_default();
430                }
431                continue;
432            }
433
434            self.keys.delete(&entry.key).await?;
435
436            // After the marker, never before. A failure between the two has to
437            // leave a ref with no claim behind it, which costs an object nobody
438            // reads, rather than a claim with no ref, which would let the next
439            // sweep free bytes this repository still holds.
440            if let Err(error) = self.keys.delete(&refs::key(ns, &oid)).await {
441                tracing::warn!(%error, oid, "a dropped marker left its index entry behind");
442            }
443
444            if frees && !self.claimed_since(ns, &oid).await {
445                // Asked before the delete, because afterwards there is nothing
446                // left to ask.
447                let size = self.size_of(&oid).await.unwrap_or_default();
448
449                if self.keys.delete(&Self::content_key(&oid)).await? {
450                    report.bytes += size;
451                }
452            }
453        }
454
455        Ok(report)
456    }
457
458    // What a bucket with no index costs, and what builds one.
459    //
460    // One listing of the whole bucket answers all three questions at once: which
461    // markers this repository holds, which oids any other repository still
462    // claims, and how big each content object is. Asked separately they would
463    // cost a request per object, which on a bucket is the difference between a
464    // collection an operator runs and one they read about.
465    //
466    // A listing that did not finish is the dangerous case. It cannot be used to
467    // conclude that nothing references an object, because the reference may sit
468    // in the pages that never arrived. So an incomplete listing still drops this
469    // repository's markers, which the retained set alone decides, and leaves
470    // every content key exactly where it is.
471    async fn sweep_whole_bucket(
472        &self,
473        ns: &Namespace,
474        retained: &std::collections::HashSet<String>,
475        grace: Duration,
476        dry_run: bool,
477    ) -> Result<crate::storage::SweepReport, Error> {
478        let listing = self.keys.listing("").await;
479        let mut report = crate::storage::SweepReport {
480            dry_run,
481            incomplete: !listing.complete,
482            ..Default::default()
483        };
484
485        let ours = Self::own_prefix(ns);
486        let mut markers = Vec::new();
487        let mut mine = Vec::new();
488        let mut claimed_elsewhere = std::collections::HashSet::new();
489        let mut sizes = std::collections::HashMap::new();
490
491        for entry in listing.entries {
492            if let Some(rest) = entry.key.strip_prefix(".content/") {
493                if let Some(oid) = rest.rsplit('/').next() {
494                    sizes.insert(oid.to_owned(), entry.size);
495                }
496                continue;
497            }
498
499            // Locks live at `.locks/{org}/{repo}/{id}`, so they never match the
500            // marker prefix and are never swept. Skipped explicitly all the same:
501            // falling through would file every lock id in the claimed set, and an
502            // object whose digest happened to equal a lock id would then never be
503            // collected. The odds are absurd today and the line costs nothing,
504            // but the code should not depend on ids and digests never colliding.
505            //
506            // The index is skipped for a sharper reason than caution:
507            // `.refs/{oid}/{org}/{repo}` ends in a repository name, so reading one
508            // as a marker would file that name as an oid somebody claims.
509            if entry.key.starts_with(".incoming/")
510                || entry.key.starts_with(".locks/")
511                || entry.key.starts_with(".refs/")
512                || entry.key.starts_with(".probe/")
513            {
514                continue;
515            }
516
517            let Some(oid) = entry.key.rsplit('/').next().map(str::to_owned) else {
518                continue;
519            };
520
521            markers.push(entry.key.clone());
522
523            if entry.key.starts_with(&ours) {
524                mine.push((entry, oid));
525            } else {
526                claimed_elsewhere.insert(oid);
527            }
528        }
529
530        // Before anything is deleted, so the index never gains a ref for a marker
531        // this sweep is about to drop. Built from the listing already paid for,
532        // and only when that listing finished: an index built from half a bucket
533        // would be missing holders, which is the one direction it must never
534        // drift in.
535        //
536        // A failure is not fatal. The listing above has already answered the
537        // question correctly on its own, so collection proceeds and the next
538        // sweep reads the bucket again.
539        if !dry_run
540            && listing.complete
541            && let Err(error) = refs::backfill(&self.keys, &markers).await
542        {
543            tracing::warn!(
544                %error,
545                "the claim index could not be built, so the next sweep reads the bucket again"
546            );
547        }
548
549        for (entry, oid) in Self::droppable(mine, retained, grace, &mut report) {
550            // Only what this call actually frees is counted. Another repository
551            // holding the same bytes means dropping this marker frees nothing,
552            // and a dry run that said otherwise would promise space it cannot
553            // deliver.
554            let frees = listing.complete && !claimed_elsewhere.contains(&oid);
555            let size = sizes.get(&oid).copied().unwrap_or_default();
556
557            if dry_run {
558                if frees {
559                    report.bytes += size;
560                }
561                continue;
562            }
563
564            self.keys.delete(&entry.key).await?;
565
566            if let Err(error) = self.keys.delete(&refs::key(ns, &oid)).await {
567                tracing::warn!(%error, oid, "a dropped marker left its index entry behind");
568            }
569
570            // Counted only when this call is the one that removed them, so two
571            // repositories letting go at once cannot each claim the same space.
572            // The listing that decided `frees` was taken before any of these
573            // deletes, so it is the stalest answer there is and the index gets
574            // the last word.
575            if frees
576                && !self.claimed_since(ns, &oid).await
577                && self.keys.delete(&Self::content_key(&oid)).await?
578            {
579                report.bytes += size;
580            }
581        }
582
583        Ok(report)
584    }
585
586    // What the bucket holds for this repository, counted from its markers and
587    // the content they point at. The markers are empty, so their own size says
588    // nothing — this is a listing plus one head per object, which is why the
589    // figure is cached the same way the local one is.
590    pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
591        let oids = self.list(&Self::own_prefix(ns)).await;
592        let objects = oids.len() as u64;
593
594        // Asked a few at a time rather than one after another. The number of
595        // requests is the same, and it is the cost this cannot avoid without
596        // changing the layout, but in series a repository holding fifty thousand
597        // objects is fifty thousand round trips end to end: minutes of a client
598        // waiting on a quota check that the cache was meant to make invisible.
599        //
600        // What would remove the requests rather than overlap them is still open
601        // in #174, because both answers there cost something else.
602        let bytes = futures_util::stream::iter(oids)
603            .map(|oid| {
604                let store = &self;
605                async move { store.size_of(&oid).await.unwrap_or_default() }
606            })
607            .buffer_unordered(SIZES_AT_ONCE)
608            .fold(0, |held, size| async move { held + size })
609            .await;
610
611        (objects, bytes)
612    }
613
614    async fn list(&self, prefix: &str) -> Vec<String> {
615        // A capacity figure that silently reads zero is worse than one that is
616        // missing, because it looks like an answer.
617        let keys = match self.keys.keys(prefix).await {
618            Ok(keys) => keys,
619            Err(error) => {
620                tracing::warn!(%error, "the object store could not be listed");
621                return Vec::new();
622            }
623        };
624
625        keys.into_iter()
626            .filter_map(|key| key.rsplit('/').next().map(str::to_owned))
627            .filter(|oid| crate::storage::LocalStore::validate_oid(oid).is_ok())
628            .collect()
629    }
630}
631
632#[cfg(test)]
633pub(crate) mod tests;