1use 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 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 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}