Skip to main content

s3_wire/client/
multipart_upload.rs

1//! Bounded orchestration for managed multipart uploads.
2
3use std::future::Future;
4use std::io;
5use std::path::PathBuf;
6use std::pin::Pin;
7use std::sync::Arc;
8use std::task::{Context, Poll};
9
10use bytes::{Bytes, BytesMut};
11use futures_util::stream::{FuturesUnordered, StreamExt as _};
12use tokio::fs::File;
13use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
14use tokio::task::JoinHandle;
15use tokio_util::sync::CancellationToken;
16
17use super::S3Client;
18use crate::error::S3Error;
19use crate::operation::{
20    AbortMultipartUploadRequest, CompleteMultipartUploadOutput, CompleteMultipartUploadRequest,
21    CompletedPart, CreateMultipartUploadRequest, ManagedMultipartUploadRequest, MultipartUpload,
22    MultipartUploadSource, ObjectKey, PartNumber, UploadId, UploadPartRequest,
23};
24use crate::stream::ByteStream;
25
26impl S3Client {
27    /// Uploads a replayable bytes or file source using bounded multipart requests.
28    ///
29    /// Part size, concurrency, and total in-flight part bytes are taken from the
30    /// client configuration. File input is snapshotted before an upload is
31    /// created. Dropping this future cancels outstanding parts and leaves an
32    /// owned cleanup task to abort the upload. A normal failure waits for abort;
33    /// if abort also fails, [`S3Error::cleanup_failure`] exposes that error while
34    /// preserving the original failure.
35    ///
36    /// # Errors
37    ///
38    /// Returns an error if the source cannot be prepared, exceeds S3's 10,000
39    /// part limit, a part fails, cancellation occurs, completion fails, or abort
40    /// cleanup fails after another error.
41    pub async fn multipart_upload(
42        &self,
43        request: ManagedMultipartUploadRequest,
44    ) -> Result<CompleteMultipartUploadOutput, S3Error> {
45        let runtime = tokio::runtime::Handle::try_current().map_err(S3Error::transport)?;
46        let cancellation = CancellationToken::new();
47        let worker_cancellation = cancellation.clone();
48        let client = self.clone();
49        let handle = runtime
50            .spawn(async move { run_multipart_upload(client, request, worker_cancellation).await });
51        OwnedMultipartTask {
52            cancellation,
53            handle,
54        }
55        .await
56    }
57}
58
59struct OwnedMultipartTask {
60    cancellation: CancellationToken,
61    handle: JoinHandle<Result<CompleteMultipartUploadOutput, S3Error>>,
62}
63
64impl Future for OwnedMultipartTask {
65    type Output = Result<CompleteMultipartUploadOutput, S3Error>;
66
67    fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
68        match Pin::new(&mut self.handle).poll(context) {
69            Poll::Ready(Ok(result)) => Poll::Ready(result),
70            Poll::Ready(Err(error)) => Poll::Ready(Err(S3Error::transport(error))),
71            Poll::Pending => Poll::Pending,
72        }
73    }
74}
75
76impl Drop for OwnedMultipartTask {
77    fn drop(&mut self) {
78        self.cancellation.cancel();
79    }
80}
81
82async fn run_multipart_upload(
83    client: S3Client,
84    request: ManagedMultipartUploadRequest,
85    cancellation: CancellationToken,
86) -> Result<CompleteMultipartUploadOutput, S3Error> {
87    let source = tokio::select! {
88        () = cancellation.cancelled() => return Err(cancelled()),
89        result = PreparedMultipartSource::prepare(request.source) => result?,
90    };
91    let part_size = client.config().multipart_part_size();
92    let part_count = source.part_count(part_size)?;
93
94    let mut create = CreateMultipartUploadRequest::new(request.key.clone());
95    create.content_type = request.content_type;
96    create.user_metadata = request.user_metadata;
97    // Once creation starts, allow it to finish even after cancellation so a
98    // successful response cannot be discarded together with the upload ID
99    // needed for cleanup.
100    let created = client.create_multipart_upload(create).await?;
101    let upload_id = created.upload_id().clone();
102
103    let result = upload_parts(
104        &client,
105        request.key.clone(),
106        upload_id.clone(),
107        Arc::new(source),
108        part_size,
109        part_count,
110        &cancellation,
111    )
112    .await
113    .and_then(|upload| {
114        CompleteMultipartUploadRequest::new(
115            upload.key().clone(),
116            upload.upload_id().clone(),
117            upload.completed_parts().to_vec(),
118        )
119        .map_err(|error| S3Error::integrity(error.to_string()))
120    });
121
122    let completion = match result {
123        Ok(completion) => {
124            tokio::select! {
125                biased;
126                result = client.complete_multipart_upload(completion) => result,
127                () = cancellation.cancelled() => Err(cancelled()),
128            }
129        }
130        Err(error) => Err(error),
131    };
132
133    match completion {
134        Ok(output) => Ok(output),
135        Err(primary) => {
136            let cleanup = client
137                .abort_multipart_upload(AbortMultipartUploadRequest::new(request.key, upload_id))
138                .await
139                .map(|_| ());
140            Err(attach_cleanup_failure(primary, cleanup))
141        }
142    }
143}
144
145async fn upload_parts(
146    client: &S3Client,
147    key: ObjectKey,
148    upload_id: UploadId,
149    source: Arc<PreparedMultipartSource>,
150    part_size: u64,
151    part_count: u16,
152    cancellation: &CancellationToken,
153) -> Result<MultipartUpload, S3Error> {
154    let concurrency = effective_concurrency(
155        client.config().multipart_concurrency(),
156        client.config().max_multipart_in_flight_bytes(),
157        part_size,
158    )?;
159    let mut next_part = 1_u16;
160    let mut pending = FuturesUnordered::new();
161    let mut upload = MultipartUpload::new(key.clone(), upload_id.clone());
162
163    loop {
164        while pending.len() < concurrency && next_part <= part_count {
165            pending.push(upload_one_part(
166                client.clone(),
167                key.clone(),
168                upload_id.clone(),
169                Arc::clone(&source),
170                part_size,
171                next_part,
172            ));
173            next_part += 1;
174        }
175        if pending.is_empty() {
176            return Ok(upload);
177        }
178        let completed = tokio::select! {
179            () = cancellation.cancelled() => return Err(cancelled()),
180            result = pending.next() => result
181                .ok_or_else(|| S3Error::integrity("multipart scheduler lost an in-flight part"))??,
182        };
183        upload
184            .record_part(completed)
185            .map_err(|error| S3Error::integrity(error.to_string()))?;
186    }
187}
188
189async fn upload_one_part(
190    client: S3Client,
191    key: ObjectKey,
192    upload_id: UploadId,
193    source: Arc<PreparedMultipartSource>,
194    part_size: u64,
195    part_number: u16,
196) -> Result<CompletedPart, S3Error> {
197    let body = source.read_part(part_size, part_number).await?;
198    let number = PartNumber::new(part_number)
199        .ok_or_else(|| S3Error::integrity("generated multipart part number is invalid"))?;
200    let output = client
201        .upload_part(UploadPartRequest::new(
202            key,
203            upload_id,
204            number,
205            ByteStream::from_bytes(body),
206        ))
207        .await?;
208    CompletedPart::new(output.part_number.get(), output.e_tag)
209        .map(|part| part.with_checksum(output.checksum))
210        .map_err(|error| S3Error::invalid_response(error.to_string()))
211}
212
213fn attach_cleanup_failure(primary: S3Error, cleanup: Result<(), S3Error>) -> S3Error {
214    match cleanup {
215        Ok(()) => primary,
216        Err(cleanup) => primary.with_cleanup_failure(cleanup),
217    }
218}
219
220fn cancelled() -> S3Error {
221    S3Error::cancellation("multipart upload was cancelled")
222}
223
224pub(crate) fn effective_concurrency(
225    configured: usize,
226    byte_budget: u64,
227    part_size: u64,
228) -> Result<usize, S3Error> {
229    let byte_slots = byte_budget / part_size;
230    let byte_slots = usize::try_from(byte_slots).unwrap_or(usize::MAX);
231    let concurrency = configured.min(byte_slots);
232    if concurrency == 0 {
233        return Err(S3Error::configuration(
234            "multipart in-flight byte budget cannot hold one part",
235        ));
236    }
237    Ok(concurrency)
238}
239
240enum PreparedMultipartSource {
241    Bytes(Bytes),
242    File {
243        path: tempfile::TempPath,
244        length: u64,
245    },
246}
247
248impl PreparedMultipartSource {
249    async fn prepare(source: MultipartUploadSource) -> Result<Self, S3Error> {
250        match source {
251            MultipartUploadSource::Bytes(bytes) => Ok(Self::Bytes(bytes)),
252            MultipartUploadSource::File(path) => Self::snapshot(path).await,
253        }
254    }
255
256    async fn snapshot(path: PathBuf) -> Result<Self, S3Error> {
257        let mut input = File::open(path).await.map_err(S3Error::transport)?;
258        let metadata = input.metadata().await.map_err(S3Error::transport)?;
259        if !metadata.is_file() {
260            return Err(S3Error::configuration(
261                "multipart upload path must identify a regular file",
262            ));
263        }
264        let snapshot = tempfile::NamedTempFile::new().map_err(S3Error::transport)?;
265        let (snapshot_file, snapshot_path) = snapshot.into_parts();
266        let mut output = File::from_std(snapshot_file);
267        let length = tokio::io::copy(&mut input, &mut output)
268            .await
269            .map_err(S3Error::transport)?;
270        output.flush().await.map_err(S3Error::transport)?;
271        drop(output);
272        let mut permissions = std::fs::metadata(&snapshot_path)
273            .map_err(S3Error::transport)?
274            .permissions();
275        permissions.set_readonly(true);
276        std::fs::set_permissions(&snapshot_path, permissions).map_err(S3Error::transport)?;
277        let actual = tokio::fs::metadata(&snapshot_path)
278            .await
279            .map_err(S3Error::transport)?
280            .len();
281        if actual != length {
282            return Err(S3Error::integrity(
283                "multipart source snapshot length changed while preparing",
284            ));
285        }
286        Ok(Self::File {
287            path: snapshot_path,
288            length,
289        })
290    }
291
292    fn length(&self) -> Result<u64, S3Error> {
293        match self {
294            Self::Bytes(bytes) => u64::try_from(bytes.len())
295                .map_err(|_| S3Error::configuration("multipart byte length does not fit in u64")),
296            Self::File { length, .. } => Ok(*length),
297        }
298    }
299
300    fn part_count(&self, part_size: u64) -> Result<u16, S3Error> {
301        let length = self.length()?;
302        if length == 0 {
303            return Err(S3Error::configuration(
304                "managed multipart uploads require a non-empty source",
305            ));
306        }
307        let count = length.div_ceil(part_size);
308        if count > u64::from(PartNumber::MAX) {
309            return Err(S3Error::configuration(
310                "multipart source exceeds the configured 10,000-part limit",
311            ));
312        }
313        u16::try_from(count)
314            .map_err(|_| S3Error::configuration("multipart part count does not fit in u16"))
315    }
316
317    async fn read_part(&self, part_size: u64, part_number: u16) -> Result<Bytes, S3Error> {
318        let offset = u64::from(part_number - 1)
319            .checked_mul(part_size)
320            .ok_or_else(|| S3Error::integrity("multipart part offset overflow"))?;
321        let remaining = self
322            .length()?
323            .checked_sub(offset)
324            .ok_or_else(|| S3Error::integrity("multipart part offset exceeds source length"))?;
325        let length = remaining.min(part_size);
326        let length_usize = usize::try_from(length)
327            .map_err(|_| S3Error::configuration("multipart part size does not fit in usize"))?;
328        match self {
329            Self::Bytes(bytes) => {
330                let start = usize::try_from(offset).map_err(|_| {
331                    S3Error::configuration("multipart byte offset does not fit in usize")
332                })?;
333                Ok(bytes.slice(start..start + length_usize))
334            }
335            Self::File { path, .. } => {
336                use tokio::io::AsyncSeekExt as _;
337
338                let mut file = File::open(path).await.map_err(S3Error::transport)?;
339                file.seek(io::SeekFrom::Start(offset))
340                    .await
341                    .map_err(S3Error::transport)?;
342                let mut bytes = BytesMut::zeroed(length_usize);
343                file.read_exact(&mut bytes)
344                    .await
345                    .map_err(S3Error::transport)?;
346                Ok(bytes.freeze())
347            }
348        }
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::error::ErrorCategory;
356
357    #[tokio::test]
358    async fn task_drop_signals_owned_cleanup_worker() {
359        let cancellation = CancellationToken::new();
360        let worker = cancellation.clone();
361        let (finished, observed) = tokio::sync::oneshot::channel();
362        let handle = tokio::spawn(async move {
363            worker.cancelled().await;
364            let _ = finished.send(());
365            Err(cancelled())
366        });
367        drop(OwnedMultipartTask {
368            cancellation,
369            handle,
370        });
371        observed.await.unwrap();
372    }
373
374    #[test]
375    fn cleanup_failure_does_not_replace_primary_error() {
376        let primary = S3Error::integrity("primary");
377        let cleanup = S3Error::invalid_response("cleanup");
378        let combined = attach_cleanup_failure(primary, Err(cleanup));
379        assert_eq!(combined.category(), ErrorCategory::Integrity);
380        assert_eq!(combined.message(), "primary");
381        assert_eq!(
382            combined.cleanup_failure().map(S3Error::category),
383            Some(ErrorCategory::InvalidResponse)
384        );
385    }
386
387    #[tokio::test]
388    async fn byte_parts_are_exact_and_bounded() {
389        let source = PreparedMultipartSource::Bytes(Bytes::from_static(b"abcdefghij"));
390        assert_eq!(source.part_count(4).unwrap(), 3);
391        assert_eq!(source.read_part(4, 1).await.unwrap(), "abcd");
392        assert_eq!(source.read_part(4, 2).await.unwrap(), "efgh");
393        assert_eq!(source.read_part(4, 3).await.unwrap(), "ij");
394    }
395
396    #[tokio::test]
397    async fn file_source_is_snapshotted_before_parts_are_read() {
398        let directory = tempfile::tempdir().unwrap();
399        let path = directory.path().join("source");
400        tokio::fs::write(&path, b"original").await.unwrap();
401        let source = PreparedMultipartSource::snapshot(path.clone())
402            .await
403            .unwrap();
404        tokio::fs::write(path, b"changed!").await.unwrap();
405        assert_eq!(source.read_part(8, 1).await.unwrap(), "original");
406    }
407
408    #[test]
409    fn part_count_rejects_empty_and_excessive_sources() {
410        let empty = PreparedMultipartSource::Bytes(Bytes::new());
411        assert!(empty.part_count(5).is_err());
412        let too_large = PreparedMultipartSource::File {
413            path: tempfile::NamedTempFile::new().unwrap().into_temp_path(),
414            length: 10_001,
415        };
416        assert!(too_large.part_count(1).is_err());
417    }
418
419    #[test]
420    fn concurrency_is_limited_by_count_and_byte_budget() {
421        assert_eq!(effective_concurrency(8, 24, 8).unwrap(), 3);
422        assert_eq!(effective_concurrency(2, 24, 8).unwrap(), 2);
423        assert!(effective_concurrency(2, 7, 8).is_err());
424    }
425}