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