Skip to main content

krabka_object_store/ops/
client.rs

1//! The concrete [`ObjectOps`] implementation over an `object_store` handle.
2//!
3//! [`ObjectStoreClient`] is the crate's only implementation of the trait, so
4//! this file is where the operation logic actually lives: the
5//! multipart-threshold branch in `put_from_path`, the optional whole-object
6//! `SHA-256` digest, and the translation of every backend call into an
7//! [`ObjectStoreError`].
8
9use {
10    bytes::Bytes,
11    futures_util::{StreamExt as _, TryStreamExt as _},
12    object_store::{
13        GetOptions, GetRange, MultipartUpload, ObjectMeta, ObjectStoreExt as _, PutOptions,
14        PutPayload, PutResult, UploadPart, WriteMultipart, path::Path,
15    },
16    sha2::{Digest as _, Sha256},
17    std::sync::Arc,
18    tokio::io::AsyncReadExt as _,
19};
20
21#[cfg(test)]
22mod tests;
23
24use {
25    crate::{
26        error::ObjectStoreError,
27        ops::{ObjectOps, ObjectStream, PutOutcome, PutRequest},
28        read::read_capped,
29    },
30    std::{
31        future,
32        io::{self, ErrorKind},
33    },
34    tokio::{fs, fs::File, runtime::Handle},
35};
36
37// Bound the payload retained by multipart tasks to eight chunks per upload.
38const MULTIPART_MAX_CONCURRENCY: usize = 8;
39
40/// The single concrete [`ObjectOps`] implementation.
41///
42/// It wraps any `object_store::ObjectStore` handle, for example a handle from
43/// [`build_object_store`](crate::build_object_store), or an
44/// `object_store::memory::InMemory` handle in tests.
45#[derive(Clone)]
46pub struct ObjectStoreClient {
47    inner: Arc<dyn object_store::ObjectStore>,
48}
49
50#[derive(Debug)]
51struct AbortOnDrop {
52    upload: Option<Box<dyn MultipartUpload>>,
53    key: Path,
54}
55
56impl AbortOnDrop {
57    fn new(upload: Box<dyn MultipartUpload>, key: &Path) -> Self {
58        Self {
59            upload: Some(upload),
60            key: key.clone(),
61        }
62    }
63
64    /// The upload, until it completes or aborts.
65    fn upload(&mut self) -> object_store::Result<&mut Box<dyn MultipartUpload>> {
66        self.upload
67            .as_mut()
68            .ok_or_else(|| object_store::Error::Generic {
69                store: "multipart upload",
70                source: "the upload already completed or aborted".into(),
71            })
72    }
73}
74
75#[async_trait::async_trait]
76impl MultipartUpload for AbortOnDrop {
77    fn put_part(&mut self, data: PutPayload) -> UploadPart {
78        match self.upload() {
79            Ok(upload) => upload.put_part(data),
80            Err(error) => Box::pin(future::ready(Err(error))),
81        }
82    }
83
84    async fn complete(&mut self) -> object_store::Result<PutResult> {
85        let result = self.upload()?.complete().await;
86        if result.is_ok() {
87            self.upload.take();
88        }
89        result
90    }
91
92    async fn abort(&mut self) -> object_store::Result<()> {
93        let Some(mut upload) = self.upload.take() else {
94            return Ok(());
95        };
96        let key = self.key.clone();
97        tokio::spawn(async move {
98            let result = upload.abort().await;
99            if let Err(error) = &result {
100                tracing::warn!(%key, %error, "failed to abort multipart upload");
101            }
102            result
103        })
104        .await
105        .map_err(|error| object_store::Error::Generic {
106            store: "multipart upload",
107            source: Box::new(error),
108        })?
109    }
110}
111
112impl Drop for AbortOnDrop {
113    fn drop(&mut self) {
114        let Some(mut upload) = self.upload.take() else {
115            return;
116        };
117        let key = self.key.clone();
118        let Ok(runtime) = Handle::try_current() else {
119            tracing::warn!(%key, "cannot abort multipart upload outside a Tokio runtime");
120            return;
121        };
122        runtime.spawn(async move {
123            if let Err(error) = upload.abort().await {
124                tracing::warn!(%key, %error, "failed to abort multipart upload");
125            }
126        });
127    }
128}
129
130impl ObjectStoreClient {
131    /// Wrap an existing object-store handle.
132    #[must_use]
133    pub fn new(inner: Arc<dyn object_store::ObjectStore>) -> Self { Self { inner } }
134}
135
136#[krabka_macros::object_ops]
137#[async_trait::async_trait]
138impl ObjectOps for ObjectStoreClient {
139    put_from_path!({
140        if chunk_size == 0 {
141            return Err(io::Error::new(
142                ErrorKind::InvalidInput,
143                "multipart chunk size must be greater than zero",
144            )
145            .into());
146        }
147
148        let len = fs::metadata(src).await?.len();
149        if len < threshold {
150            return self.put(key, fs::read(src).await?.into(), req).await;
151        }
152        // `req.mode` cannot reach multipart completion. Atomically reserve the
153        // key with a retained empty version so only its winner may proceed.
154        // A failed upload deliberately leaves the reservation behind: WORM
155        // safety wins over retrying a key whose ownership is now ambiguous.
156        if matches!(req.mode, object_store::PutMode::Create) {
157            self.inner
158                .put_opts(key, PutPayload::from_bytes(Bytes::new()), PutOptions {
159                    mode: object_store::PutMode::Create,
160                    ..PutOptions::default()
161                })
162                .await?;
163        }
164        let upload = Box::new(AbortOnDrop::new(self.inner.put_multipart(key).await?, key));
165        let mut writer = WriteMultipart::new_with_chunk_size(upload, chunk_size);
166        let mut file = File::open(src).await?;
167        let mut buf = vec![0u8; chunk_size];
168        let mut hasher = req.digest.then(Sha256::new);
169        let mut size_bytes = 0u64;
170        loop {
171            writer.wait_for_capacity(MULTIPART_MAX_CONCURRENCY).await?;
172            let n = file.read(&mut buf).await?;
173            if n == 0 {
174                break;
175            }
176            let Some(read) = buf.get(..n) else {
177                return Err(io::Error::new(
178                    ErrorKind::InvalidData,
179                    "a read reported more bytes than its buffer holds",
180                )
181                .into());
182            };
183            if let Some(hasher) = hasher.as_mut() {
184                hasher.update(read);
185            }
186            size_bytes += n as u64;
187            writer.write(read);
188        }
189        let result = writer.finish().await?;
190        let expected_sha256 = hasher.map(|hasher| hasher.finalize().into());
191        let mut e_tag = result.e_tag;
192        let mut version_id = result.version;
193        if matches!(req.mode, object_store::PutMode::Create) {
194            let meta = self.inner.head(key).await?;
195            if version_id.is_some() && meta.version != version_id {
196                return Err(ObjectStoreError::Precondition {
197                    key: key.clone(),
198                    reason: format!(
199                        "completed multipart version {:?} does not match HEAD version {:?}",
200                        version_id, meta.version
201                    ),
202                });
203            }
204            version_id = meta.version;
205            if let Some(expected) = expected_sha256 {
206                let options = GetOptions {
207                    version: version_id.clone(),
208                    ..GetOptions::default()
209                };
210                let mut stored = self.inner.get_opts(key, options).await?.into_stream();
211                let mut hasher = Sha256::new();
212                while let Some(chunk) = stored.try_next().await? {
213                    hasher.update(&chunk);
214                }
215                let actual: [u8; 32] = hasher.finalize().into();
216                if expected != actual {
217                    return Err(ObjectStoreError::Precondition {
218                        key: key.clone(),
219                        reason: "completed multipart object does not match the uploaded SHA-256"
220                            .into(),
221                    });
222                }
223            }
224            e_tag = meta.e_tag;
225        }
226        Ok(PutOutcome {
227            size_bytes,
228            sha256: expected_sha256,
229            e_tag,
230            version_id,
231            create_precondition: false,
232        })
233    });
234
235    async fn put(
236        &self,
237        key: &Path,
238        bytes: Bytes,
239        req: PutRequest,
240    ) -> Result<PutOutcome, ObjectStoreError> {
241        let create_precondition = matches!(req.mode, object_store::PutMode::Create);
242        let size_bytes = bytes.len() as u64;
243        let sha256 = req.digest.then(|| Sha256::digest(&bytes).into());
244        let result = self
245            .inner
246            .put_opts(key, PutPayload::from_bytes(bytes), PutOptions {
247                mode: req.mode,
248                ..Default::default()
249            })
250            .await?;
251        Ok(PutOutcome {
252            size_bytes,
253            sha256,
254            e_tag: result.e_tag,
255            version_id: result.version,
256            create_precondition,
257        })
258    }
259
260    async fn get(&self, key: &Path) -> Result<Bytes, ObjectStoreError> {
261        Ok(self.inner.get(key).await?.bytes().await?)
262    }
263
264    async fn get_capped(&self, key: &Path, max_bytes: u64) -> Result<Bytes, ObjectStoreError> {
265        read_capped(&self.inner, key, max_bytes).await
266    }
267
268    async fn get_range(&self, key: &Path, range: GetRange) -> Result<Bytes, ObjectStoreError> {
269        let opts = GetOptions {
270            range: Some(range),
271            ..Default::default()
272        };
273        Ok(self.inner.get_opts(key, opts).await?.bytes().await?)
274    }
275
276    async fn head(&self, key: &Path) -> Result<ObjectMeta, ObjectStoreError> {
277        Ok(self.inner.head(key).await?)
278    }
279
280    async fn list(&self, prefix: Option<Path>) -> Result<Vec<ObjectMeta>, ObjectStoreError> {
281        self.list_stream(prefix).try_collect::<Vec<_>>().await
282    }
283
284    fn list_stream(&self, prefix: Option<Path>) -> ObjectStream {
285        // `object_store`'s listing owns its paging state rather than borrowing
286        // the store, so the mapped stream is `'static` without cloning the
287        // handle into it.
288        self.inner
289            .list(prefix.as_ref())
290            .map(|result| result.map_err(ObjectStoreError::from))
291            .boxed()
292    }
293
294    async fn delete(&self, key: &Path) -> Result<(), ObjectStoreError> {
295        self.inner.delete(key).await?;
296        Ok(())
297    }
298}