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 async fn open(&self, ns: &Namespace, oid: &str) -> Result<Object, Error> {
85 match &self.0 {
86 Backend::Local(store) => store.open(ns, oid).await,
87 Backend::Bucket { bucket, staging } => {
88 if !bucket.exists(ns, oid).await {
91 return Err(Error::NotFound);
92 }
93
94 let size = bucket.size_of(oid).await?;
95 let reader = super::codec::Reader::Bucket {
96 bucket: (**bucket).clone(),
97 oid: oid.to_owned(),
98 };
99
100 match super::codec::Framed::open(
101 reader,
102 size,
103 staging.keyring().map(AsRef::as_ref),
104 oid,
105 )
106 .await?
107 {
108 Some(framed) => Ok(Object::Framed(framed)),
109 None => Ok(Object::Remote {
112 bucket: (**bucket).clone(),
113 oid: oid.to_owned(),
114 size,
115 }),
116 }
117 }
118 }
119 }
120
121 pub async fn write<S, E>(
122 &self,
123 ns: &Namespace,
124 oid: &str,
125 expected_size: Option<u64>,
126 budget: Option<Budget>,
127 chunks: S,
128 ) -> Result<u64, Error>
129 where
130 S: Stream<Item = Result<axum::body::Bytes, E>> + Unpin,
131 E: std::error::Error + Send + Sync + 'static,
132 {
133 match &self.0 {
134 Backend::Local(store) => store.write(ns, oid, expected_size, budget, chunks).await,
135 Backend::Bucket { bucket, staging } => {
136 let staged = staging
137 .stage(ns, oid, expected_size, budget, chunks)
138 .await?;
139 let outcome = bucket.store(ns, oid, &staged.path).await;
140
141 let _ = tokio::fs::remove_file(&staged.path).await;
144 outcome?;
145
146 Ok(staged.written)
147 }
148 }
149 }
150
151 pub async fn capacity(&self) -> Option<(u64, u64)> {
157 match &self.0 {
158 Backend::Local(store) => Some(store.usage().await),
159 Backend::Bucket { .. } => None,
160 }
161 }
162
163 pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
164 match &self.0 {
165 Backend::Local(store) => store.usage_of(ns).await,
166 Backend::Bucket { bucket, .. } => bucket.usage_of(ns).await,
167 }
168 }
169
170 pub async fn sweep(
171 &self,
172 ns: &Namespace,
173 retained: &std::collections::HashSet<String>,
174 grace: std::time::Duration,
175 dry_run: bool,
176 ) -> Result<SweepReport, Error> {
177 match &self.0 {
178 Backend::Local(store) => store.sweep(ns, retained, grace, dry_run).await,
179 Backend::Bucket { .. } => Err(Error::Unsupported(
180 "collection is not implemented for a bucket yet",
181 )),
182 }
183 }
184
185 pub async fn dedupe(&self, ns: &Namespace, dry_run: bool) -> Result<DedupeReport, Error> {
186 match &self.0 {
187 Backend::Local(store) => store.dedupe(ns, dry_run).await,
188 Backend::Bucket { .. } => Err(Error::Unsupported(
192 "a bucket stores each object once already, so there is nothing to deduplicate",
193 )),
194 }
195 }
196
197 pub async fn compress(&self, ns: &Namespace, dry_run: bool) -> Result<CompressReport, Error> {
198 match &self.0 {
199 Backend::Local(store) => store.compress(ns, dry_run).await,
200 Backend::Bucket { .. } => Err(Error::Unsupported(
204 "rewriting objects already in a bucket is not implemented",
205 )),
206 }
207 }
208
209 pub async fn verify(&self, ns: &Namespace) -> Result<VerifyReport, Error> {
210 match &self.0 {
211 Backend::Local(store) => store.verify(ns).await,
212 Backend::Bucket { .. } => Err(Error::Unsupported(
213 "verification is not implemented for a bucket yet",
214 )),
215 }
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use futures_util::StreamExt;
222
223 use super::*;
224 use crate::storage::s3::tests::{bucket, store};
225
226 fn namespace() -> Namespace {
227 Namespace::new("FerrLabs", "Blastlands").unwrap()
228 }
229
230 fn bucket_store(root: &tempfile::TempDir, endpoint: &str) -> Store {
231 Store::bucket(store(endpoint), LocalStore::new(root.path()))
232 }
233
234 async fn read_back(store: &Store, ns: &Namespace, oid: &str) -> Vec<u8> {
235 let object = store.open(ns, oid).await.unwrap();
236 let size = object.size();
237 let mut chunks = object.stream(0, size).await.unwrap();
238 let mut out = Vec::new();
239
240 while let Some(chunk) = chunks.next().await {
241 out.extend_from_slice(&chunk.unwrap());
242 }
243
244 out
245 }
246
247 #[tokio::test]
248 async fn an_upload_lands_in_the_bucket_and_reads_back_through_the_same_seam() {
249 let root = tempfile::tempdir().unwrap();
250 let (endpoint, _objects) = bucket().await;
251 let store = bucket_store(&root, &endpoint);
252 let payload = b"an asset that never touches this disk for long".repeat(32);
253 let oid = hex::encode(sha2::Sha256::digest(&payload));
254
255 let written = store
256 .write(
257 &namespace(),
258 &oid,
259 Some(payload.len() as u64),
260 None,
261 futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
262 payload.clone(),
263 ))]),
264 )
265 .await
266 .unwrap();
267
268 assert_eq!(written, payload.len() as u64);
269 assert!(store.exists(&namespace(), &oid).await);
270
271 assert_eq!(read_back(&store, &namespace(), &oid).await, payload);
272 }
273
274 #[tokio::test]
279 async fn a_bucket_holds_the_object_even_when_the_server_was_told_to_compress() {
280 let root = tempfile::tempdir().unwrap();
281 let (endpoint, _objects) = bucket().await;
282 let store = Store::bucket(
283 store(&endpoint),
284 LocalStore::new(root.path()).with_compression(Some(3)),
285 );
286 let payload = b"a mesh that gives up most of its ground to zstd ".repeat(4096);
287 let oid = hex::encode(sha2::Sha256::digest(&payload));
288
289 store
290 .write(
291 &namespace(),
292 &oid,
293 Some(payload.len() as u64),
294 None,
295 futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
296 payload.clone(),
297 ))]),
298 )
299 .await
300 .unwrap();
301
302 let restored = read_back(&store, &namespace(), &oid).await;
303
304 assert_eq!(
305 hex::encode(sha2::Sha256::digest(&restored)),
306 oid,
307 "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",
308 restored.len()
309 );
310 assert_eq!(restored, payload);
311 }
312
313 #[tokio::test]
314 async fn the_staging_file_does_not_outlive_the_upload() {
315 let root = tempfile::tempdir().unwrap();
316 let (endpoint, _objects) = bucket().await;
317 let store = bucket_store(&root, &endpoint);
318 let payload = b"an asset passing through".to_vec();
319 let oid = hex::encode(sha2::Sha256::digest(&payload));
320
321 store
322 .write(
323 &namespace(),
324 &oid,
325 None,
326 None,
327 futures_util::stream::iter([Ok::<_, std::io::Error>(axum::body::Bytes::from(
328 payload,
329 ))]),
330 )
331 .await
332 .unwrap();
333
334 let leftovers = crate::storage::tests::staging_files(root.path());
335 assert!(
336 leftovers.is_empty(),
337 "local disk is a write buffer here, and one that is never emptied is a disk that \
338 fills: {leftovers:?}"
339 );
340 }
341
342 #[tokio::test]
343 async fn a_bucket_reports_no_capacity_rather_than_an_empty_one() {
344 let root = tempfile::tempdir().unwrap();
345 let (endpoint, _objects) = bucket().await;
346
347 assert!(
348 bucket_store(&root, &endpoint).capacity().await.is_none(),
349 "zero would be read as an empty store by every dashboard that averages it"
350 );
351 assert!(
352 Store::local(LocalStore::new(root.path()))
353 .capacity()
354 .await
355 .is_some()
356 );
357 }
358
359 #[tokio::test]
360 async fn the_maintenance_commands_say_they_do_not_apply_rather_than_lying() {
361 let root = tempfile::tempdir().unwrap();
362 let (endpoint, _objects) = bucket().await;
363 let store = bucket_store(&root, &endpoint);
364 let ns = namespace();
365
366 for outcome in [
367 store.dedupe(&ns, true).await.err(),
368 store.compress(&ns, true).await.err(),
369 store.verify(&ns).await.err(),
370 store
371 .sweep(&ns, &std::collections::HashSet::new(), Duration::ZERO, true)
372 .await
373 .err(),
374 ] {
375 assert!(
376 matches!(outcome, Some(Error::Unsupported(_))),
377 "an operator running collection against a bucket has to be told it did nothing, \
378 not handed an empty report that reads like success: {outcome:?}"
379 );
380 }
381 }
382}