Skip to content

Commit 43ec907

Browse files
torosentCopilot
andcommitted
Reconnect workers after peer stream cancellation
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 282b8096-753c-41be-928d-2b53604825d3
1 parent 3053ef4 commit 43ec907

4 files changed

Lines changed: 53 additions & 47 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ FIXED
2020
- Fixed orchestrations failing with `OrchestrationStateError` when a `genericEvent` history event was replayed (for example, the marker the Durable Functions extension appends when rewinding an orchestration). Such informational events are now ignored during replay, matching the .NET worker.
2121
- Fixed a lock-granted entity response over the legacy entity protocol raising while trying to deserialize an empty operation result. Lock-granted events no longer attempt result deserialization.
2222
- Fixed `task.when_all()` failing fast when one of its child tasks failed. It now waits for every child task to complete before surfacing the first failure, matching the semantics of .NET's `Task.WhenAll`.
23+
- Fixed workers stopping permanently when the server reset the `GetWorkItems`
24+
stream. Workers now reconnect after a peer cancellation and still exit normally
25+
when stopped.
2326

2427
## v1.7.2
2528

durabletask-azuremanaged/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88
## Unreleased
99

1010
- Added `rewind_orchestration()` to `DurableTaskSchedulerClient` and `AsyncDurableTaskSchedulerClient` (inherited from the base clients) to rewind a failed orchestration instance to its last known good state.
11+
- Fixed Durable Task Scheduler workers stopping permanently when the service reset
12+
the `GetWorkItems` stream.
1113

1214
## v1.7.2
1315

durabletask/worker.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1063,7 +1063,7 @@ def stream_reader() -> None:
10631063
invalidate_connection(recreate_channel=recreate_channel)
10641064
error_details = str(rpc_error)
10651065

1066-
if error_code == grpc.StatusCode.CANCELLED:
1066+
if error_code == grpc.StatusCode.CANCELLED and self._shutdown.is_set():
10671067
self._logger.info(f"Disconnected from {self._host_address}")
10681068
break
10691069
elif error_code == grpc.StatusCode.UNAVAILABLE:

tests/durabletask/test_worker_resiliency.py

Lines changed: 47 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ def __str__(self):
3434
return self._details
3535

3636

37+
def _cancel_after_shutdown(worker):
38+
def cancel(*_args, **_kwargs):
39+
worker._shutdown.set()
40+
raise FakeRpcError(grpc.StatusCode.CANCELLED, "stop")
41+
42+
return cancel
43+
44+
3745
class FakeResponseStream:
3846
def __init__(self, items=(), error: grpc.RpcError | None = None):
3947
self._items = list(items)
@@ -225,7 +233,7 @@ async def test_worker_applies_configured_hello_timeout(monkeypatch):
225233
monkeypatch.setattr(worker._shutdown, "wait", lambda timeout: False)
226234

227235
stub = MagicMock()
228-
stub.GetWorkItems.side_effect = FakeRpcError(grpc.StatusCode.CANCELLED, "stop")
236+
stub.GetWorkItems.side_effect = _cancel_after_shutdown(worker)
229237

230238
monkeypatch.setattr("durabletask.worker.shared.get_grpc_channel", lambda *args, **kwargs: MagicMock())
231239
monkeypatch.setattr("durabletask.worker.stubs.TaskHubSidecarServiceStub", lambda channel: stub)
@@ -235,6 +243,32 @@ async def test_worker_applies_configured_hello_timeout(monkeypatch):
235243
assert stub.Hello.call_args.kwargs["timeout"] == 12.5
236244

237245

246+
@pytest.mark.asyncio
247+
async def test_worker_reconnects_after_peer_cancelled_stream(monkeypatch):
248+
worker = TaskHubGrpcWorker(channel=MagicMock())
249+
worker._async_worker_manager = DummyWorkerManager()
250+
monkeypatch.setattr(worker._shutdown, "wait", lambda timeout: False)
251+
252+
first_stub = MagicMock()
253+
first_stub.GetWorkItems.side_effect = FakeRpcError(
254+
grpc.StatusCode.CANCELLED,
255+
"peer reset",
256+
)
257+
second_stub = MagicMock()
258+
second_stub.GetWorkItems.side_effect = _cancel_after_shutdown(worker)
259+
stub_factory = MagicMock(side_effect=[first_stub, second_stub])
260+
261+
monkeypatch.setattr(
262+
"durabletask.worker.stubs.TaskHubSidecarServiceStub",
263+
stub_factory,
264+
)
265+
266+
await worker._async_run_loop()
267+
268+
first_stub.GetWorkItems.assert_called_once()
269+
second_stub.GetWorkItems.assert_called_once()
270+
271+
238272
@pytest.mark.asyncio
239273
async def test_worker_does_not_recreate_sdk_owned_channel_for_non_transport_setup_errors(monkeypatch):
240274
worker = TaskHubGrpcWorker(
@@ -254,7 +288,7 @@ def get_grpc_channel(*args, **kwargs):
254288
first_stub.Hello.side_effect = RuntimeError("boom")
255289

256290
second_stub = MagicMock()
257-
second_stub.GetWorkItems.side_effect = FakeRpcError(grpc.StatusCode.CANCELLED, "stop")
291+
second_stub.GetWorkItems.side_effect = _cancel_after_shutdown(worker)
258292

259293
stubs = [first_stub, second_stub]
260294

@@ -293,10 +327,7 @@ def get_grpc_channel(*args, **kwargs):
293327
grpc.StatusCode.UNAVAILABLE,
294328
"second transport failure",
295329
)))),
296-
MagicMock(GetWorkItems=MagicMock(side_effect=FakeRpcError(
297-
grpc.StatusCode.CANCELLED,
298-
"stop",
299-
))),
330+
MagicMock(GetWorkItems=MagicMock(side_effect=_cancel_after_shutdown(worker))),
300331
]
301332
stub_channels = []
302333

