Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions clients/rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,26 @@ infer = { workspace = true }
jsonwebtoken = { workspace = true }
multer = { workspace = true }
objectstore-types = { workspace = true }
rand = { workspace = true }
# Pinned below workspace version for MSRV compatibility
reqwest = { version = "0.13.1", default-features = false, features = ["charset", "http2", "system-proxy", "json", "stream", "multipart", "query"] }
sentry-core = { version = ">=0.41", default-features = false, features = ["client"] }
serde = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
tokio = { version = "1.45.0", default-features = false, features = ["fs"] }
tokio = { version = "1.45.0", default-features = false, features = ["fs", "io-util", "rt", "sync", "time"] }
tokio-util = { workspace = true, features = ["io"] }
url = { workspace = true }
zstd-safe = { workspace = true }

[dev-dependencies]
anyhow = { workspace = true }
axum = { workspace = true }
futures-util = { workspace = true }
http-body-util = { workspace = true }
jsonwebtoken = { workspace = true }
objectstore-test = { workspace = true }
serde = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt"] }
zstd = { workspace = true }

Expand Down
14 changes: 12 additions & 2 deletions clients/rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@ session.put("payload")
.send().await?;
```

### Automatic Resumable Uploads

Large uploads automatically use resumable uploads and resend only what the server hasn't
stored after a failed write. No feature flag is required.

See [`PutBuilder`] for eligibility, configuration, and recovery behavior.

### Resumable Upload API

> **Feature flag required:** Enable `resumable-upload-api` to use this API.
Expand All @@ -176,7 +183,8 @@ If a non-final chunk is larger than the granularity but its size is not a multip
server may persist only its aligned prefix.

```rust,no_run
#![cfg(feature = "resumable-upload-api")]
# #[cfg(feature = "resumable-upload-api")]
# mod example {

