Skip to content

Cache Azure Blob container initialization to avoid per-upload CreateIfNotExistsAsync - #785

Merged
berndverst merged 14 commits into
mainfrom
berndverst-cache-blob-container-init
Jul 31, 2026
Merged

Cache Azure Blob container initialization to avoid per-upload CreateIfNotExistsAsync#785
berndverst merged 14 commits into
mainfrom
berndverst-cache-blob-container-init

Conversation

@berndverst

@berndverst berndverst commented Jul 24, 2026

Copy link
Copy Markdown
Member

Summary

BlobPayloadStore.UploadAsync previously called CreateIfNotExistsAsync before every upload, adding an unnecessary storage transaction after the payload container was already known to exist.

This change caches a successful container-initialization generation for each BlobPayloadStore instance. Existing DI registrations create the payload store as a singleton, so the cached state is reused across uploads.

Implementation

  • Steady-state fast path: a volatile read returns the cached generation and skips CreateIfNotExistsAsync.
  • Concurrency-safe first use: when no generation is cached, callers enter a SemaphoreSlim asynchronously, double-check the cache, and only the lock holder calls CreateIfNotExistsAsync.
  • Caller-owned cancellation: the caller token is used both while waiting for the semaphore and by the Azure Storage create request. Cancelling one caller does not cancel another; a waiting caller can retry after a cancelled initializer releases the gate.
  • Retriable initialization failures: failed or cancelled initialization publishes no generation, so a later caller performs a fresh attempt without explicit fault eviction or a shared background task.
  • Generation-safe deletion recovery: each successful initialization publishes a unique marker. If a write returns ContainerNotFound, the upload clears the cache only when it still contains the marker that upload used, then retries once. A stale failure cannot invalidate a newer initialization.

If Azure is still processing a container deletion, the retrying create can return 409 ContainerBeingDeleted; that error propagates until deletion settles, matching the behavior before initialization was cached.

No public API changes are introduced. Main's self-describing v2 payload tokens and cross-account read behavior remain unchanged.

Testing

  • Focused unit coverage for concurrent first use, initialization failure retry, lock-holder cancellation, waiting-caller cancellation, settled deletion recovery, unrelated storage errors, and stale-generation invalidation.
  • AzureBlobPayloads.Tests: 7/7 passing, including with DOTNET_PROCESSOR_COUNT=2.
  • Existing v2 token coverage in Grpc.IntegrationTests: 6/6 passing.
  • AzureBlobPayloads.csproj builds for netstandard2.0, net6.0, net8.0, and net10.0.
  • Direct Azurite smoke verification: two uploads and downloads round-tripped successfully with exactly one container-create request in the request log.
  • The full LargePayloadConsoleApp DTS scenario was not run because DURABLE_TASK_SCHEDULER_CONNECTION_STRING was not available in this environment.

This change remains independent of other performance work.

Fixes #771

…fNotExistsAsync

BlobPayloadStore.UploadAsync previously called CreateIfNotExistsAsync on
every upload. This adds a single-flight cached initialization task so the
container is created at most once per store instance, while:

- remaining concurrency-safe for first use (concurrent callers share the
  same in-flight initialization task)
- keeping initialization failures retriable (a faulted/canceled attempt is
  not cached, so the next caller retries)
- honoring each caller's own CancellationToken without canceling the
  shared initialization for other callers
- recovering automatically if the container is deleted after
  initialization (detected via BlobErrorCode.ContainerNotFound on write,
  which resets the cache so the next upload recreates the container)

No public API changes. Added focused unit tests covering single-flight
caching, retry-after-failure, cancellation isolation, container-deletion
recovery, and unrelated-error propagation.

Fixes #771

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6fcfc0e9-3ac1-476b-adac-9ad37d7d1b12
Copilot AI review requested due to automatic review settings July 24, 2026 22:10
Comment thread test/Extensions/AzureBlobPayloads.Tests/BlobPayloadStoreTests.cs Fixed
Comment thread test/Extensions/AzureBlobPayloads.Tests/BlobPayloadStoreTests.cs Fixed

Copilot AI 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.

Pull request overview

This PR improves the Azure Blob payload externalization path by eliminating a per-upload CreateIfNotExistsAsync call, replacing it with a cached “single-flight” container initialization so repeated uploads avoid an extra storage transaction. It also adds a dedicated unit test project to validate initialization caching, cancellation isolation, failure retry, and cache reset behavior.

