Skip to content

blob: use toBuilder for correlationId stamping; fix async BlobMetadata.checksum drop - #583

Merged
LihaoLiuXs merged 7 commits into
salesforce:mainfrom
graceli5428:blobstore-toBuilder-refactor
Aug 14, 2026
Merged

blob: use toBuilder for correlationId stamping; fix async BlobMetadata.checksum drop#583
LihaoLiuXs merged 7 commits into
salesforce:mainfrom
graceli5428:blobstore-toBuilder-refactor

Conversation

@graceli5428

@graceli5428 graceli5428 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Refactor BucketClient and AsyncBucketClient correlationId-stamping helpers so they no longer rebuild response/request objects field-by-field. Adds toBuilder() support to the six affected model classes and collapses each rebuild into a one-line copy-with-override (twelve rebuilds across the two clients).

Motivation

Both clients copy the resolved OperationContext.correlationId onto every driver-returned response and onto request objects before they reach the driver. Because these model classes are immutable, the previous implementation rebuilt each object field-by-field. Any new field added to these classes was silently dropped by the copy until every callsite was updated — six such methods in each client, twelve independent drift hazards.

Behavior change

This PR fixes a silent regression from #540 , which added BlobMetadata.checksum and updated the sync BucketClient rebuild but not the async one. Since #540 merged, on every provider (AWS / GCP / Ali):

  • AsyncBucketClient.getMetadata(...) returned checksum == null
  • AsyncBucketClient.download(...) returned getMetadata().getChecksum() == null

even when the transformer had populated the field. The toBuilder() collapse closes this gap; async callers will now see the real checksum value. Callers that assumed checksum == null on the async path should be reviewed.

Changes

  • Added toBuilder() to BlobMetadata, DownloadResponse, UploadResponse, CopyResponse, UploadRequest, MultipartUploadRequest. The four response classes use Lombok @Builder(toBuilder = true); the two request classes have a hand-written toBuilder() since they already carry custom builder defaulting.
  • Collapsed the twelve rebuild helpers (six in BucketClient, six in AsyncBucketClient) into one-line toBuilder().(...).build() calls. New fields on these classes now flow through both correlationId paths automatically.

Downstream note: Mockito

The request-side helpers now call .toBuilder() on their argument. Mockito mocks return null from object-returning methods by default, so downstream tests that do mock(UploadRequest.class), mock(MultipartUploadRequest.class), or mock(DownloadResponse.class) and hand them to these clients will NPE after upgrade. Fix in test code: swap for real builders (UploadRequest.builder().key(...).build(), etc.). Five such sites inside this repo were updated in this PR.

Testing

  • New regression tests on both clients:testGetMetadataPreservesAllFieldsWhenStampingCorrelationId populates every BlobMetadata field (including checksum) with a distinct non-default value, has the driver return it, and asserts each field survives the rebuild while only correlationId is overwritten. Fails against main — this is the guard against the regression class blobstore: add the checksum data in the get and metadata #540 introduced. Companion tests cover UploadResponse and DownloadResponse field survival.
  • All 316 tests pass in blob-client (up from 310 on base); 358 pass across blob-aws / blob-gcp / blob-ali.

BucketClient rebuilt UploadResponse/DownloadResponse/BlobMetadata/CopyResponse
and UploadRequest/MultipartUploadRequest field-by-field to stamp the resolved
correlationId. Any newly added field on these classes would be silently
dropped in the rebuild until every callsite was updated.

Add toBuilder support to the six model classes (@builder(toBuilder = true) for
the Lombok-built ones; hand-written toBuilder() for the two request classes
whose builders carry defaulting logic) and collapse each rebuild in
BucketClient into a one-line toBuilder().<field>(...).build() call. Adding a
new field to any of these classes now flows through automatically.

Add a regression test that populates every field of BlobMetadata to a distinct
non-default value and verifies each survives the rebuild, with only the
correlationId overwritten by the resolved OperationContext.
@codecov-commenter

codecov-commenter commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.58%. Comparing base (aea85ed) to head (571da23).

Additional details and impacted files
@@             Coverage Diff              @@
##               main     #583      +/-   ##
============================================
- Coverage     83.61%   83.58%   -0.03%     
  Complexity      674      674              
