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(
148 &self,
149 ns: &Namespace,
150 oid: &str,
151 size: u64,
152 ) -> Option<super::s3::Presigned> {
153 match &self.backend {
154 Backend::Local(_) => None,
155 Backend::Bucket { staging, .. } if staging.encrypts() => None,
162 Backend::Bucket { bucket, .. } => bucket.presigned_upload(ns, oid, size),
163 }
164 }
165
166 pub async fn uploaded_size(&self, ns: &Namespace, oid: &str) -> Result<Option<u64>, Error> {
170 match &self.backend {
171 Backend::Local(_) => Ok(None),
172 Backend::Bucket { bucket, .. } => Ok(bucket.uploaded_size(ns, oid).await.ok()),
173 }
174 }
175
176 pub async fn adopt(&self, ns: &Namespace, oid: &str, arrived: u64) -> Result<(), Error> {
180 let outcome = match &self.backend {
181 Backend::Local(_) => Err(Error::Unsupported(
182 "objects are written through this server, so there is nothing to adopt",
183 )),
184 Backend::Bucket { bucket, .. } => bucket.adopt(ns, oid).await,
185 };
186
187 if outcome.is_ok() {
190 self.usage.stored(ns, arrived).await;
191 }
192
193 outcome
194 }
195
196 pub async fn open(&self, ns: &Namespace, oid: &str) -> Result<Object, Error> {
197 match &self.backend {
198 Backend::Local(store) => store.open(ns, oid).await,
199 Backend::Bucket { bucket, staging } => {
200 if !bucket.exists(ns, oid).await {
203 return Err(Error::NotFound);
204 }
205
206 let size = bucket.size_of(oid).await?;
207 let reader = super::codec::Reader::Bucket {
208 bucket: (**bucket).clone(),
209 oid: oid.to_owned(),
210 };
211
212 match super::codec::Framed::open(
213 reader,
214 size,
215 staging.keyring().map(AsRef::as_ref),
216 oid,
217 )
218 .await?
219 {
220 Some(framed) => Ok(Object::Framed(framed)),
221 None => Ok(Object::Remote {
224 bucket: (**bucket).clone(),
225 oid: oid.to_owned(),
226 size,
227 }),
228 }
229 }
230 }
231 }
232
233 pub async fn write<S, E>(
234 &self,
235 ns: &Namespace,
236 oid: &str,
237 expected_size: Option<u64>,
238 budget: Option<Budget>,
239 chunks: S,
240 ) -> Result<u64, Error>
241 where
242 S: Stream<Item = Result<axum::body::Bytes, E>> + Unpin,
243 E: std::error::Error + Send + Sync + 'static,
244 {
245 let written = match &self.backend {
246 Backend::Local(store) => store.write(ns, oid, expected_size, budget, chunks).await?,
247 Backend::Bucket { bucket, staging } => {
248 let fresh = !bucket.exists(ns, oid).await;
254
255 let staged = staging
256 .stage(ns, oid, expected_size, budget, chunks)
257 .await?;
258 let outcome = bucket.store(ns, oid, &staged.path).await;
259
260 let _ = tokio::fs::remove_file(&staged.path).await;
263 outcome?;
264
265 super::Written {
266 bytes: staged.written,
267 fresh,
268 }
269 }
270 };
271
272 if written.fresh {
277 self.usage.stored(ns, written.bytes).await;
278 }
279
280 Ok(written.bytes)
281 }
282
283 pub async fn capacity(&self) -> Option<(u64, u64)> {
289 match &self.backend {
290 Backend::Local(store) => Some(store.usage().await),
291 Backend::Bucket { .. } => None,
292 }
293 }
294
295 pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
300 if let Some(cached) = self.usage.cached(ns).await {
301 return cached;
302 }
303
304 let measured = match &self.backend {
305 Backend::Local(store) => store.measure_of(ns).await,
306 Backend::Bucket { bucket, .. } => bucket.usage_of(ns).await,
307 };
308
309 self.usage.remember(ns, measured.0, measured.1).await;
310
311 measured
312 }
313
314 pub async fn sweep(
315 &self,
316 ns: &Namespace,
317 retained: &std::collections::HashSet<String>,
318 grace: std::time::Duration,
319 dry_run: bool,
320 ) -> Result<SweepReport, Error> {
321 match &self.backend {
322 Backend::Local(store) => {
323 let report = store.sweep(ns, retained, grace, dry_run).await;
324
325 self.usage.forget(ns).await;
329
330 report
331 }
332 Backend::Bucket { bucket, .. } => {
333 let report = bucket.sweep(ns, retained, grace, dry_run).await;
334
335 if report.is_ok() && !dry_run {
336 self.usage.forget(ns).await;
337 }
338
339 report
340 }
341 }
342 }
343
344 pub async fn dedupe(&self, ns: &Namespace, dry_run: bool) -> Result<DedupeReport, Error> {
345 match &self.backend {
346 Backend::Local(store) => {
347 let report = store.dedupe(ns, dry_run).await;
348
349 self.usage.forget(ns).await;
353
354 report
355 }
356 Backend::Bucket { .. } => Err(Error::Unsupported(
360 "a bucket stores each object once already, so there is nothing to deduplicate",
361 )),
362 }
363 }
364
365 pub async fn compress(&self, ns: &Namespace, dry_run: bool) -> Result<CompressReport, Error> {
366 match &self.backend {
367 Backend::Local(store) => {
368 let report = store.compress(ns, dry_run).await;
369
370 self.usage.forget(ns).await;
374
375 report
376 }
377 Backend::Bucket { .. } => Err(Error::Unsupported(
381 "rewriting objects already in a bucket is not implemented",
382 )),
383 }
384 }
385
386 pub async fn verify(&self, ns: &Namespace) -> Result<VerifyReport, Error> {
387 match &self.backend {
388 Backend::Local(store) => store.verify(ns).await,
389 Backend::Bucket { .. } => Err(Error::Unsupported(
390 "verification is not implemented for a bucket yet",
391 )),
392 }
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use futures_util::StreamExt;
399
400 use super::*;
401 use crate::storage::crypt::Keyring;
402 use crate::storage::s3::tests::{bucket, redirecting, store};
403
404 fn keyring() -> std::sync::Arc<Keyring> {
405 std::sync::Arc::new(
406 Keyring::parse(&hex::encode([7u8; crate::storage::crypt::KEY])).unwrap(),
407 )
408 }
409
410 fn namespace() -> Namespace {
411 Namespace::new("FerrLabs", "Blastlands").unwrap()
412 }
413
414 fn bucket_store(root: &tempfile::TempDir, endpoint: &str) -> Store {
415 Store::bucket(store(endpoint), LocalStore::new(root.path()))
416 }
417
418 async fn read_back(store: &Store, ns: &Namespace, oid: &str) -> Vec<u8> {
419 let object = store.open(ns, oid).await.unwrap();
420 let size = object.size();
421 let mut chunks = object.stream(0, size).await.unwrap();
422 let mut out = Vec::new();
423
424 while let Some(chunk) = chunks.next().await {
425 out.extend_from_slice(&chunk.unwrap());
426 }
427
428 out
429 }
430
431 #[tokio::test]
432 async fn an_upload_lands_in_the_bucket_and_reads_back_through_the_same_seam() {
433 let root = tempfile::tempdir().unwrap();
434 let (endpoint, _objects) = bucket().await;
435 let store = bucket_store(&root, &endpoint);
436 let payload = b"an asset that never touches this disk for long".repeat(32);
437 let oid = hex::encode(sha2::Sha256::digest(&payload));
438
439 let written = store
440 .write(
441 &namespace(),
442 &oid,
443 Some(payload.len() as u64),
444 None,
445 futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
446 payload.clone(),
447 ))]),
448 )
449 .await
450 .unwrap();
451
452 assert_eq!(written, payload.len() as u64);
453 assert!(store.exists(&namespace(), &oid).await);
454
455 assert_eq!(read_back(&store, &namespace(), &oid).await, payload);
456 }
457
458 #[tokio::test]
463 async fn a_bucket_holds_the_object_even_when_the_server_was_told_to_compress() {
464 let root = tempfile::tempdir().unwrap();
465 let (endpoint, _objects) = bucket().await;
466 let store = Store::bucket(
467 store(&endpoint),
468 LocalStore::new(root.path()).with_compression(Some(3)),
469 );
470 let payload = b"a mesh that gives up most of its ground to zstd ".repeat(4096);
471 let oid = hex::encode(sha2::Sha256::digest(&payload));
472
473 store
474 .write(
475 &namespace(),
476 &oid,
477 Some(payload.len() as u64),
478 None,
479 futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
480 payload.clone(),
481 ))]),
482 )
483 .await
484 .unwrap();
485
486 let restored = read_back(&store, &namespace(), &oid).await;
487
488 assert_eq!(
489 hex::encode(sha2::Sha256::digest(&restored)),
490 oid,
491 "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",
492 restored.len()
493 );
494 assert_eq!(restored, payload);
495 }
496
497 #[tokio::test]
498 async fn the_staging_file_does_not_outlive_the_upload() {
499 let root = tempfile::tempdir().unwrap();
500 let (endpoint, _objects) = bucket().await;
501 let store = bucket_store(&root, &endpoint);
502 let payload = b"an asset passing through".to_vec();
503 let oid = hex::encode(sha2::Sha256::digest(&payload));
504
505 store
506 .write(
507 &namespace(),
508 &oid,
509 None,
510 None,
511 futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
512 payload,
513 ))]),
514 )
515 .await
516 .unwrap();
517
518 let leftovers = crate::storage::tests::staging_files(root.path());
519 assert!(
520 leftovers.is_empty(),
521 "local disk is a write buffer here, and one that is never emptied is a disk that \
522 fills: {leftovers:?}"
523 );
524 }
525
526 #[tokio::test]
527 async fn a_bucket_reports_no_capacity_rather_than_an_empty_one() {
528 let root = tempfile::tempdir().unwrap();
529 let (endpoint, _objects) = bucket().await;
530
531 assert!(
532 bucket_store(&root, &endpoint).capacity().await.is_none(),
533 "zero would be read as an empty store by every dashboard that averages it"
534 );
535 assert!(
536 Store::local(LocalStore::new(root.path()))
537 .capacity()
538 .await
539 .is_some()
540 );
541 }
542
543 #[tokio::test]
544 async fn the_maintenance_commands_say_they_do_not_apply_rather_than_lying() {
545 let root = tempfile::tempdir().unwrap();
546 let (endpoint, _objects) = bucket().await;
547 let store = bucket_store(&root, &endpoint);
548 let ns = namespace();
549
550 for outcome in [
551 store.dedupe(&ns, true).await.err(),
552 store.compress(&ns, true).await.err(),
553 store.verify(&ns).await.err(),
554 ] {
555 assert!(
556 matches!(outcome, Some(Error::Unsupported(_))),
557 "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:?}"
558 );
559 }
560
561 assert!(
563 store
564 .sweep(&ns, &std::collections::HashSet::new(), Duration::ZERO, true)
565 .await
566 .is_ok(),
567 "collection is implemented for a bucket and must not answer Unsupported"
568 );
569 }
570
571 #[tokio::test]
581 async fn a_download_is_never_redirected_to_a_frame() {
582 for label in ["compressed", "encrypted"] {
583 let root = tempfile::tempdir().unwrap();
584 let (endpoint, objects) = bucket().await;
585 let staging = match label {
586 "compressed" => LocalStore::new(root.path()).with_compression(Some(3)),
587 _ => LocalStore::new(root.path()).with_encryption(Some(keyring())),
588 };
589 let store = Store::bucket(redirecting(&endpoint), staging);
590
591 let payload =
592 b"a scene file that compresses and must still come back whole ".repeat(512);
593 let oid = hex::encode(sha2::Sha256::digest(&payload));
594
595 store
596 .write(
597 &namespace(),
598 &oid,
599 Some(payload.len() as u64),
600 None,
601 futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
602 payload.clone(),
603 ))]),
604 )
605 .await
606 .unwrap();
607
608 let stored = objects
609 .lock()
610 .unwrap()
611 .values()
612 .find(|object| !object.is_empty())
613 .cloned()
614 .unwrap();
615
616 assert_ne!(
617 stored, payload,
618 "{label}: the bucket holds a frame, which is the premise of the rest of this test"
619 );
620 assert_eq!(
621 store.redirect(&oid),
622 None,
623 "{label}: a client sent to the bucket would hash {} bytes of frame and reject the \
624 object it asked for",
625 stored.len()
626 );
627 assert_eq!(
628 read_back(&store, &namespace(), &oid).await,
629 payload,
630 "{label}: giving up the redirect is only correct because the streamed path decodes"
631 );
632 }
633 }
634
635 #[tokio::test]
639 async fn a_bucket_holding_the_object_itself_still_redirects() {
640 let root = tempfile::tempdir().unwrap();
641 let (endpoint, _objects) = bucket().await;
642 let store = Store::bucket(redirecting(&endpoint), LocalStore::new(root.path()));
643
644 let payload = b"an object stored as it arrived".repeat(32);
645 let oid = hex::encode(sha2::Sha256::digest(&payload));
646
647 store
648 .write(
649 &namespace(),
650 &oid,
651 Some(payload.len() as u64),
652 None,
653 futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
654 payload.clone(),
655 ))]),
656 )
657 .await
658 .unwrap();
659
660 assert!(store.redirect(&oid).is_some());
661 }
662}