Skip to content

feat(server): Add multipart upload endpoints#463

Open
lcian wants to merge 15 commits into
lcian/feat/multipart-upload-tieredfrom
lcian/feat/multipart-upload-endpoints
Open

feat(server): Add multipart upload endpoints#463
lcian wants to merge 15 commits into
lcian/feat/multipart-upload-tieredfrom
lcian/feat/multipart-upload-endpoints

Conversation

@lcian
Copy link
Copy Markdown
Member

@lcian lcian commented May 7, 2026

⚠️ Stacked on #458

Final part of the Multipart uploads implementation server-side, this implements the endpoints on top of the Service.

Close FS-341

@lcian lcian changed the base branch from main to lcian/feat/multipart-upload-tiered May 7, 2026 12:31
@lcian

This comment was marked as outdated.

@lcian

This comment was marked as outdated.

Comment thread objectstore-server/src/endpoints/multipart.rs
Comment thread objectstore-server/src/endpoints/multipart.rs
@lcian lcian changed the title lcian/feat/multipart upload endpoints feat(server): Add multipart upload endpoints May 7, 2026
@linear-code

This comment was marked as off-topic.

@lcian

This comment was marked as outdated.

@lcian

This comment was marked as outdated.

Comment thread objectstore-server/src/endpoints/multipart.rs
@lcian

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

@lcian

This comment was marked as resolved.

@lcian

This comment was marked as resolved.

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 5a3cfa2. Configure here.

///
/// The default returns `None`. Backends that implement
/// [`MultipartUploadBackend`] should override this to return `Some(self)`.
fn as_multipart_upload_backend(&self) -> Option<&dyn MultipartUploadBackend> {
Copy link
Copy Markdown
Member Author

@lcian lcian May 8, 2026

Choose a reason for hiding this comment

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

Not sure if this is the best approach.
Backend should not know about MultipartUploadBackend, and this means that the server errors with HTTP status NOT IMPLEMENTED when the backend doesn't support this capability.

As an alternative, we could make StorageService store a Arc<dyn MultipartUploadBackend>.
That however would prevent someone from using Objectstore with a Bigtable only configuration, which I'm not sure if we consider a legitimate usecase or not.

Regardless, we can easily make that change at any point.

headers: HeaderMap,
) -> ApiResult<Response> {
let mut metadata = Metadata::from_headers(&headers, "").map_err(ServiceError::from)?;
// TODO: Do this in `complete` instead, when we have a Service API to mutate metadata.
Copy link
Copy Markdown
Member Author

@lcian lcian May 8, 2026

Choose a reason for hiding this comment

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

This is easy to do but we would need a new method on Backend, plus all the implementations and plumbing through Service, filed a follow-up: https://linear.app/getsentry/issue/FS-356/set-correct-metadatatime-created-for-multipart-uploads.

Or, we can just accept and document that time_created refers to the time the upload was initiated, but that sounds wrong, it should probably be the time the logical object is created, which is the time the multipart upload is completed.

@lcian lcian marked this pull request as ready for review May 8, 2026 14:08
@lcian lcian requested a review from a team as a code owner May 8, 2026 14:08
@lcian lcian requested a review from matt-codecov May 8, 2026 17:19
max_parts: Option<u32>,
part_number_marker: Option<PartNumber>,
) -> ApiResult<ListPartsResponse> {
self.assert_authorized(Permission::ObjectRead, id.context())?;
Copy link
Copy Markdown
Member Author

@lcian lcian May 8, 2026

Choose a reason for hiding this comment

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

Maybe this should also require Permission::ObjectWrite instead, to match the rest of the methods.
But also, if a user has ObjectWrite they would usually also have ObjectRead in any realistic scenario.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

not sure what to do here. GCS just gives listing its own permission

if a user has ObjectWrite they would usually also have ObjectRead in any realistic scenario

that might be how we assign permissions today, but i think probably most tokens only need to be read or write.

if the point is to allow an interrupted write to continue, maybe write permissions should allow it? so a write-only client doesn't need to request additional permissions. idk

@lcian lcian force-pushed the lcian/feat/multipart-upload-tiered branch from 409be20 to d6eb979 Compare May 12, 2026 11:32
@lcian lcian requested review from matt-codecov and removed request for matt-codecov May 15, 2026 16:37
@lcian lcian force-pushed the lcian/feat/multipart-upload-endpoints branch from 5babbfb to c33ba6f Compare May 15, 2026 16:57
@codecov
Copy link
Copy Markdown

codecov Bot commented May 15, 2026

Codecov Report

❌ Patch coverage is 93.67816% with 22 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.16%. Comparing base (c21fe73) to head (c33ba6f).

Files with missing lines Patch % Lines
objectstore-server/src/endpoints/common.rs 0.00% 3 Missing ⚠️
objectstore-server/src/endpoints/multipart.rs 98.13% 3 Missing ⚠️
objectstore-service/src/backend/common.rs 0.00% 3 Missing ⚠️
objectstore-service/src/backend/gcs.rs 0.00% 3 Missing ⚠️
objectstore-service/src/backend/in_memory.rs 0.00% 3 Missing ⚠️
objectstore-service/src/backend/testing.rs 0.00% 3 Missing ⚠️
objectstore-service/src/backend/tiered.rs 0.00% 3 Missing ⚠️
objectstore-service/src/error.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@                          Coverage Diff                           @@
##           lcian/feat/multipart-upload-tiered     #463      +/-   ##
======================================================================
+ Coverage                               86.95%   87.16%   +0.20%     
======================================================================
  Files                                      77       78       +1     
  Lines                                   11685    12032     +347     
======================================================================
+ Hits                                    10161    10488     +327     
- Misses                                   1524     1544      +20     
Components Coverage Δ
Rust Backend 91.80% <93.67%> (+0.07%) ⬆️
Rust Client 80.10% <ø> (ø)
Python Client 86.36% <ø> (ø)

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread objectstore-server/src/endpoints/multipart.rs
Copy link
Copy Markdown
Contributor

@matt-codecov matt-codecov left a comment

Choose a reason for hiding this comment

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

accept w comments

max_parts: Option<u32>,
part_number_marker: Option<PartNumber>,
) -> ApiResult<ListPartsResponse> {
self.assert_authorized(Permission::ObjectRead, id.context())?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

not sure what to do here. GCS just gives listing its own permission

if a user has ObjectWrite they would usually also have ObjectRead in any realistic scenario

that might be how we assign permissions today, but i think probably most tokens only need to be read or write.

if the point is to allow an interrupted write to continue, maybe write permissions should allow it? so a write-only client doesn't need to request additional permissions. idk

Comment on lines 99 to +104
objectstore_log::error!(!!self, "error handling request");
StatusCode::INTERNAL_SERVER_ERROR
}

