feat(blob): blob lease support for the Azure Functions storage backplane - #183
Conversation
|
| Filename | Overview |
|---|---|
| src/main/java/io/floci/az/services/blob/BlobLease.java | Defines the immutable lease state machine and correctly caps break completion at a fixed lease’s natural expiry. |
| src/main/java/io/floci/az/services/blob/BlobLeaseService.java | Implements validated lease transitions, write guards, response headers, and the shared reentrant monitor used to linearize lease and blob mutations. |
| src/main/java/io/floci/az/services/blob/BlobServiceHandler.java | Routes blob lease requests, enforces lease guards, reports lease state, and synchronizes blob/container mutation preconditions with their store updates. |
| src/test/java/io/floci/az/services/BlobLeaseTest.java | Adds HTTP-level coverage for lease lifecycle, protocol errors, guarded writes, and lease cleanup. |
| src/test/java/io/floci/az/services/blob/BlobLeaseStateTest.java | Covers expiry, renewal, break timing, and fixed-lease break bounds with synthetic clocks. |
| compatibility-tests/sdk-test-java/src/test/java/io/floci/az/compat/BlobCompatibilityTest.java | Adds real Azure Java SDK compatibility coverage for lifecycle, write guards, lease stealing, and ownership handoff. |
Sequence Diagram
sequenceDiagram
participant SDK as Azure SDK / Functions Host
participant Handler as BlobServiceHandler
participant Lease as BlobLeaseService
participant Store as StorageBackend
SDK->>Handler: "PUT blob?comp=lease"
Handler->>Lease: exclusively()
Lease->>Store: Verify blob exists
Lease->>Lease: Validate and transition lease
Lease-->>SDK: Lease response and headers
SDK->>Handler: Lease-conditioned blob mutation
Handler->>Lease: exclusively()
Lease->>Lease: Validate current lease ID
Lease->>Store: Apply mutation atomically
Handler-->>SDK: Azure-compatible response
Reviews (9): Last reviewed commit: "fix(blob): classify PutBlob authorizatio..." | Re-trigger Greptile
Review follow-ups on floci-io#183 (all three findings verified real): - Guard/mutation atomicity: replace the check-then-act validateWrite pattern with BlobLeaseService.guardedWrite(), which runs the lease guard AND the store mutation under the same monitor as the (already synchronized) lease operations. A competing acquire/break can no longer slip between the guard and the write — the interleaving the Durable partition-steal path exercises. validateWrite is now private. - change without x-ms-proposed-lease-id previously stored a lease with a null id; the next renew then NPE'd into a 500 with the lease stuck forever. renew/change/release now require x-ms-lease-id and change requires x-ms-proposed-lease-id (400 MissingRequiredHeader), and proposed ids must be GUIDs (400 InvalidHeaderValue) so a malformed id can never enter the lease store. - x-ms-lease-break-period was clamped at 0 and unbounded above, allowing a lease stuck breaking arbitrarily long. Out-of-range values (outside 0-60) now return 400 InvalidHeaderValue. Four new regression tests (watched fail first). Full suite 579 green; Java BlobLeaseClient compat and the .NET Azure.Storage.Blobs harness re-verified against the rebuilt build (still line-for-line with Azurite 3.35.0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All three findings verified against the code and fixed in f8f336c: 1. Guard/mutation race (TOCTOU). Confirmed: 2. Malformed 3. Break period validation. Confirmed: negative values were clamped and large values accepted, allowing a lease stuck in New regression tests (all watched fail before the fix): rejected change leaves the lease intact and renewable, missing-header 400s for all three ops, break period |
Review follow-up on floci-io#183: deleteContainer swept blob and lease state outside the monitor that lease operations and guarded writes hold, so a concurrent acquire could install a lease for a just-deleted blob (a recreated blob would inherit it) and a guarded write could re-put blob data into a deleted container after the sweep. Generalize guardedWrite into BlobLeaseService.exclusively(): every mutation of blob or lease state now runs its precondition checks (container/blob existence, conditional headers, lease guard) AND its store mutation inside the single lease monitor — putBlob, setBlobMetadata, deleteBlob, putBlock, putBlockList, leaseBlob's existence check, and the deleteContainer sweep. This also restores Azure's natural check order (404 before the lease 412s, which guardedWrite inverted). The remaining lease-map maintenance hooks (onBlobDeleted/onContainerDeleted/clear) are synchronized on the same monitor. New sequential regression test pins the sweep invariant: lease, delete container, recreate container+blob, write and re-acquire must succeed. Full suite 580 green; Java BlobLeaseClient compat and the .NET Azure.Storage.Blobs harness re-verified (line-for-line with Azurite). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thank you for building this out, and for flagging the stack on #182 in the description yourself, that made the shared history easy to follow. (blocking) Commits (blocking) This PR also edits Heads up before you rebase: we merged #166 and #134 today, so this now conflicts on |
Review follow-ups on floci-io#183 (all three findings verified real): - Guard/mutation atomicity: replace the check-then-act validateWrite pattern with BlobLeaseService.guardedWrite(), which runs the lease guard AND the store mutation under the same monitor as the (already synchronized) lease operations. A competing acquire/break can no longer slip between the guard and the write — the interleaving the Durable partition-steal path exercises. validateWrite is now private. - change without x-ms-proposed-lease-id previously stored a lease with a null id; the next renew then NPE'd into a 500 with the lease stuck forever. renew/change/release now require x-ms-lease-id and change requires x-ms-proposed-lease-id (400 MissingRequiredHeader), and proposed ids must be GUIDs (400 InvalidHeaderValue) so a malformed id can never enter the lease store. - x-ms-lease-break-period was clamped at 0 and unbounded above, allowing a lease stuck breaking arbitrarily long. Out-of-range values (outside 0-60) now return 400 InvalidHeaderValue. Four new regression tests (watched fail first). Full suite 579 green; Java BlobLeaseClient compat and the .NET Azure.Storage.Blobs harness re-verified against the rebuilt build (still line-for-line with Azurite 3.35.0).
Review follow-up on floci-io#183: deleteContainer swept blob and lease state outside the monitor that lease operations and guarded writes hold, so a concurrent acquire could install a lease for a just-deleted blob (a recreated blob would inherit it) and a guarded write could re-put blob data into a deleted container after the sweep. Generalize guardedWrite into BlobLeaseService.exclusively(): every mutation of blob or lease state now runs its precondition checks (container/blob existence, conditional headers, lease guard) AND its store mutation inside the single lease monitor — putBlob, setBlobMetadata, deleteBlob, putBlock, putBlockList, leaseBlob's existence check, and the deleteContainer sweep. This also restores Azure's natural check order (404 before the lease 412s, which guardedWrite inverted). The remaining lease-map maintenance hooks (onBlobDeleted/onContainerDeleted/clear) are synchronized on the same monitor. New sequential regression test pins the sweep invariant: lease, delete container, recreate container+blob, write and re-acquire must succeed. Full suite 580 green; Java BlobLeaseClient compat and the .NET Azure.Storage.Blobs harness re-verified (line-for-line with Azurite).
7ca231f to
8cd84e7
Compare
|
Both blocking items addressed, and the stack is rebased onto current
On |
|
Thank you, and apologies this took me a while to review properly. I have now gone through the lease behaviour against the blob spec and it checks out across the board: acquire returns 201, renew, change and release return 200, and break returns 202, which is exactly what the spec declares for each. The duration validation matches too, 15 to 60 seconds or -1 for infinite, and rejecting outside that with Two mechanical things left, neither about the lease work: (blocking) We merged #185 today and it conflicts this branch on The red check is the same node table test as #182, which resolves once that one is updated. Small thing: the description says "Refs #136", so the issue will not auto close; "Closes #136" would wire it up. |
Review follow-ups on floci-io#183 (all three findings verified real): - Guard/mutation atomicity: replace the check-then-act validateWrite pattern with BlobLeaseService.guardedWrite(), which runs the lease guard AND the store mutation under the same monitor as the (already synchronized) lease operations. A competing acquire/break can no longer slip between the guard and the write — the interleaving the Durable partition-steal path exercises. validateWrite is now private. - change without x-ms-proposed-lease-id previously stored a lease with a null id; the next renew then NPE'd into a 500 with the lease stuck forever. renew/change/release now require x-ms-lease-id and change requires x-ms-proposed-lease-id (400 MissingRequiredHeader), and proposed ids must be GUIDs (400 InvalidHeaderValue) so a malformed id can never enter the lease store. - x-ms-lease-break-period was clamped at 0 and unbounded above, allowing a lease stuck breaking arbitrarily long. Out-of-range values (outside 0-60) now return 400 InvalidHeaderValue. Four new regression tests (watched fail first). Full suite 579 green; Java BlobLeaseClient compat and the .NET Azure.Storage.Blobs harness re-verified against the rebuilt build (still line-for-line with Azurite 3.35.0).
Review follow-up on floci-io#183: deleteContainer swept blob and lease state outside the monitor that lease operations and guarded writes hold, so a concurrent acquire could install a lease for a just-deleted blob (a recreated blob would inherit it) and a guarded write could re-put blob data into a deleted container after the sweep. Generalize guardedWrite into BlobLeaseService.exclusively(): every mutation of blob or lease state now runs its precondition checks (container/blob existence, conditional headers, lease guard) AND its store mutation inside the single lease monitor — putBlob, setBlobMetadata, deleteBlob, putBlock, putBlockList, leaseBlob's existence check, and the deleteContainer sweep. This also restores Azure's natural check order (404 before the lease 412s, which guardedWrite inverted). The remaining lease-map maintenance hooks (onBlobDeleted/onContainerDeleted/clear) are synchronized on the same monitor. New sequential regression test pins the sweep invariant: lease, delete container, recreate container+blob, write and re-acquire must succeed. Full suite 580 green; Java BlobLeaseClient compat and the .NET Azure.Storage.Blobs harness re-verified (line-for-line with Azurite).
8cd84e7 to
8370b32
Compare
|
Rebased onto current
Full unit suite green after the rebase: 748 tests, 0 failures. The red node check resolves via #182's test update, verified 11/11 locally against a running emulator. The description now says |
|
Thank you, everything from my last note is addressed: the rebase over #185 and #158, the Closes wiring, and the stack on #182 staying aligned. I also went through your three new commits and they hold up; capping the break period at a fixed lease's natural expiry is exactly what the spec prescribes, and running the authorization checks ahead of the lease monitor matches real Azure's error precedence (403 before 412). One thing on my side, and it is ours not yours: the #198 merge (ADLS list paths) landed in |
Implement Lease Blob (comp=lease) — acquire, renew, change, release, break — with real lease state reported by Get Blob/Properties and lease-conditioned enforcement on every blob write path (PutBlob, SetBlobMetadata, DeleteBlob, PutBlock, PutBlockList): - active lease + no x-ms-lease-id -> 412 LeaseIdMissing - active lease + wrong id -> 412 LeaseIdMismatchWithBlobOperation - no active lease + id supplied -> 412 LeaseNotPresentWithBlobOperation - competing acquire -> 409 LeaseAlreadyPresent - renew/change/release id mismatch -> 409 LeaseIdMismatchWithLeaseOperation - lease op with no lease -> 409 LeaseNotPresentWithLeaseOperation - renew after break -> 409 LeaseIsBrokenAndCannotBeRenewed - acquire duration outside -1/15-60 -> 400 InvalidHeaderValue Blob leases are the coordination primitive of the Azure Functions host: WebJobs singleton and timer-trigger locks and Durable Functions partition management are all blob leases. Together with the Table OData error fix (floci-io#182), this lets a self-hosted Functions fleet point AzureWebJobsStorage at floci-az for durable/timer/queue-triggered workloads — the storage-binding side of floci-io#136. (Emulating those triggers inside floci-az's own hosted-function runtime remains open.) Design: lease state is an in-memory map in BlobLeaseService (an emulator restart equals all leases expiring, matching lease semantics); the BlobLease record is a pure function of (lease, now) so expiry and break timing are unit-testable. Container leases stay unimplemented. Verified: unit suite (BlobLeaseTest, BlobLeaseStateTest) red->green; Java SDK compat (BlobLeaseClient lifecycle, write guards, steal-via- break, change handoff) fails on 0.10.0 and passes here; a .NET Azure.Storage.Blobs harness runs the same sequence against Azurite 3.35.0 and this build with line-for-line identical output. Refs floci-io#136
Review follow-ups on floci-io#183 (all three findings verified real): - Guard/mutation atomicity: replace the check-then-act validateWrite pattern with BlobLeaseService.guardedWrite(), which runs the lease guard AND the store mutation under the same monitor as the (already synchronized) lease operations. A competing acquire/break can no longer slip between the guard and the write — the interleaving the Durable partition-steal path exercises. validateWrite is now private. - change without x-ms-proposed-lease-id previously stored a lease with a null id; the next renew then NPE'd into a 500 with the lease stuck forever. renew/change/release now require x-ms-lease-id and change requires x-ms-proposed-lease-id (400 MissingRequiredHeader), and proposed ids must be GUIDs (400 InvalidHeaderValue) so a malformed id can never enter the lease store. - x-ms-lease-break-period was clamped at 0 and unbounded above, allowing a lease stuck breaking arbitrarily long. Out-of-range values (outside 0-60) now return 400 InvalidHeaderValue. Four new regression tests (watched fail first). Full suite 579 green; Java BlobLeaseClient compat and the .NET Azure.Storage.Blobs harness re-verified against the rebuilt build (still line-for-line with Azurite 3.35.0).
Review follow-up on floci-io#183: deleteContainer swept blob and lease state outside the monitor that lease operations and guarded writes hold, so a concurrent acquire could install a lease for a just-deleted blob (a recreated blob would inherit it) and a guarded write could re-put blob data into a deleted container after the sweep. Generalize guardedWrite into BlobLeaseService.exclusively(): every mutation of blob or lease state now runs its precondition checks (container/blob existence, conditional headers, lease guard) AND its store mutation inside the single lease monitor — putBlob, setBlobMetadata, deleteBlob, putBlock, putBlockList, leaseBlob's existence check, and the deleteContainer sweep. This also restores Azure's natural check order (404 before the lease 412s, which guardedWrite inverted). The remaining lease-map maintenance hooks (onBlobDeleted/onContainerDeleted/clear) are synchronized on the same monitor. New sequential regression test pins the sweep invariant: lease, delete container, recreate container+blob, write and re-acquire must succeed. Full suite 580 green; Java BlobLeaseClient compat and the .NET Azure.Storage.Blobs harness re-verified (line-for-line with Azurite).
Break Lease applies x-ms-lease-break-period only when it is shorter than the lease's remaining time, but broken() stamped breakAt unconditionally at now + period. A fixed lease with less time remaining than the requested period therefore sat in BREAKING — rejecting reacquisition — past the moment it should have expired, since stateAt() checks breakAt before expiresAt. Cap breakAt at expiresAt in broken(). The no-header default path already derived the period from the remaining time; the explicit-header path now honors the same bound. State-machine tests pin both directions: a 60s break on a lease with 5s left completes at natural expiry, and a period shorter than the remaining time is used as given.
3180591 to
7418904
Compare
|
Rebased onto current Full unit suite green on the rebased head: 789 tests, 0 failures. |
createContainer checked and wrote the namespace sentinel outside the monitor that deleteContainer sweeps under, so a create that observed absence could re-put the sentinel after a concurrent deletion sweep — resurrecting the container after DELETE answered 202 — and two concurrent creates could both answer 201 where the contract gives one ContainerAlreadyExists. ensureContainer (ARM provisioning) had the same unsynchronized put. Run both under the lease monitor via a void exclusively(Runnable) overload, and move the admin-reset sweep (clear) under it too — the last mutation outside the monitor — so the invariant stated in exclusively()'s javadoc holds everywhere. Sequential behavior is unchanged; the interleaving is eliminated by construction and the existing container lifecycle tests pin the sequential contract.
PutBlob chooses between create and write SAS authorization from blob existence, but that read sat outside the monitor while the mutation re-read state inside it. A create-only SAS (sp=c without w) that observed absence could keep CREATE authorization and overwrite a blob a concurrent request had just created, destroying its data. Move the classification inside exclusively(), driven by the same existing snapshot the mutation uses — one read now feeds authorization, the conditional-header checks, and the creation-time stamp. Sequential precedence is unchanged (403 before the container 404, then conditions, then the lease guard), pinned by the existing create-only SAS overwrite test; the body read stays outside the monitor so no I/O runs under the lock, and the authorize checks are pure token computation.
|
Thank you for the quick turnaround, and for owning the two Greptile findings rather than arguing them. I checked both fixes and they are right: creating the container and classifying the PutBlob authorization now read the same snapshot the mutation uses, under the same monitor, and the body read stays outside the lock. I also confirmed your claim the easy way: the four lease files are byte identical to the head I reviewed, so the earlier grounding against the blob spec (201 acquire, 200 renew, change and release, 202 break, 15 to 60 second durations, 0 to 60 second break period) still stands, and the #198 metadata hunk now rides inside the guarded write as it should. All eleven checks are green on this head, including every compat suite. No blockers from my side, merging now. Thank you for seeing this one through three rebases that were our fault, not yours. |
|
Excited to try the new release once it's ready, so I can remove some external container dependencies for azure! |
|
🎉 This PR is included in version 0.12.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
# [0.12.0](floci-io/floci-az@0.11.0...0.12.0) (2026-09-01) ### Bug Fixes * **blob:** default unusable blob content types to application/octet-stream ([floci-io#210](floci-io#210)) ([4fdbb7a](floci-io@4fdbb7a)) * **blob:** honor user delegation SAS signing layouts by version ([floci-io#202](floci-io#202)) ([8ca73e8](floci-io@8ca73e8)) * **blob:** preserve ADLS list directory identity ([floci-io#201](floci-io#201)) ([2a3635b](floci-io@2a3635b)) * **cosmos:** enable .NET SDK queries ([floci-io#220](floci-io#220)) ([8bde4d5](floci-io@8bde4d5)), closes [floci-io#216](floci-io#216) * **cosmos:** support .NET transactional batch ([floci-io#222](floci-io#222)) ([5efd630](floci-io@5efd630)), closes [floci-io#217](floci-io#217) * **eventhub:** make the AMQP data plane usable from the Azure SDKs (CBS + library patches) ([floci-io#237](floci-io#237)) ([5c8806e](floci-io@5c8806e)), closes [floci-io#128](floci-io#128) * **eventhub:** route sends addressed the way the Azure SDKs address them ([floci-io#241](floci-io#241)) ([0d04662](floci-io@0d04662)) * **functions:** support Python v2 app-root packages ([floci-io#200](floci-io#200)) ([09d6a7c](floci-io@09d6a7c)) * honor Cosmos patch filter predicates ([floci-io#230](floci-io#230)) ([10dd22c](floci-io@10dd22c)), closes [floci-io#217](floci-io#217) * honor X-Forwarded-Proto in /metadata/endpoints so URLs are https behind a TLS proxy ([floci-io#253](floci-io#253)) ([37aa829](floci-io@37aa829)), closes [floci-io#252](floci-io#252) * return ARM errors for failed SQL operations ([floci-io#229](floci-io#229)) ([7519c7d](floci-io@7519c7d)), closes [floci-io#138](floci-io#138) * **servicebus:** reap orphaned sidecars ([floci-io#219](floci-io#219)) ([c9f3915](floci-io@c9f3915)), closes [floci-io#218](floci-io#218) * **servicebus:** report runtime counts ([floci-io#225](floci-io#225)) ([e869661](floci-io@e869661)), closes [floci-io#214](floci-io#214) * **servicebus:** route .NET admin requests ([floci-io#221](floci-io#221)) ([c0e003f](floci-io@c0e003f)), closes [floci-io#215](floci-io#215) * **servicebus:** support session DLQ access ([floci-io#223](floci-io#223)) ([5036cad](floci-io@5036cad)) * **servicebus:** unpack AMQP batches ([floci-io#224](floci-io#224)) ([bc01cee](floci-io@bc01cee)) * **table:** wrap error responses in the OData error envelope ([floci-io#182](floci-io#182)) ([96a0f6a](floci-io@96a0f6a)), closes [floci-io#181](floci-io#181) ### Features * **aci:** add Azure Container Instances emulation (mocked ARM CRUD) ([floci-io#208](floci-io#208)) ([1377f74](floci-io@1377f74)), closes [floci-io#59](floci-io#59) * add ADLS list paths support ([floci-io#198](floci-io#198)) ([7c47687](floci-io@7c47687)) * **blob:** blob lease support for the Azure Functions storage backplane ([floci-io#183](floci-io#183)) ([e82139f](floci-io@e82139f)), closes [floci-io#182](floci-io#182) [floci-io#136](floci-io#136) * **compatibility-tests:** add Azure SDK for C++ compatibility suite ([floci-io#186](floci-io#186)) ([3c69159](floci-io@3c69159)), closes [floci-io#184](floci-io#184) * **servicebus:** add duplicate detection ([floci-io#171](floci-io#171)) ([413a65a](floci-io@413a65a)) * **servicebus:** add entity message expiration ([floci-io#172](floci-io#172)) ([0648e1a](floci-io@0648e1a)) * **servicebus:** honor per-entity MaxDeliveryCount and LockDuration ([floci-io#207](floci-io#207)) ([4d39b93](floci-io@4d39b93)) * **servicebus:** start the default namespace on boot for a deterministic AMQP endpoint ([floci-io#250](floci-io#250)) ([69d3600](floci-io@69d3600)) * **servicebus:** support non-destructive message peeking ([floci-io#203](floci-io#203)) ([93f3b6d](floci-io@93f3b6d)), closes [floci-io#137](floci-io#137) [floci-io#137](floci-io#137) * **sql:** add data-plane providers ([floci-io#204](floci-io#204)) ([d789831](floci-io@d789831)), closes [floci-io#138](floci-io#138) * **sql:** provision managed servers async ([floci-io#205](floci-io#205)) ([aff2aca](floci-io@aff2aca)), closes [floci-io#138](floci-io#138)
Closes #136
What this is
The storage-binding half of #136. That issue's full ask — durable/timer triggers inside floci-az's own hosted-function runtime — is an architectural feature for the maintainers. But the reason fleets like ours can't use floci-az at all for durable/timer workloads today is narrower: the Azure Functions host runs fine in our own containers, pointed at floci-az as its
AzureWebJobsStoragebackplane — except that backplane is missing its coordination primitive. Blob leases are not implemented (comp=lease→ 501), and Get Blob hardcodesx-ms-lease-state: available.Blob leases are what the Functions host runs on:
azure-webjobs-hosts/locks/...x-ms-lease-id), which only works if the emulator enforces the guardsChange
BlobLease— one lease's state as a pure function of (lease, now):LEASED / EXPIRED / BREAKING / BROKEN. Expiry and break timing never mutate anything, so the state machine is unit-testable with synthetic clocks.BlobLeaseService(@ApplicationScoped, same pattern asUserDelegationKeyService) — Lease Blob dispatch and the write guard. Lease state is an in-memory map: like a real lease it's transient runtime state, and an emulator restart is equivalent to every lease having expired.BlobServiceHandler— routesPUT ?comp=lease(blob-level), enforces the lease guard on every write path (PutBlob, SetBlobMetadata, DeleteBlob, PutBlock, PutBlockList), reports real lease state in Get Blob/Properties, and drops lease state when the blob/container is deleted or reset.Status/error codes follow the Lease Blob contract:
409 LeaseAlreadyPresent201(idempotent, SDK retry path)409 LeaseIdMismatchWithLeaseOperation409 LeaseNotPresentWithLeaseOperation409 LeaseIsBrokenAndCannotBeRenewed400 InvalidHeaderValue412 LeaseIdMissing412 LeaseIdMismatchWithBlobOperation412 LeaseNotPresentWithBlobOperationDeliberately out of scope: container leases (nothing in the Functions host path needs them) —
restype=container&comp=leasestill 501s honestly rather than half-working.Tests
BlobLeaseTest(15 HTTP-level tests: lifecycle, error codes, write guards, lease-dies-with-blob) andBlobLeaseStateTest(6 pure state-machine tests: expiry, renew-resets, break windows). All watched fail againstmain(501s / hardcoded headers) before implementing. Full suite: 575 tests green.sdk-test-java): 4 newBlobCompatibilityTestcases using the realBlobLeaseClient— lifecycle, write guards, steal-via-break, change handoff. All 4 error against the released 0.10.0 image and pass against this branch.BlobCompDispatchTest:leaseremoved from the unimplemented-complist (that guard's job — don't corrupt blobs via putBlob fallthrough — is now covered by the real implementation).The Java compat pass caught a real cross-SDK bug in my first cut: the acquire response carried an ISO-8601
Last-Modified, which .NET tolerates but the Java SDK parses strictly (RFC1123) and crashes on. Lease responses now echo the blob's actualETag/Last-Modifiedin wire format.Verification against real SDKs
A .NET
Azure.Storage.Blobs12.24 harness (the SDK family the Functions host uses) runs a 12-step sequence — acquire-infinite, props, write-no-lease, write-wrong-lease, write-with-lease, renew, competing acquire, change, break, steal-after-break, props, content — against Azurite 3.35.0 and against this branch. Output is line-for-line identical:Next step on our side once this and #182 ship: run a real Durable Functions fleet (fresh start and restart-with-existing-state) against floci-az as
AzureWebJobsStorageand report back on #136 with whatever the host surfaces next, if anything.🤖 Generated with Claude Code