Skip to content

refactor(workflow): keep one gRPC channel for the worker's lifetime - #1317

Open
famarting wants to merge 6 commits into
dapr:mainfrom
famarting:refactor/workflow-worker-single-channel
Open

famarting wants to merge 6 commits into
dapr:mainfrom
famarting:refactor/workflow-worker-single-channel

Conversation

@famarting

@famarting famarting commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Description

This follows @JoshVanL's review suggestion on #1312: keep one gRPC channel for the worker's lifetime instead of replacing it every time the work-item stream ends.

Why the worker replaced its channel. Recreating the channel on every reconnect came from microsoft/durabletask-python#50 (2025), which was vendored here in #963. Neither PR says why. Upstream has since moved toward this design: microsoft/durabletask-python#135 keeps the channel, re-opens only the stream, and recreates the channel only after 5 transport failures in a row. A grpc.Channel reconnects by itself, re-resolving DNS when its connection drops, so only GetWorkItems has to be re-sent.

What changes

  • start() opens one channel and stop() closes it. On reconnect the run loop only sends Hello and a new GetWorkItems on that channel.
  • Removed: the retire/publish handshake, the per-channel call counts, the deferred-close threads, _RESULT_RECONNECT_WAIT and the stream-reconnect wait on result sends.
  • Result sends and the history fetch now pass wait_for_ready=True with their existing 30s deadline. A result that is ready during a reconnect waits for the channel, instead of the worker tracking connections.
  • The connect Hello also uses wait_for_ready=True. Without it, a Hello sent while gRPC is between reconnect attempts fails at once. The run loop's retry delay then adds to gRPC's own backoff, and in testing the worker took 8.7s to reconnect after the sidecar came back, against 0.7s on fix(workflow): keep the workflow worker working across sidecar reconnects #1312.
  • gRPC's reconnect backoff is capped at 15s (grpc.max_reconnect_backoff_ms), matching the run loop's retry cap. The default is 120s, which could leave a worker idle for 2 minutes after a long outage. A caller's channel_options still override it.

What stays

Found along the way, not fixed here: for the same reason, _make_stream_teardown (used when a history fetch fails) does nothing when the channel has interceptors, which is always the case under WorkflowRuntime. Its cancel() raises AttributeError, which is logged at debug level. This predates both PRs, and I can open an issue for it.

Checked against a real gRPC server (in-process, with the SDK's metadata interceptor): start the worker, kill the sidecar while an activity runs, let the activity finish while the sidecar is down, then restart the sidecar on the same port after 6s.

#1312 this PR
Channels created one per connect attempt 1
Result delivered after the restart yes, 0.7s after it yes, 2.7s after it (gRPC's backoff)
Stream re-opened 0.7s after the restart 2.7s after the restart
stop() 0.0s 0.0s

On #1312 the 0.7s depends on when the run loop's retry happens to land. On both branches the worst case is about 15s.

Issue reference

No issue filed. This is a follow-up to review feedback on #1312.

Checklist

  • Code compiles correctly
  • Created/updated tests
  • Extended the documentation (dapr/ext/workflow/AGENTS.md)

🤖 Generated with Claude Code

@codecov

codecov Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.47059% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.62%. Comparing base (ba2b297) to head (4bcb9e7).

Files with missing lines Patch % Lines
dapr/ext/workflow/_durabletask/worker.py 96.47% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1317      +/-   ##
==========================================
+ Coverage   87.35%   87.62%   +0.26%     
==========================================
  Files         163      163              
  Lines       13069    13046      -23     
==========================================
+ Hits        11416    11431      +15     
+ Misses       1653     1615      -38     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The worker replaced its gRPC channel every time the work-item stream ended.
That forced the retire/publish handshake, per-channel call counts and the
deferred-close threads, because results and other calls could still be
using the old channel when it was closed. A grpc.Channel reconnects on its
own, so only the GetWorkItems call needs to be re-issued.

start() now opens one channel and stop() closes it. The run loop only sends
Hello and opens a new stream on it. Result sends and the history fetch use
wait_for_ready=True within their 30s deadline, so a result that is ready
during a reconnect waits for the channel instead of a separate reconnect
wait. The connect Hello waits for the channel too, so a returning sidecar is
seen as soon as gRPC reconnects rather than after the run loop's retry delay
on top of gRPC's own backoff, which is capped at 15s (default 120s).

stop() still drains calls in flight for up to 5s and closes the channel only
once no call is in flight, since grpcio crashes on a call started on a
closed channel with interceptors. Closing the channel is also what ends the
work-item stream: through an interceptor it is a generator with no cancel().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fabian Martinez <46371672+famarting@users.noreply.github.com>
@famarting
famarting force-pushed the refactor/workflow-worker-single-channel branch from 3669201 to d58bd60 Compare October 20, 2026 15:20
@famarting
famarting marked this pull request as ready for review October 20, 2026 15:25
@famarting
famarting requested review from a team as code owners October 20, 2026 15:25
@CasperGN CasperGN self-assigned this Oct 20, 2026

@CasperGN CasperGN 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.

Thanks @famarting for this contribution and for following up on the #1312 review so quickly! 🙌 Keeping one channel for the worker's lifetime makes the code much simpler. The PR description explains clearly why the old design existed, and checking against a real gRPC server with the interceptor in place was a nice touch.

I left one comment inline about how long stop() can take while the sidecar is down, please take a look. 👀

A few more notes:

  • CI is red on this head: build (windows-latest, 3.13), validate (3.14), validate-complete and both codecov checks fail. Could you check whether any of them come from this change?

Drafted with AI assistance and reviewed by me.

Comment thread dapr/ext/workflow/_durabletask/worker.py Outdated
famarting and others added 4 commits October 21, 2026 11:46
The connect Hello holds the channel while it waits for the channel to be
ready, and stop() closes the channel only once no call is in flight. During
a sidecar outage that Hello sat on its 30s deadline, so stop() did too.

Wait for the channel in 1s Hellos that check for shutdown between them,
still within the 30s budget, and end with one Hello that fails fast so the
log says why the channel is not ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fabian Martinez <46371672+famarting@users.noreply.github.com>
Each start() created an event loop and never closed it, leaving the GC to
close it later. On Windows the proactor's close calls time.monotonic(), so
a loop collected during a test that mocks time.monotonic used up the mock's
values: test_mcp_client's budget tests failed with StopIteration on the
Windows jobs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fabian Martinez <46371672+famarting@users.noreply.github.com>
…paths

Only a timed-out connect Hello is retried: a refusal from the sidecar must
fail at once rather than be retried for the whole connect timeout. Also
covers the Hello on a closed channel and the warning stop() logs when the
run loop outlives its timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fabian Martinez <46371672+famarting@users.noreply.github.com>
The reconnect tests only ever ended the work-item stream cleanly, so the
paths that disconnect after a broken stream or a failed stream open never
ran. Cover both: the worker drops its history cache and reopens the stream
on the one channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fabian Martinez <46371672+famarting@users.noreply.github.com>
@CasperGN
CasperGN added this pull request to the merge queue Oct 22, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 22, 2026
@CasperGN
CasperGN added this pull request to the merge queue Oct 22, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 22, 2026
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