============================================
  Files           215      215              
  Lines         15010    14930      -80     
  Branches       2076     2076              
============================================
- Hits          12550    12479      -71     
+ Misses         1636     1628       -8     
+ Partials        824      823       -1     
Flag Coverage Δ
unittests 83.58% <100.00%> (-0.03%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 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.

The Builder defaults metadata/tags to Collections.emptyMap() and the
withMetadata/withTags setters throw NPE on null via unmodifiableMap,
so the fields can never be null when the request is constructed
through the builder (the only construction path). The
`metadata != null ? metadata : Map.of()` guard in toBuilder() was
dead defensive code — remove it so a broken invariant would fail
loudly instead of being silently papered over.
}
return DownloadResponse.builder()
.key(r.getKey())
return r.toBuilder()

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.

Nice catch keeping .metadata(withCorrelationId(r.getMetadata(), ctx)) explicit here rather than relying on toBuilder() to shallow-copy the nested BlobMetadata — a plain r.toBuilder().correlationId(...).build() would've silently kept the driver's original (unstamped) metadata correlationId, which is exactly the class of bug this PR is trying to kill. Worth a one-line comment on this call noting the nested rebuild is intentional, not leftover ceremony — future readers might "simplify" it away.

@graceli5428 graceli5428 Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added an inline comment in third commit flagging the nested rebuild as intentional, so a future reader doesn't collapse it into a plain toBuilder().correlationId(...).

assertContextPropagated(captured);
}

