Skip to content

Commit bc6a5e1

Browse files
andystaplesberndverst
authored andcommitted
fix(worker): preserve recovery signals and stacked lock-release paths
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 974213a5-9101-41b4-9bde-aa86a72f25e1
1 parent c64dcb3 commit bc6a5e1

8 files changed

Lines changed: 126 additions & 36 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ now takes precedence over hypothetical earlier storage failures.
1717
- Orchestrations that exceed the configured external payload storage limit now
1818
fail without retrying and release their entity locks, allowing other callers to
1919
progress. Transient payload-store failures remain retryable.
20-
- Unexpected non-cancellation orchestration exits, including generator teardown
21-
failures, now fail with a clear reason instead of leaving an accepted dispatch
20+
- Previously unhandled non-cancellation `BaseException` exits, including generator
21+
teardown failures, now fail with a clear reason instead of leaving an accepted dispatch
2222
unfinished. Cancellation and process-control signals retain their semantics.
2323
The gRPC worker abandons work that exits before an outcome is sent and resets
2424
the originating work-item stream after an uncertain completion or abandonment

‎azure-functions-durable/CHANGELOG.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ FIXED
2424
- With the corresponding core `durabletask` SDK fix, oversized messages fail
2525
large-payload validation before any uploads or payload replacements. Size
2626
errors now take precedence over hypothetical earlier storage failures.
27-
- With the corresponding core `durabletask` SDK fix, unexpected non-cancellation
28-
orchestration execution or generator teardown exits now produce a failed response
27+
- With the corresponding core `durabletask` SDK fix, previously unhandled non-cancellation
28+
`BaseException` exits from orchestration execution or teardown now produce a failed response
2929
with the exception's type, message, and stack trace instead of losing the response.
3030
- With the corresponding core `durabletask` SDK fix, orchestrator generators
3131
are closed at the end of each dispatch, including while waiting for durable work.

‎durabletask-azuremanaged/CHANGELOG.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ errors now take precedence over hypothetical earlier storage failures.
1515
- With the corresponding core `durabletask` SDK fix, orchestrations that exceed
1616
the configured external payload storage limit fail without retrying and release
1717
their entity locks. Transient payload-store failures remain retryable.
18-
- With the corresponding core `durabletask` SDK fix, unexpected non-cancellation
19-
orchestration exits now fail with a clear reason. Cancellation and process-control
18+
- With the corresponding core `durabletask` SDK fix, previously unhandled
19+
non-cancellation `BaseException` exits now fail with a clear reason. Cancellation and process-control
2020
exits recover dispatch ownership, and uncertain completion or abandonment RPCs
2121
reset the originating work-item stream rather than retaining unfinished work.
2222
- With the corresponding core `durabletask` SDK fix, orchestrator generators

