lfsx_server/storage/backend.rs
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
12// Where the objects live. A bucket decouples capacity from the machine, at the
13// price of the things a filesystem gave for nothing — hard links, a directory
14// walk, and a rename that is atomic. Each of those is answered here or refused
15// out loud; none of them is quietly skipped.
16pub struct Store {
17 backend: Backend,
18 usage: super::usage::Usage,
19}
20
21enum Backend {
22 Local(LocalStore),
23 // Even with a bucket the local store stays, because a transfer has to land
24 // somewhere before anyone can tell whether it is the object it claims to be.
25 // It is a write buffer, not the store.
26 // Boxed because a bucket handle beside a local store makes this variant far
27 // larger than the other, and every Store in the process would pay for it.
28 Bucket {
29 bucket: Box<S3Store>,
30 staging: LocalStore,
31 },
32}
33
34impl Store {
35 pub fn local(store: LocalStore) -> Self {
36 Self::over(Backend::Local(store))
37 }
38
39 // Compression and encryption used to be stripped here, because a framed
40 // object was only readable through the file the codec opened and a bucket key
41 // is not one. The codec now reads from a bucket too, so the frames go up as
42 // they are and come back decoded: the header and the index are three ranged
43 // GETs, which is what the format was shaped for.
44 pub fn bucket(bucket: S3Store, staging: LocalStore) -> Self {
45 Self::over(Backend::Bucket {
46 bucket: Box::new(bucket),
47 staging,
48 })
49 }
50
51 fn over(backend: Backend) -> Self {
52 Self {
53 backend,
54 usage: super::usage::Usage::default(),
55 }
56 }
57
58 fn staging(&self) -> &LocalStore {
59 match &self.backend {
60 Backend::Local(store) => store,
61 Backend::Bucket { staging, .. } => staging,
62 }
63 }
64
65 // Everything an interrupted upload can leave behind, wherever it left it. A
66 // bucket deployment still stages locally, so both are swept and the figures
67 // add up to one answer.
68 pub async fn reclaim(&self, older_than: std::time::Duration) -> super::Reclaimed {
69 let mut reclaimed = self.staging().reclaim_staging(older_than).await;
70
71 if let Backend::Bucket { bucket, .. } = &self.backend {
72 match bucket.reclaim_incoming(older_than).await {
73 Ok(theirs) => {
74 reclaimed.files += theirs.files;
75 reclaimed.bytes += theirs.bytes;
76 }
77 Err(error) => {
78 tracing::warn!(%error, "abandoned uploads in the bucket could not be reclaimed");
79 }
80 }
81 }
82
83 reclaimed
84 }
85
86 // Readiness has to ask the backend that actually serves. Once the objects
87 // live in a bucket the volume is a write buffer, and an instance whose
88 // credentials were rotated or whose bucket is gone passes a probe that only
89 // proves its scratch disk works — then fails every transfer it is handed.
90 //
91 // So both are asked, either failing takes the instance out, and they are
92 // named apart: a full disk and a rotated key are not the same afternoon.
93 pub async fn writable(&self) -> Result<(), Error> {
94 self.staging().writable().await.map_err(|error| {
95 Error::Storage(std::io::Error::other(format!(
96 "the staging volume is not writable: {error}"
97 )))
98 })?;
99
100 if let Backend::Bucket { bucket, .. } = &self.backend {
101 bucket.reachable().await?;
102 }
103
104 Ok(())
105 }
106
107 pub fn scans(&self) -> u64 {
108 self.staging().scans()
109 }
110
111 pub async fn exists(&self, ns: &Namespace, oid: &str) -> bool {
112 match &self.backend {
113 Backend::Local(store) => store.exists(ns, oid).await,
114 Backend::Bucket { bucket, .. } => bucket.exists(ns, oid).await,
115 }
116 }
117
118 // Where the client should fetch this object from, when that is somewhere
119 // other than this server. None for a local store, and for a bucket the
120 // operator has not asked to redirect — which is the default, because the
121 // streamed path is the one that counts the bytes and holds the ceiling.
122 //
123 // The caller is responsible for having established that this repository
124 // holds the object. This hands out a signature, not a permission.
125 pub fn redirect(&self, oid: &str) -> Option<String> {
126 match &self.backend {
127 Backend::Local(_) => None,
128 // A pre-signed URL hands over whatever sits under that key, and with
129 // a codec in the path that is a frame rather than the object. The
130 // client would hash what arrived, get a digest that is not the one it
131 // asked for, and reject it. So the redirect is given up and the
132 // download streams, which is the only path that can decode.
133 //
134 // Compression is enough on its own, even though it still lets a
135 // client upload straight to the bucket. That asymmetry is the right
136 // way round: an unframed object is a perfectly good entry, so a
137 // direct upload stays safe, while one framed object anywhere in the
138 // store makes every redirect a guess.
139 Backend::Bucket { staging, .. } if staging.frames() => None,
140 Backend::Bucket { bucket, .. } => bucket.presigned_download(oid),
141 }
142 }
143
144 // Where the client should PUT the object, when that is the bucket rather than
145 // this server. None for a local store and for a bucket the operator has not
146 // asked to redirect.
147 pub fn presigned_upload(
148 &self,
149 ns: &Namespace,
150 oid: &str,
151 size: u64,
152 ) -> Option<super::s3::Presigned> {
153 match &self.backend {
154 Backend::Local(_) => None,
155 // A client uploading straight to the bucket writes the object as it
156 // is, so a configured key would never touch it and the bucket would
157 // hold plaintext while an operator believed otherwise. Encryption is
158 // a promise about what the storage provider can read; a faster upload
159 // is not worth quietly breaking it. Those transfers keep coming
160 // through the server, which seals them.
161 Backend::Bucket { staging, .. } if staging.encrypts() => None,
162 Backend::Bucket { bucket, .. } => bucket.presigned_upload(ns, oid, size),
163 }
164 }
165
166 // How big an object waiting under this repository's own upload key is. None
167 // when there is nothing waiting, which is every local deployment and every
168 // client that has not used its URL.
169 pub async fn uploaded_size(&self, ns: &Namespace, oid: &str) -> Result<Option<u64>, Error> {
170 match &self.backend {
171 Backend::Local(_) => Ok(None),
172 Backend::Bucket { bucket, .. } => Ok(bucket.uploaded_size(ns, oid).await.ok()),
173 }
174 }
175
176 // Take an upload this repository made into the shared keyspace. Only reachable
177 // for a bucket, because only there does a client write anywhere this server
178 // did not.
179 pub async fn adopt(&self, ns: &Namespace, oid: &str, arrived: u64) -> Result<(), Error> {
180 let outcome = match &self.backend {
181 Backend::Local(_) => Err(Error::Unsupported(
182 "objects are written through this server, so there is nothing to adopt",
183 )),
184 Backend::Bucket { bucket, .. } => bucket.adopt(ns, oid, arrived).await,
185 };
186
187 // Same reason as a write: verify is called once per object, so dropping
188 // what is remembered here would make every one of them re-measure.
189 if outcome.is_ok() {
190 self.usage.stored(ns, arrived).await;
191 }
192
193 outcome
194 }
195
196 pub async fn open(&self, ns: &Namespace, oid: &str) -> Result<Object, Error> {
197 match &self.backend {
198 Backend::Local(store) => store.open(ns, oid).await,
199 Backend::Bucket { bucket, staging } => {
200 // The marker is the proof of possession and is checked before
201 // anything is read, exactly as a local open checks the link.
202 if !bucket.exists(ns, oid).await {
203 return Err(Error::NotFound);
204 }
205
206 let size = bucket.size_of(oid).await?;
207 let reader = super::codec::Reader::Bucket {
208 bucket: (**bucket).clone(),
209 oid: oid.to_owned(),
210 };
211
212 match super::codec::Framed::open(
213 reader,
214 size,
215 staging.keyring().map(AsRef::as_ref),
216 oid,
217 )
218 .await?
219 {
220 Some(framed) => Ok(Object::Framed(framed)),
221 // Not one of ours: the object is the bytes, and streaming
222 // them straight through costs no extra round trip.
223 None => Ok(Object::Remote {
224 bucket: (**bucket).clone(),
225 oid: oid.to_owned(),
226 size,
227 }),
228 }
229 }
230 }
231 }
232
233 pub async fn write<S, E>(
234 &self,
235 ns: &Namespace,
236 oid: &str,
237 expected_size: Option<u64>,
238 budget: Option<Budget>,
239 chunks: S,
240 ) -> Result<u64, Error>
241 where
242 S: Stream<Item = Result<axum::body::Bytes, E>> + Unpin,
243 E: std::error::Error + Send + Sync + 'static,
244 {
245 let written = match &self.backend {
246 Backend::Local(store) => store.write(ns, oid, expected_size, budget, chunks).await?,
247 Backend::Bucket { bucket, staging } => {
248 // Asked of the bucket, because the staging store answers about a
249 // local layout a bucket deployment never fills in: it would call
250 // every upload fresh, and re-pushing an object the repository
251 // already holds would grow what is remembered without anything
252 // being stored.
253 let fresh = !bucket.exists(ns, oid).await;
254
255 let staged = staging
256 .stage(ns, oid, expected_size, budget, chunks)
257 .await?;
258 let outcome = bucket.store(ns, oid, &staged.path).await;
259
260 // The staging file has served its purpose either way. Leaving it
261 // would be a leak the reclaimer only notices a day later.
262 let _ = tokio::fs::remove_file(&staged.path).await;
263 outcome?;
264
265 super::Written {
266 bytes: staged.written,
267 fresh,
268 }
269 }
270 };
271
272 // Added to what is remembered rather than dropping it: a client pushing
273 // a hundred objects would otherwise make the next negotiation measure
274 // the repository again, which on a bucket is what this cache exists to
275 // avoid.
276 if written.fresh {
277 self.usage.stored(ns, written.bytes).await;
278 }
279
280 Ok(written.bytes)
281 }
282
283 // None rather than zero: a bucket has no cheap answer for what the whole
284 // store holds, and building one from a full listing would cost a request per
285 // object on every scrape. Zero would be read as an empty bucket by every
286 // dashboard that averages it, which is the one lie this seam otherwise
287 // refuses to tell — everything else it cannot do answers 501.
288 pub async fn capacity(&self) -> Option<(u64, u64)> {
289 match &self.backend {
290 Backend::Local(store) => Some(store.usage().await),
291 Backend::Bucket { .. } => None,
292 }
293 }
294
295 // Measured at most once a minute per repository, whichever backend is
296 // behind it. A bucket answers this by listing the repository's markers and
297 // asking the size of each, so one uncached call per object in a batch made
298 // a hundred-object push cost a hundred listings — the product, not the sum.
299 pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
300 if let Some(cached) = self.usage.cached(ns).await {
301 return cached;
302 }
303
304 let measured = match &self.backend {
305 Backend::Local(store) => store.measure_of(ns).await,
306 Backend::Bucket { bucket, .. } => bucket.usage_of(ns).await,
307 };
308
309 self.usage.remember(ns, measured.0, measured.1).await;
310
311 measured
312 }
313
314 pub async fn sweep(
315 &self,
316 ns: &Namespace,
317 retained: &std::collections::HashSet<String>,
318 grace: std::time::Duration,
319 dry_run: bool,
320 ) -> Result<SweepReport, Error> {
321 match &self.backend {
322 Backend::Local(store) => {
323 let report = store.sweep(ns, retained, grace, dry_run).await;
324
325 // Freeing gigabytes and then answering the next quota check from
326 // the figure measured before is how a client is refused space it
327 // has just been told it reclaimed.
328 self.usage.forget(ns).await;
329
330 report
331 }
332 Backend::Bucket { bucket, .. } => {
333 let report = bucket.sweep(ns, retained, grace, dry_run).await;
334
335 if report.is_ok() && !dry_run {
336 self.usage.forget(ns).await;
337 }
338
339 report
340 }
341 }
342 }
343
344 pub async fn dedupe(&self, ns: &Namespace, dry_run: bool) -> Result<DedupeReport, Error> {
345 match &self.backend {
346 Backend::Local(store) => {
347 let report = store.dedupe(ns, dry_run).await;
348
349 // Freeing gigabytes and then answering the next quota check from
350 // the figure measured before is how a client is refused space it
351 // has just been told it reclaimed.
352 self.usage.forget(ns).await;
353
354 report
355 }
356 // Content addressing already gives this: two repositories pushing the
357 // same object write the same key, and each holds a marker beside it.
358 // There is nothing left to fold in.
359 Backend::Bucket { .. } => Err(Error::Unsupported(
360 "a bucket stores each object once already, so there is nothing to deduplicate",
361 )),
362 }
363 }
364
365 pub async fn compress(&self, ns: &Namespace, dry_run: bool) -> Result<CompressReport, Error> {
366 match &self.backend {
367 Backend::Local(store) => {
368 let report = store.compress(ns, dry_run).await;
369
370 // Freeing gigabytes and then answering the next quota check from
371 // the figure measured before is how a client is refused space it
372 // has just been told it reclaimed.
373 self.usage.forget(ns).await;
374
375 report
376 }
377 // Objects arriving now are compressed if the server is configured to;
378 // rewriting the ones already in the bucket means walking it and
379 // reuploading, which is a different piece of work.
380 Backend::Bucket { .. } => Err(Error::Unsupported(
381 "rewriting objects already in a bucket is not implemented",
382 )),
383 }
384 }
385
386 pub async fn verify(&self, ns: &Namespace) -> Result<VerifyReport, Error> {
387 match &self.backend {
388 Backend::Local(store) => store.verify(ns).await,
389 Backend::Bucket { .. } => Err(Error::Unsupported(
390 "verification is not implemented for a bucket yet",
391 )),
392 }
393 }
394}
395
396#[cfg(test)]
397mod tests;