Skip to main content

lfsx_server/storage/
backend.rs

1use futures_util::Stream;
2
3use super::s3::S3Store;
4use super::{Budget, CompressReport, DedupeReport, LocalStore, Object, SweepReport, VerifyReport};
5use crate::error::Error;
6use crate::namespace::Namespace;
7#[cfg(test)]
8use sha2::Digest;
9#[cfg(test)]
10use std::time::Duration;
11
12// Where the objects live. A bucket decouples capacity from the machine, at the
13// price of the things a filesystem gave for nothing — hard links, a directory
14// walk, and a rename that is atomic. Each of those is answered here or refused
15// out loud; none of them is quietly skipped.
16pub struct Store {
17    backend: Backend,
18    usage: super::usage::Usage,
19}
20
21enum Backend {
22    Local(LocalStore),
23    // Even with a bucket the local store stays, because a transfer has to land
24    // somewhere before anyone can tell whether it is the object it claims to be.
25    // It is a write buffer, not the store.
26    // Boxed because a bucket handle beside a local store makes this variant far
27    // larger than the other, and every Store in the process would pay for it.
28    Bucket {
29        bucket: Box<S3Store>,
30        staging: LocalStore,
31    },
32}
33
34impl Store {
35    pub fn local(store: LocalStore) -> Self {
36        Self::over(Backend::Local(store))
37    }
38
39    // Compression and encryption used to be stripped here, because a framed
40    // object was only readable through the file the codec opened and a bucket key
41    // is not one. The codec now reads from a bucket too, so the frames go up as
42    // they are and come back decoded: the header and the index are three ranged
43    // GETs, which is what the format was shaped for.
44    pub fn bucket(bucket: S3Store, staging: LocalStore) -> Self {
45        Self::over(Backend::Bucket {
46            bucket: Box::new(bucket),
47            staging,
48        })
49    }
50
51    fn over(backend: Backend) -> Self {
52        Self {
53            backend,
54            usage: super::usage::Usage::default(),
55        }
56    }
57
58    fn staging(&self) -> &LocalStore {
59        match &self.backend {
60            Backend::Local(store) => store,
61            Backend::Bucket { staging, .. } => staging,
62        }
63    }
64
65    // Everything an interrupted upload can leave behind, wherever it left it. A
66    // bucket deployment still stages locally, so both are swept and the figures
67    // add up to one answer.
68    pub async fn reclaim(&self, older_than: std::time::Duration) -> super::Reclaimed {
69        let mut reclaimed = self.staging().reclaim_staging(older_than).await;
70
71        if let Backend::Bucket { bucket, .. } = &self.backend {
72            match bucket.reclaim_incoming(older_than).await {
73                Ok(theirs) => {
74                    reclaimed.files += theirs.files;
75                    reclaimed.bytes += theirs.bytes;
76                }
77                Err(error) => {
78                    tracing::warn!(%error, "abandoned uploads in the bucket could not be reclaimed");
79                }
80            }
81        }
82
83        reclaimed
84    }
85
86    // Readiness has to ask the backend that actually serves. Once the objects
87    // live in a bucket the volume is a write buffer, and an instance whose
88    // credentials were rotated or whose bucket is gone passes a probe that only
89    // proves its scratch disk works — then fails every transfer it is handed.
90    //
91    // So both are asked, either failing takes the instance out, and they are
92    // named apart: a full disk and a rotated key are not the same afternoon.
93    pub async fn writable(&self) -> Result<(), Error> {
94        self.staging().writable().await.map_err(|error| {
95            Error::Storage(std::io::Error::other(format!(
96                "the staging volume is not writable: {error}"
97            )))
98        })?;
99
100        if let Backend::Bucket { bucket, .. } = &self.backend {
101            bucket.reachable().await?;
102        }
103
104        Ok(())
105    }
106
107    pub fn scans(&self) -> u64 {
108        self.staging().scans()
109    }
110
111    pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
112        match &self.backend {
113            Backend::Local(store) => store.exists(ns, oid).await,
114            Backend::Bucket { bucket, .. } => bucket.exists(ns, oid).await,
115        }
116    }
117
118    // Where the client should fetch this object from, when that is somewhere
119    // other than this server. None for a local store, and for a bucket the
120    // operator has not asked to redirect — which is the default, because the
121    // streamed path is the one that counts the bytes and holds the ceiling.
122    //
123    // The caller is responsible for having established that this repository
124    // holds the object. This hands out a signature, not a permission.
125    pub fn redirect(&self, oid: &str) -> Option<String> {
126        match &self.backend {
127            Backend::Local(_) => None,
128            // A pre-signed URL hands over whatever sits under that key, and with
129            // a codec in the path that is a frame rather than the object. The
130            // client would hash what arrived, get a digest that is not the one it
131            // asked for, and reject it. So the redirect is given up and the
132            // download streams, which is the only path that can decode.
133            //
134            // Compression is enough on its own, even though it still lets a
135            // client upload straight to the bucket. That asymmetry is the right
136            // way round: an unframed object is a perfectly good entry, so a
137            // direct upload stays safe, while one framed object anywhere in the
138            // store makes every redirect a guess.
139            Backend::Bucket { staging, .. } if staging.frames() => None,
140            Backend::Bucket { bucket, .. } => bucket.presigned_download(oid),
141        }
142    }
143
144    // Where the client should PUT the object, when that is the bucket rather than
145    // this server. None for a local store and for a bucket the operator has not
146    // asked to redirect.
147    pub fn presigned_upload(
148        &self,
149        ns: &Namespace,
150        oid: &str,
151        size: u64,
152    ) -> Option<super::s3::Presigned> {
153        match &self.backend {
154            Backend::Local(_) => None,
155            // A client uploading straight to the bucket writes the object as it
156            // is, so a configured key would never touch it and the bucket would
157            // hold plaintext while an operator believed otherwise. Encryption is
158            // a promise about what the storage provider can read; a faster upload
159            // is not worth quietly breaking it. Those transfers keep coming
160            // through the server, which seals them.
161            Backend::Bucket { staging, .. } if staging.encrypts() => None,
162            Backend::Bucket { bucket, .. } => bucket.presigned_upload(ns, oid, size),
163        }
164    }
165
166    // How big an object waiting under this repository's own upload key is. None
167    // when there is nothing waiting, which is every local deployment and every
168    // client that has not used its URL.
169    pub async fn uploaded_size(&self, ns: &Namespace, oid: &str) -> Result<Option<u64>, Error> {
170        match &self.backend {
171            Backend::Local(_) => Ok(None),
172            Backend::Bucket { bucket, .. } => Ok(bucket.uploaded_size(ns, oid).await.ok()),
173        }
174    }
175
176    // Take an upload this repository made into the shared keyspace. Only reachable
177    // for a bucket, because only there does a client write anywhere this server
178    // did not.
179    pub async fn adopt(&self, ns: &Namespace, oid: &str, arrived: u64) -> Result<(), Error> {
180        let outcome = match &self.backend {
181            Backend::Local(_) => Err(Error::Unsupported(
182                "objects are written through this server, so there is nothing to adopt",
183            )),
184            Backend::Bucket { bucket, .. } => bucket.adopt(ns, oid).await,
185        };
186
187        // Same reason as a write: verify is called once per object, so dropping
188        // what is remembered here would make every one of them re-measure.
189        if outcome.is_ok() {
190            self.usage.stored(ns, arrived).await;
191        }
192
193        outcome
194    }
195
196    pub async fn open(&self, ns: &Namespace, oid: &str) -> Result<Object, Error> {
197        match &self.backend {
198            Backend::Local(store) => store.open(ns, oid).await,
199            Backend::Bucket { bucket, staging } => {
200                // The marker is the proof of possession and is checked before
201                // anything is read, exactly as a local open checks the link.
202                if !bucket.exists(ns, oid).await {
203                    return Err(Error::NotFound);
204                }
205
206                let size = bucket.size_of(oid).await?;
207                let reader = super::codec::Reader::Bucket {
208                    bucket: (**bucket).clone(),
209                    oid: oid.to_owned(),
210                };
211
212                match super::codec::Framed::open(
213                    reader,
214                    size,
215                    staging.keyring().map(AsRef::as_ref),
216                    oid,
217                )
218                .await?
219                {
220                    Some(framed) => Ok(Object::Framed(framed)),
221                    // Not one of ours: the object is the bytes, and streaming
222                    // them straight through costs no extra round trip.
223                    None => Ok(Object::Remote {
224                        bucket: (**bucket).clone(),
225                        oid: oid.to_owned(),
226                        size,
227                    }),
228                }
229            }
230        }
231    }
232
233    pub async fn write<S, E>(
234        &self,
235        ns: &Namespace,
236        oid: &str,
237        expected_size: Option<u64>,
238        budget: Option<Budget>,
239        chunks: S,
240    ) -> Result<u64, Error>
241    where
242        S: Stream<Item = Result<axum::body::Bytes, E>> + Unpin,
243        E: std::error::Error + Send + Sync + 'static,
244    {
245        let written = match &self.backend {
246            Backend::Local(store) => store.write(ns, oid, expected_size, budget, chunks).await?,
247            Backend::Bucket { bucket, staging } => {
248                // Asked of the bucket, because the staging store answers about a
249                // local layout a bucket deployment never fills in: it would call
250                // every upload fresh, and re-pushing an object the repository
251                // already holds would grow what is remembered without anything
252                // being stored.
253                let fresh = !bucket.exists(ns, oid).await;
254
255                let staged = staging
256                    .stage(ns, oid, expected_size, budget, chunks)
257                    .await?;
258                let outcome = bucket.store(ns, oid, &staged.path).await;
259
260                // The staging file has served its purpose either way. Leaving it
261                // would be a leak the reclaimer only notices a day later.
262                let _ = tokio::fs::remove_file(&staged.path).await;
263                outcome?;
264
265                super::Written {
266                    bytes: staged.written,
267                    fresh,
268                }
269            }
270        };
271
272        // Added to what is remembered rather than dropping it: a client pushing
273        // a hundred objects would otherwise make the next negotiation measure
274        // the repository again, which on a bucket is what this cache exists to
275        // avoid.
276        if written.fresh {
277            self.usage.stored(ns, written.bytes).await;
278        }
279
280        Ok(written.bytes)
281    }
282
283    // None rather than zero: a bucket has no cheap answer for what the whole
284    // store holds, and building one from a full listing would cost a request per
285    // object on every scrape. Zero would be read as an empty bucket by every
286    // dashboard that averages it, which is the one lie this seam otherwise
287    // refuses to tell — everything else it cannot do answers 501.
288    pub async fn capacity(&self) -> Option<(u64, u64)> {
289        match &self.backend {
290            Backend::Local(store) => Some(store.usage().await),
291            Backend::Bucket { .. } => None,
292        }
293    }
294
295    // Measured at most once a minute per repository, whichever backend is
296    // behind it. A bucket answers this by listing the repository's markers and
297    // asking the size of each, so one uncached call per object in a batch made
298    // a hundred-object push cost a hundred listings — the product, not the sum.
299    pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
300        if let Some(cached) = self.usage.cached(ns).await {
301            return cached;
302        }
303
304        let measured = match &self.backend {
305            Backend::Local(store) => store.measure_of(ns).await,
306            Backend::Bucket { bucket, .. } => bucket.usage_of(ns).await,
307        };
308
309        self.usage.remember(ns, measured.0, measured.1).await;
310
311        measured
312    }
313
314    pub async fn sweep(
315        &self,
316        ns: &Namespace,
317        retained: &std::collections::HashSet<String>,
318        grace: std::time::Duration,
319        dry_run: bool,
320    ) -> Result<SweepReport, Error> {
321        match &self.backend {
322            Backend::Local(store) => {
323                let report = store.sweep(ns, retained, grace, dry_run).await;
324
325                // Freeing gigabytes and then answering the next quota check from
326                // the figure measured before is how a client is refused space it
327                // has just been told it reclaimed.
328                self.usage.forget(ns).await;
329
330                report
331            }
332            Backend::Bucket { bucket, .. } => {
333                let report = bucket.sweep(ns, retained, grace, dry_run).await;
334
335                if report.is_ok() && !dry_run {
336                    self.usage.forget(ns).await;
337                }
338
339                report
340            }
341        }
342    }
343
344    pub async fn dedupe(&self, ns: &Namespace, dry_run: bool) -> Result<DedupeReport, Error> {
345        match &self.backend {
346            Backend::Local(store) => {
347                let report = store.dedupe(ns, dry_run).await;
348
349                // Freeing gigabytes and then answering the next quota check from
350                // the figure measured before is how a client is refused space it
351                // has just been told it reclaimed.
352                self.usage.forget(ns).await;
353
354                report
355            }
356            // Content addressing already gives this: two repositories pushing the
357            // same object write the same key, and each holds a marker beside it.
358            // There is nothing left to fold in.
359            Backend::Bucket { .. } => Err(Error::Unsupported(
360                "a bucket stores each object once already, so there is nothing to deduplicate",
361            )),
362        }
363    }
364
365    pub async fn compress(&self, ns: &Namespace, dry_run: bool) -> Result<CompressReport, Error> {
366        match &self.backend {
367            Backend::Local(store) => {
368                let report = store.compress(ns, dry_run).await;
369
370                // Freeing gigabytes and then answering the next quota check from
371                // the figure measured before is how a client is refused space it
372                // has just been told it reclaimed.
373                self.usage.forget(ns).await;
374
375                report
376            }
377            // Objects arriving now are compressed if the server is configured to;
378            // rewriting the ones already in the bucket means walking it and
379            // reuploading, which is a different piece of work.
380            Backend::Bucket { .. } => Err(Error::Unsupported(
381                "rewriting objects already in a bucket is not implemented",
382            )),
383        }
384    }
385
386    pub async fn verify(&self, ns: &Namespace) -> Result<VerifyReport, Error> {
387        match &self.backend {
388            Backend::Local(store) => store.verify(ns).await,
389            Backend::Bucket { .. } => Err(Error::Unsupported(
390                "verification is not implemented for a bucket yet",
391            )),
392        }
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use futures_util::StreamExt;
399
400    use super::*;
401    use crate::storage::crypt::Keyring;
402    use crate::storage::s3::tests::{bucket, redirecting, store};
403
404    fn keyring() -> std::sync::Arc<Keyring> {
405        std::sync::Arc::new(
406            Keyring::parse(&hex::encode([7u8; crate::storage::crypt::KEY])).unwrap(),
407        )
408    }
409
410    fn namespace() -> Namespace {
411        Namespace::new("FerrLabs", "Blastlands").unwrap()
412    }
413
414    fn bucket_store(root: &tempfile::TempDir, endpoint: &str) -> Store {
415        Store::bucket(store(endpoint), LocalStore::new(root.path()))
416    }
417
418    async fn read_back(store: &Store, ns: &Namespace, oid: &str) -> Vec<u8> {
419        let object = store.open(ns, oid).await.unwrap();
420        let size = object.size();
421        let mut chunks = object.stream(0, size).await.unwrap();
422        let mut out = Vec::new();
423
424        while let Some(chunk) = chunks.next().await {
425            out.extend_from_slice(&chunk.unwrap());
426        }
427
428        out
429    }
430
431    #[tokio::test]
432    async fn an_upload_lands_in_the_bucket_and_reads_back_through_the_same_seam() {
433        let root = tempfile::tempdir().unwrap();
434        let (endpoint, _objects) = bucket().await;
435        let store = bucket_store(&root, &endpoint);
436        let payload = b"an asset that never touches this disk for long".repeat(32);
437        let oid = hex::encode(sha2::Sha256::digest(&payload));
438
439        let written = store
440            .write(
441                &namespace(),
442                &oid,
443                Some(payload.len() as u64),
444                None,
445                futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
446                    payload.clone(),
447                ))]),
448            )
449            .await
450            .unwrap();
451
452        assert_eq!(written, payload.len() as u64);
453        assert!(store.exists(&namespace(), &oid).await);
454
455        assert_eq!(read_back(&store, &namespace(), &oid).await, payload);
456    }
457
458    // Compression and a bucket are configured independently, and a studio that
459    // turns both on gets no warning from either. What lands under the digest
460    // has to be the object, because the only thing that will ever read it back
461    // is a client that asked for those bytes by that name.
462    #[tokio::test]
463    async fn a_bucket_holds_the_object_even_when_the_server_was_told_to_compress() {
464        let root = tempfile::tempdir().unwrap();
465        let (endpoint, _objects) = bucket().await;
466        let store = Store::bucket(
467            store(&endpoint),
468            LocalStore::new(root.path()).with_compression(Some(3)),
469        );
470        let payload = b"a mesh that gives up most of its ground to zstd ".repeat(4096);
471        let oid = hex::encode(sha2::Sha256::digest(&payload));
472
473        store
474            .write(
475                &namespace(),
476                &oid,
477                Some(payload.len() as u64),
478                None,
479                futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
480                    payload.clone(),
481                ))]),
482            )
483            .await
484            .unwrap();
485
486        let restored = read_back(&store, &namespace(), &oid).await;
487
488        assert_eq!(
489            hex::encode(sha2::Sha256::digest(&restored)),
490            oid,
491            "the client asked for the object named by this digest and has no way to know the              server framed it on the way past: {} bytes came back",
492            restored.len()
493        );
494        assert_eq!(restored, payload);
495    }
496
497    #[tokio::test]
498    async fn the_staging_file_does_not_outlive_the_upload() {
499        let root = tempfile::tempdir().unwrap();
500        let (endpoint, _objects) = bucket().await;
501        let store = bucket_store(&root, &endpoint);
502        let payload = b"an asset passing through".to_vec();
503        let oid = hex::encode(sha2::Sha256::digest(&payload));
504
505        store
506            .write(
507                &namespace(),
508                &oid,
509                None,
510                None,
511                futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
512                    payload,
513                ))]),
514            )
515            .await
516            .unwrap();
517
518        let leftovers = crate::storage::tests::staging_files(root.path());
519        assert!(
520            leftovers.is_empty(),
521            "local disk is a write buffer here, and one that is never emptied is a disk that \
522             fills: {leftovers:?}"
523        );
524    }
525
526    #[tokio::test]
527    async fn a_bucket_reports_no_capacity_rather_than_an_empty_one() {
528        let root = tempfile::tempdir().unwrap();
529        let (endpoint, _objects) = bucket().await;
530
531        assert!(
532            bucket_store(&root, &endpoint).capacity().await.is_none(),
533            "zero would be read as an empty store by every dashboard that averages it"
534        );
535        assert!(
536            Store::local(LocalStore::new(root.path()))
537                .capacity()
538                .await
539                .is_some()
540        );
541    }
542
543    #[tokio::test]
544    async fn the_maintenance_commands_say_they_do_not_apply_rather_than_lying() {
545        let root = tempfile::tempdir().unwrap();
546        let (endpoint, _objects) = bucket().await;
547        let store = bucket_store(&root, &endpoint);
548        let ns = namespace();
549
550        for outcome in [
551            store.dedupe(&ns, true).await.err(),
552            store.compress(&ns, true).await.err(),
553            store.verify(&ns).await.err(),
554        ] {
555            assert!(
556                matches!(outcome, Some(Error::Unsupported(_))),
557                "an operator running one of these against a bucket has to be told it did nothing,                  not handed an empty report that reads like success: {outcome:?}"
558            );
559        }
560
561        // Collection is the one that no longer belongs in that list.
562        assert!(
563            store
564                .sweep(&ns, &std::collections::HashSet::new(), Duration::ZERO, true)
565                .await
566                .is_ok(),
567            "collection is implemented for a bucket and must not answer Unsupported"
568        );
569    }
570
571    // The bug this guards. A pre-signed download hands the client the bucket key
572    // itself, which is only the object while nothing framed it on the way in.
573    // `presigned_upload` has refused to sign an upload under a key since
574    // encryption landed, for exactly this reason; the download side had no such
575    // guard and handed out frames.
576    //
577    // Asserted against what is actually in the bucket rather than against the
578    // flag, so this fails if framing ever stops happening and the guard becomes
579    // theatre.
580    #[tokio::test]
581    async fn a_download_is_never_redirected_to_a_frame() {
582        for label in ["compressed", "encrypted"] {
583            let root = tempfile::tempdir().unwrap();
584            let (endpoint, objects) = bucket().await;
585            let staging = match label {
586                "compressed" => LocalStore::new(root.path()).with_compression(Some(3)),
587                _ => LocalStore::new(root.path()).with_encryption(Some(keyring())),
588            };
589            let store = Store::bucket(redirecting(&endpoint), staging);
590
591            let payload =
592                b"a scene file that compresses and must still come back whole ".repeat(512);
593            let oid = hex::encode(sha2::Sha256::digest(&payload));
594
595            store
596                .write(
597                    &namespace(),
598                    &oid,
599                    Some(payload.len() as u64),
600                    None,
601                    futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
602                        payload.clone(),
603                    ))]),
604                )
605                .await
606                .unwrap();
607
608            let stored = objects
609                .lock()
610                .unwrap()
611                .values()
612                .find(|object| !object.is_empty())
613                .cloned()
614                .unwrap();
615
616            assert_ne!(
617                stored, payload,
618                "{label}: the bucket holds a frame, which is the premise of the rest of this test"
619            );
620            assert_eq!(
621                store.redirect(&oid),
622                None,
623                "{label}: a client sent to the bucket would hash {} bytes of frame and reject the \
624                 object it asked for",
625                stored.len()
626            );
627            assert_eq!(
628                read_back(&store, &namespace(), &oid).await,
629                payload,
630                "{label}: giving up the redirect is only correct because the streamed path decodes"
631            );
632        }
633    }
634
635    // And the guard does not quietly disable the feature it protects. With no
636    // codec configured the bucket holds the object itself, so the redirect is
637    // exactly what the operator asked for.
638    #[tokio::test]
639    async fn a_bucket_holding_the_object_itself_still_redirects() {
640        let root = tempfile::tempdir().unwrap();
641        let (endpoint, _objects) = bucket().await;
642        let store = Store::bucket(redirecting(&endpoint), LocalStore::new(root.path()));
643
644        let payload = b"an object stored as it arrived".repeat(32);
645        let oid = hex::encode(sha2::Sha256::digest(&payload));
646
647        store
648            .write(
649                &namespace(),
650                &oid,
651                Some(payload.len() as u64),
652                None,
653                futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
654                    payload.clone(),
655                ))]),
656            )
657            .await
658            .unwrap();
659
660        assert!(store.redirect(&oid).is_some());
661    }
662}