Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
96bf619
feat(rust): Add automatic resumable uploads
lcian Oct 21, 2026
64aae59
ref(rust-client): Simplify automatic upload recovery
lcian Oct 21, 2026
e0e4113
ref(rust-client): Address automatic upload API feedback
lcian Oct 21, 2026
5695815
Merge branch 'main' into lcian/feat/rust-automatic-resumable-uploads
sentry-junior[bot] Oct 22, 2026
b06ec0b
ref(rs-client): Simplify automatic upload preparation and trim duplic…
sentry-junior[bot] Oct 23, 2026
34fd381
ref(rs-client): Poll the upload receiver directly
lcian Oct 28, 2026
a478425
chore: Merge main and resolve Rust client conflicts
lcian Oct 28, 2026
e8a70b3
docs(rs-client): Keep upload documentation at the right level
lcian Oct 28, 2026
593f8b0
ref(rs-client): Separate resumable API from protocol implementation
lcian Oct 28, 2026
7300c71
ref(rs-client): Use independent readers for resumable paths
lcian Oct 28, 2026
2af4aa9
ref(rs-client): Share resumable constructors and keep fields private
lcian Oct 28, 2026
0241bd9
ref(rs-client): Simplify direct upload body conversion
lcian Oct 28, 2026
37a16ae
ref(rs-client): Stream upload files with an async generator
lcian Oct 28, 2026
30c7a1a
ref(rs-client): Follow existing file streaming pattern
lcian Oct 28, 2026
a0a007d
ref(rs-client): Use consistent internal constructor names
lcian Oct 28, 2026
f3f93c9
docs(rs-client): Simplify automatic upload module comment
lcian Oct 28, 2026
7322630
ref(rs-client): Return offsets and inline resumable recovery
lcian Oct 28, 2026
60764f3
ref(rs-client): Rename offset helper to get_offset
lcian Oct 28, 2026
188afab
ref(rs-client): Clarify prepared HTTP body conversion name
lcian Oct 28, 2026
3f64519
ref(rs-client): Fall back for unsupported upload payloads
lcian Oct 28, 2026
15bc2d0
docs(rs-client): Explain unreachable payload fallback branches
lcian Oct 28, 2026
27ec7ec
chore: Merge main into automatic resumable uploads branch
lcian Oct 29, 2026
042a359
chore: Trigger changelog preview
sentry-junior[bot] Oct 29, 2026
2db1f26
ref(rs-client): Simplify automatic upload errors and code layout
lcian Oct 29, 2026
3f07540
ref(rs-client): Flatten the resumable upload module
lcian Oct 29, 2026
c07e591
ref(rs-client): Avoid boxed readers when spooling uploads
lcian Oct 29, 2026
9ca153c
ref(rs-client): Encapsulate spool settings and align visibility
lcian Oct 29, 2026
ddafbf3
fix(rs-client): Mint fallback tokens after upload preparation
lcian Oct 30, 2026
c6b8bef
chore: Merge main and update dependencies
lcian Oct 30, 2026
a31e2d6
chore: Merge main into automatic resumable uploads
lcian Oct 30, 2026
c744554
feat(rs-client): Add optional tracing for upload fallback
lcian Oct 30, 2026
5c27eb2
docs(rs-client): Clarify resumable uploads and feature flags
lcian Oct 30, 2026
37b1a59
ref(rs-client): Use anonymous files for compressed resumable uploads …
lcian Nov 3, 2026
616c260
Apply suggestion from @lcian
lcian Nov 3, 2026
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
3 changes: 3 additions & 0 deletions Cargo.lock

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

8 changes: 6 additions & 2 deletions clients/rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,31 +21,35 @@ 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", "time"] }
Comment thread
lcian marked this conversation as resolved.
Resolved
tokio-util = { workspace = true, features = ["io"] }
tracing = { workspace = true, optional = true }
url = { workspace = true }
zstd-safe = { workspace = true }

[dev-dependencies]
anyhow = { workspace = true }
axum = { workspace = true }
flate2 = { 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 }

[features]
default = ["native-tls", "hickory-dns"]
resumable-upload-api = []
tracing = ["dep:tracing"]

rustls = ["reqwest/rustls"]
native-tls = ["reqwest/native-tls"]
Expand Down
31 changes: 24 additions & 7 deletions clients/rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,20 +151,29 @@ session.put("payload")
.send().await?;
```

### Resumable Upload API
### Resumable Uploads

The Objectstore server supports an additional API to perform PUT operations in a way
that allows resuming. After a failed request, the upload can resume from the
server's persisted offset.

Large PUTs automatically use resumable uploads.
See [`PutBuilder`] for eligibility, configuration, and recovery behavior.

### Manual Resumable Uploads

> **Feature flag required:** Enable `resumable-upload-api` to use this API.
>
> ```toml
> objectstore-client = { version = "...", features = ["resumable-upload-api"] }
> ```

The resumable upload API allows you to upload an object across multiple requests.
It is particularly suitable for large objects, where restarting an upload from
scratch would be expensive.
Use this API only if you need granular control over resumable upload operations.
Most users should not need this, and instead rely on automatic behavior implemented by [`PutBuilder`].
If you need more controls to customize that behavior, please open an issue.

It's recommended to always try to upload the whole object in a single request if
possible, as that's always the more efficient approach.
If the request fails midway, it will be possible to resume it from the persisted offset.

**Important:** resumable uploads do not automatically compress chunk contents. The `compression`
setting only records how the object is encoded; the caller must compress the payload accordingly.
Expand All @@ -176,7 +185,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 +199,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 +218,8 @@ async fn upload_large_object(session: &Session, object: Bytes) -> Result<()> {
};
}
}
# }
# fn main() {}
```

### Many API
Expand Down Expand Up @@ -311,6 +323,11 @@ let client = Client::builder("http://localhost:8888/")
To create a token with narrower permissions or a different expiry, use the
methods on the token request returned from `create_token`.

## Feature flags

- `tracing`: enables diagnostic tracing events and spans
- `resumable-upload-api`: enables the manual Resumable Uploads API (see above)

## Configuration

In production, store the [`Client`] and [`Usecase`] in a `static` and reuse them.
Expand Down
46 changes: 46 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::{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,17 @@ 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.
/// Temporary storage may outlive the upload while HTTP readers remain open.
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 +155,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 +176,7 @@ pub struct Usecase {
name: UsecaseName,
compression: Option<Compression>,
expiration_policy: ExpirationPolicy,
resumable_threshold_bytes: Option<u64>,
}

impl Usecase {
Expand Down Expand Up @@ -203,6 +219,7 @@ impl Usecase {
compression: Some(Compression::Zstd),
// `ExpirationPolicy::default()` isn't callable in a const fn.
expiration_policy: ExpirationPolicy::Manual,
resumable_threshold_bytes: Some(256 * 1024 * 1024),
}
}

Expand Down Expand Up @@ -249,6 +266,21 @@ impl Usecase {
}
}

/// Returns the automatic resumable upload threshold, or [`None`] if disabled.
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, measured before client compression. Pass [`None`] to disable 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 @@ -340,12 +372,19 @@ impl Scope {

#[derive(Debug)]
pub(crate) struct ClientInner {
spool_dir: Option<PathBuf>,
reqwest: reqwest::Client,
service_url: Url,
propagate_traces: bool,
token: Option<TokenProvider>,
}

impl ClientInner {
pub fn spool_dir(&self) -> Option<&Path> {
self.spool_dir.as_deref()
}
}

/// A client for Objectstore. Use [`Client::builder`] to configure and construct a Client.
///
/// To perform CRUD operations, one has to create a Client, and then scope it to a [`Usecase`]
Expand Down Expand Up @@ -446,6 +485,13 @@ impl Client {
ClientBuilder::new(service_url)
}

/// Returns the configured spool directory.
///
/// `None` means the system temporary directory is used.
pub fn spool_dir(&self) -> Option<&Path> {
self.inner.spool_dir()
}

/// Creates a session for the given scope using this client.
///
/// # Errors
Expand Down
3 changes: 0 additions & 3 deletions clients/rust/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,18 +57,15 @@ pub enum Error {
message: String,
},
/// 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
7 changes: 5 additions & 2 deletions 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 All @@ -30,5 +29,9 @@ pub use head::*;
pub use key::*;
pub use many::*;
pub use put::*;

#[cfg(feature = "resumable-upload-api")]
pub use resumable::*;
pub use resumable::{
CancelUploadBuilder, CreateResumableUploadBuilder, PutChunkBuilder, ResumableUpload,
SessionToken, UploadProgress, UploadProgressBuilder,
};
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 @@ -660,6 +661,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
Loading
Loading