Skip to content

feat(gcs): Add resumable uploads - #609

Open
lcian wants to merge 32 commits into
mainfrom
feat/gcs-resumable-uploads
Open

feat(gcs): Add resumable uploads#609
lcian wants to merge 32 commits into
mainfrom
feat/gcs-resumable-uploads

Conversation

@lcian

@lcian lcian commented Sep 1, 2026

Copy link
Copy Markdown
Member

Implements the Resumable Upload API on the GCS backend by forwarding to the upstream API.

2 notable things:

  • Resumable session tokens will now be encrypted with AES-256-GCM when Objecstore is configured with compatible symmetric keys. This is necessary for GCS, where passing through the session token verbatim would allow direct operations on the bucket.
  • In the previous PR/design, we didn't consider what happens when the user tries to upload a chunk at an offset lower than the persisted one.
    GCS supports that, and just ignores the already persisted prefix. Objectstore could have a stricter contract where we enforce the client-provided offset to match what the backend has, but that would require a preflight request to query the offset on every chunk upload.
    Instead, we document that Objecstore may or may not accept such requests, and in the case of GCS we just accept them, which practically means that we always pass through the offset and body without any pre-check.

Support resumable GCS sessions with optional service-level token encryption.
@codecov

This comment has been minimized.

Comment thread objectstore-service/src/service.rs Outdated
@lcian

This comment has been minimized.

Comment thread objectstore-service/src/backend/gcs.rs Outdated
Comment thread objectstore-service/src/backend/gcs.rs
Validate session origins, preserve protocol error mapping, and report committed uploads.
@lcian

This comment has been minimized.

Comment thread objectstore-service/src/backend/gcs.rs Outdated
Comment thread objectstore-server/src/config.rs
Generate an ephemeral process-local key when no persistent keyring is configured, so backend capability tokens are never exposed to clients. Make service construction fallible when secure randomness is unavailable.
Remove the unintended TieredStorage forwarding implementation and restore its unsupported resumable-upload documentation.
@lcian

lcian commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

bugbot run
@sentry review

Comment thread objectstore-service/src/backend/gcs.rs Outdated
Comment thread objectstore-service/src/backend/gcs.rs Outdated
lcian added 12 commits September 2, 2026 11:41
Define completion relative to the backend handling the upload, so composed backends must finish publication before reporting terminal success. Clarify status-query recovery after a lost final response.

BREAKING CHANGE: Rename Committed to Complete and CommitResponse to CompleteUploadResponse.
Return the authoritative terminal status when an offset-gap recovery query finds that the upload already completed. Preserve offset mismatch and query-failure behavior for the other outcomes.
Reintroduce the offset-query Content-Length and query precedence tests. Use a protected fixture token so both requests reach the unsupported filesystem backend after session-token encryption became mandatory.
Encode the validated active-key invariant in the keyring structure so token encryption no longer needs a map lookup or expect.
Keep LessSafeKey behind a token-specific wrapper that always generates sealing nonces internally and centralizes authenticated decryption.
Flatten the active key into the keyring and keep one shared random generator. Name the private AEAD wrapper and its operations after their protocol-level roles.
Use the GCS status codes, session version, and u64 offset type directly where their meaning is already clear from context.
Keep the backend session payload and protected service envelope minimal until format versioning is needed. Allow extra backend JSON fields for forward compatibility.
Call the canonical backend object identity storage_path to match ObjectId::as_storage_path and distinguish it from a logical key.
Parse and validate GCS session locations once, retain them as Url values, and reject malformed serialized sessions during deserialization.
@lcian
lcian force-pushed the feat/gcs-resumable-uploads branch from aef7275 to 6606025 Compare September 3, 2026 08:42
Store the canonical object path and backend-owned token string in the encrypted service envelope. Backends now receive their original token string and remain responsible for parsing their own typed session state.
Encode the declared length and typed session URI directly in the backend token instead of serializing the GCS session as JSON.
@lcian

lcian commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

bugbot run
@sentry review

Comment thread objectstore-service/src/backend/gcs.rs
Comment thread objectstore-service/src/resumable.rs
Comment thread objectstore-service/src/backend/gcs.rs Outdated
Comment thread objectstore-service/src/backend/gcs.rs Outdated
Move response interpretation out of GcsBackend into range_response_to_upload_progress, which returns the progress GCS reported plus the completed object rather than writing to the change stream itself. Parsing a response now has no side effects and does not need a backend to exercise.

Reporting moves to the two call sites, which invoke report_upload_completion after the parse. Calling it from both is preferred over folding the change stream back into the parser.

