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(Backend);
17
18enum Backend {
19    Local(LocalStore),
20    // Even with a bucket the local store stays, because a transfer has to land
21    // somewhere before anyone can tell whether it is the object it claims to be.
22    // It is a write buffer, not the store.
23    // Boxed because a bucket handle beside a local store makes this variant far
24    // larger than the other, and every Store in the process would pay for it.
25    Bucket {
26        bucket: Box<S3Store>,
27        staging: LocalStore,
28    },
29}
30
31impl Store {
32    pub fn local(store: LocalStore) -> Self {
33        Self(Backend::Local(store))
34    }
35
36    // Compression and encryption used to be stripped here, because a framed
37    // object was only readable through the file the codec opened and a bucket key
38    // is not one. The codec now reads from a bucket too, so the frames go up as
39    // they are and come back decoded: the header and the index are three ranged
40    // GETs, which is what the format was shaped for.
41    pub fn bucket(bucket: S3Store, staging: LocalStore) -> Self {
42        Self(Backend::Bucket {
43            bucket: Box::new(bucket),
44            staging,
45        })
46    }
47
48    fn staging(&self) -> &LocalStore {
49        match &self.0 {
50            Backend::Local(store) => store,
51            Backend::Bucket { staging, .. } => staging,
52        }
53    }
54
55    // Everything an interrupted upload can leave behind, wherever it left it. A
56    // bucket deployment still stages locally, so both are swept and the figures
57    // add up to one answer.
58    pub async fn reclaim(&self, older_than: std::time::Duration) -> super::Reclaimed {
59        let mut reclaimed = self.staging().reclaim_staging(older_than).await;
60
61        if let Backend::Bucket { bucket, .. } = &self.0 {
62            match bucket.reclaim_incoming(older_than).await {
63                Ok(theirs) => {
64                    reclaimed.files += theirs.files;
65                    reclaimed.bytes += theirs.bytes;
66                }
67                Err(error) => {
68                    tracing::warn!(%error, "abandoned uploads in the bucket could not be reclaimed");
69                }
70            }
71        }
72
73        reclaimed
74    }
75
76    // Readiness has to ask the backend that actually serves. Once the objects
77    // live in a bucket the volume is a write buffer, and an instance whose
78    // credentials were rotated or whose bucket is gone passes a probe that only
79    // proves its scratch disk works — then fails every transfer it is handed.
80    //
81    // So both are asked, either failing takes the instance out, and they are
82    // named apart: a full disk and a rotated key are not the same afternoon.
83    pub async fn writable(&self) -> Result<(), Error> {
84        self.staging().writable().await.map_err(|error| {
85            Error::Storage(std::io::Error::other(format!(
86                "the staging volume is not writable: {error}"
87            )))
88        })?;
89
90        if let Backend::Bucket { bucket, .. } = &self.0 {
91            bucket.reachable().await?;
92        }
93
94        Ok(())
95    }
96
97    pub fn scans(&self) -> u64 {
98        self.staging().scans()
99    }
100
101    pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
102        match &self.0 {
103            Backend::Local(store) => store.exists(ns, oid).await,
104            Backend::Bucket { bucket, .. } => bucket.exists(ns, oid).await,
105        }
106    }
107
108    // Where the client should fetch this object from, when that is somewhere
109    // other than this server. None for a local store, and for a bucket the
110    // operator has not asked to redirect — which is the default, because the
111    // streamed path is the one that counts the bytes and holds the ceiling.
112    //
113    // The caller is responsible for having established that this repository
114    // holds the object. This hands out a signature, not a permission.
115    pub fn redirect(&self, oid: &str) -> Option<String> {
116        match &self.0 {
117            Backend::Local(_) => None,
118            Backend::Bucket { bucket, .. } => bucket.presigned_download(oid),
119        }
120    }
121
122    // Where the client should PUT the object, when that is the bucket rather than
123    // this server. None for a local store and for a bucket the operator has not
124    // asked to redirect.
125    pub fn presigned_upload(&self, ns: &Namespace, oid: &str) -> Option<super::s3::Presigned> {
126        match &self.0 {
127            Backend::Local(_) => None,
128            // A client uploading straight to the bucket writes the object as it
129            // is, so a configured key would never touch it and the bucket would
130            // hold plaintext while an operator believed otherwise. Encryption is
131            // a promise about what the storage provider can read; a faster upload
132            // is not worth quietly breaking it. Those transfers keep coming
133            // through the server, which seals them.
134            Backend::Bucket { staging, .. } if staging.encrypts() => None,
135            Backend::Bucket { bucket, .. } => bucket.presigned_upload(ns, oid),
136        }
137    }
138
139    // How big an object waiting under this repository's own upload key is. None
140    // when there is nothing waiting, which is every local deployment and every
141    // client that has not used its URL.
142    pub async fn uploaded_size(&self, ns: &Namespace, oid: &str) -> Result<Option<u64>, Error> {
143        match &self.0 {
144            Backend::Local(_) => Ok(None),
145            Backend::Bucket { bucket, .. } => Ok(bucket.uploaded_size(ns, oid).await.ok()),
146        }
147    }
148
149    // Take an upload this repository made into the shared keyspace. Only reachable
150    // for a bucket, because only there does a client write anywhere this server
151    // did not.
152    pub async fn adopt(&self, ns: &Namespace, oid: &str) -> Result<(), Error> {
153        match &self.0 {
154            Backend::Local(_) => Err(Error::Unsupported(
155                "objects are written through this server, so there is nothing to adopt",
156            )),
157            Backend::Bucket { bucket, .. } => bucket.adopt(ns, oid).await,
158        }
159    }
160
161    pub async fn open(&self, ns: &Namespace, oid: &str) -> Result<Object, Error> {
162        match &self.0 {
163            Backend::Local(store) => store.open(ns, oid).await,
164            Backend::Bucket { bucket, staging } => {
165                // The marker is the proof of possession and is checked before
166                // anything is read, exactly as a local open checks the link.
167                if !bucket.exists(ns, oid).await {
168                    return Err(Error::NotFound);
169                }
170
171                let size = bucket.size_of(oid).await?;
172                let reader = super::codec::Reader::Bucket {
173                    bucket: (**bucket).clone(),
174                    oid: oid.to_owned(),
175                };
176
177                match super::codec::Framed::open(
178                    reader,
179                    size,
180                    staging.keyring().map(AsRef::as_ref),
181                    oid,
182                )
183                .await?
184                {
185                    Some(framed) => Ok(Object::Framed(framed)),
186                    // Not one of ours: the object is the bytes, and streaming
187                    // them straight through costs no extra round trip.
188                    None => Ok(Object::Remote {
189                        bucket: (**bucket).clone(),
190                        oid: oid.to_owned(),
191                        size,
192                    }),
193                }
194            }
195        }
196    }
197
198    pub async fn write<S, E>(
199        &self,
200        ns: &Namespace,
201        oid: &str,
202        expected_size: Option<u64>,
203        budget: Option<Budget>,
204        chunks: S,
205    ) -> Result<u64, Error>
206    where
207        S: Stream<Item = Result<axum::body::Bytes, E>> + Unpin,
208        E: std::error::Error + Send + Sync + 'static,
209    {
210        match &self.0 {
211            Backend::Local(store) => store.write(ns, oid, expected_size, budget, chunks).await,
212            Backend::Bucket { bucket, staging } => {
213                let staged = staging
214                    .stage(ns, oid, expected_size, budget, chunks)
215                    .await?;
216                let outcome = bucket.store(ns, oid, &staged.path).await;
217
218                // The staging file has served its purpose either way. Leaving it
219                // would be a leak the reclaimer only notices a day later.
220                let _ = tokio::fs::remove_file(&staged.path).await;
221                outcome?;
222
223                Ok(staged.written)
224            }
225        }
226    }
227
228    // None rather than zero: a bucket has no cheap answer for what the whole
229    // store holds, and building one from a full listing would cost a request per
230    // object on every scrape. Zero would be read as an empty bucket by every
231    // dashboard that averages it, which is the one lie this seam otherwise
232    // refuses to tell — everything else it cannot do answers 501.
233    pub async fn capacity(&self) -> Option<(u64, u64)> {
234        match &self.0 {
235            Backend::Local(store) => Some(store.usage().await),
236            Backend::Bucket { .. } => None,
237        }
238    }
239
240    pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
241        match &self.0 {
242            Backend::Local(store) => store.usage_of(ns).await,
243            Backend::Bucket { bucket, .. } => bucket.usage_of(ns).await,
244        }
245    }
246
247    pub async fn sweep(
248        &self,
249        ns: &Namespace,
250        retained: &std::collections::HashSet<String>,
251        grace: std::time::Duration,
252        dry_run: bool,
253    ) -> Result<SweepReport, Error> {
254        match &self.0 {
255            Backend::Local(store) => store.sweep(ns, retained, grace, dry_run).await,
256            Backend::Bucket { .. } => Err(Error::Unsupported(
257                "collection is not implemented for a bucket yet",
258            )),
259        }
260    }
261
262    pub async fn dedupe(&self, ns: &Namespace, dry_run: bool) -> Result<DedupeReport, Error> {
263        match &self.0 {
264            Backend::Local(store) => store.dedupe(ns, dry_run).await,
265            // Content addressing already gives this: two repositories pushing the
266            // same object write the same key, and each holds a marker beside it.
267            // There is nothing left to fold in.
268            Backend::Bucket { .. } => Err(Error::Unsupported(
269                "a bucket stores each object once already, so there is nothing to deduplicate",
270            )),
271        }
272    }
273
274    pub async fn compress(&self, ns: &Namespace, dry_run: bool) -> Result<CompressReport, Error> {
275        match &self.0 {
276            Backend::Local(store) => store.compress(ns, dry_run).await,
277            // Objects arriving now are compressed if the server is configured to;
278            // rewriting the ones already in the bucket means walking it and
279            // reuploading, which is a different piece of work.
280            Backend::Bucket { .. } => Err(Error::Unsupported(
281                "rewriting objects already in a bucket is not implemented",
282            )),
283        }
284    }
285
286    pub async fn verify(&self, ns: &Namespace) -> Result<VerifyReport, Error> {
287        match &self.0 {
288            Backend::Local(store) => store.verify(ns).await,
289            Backend::Bucket { .. } => Err(Error::Unsupported(
290                "verification is not implemented for a bucket yet",
291            )),
292        }
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use futures_util::StreamExt;
299
300    use super::*;
301    use crate::storage::s3::tests::{bucket, store};
302
303    fn namespace() -> Namespace {
304        Namespace::new("FerrLabs", "Blastlands").unwrap()
305    }
306
307    fn bucket_store(root: &tempfile::TempDir, endpoint: &str) -> Store {
308        Store::bucket(store(endpoint), LocalStore::new(root.path()))
309    }
310
311    async fn read_back(store: &Store, ns: &Namespace, oid: &str) -> Vec<u8> {
312        let object = store.open(ns, oid).await.unwrap();
313        let size = object.size();
314        let mut chunks = object.stream(0, size).await.unwrap();
315        let mut out = Vec::new();
316
317        while let Some(chunk) = chunks.next().await {
318            out.extend_from_slice(&chunk.unwrap());
319        }
320
321        out
322    }
323
324    #[tokio::test]
325    async fn an_upload_lands_in_the_bucket_and_reads_back_through_the_same_seam() {
326        let root = tempfile::tempdir().unwrap();
327        let (endpoint, _objects) = bucket().await;
328        let store = bucket_store(&root, &endpoint);
329        let payload = b"an asset that never touches this disk for long".repeat(32);
330        let oid = hex::encode(sha2::Sha256::digest(&payload));
331
332        let written = store
333            .write(
334                &namespace(),
335                &oid,
336                Some(payload.len() as u64),
337                None,
338                futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
339                    payload.clone(),
340                ))]),
341            )
342            .await
343            .unwrap();
344
345        assert_eq!(written, payload.len() as u64);
346        assert!(store.exists(&namespace(), &oid).await);
347
348        assert_eq!(read_back(&store, &namespace(), &oid).await, payload);
349    }
350
351    // Compression and a bucket are configured independently, and a studio that
352    // turns both on gets no warning from either. What lands under the digest
353    // has to be the object, because the only thing that will ever read it back
354    // is a client that asked for those bytes by that name.
355    #[tokio::test]
356    async fn a_bucket_holds_the_object_even_when_the_server_was_told_to_compress() {
357        let root = tempfile::tempdir().unwrap();
358        let (endpoint, _objects) = bucket().await;
359        let store = Store::bucket(
360            store(&endpoint),
361            LocalStore::new(root.path()).with_compression(Some(3)),
362        );
363        let payload = b"a mesh that gives up most of its ground to zstd ".repeat(4096);
364        let oid = hex::encode(sha2::Sha256::digest(&payload));
365
366        store
367            .write(
368                &namespace(),
369                &oid,
370                Some(payload.len() as u64),
371                None,
372                futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
373                    payload.clone(),
374                ))]),
375            )
376            .await
377            .unwrap();
378
379        let restored = read_back(&store, &namespace(), &oid).await;
380
381        assert_eq!(
382            hex::encode(sha2::Sha256::digest(&restored)),
383            oid,
384            "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",
385            restored.len()
386        );
387        assert_eq!(restored, payload);
388    }
389
390    #[tokio::test]
391    async fn the_staging_file_does_not_outlive_the_upload() {
392        let root = tempfile::tempdir().unwrap();
393        let (endpoint, _objects) = bucket().await;
394        let store = bucket_store(&root, &endpoint);
395        let payload = b"an asset passing through".to_vec();
396        let oid = hex::encode(sha2::Sha256::digest(&payload));
397
398        store
399            .write(
400                &namespace(),
401                &oid,
402                None,
403                None,
404                futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
405                    payload,
406                ))]),
407            )
408            .await
409            .unwrap();
410
411        let leftovers = crate::storage::tests::staging_files(root.path());
412        assert!(
413            leftovers.is_empty(),
414            "local disk is a write buffer here, and one that is never emptied is a disk that \
415             fills: {leftovers:?}"
416        );
417    }
418
419    #[tokio::test]
420    async fn a_bucket_reports_no_capacity_rather_than_an_empty_one() {
421        let root = tempfile::tempdir().unwrap();
422        let (endpoint, _objects) = bucket().await;
423
424        assert!(
425            bucket_store(&root, &endpoint).capacity().await.is_none(),
426            "zero would be read as an empty store by every dashboard that averages it"
427        );
428        assert!(
429            Store::local(LocalStore::new(root.path()))
430                .capacity()
431                .await
432                .is_some()
433        );
434    }
435
436    #[tokio::test]
437    async fn the_maintenance_commands_say_they_do_not_apply_rather_than_lying() {
438        let root = tempfile::tempdir().unwrap();
439        let (endpoint, _objects) = bucket().await;
440        let store = bucket_store(&root, &endpoint);
441        let ns = namespace();
442
443        for outcome in [
444            store.dedupe(&ns, true).await.err(),
445            store.compress(&ns, true).await.err(),
446            store.verify(&ns).await.err(),
447            store
448                .sweep(&ns, &std::collections::HashSet::new(), Duration::ZERO, true)
449                .await
450                .err(),
451        ] {
452            assert!(
453                matches!(outcome, Some(Error::Unsupported(_))),
454                "an operator running collection against a bucket has to be told it did nothing, \
455                 not handed an empty report that reads like success: {outcome:?}"
456            );
457        }
458    }
459}