Skip to content

Add BlobBatchClient for deleting blobs in one request - #82

Draft
kartik-joshi-01 wants to merge 2 commits into
php-oss-for-azure:mainfrom
kartik-joshi-01:blob-batch
Draft

kartik-joshi-01 wants to merge 2 commits into
php-oss-for-azure:mainfrom
kartik-joshi-01:blob-batch

Conversation

@kartik-joshi-01

Copy link
Copy Markdown

Closes #81.

Batch delete, shaped after the .NET library. There's deleteBlobs() for the everyday case and createBatch() / submitBatch() when blobs need their own options or conditions.

$batchClient = $container->getBlobBatchClient();   // or $service->getBlobBatchClient()

$batchClient->deleteBlobs(['a.txt', 'reports/2026-09.csv']);
$batchClient->deleteBlobs($names, DeleteSnapshotsOption::INCLUDE_SNAPSHOTS);

$batch = $batchClient->createBatch();
$batch->deleteBlob('a.txt', new DeleteBlobOptions(snapshotsOption: DeleteSnapshotsOption::ONLY_SNAPSHOTS));
$batch->deleteBlob($container->getBlobClient('b.txt'), new DeleteBlobOptions(conditions: new BlobRequestConditions(ifMatch: $etag)));
$batchClient->submitBatch($batch);

try {
    $batchClient->deleteBlobs($names);
} catch (BlobBatchException $e) {
    foreach ($e->failures as $failure) {
        $failure->blob;                          // 'missing.txt', exactly as passed in
        $failure->index;                         // its position in the batch
        $failure->exception->errorCode;          // BlobErrorCode::BlobNotFound
    }
}

Names are relative to the client: a blob name on a container client, container/blob on a service client. BlobClient instances work too, and their snapshot / versionid selectors are kept.

What's in here

  • BlobBatchClient and BlobBatch under Specialized, with BlobBatchClientOptions (same idea as BlobLeaseClientOptions), BlobBatchException and BlobBatchFailure. deleteBlobs() is createBatch() + deleteBlob() per name + submitBatch(), so there's one code path.
  • getBlobBatchClient() on BlobContainerClient (posts to {container}?restype=container&comp=batch) and on BlobServiceClient (posts to /?comp=batch, sub-request paths are /{container}/{blob}). The scope is passed explicitly, not parsed from the URI.
  • In storage-common, a small RequestSigner interface. Each sub-request inside a batch needs its own Authorization header, and the signing logic lives in the two auth middleware, so they now implement sign() and __invoke calls it. The batch client hands the same middleware instance to the outer client and to the sub-requests, so one batch fetches an Entra token once. The signing code itself didn't change.

How it behaves

Same rules as .NET's BlobBatch: a batch belongs to the client scope that created it (same URI and credential, so a fresh getBlobBatchClient() is fine), can't be empty, can't be submitted twice, and can't be added to after submitting. Like .NET it's marked submitted before the request goes out, so a batch whose request failed can't be resubmitted either. Empty names are rejected, and on a service client a name has to be container/blob.

A batch isn't atomic and the service gives no ordering guarantee. Each sub-request succeeds or fails on its own, and every failure comes back in one BlobBatchException: a BlobBatchFailure per blob with its index, the input as you passed it, and a BlobStorageException built from the sub-response by the existing deserializer. BlobBatchException extends BlobStorageException (Java does the same), so existing catch blocks still see partial failures. A 404 is a failure, same as BlobClient::delete() and the .NET README's troubleshooting example.

If the service refuses the whole batch you get the usual BlobStorageException from the middleware. That includes the undocumented shape where the refusal comes back as a 202 with a single 400 part and no Content-ID; .NET special-cases it too, and both Azurite versions produce it when a sub-request path is malformed.

Auth: shared key and Entra ID sign every sub-request; a SAS on the client URI is appended to every sub-request path. Per the spec the SAS needs w for the batch request itself and d for each blob; the docs page says so. Sub-requests carry x-ms-date but no x-ms-version, the outer request's applies. A BlobClient from a different account, endpoint or container is rejected when you add it.

Snapshot and version selectors go out unencoded, as .NET sends them; Azurite (3.35 and 3.37) 404s the percent-encoded form. The response boundary comes from the response Content-Type, because Azure uses batchresponse_<id> while Azurite 3.35 echoes the request boundary.

Two things I didn't copy from .NET: submitBatch() returns nothing and always throws on a failure, because .NET's throwOnAnyFailure: false only makes sense with its lazy per-operation Response objects and we don't have those (Python raises by default as well). And the 256 limit is checked client-side; .NET has a TODO for it and leaves it to the service. The 4 MB body limit is left to the service.

One caveat in the docblocks: the retry middleware may replay a batch whose response was lost, and the replay then reports BlobNotFound for blobs the first attempt already deleted.

Tests

Integration on Azurite: container and account scope, awkward names (spaces, unicode, +, emoji), a missing blob and a leased blob failing while the rest get deleted, snapshot options for the whole list and per blob, deleting a selected snapshot, container SAS and account SAS with w+d. Functional tests on guzzlehttp/test-server pin the wire format: the exact shared-key signature on each sub-request (known-answer, computed after x-ms-date is set), Bearer and SAS per sub-request, one token fetch per batch, per-blob options, parsing a batchresponse_ body with 202/404/412, the single-400-part refusal, and a batch rejected as a whole. Unit tests for the batch rules, name validation, scope, the multipart parser, options inheritance and a known-answer RequestSigner test.

The core suite as tests.yml runs it, on Azurite 3.37 with Guzzle 8: 489 passed, 28 skipped. The batch integration tests also pass on Azurite 3.35. PHPStan level 10 (no suppressions) and Pint are clean, and the storage-common commit passes on its own.

Things I'd like your call on

  1. I bumped storage-blob's requirement to azure-oss/storage-common: ^2.3 for RequestSigner, since Common is at 2.2.1 with an unreleased Added entry. Change it if you version it differently.
  2. No live Entra ID batch test. The live workflow's identity is only exercised by the download tests in CredentialAuthorizationTest, and its role isn't assigned in infra/, so I can't tell whether it may delete. Bearer signing of sub-requests is covered functionally. If the identity has a data-plane write role, say so and I'll add one.
  3. The functional batch tests sit in Functional/BlobClientTest.php rather than their own class: guzzlehttp/test-server uses one fixed port, and under pest --parallel a second test-server class stops the first one's server mid-run. Splitting them needs a per-worker port or a lock.
  4. Set Blob Tier, the other batch sub-operation, is left for a follow-up PR since the SDK has no access-tier support yet: an AccessTier enum and BlobClient::setAccessTier() first, then BlobBatch::setBlobAccessTier(), BlobBatchClient::setBlobsAccessTier() and the one-operation-type-per-batch rule from .NET. Happy to do that one next if you want it.

Blob Batch sub-requests are serialized into the batch body rather than sent
through the HTTP client, so the shared key and Entra ID middleware now also
sign a single request through RequestSigner. The client factory accepts that
signer, so a client and the requests signed outside it share one token cache.
@kartik-joshi-01
kartik-joshi-01 marked this pull request as draft September 24, 2026 04:25
@brecht-vermeersch

Copy link
Copy Markdown
Member

Thanks. Will take some time to review this

This branch has not been deployed

No deployments
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.

Blob Batch API support (batch delete)

2 participants