Changes:

  • Cache container initialization in BlobPayloadStore to avoid calling CreateIfNotExistsAsync on every upload.
  • Reset the cached initialization state when a write fails with ContainerNotFound to enable recovery after out-of-band container deletion.
  • Add a new AzureBlobPayloads.Tests project with focused unit tests for concurrency, failure, cancellation, and error propagation scenarios.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs Adds cached/single-flight container initialization and cache reset on ContainerNotFound.
test/Extensions/AzureBlobPayloads.Tests/BlobPayloadStoreTests.cs Introduces unit tests for initialization caching behavior and failure/cancellation scenarios.
test/Extensions/AzureBlobPayloads.Tests/Usings.cs Adds global test usings for FluentAssertions/Moq/Xunit.
test/Extensions/AzureBlobPayloads.Tests/AzureBlobPayloads.Tests.csproj Adds new test project referencing Azure Blob payloads extension.
Microsoft.DurableTask.sln Wires the new test project into the solution under an Extensions folder.

Comment thread src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs Outdated
Comment thread test/Extensions/AzureBlobPayloads.Tests/BlobPayloadStoreTests.cs Outdated
Address 3 medium-severity concurrency issues found in code review of PR #785:

1. True single-flight: PublishNewInitializer now atomically publishes an
   unstarted Lazy<Task> gate via CompareExchange before any real work starts,
   so racing first-time callers can never each independently trigger their
   own CreateIfNotExistsAsync call. Lazy<Task> with
   LazyThreadSafetyMode.ExecutionAndPublication guarantees the factory (the
   real SDK call) runs exactly once even under concurrent Value access.

2. Self-healing cache: CreateContainerIfNotExistsAsync now clears the cached
   initializer from its own completion path (a catch block keyed off the
   Lazy<Task> instance itself) whenever it fails, independent of whether any
   caller is still around to observe the failure. Previously, cleanup only
   happened in each waiting caller's own code path, so if every waiter
   cancelled before the shared initialization later faulted, the stale
   failure was cached forever.

3. Generation-aware recovery: the ContainerNotFound catch in UploadAsync now
   does a CompareExchange against the specific initializer instance the
   upload used, instead of an unconditional write. This ensures a stale
   deletion-recovery attempt can never clobber a newer initializer already
   published by a faster-recovering concurrent upload.

