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
12pub struct Store {
17 backend: Backend,
18 usage: super::usage::Usage,
19}
20
21enum Backend {
22 Local(LocalStore),
23 Bucket {
29 bucket: Box<S3Store>,
30 staging: LocalStore,
31 },
32}
33
34impl Store {
35 pub fn local(store: LocalStore) -> Self {
36 Self::over(Backend::Local(store))
37 }
38
39 pub fn bucket(bucket: S3Store, staging: LocalStore) -> Self {
45 Self::over(Backend::Bucket {
46 bucket: Box::new(bucket),
47 staging,
48 })
49 }
50
51 fn over(backend: Backend) -> Self {
52 Self {
53 backend,
54 usage: super::usage::Usage::default(),
55 }
56 }
57
58 fn staging(&self) -> &LocalStore {
59 match &self.backend {
60 Backend::Local(store) => store,
61 Backend::Bucket { staging, .. } => staging,
62 }
63 }
64
65 pub async fn reclaim(&self, older_than: std::time::Duration) -> super::Reclaimed {
69 let mut reclaimed = self.staging().reclaim_staging(older_than).await;
70
71 if let Backend::Bucket { bucket, .. } = &self.backend {
72 match bucket.reclaim_incoming(older_than).await {
73 Ok(theirs) => {
74 reclaimed.files += theirs.files;
75 reclaimed.bytes += theirs.bytes;
76 }
77 Err(error) => {
78 tracing::warn!(%error, "abandoned uploads in the bucket could not be reclaimed");
79 }
80 }
81 }
82
83 reclaimed
84 }
85
86 pub async fn writable(&self) -> Result<(), Error> {
94 self.staging().writable().await.map_err(|error| {
95 Error::Storage(std::io::Error::other(format!(
96 "the staging volume is not writable: {error}"
97 )))
98 })?;
99
100 if let Backend::Bucket { bucket, .. } = &self.backend {
101 bucket.reachable().await?;
102 }
103
104 Ok(())
105 }
106
107 pub fn scans(&self) -> u64 {
108 self.staging().scans()
109 }
110
111 pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
112 match &self.backend {
113 Backend::Local(store) => store.exists(ns, oid).await,
114 Backend::Bucket { bucket, .. } => bucket.exists(ns, oid).await,
115 }
116 }
117
118 pub fn redirect(&self, oid: &str) -> Option<String> {
126 match &self.backend {
127 Backend::Local(_) => None,
128 Backend::Bucket { staging, .. } if staging.frames() => None,
140 Backend::Bucket { bucket, .. } => bucket.presigned_download(oid),
141 }
142 }
143
144 pub fn presigned_upload(&self, ns: &Namespace, oid: &str) -> Option<super::s3::Presigned> {
148 match &self.backend {
149 Backend::Local(_) => None,
150 Backend::Bucket { staging, .. } if staging.encrypts() => None,
157 Backend::Bucket { bucket, .. } => bucket.presigned_upload(ns, oid),
158 }
159 }
160
161 pub async fn uploaded_size(&self, ns: &Namespace, oid: &str) -> Result<Option<u64>, Error> {
165 match &self.backend {
166 Backend::Local(_) => Ok(None),
167 Backend::Bucket { bucket, .. } => Ok(bucket.uploaded_size(ns, oid).await.ok()),
168 }
169 }
170
171 pub async fn adopt(&self, ns: &Namespace, oid: &str, arrived: u64) -> Result<(), Error> {
175 let outcome = match &self.backend {
176 Backend::Local(_) => Err(Error::Unsupported(
177 "objects are written through this server, so there is nothing to adopt",
178 )),
179 Backend::Bucket { bucket, .. } => bucket.adopt(ns, oid).await,
180 };
181
182 if outcome.is_ok() {
185 self.usage.stored(ns, arrived).await;
186 }
187
188 outcome
189 }
190
191 pub async fn open(&self, ns: &Namespace, oid: &str) -> Result<Object, Error> {
192 match &self.backend {
193 Backend::Local(store) => store.open(ns, oid).await,
194 Backend::Bucket { bucket, staging } => {
195 if !bucket.exists(ns, oid).await {
198 return Err(Error::NotFound);
199 }
200
201 let size = bucket.size_of(oid).await?;
202 let reader = super::codec::Reader::Bucket {
203 bucket: (**bucket).clone(),
204 oid: oid.to_owned(),
205 };
206
207 match super::codec::Framed::open(
208 reader,
209 size,
210 staging.keyring().map(AsRef::as_ref),
211 oid,
212 )
213 .await?
214 {
215 Some(framed) => Ok(Object::Framed(framed)),
216 None => Ok(Object::Remote {
219 bucket: (**bucket).clone(),
220 oid: oid.to_owned(),
221 size,
222 }),
223 }
224 }
225 }
226 }
227
228 pub async fn write<S, E>(
229 &self,
230 ns: &Namespace,
231 oid: &str,
232 expected_size: Option<u64>,
233 budget: Option<Budget>,
234 chunks: S,
235 ) -> Result<u64, Error>
236 where
237 S: Stream<Item = Result<axum::body::Bytes, E>> + Unpin,
238 E: std::error::Error + Send + Sync + 'static,
239 {
240 let written = match &self.backend {
241 Backend::Local(store) => store.write(ns, oid, expected_size, budget, chunks).await?,
242 Backend::Bucket { bucket, staging } => {
243 let fresh = !bucket.exists(ns, oid).await;
249
250 let staged = staging
251 .stage(ns, oid, expected_size, budget, chunks)
252 .await?;
253 let outcome = bucket.store(ns, oid, &staged.path).await;
254
255 let _ = tokio::fs::remove_file(&staged.path).await;
258 outcome?;
259
260 super::Written {
261 bytes: staged.written,
262 fresh,
263 }
264 }
265 };
266
267 if written.fresh {
272 self.usage.stored(ns, written.bytes).await;
273 }
274
275 Ok(written.bytes)
276 }
277
278 pub async fn capacity(&self) -> Option<(u64, u64)> {
284 match &self.backend {
285 Backend::Local(store) => Some(store.usage().await),
286 Backend::Bucket { .. } => None,
287 }
288 }
289
290 pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
295 if let Some(cached) = self.usage.cached(ns).await {
296 return cached;
297 }
298
299 let measured = match &self.backend {
300 Backend::Local(store) => store.measure_of(ns).await,
301 Backend::Bucket { bucket, .. } => bucket.usage_of(ns).await,
302 };
303
304 self.usage.remember(ns, measured.0, measured.1).await;
305
306 measured
307 }
308
309 pub async fn sweep(
310 &self,
311 ns: &Namespace,
312 retained: &std::collections::HashSet<String>,
313 grace: std::time::Duration,
314 dry_run: bool,
315 ) -> Result<SweepReport, Error> {
316 match &self.backend {
317 Backend::Local(store) => {
318 let report = store.sweep(ns, retained, grace, dry_run).await;
319
320 self.usage.forget(ns).await;
324
325 report
326 }
327 Backend::Bucket { bucket, .. } => {
328 let report = bucket.sweep(ns, retained, grace, dry_run).await;
329
330 if report.is_ok() && !dry_run {
331 self.usage.forget(ns).await;
332 }
333
334 report
335 }
336 }
337 }
338
339 pub async fn dedupe(&self, ns: &Namespace, dry_run: bool) -> Result<DedupeReport, Error> {
340 match &self.backend {
341 Backend::Local(store) => {
342 let report = store.dedupe(ns, dry_run).await;
343
344 self.usage.forget(ns).await;
348
349 report
350 }
351 Backend::Bucket { .. } => Err(Error::Unsupported(
355 "a bucket stores each object once already, so there is nothing to deduplicate",
356 )),
357 }
358 }
359
360 pub async fn compress(&self, ns: &Namespace, dry_run: bool) -> Result<CompressReport, Error> {
361 match &self.backend {
362 Backend::Local(store) => {
363 let report = store.compress(ns, dry_run).await;
364
365 self.usage.forget(ns).await;
369
370 report
371 }
372 Backend::Bucket { .. } => Err(Error::Unsupported(
376 "rewriting objects already in a bucket is not implemented",
377 )),
378 }
379 }
380
381 pub async fn verify(&self, ns: &Namespace) -> Result<VerifyReport, Error> {
382 match &self.backend {
383 Backend::Local(store) => store.verify(ns).await,
384 Backend::Bucket { .. } => Err(Error::Unsupported(
385 "verification is not implemented for a bucket yet",
386 )),
387 }
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use futures_util::StreamExt;
394
395 use super::*;
396 use crate::storage::crypt::Keyring;
397 use crate::storage::s3::tests::{bucket, redirecting, store};
398
399 fn keyring() -> std::sync::Arc<Keyring> {
400 std::sync::Arc::new(
401 Keyring::parse(&hex::encode([7u8; crate::storage::crypt::KEY])).unwrap(),
402 )
403 }
404
405 fn namespace() -> Namespace {
406 Namespace::new("FerrLabs", "Blastlands").unwrap()
407 }
408
409 fn bucket_store(root: &tempfile::TempDir, endpoint: &str) -> Store {
410 Store::bucket(store(endpoint), LocalStore::new(root.path()))
411 }
412
413 async fn read_back(store: &Store, ns: &Namespace, oid: &str) -> Vec<u8> {
414 let object = store.open(ns, oid).await.unwrap();
415 let size = object.size();
416 let mut chunks = object.stream(0, size).await.unwrap();
417 let mut out = Vec::new();
418
419 while let Some(chunk) = chunks.next().await {
420 out.extend_from_slice(&chunk.unwrap());
421 }
422
423 out
424 }
425
426 #[tokio::test]
427 async fn an_upload_lands_in_the_bucket_and_reads_back_through_the_same_seam() {
428 let root = tempfile::tempdir().unwrap();
429 let (endpoint, _objects) = bucket().await;
430 let store = bucket_store(&root, &endpoint);
431 let payload = b"an asset that never touches this disk for long".repeat(32);
432 let oid = hex::encode(sha2::Sha256::digest(&payload));
433
434 let written = store
435 .write(
436 &namespace(),
437 &oid,
438 Some(payload.len() as u64),
439 None,
440 futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
441 payload.clone(),
442 ))]),
443 )
444 .await
445 .unwrap();
446
447 assert_eq!(written, payload.len() as u64);
448 assert!(store.exists(&namespace(), &oid).await);
449
450 assert_eq!(read_back(&store, &namespace(), &oid).await, payload);
451 }
452
453 #[tokio::test]
458 async fn a_bucket_holds_the_object_even_when_the_server_was_told_to_compress() {
459 let root = tempfile::tempdir().unwrap();
460 let (endpoint, _objects) = bucket().await;
461 let store = Store::bucket(
462 store(&endpoint),
463 LocalStore::new(root.path()).with_compression(Some(3)),
464 );
465 let payload = b"a mesh that gives up most of its ground to zstd ".repeat(4096);
466 let oid = hex::encode(sha2::Sha256::digest(&payload));
467
468 store
469 .write(
470 &namespace(),
471 &oid,
472 Some(payload.len() as u64),
473 None,
474 futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
475 payload.clone(),
476 ))]),
477 )
478 .await
479 .unwrap();
480
481 let restored = read_back(&store, &namespace(), &oid).await;
482
483 assert_eq!(
484 hex::encode(sha2::Sha256::digest(&restored)),
485 oid,
486 "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",
487 restored.len()
488 );
489 assert_eq!(restored, payload);
490 }
491
492 #[tokio::test]
493 async fn the_staging_file_does_not_outlive_the_upload() {
494 let root = tempfile::tempdir().unwrap();
495 let (endpoint, _objects) = bucket().await;
496 let store = bucket_store(&root, &endpoint);
497 let payload = b"an asset passing through".to_vec();
498 let oid = hex::encode(sha2::Sha256::digest(&payload));
499
500 store
501 .write(
502 &namespace(),
503 &oid,
504 None,
505 None,
506 futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
507 payload,
508 ))]),
509 )
510 .await
511 .unwrap();
512
513 let leftovers = crate::storage::tests::staging_files(root.path());
514 assert!(
515 leftovers.is_empty(),
516 "local disk is a write buffer here, and one that is never emptied is a disk that \
517 fills: {leftovers:?}"
518 );
519 }
520
521 #[tokio::test]
522 async fn a_bucket_reports_no_capacity_rather_than_an_empty_one() {
523 let root = tempfile::tempdir().unwrap();
524 let (endpoint, _objects) = bucket().await;
525
526 assert!(
527 bucket_store(&root, &endpoint).capacity().await.is_none(),
528 "zero would be read as an empty store by every dashboard that averages it"
529 );
530 assert!(
531 Store::local(LocalStore::new(root.path()))
532 .capacity()
533 .await
534 .is_some()
535 );
536 }
537
538 #[tokio::test]
539 async fn the_maintenance_commands_say_they_do_not_apply_rather_than_lying() {
540 let root = tempfile::tempdir().unwrap();
541 let (endpoint, _objects) = bucket().await;
542 let store = bucket_store(&root, &endpoint);
543 let ns = namespace();
544
545 for outcome in [
546 store.dedupe(&ns, true).await.err(),
547 store.compress(&ns, true).await.err(),
548 store.verify(&ns).await.err(),
549 ] {
550 assert!(
551 matches!(outcome, Some(Error::Unsupported(_))),
552 "an operator running one of these against a bucket has to be told it did nothing, not handed an empty report that reads like success: {outcome:?}"
553 );
554 }
555
556 assert!(
558 store
559 .sweep(&ns, &std::collections::HashSet::new(), Duration::ZERO, true)
560 .await
561 .is_ok(),
562 "collection is implemented for a bucket and must not answer Unsupported"
563 );
564 }
565
566 #[tokio::test]
576 async fn a_download_is_never_redirected_to_a_frame() {
577 for label in ["compressed", "encrypted"] {
578 let root = tempfile::tempdir().unwrap();
579 let (endpoint, objects) = bucket().await;
580 let staging = match label {
581 "compressed" => LocalStore::new(root.path()).with_compression(Some(3)),
582 _ => LocalStore::new(root.path()).with_encryption(Some(keyring())),
583 };
584 let store = Store::bucket(redirecting(&endpoint), staging);
585
586 let payload =
587 b"a scene file that compresses and must still come back whole ".repeat(512);
588 let oid = hex::encode(sha2::Sha256::digest(&payload));
589
590 store
591 .write(
592 &namespace(),
593 &oid,
594 Some(payload.len() as u64),
595 None,
596 futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
597 payload.clone(),
598 ))]),
599 )
600 .await
601 .unwrap();
602
603 let stored = objects
604 .lock()
605 .unwrap()
606 .values()
607 .find(|object| !object.is_empty())
608 .cloned()
609 .unwrap();
610
611 assert_ne!(
612 stored, payload,
613 "{label}: the bucket holds a frame, which is the premise of the rest of this test"
614 );
615 assert_eq!(
616 store.redirect(&oid),
617 None,
618 "{label}: a client sent to the bucket would hash {} bytes of frame and reject the \
619 object it asked for",
620 stored.len()
621 );
622 assert_eq!(
623 read_back(&store, &namespace(), &oid).await,
624 payload,
625 "{label}: giving up the redirect is only correct because the streamed path decodes"
626 );
627 }
628 }
629
630 #[tokio::test]
634 async fn a_bucket_holding_the_object_itself_still_redirects() {
635 let root = tempfile::tempdir().unwrap();
636 let (endpoint, _objects) = bucket().await;
637 let store = Store::bucket(redirecting(&endpoint), LocalStore::new(root.path()));
638
639 let payload = b"an object stored as it arrived".repeat(32);
640 let oid = hex::encode(sha2::Sha256::digest(&payload));
641
642 store
643 .write(
644 &namespace(),
645 &oid,
646 Some(payload.len() as u64),
647 None,
648 futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
649 payload.clone(),
650 ))]),
651 )
652 .await
653 .unwrap();
654
655 assert!(store.redirect(&oid).is_some());
656 }
657}