Skip to content

fix: condition counter writes on the read-time ETag to prevent duplicate ids under multi-instance concurrency#30

Open
akshaybheda wants to merge 2 commits into
0x414c49:mainfrom
akshaybheda:fix/read-time-etag-cas
Open

fix: condition counter writes on the read-time ETag to prevent duplicate ids under multi-instance concurrency#30
akshaybheda wants to merge 2 commits into
0x414c49:mainfrom
akshaybheda:fix/read-time-etag-cas

Conversation

@akshaybheda

Copy link
Copy Markdown

Problem

BlobOptimisticDataStore.TryOptimisticWrite conditions its upload on the blob's current
ETag, fetched via GetProperties() at write time — not the ETag of the value the preceding
GetData returned. A competing writer that commits between the read and the write goes
undetected: its commit changes the ETag, the late writer fetches that new ETag, and the
If-Match passes. Both writers succeed and both processes hand out the same id range.

Step Process A Process B
1 GetData → "500"
2 GetData → "500"
3 writes "501" (If-Match: E1) → OK, ETag → E2; dispenses 500
4 GetPropertiesE2; writes "501" (If-Match: E2) → OK; also dispenses 500

We hit this in production-like testing: 100 parallel entity creates against a two-instance
deployment produced duplicate ids (used as primary keys). This appears to have regressed in
the Azure.Storage.Blobs v12 migration — the legacy CloudBlockBlob cached Properties.ETag
from the preceding read, so the old AccessCondition.GenerateIfMatchCondition(...) compared
against the read-time state; BlockBlobClient doesn't cache, and GetProperties() became a
live call. Related earlier attempt: #25.

Fix

  • GetData(Async) uses DownloadContent(Async), which returns the content and its ETag from
    a single response, and records the read-time ETag per block name.
  • TryOptimisticWrite(Async) consumes that ETag for the If-Match condition, making the
    read→write cycle a correct compare-and-swap: an intervening commit fails the write with 412
    and UniqueIdGenerator re-reads and retries.
  • No public API changes. Writes without a preceding read fall back to the previous
    current-ETag behaviour.

Test

New Azurite integration test (ShouldNotGenerateDuplicateIdsAcrossGeneratorsUnderContention):
four independent generators allocate 100 ids each from one scope with BatchSize = 1 and all
400 must be distinct. This reproduces duplicates on the previous implementation.

…ate ids

TryOptimisticWrite conditioned its upload on the blob ETag fetched at
write time (GetProperties), not on the ETag of the value GetData
returned. A competing writer that committed between the read and the
write therefore went undetected: its commit changed the ETag, the
late writer fetched that new ETag, and the If-Match condition passed.
Both writers succeeded and both processes handed out the same id range.

GetData(Async) now uses DownloadContent, which returns the content and
its ETag from a single response, and records that read-time ETag per
block. TryOptimisticWrite(Async) consumes it for the If-Match condition,
turning the read->write cycle into a correct compare-and-swap: an
intervening commit fails the write with 412 and UniqueIdGenerator
re-reads and retries. Writers without a preceding read keep the previous
current-ETag behaviour.

Adds an Azurite integration test with four independent generators
allocating concurrently from one scope (BatchSize 1), which reproduces
the duplicate ids on the previous implementation.
@akshaybheda

Copy link
Copy Markdown
Author

@0x414c49 Can you check this?

@0x414c49

0x414c49 commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Nice find, and the repro test is great, really shows the bug clearly (231/400 unique on main before this fix, all 400 unique after). Tested it locally against main and against this branch to confirm.

One thing worth a look before merging: readETags is a single ConcurrentDictionary<string, ETag> on the instance, keyed only by block name. That's fine for the way this library is actually wired today (DI singleton store + UniqueIdGenerator locking per scope name around the whole read+write cycle serializes everything for a given block), but the class itself doesn't enforce that. If BlobOptimisticDataStore were ever shared by two independent callers hitting the same block concurrently, one caller's GetData could overwrite the other's entry in the dict. The failure mode isn't a crash, it's a silent fallback to GetProperties() (the old current-ETag behavior), which quietly brings back the exact bug this PR fixes for that caller.

Not something I'd block on since it can't happen with how the store is registered/used in this repo, but maybe worth a short comment on the class or the field noting that read+write for the same block need to stay serialized per instance, so nobody hits this if they use BlobOptimisticDataStore directly down the line.

Also, small side benefit worth calling out in the description: switching to DownloadContent instead of DownloadTo + a separate GetProperties call also saves a network round trip on the write path when there was a preceding read. Nice bonus on top of the correctness fix.

Logic itself looks right to me, good to go from my side.

…obOptimisticDataStore

The read-time ETag captured by GetData is stored per block name and consumed
by the next TryOptimisticWrite. That requires the read->write cycle for a
given block to stay serialized per instance (as UniqueIdGenerator does via its
per-scope lock). Add a class-level XML doc comment noting the invariant and the
silent duplicate-id fallback that occurs if a shared instance interleaves
concurrent read+write for the same block, so future direct users of the class
stay within it. Doc-only; no behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@akshaybheda

Copy link
Copy Markdown
Author

added the comment

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