Also replaces the sequential LINQ 'concurrency' test (which never actually
raced, since Task.WhenAll doesn't force concurrent entry) with a
Barrier-gated Task.Run-based test that forces genuinely concurrent workers
and verifies exactly one create call. Adds two new deterministic regression
tests: all-waiters-cancel-then-init-fails-then-retry, and overlapping stale
ContainerNotFound failures vs. a newer initializer.

No public API changes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6fcfc0e9-3ac1-476b-adac-9ad37d7d1b12
Copilot AI review requested due to automatic review settings July 24, 2026 23:26

Copilot AI 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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs Outdated
Address a Medium issue from Terra's final re-review of PR #785: in the
NETSTANDARD2_0-only branch of WaitForInitializationAsync, when a caller's
own cancellation token fires first, it throws OperationCanceledException
without ever awaiting the shared initialization task. If every waiter
abandons the task this way and it later faults, nobody observes its
exception, which the runtime reports via TaskScheduler.UnobservedTaskException
on finalization.

Fix: attach a fire-and-forget OnlyOnFaulted continuation that touches
Task.Exception when a caller abandons the shared task due to its own
cancellation, so the fault is always observed regardless of whether any
caller stays around to await it. This doesn't block or delay the caller's
own cancellation, and is independent of the existing cache self-healing
logic in CreateContainerIfNotExistsAsync.

Since the test project only targets a single runnable framework (not
netstandard2.0), the ifdef'd branch itself can't be directly exercised by
xunit. Adds a framework-neutral test that proves the underlying
fault-observation pattern (OnlyOnFaulted continuation touching .Exception)
prevents TaskScheduler.UnobservedTaskException, using a GC/finalization
pass to verify no unobserved exception is reported.

Verified via dotnet build across all 4 target frameworks
(netstandard2.0, net6.0, net8.0, net10.0): 0 errors, no new warnings. All
8 unit tests pass, including the new test run 8x for stability.

No public API changes; cancellation and cache self-healing semantics are
unaffected.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6fcfc0e9-3ac1-476b-adac-9ad37d7d1b12
Copilot AI review requested due to automatic review settings July 24, 2026 23:55
Comment thread test/Extensions/AzureBlobPayloads.Tests/BlobPayloadStoreTests.cs Fixed
Comment thread test/Extensions/AzureBlobPayloads.Tests/BlobPayloadStoreTests.cs Fixed
Comment thread test/Extensions/AzureBlobPayloads.Tests/BlobPayloadStoreTests.cs Fixed

Copilot AI 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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs:236

  • In the non-NETSTANDARD2_0 branch, awaiting initializationTask.WaitAsync(cancellationToken) can throw OperationCanceledException without ever observing the underlying initializationTask. If all callers cancel before the shared initialization later faults, the fault may go unobserved and can surface via TaskScheduler.UnobservedTaskException on finalization. Consider mirroring the NETSTANDARD2_0 cancellation path by attaching a fault-observing continuation when cancellation happens first.
        await initializationTask.WaitAsync(cancellationToken).ConfigureAwait(false);
#endif

…nitializer

Terra's re-review found the previous fault-observation fix only covered
the NETSTANDARD2_0 cancellation branch of WaitForInitializationAsync; on
modern TFMs (WaitAsync-based cancellation), if every caller cancels
before the shared initializer later faults, the fault remained
unobserved (reproduced on net10.0).

Move fault observation out of WaitForInitializationAsync entirely and
attach it exactly once, in PublishNewInitializer, by whichever caller
wins the CompareExchange publish race. This decouples observation from
any particular caller's cancellation code path, fixing both the
NETSTANDARD2_0 and WaitAsync-based branches with a single mechanism,
while preserving the publish-before-work-starts single-flight
invariant (the CompareExchange still happens before .Value is
accessed).

Revert the now-redundant per-caller ContinueWith block in the
NETSTANDARD2_0 branch back to a plain ThrowIfCancellationRequested.

Add a production regression test exercising the WaitAsync cancellation
path (net10.0): all callers cancel, then the shared initializer
faults, and TaskScheduler.UnobservedTaskException is asserted to never
fire. Verified the test fails without the fix (temporarily disabled
the observer call) and passes with it, confirming it is not a false
positive.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6fcfc0e9-3ac1-476b-adac-9ad37d7d1b12
Copilot AI review requested due to automatic review settings July 25, 2026 00:08
Comment thread test/Extensions/AzureBlobPayloads.Tests/BlobPayloadStoreTests.cs Fixed
Comment thread test/Extensions/AzureBlobPayloads.Tests/BlobPayloadStoreTests.cs Fixed
Comment thread test/Extensions/AzureBlobPayloads.Tests/BlobPayloadStoreTests.cs Fixed

Copilot AI 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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Terra's final review flagged that the cancellation/fault regression test
relied on TaskScheduler.UnobservedTaskException plus a forced GC while
still holding references reachable from the async state machine, making
pass/fail dependent on GC/finalization timing, debugger attachment, and
JIT optimizations rather than purely on whether the fix is present.

Add an internal Action<Exception>? OnInitializationFaultObserved property
on BlobPayloadStore, invoked by ObserveFaultWithoutAwaiting (now an
instance method) immediately after it reads the faulted task's Exception.
Defaults to null in production (no-op, zero overhead); each test
constructs its own store instance so there's no shared state to reset.

Rewrite UploadAsync_AllWaitersCancelThenInitializerFaults_ExceptionIsObserved
to set this hook to a thread-safe (Interlocked/Volatile) counter + captured
exception instead of subscribing to UnobservedTaskException and forcing a
GC, and assert the continuation fired exactly once with the expected fault.
Verified the test correctly fails when the hook wiring is temporarily
removed, and passes across 8 repeat runs once restored.

No public API change (the new member is internal); no production behavior
change (the hook is a no-op unless a test sets it).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6fcfc0e9-3ac1-476b-adac-9ad37d7d1b12
Copilot AI review requested due to automatic review settings July 25, 2026 00:22

Copilot AI 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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs Outdated
…olling

Fixes a critical regression introduced by the previous commit: the fault
continuation used 	his.OnInitializationFaultObserved?.Invoke(t.Exception!),
which short-circuits and never evaluates 	.Exception when the hook is null
(the production default). This meant the shared initializer's fault was no
longer actually being observed in production whenever every caller cancelled
before it faulted - reintroducing the original UnobservedTaskException risk
this method exists to prevent. Fixed by unconditionally reading
Task.Exception into a local first, then optionally invoking the hook with
it.

Also addresses two review nits:
- Removed FaultObservingContinuation_PreventsUnobservedTaskException, a
  standalone test that never invoked BlobPayloadStore and described
  netstandard2.0-specific cancellation-path behavior that no longer exists
  (the continuation is now attached once in PublishNewInitializer regardless
  of TFM). The behavior it aimed to cover is already exercised end-to-end by
  UploadAsync_AllWaitersCancelThenInitializerFaults_ExceptionIsObserved.
- Replaced that test's up-to-1s polling loop for the fault-observing hook
  with a TaskCompletionSource<Exception> the hook completes, awaited via
  Task.WhenAny with a 5s timeout guard (fails with a clear assertion message
  instead of polling or hanging indefinitely).

No public API change; no other production behavior change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6fcfc0e9-3ac1-476b-adac-9ad37d7d1b12
Copilot AI review requested due to automatic review settings July 25, 2026 00:31

Copilot AI 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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

…class by construction

Redesign OnInitializationFaultObserved from a nullable auto-property to
a non-nullable property backed by a private field that defaults to,
and normalizes null assignments back to, a shared no-op delegate.

This lets ObserveFaultWithoutAwaiting invoke the hook directly with no
null-conditional operator, so the exact same statement executes
whether or not a test has overridden the hook - closing a coverage gap
where the previous nullable-hook design meant every existing test set
a non-null hook, and so could never distinguish the fix (unconditional
read of Task.Exception) from the historical short-circuiting bug
(hook?.Invoke(t.Exception!), which never evaluates Task.Exception
when the hook is null in production).

Add OnInitializationFaultObserved_DefaultsToNonNullNoOpAndRejectsNull,
which proves the untouched production default is never null and that
assigning null resets it to the no-op rather than making it null -
independent of any test that installs a custom hook. Verified this
test fails under the old nullable/short-circuiting design and passes
under the fix.

No public API change (member remains internal); negligible production
overhead (one extra no-op delegate invocation on the rare fault path).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6fcfc0e9-3ac1-476b-adac-9ad37d7d1b12
Copilot AI review requested due to automatic review settings July 25, 2026 00:42

Copilot AI 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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

…al test

UploadAsync_AllWaitersCancelBeforeInitializationFails_NextUploadRetriesWithFreshAttempt
used 'await Task.Delay(100)' to give the shared initializer's
self-healing completion path a chance to run before retrying, with no
guarantee the cache-clearing continuation had actually completed in
that window.

Replace it with the existing OnInitializationFaultObserved
TaskCompletionSource coordination pattern: the fault-observing
continuation only runs after CreateContainerIfNotExistsAsync has
already cleared the cache in its catch block (strictly before
re-throwing), so awaiting that hook - with a timeout diagnostic guard
- deterministically waits for self-healing to complete without any
arbitrary sleep to tune or risk racing under load.

No production behavior change. Verified 9/9 full suite and 8x repeat
of the fixed test for stability; all 4 TFMs build clean.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6fcfc0e9-3ac1-476b-adac-9ad37d7d1b12
Copilot AI review requested due to automatic review settings July 25, 2026 00:52

Copilot AI 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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Comment thread src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs Outdated
Comment thread test/Extensions/AzureBlobPayloads.Tests/BlobPayloadStoreTests.cs
Bernd Verst and others added 2 commits July 27, 2026 11:31
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 01dc5b7c-c742-45b7-ae8c-5cda9bcfbd2c
Copilot AI review requested due to automatic review settings July 27, 2026 18:37

Copilot AI 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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings July 31, 2026 15:15

Copilot AI 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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

@YunchuWang YunchuWang left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verified locally on this branch: builds clean, 9/9 tests pass, no new analyzer warnings on BlobPayloadStore.cs, and the modified .sln restores cleanly (project GUID, all six configuration mappings, and the nested-project entries are all correct).

The optimization is right and the win is real. On a worker running the default MaximumConcurrentActivityWorkItems = 100 * ProcessorCount, this roughly halves requests on the upload path — and more importantly, a fan-out orchestrator reply that externalizes 500 action inputs goes from 500 redundant container calls to zero, since ExternalizeRequestPayloadsAsync walks actions sequentially with no Task.WhenAll. That is the #771 case and it needs no concurrency at all to hurt.

Things I specifically checked and found correct:

  • Publish-before-startPublishNewInitializer completes the CompareExchange before anything touches .Value, with LazyThreadSafetyMode.ExecutionAndPublication as a backstop. This properly fixes the original "an async method starts on call, not on await" flaw.
  • Generation-precise invalidation — using the initializer instance as the CAS comparand in both the ContainerNotFound handler and the failure self-heal. I traced racing publishers, stale invalidation, and the fault self-heal path and could not construct an interleaving that produces a redundant create or clobbers a newer initializer.
  • Retry scope — the try covers OpenWriteAsync, the writes, FlushAsync and the using disposal, so ContainerNotFound is caught wherever the SDK surfaces it, and retryAfterContainerNotFound correctly bounds it to one attempt.
  • Singleton registrationAddSingleton<PayloadStore> in both DI extensions, so the cache is actually effective. Worth noting this is a new, unstated coupling: BlobPayloadStore is now stateful and only pays off as a singleton.
  • DownloadAsync / IsKnownPayloadToken — untouched, correctly so.

One item below (the uncancellable initializer) I would like addressed before merge. The design comment is a recommendation rather than a blocker — I have included the places where the current approach is genuinely better than the alternative I sketch.

Comment thread src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs Outdated
Comment thread src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs Outdated
Comment thread src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs Outdated
Comment thread src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs Outdated
Comment thread src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs Outdated
Comment thread src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs Outdated
Comment thread test/Extensions/AzureBlobPayloads.Tests/BlobPayloadStoreTests.cs Outdated
Comment thread test/Extensions/AzureBlobPayloads.Tests/BlobPayloadStoreTests.cs Outdated
Comment thread test/Extensions/AzureBlobPayloads.Tests/BlobPayloadStoreTests.cs Outdated
@YunchuWang

Copy link
Copy Markdown
Member

Follow-up: end-to-end verification against real storage

Separate from the inline comments — this is about test kind rather than test content, so it did not fit on a line.

As far as I can tell from the PR description and discussion, this has not been exercised against real (or emulated) storage. The Testing section lists focused unit coverage, and the strongest runtime claim is that the project builds across all target frameworks. CI (validate-build.yml) runs dotnet test on windows-latest with no Azurite service and no storage configuration, so there is no integration coverage there either. Apologies if I have missed a manual run that happened outside the PR — if so, please ignore this.

Worth noting this is also the first test project this extension has ever had, which is a genuine improvement. The observation below is only that all eight tests use Mock<BlobContainerClient> and assert call counts on it — which validates the coordination logic well (I traced it and could not break it), but by construction cannot validate anything where the mock's behavior diverges from real Azure.

One concrete instance of that divergence

UploadAsync_ContainerDeletedAfterInitialization_ResetsCacheAndRecreatesContainer passes because the mocked re-create returns success immediately. Real Azure does not behave that way.

From the Delete Container REST docs:

When a container is deleted, a container with the same name can't be created for at least 30 seconds... While the container is being deleted, attempts to create a container of the same name fail with status code 409 (Conflict).

And BlobContainerClient.CreateIfNotExistsInternal in Azure.Storage.Blobs 12.27.0 (the version pinned in Directory.Packages.props) swallows only one error code:

catch (RequestFailedException storageRequestFailedException)
when (storageRequestFailedException.ErrorCode == BlobErrorCode.ContainerAlreadyExists)

ContainerBeingDeleted is not swallowed, and 409 is not in Azure.Core's retriable set (408/429/5xx). So against real storage:

container deleted by operator
  -> OpenWriteAsync            -> 404 ContainerNotFound
  -> cache invalidated, retryAfterContainerNotFound = false, continue
  -> CreateIfNotExistsAsync    -> 409 ContainerBeingDeleted     <-- mock returns success here
  -> not swallowed by the SDK, not retried by the pipeline
  -> throws, and the one-shot retry budget is already spent -> upload fails

To be fair about what this is and is not:

  • Not a regression. Before this PR every upload called CreateIfNotExists, so it would hit the same 409. Behavior really is preserved, as the description says.
  • It does work once the deletion has fully settled (roughly 30s+), which is the path the unit test models.
  • The gap is that the commit is titled "Retry upload after container deletion" and the description says it "recreates the container, and retries that same upload once" — and in the timing most likely to occur in practice (operator deletes the container, traffic arrives immediately after) it will not. A mock cannot distinguish those two worlds.

I am not suggesting this needs to be fixed here — handling ContainerBeingDeleted properly means waiting out a 30s window, which is arguably out of scope. Mostly I would not want the test name and commit title to imply a guarantee that does not hold, so a comment noting the limitation would be enough.

Two smaller gaps

  • The central premise — container already exists, so skipping CreateIfNotExists still leaves OpenWriteAsync working — is the entire point of the change and is only ever exercised against a mock.
  • The request-count reduction that motivates the PR has not been measured; it currently exists as an argument rather than an observation.

Why this is a cheap ask

samples/LargePayloadConsoleApp already exists and is already in the solution, and is more or less exactly this harness — its README describes externalizing large payloads to blob storage over Durable Task Scheduler with no sidecar, defaulting to Azurite. It sets ThresholdBytes = 1024 so everything externalizes, and covers large orchestration input, activity I/O, sub-orchestrations, external events, custom status, entity input/output/state, oversized rejection, and ThreeLargeActivities — three parallel 13MB activities, which is the fan-out shape this PR is optimizing.

dotnet run against Azurite plus a DTS connection string is maybe ten minutes. Given this extension ships against real DTS and real storage, and this PR changes the write path for every externalized payload, that seems proportionate — and it would let you attach a measured before/after transaction count, which would make the case for the change stronger than any of the unit tests can.

Bernd Verst and others added 2 commits July 31, 2026 11:20
Replace the shared Lazy<Task> initializer with an async semaphore and unique generation token. The caller that owns initialization now passes its cancellation token to Azure Storage, failures naturally leave the cache empty, waiting callers remain independently cancellable, and stale ContainerNotFound failures cannot invalidate a newer successful generation.

Remove the shared-task fault observer, target-framework cancellation fork, and test-only hook. Replace the blocking Barrier and fixed delays with deterministic async coordination, document the settled-deletion limitation, and clean the test project metadata.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6fcfc0e9-3ac1-476b-adac-9ad37d7d1b12
Preserve the self-describing v2 payload token and cross-account read changes from #766 while retaining the review-driven, caller-cancellable container initialization cache.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6fcfc0e9-3ac1-476b-adac-9ad37d7d1b12
Copilot AI review requested due to automatic review settings July 31, 2026 18:26
@berndverst

berndverst commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

Thanks for calling this out. I made the deletion limitation explicit in both the focused test and PR description: the recovery test models re-creation after deletion has settled, while an immediate real-Azure retry can surface 409 ContainerBeingDeleted and still propagates exactly as it did before caching. I also renamed the test accordingly.

I could not run LargePayloadConsoleApp end to end because this environment has no DURABLE_TASK_SCHEDULER_CONNECTION_STRING. I did run the final merged BlobPayloadStore directly against Azurite: two uploads and two downloads round-tripped successfully, and the Azurite request log recorded exactly one PUT ...?restype=container request. That verifies both the steady-state skip against an emulator and the request-count reduction on the changed storage path. The PR testing section now records both that result and the DTS prerequisite limitation.

Copilot AI 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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings July 31, 2026 18:46

Copilot AI 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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs:94

  • The internal test-only constructor initializes clientOptions with default settings, but the public constructor configures important retry/network-timeout settings. Since clientOptions is used (e.g., for cross-account reads in DownloadAsync), tests using this constructor can exercise different behavior than production, and future tests may miss issues tied to retry configuration.
    {
        this.options = options ?? throw new ArgumentNullException(nameof(options));
        this.containerClient = containerClient ?? throw new ArgumentNullException(nameof(containerClient));
        this.clientOptions = new BlobClientOptions();
    }

@berndverst
berndverst merged commit 49bfa85 into main Jul 31, 2026
8 of 9 checks passed
@berndverst
berndverst deleted the berndverst-cache-blob-container-init branch July 31, 2026 18:56
YunchuWang added a commit that referenced this pull request Jul 31, 2026
Resolve the BlobPayloadStore.cs conflict by taking main's self-describing v2
token support wholesale and adding a v2-aware DeleteAsync (parallel to
DownloadAsync). Consolidate the AzureBlobPayloads unit tests onto main's
test/Extensions/AzureBlobPayloads.Tests project (added by #785), which collided
with this branch's flat test/AzureBlobPayloads.Tests project (same assembly
name, namespace and BlobPayloadStoreTests class).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 44c27836-c49c-45fd-ae2b-3309f9c3f0f0
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.

Performance: cache Azure Blob payload container initialization

3 participants