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