use std::collections::HashMap;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use crate::apis::configuration::Configuration;
use crate::apis::{self, Error};
use crate::models;
const MIB: u64 = 1024 * 1024;
pub const DEFAULT_MAX_CONCURRENCY: usize = 10;
pub const DEFAULT_PART_SIZE: u64 = 8 * MIB;
pub const TARGET_MAX_PARTS: u64 = 9000;
pub const MIN_PART_SIZE: u64 = 5 * MIB;
pub const MAX_PART_SIZE: u64 = 5 * 1024 * MIB;
pub const MAX_MINT_BATCH: usize = 100;
pub const STREAMING_THRESHOLD: u64 = DEFAULT_PART_SIZE;
pub const UPLOAD_MEMORY_BUDGET: u64 = 256 * MIB;
pub fn auto_part_size_hint(declared_size: u64) -> u64 {
let by_count = declared_size.div_ceil(TARGET_MAX_PARTS);
let raw = DEFAULT_PART_SIZE.max(by_count);
let rounded = raw.div_ceil(MIB) * MIB;
rounded.clamp(MIN_PART_SIZE, MAX_PART_SIZE)
}
pub fn effective_in_flight(max_concurrency: usize, part_size: u64) -> usize {
let cap = max_concurrency.max(1);
let by_budget = (UPLOAD_MEMORY_BUDGET / part_size.max(1)).max(1) as usize;
by_budget.min(cap)
}
pub type UploadProgress = Arc<dyn Fn(u64, u64) + Send + Sync>;
#[derive(Default, Clone)]
pub struct UploadOptions {
pub content_type: Option<String>,
pub content_encoding: Option<String>,
pub filename: Option<String>,
pub part_size: Option<u64>,
pub max_concurrency: Option<usize>,
pub progress: Option<UploadProgress>,
}
impl std::fmt::Debug for UploadOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UploadOptions")
.field("content_type", &self.content_type)
.field("content_encoding", &self.content_encoding)
.field("filename", &self.filename)
.field("part_size", &self.part_size)
.field("max_concurrency", &self.max_concurrency)
.field("progress", &self.progress.as_ref().map(|_| "<callback>"))
.finish()
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum UploadError {
Io(std::io::Error),
CreateSession(Error<apis::uploads_api::CreateUploadSessionHandlerError>),
Storage(reqwest::Error),
StorageStatus {
status: reqwest::StatusCode,
part_number: Option<i32>,
body: String,
},
MissingETag {
part_number: i32,
},
MalformedSession(String),
SizeOverflow {
what: &'static str,
value: u64,
},
Finalize(Error<apis::uploads_api::FinalizeUploadHandlerError>),
MintParts(Error<apis::uploads_api::MintUploadPartsHandlerError>),
}
impl std::fmt::Display for UploadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UploadError::Io(e) => write!(f, "reading the source file failed: {e}"),
UploadError::CreateSession(e) => write!(f, "opening the upload session failed: {e}"),
UploadError::Storage(e) => write!(f, "uploading to storage failed: {e}"),
UploadError::StorageStatus {
status,
part_number,
body,
} => match part_number {
Some(n) => write!(f, "storage rejected part {n} with status {status}: {body}"),
None => write!(
f,
"storage rejected the upload with status {status}: {body}"
),
},
UploadError::MissingETag { part_number } => write!(
f,
"storage returned no ETag for part {part_number}; cannot finalize"
),
UploadError::SizeOverflow { what, value } => {
write!(
f,
"{what} ({value} bytes) exceeds the maximum supported size"
)
}
UploadError::MalformedSession(msg) => {
write!(f, "malformed upload session response: {msg}")
}
UploadError::Finalize(e) => write!(f, "finalizing the upload failed: {e}"),
UploadError::MintParts(e) => write!(f, "minting upload part URLs failed: {e}"),
}
}
}
impl std::error::Error for UploadError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
UploadError::Io(e) => Some(e),
UploadError::CreateSession(e) => Some(e),
UploadError::Storage(e) => Some(e),
UploadError::Finalize(e) => Some(e),
UploadError::MintParts(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for UploadError {
fn from(e: std::io::Error) -> Self {
UploadError::Io(e)
}
}
pub(crate) async fn upload_file(
configuration: &Configuration,
path: &Path,
opts: UploadOptions,
) -> Result<models::FinalizeUploadResponse, UploadError> {
let metadata = tokio::fs::metadata(path).await?;
let total = metadata.len();
let filename = opts
.filename
.clone()
.or_else(|| path.file_name().map(|n| n.to_string_lossy().into_owned()));
let part_size_hint = opts.part_size.unwrap_or_else(|| auto_part_size_hint(total));
let part_size_hint_i64 =
i64::try_from(part_size_hint).map_err(|_| UploadError::SizeOverflow {
what: "part_size",
value: part_size_hint,
})?;
let declared_size_bytes = if total > STREAMING_THRESHOLD {
None
} else {
let size = i64::try_from(total).map_err(|_| UploadError::SizeOverflow {
what: "declared_size_bytes",
value: total,
})?;
Some(Some(size))
};
let create = models::CreateUploadRequest {
content_type: opts.content_type.clone().map(Some),
content_encoding: opts.content_encoding.clone().map(Some),
filename: filename.map(Some),
part_size: Some(Some(part_size_hint_i64)),
declared_size_bytes,
..models::CreateUploadRequest::new()
};
let session = apis::uploads_api::create_upload_session_handler(configuration, create)
.await
.map_err(UploadError::CreateSession)?;
if let Some(ref progress) = opts.progress {
progress(0, total);
}
let parts = match session.mode.as_str() {
"single" => {
upload_single(&session, path, total, opts.progress.as_ref()).await?;
None
}
"multipart" => {
let max_concurrency = opts.max_concurrency.unwrap_or(DEFAULT_MAX_CONCURRENCY);
let parts = if matches!(session.part_urls, Some(Some(_))) {
upload_multipart(
configuration,
&session,
path,
total,
max_concurrency,
opts.progress.as_ref(),
)
.await?
} else {
upload_multipart_streaming(
configuration,
&session,
path,
total,
max_concurrency,
opts.progress.as_ref(),
)
.await?
};
Some(parts)
}
other => {
return Err(UploadError::MalformedSession(format!(
"unknown upload mode `{other}`"
)))
}
};
let finalize_body = Some(
parts
.map(|parts| models::FinalizeUploadRequest {
parts: Some(Some(parts)),
})
.unwrap_or_default(),
);
let mut finalize_config = configuration.clone();
finalize_config.retry.max_retries = 0;
apis::uploads_api::finalize_upload_handler(
&finalize_config,
&session.upload_id,
&session.finalize_token,
finalize_body,
)
.await
.map_err(UploadError::Finalize)
}
async fn upload_single(
session: &models::UploadSessionResponse,
path: &Path,
total: u64,
progress: Option<&UploadProgress>,
) -> Result<(), UploadError> {
let url =
session.url.clone().flatten().ok_or_else(|| {
UploadError::MalformedSession("single upload missing `url`".to_owned())
})?;
let file = tokio::fs::File::open(path).await?;
let body = progress_stream(file, total, progress.cloned());
put_stream_to_storage(&url, &session.headers, body, total).await?;
if let Some(progress) = progress {
progress(total, total);
}
Ok(())
}
fn progress_stream(
file: tokio::fs::File,
total: u64,
progress: Option<UploadProgress>,
) -> ProgressStream {
use tokio_util::codec::{BytesCodec, FramedRead};
ProgressStream {
inner: FramedRead::new(file, BytesCodec::new()),
done: 0,
total,
progress,
}
}
struct ProgressStream {
inner: tokio_util::codec::FramedRead<tokio::fs::File, tokio_util::codec::BytesCodec>,
done: u64,
total: u64,
progress: Option<UploadProgress>,
}
impl futures_core::Stream for ProgressStream {
type Item = std::io::Result<bytes::Bytes>;
fn poll_next(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
use std::task::Poll;
let this = self.get_mut();
match std::pin::Pin::new(&mut this.inner).poll_next(cx) {
Poll::Ready(Some(Ok(chunk))) => {
let chunk = chunk.freeze();
this.done = (this.done + chunk.len() as u64).min(this.total);
if let Some(ref progress) = this.progress {
progress(this.done, this.total);
}
Poll::Ready(Some(Ok(chunk)))
}
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
}
}
async fn upload_multipart(
configuration: &Configuration,
session: &models::UploadSessionResponse,
path: &Path,
total: u64,
max_concurrency: usize,
progress: Option<&UploadProgress>,
) -> Result<Vec<models::FinalizeUploadPart>, UploadError> {
let part_urls = session.part_urls.clone().flatten().ok_or_else(|| {
UploadError::MalformedSession("multipart upload missing `part_urls`".to_owned())
})?;
let part_size = session.part_size.flatten().ok_or_else(|| {
UploadError::MalformedSession("multipart upload missing `part_size`".to_owned())
})?;
if part_size <= 0 {
return Err(UploadError::MalformedSession(format!(
"multipart upload has non-positive `part_size` {part_size}"
)));
}
let part_size = part_size as u64;
if part_urls.is_empty() {
return Err(UploadError::MalformedSession(
"multipart upload has empty `part_urls`".to_owned(),
));
}
let expected_parts = total.div_ceil(part_size).max(1);
if part_urls.len() as u64 != expected_parts {
return Err(UploadError::MalformedSession(format!(
"multipart upload returned {} part URLs but the file ({total} bytes) \
splits into {expected_parts} parts of {part_size} bytes",
part_urls.len()
)));
}
let in_flight_cap = effective_in_flight(max_concurrency, part_size);
let done = Arc::new(AtomicU64::new(0));
let mut results: Vec<Option<models::FinalizeUploadPart>> = vec![None; part_urls.len()];
let mut next = 0usize;
let mut join_set: tokio::task::JoinSet<
Result<(usize, models::FinalizeUploadPart), UploadError>,
> = tokio::task::JoinSet::new();
loop {
while join_set.len() < in_flight_cap && next < part_urls.len() {
let index = next;
next += 1;
let part_number = (index + 1) as i32;
let url = part_urls[index].clone();
let offset = index as u64 * part_size;
if offset >= total && total > 0 {
continue;
}
let len = part_size.min(total.saturating_sub(offset));
let headers = session.headers.clone();
let done = Arc::clone(&done);
let progress = progress.cloned();
let retry = configuration.retry;
let path = path.to_path_buf();
join_set.spawn(async move {
let chunk = read_range(&path, offset, len).await?;
let resp =
put_to_storage(&retry, &url, &headers, chunk, len, Some(part_number)).await?;
let e_tag = resp
.headers()
.get(reqwest::header::ETAG)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned())
.ok_or(UploadError::MissingETag { part_number })?;
if let Some(progress) = progress.as_ref() {
let now = done.fetch_add(len, Ordering::SeqCst) + len;
progress(now, total);
}
Ok::<_, UploadError>((index, models::FinalizeUploadPart { e_tag, part_number }))
});
}
match join_set.join_next().await {
Some(Ok(Ok((index, part)))) => results[index] = Some(part),
Some(Ok(Err(e))) => {
join_set.abort_all();
return Err(e);
}
Some(Err(join_err)) => {
join_set.abort_all();
return Err(UploadError::Io(std::io::Error::other(format!(
"part upload task failed: {join_err}"
))));
}
None => break,
}
}
Ok(results.into_iter().flatten().collect())
}
async fn upload_multipart_streaming(
configuration: &Configuration,
session: &models::UploadSessionResponse,
path: &Path,
total: u64,
max_concurrency: usize,
progress: Option<&UploadProgress>,
) -> Result<Vec<models::FinalizeUploadPart>, UploadError> {
let part_size = session.part_size.flatten().ok_or_else(|| {
UploadError::MalformedSession("streaming upload missing `part_size`".to_owned())
})?;
if part_size <= 0 {
return Err(UploadError::MalformedSession(format!(
"streaming upload has non-positive `part_size` {part_size}"
)));
}
let part_size = part_size as u64;
let expected_parts = total.div_ceil(part_size).max(1) as usize;
let in_flight_cap = effective_in_flight(max_concurrency, part_size);
let done = Arc::new(AtomicU64::new(0));
let mut results: Vec<Option<models::FinalizeUploadPart>> = vec![None; expected_parts];
let config = Arc::new(configuration.clone());
let upload_id = session.upload_id.clone();
let finalize_token = session.finalize_token.clone();
let mut minted: std::collections::VecDeque<(i32, String)> = std::collections::VecDeque::new();
let mut next_to_mint = 1i32; let mut next_part = 1i32;
let mut join_set: tokio::task::JoinSet<
Result<(usize, models::FinalizeUploadPart), UploadError>,
> = tokio::task::JoinSet::new();
loop {
while join_set.len() < in_flight_cap && (next_part as usize) <= expected_parts {
if minted.len() < in_flight_cap && (next_to_mint as usize) <= expected_parts {
let lo = next_to_mint;
let hi = (lo as usize + MAX_MINT_BATCH - 1).min(expected_parts) as i32;
let resp = apis::uploads_api::mint_upload_parts_handler(
&config,
&upload_id,
&finalize_token,
models::MintUploadPartsRequest::new((lo..=hi).collect()),
)
.await
.map_err(UploadError::MintParts)?;
let mut by_number: std::collections::HashMap<i32, String> = resp
.parts
.into_iter()
.map(|p| (p.part_number, p.url))
.collect();
for n in lo..=hi {
let url = by_number.remove(&n).ok_or_else(|| {
UploadError::MalformedSession(format!(
"mint did not return a URL for part {n}"
))
})?;
minted.push_back((n, url));
}
if !by_number.is_empty() {
return Err(UploadError::MalformedSession(
"mint returned URLs for parts that were not requested".to_owned(),
));
}
next_to_mint = hi + 1;
}
let (part_number, url) = minted.pop_front().ok_or_else(|| {
UploadError::MalformedSession(
"streaming mint returned no URL for a pending part".to_owned(),
)
})?;
debug_assert_eq!(part_number, next_part);
next_part += 1;
let index = (part_number - 1) as usize;
let offset = index as u64 * part_size;
let len = part_size.min(total.saturating_sub(offset));
let headers = session.headers.clone();
let done = Arc::clone(&done);
let progress = progress.cloned();
let retry = configuration.retry;
let path = path.to_path_buf();
let config = Arc::clone(&config);
let upload_id = upload_id.clone();
let finalize_token = finalize_token.clone();
join_set.spawn(async move {
let chunk = read_range(&path, offset, len).await?;
let resp =
match put_to_storage(&retry, &url, &headers, chunk, len, Some(part_number))
.await
{
Err(UploadError::StorageStatus { status, .. })
if status == reqwest::StatusCode::FORBIDDEN =>
{
let remint = apis::uploads_api::mint_upload_parts_handler(
&config,
&upload_id,
&finalize_token,
models::MintUploadPartsRequest::new(vec![part_number]),
)
.await
.map_err(UploadError::MintParts)?;
let fresh = remint
.parts
.into_iter()
.find(|p| p.part_number == part_number)
.ok_or_else(|| {
UploadError::MalformedSession(format!(
"re-mint returned no URL for part {part_number}"
))
})?;
let chunk = read_range(&path, offset, len).await?;
put_to_storage(
&retry,
&fresh.url,
&headers,
chunk,
len,
Some(part_number),
)
.await?
}
other => other?,
};
let e_tag = resp
.headers()
.get(reqwest::header::ETAG)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned())
.ok_or(UploadError::MissingETag { part_number })?;
if let Some(progress) = progress.as_ref() {
let now = done.fetch_add(len, Ordering::SeqCst) + len;
progress(now, total);
}
Ok::<_, UploadError>((index, models::FinalizeUploadPart { e_tag, part_number }))
});
}
match join_set.join_next().await {
Some(Ok(Ok((index, part)))) => results[index] = Some(part),
Some(Ok(Err(e))) => {
join_set.abort_all();
return Err(e);
}
Some(Err(join_err)) => {
join_set.abort_all();
return Err(UploadError::Io(std::io::Error::other(format!(
"part upload task failed: {join_err}"
))));
}
None => break,
}
}
let mut parts = Vec::with_capacity(results.len());
for (i, slot) in results.into_iter().enumerate() {
parts.push(slot.ok_or_else(|| {
UploadError::MalformedSession(format!("part {} was never uploaded", i + 1))
})?);
}
Ok(parts)
}
async fn read_range(path: &Path, offset: u64, len: u64) -> Result<bytes::Bytes, UploadError> {
use tokio::io::{AsyncReadExt, AsyncSeekExt};
let mut file = tokio::fs::File::open(path).await?;
file.seek(std::io::SeekFrom::Start(offset)).await?;
let mut buf = vec![0u8; len as usize];
file.read_exact(&mut buf).await?;
Ok(bytes::Bytes::from(buf))
}
async fn put_to_storage(
retry: &crate::query::RetryPolicy,
url: &str,
headers: &HashMap<String, String>,
body: bytes::Bytes,
content_length: u64,
part_number: Option<i32>,
) -> Result<reqwest::Response, UploadError> {
let client = storage_client();
let mut req_builder = client
.request(reqwest::Method::PUT, url)
.header(reqwest::header::CONTENT_LENGTH, content_length);
for (name, value) in headers {
req_builder = req_builder.header(name.as_str(), value.as_str());
}
req_builder = req_builder.body(reqwest::Body::from(body));
let req = req_builder.build().map_err(UploadError::Storage)?;
crate::http_log::log_request(&req);
let resp = crate::http::execute_retrying(&client, req, retry)
.await
.map_err(UploadError::Storage)?;
let status = resp.status();
crate::http_log::log_response_status(status);
if status.is_client_error() || status.is_server_error() {
let body = resp.text().await.unwrap_or_default();
crate::http_log::log_response_body(&body);
return Err(UploadError::StorageStatus {
status,
part_number,
body,
});
}
Ok(resp)
}
async fn put_stream_to_storage<S>(
url: &str,
headers: &HashMap<String, String>,
body: S,
content_length: u64,
) -> Result<reqwest::Response, UploadError>
where
S: futures_core::Stream<Item = std::io::Result<bytes::Bytes>> + Send + 'static,
{
let client = storage_client();
let mut req_builder = client
.request(reqwest::Method::PUT, url)
.header(reqwest::header::CONTENT_LENGTH, content_length);
for (name, value) in headers {
req_builder = req_builder.header(name.as_str(), value.as_str());
}
req_builder = req_builder.body(reqwest::Body::wrap_stream(body));
let req = req_builder.build().map_err(UploadError::Storage)?;
crate::http_log::log_request(&req);
let resp = client.execute(req).await.map_err(UploadError::Storage)?;
let status = resp.status();
crate::http_log::log_response_status(status);
if status.is_client_error() || status.is_server_error() {
let body = resp.text().await.unwrap_or_default();
crate::http_log::log_response_body(&body);
return Err(UploadError::StorageStatus {
status,
part_number: None,
body,
});
}
Ok(resp)
}
fn storage_client() -> reqwest::Client {
static STORAGE_CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
STORAGE_CLIENT
.get_or_init(|| {
reqwest::Client::builder()
.build()
.unwrap_or_default()
})
.clone()
}
#[cfg(test)]
mod tests {
use super::*;
fn part_count(size: u64, part: u64) -> u64 {
size.div_ceil(part)
}
#[test]
fn auto_part_size_keeps_8mib_for_normal_files() {
assert_eq!(auto_part_size_hint(0), DEFAULT_PART_SIZE);
assert_eq!(auto_part_size_hint(1), DEFAULT_PART_SIZE);
assert_eq!(auto_part_size_hint(100 * MIB), DEFAULT_PART_SIZE);
assert_eq!(auto_part_size_hint(1024 * MIB), DEFAULT_PART_SIZE); let boundary = DEFAULT_PART_SIZE * TARGET_MAX_PARTS;
assert_eq!(auto_part_size_hint(boundary), DEFAULT_PART_SIZE);
}
#[test]
fn auto_part_size_scales_up_for_very_large_files_and_caps_parts() {
let big = 200 * 1024 * MIB; let hint = auto_part_size_hint(big);
assert!(
hint > DEFAULT_PART_SIZE,
"hint should scale above 8 MiB for a 200 GiB file, got {hint}"
);
assert_eq!(hint % MIB, 0, "hint must be a whole MiB, got {hint}");
assert!(
part_count(big, hint) <= TARGET_MAX_PARTS,
"part count {} must be <= {TARGET_MAX_PARTS}",
part_count(big, hint)
);
assert!((MIN_PART_SIZE..=MAX_PART_SIZE).contains(&hint));
}
#[test]
fn auto_part_size_clamps_to_max_for_enormous_files() {
let enormous = 100 * 1024 * 1024 * MIB; assert_eq!(auto_part_size_hint(enormous), MAX_PART_SIZE);
}
#[test]
fn effective_in_flight_capped_by_max_concurrency_for_small_parts() {
assert_eq!(effective_in_flight(12, 8 * MIB), 12);
assert_eq!(effective_in_flight(10, 8 * MIB), 10);
assert_eq!(effective_in_flight(12, MIB), 12);
}
#[test]
fn effective_in_flight_reduced_by_memory_budget_for_large_parts() {
assert_eq!(effective_in_flight(12, 64 * MIB), 4);
assert_eq!(effective_in_flight(12, 128 * MIB), 2);
}
#[test]
fn effective_in_flight_honors_explicit_low_concurrency() {
assert_eq!(effective_in_flight(1, 8 * MIB), 1);
assert_eq!(effective_in_flight(0, 8 * MIB), 1);
assert_eq!(effective_in_flight(2, 8 * MIB), 2);
}
#[test]
fn effective_in_flight_floors_at_1_for_huge_parts_and_handles_zero() {
assert_eq!(effective_in_flight(12, UPLOAD_MEMORY_BUDGET * 4), 1);
assert_eq!(effective_in_flight(12, 0), 12);
}
}