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(Backend);
17
18enum Backend {
19 Local(LocalStore),
20 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 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 reclaim(&self, older_than: std::time::Duration) -> super::Reclaimed {
59 let mut reclaimed = self.staging().reclaim_staging(older_than).await;
60
61 if let Backend::Bucket { bucket, .. } = &self.0 {
62 match bucket.reclaim_incoming(older_than).await {
63 Ok(theirs) => {
64 reclaimed.files += theirs.files;
65 reclaimed.bytes += theirs.bytes;
66 }
67 Err(error) => {
68 tracing::warn!(%error, "abandoned uploads in the bucket could not be reclaimed");
69 }
70 }
71 }
72
73 reclaimed
74 }
75
76 pub async fn writable(&self) -> Result<(), Error> {
84 self.staging().writable().await.map_err(|error| {
85 Error::Storage(std::io::Error::other(format!(
86 "the staging volume is not writable: {error}"
87 )))
88 })?;
89
90 if let Backend::Bucket { bucket, .. } = &self.0 {
91 bucket.reachable().await?;
92 }
93
94 Ok(())
95 }
96
97 pub fn scans(&self) -> u64 {
98 self.staging().scans()
99 }
100
101 pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
102 match &self.0 {
103 Backend::Local(store) => store.exists(ns, oid).await,
104 Backend::Bucket { bucket, .. } => bucket.exists(ns, oid).await,
105 }
106 }
107
108 pub fn redirect(&self, oid: &str) -> Option<String> {
116 match &self.0 {
117 Backend::Local(_) => None,
118 Backend::Bucket { bucket, .. } => bucket.presigned_download(oid),
119 }
120 }
121
122 pub fn presigned_upload(&self, ns: &Namespace, oid: &str) -> Option<super::s3::Presigned> {
126 match &self.0 {
127 Backend::Local(_) => None,
128 Backend::Bucket { staging, .. } if staging.encrypts() => None,
135 Backend::Bucket { bucket, .. } => bucket.presigned_upload(ns, oid),
136 }
137 }
138
139 pub async fn uploaded_size(&self, ns: &Namespace, oid: &str) -> Result<Option<u64>, Error> {
143 match &self.0 {
144 Backend::Local(_) => Ok(None),
145 Backend::Bucket { bucket, .. } => Ok(bucket.uploaded_size(ns, oid).await.ok()),
146 }
147 }
148
149 pub async fn adopt(&self, ns: &Namespace, oid: &str) -> Result<(), Error> {
153 match &self.0 {
154 Backend::Local(_) => Err(Error::Unsupported(
155 "objects are written through this server, so there is nothing to adopt",
156 )),
157 Backend::Bucket { bucket, .. } => bucket.adopt(ns, oid).await,
158 }
159 }
160
161 pub async fn open(&self, ns: &Namespace, oid: &str) -> Result<Object, Error> {
162 match &self.0 {
163 Backend::Local(store) => store.open(ns, oid).await,
164 Backend::Bucket { bucket, staging } => {
165 if !bucket.exists(ns, oid).await {
168 return Err(Error::NotFound);
169 }
170
171 let size = bucket.size_of(oid).await?;
172 let reader = super::codec::Reader::Bucket {
173 bucket: (**bucket).clone(),
174 oid: oid.to_owned(),
175 };
176
177 match super::codec::Framed::open(
178 reader,
179 size,
180 staging.keyring().map(AsRef::as_ref),
181 oid,
182 )
183 .await?
184 {
185 Some(framed) => Ok(Object::Framed(framed)),
186 None => Ok(Object::Remote {
189 bucket: (**bucket).clone(),
190 oid: oid.to_owned(),
191 size,
192 }),
193 }
194 }
195 }
196 }
197
198 pub async fn write<S, E>(
199 &self,
200 ns: &Namespace,
201 oid: &str,
202 expected_size: Option<u64>,
203 budget: Option<Budget>,
204 chunks: S,
205 ) -> Result<u64, Error>
206 where
207 S: Stream<Item = Result<axum::body::Bytes, E>> + Unpin,
208 E: std::error::Error + Send + Sync + 'static,
209 {
210 match &self.0 {
211 Backend::Local(store) => store.write(ns, oid, expected_size, budget, chunks).await,
212 Backend::Bucket { bucket, staging } => {
213 let staged = staging
214 .stage(ns, oid, expected_size, budget, chunks)
215 .await?;
216 let outcome = bucket.store(ns, oid, &staged.path).await;
217
218 let _ = tokio::fs::remove_file(&staged.path).await;
221 outcome?;
222
223 Ok(staged.written)
224 }
225 }
226 }
227
228 pub async fn capacity(&self) -> Option<(u64, u64)> {
234 match &self.0 {
235 Backend::Local(store) => Some(store.usage().await),
236 Backend::Bucket { .. } => None,
237 }
238 }
239
240 pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
241 match &self.0 {
242 Backend::Local(store) => store.usage_of(ns).await,
243 Backend::Bucket { bucket, .. } => bucket.usage_of(ns).await,
244 }
245 }
246
247 pub async fn sweep(
248 &self,
249 ns: &Namespace,
250 retained: &std::collections::HashSet<String>,
251 grace: std::time::Duration,
252 dry_run: bool,
253 ) -> Result<SweepReport, Error> {
254 match &self.0 {
255 Backend::Local(store) => store.sweep(ns, retained, grace, dry_run).await,
256 Backend::Bucket { .. } => Err(Error::Unsupported(
257 "collection is not implemented for a bucket yet",
258 )),
259 }
260 }
261
262 pub async fn dedupe(&self, ns: &Namespace, dry_run: bool) -> Result<DedupeReport, Error> {
263 match &self.0 {
264 Backend::Local(store) => store.dedupe(ns, dry_run).await,
265 Backend::Bucket { .. } => Err(Error::Unsupported(
269 "a bucket stores each object once already, so there is nothing to deduplicate",
270 )),
271 }
272 }
273
274 pub async fn compress(&self, ns: &Namespace, dry_run: bool) -> Result<CompressReport, Error> {
275 match &self.0 {
276 Backend::Local(store) => store.compress(ns, dry_run).await,
277 Backend::Bucket { .. } => Err(Error::Unsupported(
281 "rewriting objects already in a bucket is not implemented",
282 )),
283 }
284 }
285
286 pub async fn verify(&self, ns: &Namespace) -> Result<VerifyReport, Error> {
287 match &self.0 {
288 Backend::Local(store) => store.verify(ns).await,
289 Backend::Bucket { .. } => Err(Error::Unsupported(
290 "verification is not implemented for a bucket yet",
291 )),
292 }
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use futures_util::StreamExt;
299
300 use super::*;
301 use crate::storage::s3::tests::{bucket, store};
302
303 fn namespace() -> Namespace {
304 Namespace::new("FerrLabs", "Blastlands").unwrap()
305 }
306
307 fn bucket_store(root: &tempfile::TempDir, endpoint: &str) -> Store {
308 Store::bucket(store(endpoint), LocalStore::new(root.path()))
309 }
310
311 async fn read_back(store: &Store, ns: &Namespace, oid: &str) -> Vec<u8> {
312 let object = store.open(ns, oid).await.unwrap();
313 let size = object.size();
314 let mut chunks = object.stream(0, size).await.unwrap();
315 let mut out = Vec::new();
316
317 while let Some(chunk) = chunks.next().await {
318 out.extend_from_slice(&chunk.unwrap());
319 }
320
321 out
322 }
323
324 #[tokio::test]
325 async fn an_upload_lands_in_the_bucket_and_reads_back_through_the_same_seam() {
326 let root = tempfile::tempdir().unwrap();
327 let (endpoint, _objects) = bucket().await;
328 let store = bucket_store(&root, &endpoint);
329 let payload = b"an asset that never touches this disk for long".repeat(32);
330 let oid = hex::encode(sha2::Sha256::digest(&payload));
331
332 let written = store
333 .write(
334 &namespace(),
335 &oid,
336 Some(payload.len() as u64),
337 None,
338 futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
339 payload.clone(),
340 ))]),
341 )
342 .await
343 .unwrap();
344
345 assert_eq!(written, payload.len() as u64);
346 assert!(store.exists(&namespace(), &oid).await);
347
348 assert_eq!(read_back(&store, &namespace(), &oid).await, payload);
349 }
350
351 #[tokio::test]
356 async fn a_bucket_holds_the_object_even_when_the_server_was_told_to_compress() {
357 let root = tempfile::tempdir().unwrap();
358 let (endpoint, _objects) = bucket().await;
359 let store = Store::bucket(
360 store(&endpoint),
361 LocalStore::new(root.path()).with_compression(Some(3)),
362 );
363 let payload = b"a mesh that gives up most of its ground to zstd ".repeat(4096);
364 let oid = hex::encode(sha2::Sha256::digest(&payload));
365
366 store
367 .write(
368 &namespace(),
369 &oid,
370 Some(payload.len() as u64),
371 None,
372 futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
373 payload.clone(),
374 ))]),
375 )
376 .await
377 .unwrap();
378
379 let restored = read_back(&store, &namespace(), &oid).await;
380
381 assert_eq!(
382 hex::encode(sha2::Sha256::digest(&restored)),
383 oid,
384 "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",
385 restored.len()
386 );
387 assert_eq!(restored, payload);
388 }
389
390 #[tokio::test]
391 async fn the_staging_file_does_not_outlive_the_upload() {
392 let root = tempfile::tempdir().unwrap();
393 let (endpoint, _objects) = bucket().await;
394 let store = bucket_store(&root, &endpoint);
395 let payload = b"an asset passing through".to_vec();
396 let oid = hex::encode(sha2::Sha256::digest(&payload));
397
398 store
399 .write(
400 &namespace(),
401 &oid,
402 None,
403 None,
404 futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
405 payload,
406 ))]),
407 )
408 .await
409 .unwrap();
410
411 let leftovers = crate::storage::tests::staging_files(root.path());
412 assert!(
413 leftovers.is_empty(),
414 "local disk is a write buffer here, and one that is never emptied is a disk that \
415 fills: {leftovers:?}"
416 );
417 }
418
419 #[tokio::test]
420 async fn a_bucket_reports_no_capacity_rather_than_an_empty_one() {
421 let root = tempfile::tempdir().unwrap();
422 let (endpoint, _objects) = bucket().await;
423
424 assert!(
425 bucket_store(&root, &endpoint).capacity().await.is_none(),
426 "zero would be read as an empty store by every dashboard that averages it"
427 );
428 assert!(
429 Store::local(LocalStore::new(root.path()))
430 .capacity()
431 .await
432 .is_some()
433 );
434 }
435
436 #[tokio::test]
437 async fn the_maintenance_commands_say_they_do_not_apply_rather_than_lying() {
438 let root = tempfile::tempdir().unwrap();
439 let (endpoint, _objects) = bucket().await;
440 let store = bucket_store(&root, &endpoint);
441 let ns = namespace();
442
443 for outcome in [
444 store.dedupe(&ns, true).await.err(),
445 store.compress(&ns, true).await.err(),
446 store.verify(&ns).await.err(),
447 store
448 .sweep(&ns, &std::collections::HashSet::new(), Duration::ZERO, true)
449 .await
450 .err(),
451 ] {
452 assert!(
453 matches!(outcome, Some(Error::Unsupported(_))),
454 "an operator running collection against a bucket has to be told it did nothing, \
455 not handed an empty report that reads like success: {outcome:?}"
456 );
457 }
458 }
459}