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    // The staging store is stripped of compression and encryption rather than
37    // trusted not to have been handed either: each is configured independently
38    // of the bucket, and what they produce together is silent. The frames would
39    // go up under the digest of the plaintext, and every download would hand the
40    // client a zstd or ChaCha20 stream it never asked for. Nothing on the far
41    // side of the upload knows to undo them — that lives in the file the local
42    // store opens, which is the one thing a bucket does not have.
43    pub fn bucket(bucket: S3Store, staging: LocalStore) -> Self {
44        Self(Backend::Bucket {
45            bucket: Box::new(bucket),
46            staging: staging.with_compression(None).with_encryption(None),
47        })
48    }
49
50    fn staging(&self) -> &LocalStore {
51        match &self.0 {
52            Backend::Local(store) => store,
53            Backend::Bucket { staging, .. } => staging,
54        }
55    }
56
57    pub async fn writable(&self) -> Result<(), Error> {
58        self.staging().writable().await
59    }
60
61    pub fn scans(&self) -> u64 {
62        self.staging().scans()
63    }
64
65    pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
66        match &self.0 {
67            Backend::Local(store) => store.exists(ns, oid).await,
68            Backend::Bucket { bucket, .. } => bucket.exists(ns, oid).await,
69        }
70    }
71
72    // Where the client should fetch this object from, when that is somewhere
73    // other than this server. None for a local store, and for a bucket the
74    // operator has not asked to redirect — which is the default, because the
75    // streamed path is the one that counts the bytes and holds the ceiling.
76    //
77    // The caller is responsible for having established that this repository
78    // holds the object. This hands out a signature, not a permission.
79    pub fn redirect(&self, oid: &str) -> Option<String> {
80        match &self.0 {
81            Backend::Local(_) => None,
82            Backend::Bucket { bucket, .. } => bucket.presigned_download(oid),
83        }
84    }
85
86    pub async fn open(&self, ns: &Namespace, oid: &str) -> Result<Object, Error> {
87        match &self.0 {
88            Backend::Local(store) => store.open(ns, oid).await,
89            Backend::Bucket { bucket, .. } => {
90                if !bucket.exists(ns, oid).await {
91                    return Err(Error::NotFound);
92                }
93
94                Ok(Object::Remote {
95                    bucket: (**bucket).clone(),
96                    oid: oid.to_owned(),
97                    size: bucket.size_of(oid).await?,
98                })
99            }
100        }
101    }
102
103    pub async fn write<S, E>(
104        &self,
105        ns: &Namespace,
106        oid: &str,
107        expected_size: Option<u64>,
108        budget: Option<Budget>,
109        chunks: S,
110    ) -> Result<u64, Error>
111    where
112        S: Stream<Item = Result<axum::body::Bytes, E>> + Unpin,
113        E: std::error::Error + Send + Sync + 'static,
114    {
115        match &self.0 {
116            Backend::Local(store) => store.write(ns, oid, expected_size, budget, chunks).await,
117            Backend::Bucket { bucket, staging } => {
118                let staged = staging
119                    .stage(ns, oid, expected_size, budget, chunks)
120                    .await?;
121                let outcome = bucket.store(ns, oid, &staged.path).await;
122
123                // The staging file has served its purpose either way. Leaving it
124                // would be a leak the reclaimer only notices a day later.
125                let _ = tokio::fs::remove_file(&staged.path).await;
126                outcome?;
127
128                Ok(staged.written)
129            }
130        }
131    }
132
133    // None rather than zero: a bucket has no cheap answer for what the whole
134    // store holds, and building one from a full listing would cost a request per
135    // object on every scrape. Zero would be read as an empty bucket by every
136    // dashboard that averages it, which is the one lie this seam otherwise
137    // refuses to tell — everything else it cannot do answers 501.
138    pub async fn capacity(&self) -> Option<(u64, u64)> {
139        match &self.0 {
140            Backend::Local(store) => Some(store.usage().await),
141            Backend::Bucket { .. } => None,
142        }
143    }
144
145    pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
146        match &self.0 {
147            Backend::Local(store) => store.usage_of(ns).await,
148            Backend::Bucket { bucket, .. } => bucket.usage_of(ns).await,
149        }
150    }
151
152    pub async fn sweep(
153        &self,
154        ns: &Namespace,
155        retained: &std::collections::HashSet<String>,
156        grace: std::time::Duration,
157        dry_run: bool,
158    ) -> Result<SweepReport, Error> {
159        match &self.0 {
160            Backend::Local(store) => store.sweep(ns, retained, grace, dry_run).await,
161            Backend::Bucket { .. } => Err(Error::Unsupported(
162                "collection is not implemented for a bucket yet",
163            )),
164        }
165    }
166
167    pub async fn dedupe(&self, ns: &Namespace, dry_run: bool) -> Result<DedupeReport, Error> {
168        match &self.0 {
169            Backend::Local(store) => store.dedupe(ns, dry_run).await,
170            // Content addressing already gives this: two repositories pushing the
171            // same object write the same key, and each holds a marker beside it.
172            // There is nothing left to fold in.
173            Backend::Bucket { .. } => Err(Error::Unsupported(
174                "a bucket stores each object once already, so there is nothing to deduplicate",
175            )),
176        }
177    }
178
179    pub async fn compress(&self, ns: &Namespace, dry_run: bool) -> Result<CompressReport, Error> {
180        match &self.0 {
181            Backend::Local(store) => store.compress(ns, dry_run).await,
182            Backend::Bucket { .. } => Err(Error::Unsupported(
183                "compression is not implemented for a bucket yet",
184            )),
185        }
186    }
187
188    pub async fn verify(&self, ns: &Namespace) -> Result<VerifyReport, Error> {
189        match &self.0 {
190            Backend::Local(store) => store.verify(ns).await,
191            Backend::Bucket { .. } => Err(Error::Unsupported(
192                "verification is not implemented for a bucket yet",
193            )),
194        }
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use futures_util::StreamExt;
201
202    use super::*;
203    use crate::storage::s3::tests::{bucket, store};
204
205    fn namespace() -> Namespace {
206        Namespace::new("FerrLabs", "Blastlands").unwrap()
207    }
208
209    fn bucket_store(root: &tempfile::TempDir, endpoint: &str) -> Store {
210        Store::bucket(store(endpoint), LocalStore::new(root.path()))
211    }
212
213    async fn read_back(store: &Store, ns: &Namespace, oid: &str) -> Vec<u8> {
214        let object = store.open(ns, oid).await.unwrap();
215        let size = object.size();
216        let mut chunks = object.stream(0, size).await.unwrap();
217        let mut out = Vec::new();
218
219        while let Some(chunk) = chunks.next().await {
220            out.extend_from_slice(&chunk.unwrap());
221        }
222
223        out
224    }
225
226    #[tokio::test]
227    async fn an_upload_lands_in_the_bucket_and_reads_back_through_the_same_seam() {
228        let root = tempfile::tempdir().unwrap();
229        let (endpoint, _objects) = bucket().await;
230        let store = bucket_store(&root, &endpoint);
231        let payload = b"an asset that never touches this disk for long".repeat(32);
232        let oid = hex::encode(sha2::Sha256::digest(&payload));
233
234        let written = store
235            .write(
236                &namespace(),
237                &oid,
238                Some(payload.len() as u64),
239                None,
240                futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
241                    payload.clone(),
242                ))]),
243            )
244            .await
245            .unwrap();
246
247        assert_eq!(written, payload.len() as u64);
248        assert!(store.exists(&namespace(), &oid).await);
249
250        assert_eq!(read_back(&store, &namespace(), &oid).await, payload);
251    }
252
253    // Compression and a bucket are configured independently, and a studio that
254    // turns both on gets no warning from either. What lands under the digest
255    // has to be the object, because the only thing that will ever read it back
256    // is a client that asked for those bytes by that name.
257    #[tokio::test]
258    async fn a_bucket_holds_the_object_even_when_the_server_was_told_to_compress() {
259        let root = tempfile::tempdir().unwrap();
260        let (endpoint, _objects) = bucket().await;
261        let store = Store::bucket(
262            store(&endpoint),
263            LocalStore::new(root.path()).with_compression(Some(3)),
264        );
265        let payload = b"a mesh that gives up most of its ground to zstd ".repeat(4096);
266        let oid = hex::encode(sha2::Sha256::digest(&payload));
267
268        store
269            .write(
270                &namespace(),
271                &oid,
272                Some(payload.len() as u64),
273                None,
274                futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
275                    payload.clone(),
276                ))]),
277            )
278            .await
279            .unwrap();
280
281        let restored = read_back(&store, &namespace(), &oid).await;
282
283        assert_eq!(
284            hex::encode(sha2::Sha256::digest(&restored)),
285            oid,
286            "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",
287            restored.len()
288        );
289        assert_eq!(restored, payload);
290    }
291
292    #[tokio::test]
293    async fn the_staging_file_does_not_outlive_the_upload() {
294        let root = tempfile::tempdir().unwrap();
295        let (endpoint, _objects) = bucket().await;
296        let store = bucket_store(&root, &endpoint);
297        let payload = b"an asset passing through".to_vec();
298        let oid = hex::encode(sha2::Sha256::digest(&payload));
299
300        store
301            .write(
302                &namespace(),
303                &oid,
304                None,
305                None,
306                futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
307                    payload,
308                ))]),
309            )
310            .await
311            .unwrap();
312
313        let leftovers = crate::storage::tests::staging_files(root.path());
314        assert!(
315            leftovers.is_empty(),
316            "local disk is a write buffer here, and one that is never emptied is a disk that \
317             fills: {leftovers:?}"
318        );
319    }
320
321    #[tokio::test]
322    async fn a_bucket_reports_no_capacity_rather_than_an_empty_one() {
323        let root = tempfile::tempdir().unwrap();
324        let (endpoint, _objects) = bucket().await;
325
326        assert!(
327            bucket_store(&root, &endpoint).capacity().await.is_none(),
328            "zero would be read as an empty store by every dashboard that averages it"
329        );
330        assert!(
331            Store::local(LocalStore::new(root.path()))
332                .capacity()
333                .await
334                .is_some()
335        );
336    }
337
338    #[tokio::test]
339    async fn the_maintenance_commands_say_they_do_not_apply_rather_than_lying() {
340        let root = tempfile::tempdir().unwrap();
341        let (endpoint, _objects) = bucket().await;
342        let store = bucket_store(&root, &endpoint);
343        let ns = namespace();
344
345        for outcome in [
346            store.dedupe(&ns, true).await.err(),
347            store.compress(&ns, true).await.err(),
348            store.verify(&ns).await.err(),
349            store
350                .sweep(&ns, &std::collections::HashSet::new(), Duration::ZERO, true)
351                .await
352                .err(),
353        ] {
354            assert!(
355                matches!(outcome, Some(Error::Unsupported(_))),
356                "an operator running collection against a bucket has to be told it did nothing, \
357                 not handed an empty report that reads like success: {outcome:?}"
358            );
359        }
360    }
361}