Skip to content

feat(upload): Resumable Uploads#6203

Open
jjbayer wants to merge 45 commits into
masterfrom
feat/tus-offset
Open

feat(upload): Resumable Uploads#6203
jjbayer wants to merge 45 commits into
masterfrom
feat/tus-offset

Conversation

@jjbayer

@jjbayer jjbayer commented Jul 9, 2026

Copy link
Copy Markdown
Member

Implement uploads that can be resumed from a given byte offset.

See core protocol of TUS: https://tus.io/protocols/resumable-upload#core-protocol

Design

We have to work around a few limitations:

  • We have to be able to map Upload-Offset to multipart part numbers and vice versa.
  • S3 requires every multipart part to be at least 5 MiB.
  • S3 allows only 10,000 parts per multipart upload.
  • Objectstore requires us to know the size of a part upfront (this is getting fixed, see issue).

This leads to the following design:

image

To prevent excessive buffering, we upload a part as soon as we hit the 5 MiB threshold. This creates a lot of requests and IMO contributes to the high failure rate.

Near-Future Work

Once the content_length requirement on multipart.put_stream has been lifted, we can simply put the stream through an async_compression zstd-encoder and forward it 1:1 to objectstore. We just need to make sure to upload full grains, such that the number of uploaded bytes divides the upload granularity cleanly. See INGEST-1030.

Closes INGEST-974 INGEST-975

@linear-code

linear-code Bot commented Jul 9, 2026

Copy link
Copy Markdown

INGEST-974

INGEST-975

@jjbayer
jjbayer force-pushed the feat/tus-offset branch from 2d8f441 to 8e078f2 Compare July 9, 2026 21:07
Comment thread relay-server/src/services/upload.rs
Comment thread relay-server/src/services/objectstore.rs Outdated
@jjbayer
jjbayer marked this pull request as ready for review July 17, 2026 20:13
@jjbayer
jjbayer requested a review from a team as a code owner July 17, 2026 20:13
Comment thread relay-server/src/services/objectstore.rs Outdated
Comment thread relay-server/src/services/upload.rs Outdated
Comment thread relay-server/src/services/objectstore.rs Outdated
Comment thread relay-server/src/services/objectstore.rs Outdated
Comment thread relay-server/src/services/objectstore.rs Outdated
Comment thread relay-server/src/services/objectstore.rs Outdated
@jjbayer
jjbayer marked this pull request as draft July 17, 2026 22:00
Comment on lines +56 to +60
if signed_location.upload_id().is_some() {
// `SignedLocation<Final>` should never have an upload ID.
// NOTE: we could encode this into the `Final` type.
return Err(ProcessingError::InvalidAttachmentRef);
}

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.

@jjbayer
jjbayer marked this pull request as ready for review July 18, 2026 21:08
Comment thread relay-server/src/services/objectstore.rs
.checked_sub(offset)
.ok_or(Error::InvalidOffset(offset, u))?;
(0, remaining_bytes)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Signed length below Upload-Length

Medium Severity

When Upload-Length is known, the PATCH handler sets the stream lower bound to 0 instead of requiring the remaining byte count. A client can send fewer bytes than declared, still receive 204, and the upload service signs a Final location whose length is the bytes actually streamed (oneshot) rather than the announced total. That can finalize an upload below the committed length the client advertised at creation.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 57ebe51. Configure here.

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.

This is by design: The client is allowed to upload a file in parts. For "oneshot", we deliberately correct the returned upload length down.

Comment thread relay-server/src/services/objectstore.rs

@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.

There are 2 total unresolved issues (including 1 from previous review).

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 9bf8390. Configure here.

});
}

let mut multipart = session.resume_multipart_upload(key, upload_id)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Multipart resume skips offset check

High Severity

Resumable multipart uploads accept the client Upload-Offset after alignment checks only, without comparing it to progress already stored via list_parts. A PATCH can start at the wrong offset, write overlapping or gap-ridden parts, and still pass the closing max_part_number check because that check uses the client-derived new_offset, not the server’s prior progress.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9bf8390. Configure here.

@jjbayer jjbayer Jul 18, 2026

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.

This is true, I decided to only do a list_parts only on the final request to reduce the number of requests to objectstore. We can still revisit this later, but the gist is that a client can only inflate or corrupt their own data, not fake an offset that is smaller than the actual bytes uploaded.

Comment thread relay-server/src/services/objectstore.rs
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.

1 participant