@@ -352,10 +383,7 @@ def get_grpc_channel(*args, **kwargs):
352383
stub_channels = []
353384
stubs = [
354385
MagicMock(GetWorkItems=MagicMock(return_value=blocking_stream)),
355-
MagicMock(GetWorkItems=MagicMock(side_effect=FakeRpcError(
356-
grpc.StatusCode.CANCELLED,
357-
"stop",
358-
))),
386+
MagicMock(GetWorkItems=MagicMock(side_effect=_cancel_after_shutdown(worker))),
359387
]
360388

361389
def create_stub(channel):
@@ -393,10 +421,7 @@ def get_grpc_channel(*args, **kwargs):
393421
stub_channels = []
394422
stubs = [
395423
MagicMock(GetWorkItems=MagicMock(return_value=FakeResponseStream())),
396-
MagicMock(GetWorkItems=MagicMock(side_effect=FakeRpcError(
397-
grpc.StatusCode.CANCELLED,
398-
"stop",
399-
))),
424+
MagicMock(GetWorkItems=MagicMock(side_effect=_cancel_after_shutdown(worker))),
400425
]
401426

402427
def create_stub(channel):
@@ -440,10 +465,7 @@ def complete_activity(response):
440465
first_stub.CompleteActivityTask.side_effect = complete_activity
441466

442467
second_stub = MagicMock()
443-
second_stub.GetWorkItems.side_effect = FakeRpcError(
444-
grpc.StatusCode.CANCELLED,
445-
"stop",
446-
)
468+
second_stub.GetWorkItems.side_effect = _cancel_after_shutdown(worker)
447469

448470
stubs = [first_stub, second_stub]
449471
stub_channels = []
@@ -495,10 +517,7 @@ def get_grpc_channel(*args, **kwargs):
495517
first_stub.GetWorkItems.return_value = FakeResponseStream(items=[_make_activity_work_item()])
496518

497519
second_stub = MagicMock()
498-
second_stub.GetWorkItems.side_effect = FakeRpcError(
499-
grpc.StatusCode.CANCELLED,
500-
"stop",
501-
)
520+
second_stub.GetWorkItems.side_effect = _cancel_after_shutdown(worker)
502521

503522
stubs = [first_stub, second_stub]
504523

@@ -561,10 +580,7 @@ def complete_activity(response):
561580
first_stub.CompleteActivityTask.side_effect = complete_activity
562581

563582
second_stub = MagicMock()
564-
second_stub.GetWorkItems.side_effect = FakeRpcError(
565-
grpc.StatusCode.CANCELLED,
566-
"stop",
567-
)
583+
second_stub.GetWorkItems.side_effect = _cancel_after_shutdown(worker)
568584

569585
stubs = [first_stub, second_stub]
570586
stub_channels = []
@@ -637,10 +653,7 @@ def complete_activity(response):
637653
first_stub.CompleteActivityTask.side_effect = complete_activity
638654

639655
second_stub = MagicMock()
640-
second_stub.GetWorkItems.side_effect = FakeRpcError(
641-
grpc.StatusCode.CANCELLED,
642-
"stop",
643-
)
656+
second_stub.GetWorkItems.side_effect = _cancel_after_shutdown(worker)
644657

645658
stubs = [first_stub, second_stub]
646659
stub_channels = []
@@ -684,10 +697,7 @@ def complete_activity(response):
684697
first_stub.CompleteActivityTask.side_effect = complete_activity
685698

686699
second_stub = MagicMock()
687-
second_stub.GetWorkItems.side_effect = FakeRpcError(
688-
grpc.StatusCode.CANCELLED,
689-
"stop",
690-
)
700+
second_stub.GetWorkItems.side_effect = _cancel_after_shutdown(worker)
691701

692702
stubs = [first_stub, second_stub]
693703
stub_channels = []
@@ -751,10 +761,7 @@ def fake_delay(attempt, *, base_seconds, cap_seconds):
751761
"connect failed",
752762
)
753763
second_stub = MagicMock()
754-
second_stub.GetWorkItems.side_effect = FakeRpcError(
755-
grpc.StatusCode.CANCELLED,
756-
"stop",
757-
)
764+
second_stub.GetWorkItems.side_effect = _cancel_after_shutdown(worker)
758765
stubs = [first_stub, second_stub]
759766

760767
def create_stub(current_channel):
@@ -789,10 +796,7 @@ async def test_worker_never_replaces_caller_owned_channel_during_transport_failu
789796
grpc.StatusCode.UNAVAILABLE,
790797
"transport failure",
791798
)))),
792-
MagicMock(GetWorkItems=MagicMock(side_effect=FakeRpcError(
793-
grpc.StatusCode.CANCELLED,
794-
"stop",
795-
))),
799+
MagicMock(GetWorkItems=MagicMock(side_effect=_cancel_after_shutdown(worker))),
796800
]
797801

798802
def create_stub(channel):
@@ -846,10 +850,7 @@ def get_grpc_channel(*args, **kwargs):
846850
items=[work_item],
847851
error=FakeRpcError(grpc.StatusCode.UNAVAILABLE, "second transport failure"),
848852
))),
849-
MagicMock(GetWorkItems=MagicMock(side_effect=FakeRpcError(
850-
grpc.StatusCode.CANCELLED,
851-
"stop",
852-
))),
853+
MagicMock(GetWorkItems=MagicMock(side_effect=_cancel_after_shutdown(worker))),
853854
]
854855

855856
monkeypatch.setattr("durabletask.worker.shared.get_grpc_channel", get_grpc_channel)

0 commit comments

Comments
 (0)