Skip to main content

hotdata/
uploads.rs

1//! Ergonomic, hand-written direct-to-storage (presigned) file uploads.
2//!
3//! This module is regeneration-immune: it is protected by `.openapi-generator-ignore`
4//! and is never emitted by the OpenAPI generator. It orchestrates the
5//! presigned-upload flow that the generated [`apis::uploads_api`](crate::apis::uploads_api)
6//! ops expose as raw building blocks:
7//!
8//! 1. `POST /v1/uploads` ([`create_upload_session_handler`]) opens a session.
9//!    A small file declares its size and gets a single `url` (`mode == "single"`)
10//!    or, for a known-size multipart upload, a full set of `part_urls` plus a
11//!    `part_size` (`mode == "multipart"`). A large file omits its declared size
12//!    to open a **streaming** session: `mode == "multipart"` with a `part_size`
13//!    but NO `part_urls` — the client mints each part URL on demand from
14//!    `POST /v1/uploads/{id}/parts` ([`mint_upload_parts_handler`]) just before
15//!    uploading that part, so a URL can't expire mid-transfer on a slow upload.
16//!    Every session also carries a one-time `finalize_token`.
17//! 2. The client `PUT`s the bytes **directly to object storage** — never back
18//!    through the API. Single uploads stream the whole file to `url`; multipart
19//!    uploads slice the file into `part_size`-byte chunks and `PUT` each chunk to
20//!    its part URL (pre-issued for known-size, minted on demand for streaming),
21//!    collecting the storage `ETag` per part.
22//! 3. `POST /v1/uploads/{upload_id}/finalize` ([`finalize_upload_handler`])
23//!    confirms the upload with the finalize token in the `X-Upload-Finalize-Token`
24//!    header (empty body for single; the ascending `{part_number, e_tag}` list
25//!    for multipart) and returns a [`models::FinalizeUploadResponse`].
26//!
27//! # Storage PUT header isolation
28//!
29//! A presigned storage URL already carries its authorization in the query string
30//! (or in the server-provided `headers` map). Object stores (S3 and compatible)
31//! reject a `PUT` with `403 SignatureDoesNotMatch` if it carries extra
32//! signed-ish headers, so [`put_to_storage`] sends a *bare* request: NONE of the
33//! SDK's bearer / workspace headers, only an explicit `Content-Length`
34//! and whatever the server placed in `headers` (currently always empty). It also
35//! refuses to let reqwest auto-append a charset to a `Content-Type` — a type is
36//! sent only when the server's `headers` map asks for one.
37//!
38//! No S3/AWS SDK is involved: storage `PUT`s are plain `reqwest`.
39
40use std::collections::HashMap;
41use std::path::Path;
42use std::sync::atomic::{AtomicU64, Ordering};
43use std::sync::Arc;
44use std::time::Duration;
45
46use crate::apis::configuration::Configuration;
47use crate::apis::{self, Error};
48use crate::models;
49
50/// One mebibyte, the unit the storage part-size range is expressed in.
51const MIB: u64 = 1024 * 1024;
52
53/// Default cap on concurrent part `PUT`s when the caller doesn't set
54/// [`UploadOptions::max_concurrency`]. Matches the boto3 / AWS CLI default of 10.
55/// The effective in-flight count is the MIN of this and a memory budget (see
56/// [`effective_in_flight`]).
57pub const DEFAULT_MAX_CONCURRENCY: usize = 10;
58
59/// Default part-size hint, in bytes (8 MiB), sent when the caller doesn't set
60/// [`UploadOptions::part_size`]. The server clamps the hint to its own range and
61/// returns the actual size. See [`auto_part_size_hint`].
62pub const DEFAULT_PART_SIZE: u64 = 8 * MIB;
63
64/// Target ceiling on part count when auto-scaling the part-size hint for very
65/// large files, with headroom under S3's hard 10,000-part limit. See
66/// [`auto_part_size_hint`].
67pub const TARGET_MAX_PARTS: u64 = 9000;
68
69/// Minimum part size storage accepts (5 MiB). The hint is clamped to at least
70/// this; the server enforces it too.
71pub const MIN_PART_SIZE: u64 = 5 * MIB;
72
73/// Maximum part size storage accepts (5 GiB). The hint is clamped to at most
74/// this.
75pub const MAX_PART_SIZE: u64 = 5 * 1024 * MIB;
76
77/// File-size boundary between the two upload strategies. A file at or below this
78/// size takes the known-size path — a single quick `PUT` (or a short eager
79/// multipart) that completes well within a presigned URL's TTL, so there is no
80/// expiry risk. A larger file uses the streaming just-in-time path, minting each
81/// part URL only moments before it is uploaded. Set to [`DEFAULT_PART_SIZE`],
82/// the server's default single-vs-multipart boundary, so small uploads keep the
83/// single-`PUT` fast path unchanged.
84pub const STREAMING_THRESHOLD: u64 = DEFAULT_PART_SIZE;
85
86/// Target peak-memory budget for in-flight part buffers (256 MiB). Each
87/// in-flight part buffers up to `part_size` bytes, so [`effective_in_flight`]
88/// derives the in-flight count as `budget / part_size`.
89///
90/// This is a TARGET, not a hard ceiling: it holds while `part_size` is small
91/// relative to the budget (the normal case — 8 MiB parts stay well under it). It
92/// cannot bound memory below one in-flight part, so when the server returns a
93/// very large `part_size` (e.g. a 5 GiB part on a huge file), a single in-flight
94/// part already exceeds this budget and peak memory is `1 * part_size`. In other
95/// words the budget caps *concurrency*, not the size of one part.
96pub const UPLOAD_MEMORY_BUDGET: u64 = 256 * MIB;
97
98/// Compute the part-size HINT to send to the server in
99/// `CreateUploadRequest.part_size` when the caller did not specify one.
100///
101/// Starts from [`DEFAULT_PART_SIZE`] (8 MiB) and grows only for files large
102/// enough that 8 MiB parts would exceed [`TARGET_MAX_PARTS`] — so the common
103/// case is unchanged and only very large files (beyond ~72 GiB) get a larger
104/// hint to keep the part count bounded. The result is rounded UP to a whole MiB
105/// and clamped to `[MIN_PART_SIZE, MAX_PART_SIZE]`. The server still has the
106/// final say and clamps to its own range.
107///
108/// Pure and total: `declared_size == 0` yields [`DEFAULT_PART_SIZE`].
109pub fn auto_part_size_hint(declared_size: u64) -> u64 {
110    // Smallest part size that keeps the count at or under the target.
111    let by_count = declared_size.div_ceil(TARGET_MAX_PARTS);
112    let raw = DEFAULT_PART_SIZE.max(by_count);
113    // Round up to a whole MiB so the hint is a clean multiple.
114    let rounded = raw.div_ceil(MIB) * MIB;
115    rounded.clamp(MIN_PART_SIZE, MAX_PART_SIZE)
116}
117
118/// Compute how many part `PUT`s to keep in flight, given the caller's
119/// `max_concurrency` (already defaulted to [`DEFAULT_MAX_CONCURRENCY`]) and the
120/// SERVER's actual returned `part_size`.
121///
122/// Peak buffered memory is `in_flight * part_size`, so we cap in-flight at
123/// `UPLOAD_MEMORY_BUDGET / part_size`, then at `max_concurrency`. Normal 8 MiB
124/// parts give `256/8 = 32`, capped to `max_concurrency`; a 64 MiB part gives `4`.
125///
126/// `max_concurrency` is honored as an explicit floor: a caller asking for `1`
127/// (or `0`) gets serial uploads (`1`), so the budget never *raises* concurrency
128/// above what was requested. The budget-derived count itself has a floor of 1
129/// (you must keep at least one part in flight to make progress), so the overall
130/// result is always `>= 1`.
131///
132/// Pure and total: a zero `part_size` is treated as 1 to avoid division by zero.
133pub fn effective_in_flight(max_concurrency: usize, part_size: u64) -> usize {
134    // Honor an explicit low request down to serial (1); never below 1.
135    let cap = max_concurrency.max(1);
136    let by_budget = (UPLOAD_MEMORY_BUDGET / part_size.max(1)).max(1) as usize;
137    by_budget.min(cap)
138}
139
140/// Progress callback: invoked as bytes flow with `(bytes_done_total, total)`,
141/// where `total` is the full declared file size. `bytes_done_total` is
142/// monotonically non-decreasing and reaches exactly `total` when the transfer
143/// completes. Shared (`Arc`) so it can be cloned across concurrent part tasks;
144/// it must therefore be `Send + Sync`.
145pub type UploadProgress = Arc<dyn Fn(u64, u64) + Send + Sync>;
146
147/// Options for [`Client::upload_file`](crate::Client::upload_file).
148///
149/// All fields are optional. `content_type` / `content_encoding` / `filename`
150/// are recorded with the upload (advisory metadata; they do not change where the
151/// bytes are stored). `part_size` is a hint the server clamps to its allowed
152/// range and ignores for single-`PUT` uploads. `progress`, when set, is invoked
153/// as bytes flow.
154#[derive(Default, Clone)]
155pub struct UploadOptions {
156    /// Content type to record for the uploaded file (e.g. a Parquet/CSV/JSON
157    /// MIME type). Advisory.
158    pub content_type: Option<String>,
159    /// Content encoding to record for the uploaded file (e.g. `gzip`). Advisory.
160    pub content_encoding: Option<String>,
161    /// Original file name, recorded for bookkeeping. Advisory. Defaults to the
162    /// source path's file name when not set.
163    pub filename: Option<String>,
164    /// Preferred part size, in bytes, for a large (multipart) upload. A hint;
165    /// the server clamps it and ignores it for single-`PUT` uploads. When unset,
166    /// the SDK auto-scales a hint via [`auto_part_size_hint`] (8 MiB for normal
167    /// files, larger only for very large ones to bound the part count).
168    pub part_size: Option<u64>,
169    /// Maximum number of part `PUT`s to keep in flight for a multipart upload.
170    /// `None` uses [`DEFAULT_MAX_CONCURRENCY`]. The effective in-flight count is
171    /// the MIN of this and a peak-memory budget derived from the server's actual
172    /// part size (see [`effective_in_flight`]), so memory stays bounded.
173    pub max_concurrency: Option<usize>,
174    /// Optional progress callback invoked with `(bytes_done_total, total)`.
175    pub progress: Option<UploadProgress>,
176}
177
178impl std::fmt::Debug for UploadOptions {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        f.debug_struct("UploadOptions")
181            .field("content_type", &self.content_type)
182            .field("content_encoding", &self.content_encoding)
183            .field("filename", &self.filename)
184            .field("part_size", &self.part_size)
185            .field("max_concurrency", &self.max_concurrency)
186            .field("progress", &self.progress.as_ref().map(|_| "<callback>"))
187            .finish()
188    }
189}
190
191/// Error returned by [`Client::upload_file`](crate::Client::upload_file).
192///
193/// Marked `#[non_exhaustive]`: new variants may be added without a breaking
194/// change, so downstream `match`es should carry a wildcard arm.
195#[derive(Debug)]
196#[non_exhaustive]
197pub enum UploadError {
198    /// Opening or reading the local source file failed.
199    Io(std::io::Error),
200    /// Opening the upload session (`POST /v1/uploads`) failed. A `501`
201    /// `PRESIGN_UNSUPPORTED` lands here too — the presigned path is a hard
202    /// requirement, so a backend that cannot presign is a hard error.
203    CreateSession(Error<apis::uploads_api::CreateUploadSessionHandlerError>),
204    /// A storage `PUT` (or the request building / transport around it) failed.
205    Storage(reqwest::Error),
206    /// A storage `PUT` returned a non-2xx status. Carries the status and the
207    /// response body for diagnosis.
208    StorageStatus {
209        /// The HTTP status the storage endpoint returned.
210        status: reqwest::StatusCode,
211        /// The 1-based part number for a multipart `PUT`, or `None` for the
212        /// single-`PUT` path.
213        part_number: Option<i32>,
214        /// The storage response body (often XML for S3-style errors).
215        body: String,
216    },
217    /// Storage accepted a part `PUT` but returned no `ETag` header, so the part
218    /// cannot be finalized.
219    MissingETag {
220        /// The 1-based part number whose `PUT` response lacked an `ETag`.
221        part_number: i32,
222    },
223    /// The create-session response was internally inconsistent for its declared
224    /// `mode` (e.g. `single` without a `url`, or `multipart` without
225    /// `part_urls` / `part_size`).
226    MalformedSession(String),
227    /// A size (the file's declared size, or the part-size hint) did not fit the
228    /// wire's signed 64-bit field. Only reachable for pathological sizes beyond
229    /// `i64::MAX` bytes (~8 EiB).
230    SizeOverflow {
231        /// What overflowed (e.g. `"declared_size_bytes"`).
232        what: &'static str,
233        /// The offending value.
234        value: u64,
235    },
236    /// Finalizing the upload (`POST /v1/uploads/{id}/finalize`) failed.
237    Finalize(Error<apis::uploads_api::FinalizeUploadHandlerError>),
238    /// Minting part URLs on demand (`POST /v1/uploads/{id}/parts`) failed during
239    /// a streaming upload — either the initial batch or an on-403 re-mint.
240    MintParts(Error<apis::uploads_api::MintUploadPartsHandlerError>),
241}
242
243impl std::fmt::Display for UploadError {
244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        match self {
246            UploadError::Io(e) => write!(f, "reading the source file failed: {e}"),
247            UploadError::CreateSession(e) => write!(f, "opening the upload session failed: {e}"),
248            UploadError::Storage(e) => write!(f, "uploading to storage failed: {e}"),
249            UploadError::StorageStatus {
250                status,
251                part_number,
252                body,
253            } => match part_number {
254                Some(n) => write!(f, "storage rejected part {n} with status {status}: {body}"),
255                None => write!(
256                    f,
257                    "storage rejected the upload with status {status}: {body}"
258                ),
259            },
260            UploadError::MissingETag { part_number } => write!(
261                f,
262                "storage returned no ETag for part {part_number}; cannot finalize"
263            ),
264            UploadError::SizeOverflow { what, value } => {
265                write!(
266                    f,
267                    "{what} ({value} bytes) exceeds the maximum supported size"
268                )
269            }
270            UploadError::MalformedSession(msg) => {
271                write!(f, "malformed upload session response: {msg}")
272            }
273            UploadError::Finalize(e) => write!(f, "finalizing the upload failed: {e}"),
274            UploadError::MintParts(e) => write!(f, "minting upload part URLs failed: {e}"),
275        }
276    }
277}
278
279impl std::error::Error for UploadError {
280    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
281        match self {
282            UploadError::Io(e) => Some(e),
283            UploadError::CreateSession(e) => Some(e),
284            UploadError::Storage(e) => Some(e),
285            UploadError::Finalize(e) => Some(e),
286            UploadError::MintParts(e) => Some(e),
287            _ => None,
288        }
289    }
290}
291
292impl From<std::io::Error> for UploadError {
293    fn from(e: std::io::Error) -> Self {
294        UploadError::Io(e)
295    }
296}
297
298/// Upload a local file directly to object storage and finalize it.
299///
300/// This is the orchestration behind [`Client::upload_file`](crate::Client::upload_file);
301/// see that method for the public contract. It stats `path` for the declared
302/// size, opens a session, drives the single-`PUT` or multipart path, and
303/// finalizes.
304pub(crate) async fn upload_file(
305    configuration: &Configuration,
306    path: &Path,
307    opts: UploadOptions,
308) -> Result<models::FinalizeUploadResponse, UploadError> {
309    let metadata = tokio::fs::metadata(path).await?;
310    let total = metadata.len();
311
312    let filename = opts
313        .filename
314        .clone()
315        .or_else(|| path.file_name().map(|n| n.to_string_lossy().into_owned()));
316
317    // Part-size hint: honor an explicit caller value, else auto-scale from the
318    // declared size so the common case stays at 8 MiB and only very large files
319    // grow the hint (bounding the part count). The server clamps it regardless.
320    let part_size_hint = opts.part_size.unwrap_or_else(|| auto_part_size_hint(total));
321
322    // The wire models the part-size hint as a signed i64; reject (rather than
323    // silently wrap) a pathological hint beyond i64::MAX.
324    let part_size_hint_i64 =
325        i64::try_from(part_size_hint).map_err(|_| UploadError::SizeOverflow {
326            what: "part_size",
327            value: part_size_hint,
328        })?;
329
330    // Default a large file to a JUST-IN-TIME (streaming) session: omit
331    // `declared_size_bytes` so the server mints NO part URLs up front. The client
332    // then mints each part URL moments before it uploads that part (see
333    // `upload_multipart_streaming`), so a URL cannot expire mid-transfer no
334    // matter how long a slow upload runs — the failure mode of the eager
335    // known-size path, whose URLs share a ~30-minute TTL. A small file is a
336    // single quick `PUT` with no expiry risk, so it keeps the known-size path
337    // (and the server's single-`PUT` fast path) by declaring its size.
338    //
339    // `declared_size_bytes` is sent (and so range-checked against the wire's
340    // i64) ONLY on the known-size path; a streaming upload omits it entirely, so
341    // a size beyond i64::MAX is never an obstacle to a streamed file.
342    let declared_size_bytes = if total > STREAMING_THRESHOLD {
343        None
344    } else {
345        let size = i64::try_from(total).map_err(|_| UploadError::SizeOverflow {
346            what: "declared_size_bytes",
347            value: total,
348        })?;
349        Some(Some(size))
350    };
351    let create = models::CreateUploadRequest {
352        content_type: opts.content_type.clone().map(Some),
353        content_encoding: opts.content_encoding.clone().map(Some),
354        filename: filename.map(Some),
355        part_size: Some(Some(part_size_hint_i64)),
356        declared_size_bytes,
357        ..models::CreateUploadRequest::new()
358    };
359    let session = apis::uploads_api::create_upload_session_handler(configuration, create)
360        .await
361        .map_err(UploadError::CreateSession)?;
362
363    // Report initial progress so a 0-byte file (or an instant single PUT) still
364    // emits a terminal (0/0 or total/total) tick.
365    if let Some(ref progress) = opts.progress {
366        progress(0, total);
367    }
368
369    let parts = match session.mode.as_str() {
370        "single" => {
371            upload_single(&session, path, total, opts.progress.as_ref()).await?;
372            None
373        }
374        "multipart" => {
375            let max_concurrency = opts.max_concurrency.unwrap_or(DEFAULT_MAX_CONCURRENCY);
376            // A streaming (unknown-size) session returns NO part URLs up front
377            // (the `part_urls` key is absent or null) — mint them on demand. A
378            // known-size session returns the full `part_urls` list to PUT to
379            // directly. An explicitly present (even empty) list is a known-size
380            // response; `upload_multipart` validates it and rejects an empty one.
381            let parts = if matches!(session.part_urls, Some(Some(_))) {
382                upload_multipart(
383                    configuration,
384                    &session,
385                    path,
386                    total,
387                    max_concurrency,
388                    opts.progress.as_ref(),
389                )
390                .await?
391            } else {
392                upload_multipart_streaming(
393                    configuration,
394                    &session,
395                    path,
396                    total,
397                    max_concurrency,
398                    opts.progress.as_ref(),
399                )
400                .await?
401            };
402            Some(parts)
403        }
404        other => {
405            return Err(UploadError::MalformedSession(format!(
406                "unknown upload mode `{other}`"
407            )))
408        }
409    };
410
411    // Finalize: single sends an empty object `{}`; multipart sends
412    // `{"parts": [...]}` with the ascending, non-duplicate parts list. The token
413    // rides the X-Upload-Finalize-Token header (handled by the generated op).
414    //
415    // The body MUST be a JSON object, never `null`: the server rejects a `null`
416    // finalize body ("invalid type: null, expected struct FinalizeUploadRequest")
417    // even though the field is logically optional for single uploads. So we wrap
418    // in `Some(..)` for both modes — the generated op then serializes a struct,
419    // and `parts` (skip_serializing_if = Option::is_none) drops out for single,
420    // yielding `{}`.
421    let finalize_body = Some(
422        parts
423            .map(|parts| models::FinalizeUploadRequest {
424                parts: Some(Some(parts)),
425            })
426            .unwrap_or_default(),
427    );
428
429    // Finalize is exactly-once on the server: a second finalize of the same
430    // upload is rejected. The generated op routes through `execute_retrying`,
431    // which would retry an ambiguous failure (a lost response, or a 429 the
432    // server actually processed) — turning a finalize that SUCCEEDED into a
433    // spurious "already finalized" error on the retry. So we call it with retries
434    // disabled (a single attempt). Part PUTs stay retryable (idempotent: storage
435    // overwrites a part by number); only finalize is single-shot.
436    let mut finalize_config = configuration.clone();
437    finalize_config.retry.max_retries = 0;
438
439    apis::uploads_api::finalize_upload_handler(
440        &finalize_config,
441        &session.upload_id,
442        &session.finalize_token,
443        finalize_body,
444    )
445    .await
446    .map_err(UploadError::Finalize)
447}
448
449/// Single-`PUT` path: stream the whole file to `session.url`, invoking the
450/// progress callback incrementally as chunks are sent to storage.
451///
452/// The body is a [`progress_stream`] wrapping the file reader, so progress is
453/// byte-granular (a multi-GB upload reports smooth `done/total` ticks rather
454/// than jumping 0% -> 100%). A streaming body is not clonable, so this single
455/// `PUT` is sent once with no 429/reset retry — an intentional trade for smooth
456/// progress on the large, common single-`PUT` path; a presigned storage `PUT`
457/// is not expected to be admission-shed.
458async fn upload_single(
459    session: &models::UploadSessionResponse,
460    path: &Path,
461    total: u64,
462    progress: Option<&UploadProgress>,
463) -> Result<(), UploadError> {
464    let url =
465        session.url.clone().flatten().ok_or_else(|| {
466            UploadError::MalformedSession("single upload missing `url`".to_owned())
467        })?;
468
469    let file = tokio::fs::File::open(path).await?;
470    let body = progress_stream(file, total, progress.cloned());
471
472    put_stream_to_storage(&url, &session.headers, body, total).await?;
473
474    // Guarantee a terminal tick at exactly `total`, even if the stream's last
475    // chunk boundary or an empty file left the counter short. Monotonic: the
476    // streamed ticks never exceed `total`.
477    if let Some(progress) = progress {
478        progress(total, total);
479    }
480    Ok(())
481}
482
483/// Wrap a file reader in a byte-counting stream of `Bytes` chunks. Each chunk
484/// advances a running total and invokes `progress(done, total)` as it is yielded
485/// to the request body, so progress reflects bytes actually handed to the
486/// transport. Monotonic non-decreasing; the running total never exceeds `total`.
487fn progress_stream(
488    file: tokio::fs::File,
489    total: u64,
490    progress: Option<UploadProgress>,
491) -> ProgressStream {
492    use tokio_util::codec::{BytesCodec, FramedRead};
493
494    ProgressStream {
495        inner: FramedRead::new(file, BytesCodec::new()),
496        done: 0,
497        total,
498        progress,
499    }
500}
501
502/// A [`Stream`](futures_core::Stream) of `Bytes` chunks read from a file that
503/// reports cumulative byte progress as each chunk is yielded. Hand-rolled over
504/// `futures_core` (the crate's only direct futures dep) rather than pulling in
505/// `futures_util`, staying on `futures_core::Stream`.
506struct ProgressStream {
507    inner: tokio_util::codec::FramedRead<tokio::fs::File, tokio_util::codec::BytesCodec>,
508    done: u64,
509    total: u64,
510    progress: Option<UploadProgress>,
511}
512
513impl futures_core::Stream for ProgressStream {
514    type Item = std::io::Result<bytes::Bytes>;
515
516    fn poll_next(
517        self: std::pin::Pin<&mut Self>,
518        cx: &mut std::task::Context<'_>,
519    ) -> std::task::Poll<Option<Self::Item>> {
520        use std::task::Poll;
521        // `inner` (FramedRead) is Unpin, and our other fields are too, so a
522        // mutable projection through `get_mut` is sound without pin-project.
523        let this = self.get_mut();
524        match std::pin::Pin::new(&mut this.inner).poll_next(cx) {
525            Poll::Ready(Some(Ok(chunk))) => {
526                let chunk = chunk.freeze();
527                this.done = (this.done + chunk.len() as u64).min(this.total);
528                if let Some(ref progress) = this.progress {
529                    progress(this.done, this.total);
530                }
531                Poll::Ready(Some(Ok(chunk)))
532            }
533            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
534            Poll::Ready(None) => Poll::Ready(None),
535            Poll::Pending => Poll::Pending,
536        }
537    }
538}
539
540/// A single part's upload work, independent of how the bytes actually get to
541/// storage. The known-size path builds one of these per part and hands them to
542/// [`upload_parts_resilient`]; tests substitute a fake uploader.
543#[derive(Clone, Debug, PartialEq, Eq)]
544struct PartPlan {
545    /// 0-based position — indexes both the results vector and the URL list.
546    index: usize,
547    /// 1-based S3 part number.
548    part_number: i32,
549    /// Byte offset of this part within the source file.
550    offset: u64,
551    /// Byte length of this part (the last part is the remainder).
552    len: u64,
553}
554
555/// Outer-loop retry policy for whole-upload resilience, layered ON TOP of the
556/// per-part transport retries in [`crate::http::execute_retrying`].
557///
558/// The inner per-part retries all burn within a few seconds, so they only ride
559/// out a momentary blip *during one part*. A longer network interruption (or a
560/// flaky uplink that resets connections under load) needs a genuinely later
561/// attempt. Each extra round re-sweeps ONLY the parts still failing, after a
562/// backoff, and at reduced concurrency — fewer in-flight connections reset less
563/// on a saturated link. Crucially, parts that already succeeded keep their
564/// ETags across rounds, so a single bad part never discards the whole transfer.
565#[derive(Clone, Copy, Debug)]
566struct RetryRounds {
567    /// Re-sweeps after the initial pass. `0` reproduces the legacy behavior: a
568    /// single part exhausting its inner retries fails the entire upload.
569    max_extra_rounds: u32,
570    /// Backoff before the first re-sweep; doubles each subsequent round.
571    base_delay: Duration,
572}
573
574impl Default for RetryRounds {
575    fn default() -> Self {
576        Self {
577            max_extra_rounds: 3,
578            base_delay: Duration::from_secs(2),
579        }
580    }
581}
582
583impl RetryRounds {
584    /// Backoff before `round` (1-based for re-sweeps): capped exponential.
585    fn delay_for(self, round: u32) -> Duration {
586        let shift = round.saturating_sub(1).min(16);
587        self.base_delay.saturating_mul(1u32 << shift)
588    }
589}
590
591/// In-flight cap for a given 0-based round: halve per round, never below 1. A
592/// saturated, jittery uplink (the failure mode this whole loop targets) resets
593/// fewer connections when fewer are in flight, so later rounds back off.
594fn round_in_flight(base_in_flight: usize, round: u32) -> usize {
595    let shift = round.min(usize::BITS - 1);
596    (base_in_flight >> shift).max(1)
597}
598
599/// Whether an upload error is *terminal* — guaranteed to reproduce on a
600/// re-sweep because it reflects a server-contract or sizing violation rather
601/// than a transient network condition. Terminal errors fail the upload
602/// immediately; everything else (transport resets, timeouts, storage 4xx/5xx,
603/// mint failures) stays retryable, so a flaky link is never mistaken for a
604/// permanent fault. Kept deliberately narrow: only errors that are deterministic
605/// in the part's own inputs belong here.
606fn is_terminal(err: &UploadError) -> bool {
607    matches!(
608        err,
609        UploadError::MalformedSession(_) | UploadError::SizeOverflow { .. }
610    )
611}
612
613/// Upload every part, surviving transient per-part failures without discarding
614/// the parts that already succeeded.
615///
616/// A round runs the still-pending parts through a `JoinSet` bounded by the
617/// round's in-flight cap, recording each success and **collecting** (not
618/// propagating) each failure. If any parts remain, it waits per [`RetryRounds`]
619/// and re-sweeps just those, at reduced concurrency, until they all land or the
620/// rounds are exhausted. Completed parts' ETags persist across rounds, so the
621/// work already done is never thrown away — the bug this replaces aborted the
622/// whole upload the moment one part exhausted its inner retries.
623///
624/// `upload_part` performs one part's transfer (including its own inner transport
625/// retries) and MUST be idempotent: re-running a part overwrites it in storage
626/// (S3 `UploadPart` by number), so a re-swept part is safe.
627async fn upload_parts_resilient<F, Fut>(
628    plans: Vec<PartPlan>,
629    base_in_flight: usize,
630    rounds: RetryRounds,
631    upload_part: F,
632) -> Result<Vec<models::FinalizeUploadPart>, UploadError>
633where
634    F: Fn(PartPlan) -> Fut + Clone + Send + Sync + 'static,
635    Fut: std::future::Future<Output = Result<models::FinalizeUploadPart, UploadError>>
636        + Send
637        + 'static,
638{
639    let total_parts = plans.len();
640    // `results` is indexed by `plan.index`, so every plan's index must fall in
641    // `0..total_parts` (both callers build consecutive 0-based plans). Enforce
642    // it so a future caller passing a sparse/offset set fails loudly in tests
643    // rather than panicking or writing the wrong slot.
644    debug_assert!(
645        plans.iter().all(|p| p.index < total_parts),
646        "PartPlan.index must be within 0..plans.len()"
647    );
648    let mut results: Vec<Option<models::FinalizeUploadPart>> = vec![None; total_parts];
649    let mut remaining = plans;
650    let mut last_err: Option<UploadError> = None;
651
652    for round in 0..=rounds.max_extra_rounds {
653        if remaining.is_empty() {
654            break;
655        }
656        if round > 0 {
657            // A genuinely later attempt on a fresh window — the point of the
658            // outer loop, distinct from the inner retries that already ran.
659            tokio::time::sleep(rounds.delay_for(round)).await;
660        }
661
662        let in_flight = round_in_flight(base_in_flight, round);
663        let mut pending = std::mem::take(&mut remaining).into_iter();
664        let mut failed: Vec<PartPlan> = Vec::new();
665        let mut join_set: tokio::task::JoinSet<
666            Result<(usize, models::FinalizeUploadPart), (PartPlan, UploadError)>,
667        > = tokio::task::JoinSet::new();
668
669        loop {
670            while join_set.len() < in_flight {
671                let Some(plan) = pending.next() else { break };
672                let upload_part = upload_part.clone();
673                join_set.spawn(async move {
674                    let index = plan.index;
675                    match upload_part(plan.clone()).await {
676                        Ok(part) => Ok((index, part)),
677                        Err(e) => Err((plan, e)),
678                    }
679                });
680            }
681            match join_set.join_next().await {
682                Some(Ok(Ok((index, part)))) => results[index] = Some(part),
683                Some(Ok(Err((plan, e)))) => {
684                    // A clearly-terminal error (server-contract / sizing
685                    // violation) reproduces identically on every re-sweep, so
686                    // fail fast rather than burning the whole round budget on it.
687                    // Anything network-ish stays retryable — we never regress
688                    // resilience by mistaking a flaky link for a permanent fault.
689                    if is_terminal(&e) {
690                        join_set.abort_all();
691                        return Err(e);
692                    }
693                    // Record the failure and keep draining the rest — do NOT
694                    // abort the other in-flight parts. This part is re-swept in
695                    // the next round.
696                    failed.push(plan);
697                    last_err = Some(e);
698                }
699                Some(Err(join_err)) => {
700                    join_set.abort_all();
701                    return Err(UploadError::Io(std::io::Error::other(format!(
702                        "part upload task failed: {join_err}"
703                    ))));
704                }
705                None => break,
706            }
707        }
708        remaining = failed;
709    }
710
711    if !remaining.is_empty() {
712        // Rounds exhausted with parts still failing — surface the last
713        // underlying error so the caller's normal error mapping applies.
714        return Err(last_err
715            .unwrap_or_else(|| UploadError::Io(std::io::Error::other("multipart upload failed"))));
716    }
717
718    Ok(results.into_iter().flatten().collect())
719}
720
721/// Multipart path: slice the file into `part_size`-byte chunks (the last is the
722/// remainder), `PUT` each chunk to its `part_urls[i - 1]` with bounded
723/// concurrency, and collect `(part_number, e_tag)` per part.
724///
725/// `max_concurrency` is the caller's ceiling on in-flight parts; the effective
726/// count also honors a peak-memory budget derived from the server's actual
727/// `part_size` (see [`effective_in_flight`]).
728///
729/// Returns the parts sorted ascending by part number, ready for finalize.
730async fn upload_multipart(
731    configuration: &Configuration,
732    session: &models::UploadSessionResponse,
733    path: &Path,
734    total: u64,
735    max_concurrency: usize,
736    progress: Option<&UploadProgress>,
737) -> Result<Vec<models::FinalizeUploadPart>, UploadError> {
738    let part_urls = session.part_urls.clone().flatten().ok_or_else(|| {
739        UploadError::MalformedSession("multipart upload missing `part_urls`".to_owned())
740    })?;
741    let part_size = session.part_size.flatten().ok_or_else(|| {
742        UploadError::MalformedSession("multipart upload missing `part_size`".to_owned())
743    })?;
744    if part_size <= 0 {
745        return Err(UploadError::MalformedSession(format!(
746            "multipart upload has non-positive `part_size` {part_size}"
747        )));
748    }
749    let part_size = part_size as u64;
750
751    if part_urls.is_empty() {
752        return Err(UploadError::MalformedSession(
753            "multipart upload has empty `part_urls`".to_owned(),
754        ));
755    }
756
757    // The URL count must match the number of `part_size`-byte chunks the file
758    // splits into (last is the remainder). Too many URLs and we'd PUT a
759    // zero-length trailing part; too few and we'd finalize an incomplete list.
760    // Both mean a session inconsistent with our declared size, so fail loudly.
761    let expected_parts = total.div_ceil(part_size).max(1);
762    if part_urls.len() as u64 != expected_parts {
763        return Err(UploadError::MalformedSession(format!(
764            "multipart upload returned {} part URLs but the file ({total} bytes) \
765             splits into {expected_parts} parts of {part_size} bytes",
766            part_urls.len()
767        )));
768    }
769
770    // Peak buffered memory is in_flight * part_size; bound in-flight by both the
771    // caller's max_concurrency and the memory budget, using the SERVER's actual
772    // part size (the same value we slice by below).
773    let in_flight_cap = effective_in_flight(max_concurrency, part_size);
774
775    // Aggregate progress across parts via a shared counter; each part adds its
776    // own byte count once it lands (on success only — a re-swept part that
777    // failed an earlier round did not count, so bytes are never double-counted).
778    let done = Arc::new(AtomicU64::new(0));
779
780    // One plan per part. The last part carries the remainder; earlier parts are
781    // exactly `part_size`. A part starting at/after EOF (only possible for a
782    // zero-length file) is skipped rather than PUT as a zero-length object.
783    let mut plans: Vec<PartPlan> = Vec::with_capacity(part_urls.len());
784    for index in 0..part_urls.len() {
785        let offset = index as u64 * part_size;
786        if offset >= total && total > 0 {
787            continue;
788        }
789        let len = part_size.min(total.saturating_sub(offset));
790        plans.push(PartPlan {
791            index,
792            part_number: (index + 1) as i32,
793            offset,
794            len,
795        });
796    }
797
798    // Per-part uploader: a positioned read of exactly this part's byte range (so
799    // a re-read on retry never shares a cursor) then a header-isolated `PUT`.
800    // Captures only `Arc`s and `Copy` values, so the closure is
801    // `Clone + Send + Sync + 'static` and `upload_parts_resilient` can re-run it
802    // across rounds and concurrent tasks.
803    let part_urls = Arc::new(part_urls);
804    let headers = Arc::new(session.headers.clone());
805    let path = Arc::new(path.to_path_buf());
806    let retry = configuration.retry; // RetryPolicy is Copy.
807    let progress = progress.cloned();
808
809    let uploader = move |plan: PartPlan| {
810        let part_urls = Arc::clone(&part_urls);
811        let headers = Arc::clone(&headers);
812        let path = Arc::clone(&path);
813        let done = Arc::clone(&done);
814        let progress = progress.clone();
815        async move {
816            let url = part_urls[plan.index].clone();
817            let chunk = read_range(&path, plan.offset, plan.len).await?;
818            let resp = put_to_storage(
819                &retry,
820                &url,
821                &headers,
822                chunk,
823                plan.len,
824                Some(plan.part_number),
825            )
826            .await?;
827            let e_tag = parse_etag(resp.headers(), plan.part_number)?;
828            if let Some(progress) = progress.as_ref() {
829                let now = done.fetch_add(plan.len, Ordering::SeqCst) + plan.len;
830                progress(now, total);
831            }
832            Ok(models::FinalizeUploadPart {
833                e_tag,
834                part_number: plan.part_number,
835            })
836        }
837    };
838
839    // Resilient outer loop: a single part's transient failure no longer aborts
840    // the whole upload — it is re-swept on a later round while completed parts
841    // keep their ETags. `upload_parts_resilient` returns the parts ascending by
842    // part number with no duplicates.
843    upload_parts_resilient(plans, in_flight_cap, RetryRounds::default(), uploader).await
844}
845
846/// Streaming (just-in-time) multipart path: the session was opened WITHOUT a
847/// declared size, so the server minted no part URLs up front. We still know the
848/// local file's size, so the part count is fixed by the server's echoed
849/// `part_size`.
850///
851/// Each part mints a FRESH presigned URL immediately before its `PUT` (via
852/// `POST /v1/uploads/{id}/parts`), so a URL can never expire mid-transfer on a
853/// slow upload — and a part re-swept by [`upload_parts_resilient`] simply
854/// re-mints. This replaces the earlier batched pre-mint pipeline and its
855/// one-shot on-`403` re-mint: per-part minting is simpler and fully resilient,
856/// and with bounded concurrency the extra mint round-trip overlaps other parts'
857/// in-flight `PUT`s rather than serializing.
858///
859/// The deliberate cost is mint *request volume*: one `POST /parts` per part
860/// (up to [`TARGET_MAX_PARTS`]) instead of the old batched ≤100-per-call. We
861/// accept it because pre-minting a batch ahead is what made slow uploads fail —
862/// buffered URLs age in the queue and can expire before their part's `PUT` is
863/// reached on a constrained link. Minting each URL immediately before use keeps
864/// its age minimal, which is the whole point on the slow links this hardens.
865///
866/// Returns the parts sorted ascending by part number, ready for finalize.
867async fn upload_multipart_streaming(
868    configuration: &Configuration,
869    session: &models::UploadSessionResponse,
870    path: &Path,
871    total: u64,
872    max_concurrency: usize,
873    progress: Option<&UploadProgress>,
874) -> Result<Vec<models::FinalizeUploadPart>, UploadError> {
875    let part_size = session.part_size.flatten().ok_or_else(|| {
876        UploadError::MalformedSession("streaming upload missing `part_size`".to_owned())
877    })?;
878    if part_size <= 0 {
879        return Err(UploadError::MalformedSession(format!(
880            "streaming upload has non-positive `part_size` {part_size}"
881        )));
882    }
883    let part_size = part_size as u64;
884
885    // Slice by the SERVER's echoed part size (never our hint); the last part is
886    // the remainder. We know the file size, so the part count is fixed up front
887    // even though the server does not.
888    let expected_parts = total.div_ceil(part_size).max(1) as usize;
889
890    // Peak buffered memory is in_flight * part_size; bound in-flight by both the
891    // caller's max_concurrency and the memory budget (same as the eager path).
892    let in_flight_cap = effective_in_flight(max_concurrency, part_size);
893
894    // One plan per part (same shape as the known-size path). A part starting
895    // at/after EOF (only possible for a zero-length file) is skipped rather than
896    // PUT as a zero-length object.
897    let mut plans: Vec<PartPlan> = Vec::with_capacity(expected_parts);
898    for index in 0..expected_parts {
899        let offset = index as u64 * part_size;
900        if offset >= total && total > 0 {
901            continue;
902        }
903        let len = part_size.min(total.saturating_sub(offset));
904        plans.push(PartPlan {
905            index,
906            part_number: (index + 1) as i32,
907            offset,
908            len,
909        });
910    }
911
912    // Per-part uploader: mint a fresh URL for THIS part immediately before
913    // uploading it, then PUT. Captures only `Arc`s and `Copy` values, so the
914    // closure is `Clone + Send + Sync + 'static` and `upload_parts_resilient`
915    // can re-run it across rounds and concurrent tasks; a re-swept part re-mints
916    // a fresh URL, so expiry is impossible.
917    let config = Arc::new(configuration.clone());
918    let upload_id = Arc::new(session.upload_id.clone());
919    let finalize_token = Arc::new(session.finalize_token.clone());
920    let headers = Arc::new(session.headers.clone());
921    let path = Arc::new(path.to_path_buf());
922    let retry = configuration.retry;
923    let done = Arc::new(AtomicU64::new(0));
924    let progress = progress.cloned();
925
926    let uploader = move |plan: PartPlan| {
927        let config = Arc::clone(&config);
928        let upload_id = Arc::clone(&upload_id);
929        let finalize_token = Arc::clone(&finalize_token);
930        let headers = Arc::clone(&headers);
931        let path = Arc::clone(&path);
932        let done = Arc::clone(&done);
933        let progress = progress.clone();
934        async move {
935            let minted = apis::uploads_api::mint_upload_parts_handler(
936                &config,
937                &upload_id,
938                &finalize_token,
939                models::MintUploadPartsRequest::new(vec![plan.part_number]),
940            )
941            .await
942            .map_err(UploadError::MintParts)?;
943            let url = minted
944                .parts
945                .into_iter()
946                .find(|p| p.part_number == plan.part_number)
947                .map(|p| p.url)
948                .ok_or_else(|| {
949                    UploadError::MalformedSession(format!(
950                        "mint returned no URL for part {}",
951                        plan.part_number
952                    ))
953                })?;
954
955            let chunk = read_range(&path, plan.offset, plan.len).await?;
956            let resp = put_to_storage(
957                &retry,
958                &url,
959                &headers,
960                chunk,
961                plan.len,
962                Some(plan.part_number),
963            )
964            .await?;
965            let e_tag = parse_etag(resp.headers(), plan.part_number)?;
966            if let Some(progress) = progress.as_ref() {
967                let now = done.fetch_add(plan.len, Ordering::SeqCst) + plan.len;
968                progress(now, total);
969            }
970            Ok(models::FinalizeUploadPart {
971                e_tag,
972                part_number: plan.part_number,
973            })
974        }
975    };
976
977    // Same resilient outer loop as the known-size path: a transient part failure
978    // is re-swept on a later round (re-minting a fresh URL) instead of aborting
979    // the whole upload; completed parts keep their ETags.
980    upload_parts_resilient(plans, in_flight_cap, RetryRounds::default(), uploader).await
981}
982
983/// Extract and validate the storage `ETag` from a part `PUT` response. Rejects a
984/// missing OR empty/whitespace-only header: finalize needs a real ETag per part,
985/// and an empty value would be carried into the completion request only to fail
986/// (or silently corrupt) it later. Treated as [`UploadError::MissingETag`], so a
987/// re-sweep can re-`PUT` the part and pick up a real ETag.
988fn parse_etag(
989    headers: &reqwest::header::HeaderMap,
990    part_number: i32,
991) -> Result<String, UploadError> {
992    let etag = headers
993        .get(reqwest::header::ETAG)
994        .and_then(|v| v.to_str().ok())
995        .map(|s| s.to_owned())
996        .ok_or(UploadError::MissingETag { part_number })?;
997    if etag.trim().is_empty() {
998        return Err(UploadError::MissingETag { part_number });
999    }
1000    Ok(etag)
1001}
1002
1003/// Read exactly `len` bytes starting at `offset` from `path`. A positioned read
1004/// (seek + read_exact) so multipart part tasks never share a cursor and a retry
1005/// re-reads the same range cleanly.
1006async fn read_range(path: &Path, offset: u64, len: u64) -> Result<bytes::Bytes, UploadError> {
1007    use tokio::io::{AsyncReadExt, AsyncSeekExt};
1008
1009    let mut file = tokio::fs::File::open(path).await?;
1010    file.seek(std::io::SeekFrom::Start(offset)).await?;
1011    let mut buf = vec![0u8; len as usize];
1012    file.read_exact(&mut buf).await?;
1013    Ok(bytes::Bytes::from(buf))
1014}
1015
1016/// Connect-phase timeout for storage `PUT`s. Bounds only TCP+TLS establishment
1017/// (not the transfer), so it is safe for both the bounded multipart parts and
1018/// the unbounded single-`PUT` whole-file path. Generous: a healthy connect is
1019/// sub-second, so 30 s only trips a genuinely dead/black-holed endpoint.
1020const STORAGE_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
1021
1022/// Fixed slack added to every per-part timeout for connect/TLS, request
1023/// queueing, and the response round-trip, independent of part size.
1024const PART_TIMEOUT_BASE: Duration = Duration::from_secs(60);
1025
1026/// Throughput floor used to size the per-part timeout. A part is only aborted if
1027/// it cannot sustain even this rate — 64 KiB/s (≈512 kbit/s), well below any
1028/// link on which an upload is worth attempting — so a legitimately slow but
1029/// progressing transfer is never killed; only a true stall is.
1030const PART_TIMEOUT_MIN_BYTES_PER_SEC: u64 = 64 * 1024;
1031
1032/// Operational ceiling on the per-part timeout. Without it a huge part (e.g. a
1033/// 5 GiB part on a multi-TB upload) would compute a ~22-hour timeout, so a
1034/// stalled giant part would hang for the better part of a day before the outer
1035/// loop could re-sweep it. 30 minutes still comfortably covers a legitimately
1036/// slow large part while keeping stall recovery bounded.
1037const PART_TIMEOUT_MAX: Duration = Duration::from_secs(30 * 60);
1038
1039/// Generous per-part total `PUT` timeout, scaled to the part size: a fixed base
1040/// plus the time the part would take at the throughput floor, capped at
1041/// [`PART_TIMEOUT_MAX`]. Examples: an 8 MiB part → ~188 s; a 64 MiB part →
1042/// ~18 min; anything above ~111 MiB → the 30 min cap. The goal is to catch a
1043/// stalled connection (which would otherwise hang the upload forever) without
1044/// aborting a healthy slow link — the outer [`upload_parts_resilient`] loop then
1045/// re-sweeps the timed-out part.
1046fn part_put_timeout(content_length: u64) -> Duration {
1047    (PART_TIMEOUT_BASE + Duration::from_secs(content_length / PART_TIMEOUT_MIN_BYTES_PER_SEC))
1048        .min(PART_TIMEOUT_MAX)
1049}
1050
1051/// `PUT` a body to a presigned storage URL with strict header isolation.
1052///
1053/// Attaches NONE of the SDK's auth/workspace/user-agent headers — a
1054/// presigned URL already carries its authorization, and an extra signed-ish
1055/// header makes S3-compatible storage return `403`. Only an explicit
1056/// `Content-Length` and the server-provided `headers` map (replayed verbatim;
1057/// currently always empty) are sent. A `Content-Type` is set ONLY when the
1058/// `headers` map includes one, so reqwest never auto-appends a charset.
1059///
1060/// Sent on the dedicated, header-bare [`storage_client`] with a generous,
1061/// part-size-scaled request timeout (see [`part_put_timeout`]) so a stalled
1062/// connection fails — into the outer retry loop — instead of hanging forever,
1063/// while a legitimately slow but progressing part is never aborted. The body
1064/// buffers in memory so it clones cleanly across retries via
1065/// [`crate::http::execute_retrying`]. Part `PUT`s are retryable: storage
1066/// overwrites a part by number, so a retried part is idempotent. `retry` is the
1067/// SDK's retry policy (carried on `Configuration`), used only for the retry
1068/// timing here.
1069async fn put_to_storage(
1070    retry: &crate::query::RetryPolicy,
1071    url: &str,
1072    headers: &HashMap<String, String>,
1073    body: bytes::Bytes,
1074    content_length: u64,
1075    part_number: Option<i32>,
1076) -> Result<reqwest::Response, UploadError> {
1077    let client = storage_client();
1078
1079    let mut req_builder = client
1080        .request(reqwest::Method::PUT, url)
1081        .header(reqwest::header::CONTENT_LENGTH, content_length);
1082
1083    // Replay the server-provided headers verbatim. Currently always empty; this
1084    // is the only place a Content-Type may be set, so reqwest can't auto-append
1085    // a charset.
1086    for (name, value) in headers {
1087        req_builder = req_builder.header(name.as_str(), value.as_str());
1088    }
1089
1090    // A buffered Bytes body clones cleanly, so 429 / pre-response-reset retries
1091    // in `execute_retrying` can re-send it.
1092    req_builder = req_builder.body(reqwest::Body::from(body));
1093
1094    // Per-PART total timeout, scaled to the part size. Bounds a single part so a
1095    // silently black-holed connection (no RST, write just stalls — which a
1096    // read/idle timeout would not catch) fails instead of hanging the whole
1097    // upload forever. NOT applied to the single-`PUT` whole-file path, which is
1098    // legitimately unbounded. `try_clone` in `execute_retrying` preserves this
1099    // per-request timeout, so every inner attempt gets a fresh full budget.
1100    req_builder = req_builder.timeout(part_put_timeout(content_length));
1101
1102    let req = req_builder.build().map_err(UploadError::Storage)?;
1103    crate::http_log::log_request(&req);
1104    let resp = crate::http::execute_retrying_unauthenticated(&client, req, retry)
1105        .await
1106        .map_err(UploadError::Storage)?;
1107
1108    let status = resp.status();
1109    crate::http_log::log_response_status(status);
1110    if status.is_client_error() || status.is_server_error() {
1111        let body = resp.text().await.unwrap_or_default();
1112        crate::http_log::log_response_body(&body);
1113        return Err(UploadError::StorageStatus {
1114            status,
1115            part_number,
1116            body,
1117        });
1118    }
1119    Ok(resp)
1120}
1121
1122/// `PUT` a streaming body to a presigned storage URL with the same strict
1123/// header isolation as [`put_to_storage`] (no SDK auth/scope headers; explicit
1124/// `Content-Length`; `Content-Type` only from the server `headers` map).
1125///
1126/// Used by the single-`PUT` path so progress is byte-granular. A streamed body
1127/// is not clonable, so this is a SINGLE attempt with no 429/reset retry — unlike
1128/// the buffered, retryable [`put_to_storage`] used per multipart part.
1129async fn put_stream_to_storage<S>(
1130    url: &str,
1131    headers: &HashMap<String, String>,
1132    body: S,
1133    content_length: u64,
1134) -> Result<reqwest::Response, UploadError>
1135where
1136    S: futures_core::Stream<Item = std::io::Result<bytes::Bytes>> + Send + 'static,
1137{
1138    let client = storage_client();
1139
1140    let mut req_builder = client
1141        .request(reqwest::Method::PUT, url)
1142        // Explicit Content-Length so the body is sized (not chunked) — storage
1143        // can reject an oversized upload up front, and reqwest honors it as the
1144        // framing for a wrapped stream.
1145        .header(reqwest::header::CONTENT_LENGTH, content_length);
1146
1147    for (name, value) in headers {
1148        req_builder = req_builder.header(name.as_str(), value.as_str());
1149    }
1150
1151    req_builder = req_builder.body(reqwest::Body::wrap_stream(body));
1152
1153    let req = req_builder.build().map_err(UploadError::Storage)?;
1154    crate::http_log::log_request(&req);
1155    // A streamed body can't be cloned, so send once (no retry helper).
1156    let resp = client.execute(req).await.map_err(UploadError::Storage)?;
1157
1158    let status = resp.status();
1159    crate::http_log::log_response_status(status);
1160    if status.is_client_error() || status.is_server_error() {
1161        let body = resp.text().await.unwrap_or_default();
1162        crate::http_log::log_response_body(&body);
1163        return Err(UploadError::StorageStatus {
1164            status,
1165            part_number: None,
1166            body,
1167        });
1168    }
1169    Ok(resp)
1170}
1171
1172/// The dedicated, process-wide reqwest client used for storage `PUT`s.
1173///
1174/// Deliberately NOT `configuration.client`: a host app may have installed
1175/// default headers (auth / workspace / `User-Agent` / `Content-Type`) on the
1176/// SDK's main client, which reqwest would then apply to the storage `PUT` —
1177/// making S3-compatible storage return `403 SignatureDoesNotMatch`. This client
1178/// is built bare: no default headers, and no request timeout (a large upload
1179/// legitimately takes minutes). It is built once and reused.
1180///
1181/// Trade-off: TLS / proxy / connection-pool settings on the SDK's main client
1182/// do NOT apply to storage `PUT`s — they go through this independent client.
1183/// That is intentional; storage transfers must be header-isolated, and a
1184/// host-configured proxy for the API host is not assumed to front object
1185/// storage.
1186fn storage_client() -> reqwest::Client {
1187    static STORAGE_CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
1188    STORAGE_CLIENT
1189        .get_or_init(|| {
1190            reqwest::Client::builder()
1191                // No `default_headers` and no client-wide request `timeout` (the
1192                // single-`PUT` whole-file path is legitimately unbounded; the
1193                // multipart path bounds each part per-request — see
1194                // `part_put_timeout`). A connect timeout is safe for both: it
1195                // bounds only connection establishment, not the transfer, so a
1196                // dead endpoint fails fast into the retry/outer loop.
1197                .connect_timeout(STORAGE_CONNECT_TIMEOUT)
1198                .build()
1199                // Falls back to a plain default client if the builder somehow
1200                // fails (e.g. no TLS backend); still header-bare.
1201                .unwrap_or_default()
1202        })
1203        .clone()
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208    use super::*;
1209
1210    /// The part count a given hint would produce for a file of `size`.
1211    fn part_count(size: u64, part: u64) -> u64 {
1212        size.div_ceil(part)
1213    }
1214
1215    #[test]
1216    fn auto_part_size_keeps_8mib_for_normal_files() {
1217        // Empty and small files default to 8 MiB.
1218        assert_eq!(auto_part_size_hint(0), DEFAULT_PART_SIZE);
1219        assert_eq!(auto_part_size_hint(1), DEFAULT_PART_SIZE);
1220        assert_eq!(auto_part_size_hint(100 * MIB), DEFAULT_PART_SIZE);
1221        assert_eq!(auto_part_size_hint(1024 * MIB), DEFAULT_PART_SIZE); // 1 GiB
1222                                                                        // Right at the boundary: 8 MiB * 9000 parts = 72 GiB still fits 8 MiB.
1223        let boundary = DEFAULT_PART_SIZE * TARGET_MAX_PARTS;
1224        assert_eq!(auto_part_size_hint(boundary), DEFAULT_PART_SIZE);
1225    }
1226
1227    #[test]
1228    fn auto_part_size_scales_up_for_very_large_files_and_caps_parts() {
1229        // Beyond ~72 GiB the hint must grow above 8 MiB.
1230        let big = 200 * 1024 * MIB; // 200 GiB
1231        let hint = auto_part_size_hint(big);
1232        assert!(
1233            hint > DEFAULT_PART_SIZE,
1234            "hint should scale above 8 MiB for a 200 GiB file, got {hint}"
1235        );
1236        // Hint is a whole number of MiB.
1237        assert_eq!(hint % MIB, 0, "hint must be a whole MiB, got {hint}");
1238        // Part count stays at or under the target ceiling.
1239        assert!(
1240            part_count(big, hint) <= TARGET_MAX_PARTS,
1241            "part count {} must be <= {TARGET_MAX_PARTS}",
1242            part_count(big, hint)
1243        );
1244        // And always within storage's accepted range.
1245        assert!((MIN_PART_SIZE..=MAX_PART_SIZE).contains(&hint));
1246    }
1247
1248    #[test]
1249    fn auto_part_size_clamps_to_max_for_enormous_files() {
1250        // A file so large the count-driven size would exceed 5 GiB clamps to the
1251        // 5 GiB ceiling (the part count then necessarily exceeds the soft target,
1252        // which is fine — it's a hint and the server has the final say).
1253        let enormous = 100 * 1024 * 1024 * MIB; // 100 PiB
1254        assert_eq!(auto_part_size_hint(enormous), MAX_PART_SIZE);
1255    }
1256
1257    #[test]
1258    fn effective_in_flight_capped_by_max_concurrency_for_small_parts() {
1259        // 8 MiB parts: budget allows 256/8 = 32, so max_concurrency wins.
1260        assert_eq!(effective_in_flight(12, 8 * MIB), 12);
1261        assert_eq!(effective_in_flight(10, 8 * MIB), 10);
1262        // A tiny part size still can't exceed max_concurrency.
1263        assert_eq!(effective_in_flight(12, MIB), 12);
1264    }
1265
1266    #[test]
1267    fn effective_in_flight_reduced_by_memory_budget_for_large_parts() {
1268        // 64 MiB parts: budget allows 256/64 = 4, below max_concurrency.
1269        assert_eq!(effective_in_flight(12, 64 * MIB), 4);
1270        // 128 MiB parts: 256/128 = 2.
1271        assert_eq!(effective_in_flight(12, 128 * MIB), 2);
1272    }
1273
1274    #[test]
1275    fn effective_in_flight_honors_explicit_low_concurrency() {
1276        // An explicit max_concurrency of 1 means serial uploads — NOT raised to a
1277        // floor of 2. (Regression guard for the Codex finding.)
1278        assert_eq!(effective_in_flight(1, 8 * MIB), 1);
1279        // 0 is normalized to 1 (you can't run zero in flight), not to 2.
1280        assert_eq!(effective_in_flight(0, 8 * MIB), 1);
1281        // 2 stays 2.
1282        assert_eq!(effective_in_flight(2, 8 * MIB), 2);
1283    }
1284
1285    #[test]
1286    fn effective_in_flight_floors_at_1_for_huge_parts_and_handles_zero() {
1287        // A part larger than the whole budget still keeps at least 1 in flight
1288        // (the budget can't bound below a single part).
1289        assert_eq!(effective_in_flight(12, UPLOAD_MEMORY_BUDGET * 4), 1);
1290        // Zero part size doesn't divide-by-zero (treated as 1 byte): the budget
1291        // then allows a huge count, so max_concurrency wins.
1292        assert_eq!(effective_in_flight(12, 0), 12);
1293    }
1294}
1295
1296#[cfg(test)]
1297mod resilient_retry_tests {
1298    use super::*;
1299    use std::collections::HashMap;
1300    use std::sync::atomic::AtomicUsize;
1301    use std::sync::Mutex;
1302
1303    fn plan(n: i32) -> PartPlan {
1304        PartPlan {
1305            index: (n - 1) as usize,
1306            part_number: n,
1307            offset: (n as u64 - 1) * 16,
1308            len: 16,
1309        }
1310    }
1311    fn plans(count: i32) -> Vec<PartPlan> {
1312        (1..=count).map(plan).collect()
1313    }
1314    fn no_delay(max_extra_rounds: u32) -> RetryRounds {
1315        RetryRounds {
1316            max_extra_rounds,
1317            base_delay: Duration::ZERO,
1318        }
1319    }
1320
1321    /// A transport-free stand-in for the real per-part uploader. It records
1322    /// attempts per part and can be told to fail the first K attempts of
1323    /// specific parts (modelling a part whose inner transport retries were
1324    /// exhausted by a network blip) before succeeding. Also tracks peak
1325    /// in-flight concurrency to verify the cap is honored.
1326    #[derive(Clone)]
1327    struct FakeUploader {
1328        fail: Arc<Mutex<HashMap<i32, usize>>>, // part_number -> remaining forced failures
1329        attempts: Arc<Mutex<HashMap<i32, usize>>>,
1330        in_flight: Arc<AtomicUsize>,
1331        peak_in_flight: Arc<AtomicUsize>,
1332    }
1333
1334    impl FakeUploader {
1335        fn new(fail: HashMap<i32, usize>) -> Self {
1336            Self {
1337                fail: Arc::new(Mutex::new(fail)),
1338                attempts: Arc::new(Mutex::new(HashMap::new())),
1339                in_flight: Arc::new(AtomicUsize::new(0)),
1340                peak_in_flight: Arc::new(AtomicUsize::new(0)),
1341            }
1342        }
1343        fn attempts_for(&self, n: i32) -> usize {
1344            *self.attempts.lock().unwrap().get(&n).unwrap_or(&0)
1345        }
1346        fn peak(&self) -> usize {
1347            self.peak_in_flight.load(Ordering::SeqCst)
1348        }
1349
1350        fn call(
1351            &self,
1352            plan: PartPlan,
1353        ) -> impl std::future::Future<Output = Result<models::FinalizeUploadPart, UploadError>>
1354               + Send
1355               + 'static {
1356            let fail = Arc::clone(&self.fail);
1357            let attempts = Arc::clone(&self.attempts);
1358            let in_flight = Arc::clone(&self.in_flight);
1359            let peak = Arc::clone(&self.peak_in_flight);
1360            async move {
1361                let cur = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1362                peak.fetch_max(cur, Ordering::SeqCst);
1363                // Force overlap so peak-in-flight reflects real concurrency.
1364                tokio::task::yield_now().await;
1365                *attempts
1366                    .lock()
1367                    .unwrap()
1368                    .entry(plan.part_number)
1369                    .or_insert(0) += 1;
1370                let should_fail = {
1371                    let mut f = fail.lock().unwrap();
1372                    match f.get_mut(&plan.part_number) {
1373                        Some(remaining) if *remaining > 0 => {
1374                            *remaining -= 1;
1375                            true
1376                        }
1377                        _ => false,
1378                    }
1379                };
1380                in_flight.fetch_sub(1, Ordering::SeqCst);
1381                if should_fail {
1382                    Err(UploadError::Io(std::io::Error::other(
1383                        "simulated connection reset",
1384                    )))
1385                } else {
1386                    Ok(models::FinalizeUploadPart {
1387                        e_tag: format!("etag-{}", plan.part_number),
1388                        part_number: plan.part_number,
1389                    })
1390                }
1391            }
1392        }
1393    }
1394
1395    // ---- pure policy ----
1396
1397    #[test]
1398    fn round_in_flight_halves_each_round_min_one() {
1399        assert_eq!(round_in_flight(8, 0), 8);
1400        assert_eq!(round_in_flight(8, 1), 4);
1401        assert_eq!(round_in_flight(8, 2), 2);
1402        assert_eq!(round_in_flight(8, 3), 1);
1403        assert_eq!(round_in_flight(8, 99), 1);
1404        assert_eq!(round_in_flight(1, 3), 1);
1405    }
1406
1407    #[test]
1408    fn delay_for_grows_exponentially() {
1409        let r = RetryRounds {
1410            max_extra_rounds: 3,
1411            base_delay: Duration::from_secs(2),
1412        };
1413        assert_eq!(r.delay_for(1), Duration::from_secs(2));
1414        assert_eq!(r.delay_for(2), Duration::from_secs(4));
1415        assert_eq!(r.delay_for(3), Duration::from_secs(8));
1416    }
1417
1418    #[test]
1419    fn part_put_timeout_is_generous_and_scales_with_part_size() {
1420        // 8 MiB part: 60s base + 8MiB / 64KiB/s = 60 + 128 = 188s. Comfortably
1421        // above the ~3s an 8 MiB part takes on a healthy link, so a legit slow
1422        // transfer is never aborted; only a true stall trips it.
1423        assert_eq!(part_put_timeout(8 * 1024 * 1024), Duration::from_secs(188));
1424        // 64 MiB part stays generous (~18 min).
1425        assert_eq!(
1426            part_put_timeout(64 * 1024 * 1024),
1427            Duration::from_secs(60 + 1024)
1428        );
1429        // A tiny/empty part still gets the full fixed base.
1430        assert_eq!(part_put_timeout(0), Duration::from_secs(60));
1431        // Monotonic in part size (below the cap).
1432        assert!(part_put_timeout(32 * 1024 * 1024) > part_put_timeout(8 * 1024 * 1024));
1433        // A huge part is capped at the 30 min operational ceiling rather than the
1434        // ~22.8 h the raw formula would yield, so stall recovery stays bounded.
1435        assert_eq!(part_put_timeout(5 * 1024 * 1024 * 1024), PART_TIMEOUT_MAX);
1436        assert_eq!(part_put_timeout(u64::MAX), PART_TIMEOUT_MAX);
1437    }
1438
1439    #[test]
1440    fn terminal_errors_are_only_contract_violations() {
1441        assert!(is_terminal(&UploadError::MalformedSession("bad".into())));
1442        assert!(is_terminal(&UploadError::SizeOverflow {
1443            what: "x",
1444            value: 1,
1445        }));
1446        // Network-ish failures must stay retryable so the outer loop re-sweeps.
1447        assert!(!is_terminal(&UploadError::Io(std::io::Error::other(
1448            "reset"
1449        ))));
1450        assert!(!is_terminal(&UploadError::MissingETag { part_number: 1 }));
1451        assert!(!is_terminal(&UploadError::StorageStatus {
1452            status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1453            part_number: Some(1),
1454            body: String::new(),
1455        }));
1456    }
1457
1458    #[test]
1459    fn parse_etag_rejects_missing_and_blank() {
1460        use reqwest::header::{HeaderMap, HeaderValue, ETAG};
1461        let mut ok = HeaderMap::new();
1462        ok.insert(ETAG, HeaderValue::from_static("\"etag-7\""));
1463        assert_eq!(parse_etag(&ok, 7).unwrap(), "\"etag-7\"");
1464
1465        // Missing header.
1466        assert!(matches!(
1467            parse_etag(&HeaderMap::new(), 7),
1468            Err(UploadError::MissingETag { part_number: 7 })
1469        ));
1470        // Present but empty / whitespace-only — must be rejected, not finalized.
1471        for blank in ["", "   "] {
1472            let mut h = HeaderMap::new();
1473            h.insert(ETAG, HeaderValue::from_str(blank).unwrap());
1474            assert!(
1475                matches!(parse_etag(&h, 7), Err(UploadError::MissingETag { .. })),
1476                "blank ETag {blank:?} must be rejected"
1477            );
1478        }
1479    }
1480
1481    // ---- REPRODUCE: legacy behavior = one pass, no outer rounds ----
1482
1483    #[tokio::test]
1484    async fn repro_single_part_blip_sinks_whole_upload_without_rounds() {
1485        // Part 3 fails once. With NO extra rounds (the legacy abort-on-first-
1486        // exhaustion behavior) that single transient failure fails the entire
1487        // upload, discarding the work done on parts 1, 2, 4, 5.
1488        let fake = FakeUploader::new(HashMap::from([(3, 1)]));
1489        let f = fake.clone();
1490        let res = upload_parts_resilient(plans(5), 4, no_delay(0), move |p| f.call(p)).await;
1491        assert!(
1492            res.is_err(),
1493            "a single transient part failure should sink the upload under legacy (0-round) semantics"
1494        );
1495    }
1496
1497    // ---- FIX: outer rounds re-sweep only the failed parts ----
1498
1499    #[tokio::test]
1500    async fn fix_single_part_blip_recovers_on_a_later_round() {
1501        let fake = FakeUploader::new(HashMap::from([(3, 1)]));
1502        let f = fake.clone();
1503        let res = upload_parts_resilient(plans(5), 4, no_delay(3), move |p| f.call(p))
1504            .await
1505            .expect("the flaky part should recover on a later round");
1506        // All five parts present, ascending, with the right ETags.
1507        let nums: Vec<i32> = res.iter().map(|p| p.part_number).collect();
1508        assert_eq!(nums, vec![1, 2, 3, 4, 5]);
1509        assert_eq!(res[2].e_tag, "etag-3");
1510        // The flaky part was attempted twice (round 0 fail, round 1 success);
1511        // every healthy part exactly once — completed work is never redone.
1512        assert_eq!(fake.attempts_for(3), 2);
1513        for n in [1, 2, 4, 5] {
1514            assert_eq!(fake.attempts_for(n), 1, "part {n} must not be re-uploaded");
1515        }
1516    }
1517
1518    #[tokio::test]
1519    async fn fix_multiple_flaky_parts_all_recover() {
1520        let fake = FakeUploader::new(HashMap::from([(2, 2), (5, 1), (7, 3)]));
1521        let f = fake.clone();
1522        let res = upload_parts_resilient(plans(8), 4, no_delay(3), move |p| f.call(p))
1523            .await
1524            .expect("all parts should recover within the round budget");
1525        assert_eq!(res.len(), 8);
1526        assert_eq!(fake.attempts_for(2), 3); // 2 fails + success
1527        assert_eq!(fake.attempts_for(7), 4); // 3 fails + success
1528        assert_eq!(fake.attempts_for(5), 2);
1529    }
1530
1531    #[tokio::test]
1532    async fn permanent_failure_surfaced_after_exhausting_rounds() {
1533        // Part 4 always fails (more failures than rounds). After the initial
1534        // pass plus `max_extra_rounds` re-sweeps the upload gives up — but only
1535        // after exactly 1 + max_extra_rounds attempts of that part, and without
1536        // ever re-uploading the healthy parts.
1537        let fake = FakeUploader::new(HashMap::from([(4, 99)]));
1538        let f = fake.clone();
1539        let res = upload_parts_resilient(plans(5), 4, no_delay(2), move |p| f.call(p)).await;
1540        assert!(res.is_err());
1541        assert_eq!(fake.attempts_for(4), 3, "1 initial pass + 2 re-sweeps");
1542        for n in [1, 2, 3, 5] {
1543            assert_eq!(fake.attempts_for(n), 1);
1544        }
1545    }
1546
1547    #[tokio::test]
1548    async fn happy_path_uploads_each_part_exactly_once() {
1549        let fake = FakeUploader::new(HashMap::new());
1550        let f = fake.clone();
1551        let res = upload_parts_resilient(plans(6), 4, no_delay(3), move |p| f.call(p))
1552            .await
1553            .unwrap();
1554        assert_eq!(res.len(), 6);
1555        for n in 1..=6 {
1556            assert_eq!(fake.attempts_for(n), 1);
1557        }
1558    }
1559
1560    #[tokio::test]
1561    async fn concurrency_never_exceeds_base_cap() {
1562        let fake = FakeUploader::new(HashMap::new());
1563        let f = fake.clone();
1564        upload_parts_resilient(plans(20), 3, no_delay(3), move |p| f.call(p))
1565            .await
1566            .unwrap();
1567        assert!(
1568            fake.peak() <= 3,
1569            "peak in-flight {} exceeded the cap of 3",
1570            fake.peak()
1571        );
1572    }
1573
1574    #[tokio::test]
1575    async fn terminal_error_fails_fast_without_resweeping() {
1576        // A terminal error (server-contract violation) reproduces on every
1577        // re-sweep, so it must fail the upload immediately — NOT be retried for
1578        // all rounds the way a transient failure is.
1579        let p2_attempts = Arc::new(AtomicUsize::new(0));
1580        let counter = Arc::clone(&p2_attempts);
1581        let res = upload_parts_resilient(plans(4), 4, no_delay(3), move |plan: PartPlan| {
1582            let counter = Arc::clone(&counter);
1583            async move {
1584                if plan.part_number == 2 {
1585                    counter.fetch_add(1, Ordering::SeqCst);
1586                    Err(UploadError::MalformedSession("contract violation".into()))
1587                } else {
1588                    Ok(models::FinalizeUploadPart {
1589                        e_tag: format!("etag-{}", plan.part_number),
1590                        part_number: plan.part_number,
1591                    })
1592                }
1593            }
1594        })
1595        .await;
1596        assert!(matches!(res, Err(UploadError::MalformedSession(_))));
1597        assert_eq!(
1598            p2_attempts.load(Ordering::SeqCst),
1599            1,
1600            "a terminal error must be attempted once, never re-swept across rounds"
1601        );
1602    }
1603}