use bytes::Bytes;
use anyhow::{Context as _, Result};
Expand All @@ -189,7 +197,7 @@ async fn upload_large_object(session: &Session, object: Bytes) -> Result<()> {
.content_type("application/octet-stream")
.send()
.await?
// Fall back to a regular [`Session:;put`].
// Fall back to a regular Session::put.
.context("resumable upload declined for this object")?;

let mut offset = 0;
Expand All @@ -208,6 +216,8 @@ async fn upload_large_object(session: &Session, object: Bytes) -> Result<()> {
};
}
}
# }
# fn main() {}
```

### Many API
Expand Down
32 changes: 32 additions & 0 deletions clients/rust/src/client.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::io;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;

Expand All @@ -21,6 +22,7 @@ struct ClientBuilderInner {
propagate_traces: bool,
reqwest_builder: reqwest::ClientBuilder,
token: Option<TokenProvider>,
spool_dir: Option<PathBuf>,
}

impl ClientBuilderInner {
Expand Down Expand Up @@ -71,6 +73,7 @@ impl ClientBuilder {
propagate_traces: false,
reqwest_builder,
token: None,
spool_dir: None,
}))
}

Expand Down Expand Up @@ -124,6 +127,16 @@ impl ClientBuilder {
Self(Ok(inner))
}

/// Sets the directory for temporary files used to prepare compressed resumable uploads.
///
/// By default, the system temporary directory is used. The directory must already exist.
/// If a temporary file cannot be created, uploads fall back to direct streaming.
pub fn spool_dir(self, path: impl Into<PathBuf>) -> Self {
let Ok(mut inner) = self.0 else { return self };
inner.spool_dir = Some(path.into());
Self(Ok(inner))
}

/// Returns a [`Client`] that uses this [`ClientBuilder`] configuration.
///
/// # Errors
Expand All @@ -141,6 +154,7 @@ impl ClientBuilder {
service_url: inner.service_url,
propagate_traces: inner.propagate_traces,
token: inner.token,
spool_dir: inner.spool_dir,
}),
})
}
Expand All @@ -161,6 +175,7 @@ pub struct Usecase {
name: Arc<UsecaseName>,
compression: Option<Compression>,
expiration_policy: ExpirationPolicy,
resumable_threshold_bytes: Option<u64>,
}

impl Usecase {
Expand All @@ -177,6 +192,7 @@ impl Usecase {
name: Arc::new(UsecaseName::new(name)?),
compression: Some(Compression::Zstd),
expiration_policy: ExpirationPolicy::default(),
resumable_threshold_bytes: Some(256 * 1024 * 1024),
})
}

Expand Down Expand Up @@ -234,6 +250,21 @@ impl Usecase {
}
}

/// Returns the minimum remaining input size for automatic resumable uploads.
pub fn resumable_threshold_bytes(&self) -> Option<u64> {
self.resumable_threshold_bytes
}

/// Sets the minimum remaining input size for automatic resumable uploads.
///
/// Defaults to 256 MiB. Pass [`None`] to disable automatic resumables or zero to include
/// every supported payload. Individual uploads can override this using
/// [`crate::PutBuilder::resumable_threshold_bytes`]. See [`crate::PutBuilder`] for eligibility.
pub fn with_resumable_threshold_bytes(mut self, threshold: impl Into<Option<u64>>) -> Self {
self.resumable_threshold_bytes = threshold.into();
self
}

/// Creates a new custom [`Scope`].
///
/// Add parts to it using [`Scope::push`].
Expand Down Expand Up @@ -325,6 +356,7 @@ impl Scope {

#[derive(Debug)]
pub(crate) struct ClientInner {
pub(crate) spool_dir: Option<PathBuf>,
reqwest: reqwest::Client,
service_url: Url,
propagate_traces: bool,
Expand Down
9 changes: 6 additions & 3 deletions clients/rust/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,22 @@ pub enum Error {
/// The error message.
message: String,
},
/// An automatic resumable upload failed after session creation.
///
/// The source preserves the failure that caused the upload to stop. Cancellation errors
/// are ignored so they cannot replace that failure.
#[error("upload failed: {0}")]
UploadFailed(#[source] Box<dyn std::error::Error + Send + Sync>),
/// A resumable upload session expired, was canceled, or could otherwise not be found.
#[cfg(feature = "resumable-upload-api")]
#[error("resumable upload session is not available")]
ResumableUploadUnavailable,
/// A resumable chunk starts at a different offset than the server currently holds.
#[cfg(feature = "resumable-upload-api")]
#[error("upload offset mismatch (server holds {offset} bytes)")]
UploadOffsetMismatch {
/// The server's authoritative upload offset in bytes.
offset: u64,
},
/// A non-final chunk is shorter than the upload granularity.
#[cfg(feature = "resumable-upload-api")]
#[error(
"non-final chunk {chunk_length} is smaller than upload granularity {upload_granularity}"
)]
Expand Down
1 change: 0 additions & 1 deletion clients/rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ mod key;
mod many;
mod put;
mod response;
#[cfg(feature = "resumable-upload-api")]
mod resumable;
pub mod utils;

Expand Down
3 changes: 3 additions & 0 deletions clients/rust/src/many.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ impl From<PutBuilder> for BatchOperation {
compression,
body,
session: _session,
resumable_threshold_bytes: _,
} = value;
BatchOperation::Insert {
key,
Expand Down Expand Up @@ -640,6 +641,8 @@ async fn execute_individual(op: BatchOperation, session: &Session) -> OperationR
} => {
let error_key = key.clone().unwrap_or_else(|| "<unknown>".to_owned());
let put = PutBuilder {
// Automatic resumables are currently limited to standalone uploads.
resumable_threshold_bytes: None,
session: session.clone(),
metadata,
compression,
Expand Down
84 changes: 74 additions & 10 deletions clients/rust/src/put.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ pub use objectstore_types::metadata::{Compression, ExpirationPolicy};
use crate::response::ResponseExt as _;
use crate::{ClientStream, ObjectKey, Session};

mod automatic;
mod payload;

/// The response returned from the service after uploading an object.
#[derive(Debug, Deserialize)]
pub struct PutResponse {
Expand Down Expand Up @@ -75,6 +78,7 @@ impl Session {
.compression()
.map(CompressionMode::Compress),
key: None,
resumable_threshold_bytes: self.scope.usecase().resumable_threshold_bytes(),
body,
}
}
Expand All @@ -85,11 +89,15 @@ impl Session {
}

/// Creates or replaces an object using a streaming payload.
///
/// Streams use direct uploads, regardless of the resumable threshold.
pub fn put_stream(&self, body: ClientStream) -> PutBuilder {
self.put_body(PutBody::Stream(body))
}

/// Creates or replaces an object using an [`AsyncRead`] payload.
///
/// Readers use direct uploads. Use [`Self::put_file`] for automatic resumable file uploads.
pub fn put_read<R>(&self, body: R) -> PutBuilder
where
R: AsyncRead + Send + Sync + 'static,
Expand Down Expand Up @@ -125,16 +133,47 @@ impl Session {
}

/// A [`put`](Session::put) request builder.
///
/// # Automatic resumable uploads
///
/// Buffers and regular files (including paths) use resumable uploads at 256 MiB by default,
/// measured from the current position before client compression. Override or disable this with
/// [`Self::resumable_threshold_bytes`]. Streams, generic readers, and files of unknown size use
/// direct uploads. Files must remain unchanged during upload.
///
/// Input is reused unless compression is needed, in which case the compressed payload is spooled
/// to a temporary file. Use [`crate::ClientBuilder::spool_dir`] to choose its directory.
/// Transient failures and writes making no progress are retried up to twice. Acknowledged
/// progress resets the retry count.
///
/// No feature flag is required. Uploads queued through [`Session::many`] do not use resumables.
#[derive(Debug)]
pub struct PutBuilder {
pub(crate) session: Session,
pub(crate) metadata: Metadata,
pub(crate) compression: Option<CompressionMode>,
pub(crate) key: Option<ObjectKey>,
pub(crate) body: PutBody,
pub(crate) resumable_threshold_bytes: Option<u64>,
}

impl PutBuilder {
/// Sets the minimum remaining input size for automatic resumable uploads.
///
/// Inherits [`crate::Usecase::resumable_threshold_bytes`] unless overridden. Pass [`None`]
/// to disable or zero to include every supported payload. See [`PutBuilder`] for eligibility.
///
/// ```no_run
/// # async fn example(session: objectstore_client::Session) -> objectstore_client::Result<()> {
/// session.put("payload").resumable_threshold_bytes(None).send().await?;
/// # Ok(())
/// # }
/// ```
pub fn resumable_threshold_bytes(mut self, threshold: impl Into<Option<u64>>) -> Self {
self.resumable_threshold_bytes = threshold.into();
self
}

