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