/**

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.

The new testGetMetadataPreservesAllFieldsWhenStampingCorrelationId is a genuinely good regression test — it would have caught the original silent-drop bug. But it only covers BlobMetadata. UploadResponse went through the identical conversion and has no equivalent test: getTestUploadResponse() only builds key/versionId/eTag, leaving checksumValue unset, so a dropped checksumValue in the toBuilder() rebuild wouldn't fail today. DownloadResponse has no field-survival coverage at all — the download tests only assert the request was forwarded correctly, never construct or compare a DownloadResponse. (CopyResponse is fine — testCopy already sets all four non-correlationId fields.) Given the whole point of this PR is closing that drift hazard, should UploadResponse and DownloadResponse get the same field-completeness test, or is BlobMetadata considered the representative case?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added testUploadPreservesAllFieldsWhenStampingCorrelationId and testDownloadPreservesAllFieldsWhenStampingCorrelationId mirroring the BlobMetadata one.

when(mockBlobStore.upload(any(), any(File.class))).thenThrow(RuntimeException.class);
when(mockBlobStore.upload(any(), any(Path.class))).thenThrow(RuntimeException.class);
UploadRequest request = mock(UploadRequest.class);
UploadRequest request = UploadRequest.builder().withKey("object-1").build();

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.

Good fix swapping mock(UploadRequest.class) for a real UploadRequest — the old mock would've returned null from toBuilder() and NPE'd before ever reaching the exception-mapping path this test is actually checking. Confirms the toBuilder migration needed to hunt down every mocked request/response of these six types across the test suite; did a pass over AsyncBucketClientTest.java turn up the same landmine (it also has mock(UploadRequest.class) at line 257)? [Thinking out loud] since the async client isn't being touched in this PR, its test file's mocks won't NPE today — but the moment the async migration happens, that test needs the identical fix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Swapped mock(UploadRequest.class) for a real UploadRequest preemptively

@iamabhilaksh iamabhilaksh left a comment

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.

Clean refactor — the toBuilder() swap kills the field-drift hazard on the sync path, and the explicit nested-metadata rebuild is the right call. Left a few inline questions on test coverage completeness (UploadResponse/DownloadResponse don't have the field-survival test that BlobMetadata now has).

One scope question I couldn't anchor inline: AsyncBucketClient still has the exact same six withCorrelationId/withResolvedContext methods doing the field-by-field rebuild this PR calls a drift hazard. Since BlobMetadata, UploadResponse, DownloadResponse, and CopyResponse all get @Builder(toBuilder = true) here, the async client could take the identical one-line fix with zero additional model changes. Was sync-only deliberate, or is the async migration a planned follow-up? If it's not planned, we're back to the same drift hazard, just in the other client.

- BucketClient.java: add a comment on withCorrelationId(DownloadResponse)
  noting the nested BlobMetadata rebuild is intentional; a plain
  r.toBuilder().correlationId(...) would shallow-copy the driver's
  unstamped metadata and defeat the purpose of the rebuild.
- BucketClientTest.java: add field-survival tests for UploadResponse
  and DownloadResponse that mirror the existing BlobMetadata test,
  covering the same drift-hazard the PR closes for all four response
  types stamped through toBuilder() (CopyResponse is already covered
  by testCopy).
- AsyncBucketClientTest.java: replace mock(UploadRequest.class) in
  testUploadThrowsFutureException with a real UploadRequest — it
  doesn't NPE today because the async client isn't in this PR, but it
  will the moment the async client migrates, so fixing it preemptively.
Mirror the sync-client refactor on AsyncBucketClient: replace the six
field-by-field rebuilds in withCorrelationId(UploadResponse/DownloadResponse/
BlobMetadata/CopyResponse) and withResolvedContext(UploadRequest/
MultipartUploadRequest) with toBuilder() copies. Closes the same drift hazard
in the async path — the BlobMetadata rebuild had already silently drifted
(the checksum field was never being copied through).

Add field-survival tests mirroring the sync suite (getMetadata, upload, download)
that populate every field to a distinct value and assert every field survives
the rebuild so any future field addition is caught by a failing test.

Swap the four DownloadResponse-mock success-path tests to real builder-produced
responses; toBuilder() on a mock returns null, which previously masked the
missing checksum field and would otherwise NPE under the new rebuild path.
@graceli5428

Copy link
Copy Markdown
Contributor Author

@iamabhilaksh Good catch — sync-only wasn't deliberate, just missed it in the initial cut. Just pushed the async migration in the follow-up commit: same six methods collapsed to toBuilder(), plus mirror field-survival tests for getMetadata/upload/download in AsyncBucketClientTest.

While in there, the drift hazard turned out to already be a latent bug — withCorrelationId(BlobMetadata) on the async path was only copying 10 of the 11 fields and silently dropping checksum. The new field-survival test would have failed against the pre-refactor code; it passes after the toBuilder() swap. So this PR now closes the drift class on both sides.

@iamabhilaksh iamabhilaksh left a comment

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.

LGTM 👍 — thanks for extending it to AsyncBucketClient and adding the field-survival tests; that checksum drift the async path had is exactly the failure mode this closes. Nicely done.

* {@link UploadRequest} requires only extending this method (and the corresponding builder
* setter) — not every callsite that copies a request.
*/
public Builder toBuilder() {

@LihaoLiuXs LihaoLiuXs Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

UploadRequest.toBuilder() and MultipartUploadRequest.toBuilder() are the only two copy helpers in this PR that can drift — Lombok generates the other four, so those are structurally safe. But only the multipart one is actually guarded by a test.

I checked this by mutation rather than by reading: deleting one field-copy line at a time from this method and re-running mvn test -pl blob/blob-client. 10 of the 11 copied fields can be deleted with the suite still 100% green. Only .withKey(key) is caught:

CAUGHT   | .withKey(key)                          -> 8 failures
SURVIVED | .withContentLength(contentLength)      -> 316 pass, 0 failures
SURVIVED | .withMetadata(metadata)                -> 316 pass, 0 failures
SURVIVED | .withTags(tags)                        -> 316 pass, 0 failures
SURVIVED | .withStorageClass(storageClass)        -> 316 pass, 0 failures
SURVIVED | .withKmsKeyId(kmsKeyId)                -> 316 pass, 0 failures
SURVIVED | .withUseKmsManagedKey(...)             -> 316 pass, 0 failures
SURVIVED | .withObjectLock(objectLock)            -> 316 pass, 0 failures
SURVIVED | .withChecksumValue(checksumValue)      -> 316 pass, 0 failures
SURVIVED | .withChecksumAlgorithm(...)            -> 316 pass, 0 failures
SURVIVED | .withContentType(contentType)          -> 316 pass, 0 failures

The withKey result is worth noting on its own: it proves this branch really is executed by testUpload{InputStream,ByteArray,File,Path}. So the method has line coverage but no assertion coverage for 10 of its fields.

Some of those are more than cosmetic. Silently dropping kmsKeyId or useKmsManagedKey writes an object without the intended CMK; dropping objectLock writes it without WORM retention or legal hold; dropping checksumValue/checksumAlgorithm skips upload integrity validation. Those are encryption and compliance settings failing open with nothing in CI going red.

The path is reachable in normal use: MultiCloudJLogger.resolveContext() returns a new OperationContext when the caller supplies none, and withResolvedContext short-circuits on reference equality (ctx == req.getOperationContext()), so for the common case of a caller omitting OperationContext this rebuild runs on every upload.

The fix is to mirror what already exists for the sibling class: testWithResolvedContextMultipartRebuildPreservesAllFields (AsyncBucketClientTest) calls withResolvedContext directly with a deliberately distinct context to force the rebuild branch, then asserts every field. withResolvedContext is package-private and the test classes share the package, so a UploadRequest equivalent is straightforward. Under the same sweep, that shape catches all 10 currently-unguarded drops.

One small thing while you're in there: the multipart test guards 8 of its 9 fields, but .withChecksumEnabled(checksumEnabled) also survives mutation. The fixture sets both withChecksumEnabled(true) and withChecksumAlgorithm(SHA256), and since the constructor derives checksumEnabled = builder.checksumEnabled || builder.checksumAlgorithm != null, the assertTrue(rebuilt.isChecksumEnabled()) passes via the algorithm regardless of whether the flag was copied. A case with checksumEnabled = true and no explicit algorithm — a valid configuration, meaning "checksum on, substrate-native default" — would close it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added three tests in most recent commit populating every field with distinct non-defaults and asserting each survives the rebuild:

  • AsyncBucketClientTest.testWithResolvedContextUploadRebuildPreservesAllFields (async side already had the MultipartUploadRequest variant)
  • BucketClientTest.testWithResolvedContextUploadRebuildPreservesAllFields
  • BucketClientTest.testWithResolvedContextMultipartRebuildPreservesAllFields


/** Blob metadata data object */
@Builder
@Builder(toBuilder = true)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

metadata is @Singular("metadata"), which gives the generated toBuilder() a behavior most callers will not expect: Lombok's plural setter adds entries rather than replacing them.

Verified against the compiled class:

BlobMetadata m = ...;  // metadata = {a=1, b=2}

m.toBuilder().metadata(Map.of("c", "3")).build().getMetadata();
// => {a=1, b=2, c=3}    and not {c=3}

This PR's own code path is unaffected, since it only overrides correlationId. The concern is that toBuilder() is now public API on a published SDK, and "copy this metadata but swap out the user-metadata map" is the natural next use — which will silently union the two maps instead of replacing. clearMetadata() is the escape hatch, but nothing signals that a caller needs it.

Worth a javadoc line on the class (or the field) noting that the plural metadata(Map) setter accumulates, and that replacing requires clearMetadata() first.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a javadoc note on the field documenting that .metadata(Map) on a toBuilder() copy unions with the existing entries rather than replacing them, and how to replace (clearMetadata() first) or append (.metadata(key, value)).

.objectLockInfo(m.getObjectLockInfo())
.correlationId(ctx.getCorrelationId())
.build();
return m.toBuilder().correlationId(ctx.getCorrelationId()).build();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This line is a user-visible bug fix rather than a pure refactor, and it is currently undisclosed.

git log -S '.checksum(m.getChecksum())' against BucketClient shows #540 added BlobMetadata.checksum and updated the field-by-field rebuild there. The same search against AsyncBucketClient returns nothing — the async rebuild never received it. So since #540, AsyncBucketClient.getMetadata() and download() have returned checksum == null unconditionally on every provider, even though the transformers populate it (AwsTransformer.toMetadata / toDownloadResponse and the GCP/Ali equivalents). Collapsing to toBuilder() fixes that, and the new assertEquals(fromDriver.getChecksum(), actual.getChecksum()) assertion would fail against main — which is exactly the right guard.

The issue is disclosure. The PR title says "refactor BucketClient correlationId stamping", and the description mentions neither the async client nor the checksum. Since this repo squash-merges and release-please maps the blobstore: type into the changelog, a behavior fix would ship described as a refactor, leaving consumers who suddenly see a populated checksum nothing to correlate it against.

Suggest retitling, or adding a short "Behavior change" line naming AsyncBucketClient.getMetadata() / download() and referencing #540 as the regression source.

Two smaller description drifts worth fixing in the same pass: it says six methods in BucketClient (now twelve across two files), and "311 tests pass in blob-client" (actual is 316, up from 310 on base). A release note would also help for the fact that Mockito mocks of UploadRequest / DownloadResponse no longer work through these clients — toBuilder() returns null on a mock, which is what forced the mock-to-real swaps here, and downstream test suites will hit the same NPE on upgrade.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated title and description to name the AsyncBucketClient checksum fix as a behavior change (references #540), correct the scope to both clients, and add a Mockito heads-up for downstream tests.


/** Wrapper object for copy result data */
@Builder
@Builder(toBuilder = true)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Now that CopyResponse has toBuilder(), there is one remaining field-by-field CopyResponse copy in the repo that this PR's rationale covers verbatim. AliBlobStore.buildCopyResponse (blob/blob-ali/src/main/java/com/salesforce/multicloudj/blob/ali/AliBlobStore.java:581-586) rebuilds a real CopyResponse — the one returned by transformer.toCopyResponse(...) — purely to override lastModified:

return CopyResponse.builder()
    .key(response.getKey())
    .versionId(response.getVersionId())
    .eTag(response.getETag())
    .lastModified(transformer.parseLastModified(headResult.lastModified()))
    .build();

That is now expressible as response.toBuilder().lastModified(...).build(), and any future CopyResponse field would flow through automatically instead of needing to be remembered here.

Not blocking, and it sits outside this diff — but it is the same drift hazard, and the tool to remove it lands in this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Collapsed to response.toBuilder().lastModified(...).build(). All 272 blob-ali tests pass.

@graceli5428 graceli5428 changed the title blobstore: refactor BucketClient correlationId stamping to use toBuilder blob: use toBuilder for correlationId stamping; fix async BlobMetadata.checksum drop Aug 6, 2026
- Add rebuild-branch tests for withResolvedContext on UploadRequest and
  MultipartUploadRequest to close the mutation-testing gap that only
  caught .withKey(key) — the new tests populate every field with distinct
  non-defaults and assert each survives the rebuild
- Document the @Singular("metadata") accumulation footgun on
  BlobMetadata.metadata — .metadata(newMap) on a toBuilder() copy unions
  entries with the existing map rather than replacing them
- Collapse AliBlobStore.buildCopyResponse field-by-field rebuild to use
  response.toBuilder().lastModified(...).build()

@iamabhilaksh iamabhilaksh left a comment

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.

LGTM 👍 — re-approving on the current head; the follow-up commits addressed the second round cleanly (toBuilder now covers CopyResponse, and the field-preservation tests close the mutation-coverage gap).

@LihaoLiuXs LihaoLiuXs left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

In the provider implementation why we only updated the AliBlobStore.java? Do we need to apply the same change in aws and gcp?

@graceli5428

Copy link
Copy Markdown
Contributor Author

The AWS and GCP paths don't have the same pattern. The Ali change was for a specific two-stage rebuild:AliBlobStore first builds a CopyResponse from the OSS CopyObjectResult, and only if lastModified comes back null does it do a follow-up HEAD and rebuild the response to fill it in — that second rebuild is what got collapsed to toBuilder().lastModified(...).build().

AWS and GCP just build the CopyResponse once from the SDK response (AwsTransformer.toCopyResponse, GcpTransformer.toCopyResponse, AwsAsyncBlobStore.doCopy) — no existing CopyResponse to copy from, so nothing to convert. Grepped the rest of blob-aws and blob-gcp for Response.builder() too and every other call site is an initial construction, not a rebuild.

private final String eTag;
private final long objectSize;

/**

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Lets remove this comments

@LihaoLiuXs
LihaoLiuXs merged commit 095aee8 into salesforce:main Aug 14, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants