Uh oh!
There was an error while loading. Please reload this page.
Preserve gRPC channel recreation when externalized payloads are enabled - #797
Preserve gRPC channel recreation when externalized payloads are enabled#797wangbill (YunchuWang) wants to merge 4 commits into
Conversation
Enabling UseExternalizedPayloads (AzureBlobPayloads) silently disabled gRPC
channel recreation on both the worker and the client.
IConfigureOptions runs before IPostConfigureOptions. DTS's ConfigureGrpcChannel
sets both options.Channel and SetChannelRecreator(...). The AzureBlobPayloads
PostConfigure then moved the channel onto an intercepted CallInvoker and nulled
options.Channel (it had to, because "Channel supersedes CallInvoker" per
GrpcDurableTaskClientOptions). That killed every recreation path:
- Worker path 1 guard requires a non-null channel; latestObservedChannel is
seeded from grpcOptions.Channel == null.
- Worker path 2 requires Channel and CallInvoker both null; CallInvoker was set.
- Client's CallInvoker branch explicitly cannot recreate an external channel.
Recreation is on by default (ChannelRecreateFailureThreshold = 5), so on backend
scale/upgrade/node replacement the worker wedged on a half-open HTTP/2
connection and never recovered until the process was restarted.
Fix: add an internal CallInvokerDecorator hook, mirroring the existing
SetChannelRecreator idiom. The extension registers a decorator instead of
mutating Channel/CallInvoker, and core applies it at every point a CallInvoker
is produced - including after a channel recreate. The decorator is applied
outside ChannelRecreatingCallInvoker so internal channel swaps stay transparent
to the interceptor. Purely additive: with no decorator set, behavior is
unchanged.
Also fixes a second defect: UseGrpc("http://localhost:4001") (Address-only)
previously threw ArgumentException and was unusable with externalized payloads.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>There was a problem hiding this comment.
Pull request overview
This PR fixes a shipped regression where enabling the AzureBlobPayloads externalized-payloads extension inadvertently disabled gRPC channel recreation for both the worker and client, preventing self-healing from wedged/half-open HTTP/2 connections. It does so by introducing an internal CallInvokerDecorator hook that lets extensions attach interceptors without mutating Channel/CallInvoker, and ensures the decorator is applied consistently (including after worker channel recreation).
Changes:
- Added internal
SetCallInvokerDecorator/ApplyCallInvokerDecoratorhooks for both worker and client gRPC options. - Updated worker and client invoker construction paths to apply the decorator in the correct place (client: outside
ChannelRecreatingCallInvoker; worker: on initial invoker and on recreated invokers). - Updated AzureBlobPayloads DI extensions to register a decorator (instead of nulling
Channel) and added comprehensive regression and core tests.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| test/Worker/Grpc.Tests/GrpcDurableTaskWorkerOptionsInternalTests.cs | Verifies safe defaults and contract behavior for the new worker CallInvokerDecorator option. |
| test/Worker/Grpc.Tests/GrpcDurableTaskWorkerCallInvokerDecoratorTests.cs | Ensures worker applies the decorator for initial invokers and both channel recreation paths. |
| test/Client/Grpc.Tests/GrpcDurableTaskClientCallInvokerDecoratorTests.cs | Ensures client applies the decorator across Channel/Address/external-invoker paths and outside ChannelRecreatingCallInvoker. |
| test/Extensions/AzureBlobPayloads.Tests/ExternalizedPayloadsCallInvokerDecoratorTests.cs | Regression tests proving externalized payloads no longer null Channel, no longer break Address-only setup, and still intercepts calls end-to-end. |
| test/Extensions/AzureBlobPayloads.Tests/AzureBlobPayloads.Tests.csproj | Adds DI package reference needed by new test coverage. |
| src/Worker/Grpc/GrpcDurableTaskWorkerOptions.cs | Introduces internal CallInvokerDecorator option storage for the worker. |
| src/Worker/Grpc/Internal/InternalOptionsExtensions.cs | Adds worker SetCallInvokerDecorator and ApplyCallInvokerDecorator internal APIs. |
| src/Worker/Grpc/GrpcDurableTaskWorker.cs | Applies the decorator when building invokers and when recreating channels. |
| src/Client/Grpc/GrpcDurableTaskClientOptions.cs | Introduces internal CallInvokerDecorator option storage for the client. |
| src/Client/Grpc/Internal/InternalOptionsExtensions.cs | Adds client SetCallInvokerDecorator and ApplyCallInvokerDecorator internal APIs. |
| src/Client/Grpc/GrpcDurableTaskClient.cs | Applies the decorator outside core invoker creation to preserve recreation semantics. |
| src/Extensions/AzureBlobPayloads/DependencyInjection/DurableTaskWorkerBuilderExtensions.AzureBlobPayloads.cs | Switches from mutating Channel/CallInvoker to registering a decorator and preserves LargePayloads capability. |
| src/Extensions/AzureBlobPayloads/DependencyInjection/DurableTaskClientBuilderExtensions.AzureBlobPayloads.cs | Switches client externalized payloads to decorator-based interception without breaking channel recreation. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
halspang
left a comment
There was a problem hiding this comment.
Overall it seems fine, but I'd like to find a different way to handle the internal nature of the options if we can.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…ollection
Addresses review feedback that the previous revision exposed `public`
extension methods (`SetCallInvokerDecorator` / `ApplyCallInvokerDecorator`)
in a `.Internal` namespace whose only protection was an XML remark saying
"do not use".
`ApplyCallInvokerDecorator` was only ever called from the assembly that
declared it, so it never needed to be public at all. The one hook that does
need to cross an assembly boundary is now a first-class, supported public
extensibility point modeled on `Grpc.Net.ClientFactory`'s
`IHttpClientBuilder.AddInterceptor()`:
public IList<Grpc.Core.Interceptors.Interceptor> Interceptors { get; }
added to both `GrpcDurableTaskClientOptions` and
`GrpcDurableTaskWorkerOptions`. Net public API surface is smaller than the
previous revision (one property per options class instead of four extension
methods), and interceptors compose additively so multiple extensions can
coexist — something a single `Func<CallInvoker, CallInvoker>` could not do.
The functional fix is unchanged. Interceptors are applied at exactly the
same sites the decorator was:
- `GrpcDurableTaskClient.GetCallInvoker` — outside any
`ChannelRecreatingCallInvoker`, so the wrapper's internal channel swaps
stay transparent to interceptors.
- `GrpcDurableTaskWorker.GetCallInvoker`.
- Both `ChannelRecreateResult` construction sites in
`GrpcDurableTaskWorker.TryRecreateChannelAsync` (recreator-owned and
worker-owned paths), so interceptors survive every channel recreate.
The AzureBlobPayloads extension now calls `opt.Interceptors.Add(...)`
instead of mutating `Channel`/`CallInvoker`, so enabling
`UseExternalizedPayloads` no longer silently disables gRPC channel
recreation, and the `Address`-only form `UseGrpc("http://localhost:4001")`
no longer throws.
Interceptor ordering is list order (first added is outermost); this is
documented on both properties and pinned by a test.
Tests renamed from `*CallInvokerDecoratorTests` to `*InterceptorsTests` and
reworked to assert through the real invoker-building path rather than
calling the removed extension method directly. Added coverage for
interceptor ordering and for the purely-additive invariant (with an empty
`Interceptors` list the produced invoker is reference-identical to the
undecorated one) on both worker and client.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (4)
test/Extensions/AzureBlobPayloads.Tests/ExternalizedPayloadsInterceptorTests.cs:120
- This test creates a GrpcChannel solely to obtain a CallInvoker, but the channel is never disposed. Even though this is a unit test, leaking channels can keep timers/sockets alive and cause test flakiness when the suite grows.
// Arrange
CallInvoker external = GrpcChannel.ForAddress("http://localhost:4001").CreateCallInvoker();
ServiceCollection services = new();
services.AddSingleton<PayloadStore>(new FakePayloadStore());
DefaultDurableTaskWorkerBuilder builder = new(null, services);
builder.UseGrpc(opt => opt.CallInvoker = external);
test/Extensions/AzureBlobPayloads.Tests/ExternalizedPayloadsInterceptorTests.cs:68
- This test creates a GrpcChannel and leaves it undisposed. Disposing the channel at the end of the test helps avoid leaking timers/sockets across the test run.
// Arrange
GrpcChannel channel = GrpcChannel.ForAddress("http://localhost:4001");
ServiceCollection services = new();
services.AddSingleton<PayloadStore>(new FakePayloadStore());
DefaultDurableTaskClientBuilder builder = new(null, services);
builder.UseGrpc(channel);
src/Client/Grpc/GrpcDurableTaskClientOptions.cs:46
- The PR description states the fix is implemented as an internal CallInvokerDecorator hook and that there is "No public API change". However, this change adds a new public Interceptors property on GrpcDurableTaskClientOptions, which is a public API surface addition (even if backward-compatible). Please either update the PR description to reflect the actual public API change, or adjust the implementation to match the described internal-only design.
/// <summary>
/// Gets the gRPC interceptors applied to every <see cref="CallInvoker"/> the client builds from its
/// configured transport, including invokers rebuilt after the underlying channel is recreated.
/// </summary>
/// <remarks>
/// <para>
/// This is the supported way to attach cross-cutting gRPC behavior — authentication headers, tracing,
/// logging, payload externalization — to the Durable Task gRPC client. Prefer it over supplying a
/// pre-built, already-intercepted <see cref="CallInvoker"/> in place of <see cref="Channel"/>: an
/// externally-supplied invoker opts the client out of gRPC channel recreation, so a wedged connection
/// can never be replaced.
/// </para>
/// <para>
/// Interceptors run in list order — the first interceptor added is the outermost, so it observes each
/// outgoing call first and each response last. Registration is purely additive: while this collection
/// is empty, the client uses exactly the invoker its configured transport produces.
/// </para>
/// </remarks>
public IList<Interceptor> Interceptors { get; } = new List<Interceptor>();
test/Extensions/AzureBlobPayloads.Tests/ExternalizedPayloadsInterceptorTests.cs:48
- This test creates a GrpcChannel and leaves it undisposed. Disposing the channel at the end of the test helps avoid leaking timers/sockets across the test run.
This issue also appears in the following locations of the same file:
- line 62
- line 115
// Arrange
GrpcChannel channel = GrpcChannel.ForAddress("http://localhost:4001");
ServiceCollection services = new();
services.AddSingleton<PayloadStore>(new FakePayloadStore());
DefaultDurableTaskWorkerBuilder builder = new(null, services);
builder.UseGrpc(channel);
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (3)
test/Extensions/AzureBlobPayloads.Tests/ExternalizedPayloadsInterceptorTests.cs:63
- GrpcChannel implements IDisposable; this test creates a channel but never disposes it, which can leak sockets/resources across the test run.
GrpcChannel channel = GrpcChannel.ForAddress("http://localhost:4001");
test/Extensions/AzureBlobPayloads.Tests/ExternalizedPayloadsInterceptorTests.cs:116
- This creates a GrpcChannel just to obtain a CallInvoker, but the channel is never disposed. Hold on to the channel and dispose it at the end of the test to avoid leaking resources.
CallInvoker external = GrpcChannel.ForAddress("http://localhost:4001").CreateCallInvoker();
test/Extensions/AzureBlobPayloads.Tests/ExternalizedPayloadsInterceptorTests.cs:44
- GrpcChannel implements IDisposable; this test creates a channel but never disposes it, which can leak sockets/resources across the test run.
This issue also appears in the following locations of the same file:
- line 63
- line 116
GrpcChannel channel = GrpcChannel.ForAddress("http://localhost:4001");
| /// is empty, the worker uses exactly the invoker its configured transport produces. | ||
| /// </para> | ||
| /// </remarks> | ||
| public IList<Interceptor> Interceptors { get; } = new List<Interceptor>(); |
There was a problem hiding this comment.
Since this remains mutable, could we technically change the value after initialization and then have that value take effect only after a recreate?
There was a problem hiding this comment.
Yes — you could, and that was a real bug. Fixed in a295ed5.
The worker read grpcOptions.Interceptors live in three places: once in GetCallInvoker at startup, and again on each of the two recreate paths in TryRecreateChannelAsync. Intercept() builds a frozen InterceptingCallInvoker graph that captures the interceptor references rather than holding onto the list, so an Add() after construction did nothing at the time it happened, then silently activated at the next channel recreate — which is triggered by backend replacement / node restart / consecutive failures, so the delay was unbounded and externally driven.
It was reachable, not just theoretical: the worker resolves options via IOptionsMonitor.Get(name) and those instances are cached per name, so anything else holding the same monitor gets the same instance and can mutate the exact list the recreate path re-reads.
The fix is to snapshot once into a readonly Interceptor[] in the constructor and use it at all three sites. Interceptors are now taken at startup and never change for the lifetime of the worker. Side benefits:
- The worker now matches the client, which already had read-once semantics (it builds its invoker once in the constructor, and
ChannelRecreatingCallInvokerswaps channels inside the interceptor wrapper, so the collection was never re-read there). - It matches the invariant already documented ~60 lines below about not re-reading
this.grpcOptions.Channelinside the loop. - It removes a
List<T>thread-safety hazard, since recreate runs on the worker's background loop and could previously race a concurrentAdd().
The read-once contract is now documented in the <remarks> on Interceptors for both the worker and client options: the collection must be populated while options are being configured, and mutating it afterwards has no effect.
Added three tests, including one that pins your exact scenario — mutate the live collection after the worker is built, force a recreate, assert the rebuilt invoker still carries only the chain captured at construction. It inserts at index 0 rather than appending, so the late interceptor would land outermost and actually be observable; appending would leave it innermost where the outer short-circuit would mask it and make the assertion vacuous. It fails without the snapshot.
The worker read `grpcOptions.Interceptors` live at three points: once in `GetCallInvoker` at startup, and again on each of the two channel-recreate paths in `TryRecreateChannelAsync`. `Intercept()` builds a frozen `InterceptingCallInvoker` graph that captures interceptor references rather than holding the list, so an `Interceptors.Add(...)` performed after the worker was constructed had no effect at the time it happened, then silently took effect at the next channel recreate. That is reachable, not theoretical: the worker resolves its options via `IOptionsMonitor.Get(name)`, and options instances are cached per name, so any other holder of the same monitor gets the same instance and can mutate the very list the worker re-reads. Because recreate is triggered by external events (backend replacement, node restart, consecutive failures), the delay between the mutation and its activation is nondeterministic. Capture the collection once into a `readonly Interceptor[]` in the constructor and use that snapshot at all three sites. This gives the worker the same read-once semantics the client already had (the client builds its invoker once in its constructor, and `ChannelRecreatingCallInvoker` swaps channels inside the interceptor wrapper), and it matches the existing "do not re-read `this.grpcOptions.Channel` inside the loop" invariant just below. It also removes a `List<T>` thread-safety hazard, since recreate runs on the worker's background loop and could previously race a concurrent `Add()`. Document the read-once contract in the `<remarks>` on `Interceptors` for both the worker and client options, and add three tests that pin the scenario: interceptors added after construction never take effect, on the recreator path, the worker-owned rebuild path, and the startup invoker. Also dispose the `GrpcChannel` instances that the externalized-payloads tests were leaking. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5e3106c1-d9ec-4666-a1ec-c8e5867f87f8
Summary
Enabling
UseExternalizedPayloads(the AzureBlobPayloads large-payload extension) silently disabled gRPC channel recreation on both the worker and the client. This is a shipped bug onmain, introduced by #468 (ae1433aa).User-visible impact
Channel recreation is on by default (
ChannelRecreateFailureThreshold = 5in bothGrpcDurableTaskWorkerOptionsandGrpcDurableTaskClientOptions). Its whole purpose, perSetChannelRecreator's own doc comment, is to recover from "repeated connect failures (e.g., because the backend was replaced and the existing channel is wedged on a half-open HTTP/2 connection)".With externalized payloads enabled, that recovery was gone. On backend scale-out, upgrade, or node replacement, the worker wedges on a half-open HTTP/2 connection, stops processing work, and never recovers until the process is manually restarted.
Root cause
IConfigureOptionsruns beforeIPostConfigureOptions.ConfigureGrpcChannel(anIConfigureOptions) sets bothoptions.Channel(DurableTaskSchedulerWorkerExtensions.cs:176) andoptions.SetChannelRecreator(...)(:185).PostConfigurethen ran, moved the channel onto an interceptedCallInvoker, and nulledoptions.Channel.It had to null
Channel, because the core contract documents that "Channel ... will supersede CallInvoker" (GrpcDurableTaskClientOptions.cs:17) — leavingChannelset would make core build a raw invoker and bypass the interceptor entirely.The consequence was that every recreation path died:
recreator is not null && currentChannel is not nulllatestObservedChannelis seeded fromgrpcOptions.Channel, which was nownullChannel is null && CallInvoker is nullCallInvokerwas now setCallInvokerbranchProbe output against the shipped code (DTS-shaped worker,
IConfigureOptionssetting Channel + recreator, then optionalUseExternalizedPayloads):Why the simpler alternatives don't work
Channel. Core then takes theChannelbranch and builds a raw invoker, so the interceptor is dropped at startup and large payloads break outright.CallInvoker. Directly contradicts the documented public contract atGrpcDurableTaskClientOptions.cs:17and changes behavior for every consumer, not just this extension.newChannel.CreateCallInvoker()with no extension hook, so the interceptor would be lost on every recreate anyway.The fix: a public
InterceptorscollectionStop mutating
Channel/CallInvokerfrom the extension. Instead the extension registers an interceptor, and core applies the configured interceptors at every point aCallInvokeris produced — including after a channel recreate.Channelstays set (so recreation works) and the interceptor is always applied (so large payloads work).This is the same shape as
Grpc.Net.ClientFactory'sIHttpClientBuilder.AddInterceptor(), so it should be immediately familiar to anyone who has configured a gRPC client in ASP.NET Core.GrpcDurableTaskClientOptions/GrpcDurableTaskWorkerOptions: new publicInterceptorsproperty, documented as the supported way to attach cross-cutting gRPC behavior (auth headers, tracing, logging, payload externalization) — explicitly preferred over supplying a pre-built, already-interceptedCallInvoker, which opts you out of channel recreation.GrpcDurableTaskClient.GetCallInvoker→ thin wrapper overGetCallInvokerCorethat applies the interceptors once. They are applied outsideChannelRecreatingCallInvoker, so the wrapper's internal channel swaps stay transparent to them.GrpcDurableTaskWorker.GetCallInvoker→ same wrapper/core split.GrpcDurableTaskWorker.TryRecreateChannelAsyncapplies the interceptors to the new invoker at both recreate sites (recreator-owned and worker-owned). This is the crux — without it interceptors would be silently lost on every recreate, which would be worse than the original bug.if (Channel) / else if (CallInvoker) / else throwblock is replaced with a singleopt.Interceptors.Add(new AzureBlobPayloadsSideCarInterceptor(store, opts)).Channelis no longer nulled andCallInvokeris no longer mutated. The worker still addsP.WorkerCapability.LargePayloads.Ordering is list order: the first interceptor added is the outermost, so it sees each outgoing call first and each response last. This is documented on the property and pinned by a test.
Design invariant: purely additive to core. While
Interceptorsis empty, the produced invoker is reference-identical to the one the configured transport produces, so behavior is byte-for-byte identical to today. Asserted on both the worker and the client.Read-once contract
Interceptorsis captured once, when the worker or client is constructed, and the resulting chain is fixed for that instance's lifetime — including across channel recreation. It must therefore be populated while options are being configured (Configure/PostConfigure); mutating it later has no effect. This is documented on the property on both options classes.The client already behaved this way: it builds its invoker once in its constructor, and
ChannelRecreatingCallInvokerswaps channels inside the interceptor wrapper, so the collection is never re-read.The worker was the outlier. It read
grpcOptions.Interceptorsat three separate points — once inGetCallInvokerat startup, and again on each of the two recreate paths inTryRecreateChannelAsync. BecauseIntercept()builds a frozenInterceptingCallInvokergraph that captures interceptor references rather than retaining the list, anAdd()performed after construction did nothing at the time, then silently took effect at the next recreate. That is reachable rather than theoretical: the worker resolves its options throughIOptionsMonitor.Get(name), and options instances are cached per name, so any other holder of the same monitor gets the same instance and can mutate the list the worker re-reads. Since recreate is triggered externally (backend replacement, node restart, consecutive failures), the delay between the mutation and its activation was nondeterministic.The worker now snapshots into a
readonly Interceptor[]in its constructor and uses that at all three sites, matching the client and matching the existing "do not re-readthis.grpcOptions.Channelinside the loop" invariant a few lines below. As a side effect this removes aList<T>thread-safety hazard, since recreate runs on the worker's background loop and could previously race a concurrentAdd().Public API surface
The net public API surface is smaller than the previous revision — one property per options class, instead of four extension methods:
No
.Internalnamespace, no "not subject to the same compatibility standards" remarks, and noInternalsVisibleTo.SetChannelRecreatoris untouched. No proto change.Bonus bug fixed
Deleting the
else { throw }fixes a second, independent defect: todayUseGrpc("http://localhost:4001")— the Address-only form, the most common raw-gRPC worker setup — throwsArgumentExceptionand is completely unusable with externalized payloads. OnlyUseGrpc(o => o.CallInvoker = ...)worked. Now core creates its channel fromAddressand the registered interceptor still applies. One change, two bugs.Tests
New coverage (all four suites green):
ExternalizedPayloadsInterceptorTests, 8 tests) — worker and clientChannelis no longer nulled; Address-only no longer throws; the external-CallInvokerpath is no longer mutated;LargePayloadscapability is still announced; and two end-to-end interception tests that drive a realCreateInstanceRequestthrough the product's own private invoker-building path and assert the payload was actually uploaded to the store and replaced with a token. (There was no pre-existing interception test.)GrpcDurableTaskWorkerInterceptorsTests, 13 tests) — interceptors applied on theChannel,Address, and external-CallInvokerpaths; applied to the invoker produced after a channel recreate on both recreate paths (reached via reflection onTryRecreateChannelAsync); reference-identity when the list is empty (initial build and after recreate); ordering; and three tests pinning the read-once contract — an interceptor added after construction never runs, on the recreator path, the worker-owned rebuild path, and the startup invoker.GrpcDurableTaskClientInterceptorsTests, 8 tests) — interceptors applied on theChannel,Address, and external-CallInvokerpaths; applied outsideChannelRecreatingCallInvoker; reference-identity when the list is empty (with and without a recreator); and ordering.Interceptorsdefaults to an empty, non-null list on both options classes.Tests assert through the real invoker-building path rather than calling a helper directly, so they cannot pass by re-implementing how interceptors are applied.
Regression evidence
The four extension regression assertions were re-run against the base commit (
ab4be125) with theInterceptorsassertions stripped so they compile there. All four fail before the fix:All pass after the fix.
The three read-once tests were likewise confirmed to fail before the snapshot change and pass after:
Verification
Grpc.IntegrationTestsis part of the solution and builds clean.Notes
main, does not touch that branch.Interceptorson the two gRPC options classes). That is intentional and is the point of this revision. No proto change, no behavior change when the collection is empty.