Skip to main content

snix_castore/blob_engine/
concurrent_uploads.rs

1//! Concurrent blob uploads.
2
3use std::{
4    io::{Cursor, Write},
5    sync::Arc,
6};
7
8use tokio::{
9    io::AsyncRead,
10    sync::Semaphore,
11    task::{JoinError, JoinSet},
12};
13use tokio_util::io::InspectReader;
14use tracing::{Instrument, Level, instrument};
15
16use crate::{
17    B3Digest, Path, PathBuf,
18    blob_engine::{self, BlobEngine},
19};
20
21/// Files smaller than this threshold, in bytes, are uploaded to the [BlobEngine] in the
22/// background.
23///
24/// This is a u32 since we acquire a weighted semaphore using the size of the blob.
25/// [Semaphore::acquire_many_owned] takes a u32, so we need to ensure the size of
26/// the blob can be represented using a u32 and will not cause an overflow.
27const CONCURRENT_BLOB_UPLOAD_THRESHOLD: u32 = 1024 * 1024;
28
29/// The maximum amount of bytes allowed to be buffered in memory to perform async blob uploads.
30const MAX_BUFFER_SIZE: usize = 128 * 1024 * 1024;
31
32#[derive(Debug, thiserror::Error)]
33pub enum Error {
34    #[error("unable to read blob contents for {0}: {1}")]
35    BlobRead(PathBuf, std::io::Error),
36
37    #[error("unable to check whether blob at {0} already exists: {1}")]
38    BlobCheck(PathBuf, blob_engine::Error),
39
40    // FUTUREWORK: proper error for blob finalize
41    #[error("unable to finalize blob {0}: {1}")]
42    BlobFinalize(PathBuf, std::io::Error),
43
44    #[error("unexpected size for {path} wanted: {wanted} got: {got}")]
45    UnexpectedSize {
46        path: PathBuf,
47        wanted: u64,
48        got: u64,
49    },
50
51    #[error("blob upload join error: {0}")]
52    JoinError(#[from] JoinError),
53}
54
55/// The concurrent blob uploader provides a mechanism for concurrently uploading small blobs.
56/// This is useful when ingesting from sources like tarballs and archives which each blob entry
57/// must be read sequentially. Ingesting many small blobs sequentially becomes slow due to
58/// round trip time with the [BlobEngine].
59/// The concurrent blob uploader will buffer small blobs in memory and upload
60/// them to the [BlobEngine] in the background.
61///
62/// Once all blobs have been uploaded, make sure to call [ConcurrentBlobUploader::join] to wait
63/// for all background jobs to complete and check for any errors.
64pub struct ConcurrentBlobUploader<BE> {
65    blob_engine: BE,
66    upload_tasks: JoinSet<Result<(), Error>>,
67    upload_semaphore: Arc<Semaphore>,
68}
69
70impl<BE> ConcurrentBlobUploader<BE>
71where
72    BE: BlobEngine + Clone + 'static,
73{
74    /// Creates a new concurrent blob uploader which uploads blobs to the provided
75    /// [BlobEngine].
76    pub fn new(blob_engine: BE) -> Self {
77        Self {
78            blob_engine,
79            upload_tasks: JoinSet::new(),
80            upload_semaphore: Arc::new(Semaphore::new(MAX_BUFFER_SIZE)),
81        }
82    }
83
84    /// Uploads a blob to the [BlobEngine]. If the blob is small enough it will be read to a buffer
85    /// and uploaded in the background.
86    /// This will read the entirety of the provided reader unless an error occurs, even if blobs
87    /// are uploaded in the background..
88    #[instrument(skip_all, fields(nar.path=%path, blob.size=expected_size), err, ret(level = Level::TRACE, Display))]
89    pub async fn upload<R>(
90        &mut self,
91        path: &Path,
92        expected_size: u64,
93        mut r: R,
94    ) -> Result<B3Digest, Error>
95    where
96        R: AsyncRead + Unpin,
97    {
98        if expected_size < CONCURRENT_BLOB_UPLOAD_THRESHOLD as u64 {
99            let mut buffer = Vec::with_capacity(expected_size as usize);
100            let mut hasher = blake3::Hasher::new();
101            let mut reader = InspectReader::new(&mut r, |bytes| {
102                hasher.write_all(bytes).unwrap();
103            });
104
105            let permit = self
106                .upload_semaphore
107                .clone()
108                // This cast is safe because ensure the header_size is less than
109                // CONCURRENT_BLOB_UPLOAD_THRESHOLD which is a u32.
110                .acquire_many_owned(expected_size as u32)
111                .await
112                .unwrap();
113            let size = tokio::io::copy(&mut reader, &mut buffer)
114                .await
115                .map_err(|e| Error::BlobRead(path.into(), e))?;
116            let digest: B3Digest = hasher.finalize().as_bytes().into();
117
118            if size != expected_size {
119                return Err(Error::UnexpectedSize {
120                    path: path.into(),
121                    wanted: expected_size,
122                    got: size,
123                });
124            }
125
126            self.upload_tasks.spawn({
127                let blob_engine = self.blob_engine.clone();
128                let path = path.to_owned();
129                let r = Cursor::new(buffer);
130                async move {
131                    // We know the blob digest already, check it exists before sending it.
132                    if blob_engine
133                        .has(&digest)
134                        .await
135                        .map_err(|e| Error::BlobCheck(path.clone(), e))?
136                    {
137                        drop(permit);
138                        return Ok(());
139                    }
140
141                    let uploaded_digest =
142                        upload_blob(&blob_engine, &path, expected_size, r).await?;
143
144                    assert_eq!(uploaded_digest, digest, "Snix bug: blob digest mismatch");
145
146                    // Make sure we hold the permit until we finish writing the blob
147                    // to the [BlobEngine].
148                    drop(permit);
149                    Ok(())
150                }
151                .in_current_span()
152            });
153
154            return Ok(digest);
155        }
156
157        upload_blob(&self.blob_engine, path, expected_size, r).await
158    }
159
160    /// Waits for all background upload jobs to complete, returning any upload errors.
161    pub async fn join(mut self) -> Result<(), Error> {
162        while let Some(result) = self.upload_tasks.join_next().await {
163            result??;
164        }
165        Ok(())
166    }
167}
168
169async fn upload_blob<BE, R>(
170    blob_engine: &BE,
171    path: &Path,
172    expected_size: u64,
173    mut r: R,
174) -> Result<B3Digest, Error>
175where
176    BE: BlobEngine,
177    R: AsyncRead + Unpin,
178{
179    let mut writer = blob_engine.open_write().await;
180
181    let size = tokio::io::copy(&mut r, &mut writer)
182        .await
183        .map_err(|e| Error::BlobRead(path.into(), e))?;
184
185    let digest = writer
186        .close()
187        .await
188        .map_err(|e| Error::BlobFinalize(path.into(), e))?;
189
190    if size != expected_size {
191        return Err(Error::UnexpectedSize {
192            path: path.into(),
193            wanted: expected_size,
194            got: size,
195        });
196    }
197
198    Ok(digest)
199}