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