Preserve gRPC channel recreation when externalized payloads are enabled - #797
Open
wangbill (YunchuWang) wants to merge 1 commit into
Open
Preserve gRPC channel recreation when externalized payloads are enabled#797wangbill (YunchuWang) wants to merge 1 commit into
wangbill (YunchuWang) wants to merge 1 commit 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>
Contributor
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: an internal
CallInvokerDecoratorhookStop mutating
Channel/CallInvokerfrom the extension. Instead the extension registers a decorator, and core applies it 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 mirrors the existing
SetChannelRecreatoridiom exactly (naming, placement, accessibility, doc-comment style).GrpcDurableTaskClientOptions.InternalOptions/GrpcDurableTaskWorkerOptions.InternalOptions: newFunc<CallInvoker, CallInvoker>? CallInvokerDecorator.InternalOptionsExtensions: newSetCallInvokerDecorator(...)plus a publicApplyCallInvokerDecorator(options, invoker)helper (the extension is a separate assembly and cannot reachInternalOptionsdirectly).GrpcDurableTaskClient.GetCallInvoker→ renamed toGetCallInvokerCore, with a thinGetCallInvokerwrapper that applies the decorator once. The decorator is applied outsideChannelRecreatingCallInvoker, so the wrapper's internal channel swaps stay transparent to the interceptor.GrpcDurableTaskWorker.GetCallInvoker→ same rename + wrap.GrpcDurableTaskWorker.TryRecreateChannelAsyncapplies the decorator to the new invoker at both recreate sites. This is the crux — without it the interceptor 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 singleSetCallInvokerDecorator(inv => inv.Intercept(new AzureBlobPayloadsSideCarInterceptor(store, opts))).Channelis no longer nulled. The worker still addsP.WorkerCapability.LargePayloads.Design invariant: purely additive to core. With no decorator set, behavior is byte-for-byte identical to today. This is asserted by tests on both the worker and the client.
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. With the decorator, core creates its channel fromAddressand the decorator still applies. One change, two bugs.Tests
New coverage (all four suites green):
ExternalizedPayloadsCallInvokerDecoratorTests, 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 decorated invoker and assert the payload was actually uploaded to the store and replaced with a token. (There was no pre-existing interception test.)GrpcDurableTaskWorkerCallInvokerDecoratorTests, 4 tests) — decorator applied to the initial invoker; not applied when unset; and applied to the invoker produced after a channel recreate on both recreate paths (reached via reflection onTryRecreateChannelAsync).GrpcDurableTaskClientCallInvokerDecoratorTests, 5 tests) — decorator applied on theChannel, external-CallInvoker, andAddresspaths; applied outsideChannelRecreatingCallInvoker; and not applied when unset.CallInvokerDecoratordefaults to null,SetCallInvokerDecorator(null)throws,ApplyCallInvokerDecoratoris identity without a decorator.Regression evidence
The extension regression tests were written first and observed failing on the base commit (
ab4be125) — 2 failing withExpected options.Channel to refer to ... but found <null>, 2 with theArgumentExceptionthrown fromDurableTaskWorkerBuilderExtensions.AzureBlobPayloads.cs:78. All pass after the fix.Verification
Notes
main, does not touch that branch.SetCallInvokerDecorator/ApplyCallInvokerDecoratorlive in the.Internalnamespace alongsideSetChannelRecreatorand carry the standard "not subject to the same compatibility standards" remarks.