feat(gcs): Add resumable uploads - #609
Conversation
Support resumable GCS sessions with optional service-level token encryption.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Validate session origins, preserve protocol error mapping, and report committed uploads.
This comment has been minimized.
This comment has been minimized.
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.
|
bugbot run |
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.
aef7275 to
6606025
Compare
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.
|
bugbot run |
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.
|
bugbot run |
| .await | ||
| .reqwest_context("GCS: upload resumable chunk")?; | ||
|
|
||
| range_response_to_upload_progress(&session, response) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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.
| serialize_with = "serialize_object_id", | ||
| deserialize_with = "deserialize_object_id" | ||
| )] | ||
| pub(crate) object_id: ObjectId, |
| .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() { |
| .await | ||
| .reqwest_context("GCS: upload resumable chunk")?; | ||
|
|
||
| range_response_to_upload_progress(&session, response) |
There was a problem hiding this comment.
check if theres a higher level lib to do enc/decryption
| &self, | ||
| id: ObjectId, | ||
| session: SessionToken, | ||
| session: EncryptedSessionToken, |
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
| 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( |
There was a problem hiding this comment.
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> { |
There was a problem hiding this comment.
Two options here:
- Let's try to make this infallible by using an infallible random generator and use a variation of
newthat 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) |
There was a problem hiding this comment.
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>, |
There was a problem hiding this comment.
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>> { |
There was a problem hiding this comment.
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])") |
There was a problem hiding this comment.
| f.write_str("SessionToken([redacted])") | |
| f.write_str("SessionToken") |
| where | ||
| D: Deserializer<'de>, | ||
| { | ||
| let encoded = String::deserialize(deserializer)?; |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
Function can be removed and inlined.

Implements the Resumable Upload API on the GCS backend by forwarding to the upstream API.
2 notable things:
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.