Repository navigation
feat(rs-client): Add automatic resumable uploads #700
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 11 commits
96bf619
64aae59
e0e4113
5695815
b06ec0b
34fd381
a478425
e8a70b3
593f8b0
7300c71
2af4aa9
0241bd9
37a16ae
30c7a1a
a0a007d
f3f93c9
7322630
60764f3
188afab
3f64519
15bc2d0
27ec7ec
042a359
2db1f26
3f07540
c07e591
9ca153c
ddafbf3
c6b8bef
a31e2d6
c744554
5c27eb2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -151,6 +151,13 @@ session.put("payload") | |
| .send().await?; | ||
| ``` | ||
|
|
||
| ### Automatic Resumable Uploads | ||
|
|
||
| Large PUTs automatically use resumable uploads, resending only what the server hasn't | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We describe automatic resumable uploads before we describe resumable uploads. I'd consider to restructure this slightly. In the initial section, describe:
The next section is about the low-level manual API for power-users that's gated behind a feature flag. |
||
| stored after a failed request. | ||
| See [`PutBuilder`] for eligibility, configuration, and recovery behavior, and | ||
| [`Session::put_path`] for the path stability requirement. | ||
|
|
||
| ### Resumable Upload API | ||
|
|
||
| > **Feature flag required:** Enable `resumable-upload-api` to use this API. | ||
|
|
@@ -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}; | ||
|
|
@@ -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; | ||
|
|
@@ -208,6 +216,8 @@ async fn upload_large_object(session: &Session, object: Bytes) -> Result<()> { | |
| }; | ||
| } | ||
| } | ||
| # } | ||
| # fn main() {} | ||
| ``` | ||
|
|
||
| ### Many API | ||
|
|
||
| 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; | ||
|
|
||
|
|
@@ -21,6 +22,7 @@ struct ClientBuilderInner { | |
| propagate_traces: bool, | ||
| reqwest_builder: reqwest::ClientBuilder, | ||
| token: Option<TokenProvider>, | ||
| spool_dir: Option<PathBuf>, | ||
| } | ||
|
|
||
| impl ClientBuilderInner { | ||
|
|
@@ -71,6 +73,7 @@ impl ClientBuilder { | |
| propagate_traces: false, | ||
| reqwest_builder, | ||
| token: None, | ||
| spool_dir: None, | ||
| })) | ||
| } | ||
|
|
||
|
|
@@ -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 | ||
|
|
@@ -141,6 +155,7 @@ impl ClientBuilder { | |
| service_url: inner.service_url, | ||
| propagate_traces: inner.propagate_traces, | ||
| token: inner.token, | ||
| spool_dir: inner.spool_dir, | ||
| }), | ||
| }) | ||
| } | ||
|
|
@@ -161,6 +176,7 @@ pub struct Usecase { | |
| name: Arc<UsecaseName>, | ||
| compression: Option<Compression>, | ||
| expiration_policy: ExpirationPolicy, | ||
| resumable_threshold_bytes: Option<u64>, | ||
| } | ||
|
|
||
| impl Usecase { | ||
|
|
@@ -177,6 +193,7 @@ impl Usecase { | |
| name: Arc::new(UsecaseName::new(name)?), | ||
| compression: Some(Compression::Zstd), | ||
| expiration_policy: ExpirationPolicy::default(), | ||
| resumable_threshold_bytes: Some(256 * 1024 * 1024), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This means automatic resumable uploads are enabled by default. This is something we should call out explicitly in the changelog. Let's check with Ingest team what their thoughts on the behavior are. |
||
| }) | ||
| } | ||
|
|
||
|
|
@@ -234,6 +251,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`]. | ||
|
|
@@ -325,6 +357,7 @@ impl Scope { | |
|
|
||
| #[derive(Debug)] | ||
| pub(crate) struct ClientInner { | ||
| pub(crate) spool_dir: Option<PathBuf>, | ||
|
lcian marked this conversation as resolved.
Resolved
|
||
| reqwest: reqwest::Client, | ||
| service_url: Url, | ||
| propagate_traces: bool, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
|
@@ -75,6 +78,7 @@ impl Session { | |
| .compression() | ||
| .map(CompressionMode::Compress), | ||
| key: None, | ||
| resumable_threshold_bytes: self.scope.usecase().resumable_threshold_bytes(), | ||
| body, | ||
| } | ||
| } | ||
|
|
@@ -85,11 +89,15 @@ impl Session { | |
| } | ||
|
|
||
| /// Creates or replaces an object using a streaming payload. | ||
| /// | ||
| /// Streams use direct uploads, regardless of the resumable threshold. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This may be confusing to a first-time reader. We should have a small paragraph introducing resumable uploads, linking to the client-level or crate-level docs where resumable uploads are explained, and say that they will be automatically used above the configured threshold. Then, call out that streams never get automatic behavior. |
||
| 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_path`] for automatic resumable file uploads. | ||
| pub fn put_read<R>(&self, body: R) -> PutBuilder | ||
| where | ||
| R: AsyncRead + Send + Sync + 'static, | ||
|
|
@@ -100,6 +108,8 @@ impl Session { | |
|
|
||
| /// Creates or replaces an object using the contents of an opened file. | ||
| /// | ||
| /// Uploads from the current position using a direct request, regardless of the resumable threshold. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In a follow-up, let's investigate how to make resumable uploads on files working, too. From the outside, it is surprising that this is possible with paths but not with files. |
||
| /// | ||
| /// The file descriptor is held open from the moment this method is called until the | ||
| /// upload completes. When enqueueing many files via [`Session::many`], prefer | ||
| /// [`put_path`](Session::put_path) instead: it defers opening the file until just before | ||
|
|
@@ -111,6 +121,10 @@ impl Session { | |
|
|
||
| /// Creates or replaces an object using the contents of the file at `path`. | ||
| /// | ||
| /// For resumable uploads, the path must stay readable and must not be modified, renamed, | ||
| /// removed, or replaced until the upload completes. Each attempt opens a new handle. | ||
| /// Handles may remain open until pending HTTP requests release their readers. | ||
| /// | ||
| /// Unlike [`put_file`](Session::put_file), this method defers opening the file until the | ||
| /// request is actually sent. When enqueueing many file uploads via [`Session::many`], this | ||
| /// ensures that file descriptors are opened only within the active concurrency window, | ||
|
|
@@ -125,16 +139,43 @@ impl Session { | |
| } | ||
|
|
||
| /// A [`put`](Session::put) request builder. | ||
| /// | ||
| /// # Automatic resumable uploads | ||
| /// | ||
| /// Large buffers and paths to regular files use resumable uploads automatically, | ||
| /// recovering from interrupted requests. Open files, streams, readers, and uploads queued through | ||
| /// [`Session::many`] do not use resumables. No feature flag is required. | ||
| /// | ||
| /// Configure this with [`Self::resumable_threshold_bytes`] and [`crate::ClientBuilder::spool_dir`]. | ||
| #[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. | ||
| /// | ||
| /// Measures remaining bytes before client compression. Inherits the | ||
| /// [`Usecase` threshold](crate::Usecase::with_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 | ||
|
|
@@ -146,7 +187,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. | ||
|
|
@@ -312,12 +353,37 @@ impl PutBuilder { | |
| .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 prepared = payload::prepare( | ||
| self.body, | ||
| self.compression, | ||
| self.resumable_threshold_bytes, | ||
| self.session.client.spool_dir.clone(), | ||
| ) | ||
| .await?; | ||
| if let payload::PreparedBody::Replayable(body) = &prepared { | ||
| let create = crate::resumable::implementation::CreateResumableUploadBuilder::new( | ||
| self.session.clone(), | ||
| body.len(), | ||
| self.key.clone(), | ||
| self.metadata, | ||
| ); | ||
| if let Ok(Some(upload)) = create.send().await { | ||
|
lcian marked this conversation as resolved.
Resolved
lcian marked this conversation as resolved.
Resolved
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we cache rejections on the client, so we don't re-attempt to create resumable uploads if we know a server blocks it?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Currently we cannot distinguish between "this particular upload creation request was rejected" or "the server doesn't support resumable uploads" because both return 501 not implemented. |
||
| return automatic::upload(body, upload).await; | ||
|
lcian marked this conversation as resolved.
Resolved
|
||
| } | ||
| } | ||
|
|
||
| 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 { | ||
| builder = builder.headers(headers); | ||
| if let Some(length) = content_length { | ||
| builder = builder.header(reqwest::header::CONTENT_LENGTH, length); | ||
| } | ||
| let response = builder.body(body).send().await?; | ||
|
lcian marked this conversation as resolved.
Resolved
|
||
| Ok(response.error_for_status_and_drain().await?.json().await?) | ||
| }) | ||
| .await | ||
| } | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.