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