/// Sets an explicit object key.
///
/// If a key is specified, the object will be stored under that key. Otherwise, the Objectstore
Expand All @@ -146,7 +185,7 @@ impl PutBuilder {

/// Sets an explicit compression algorithm to be used for this payload.
///
/// The client compresses the payload while uploading it and records the algorithm in the
/// The client compresses the payload and records the algorithm in the
/// object's metadata. [`None`] should be used if no compression should be performed by the
/// client, either because the payload is uncompressible (such as a media format), or if the
/// compression should not be recorded for this object.
Expand Down Expand Up @@ -323,17 +362,42 @@ impl PutBuilder {
None => reqwest::Method::POST,
};

let mut builder = self
.session
.request(method, self.key.as_deref().unwrap_or_default())?;

self.metadata.compression = self.compression.map(CompressionMode::compression);
let body = encode_body(self.body, self.compression).await?;

builder = builder.headers(self.metadata.to_headers("")?);
let headers = self.metadata.to_headers("")?;
let mut prepared = payload::prepare(
self.body,
self.compression,
self.resumable_threshold_bytes,
self.session.client.spool_dir.clone(),
)
.await?;
if let payload::PreparedBody::Replay(body) = &mut prepared {
let create = crate::resumable::CreateResumableUploadBuilder {
session: self.session.clone(),
total_length: body.len(),
key: self.key.clone(),
metadata: self.metadata,
};
if let Ok(Some(upload)) = create.send().await {
return automatic::upload(body, upload).await;
}
}

let response = builder.body(body).send().await?;
Ok(response.error_for_status_and_drain().await?.json().await?)
// All direct uploads, including preparation and session-creation fallbacks, converge here.
prepared
.with_body(self.compression, |body, content_length| async move {
// Mint authentication after preparation, which may outlast a token's validity.
let mut builder = self
.session
.request(method, self.key.as_deref().unwrap_or_default())?
.headers(headers);
if let Some(length) = content_length {
builder = builder.header(reqwest::header::CONTENT_LENGTH, length);
}
let response = builder.body(body).send().await?;
Ok(response.error_for_status_and_drain().await?.json().await?)
})
.await
}
}

Expand Down
Loading
Loading