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