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
32const CONCURRENT_CHUNK_UPLOADS: usize = 64;
34
35#[derive(Clone)]
72pub struct ObjectStoreBlobService {
73 instance_name: String,
74 object_store: Arc<dyn ObjectStore>,
75 base_path: Path,
76
77 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 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 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 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 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 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 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 let blob_data = get_result.bytes().await?;
214 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 match self
232 .object_store
233 .head(&derive_chunk_path(&self.base_path, digest))
234 .await
235 {
236 Ok(_) => {
237 debug!("found a single chunk");
239 Ok(Some(vec![]))
240 }
241 Err(object_store::Error::NotFound { .. }) => {
242 debug!("not found");
244 Ok(None)
245 }
246 Err(e) => Err(e.into()),
248 }
249 }
250 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 fn try_from(url: url::Url) -> Result<Self, Self::Error> {
277 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 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 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 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#[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 let mut hasher = blake3::Hasher::new();
369 let mut b3_r = InspectReader::new(r, |data| {
370 hasher.update(data);
371 });
372 let mut chunker =
374 AsyncStreamCDC::new(&mut b3_r, min_chunk_size, avg_chunk_size, max_chunk_size);
375
376 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 vec![]
396 } else {
397 chunks
398 };
399
400 let stat_blob_response = StatBlobResponse {
401 chunks,
402 bao: "".into(), };
404
405 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 Ok(_) => {
412 trace!(
413 blob.digest = %blob_digest,
414 blob.path = %blob_path,
415 "blob already exists on backend"
416 );
417 }
418 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 Err(err)?
432 }
433 }
434
435 Ok(blob_digest)
436}
437
438#[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 Ok(_) => {
450 debug!("chunk already exists");
451 }
452
453 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 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 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 let fut = this.fut.as_pin_mut().expect("not future");
511 let fut_p = fut.poll(cx);
512 if fut_p.is_ready() {
516 return Poll::Ready(Err(io::Error::other("upload failed")));
517 }
518
519 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 let fut = this.fut.as_pin_mut().expect("not future");
533 let fut_p = fut.poll(cx);
534 if fut_p.is_ready() {
538 return Poll::Ready(Err(io::Error::other("upload failed")));
539 }
540
541 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 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 writer.shutdown().await?;
569
570 let fut = self.fut.take().expect("fut must be some");
572 let resp = pin!(fut).await;
574
575 match resp.as_ref() {
576 Ok(b3_digest) => {
579 self.fut_output = Some(Ok(*b3_digest));
580 }
581 Err(e) => {
582 self.fut_output = Some(Err(std::io::Error::new(e.kind(), e.to_string())))
586 }
587 }
588 resp
589 }
590 None => {
591 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 #[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 assert!(blobsvc.has(blob_digest).await.unwrap());
645
646 let chunks = blobsvc.chunks(blob_digest).await.unwrap().unwrap();
648 if blob.len() < 1024 / 2 {
649 assert!(chunks.is_empty());
651 } else if blob.len() > 1024 * 2 {
652 assert!(chunks.len() >= 2);
655 }
656 }
657}