ApiError::Internal(_) => {
objectstore_log::error!(!!self, "internal error");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

are we dropping important errors from these logs?

Self::Panic(_) => Level::ERROR,
Self::Dropped => Level::ERROR,
Self::UnexpectedTombstone => Level::ERROR,
Self::NotImplemented => Level::WARN,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this is probably an error? if this ever happens in a deployment it means something was misconfigured

Comment on lines +243 to +266
fn ensure_inner_multipart(&self) -> Result<()> {
self.inner
.as_multipart_upload_backend()
.ok_or(Error::NotImplemented)?;
Ok(())
}

/// Initiates a new multipart upload.
pub async fn initiate_multipart(
&self,
id: ObjectId,
metadata: Metadata,
) -> Result<InitiateMultipartResponse> {
self.ensure_inner_multipart()?;
let inner = Arc::clone(&self.inner);
self.spawn("initiate_multipart", async move {
inner
.as_multipart_upload_backend()
.unwrap()
.initiate_multipart(&id, &metadata)
.await
})
.await
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

there's a way to streamline this a bit to get rid of the ensure_... helper and .unwrap() calls:

pub trait Backend {
  /// Make this an explicit `Arc<dyn T>` -> `Arc<dyn U>` cast.
  /// Return `Err(Error::NotImplemented)` by default.
  fn as_multipart_upload_backend(self: Arc<Self>) -> Result<Arc<dyn MultipartUploadBackend>> {
    Err(Error::NotImplemented)
  }
}

impl Backend for GcsBackend {
  /// Convert `Arc<dyn Backend>` into `Arc<dyn MultipartUploadBackend>`
  fn as_multipart_upload_backend(self: Arc<Self>) -> Result<Arc<dyn MultipartUploadBackend>> {
    Ok(self)
  }
}

impl StorageService {
  pub async fn initiate_multipart(...) -> Result<InitiateMultipartResponse> {
    // Clone `inner` so it can be `move`d into the async block, as before.
    // `.as_multipart_upload_backend()` transforms the clone into `Arc<dyn MultipartUploadBackend>`.
    // It still shares its underlying pointer/refcount with the original, though!
    // `?` to return `Err(Error::NotImplemented)` when not implemented.
    let inner_multipart = self.inner.clone().as_multipart_upload_backend()?;
    self.spawn("initiate_multipart", async move {
      inner_multipart.initiate_multipart(&id, &metadata).await
    }).await
  }
}

the Arc refcounting all still tracks the same object. this is just a super verbose cast for trait objects inside Arcs. you can confirm the refcount stuff in this playground link https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=ea6b827dd6fb9c3194bac54d0f62b2ea

Comment on lines +127 to +132
fn validate_part_number(part_number: u32) -> ApiResult<()> {
if part_number == 0 {
return Err(ApiError::Client("part_number must be >= 1".into()));
}
Ok(())
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

would using a type PartNumber = std::num::NonZero<u32> alias instead of u32 for these part_number fields take care of this? we'd have to move the error handling somewhere else but we wouldn't have to remember to call validate()

id,
params.upload_id,
params.max_parts,
params.part_number_marker,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

technically you have to validate that this is >=1 as well

Query(params): Query<UploadIdQuery>,
Json(body): Json<CompleteRequest>,
) -> ApiResult<Response> {
service.check_permission(Permission::ObjectWrite, id.context())?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

is there something we need to check that isn't covered in AuthAwareService's wrapper around complete_multipart()?

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