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