Rename parse_resumable_range to range_response_to_offset so both helpers read as conversions from the same response.
@lcian

lcian commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

bugbot run
@sentry review

Comment thread objectstore-service/src/backend/gcs.rs
.await
.reqwest_context("GCS: upload resumable chunk")?;

range_response_to_upload_progress(&session, response)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we could be nice to the user and, for example, if they submit a chunk at the wrong offset, query the offset again for them and return a 200 with the correct offset instead of an error.
Turns out that's not very easy to do, also because of the different ways that GCS can report an error that we would have to handle, so I'm not doing that for now.
Right now we recognize NOT_FOUND/GONE/CLIENT_CLOSED_REQUEST, and in other cases we'll return 500 (mapped from ErrorKind::BackendFailure).
In such cases the user is responsible to query the offset again.

@lcian
lcian marked this pull request as ready for review September 4, 2026 11:41
@lcian
lcian requested a review from a team as a code owner September 4, 2026 11:41

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b50835d. Configure here.

Comment thread objectstore-service/src/backend/gcs.rs Outdated
Comment thread objectstore-service/src/backend/gcs.rs Outdated
serialize_with = "serialize_object_id",
deserialize_with = "deserialize_object_id"
)]
pub(crate) object_id: ObjectId,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

think if we need this

.map_err(|_| ErrorKind::UnknownUploadSession)?;
let session_uri = Url::parse(session_uri).map_err(|_| ErrorKind::UnknownUploadSession)?;
let session = Self::new(session_uri, total_length);
if session.session_uri.origin() != endpoint.origin() {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this check

.await
.reqwest_context("GCS: upload resumable chunk")?;

range_response_to_upload_progress(&session, response)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use an enum for completed

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check if theres a higher level lib to do enc/decryption

&self,
id: ObjectId,
session: SessionToken,
session: EncryptedSessionToken,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's rename these parameters to token.

pub(crate) backend_token: BackendToken,
}

fn serialize_object_id<S>(id: &ObjectId, serializer: S) -> std::result::Result<S::Ok, S::Error>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
fn serialize_object_id<S>(id: &ObjectId, serializer: S) -> std::result::Result<S::Ok, S::Error>
fn serialize_object_id<S>(id: &ObjectId, serializer: S) -> Result<S::Ok, S::Error>

header.push(key_id.len() as u8);
header.extend_from_slice(key_id);

let mut ciphertext = serde_json::to_vec(&session).context(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON escapes certain character sequences in strings. It would be good to explore other serialization formats like bincode.

At the same time we have to consider forward compatibility (if we keep the structured session token). JSON has named keys, so it's easy to add new ones, while bincode will break if the struct changes.

/// persistent keyring with [`Self::new`] when resumable sessions must survive restarts.
///
/// Returns an error if secure random key generation fails.
pub fn ephemeral() -> anyhow::Result<Self> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two options here:

  • Let's try to make this infallible by using an infallible random generator and use a variation of new that always succeeds.
  • If this is not possible, we could maybe lift the construction of the ephemeral encryptor out of the service constructor and pass it during startup?

}

fn into_token(self) -> BackendToken {
format!("{}.{}", self.total_length, self.session_uri)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a follow-up, let's investigate if we can move the responsibility of stringifying up into the encoder / the service. This all depends on how we can send a typed backend token generically through the tiered backend.

/// key_files:
/// v1: /var/run/secrets/objectstore/resumable-upload-v1
/// ```
pub resumable_token_encryption: Option<ResumableTokenEncryptionConfig>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest to make this configuration independent of resumable uploads. This is a key that can be used for any symmetric encryption business, which includes but does not have to be limited to resumable uploads.


impl Service {
/// Loads and validates the configured resumable token encryption keys.
pub(crate) fn resumable_token_encryption(&self) -> Result<Option<Encryptor>> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per #599, there's a way to include file contents directly via config. This is also what the new kafka config uses. Let's check if we can use this instead.

Self(token)
impl fmt::Debug for SessionToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("SessionToken([redacted])")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
f.write_str("SessionToken([redacted])")
f.write_str("SessionToken")

where
D: Deserializer<'de>,
{
let encoded = String::deserialize(deserializer)?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider deserializing into a Cow here, which can borrow and doesn't allocate.

String::from_utf8(bytes)
.map(SessionToken::from)
.map_err(|error| ApiError::map_client("session token is not valid UTF-8", error))
SessionToken::from_base64url(encoded)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function can be removed and inlined.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants