Skip to content

blob: fix bulk delete breaking on more than 1000 objects - #531

Open
hkhiri wants to merge 2 commits into
salesforce:mainfrom
hkhiri:blob-aws-batch-delete-chunking
Open

blob: fix bulk delete breaking on more than 1000 objects#531
hkhiri wants to merge 2 commits into
salesforce:mainfrom
hkhiri:blob-aws-batch-delete-chunking

Conversation

@hkhiri

@hkhiri hkhiri commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Right now deleting more than 1000 objects at once just fails. S3 caps DeleteObjects at 1000 keys per request and we were sending them all in one shot. So any large cleanup blows up.

This splits the delete into batches under the limit (one after another for the sync client, in parallel for async), so deletes of any size just work now. Added tests for the >1000 case and the empty case.

Ref: https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 82.40%. Comparing base (19c33e6) to head (6b70d39).

Additional details and impacted files
@@            Coverage Diff            @@
##               main     #531   +/-   ##
=========================================
  Coverage     82.39%   82.40%           
  Complexity      662      662           
=========================================
  Files           210      210           
  Lines         14334    14342    +8     
  Branches       1932     1932           
=========================================
+ Hits          11811    11819    +8     
  Misses         1696     1696           
  Partials        827      827           
Flag Coverage Δ
unittests 82.40% <100.00%> (+<0.01%) ⬆️

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.

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

1000 is a limit for all providers, as a good practice we should leave this batching to client so they don't sent very large numbers. Having these features unnecessarily also results in side-effects such as OOMs and we don't want to encourage it.

protected void doDelete(Collection<BlobIdentifier> objects) {
s3Client.deleteObjects(transformer.toDeleteRequests(objects));
for (DeleteObjectsRequest request : transformer.toDeleteRequests(objects)) {
s3Client.deleteObjects(request);

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.

DeleteObjectsResponse is discarded, so per-key delete failures are silently reported as success.

S3 DeleteObjects returns HTTP 200 even when individual keys fail — the failures come back in the response's Errors array (AccessDenied on an object-lock/retention-protected or cross-account key, InternalError, etc.), and the SDK does not throw for them. Both this loop and AwsAsyncBlobStore.doDelete (which drops each response into CompletableFuture.allOf) throw the response away; .errors() is referenced nowhere in the blob module. That contradicts BucketClient.delete(Collection)'s documented contract: "@throws SubstrateSdkException Thrown if the operation fails."

This predates the PR, but chunking is what makes it dangerous. Previously a >1000-key delete failed loudly, so this could only ever hide failures within one request. Now a 50,000-object delete issues 50 requests and returns success even if thousands of keys were rejected — the caller has no way to learn which objects still exist. On a retention/erasure path that is a silent compliance failure.

Suggested fix: collect response.errors() across chunks and, if non-empty, throw a SubstrateSdkException naming the failed keys and their error codes.

Comment on lines +281 to +285
CompletableFuture<?>[] deletions =
transformer.toDeleteRequests(objects).stream()
.map(request -> client.deleteObjects(request))
.toArray(CompletableFuture[]::new);
return CompletableFuture.allOf(deletions);

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.

The async path submits every chunk at once, so in-flight request count is driven directly by caller input with no cap.

toDeleteRequests(objects).stream().map(client::deleteObjects) eagerly starts ceil(n/1000) requests before allOf is built. A 5M-object delete fires 5,000 concurrent deleteObjects calls. NettyNioAsyncHttpClient defaults to maxConcurrency 50 with a 10s connectionAcquisitionTimeout and a 10,000-entry pending-acquire queue, so the tail of that burst fails with connection-acquisition timeouts rather than being throttled gracefully, and S3 will answer the burst with 503 SlowDown. Because allOf fails as soon as any chunk fails while the rest keep running, the caller gets one exception and no record of which chunks landed.

The sync path is deliberately sequential here, so the two implementations of the same driver method have very different blast radii under load.

Suggested fix: bound the fan-out — e.g. chain chunks with thenCompose in groups of a fixed width (8–16), or walk the chunk list with a small fixed-size window — so concurrency is a constant rather than a function of the caller's collection size.

.bucket(getBucket())
.delete(Delete.builder().objects(objectIds).build())
.build();
return Lists.partition(objectIds, MAX_DELETE_OBJECTS_PER_REQUEST).stream()

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 duplicates a chunking mechanism that already exists in this module, leaving two constants for the same S3 limit and a now-redundant double partition.

AwsTransformer already has partitionList(List<BlobInfo>, int) (line 924), and AwsAsyncBlobStore already declares private static final int MAX_OBJECTS_PER_DELETE = 1000 (line 95) and uses it in doDeleteDirectory to pre-chunk batches at exactly this limit before calling doDelete:

List<List<BlobInfo>> partitionedBlobLists =
    transformer.partitionList(batch.getBlobs(), MAX_OBJECTS_PER_DELETE);
for (List<BlobInfo> blobList : partitionedBlobLists) {
  futures.add(doDelete(transformer.toBlobIdentifiers(blobList)));
}

After this change doDeleteDirectory partitions at 1000 and then toDeleteRequests partitions the result at 1000 again — harmless, but the outer pre-chunking is now dead weight, and the S3 key limit is encoded in two separate constants in the same module.

Suggested fix: keep MAX_DELETE_OBJECTS_PER_REQUEST as the single source of truth, delete AwsAsyncBlobStore.MAX_OBJECTS_PER_DELETE, and drop the now-unnecessary partitionList pre-chunking in doDeleteDirectory so bulk delete is batched in exactly one place.

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