Skip to main content

snix_castore/blobservice/object_store/
mod.rs

1use std::{
2    collections::{HashMap, hash_map},
3    io::{self, Cursor},
4    pin::pin,
5    sync::Arc,
6    task::Poll,
7};
8
9use data_encoding::HEXLOWER;
10use fastcdc::v2020::AsyncStreamCDC;
11use futures::{Future, TryStreamExt};
12use object_store::{ObjectStore, ObjectStoreExt, ObjectStoreScheme, path::Path};
13use pin_project_lite::pin_project;
14use prost::Message;
15use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
16use tokio_util::io::InspectReader;
17use tonic::async_trait;
18use tracing::{Level, debug, instrument, trace};
19use url::Url;
20
21use crate::{
22    B3Digest,
23    composition::{CompositionContext, ServiceBuilder},
24    proto::{StatBlobResponse, stat_blob_response::ChunkMeta},
25};
26
27use super::{BlobReader, BlobService, BlobWriter, ChunkedReader};
28
29#[cfg(feature = "cloud")]
30mod aws;
31
32/// The number of chunks that will be uploaded in parallel, per blob.
33const CONCURRENT_CHUNK_UPLOADS: usize = 64;
34
35/// Uses any object storage supported by the [object_store] crate to provide a
36/// snix-castore [BlobService].
37///
38/// # Data format
39/// Data is organized in "blobs" and "chunks".
40/// Blobs don't hold the actual data, but instead contain a list of more
41/// granular chunks that assemble to the contents requested.
42/// This allows clients to seek, and not download chunks they already have
43/// locally, as it's referred to from other files.
44/// Check `rpc_blobstore` and more general BlobStore docs on that.
45///
46/// ## Blobs
47/// Stored at `${base_path}/blobs/b3/$digest_key`. They contains the serialized
48/// StatBlobResponse for the blob with the digest.
49///
50/// ## Chunks
51/// Chunks are stored at `${base_path}/chunks/b3/$digest_key`. They contain
52/// the literal contents of the chunk, but are zstd-compressed.
53///
54/// ## Digest key sharding
55/// The blake3 digest encoded in lower hex, and sharded after the second
56/// character.
57/// The blob for "Hello World" is stored at
58/// `${base_path}/blobs/b3/41/41f8394111eb713a22165c46c90ab8f0fd9399c92028fd6d288944b23ff5bf76`.
59///
60/// This reduces the number of files in the same directory, which would be a
61/// problem at least when using [object_store::local::LocalFileSystem].
62///
63/// # Future changes
64/// There's no guarantees about this being a final format yet.
65/// Once object_store gets support for additional metadata / content-types,
66/// we can eliminate some requests (small blobs only consisting of a single
67/// chunk can be stored as-is, without the blob index file).
68/// It also allows signalling any compression of chunks in the content-type.
69/// Migration *should* be possible by simply adding the right content-types to
70/// all keys stored so far, but no promises ;-)
71#[derive(Clone)]
72pub struct ObjectStoreBlobService {
73    instance_name: String,
74    object_store: Arc<dyn ObjectStore>,
75    base_path: Path,
76
77    /// Average chunk size for FastCDC, in bytes.
78    /// min value is half, max value double of that number.
79    avg_chunk_size: u32,
80}
81
82#[instrument(level=Level::TRACE, skip_all,fields(base_path=%base_path,blob.digest=%digest),ret(Display))]
83fn derive_blob_path(base_path: &Path, digest: &B3Digest) -> Path {
84    base_path
85        .clone()
86        .join("blobs")
87        .join("b3")
88        .join(HEXLOWER.encode(&digest[..2]))
89        .join(HEXLOWER.encode(&digest[..]))
90}
91
92#[instrument(level=Level::TRACE, skip_all,fields(base_path=%base_path,chunk.digest=%digest),ret(Display))]
93fn derive_chunk_path(base_path: &Path, digest: &B3Digest) -> Path {
94    base_path
95        .clone()
96        .join("chunks")
97        .join("b3")
98        .join(HEXLOWER.encode(&digest[..2]))
99        .join(HEXLOWER.encode(&digest[..]))
100}
101
102#[async_trait]
103impl BlobService for ObjectStoreBlobService {
104    #[instrument(skip_all, ret(level = Level::TRACE), err, fields(blob.digest=%digest, instance_name=%self.instance_name))]
105    async fn has(&self, digest: &B3Digest) -> io::Result<bool> {
106        // TODO: clarify if this should work for chunks or not, and explicitly
107        // document in the proto docs.
108        let p = derive_blob_path(&self.base_path, digest);
109
110        match self.object_store.head(&p).await {
111            Ok(_) => Ok(true),
112            Err(object_store::Error::NotFound { .. }) => {
113                let p = derive_chunk_path(&self.base_path, digest);
114                match self.object_store.head(&p).await {
115                    Ok(_) => Ok(true),
116                    Err(object_store::Error::NotFound { .. }) => Ok(false),
117                    Err(e) => Err(e)?,
118                }
119            }
120            Err(e) => Err(e)?,
121        }
122    }
123
124    #[instrument(skip_all, err, fields(blob.digest=%digest, instance_name=%self.instance_name))]
125    async fn open_read(&self, digest: &B3Digest) -> io::Result<Option<Box<dyn BlobReader>>> {
126        // handle reading the empty blob.
127        if digest.as_slice() == blake3::hash(b"").as_bytes() {
128            return Ok(Some(Box::new(Cursor::new(b"")) as Box<dyn BlobReader>));
129        }
130        match self
131            .object_store
132            .get(&derive_chunk_path(&self.base_path, digest))
133            .await
134        {
135            Ok(res) => {
136                // handle reading blobs that are small enough to fit inside a single chunk:
137                // fetch the entire chunk into memory, decompress, ensure the b3 digest matches,
138                // and return a io::Cursor over that data.
139                // FUTUREWORK: use zstd::bulk to prevent decompression bombs
140
141                let chunk_raw_bytes = res.bytes().await?;
142                let chunk_contents = zstd::stream::decode_all(Cursor::new(chunk_raw_bytes))?;
143
144                if *digest != blake3::hash(&chunk_contents).as_bytes().into() {
145                    Err(io::Error::other("chunk contents invalid"))?;
146                }
147
148                Ok(Some(Box::new(Cursor::new(chunk_contents))))
149            }
150            Err(object_store::Error::NotFound { .. }) => {
151                // NOTE: For public-facing things, we would want to stop here.
152                // Clients should fetch granularly, so they can make use of
153                // chunks they have locally.
154                // However, if this is used directly, without any caches, do the
155                // assembly here.
156                // This is subject to change, once we have store composition.
157                // TODO: make this configurable, and/or clarify behaviour for
158                // the gRPC server surface (explicitly document behaviour in the
159                // proto docs)
160                if let Some(chunks) = self.chunks(digest).await? {
161                    let chunked_reader = ChunkedReader::from_chunks(
162                        chunks.into_iter().map(|chunk| {
163                            (
164                                chunk.digest.try_into().expect("invalid b3 digest"),
165                                chunk.size,
166                            )
167                        }),
168                        Arc::new(self.clone()) as Arc<dyn BlobService>,
169                    );
170
171                    Ok(Some(Box::new(chunked_reader)))
172                } else {
173                    // This is neither a chunk nor a blob, return None.
174                    Ok(None)
175                }
176            }
177            Err(e) => Err(e.into()),
178        }
179    }
180
181    #[instrument(skip_all, fields(instance_name=%self.instance_name))]
182    async fn open_write(&self) -> Box<dyn BlobWriter> {
183        // ObjectStoreBlobWriter implements AsyncWrite, but all the chunking
184        // needs an AsyncRead, so we create a pipe here.
185        // In its `AsyncWrite` implementation, `ObjectStoreBlobWriter` delegates
186        // writes to w. It periodically polls the future that's reading from the
187        // other side.
188        let (w, r) = tokio::io::duplex(self.avg_chunk_size as usize * 10);
189
190        Box::new(ObjectStoreBlobWriter {
191            writer: Some(w),
192            fut: Some(Box::pin(chunk_and_upload(
193                r,
194                self.object_store.clone(),
195                self.base_path.clone(),
196                self.avg_chunk_size / 2,
197                self.avg_chunk_size,
198                self.avg_chunk_size * 2,
199            ))),
200            fut_output: None,
201        })
202    }
203
204    #[instrument(skip_all, err, fields(blob.digest=%digest, instance_name=%self.instance_name))]
205    async fn chunks(&self, digest: &B3Digest) -> io::Result<Option<Vec<ChunkMeta>>> {
206        match self
207            .object_store
208            .get(&derive_blob_path(&self.base_path, digest))
209            .await
210        {
211            Ok(get_result) => {
212                // fetch the data at the blob path
213                let blob_data = get_result.bytes().await?;
214                // parse into StatBlobResponse
215                let stat_blob_response: StatBlobResponse = StatBlobResponse::decode(blob_data)?;
216
217                debug!(
218                    chunk.count = stat_blob_response.chunks.len(),
219                    blob.size = stat_blob_response
220                        .chunks
221                        .iter()
222                        .map(|x| x.size)
223                        .sum::<u64>(),
224                    "found more granular chunks"
225                );
226
227                Ok(Some(stat_blob_response.chunks))
228            }
229            Err(object_store::Error::NotFound { .. }) => {
230                // If there's only a chunk, we must return the empty vec here, rather than None.
231                match self
232                    .object_store
233                    .head(&derive_chunk_path(&self.base_path, digest))
234                    .await
235                {
236                    Ok(_) => {
237                        // present, but no more chunks available
238                        debug!("found a single chunk");
239                        Ok(Some(vec![]))
240                    }
241                    Err(object_store::Error::NotFound { .. }) => {
242                        // Neither blob nor single chunk found
243                        debug!("not found");
244                        Ok(None)
245                    }
246                    // error checking for chunk
247                    Err(e) => Err(e.into()),
248                }
249            }
250            // error checking for blob
251            Err(err) => Err(err.into()),
252        }
253    }
254}
255
256fn default_avg_chunk_size() -> u32 {
257    256 * 1024
258}
259
260#[derive(serde::Deserialize)]
261#[serde(deny_unknown_fields)]
262pub struct ObjectStoreBlobServiceConfig {
263    object_store_url: String,
264    #[serde(default = "default_avg_chunk_size")]
265    avg_chunk_size: u32,
266    object_store_options: HashMap<String, String>,
267}
268
269impl TryFrom<url::Url> for ObjectStoreBlobServiceConfig {
270    type Error = Box<dyn std::error::Error + Send + Sync>;
271    /// Constructs a new [ObjectStoreBlobService] from a [Url] supported by
272    /// [object_store].
273    /// Any path suffix becomes the base path of the object store.
274    /// additional options, the same as in [object_store::parse_url_opts] can
275    /// be passed.
276    fn try_from(url: url::Url) -> Result<Self, Self::Error> {
277        // We need to convert the URL to string, strip the prefix there, and then
278        // parse it back as url, as Url::set_scheme() rejects some of the transitions we want to do.
279        let trimmed_url = {
280            let s = url.to_string();
281            let mut url = Url::parse(
282                s.strip_prefix("objectstore+")
283                    .ok_or("Missing objectstore uri")?,
284            )?;
285            // trim the query pairs, they might contain credentials or local settings we don't want to send as-is.
286            url.set_query(None);
287            url
288        };
289        Ok(ObjectStoreBlobServiceConfig {
290            object_store_url: trimmed_url.into(),
291            object_store_options: url
292                .query_pairs()
293                .into_iter()
294                .map(|(k, v)| (k.to_string(), v.to_string()))
295                .collect(),
296            avg_chunk_size: 256 * 1024,
297        })
298    }
299}
300
301#[async_trait]
302impl ServiceBuilder for ObjectStoreBlobServiceConfig {
303    type Output = dyn BlobService;
304    async fn build<'a>(
305        &'a self,
306        instance_name: &str,
307        _context: &CompositionContext,
308    ) -> Result<Arc<Self::Output>, Box<dyn std::error::Error + Send + Sync>> {
309        let opts = {
310            let mut opts: HashMap<&str, _> = self
311                .object_store_options
312                .iter()
313                .map(|(k, v)| (k.as_str(), v.as_str()))
314                .collect();
315
316            if let hash_map::Entry::Vacant(e) =
317                opts.entry(object_store::ClientConfigKey::UserAgent.as_ref())
318            {
319                e.insert(crate::USER_AGENT);
320            }
321
322            opts
323        };
324
325        // object_store doesn't sufficiently support the AWS credential chain.
326        let object_store_url: url::Url = self.object_store_url.parse()?;
327        let (object_store_scheme, path) =
328            object_store::ObjectStoreScheme::parse(&object_store_url)?;
329
330        let (object_store, path) = match object_store_scheme {
331            #[cfg(feature = "cloud")]
332            ObjectStoreScheme::AmazonS3 => {
333                // In the AWS case, we only support s3:// URLs.
334                if object_store_url.scheme() != "s3" {
335                    return Err(Box::new(std::io::Error::new(
336                        std::io::ErrorKind::InvalidInput,
337                        "only s3://-style URLs supported",
338                    )));
339                }
340
341                let store = aws::setup_aws_object_store(&object_store_url, opts).await?;
342                (Box::new(store) as Box<dyn ObjectStore>, path)
343            }
344            _ => object_store::parse_url_opts(&object_store_url, opts)?,
345        };
346
347        Ok(Arc::new(ObjectStoreBlobService {
348            instance_name: instance_name.to_string(),
349            object_store: Arc::new(object_store),
350            base_path: path,
351            avg_chunk_size: self.avg_chunk_size,
352        }))
353    }
354}
355
356/// Reads blob contents from a AsyncRead, chunks and uploads them.
357/// On success, returns a [StatBlobResponse] pointing to the individual chunks.
358#[instrument(skip_all, fields(base_path=%base_path, min_chunk_size, avg_chunk_size, max_chunk_size), err)]
359async fn chunk_and_upload<R: AsyncRead + Unpin>(
360    r: R,
361    object_store: Arc<dyn ObjectStore>,
362    base_path: Path,
363    min_chunk_size: u32,
364    avg_chunk_size: u32,
365    max_chunk_size: u32,
366) -> io::Result<B3Digest> {
367    // wrap reader with something calculating the blake3 hash of all data read.
368    let mut hasher = blake3::Hasher::new();
369    let mut b3_r = InspectReader::new(r, |data| {
370        hasher.update(data);
371    });
372    // set up a fastcdc chunker
373    let mut chunker =
374        AsyncStreamCDC::new(&mut b3_r, min_chunk_size, avg_chunk_size, max_chunk_size);
375
376    // Use the fastcdc chunker to produce a stream of chunks, and upload these
377    // that don't exist to the backend.
378    let chunks = chunker
379        .as_stream()
380        .err_into()
381        .map_ok(|chunk_data| {
382            let object_store = object_store.clone();
383            let chunk_digest: B3Digest = blake3::hash(&chunk_data.data).as_bytes().into();
384            let chunk_path = derive_chunk_path(&base_path, &chunk_digest);
385            upload_chunk(object_store, chunk_digest, chunk_path, chunk_data.data)
386        })
387        .try_buffered(CONCURRENT_CHUNK_UPLOADS)
388        .try_collect::<Vec<ChunkMeta>>()
389        .await?;
390
391    let chunks = if chunks.len() < 2 {
392        // The chunker returned only one chunk, which is the entire blob.
393        // According to the protocol, we must return an empty list of chunks
394        // when the blob is not split up further.
395        vec![]
396    } else {
397        chunks
398    };
399
400    let stat_blob_response = StatBlobResponse {
401        chunks,
402        bao: "".into(), // still todo
403    };
404
405    // check for Blob, if it doesn't exist, persist.
406    let blob_digest: B3Digest = hasher.finalize().into();
407    let blob_path = derive_blob_path(&base_path, &blob_digest);
408
409    match object_store.head(&blob_path).await {
410        // blob already exists, nothing to do
411        Ok(_) => {
412            trace!(
413                blob.digest = %blob_digest,
414                blob.path = %blob_path,
415                "blob already exists on backend"
416            );
417        }
418        // chunk does not yet exist, upload first
419        Err(object_store::Error::NotFound { .. }) => {
420            debug!(
421                blob.digest = %blob_digest,
422                blob.path = %blob_path,
423                "uploading blob"
424            );
425            object_store
426                .put(&blob_path, stat_blob_response.encode_to_vec().into())
427                .await?;
428        }
429        Err(err) => {
430            // other error
431            Err(err)?
432        }
433    }
434
435    Ok(blob_digest)
436}
437
438/// upload chunk if it doesn't exist yet.
439#[instrument(skip_all, fields(chunk.digest = %chunk_digest, chunk.size = chunk_data.len(), chunk.path = %chunk_path), err)]
440async fn upload_chunk(
441    object_store: Arc<dyn ObjectStore>,
442    chunk_digest: B3Digest,
443    chunk_path: Path,
444    chunk_data: Vec<u8>,
445) -> std::io::Result<ChunkMeta> {
446    let chunk_size = chunk_data.len();
447    match object_store.head(&chunk_path).await {
448        // chunk already exists, nothing to do
449        Ok(_) => {
450            debug!("chunk already exists");
451        }
452
453        // chunk does not yet exist, compress and upload.
454        Err(object_store::Error::NotFound { .. }) => {
455            let chunk_data_compressed =
456                zstd::encode_all(Cursor::new(chunk_data), zstd::DEFAULT_COMPRESSION_LEVEL)?;
457
458            debug!(chunk.compressed_size=%chunk_data_compressed.len(), "uploading chunk");
459
460            object_store
461                .as_ref()
462                .put(&chunk_path, chunk_data_compressed.into())
463                .await?;
464        }
465        // other error
466        Err(err) => Err(err)?,
467    }
468
469    Ok(ChunkMeta {
470        digest: chunk_digest.into(),
471        size: chunk_size as u64,
472    })
473}
474
475pin_project! {
476    /// Takes care of blob uploads.
477    /// All writes are relayed to self.writer, and we continuously poll the
478    /// future (which will internally read from the other side of the pipe and
479    /// upload chunks).
480    /// Our BlobWriter::close() needs to drop self.writer, so the other side
481    /// will read EOF and can finalize the blob.
482    /// The future should then resolve and return the blob digest.
483    pub struct ObjectStoreBlobWriter<W, Fut>
484    where
485        W: AsyncWrite,
486        Fut: Future,
487    {
488        #[pin]
489        writer: Option<W>,
490
491        #[pin]
492        fut: Option<Fut>,
493
494        fut_output: Option<io::Result<B3Digest>>
495    }
496}
497
498impl<W, Fut> tokio::io::AsyncWrite for ObjectStoreBlobWriter<W, Fut>
499where
500    W: AsyncWrite + Send + Unpin,
501    Fut: Future,
502{
503    fn poll_write(
504        self: std::pin::Pin<&mut Self>,
505        cx: &mut std::task::Context<'_>,
506        buf: &[u8],
507    ) -> std::task::Poll<Result<usize, io::Error>> {
508        let this = self.project();
509        // poll the future.
510        let fut = this.fut.as_pin_mut().expect("not future");
511        let fut_p = fut.poll(cx);
512        // if it's ready, the only way this could have happened is that the
513        // upload failed, because we're only closing `self.writer` after all
514        // writes happened.
515        if fut_p.is_ready() {
516            return Poll::Ready(Err(io::Error::other("upload failed")));
517        }
518
519        // write to the underlying writer
520        this.writer
521            .as_pin_mut()
522            .expect("writer must be some")
523            .poll_write(cx, buf)
524    }
525
526    fn poll_flush(
527        self: std::pin::Pin<&mut Self>,
528        cx: &mut std::task::Context<'_>,
529    ) -> std::task::Poll<Result<(), io::Error>> {
530        let this = self.project();
531        // poll the future.
532        let fut = this.fut.as_pin_mut().expect("not future");
533        let fut_p = fut.poll(cx);
534        // if it's ready, the only way this could have happened is that the
535        // upload failed, because we're only closing `self.writer` after all
536        // writes happened.
537        if fut_p.is_ready() {
538            return Poll::Ready(Err(io::Error::other("upload failed")));
539        }
540
541        // Call poll_flush on the writer
542        this.writer
543            .as_pin_mut()
544            .expect("writer must be some")
545            .poll_flush(cx)
546    }
547
548    fn poll_shutdown(
549        self: std::pin::Pin<&mut Self>,
550        _cx: &mut std::task::Context<'_>,
551    ) -> std::task::Poll<Result<(), io::Error>> {
552        // There's nothing to do on shutdown. We might have written some chunks
553        // that are nowhere else referenced, but cleaning them up here would be racy.
554        std::task::Poll::Ready(Ok(()))
555    }
556}
557
558#[async_trait]
559impl<W, Fut> BlobWriter for ObjectStoreBlobWriter<W, Fut>
560where
561    W: AsyncWrite + Send + Unpin,
562    Fut: Future<Output = io::Result<B3Digest>> + Send + Unpin,
563{
564    async fn close(&mut self) -> io::Result<B3Digest> {
565        match self.writer.take() {
566            Some(mut writer) => {
567                // shut down the writer, so the other side will read EOF.
568                writer.shutdown().await?;
569
570                // take out the future.
571                let fut = self.fut.take().expect("fut must be some");
572                // await it.
573                let resp = pin!(fut).await;
574
575                match resp.as_ref() {
576                    // In the case of an Ok value, we store it in self.fut_output,
577                    // so future calls to close can return that.
578                    Ok(b3_digest) => {
579                        self.fut_output = Some(Ok(*b3_digest));
580                    }
581                    Err(e) => {
582                        // for the error type, we need to cheat a bit, as
583                        // they're not clone-able.
584                        // Simply store a sloppy clone, with the same ErrorKind and message there.
585                        self.fut_output = Some(Err(std::io::Error::new(e.kind(), e.to_string())))
586                    }
587                }
588                resp
589            }
590            None => {
591                // called a second time, return self.fut_output.
592                match self.fut_output.as_ref().unwrap() {
593                    Ok(b3_digest) => Ok(*b3_digest),
594                    Err(e) => Err(std::io::Error::new(e.kind(), e.to_string())),
595                }
596            }
597        }
598    }
599}
600
601#[cfg(test)]
602mod test {
603    use super::{chunk_and_upload, default_avg_chunk_size};
604    use crate::{
605        blobservice::{BlobService, ObjectStoreBlobService},
606        fixtures::{BLOB_A, BLOB_A_DIGEST, BLOB_B, BLOB_B_DIGEST},
607    };
608    use std::{io::Cursor, sync::Arc};
609    use url::Url;
610
611    /// Tests chunk_and_upload directly, bypassing the BlobWriter at open_write().
612    #[rstest::rstest]
613    #[case::a(&BLOB_A, &BLOB_A_DIGEST)]
614    #[case::b(&BLOB_B, &BLOB_B_DIGEST)]
615    #[tokio::test]
616    async fn test_chunk_and_upload(
617        #[case] blob: &bytes::Bytes,
618        #[case] blob_digest: &crate::B3Digest,
619    ) {
620        let (object_store, base_path) =
621            object_store::parse_url(&Url::parse("memory:///").unwrap()).unwrap();
622        let object_store: Arc<dyn object_store::ObjectStore> = Arc::from(object_store);
623        let blobsvc = Arc::new(ObjectStoreBlobService {
624            instance_name: "test".into(),
625            object_store: object_store.clone(),
626            avg_chunk_size: default_avg_chunk_size(),
627            base_path,
628        });
629
630        let inserted_blob_digest = chunk_and_upload(
631            &mut Cursor::new(blob.to_vec()),
632            object_store,
633            object_store::path::Path::from("/"),
634            1024 / 2,
635            1024,
636            1024 * 2,
637        )
638        .await
639        .expect("chunk_and_upload succeeds");
640
641        assert_eq!(blob_digest.clone(), inserted_blob_digest);
642
643        // Now we should have the blob
644        assert!(blobsvc.has(blob_digest).await.unwrap());
645
646        // Check if it was chunked correctly
647        let chunks = blobsvc.chunks(blob_digest).await.unwrap().unwrap();
648        if blob.len() < 1024 / 2 {
649            // The blob is smaller than the min chunk size, it should have been inserted as a whole
650            assert!(chunks.is_empty());
651        } else if blob.len() > 1024 * 2 {
652            // The blob is larger than the max chunk size, make sure it was split up into at least
653            // two chunks
654            assert!(chunks.len() >= 2);
655        }
656    }
657}