‎durabletask/worker.py‎

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,7 +1014,10 @@ def stream_reader(response_stream: Any = response_stream) -> None:
10141014
self._execute_orchestrator,
10151015
_on_outcome_uncertain=response_stream.cancel,
10161016
),
1017-
self._cancel_orchestrator,
1017+
partial(
1018+
self._cancel_orchestrator,
1019+
_on_outcome_uncertain=response_stream.cancel,
1020+
),
10181021
work_item.orchestratorRequest,
10191022
stub,
10201023
work_item.completionToken,
@@ -1173,6 +1176,7 @@ def _execute_orchestrator(
11731176
stub.CompleteOrchestratorTask(res)
11741177
except BaseException as error:
11751178
recovery_error: BaseException | None = None
1179+
outcome_uncertain = outcome_attempted
11761180
self._logger.error(
11771181
f"Orchestrator dispatch for '{req.instanceId}' exited without a confirmed outcome.",
11781182
exc_info=True,
@@ -1182,33 +1186,35 @@ def _execute_orchestrator(
11821186
self._cancel_orchestrator(req, stub, completionToken)
11831187
except BaseException as abandonment_error:
11841188
recovery_error = abandonment_error
1185-
outcome_attempted = True
1186-
self._logger.error(
1187-
f"Failed to abandon orchestrator dispatch for '{req.instanceId}'.",
1188-
exc_info=True,
1189-
)
1189+
outcome_uncertain = True
11901190
# A failed RPC may already have been accepted. Do not retry it or
11911191
# send a conflicting outcome; release ownership through its stream.
1192-
if outcome_attempted and _on_outcome_uncertain is not None:
1193-
self._logger.error(
1194-
f"Resetting the owning work-item stream for '{req.instanceId}' after an uncertain outcome."
1195-
)
1192+
if outcome_uncertain and _on_outcome_uncertain is not None:
11961193
try:
1197-
_on_outcome_uncertain()
1194+
self._reset_orchestrator_stream(req.instanceId, _on_outcome_uncertain)
11981195
except BaseException as reset_error:
11991196
if recovery_error is None or (
12001197
isinstance(recovery_error, Exception) and not isinstance(reset_error, Exception)
12011198
):
12021199
recovery_error = reset_error
1203-
self._logger.error(
1204-
f"Failed to reset the owning work-item stream for '{req.instanceId}'.",
1205-
exc_info=True,
1206-
)
12071200
if not isinstance(error, Exception):
12081201
raise
12091202
if recovery_error is not None and not isinstance(recovery_error, Exception):
12101203
raise recovery_error
12111204

1205+
def _reset_orchestrator_stream(self, instance_id: str, reset: Callable[[], Any]) -> None:
1206+
self._logger.error(
1207+
f"Resetting the owning work-item stream for '{instance_id}' after an uncertain outcome."
1208+
)
1209+
try:
1210+
reset()
1211+
except BaseException:
1212+
self._logger.error(
1213+
f"Failed to reset the owning work-item stream for '{instance_id}'.",
1214+
exc_info=True,
1215+
)
1216+
raise
1217+
12121218
def _get_orchestrator_response(
12131219
self,
12141220
req: pb.OrchestratorRequest,
@@ -1351,12 +1357,27 @@ def _cancel_orchestrator(
13511357
req: pb.OrchestratorRequest,
13521358
stub: Any,
13531359
completionToken: Any,
1360+
*,
1361+
_on_outcome_uncertain: Callable[[], Any] | None = None,
13541362
) -> None:
1355-
stub.AbandonTaskOrchestratorWorkItem(
1356-
pb.AbandonOrchestrationTaskRequest(
1357-
completionToken=completionToken
1363+
try:
1364+
stub.AbandonTaskOrchestratorWorkItem(
1365+
pb.AbandonOrchestrationTaskRequest(
1366+
completionToken=completionToken
1367+
)
13581368
)
1359-
)
1369+
except BaseException as error:
1370+
self._logger.error(
1371+
f"Failed to abandon orchestrator dispatch for '{req.instanceId}'.",
1372+
exc_info=True,
1373+
)
1374+
if _on_outcome_uncertain is not None:
1375+
try:
1376+
self._reset_orchestrator_stream(req.instanceId, _on_outcome_uncertain)
1377+
except BaseException as reset_error:
1378+
if isinstance(error, Exception) and not isinstance(reset_error, Exception):
1379+
raise
1380+
raise
13601381
self._logger.info(f"Cancelled orchestration task for invocation ID: {req.instanceId}")
13611382

13621383
def _on_activity_execution_started(self, req: pb.ActivityRequest) -> None:

‎tests/azure-functions-durable/test_worker_compat.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,34 @@ def test_worker_uses_propagate_only_tracing():
5656
assert worker.emit_trace_spans is False
5757

5858

59+
@pytest.mark.parametrize("use_async", [False, True])
60+
async def test_worker_returns_failed_response_for_unexpected_teardown(monkeypatch, use_async):
61+
monkeypatch.setattr(payloads, "_payload_store", None)
62+
63+
class TeardownExit(BaseException):
64+
pass
65+
66+
def orchestrator(context):
67+
try:
68+
yield context.call_activity("work")
69+
finally:
70+
raise TeardownExit("deterministic teardown failure")
71+
72+
worker = DurableFunctionsWorker()
73+
request = _encode_orchestrator_request("teardown")
74+
if use_async:
75+
encoded = await worker.execute_orchestration_request_async(orchestrator, request)
76+
else:
77+
encoded = worker.execute_orchestration_request(orchestrator, request)
78+
response = _decode_orchestrator_response(encoded)
79+
completion = _get_completion_action(response)
80+
81+
assert completion.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED
82+
assert completion.failureDetails.errorType.endswith(".TeardownExit")
83+
assert completion.failureDetails.errorMessage == "deterministic teardown failure"
84+
assert len(response.actions) == 1
85+
86+
5987
def test_worker_created_before_configuration_hydrates_and_externalizes(monkeypatch, payload_store_factory):
6088
monkeypatch.setattr(payloads, "_payload_store", None)
6189
worker = DurableFunctionsWorker()

‎tests/durabletask-azuremanaged/test_azuremanaged_grpc_resiliency.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT License.
33

4-
from unittest.mock import patch
4+
from unittest.mock import Mock, patch
55
from collections.abc import Mapping
66
from typing import Any
77

8+
from durabletask import task
9+
from durabletask.internal import helpers
10+
from durabletask.internal import orchestrator_service_pb2 as pb
811
from durabletask.azuremanaged.client import (
912
AsyncDurableTaskSchedulerClient,
1013
DurableTaskSchedulerClient,
@@ -40,6 +43,34 @@ def test_dts_worker_passes_resiliency_options_to_base_worker():
4043
assert mock_init.call_args.kwargs["resiliency_options"] is resiliency
4144

4245

46+
def test_dts_worker_completes_unexpected_teardown_failure():
47+
class TeardownExit(BaseException):
48+
pass
49+
50+
def orchestrator(ctx: task.OrchestrationContext, _: Any):
51+
try:
52+
yield ctx.call_activity("work")
53+
finally:
54+
raise TeardownExit("deterministic teardown failure")
55+
56+
with DurableTaskSchedulerWorker(
57+
host_address="localhost:4001", taskhub="hub", token_credential=None) as worker:
58+
name = worker.add_orchestrator(orchestrator)
59+
request = pb.OrchestratorRequest(instanceId="instance", newEvents=[
60+
helpers.new_orchestrator_started_event(),
61+
helpers.new_execution_started_event(name, "instance"),
62+
])
63+
stub = Mock()
64+
worker._execute_orchestrator(request, stub, "original-token")
65+
66+
stub.CompleteOrchestratorTask.assert_called_once()
67+
stub.AbandonTaskOrchestratorWorkItem.assert_not_called()
68+
response = stub.CompleteOrchestratorTask.call_args.args[0]
69+
assert response.completionToken == "original-token"
70+
assert response.actions[0].completeOrchestration.orchestrationStatus == pb.ORCHESTRATION_STATUS_FAILED
71+
assert response.actions[0].completeOrchestration.failureDetails.errorMessage == "deterministic teardown failure"
72+
73+
4374
def test_dts_worker_passes_exception_properties_provider_to_base_worker():
4475
class PropertiesProvider:
4576
def get_exception_properties(self, exception: Exception) -> Mapping[str, Any] | None:

‎tests/durabletask/test_orchestration_dispatch_recovery.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,16 +96,18 @@ def orchestrator(ctx: task.OrchestrationContext, _: Any) -> Generator[task.Task[
9696
assert "secondary cleanup failure" not in completion.failureDetails.errorMessage
9797

9898

99-
@pytest.mark.parametrize("release_before_wait", [False, True])
100-
def test_unexpected_teardown_failure_preserves_entity_lock_releases(release_before_wait: bool):
99+
@pytest.mark.parametrize("wait", ["locked-event", "released-event", "entity-call"])
100+
def test_unexpected_teardown_failure_preserves_entity_lock_releases(wait: str):
101101
entity_id = EntityInstanceId("Counter", "key")
102102

103103
def orchestrator(ctx: task.OrchestrationContext, _: Any) -> Generator[task.Task[Any], Any, None]:
104104
acquired = False
105105
try:
106106
with (yield ctx.lock_entities([entity_id])):
107107
acquired = True
108-
if not release_before_wait:
108+
if wait == "entity-call":
109+
yield ctx.call_entity(entity_id, "get")
110+
elif wait == "locked-event":
109111
yield ctx.wait_for_external_event("ready")
110112
yield ctx.wait_for_external_event("ready")
111113
finally:

‎tests/durabletask/test_worker_resiliency.py‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -747,7 +747,8 @@ def create_stub(channel):
747747

748748

749749
@pytest.mark.asyncio
750-
async def test_orchestrator_uncertain_outcome_resets_only_originating_stream(monkeypatch):
750+
@pytest.mark.parametrize("operation", ["complete", "abandon"])
751+
async def test_orchestrator_uncertain_outcome_resets_only_originating_stream(monkeypatch, operation):
751752
channel = MagicMock()
752753
worker = TaskHubGrpcWorker(channel=channel)
753754
manager = DummyWorkerManager()
@@ -762,6 +763,7 @@ async def test_orchestrator_uncertain_outcome_resets_only_originating_stream(mon
762763
first_stub = MagicMock()
763764
first_stub.GetWorkItems.return_value = first_stream
764765
first_stub.CompleteOrchestratorTask.side_effect = RuntimeError("completion outcome unknown")
766+
first_stub.AbandonTaskOrchestratorWorkItem.side_effect = RuntimeError("abandonment outcome unknown")
765767
next_stub = MagicMock()
766768
next_stub.GetWorkItems.return_value = next_stream
767769
stop_stub = MagicMock()
@@ -775,12 +777,18 @@ async def test_orchestrator_uncertain_outcome_resets_only_originating_stream(mon
775777
next_stream.cancelled = False
776778
worker._response_stream = next_stream
777779
_, submission = manager.submissions[0]
778-
handler, _, request, original_stub, token = submission
779-
handler(request, original_stub, token)
780-
781-
first_stub.CompleteOrchestratorTask.assert_called_once()
782-
assert first_stub.CompleteOrchestratorTask.call_args.args[0].completionToken == "original-token"
783-
first_stub.AbandonTaskOrchestratorWorkItem.assert_not_called()
780+
handler, cancel, request, original_stub, token = submission
781+
if operation == "complete":
782+
handler(request, original_stub, token)
783+
first_stub.CompleteOrchestratorTask.assert_called_once()
784+
assert first_stub.CompleteOrchestratorTask.call_args.args[0].completionToken == "original-token"
785+
first_stub.AbandonTaskOrchestratorWorkItem.assert_not_called()
786+
else:
787+
with pytest.raises(RuntimeError, match="abandonment outcome unknown"):
788+
cancel(request, original_stub, token)
789+
first_stub.CompleteOrchestratorTask.assert_not_called()
790+
first_stub.AbandonTaskOrchestratorWorkItem.assert_called_once()
791+
assert first_stub.AbandonTaskOrchestratorWorkItem.call_args.args[0].completionToken == "original-token"
784792
assert first_stream.cancelled
785793
assert not next_stream.cancelled
786794
channel.close.assert_not_called()

0 commit comments

Comments
 (0)