Skip to content

Commit 94ec7a8

Browse files
committed
Propagate entity operation failures over legacy entity protocol
call_entity failures delivered via the legacy entity protocol (used by the Azure Functions Durable extension) were silently completing instead of raising. _handle_entity_event_raised now detects failed ResponseMessage payloads (exceptionType/failureDetails) and fails the task with an EntityOperationFailedException, matching the current entity protocol and the .NET SDK.
1 parent 8440526 commit 94ec7a8

4 files changed

Lines changed: 183 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ CHANGED
1111

1212
- Changed the default large-payload externalization threshold (`LargePayloadStorageOptions.threshold_bytes`) from 900,000 bytes to 262,144 bytes (256 KiB), matching the .NET SDK default. Behavioral change (not source/binary breaking): payloads larger than 256 KiB are now externalized by default.
1313

14+
FIXED
15+
16+
- Fixed `OrchestrationContext.call_entity` not propagating entity operation failures when running under the legacy entity protocol (used by the Azure Functions Durable extension). A failed entity operation now raises `TaskFailedError` in the calling orchestration instead of silently completing, matching the behavior of the current entity protocol and the .NET SDK.
17+
1418
## v1.7.0
1519

1620
ADDED

durabletask/internal/helpers.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import traceback
55
from datetime import datetime, timezone
6+
from typing import Any, cast
67

78
from google.protobuf import timestamp_pb2, wrappers_pb2
89

@@ -136,6 +137,37 @@ def new_failure_details(ex: Exception, _visited: set[int] | None = None) -> pb.T
136137
)
137138

138139

140+
def _failure_details_from_core_dict(fd: dict[str, Any]) -> pb.TaskFailureDetails:
141+
"""Convert a serialized DurableTask.Core ``FailureDetails`` dict to protobuf."""
142+
inner = fd.get("InnerFailure")
143+
stack_trace = fd.get("StackTrace")
144+
return pb.TaskFailureDetails(
145+
errorType=str(fd.get("ErrorType") or ""),
146+
errorMessage=str(fd.get("ErrorMessage") or ""),
147+
stackTrace=get_string_value(str(stack_trace) if stack_trace is not None else None),
148+
innerFailure=_failure_details_from_core_dict(cast(dict[str, Any], inner)) if isinstance(inner, dict) else None,
149+
isNonRetriable=bool(fd.get("IsNonRetriable", False)),
150+
)
151+
152+
153+
def entity_response_failure_details(response: dict[str, Any]) -> pb.TaskFailureDetails | None:
154+
"""Extract failure details from a legacy-protocol entity ``ResponseMessage``.
155+
156+
The legacy entity protocol (used when the Durable WebJobs extension
157+
translates entity operations) delivers operation results as a
158+
``ResponseMessage`` whose ``exceptionType`` (error message) or
159+
``failureDetails`` field is populated when the operation failed. Returns
160+
``None`` when the response represents a successful operation.
161+
"""
162+
failure_details = response.get("failureDetails")
163+
if isinstance(failure_details, dict):
164+
return _failure_details_from_core_dict(cast(dict[str, Any], failure_details))
165+
error_message = response.get("exceptionType")
166+
if error_message is not None:
167+
return pb.TaskFailureDetails(errorType="", errorMessage=str(error_message))
168+
return None
169+
170+
139171
def new_event_sent_event(event_id: int, instance_id: str, input: str):
140172
return pb.HistoryEvent(
141173
eventId=event_id,

durabletask/worker.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2574,7 +2574,7 @@ def _cancel_timer() -> None:
25742574
elif event.HasField("eventRaised"):
25752575
if event.eventRaised.name in ctx._entity_task_id_map: # pyright: ignore[reportPrivateUsage]
25762576
entity_id, operation, task_id = ctx._entity_task_id_map.get(event.eventRaised.name, (None, None, None)) # pyright: ignore[reportPrivateUsage]
2577-
self._handle_entity_event_raised(ctx, event, entity_id, task_id, False)
2577+
self._handle_entity_event_raised(ctx, event, entity_id, task_id, False, operation)
25782578
elif event.eventRaised.name in ctx._entity_lock_task_id_map: # pyright: ignore[reportPrivateUsage]
25792579
entity_id, task_id = ctx._entity_lock_task_id_map.get(event.eventRaised.name, (None, None)) # pyright: ignore[reportPrivateUsage]
25802580
self._handle_entity_event_raised(ctx, event, entity_id, task_id, True)
@@ -2803,7 +2803,8 @@ def _handle_entity_event_raised(self,
28032803
event: pb.HistoryEvent,
28042804
entity_id: EntityInstanceId | None,
28052805
task_id: int | None,
2806-
is_lock_event: bool):
2806+
is_lock_event: bool,
2807+
operation: str | None = None):
28072808
# This eventRaised represents the result of an entity operation after being translated to the old
28082809
# entity protocol by the Durable WebJobs extension
28092810
if entity_id is None:
@@ -2813,14 +2814,31 @@ def _handle_entity_event_raised(self,
28132814
entity_task = ctx._pending_tasks.pop(task_id, None) # pyright: ignore[reportPrivateUsage]
28142815
if not entity_task:
28152816
raise RuntimeError(f"Could not retrieve entity task for entity-related eventRaised with ID '{event.eventId}'")
2816-
result = None
2817+
response: Any | None = None
28172818
if not ph.is_empty(event.eventRaised.input):
2819+
response = self._data_converter.deserialize(event.eventRaised.input.value)
2820+
2821+
# For entity operation calls (lock acquisitions never fail this way), the legacy-protocol
2822+
# ResponseMessage signals a failed operation via its "exceptionType" (error message) or
2823+
# "failureDetails" field. Propagate that as a task failure so an awaiting call_entity raises,
2824+
# matching the new entity protocol and the .NET SDK.
2825+
if not is_lock_event and isinstance(response, dict):
2826+
failure_details = ph.entity_response_failure_details(cast(dict[str, Any], response))
2827+
if failure_details is not None:
2828+
failure = EntityOperationFailedException(entity_id, operation or "", failure_details)
2829+
ctx._entity_context.recover_lock_after_call(entity_id) # pyright: ignore[reportPrivateUsage]
2830+
entity_task.fail(str(failure), failure)
2831+
ctx.resume()
2832+
return
2833+
2834+
result = None
2835+
if response is not None:
28182836
# TODO: Investigate why the event result is wrapped in a dict with "result" key
28192837
# The expected type applies to the unwrapped result value, not the
28202838
# transport wrapper. Unwrap first, then coerce the already-parsed
28212839
# inner value to the expected type via the converter (no redundant
28222840
# re-serialization round-trip).
2823-
unwrapped = self._data_converter.deserialize(event.eventRaised.input.value)["result"]
2841+
unwrapped: Any = cast(Any, response)["result"]
28242842
result = self._data_converter.coerce(
28252843
unwrapped,
28262844
entity_task._expected_type, # pyright: ignore[reportPrivateUsage]

tests/durabletask/test_orchestration_executor.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2095,6 +2095,131 @@ def orchestrator(ctx: task.OrchestrationContext, _):
20952095
assert actions[0].sendEntityMessage.entityOperationCalled.targetInstanceId.value == str(test_entity_id)
20962096

20972097

2098+
def test_entity_call_failure_propagated_over_old_protocol():
2099+
"""A failed entity operation delivered via the legacy protocol (an eventRaised
2100+
ResponseMessage with failureDetails) must surface as a TaskFailedError to the caller."""
2101+
test_entity_id = entities.EntityInstanceId("Counter", "myCounter")
2102+
2103+
def orchestrator(ctx: task.OrchestrationContext, _):
2104+
try:
2105+
yield ctx.call_entity(test_entity_id, "set", 1)
2106+
except task.TaskFailedError as e:
2107+
return e.details.message
2108+
return "no error"
2109+
2110+
registry = worker._Registry()
2111+
name = registry.add_orchestrator(orchestrator)
2112+
2113+
started_events = [
2114+
helpers.new_orchestrator_started_event(),
2115+
helpers.new_execution_started_event(name, TEST_INSTANCE_ID, None),
2116+
]
2117+
2118+
executor = worker._OrchestrationExecutor(registry, TEST_LOGGER, JsonDataConverter())
2119+
result1 = executor.execute(TEST_INSTANCE_ID, [], started_events)
2120+
actions = result1.actions
2121+
assert len(actions) == 1
2122+
assert actions[0].sendEntityMessage.HasField("entityOperationCalled")
2123+
request_id = actions[0].sendEntityMessage.entityOperationCalled.requestId
2124+
2125+
# The Durable WebJobs extension translates a failed entity operation into a
2126+
# legacy-protocol ResponseMessage carrying a serialized FailureDetails.
2127+
response_message = {
2128+
"result": None,
2129+
"failureDetails": {
2130+
"ErrorType": "ValueError",
2131+
"ErrorMessage": "Something went wrong!",
2132+
"StackTrace": None,
2133+
"InnerFailure": None,
2134+
"IsNonRetriable": False,
2135+
},
2136+
}
2137+
new_events = [
2138+
helpers.new_event_sent_event(1, str(test_entity_id), json.dumps({"id": request_id})),
2139+
helpers.new_event_raised_event(request_id, json.dumps(response_message)),
2140+
]
2141+
result2 = executor.execute(TEST_INSTANCE_ID, started_events, new_events)
2142+
complete_action = get_and_validate_complete_orchestration_action_list(1, result2.actions)
2143+
assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_COMPLETED
2144+
output = json.loads(complete_action.result.value)
2145+
assert output == (
2146+
"Operation 'set' on entity '@counter@myCounter' failed with error: Something went wrong!"
2147+
)
2148+
2149+
2150+
def test_entity_call_failure_propagated_over_old_protocol_exception_type():
2151+
"""A failed entity operation whose legacy ResponseMessage only carries the
2152+
``exceptionType`` (error message) field must still surface as a TaskFailedError."""
2153+
test_entity_id = entities.EntityInstanceId("Counter", "myCounter")
2154+
2155+
def orchestrator(ctx: task.OrchestrationContext, _):
2156+
try:
2157+
yield ctx.call_entity(test_entity_id, "set", 1)
2158+
except task.TaskFailedError as e:
2159+
return e.details.message
2160+
return "no error"
2161+
2162+
registry = worker._Registry()
2163+
name = registry.add_orchestrator(orchestrator)
2164+
2165+
started_events = [
2166+
helpers.new_orchestrator_started_event(),
2167+
helpers.new_execution_started_event(name, TEST_INSTANCE_ID, None),
2168+
]
2169+
2170+
executor = worker._OrchestrationExecutor(registry, TEST_LOGGER, JsonDataConverter())
2171+
result1 = executor.execute(TEST_INSTANCE_ID, [], started_events)
2172+
actions = result1.actions
2173+
assert len(actions) == 1
2174+
request_id = actions[0].sendEntityMessage.entityOperationCalled.requestId
2175+
2176+
response_message = {"result": None, "exceptionType": "Something went wrong!"}
2177+
new_events = [
2178+
helpers.new_event_sent_event(1, str(test_entity_id), json.dumps({"id": request_id})),
2179+
helpers.new_event_raised_event(request_id, json.dumps(response_message)),
2180+
]
2181+
result2 = executor.execute(TEST_INSTANCE_ID, started_events, new_events)
2182+
complete_action = get_and_validate_complete_orchestration_action_list(1, result2.actions)
2183+
assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_COMPLETED
2184+
output = json.loads(complete_action.result.value)
2185+
assert output == (
2186+
"Operation 'set' on entity '@counter@myCounter' failed with error: Something went wrong!"
2187+
)
2188+
2189+
2190+
def test_entity_call_success_over_old_protocol():
2191+
"""A successful entity operation delivered via the legacy protocol must
2192+
complete the call_entity task with the unwrapped result."""
2193+
test_entity_id = entities.EntityInstanceId("Counter", "myCounter")
2194+
2195+
def orchestrator(ctx: task.OrchestrationContext, _):
2196+
return (yield ctx.call_entity(test_entity_id, "get", return_type=int))
2197+
2198+
registry = worker._Registry()
2199+
name = registry.add_orchestrator(orchestrator)
2200+
2201+
started_events = [
2202+
helpers.new_orchestrator_started_event(),
2203+
helpers.new_execution_started_event(name, TEST_INSTANCE_ID, None),
2204+
]
2205+
2206+
executor = worker._OrchestrationExecutor(registry, TEST_LOGGER, JsonDataConverter())
2207+
result1 = executor.execute(TEST_INSTANCE_ID, [], started_events)
2208+
actions = result1.actions
2209+
assert len(actions) == 1
2210+
request_id = actions[0].sendEntityMessage.entityOperationCalled.requestId
2211+
2212+
response_message = {"result": json.dumps(42)}
2213+
new_events = [
2214+
helpers.new_event_sent_event(1, str(test_entity_id), json.dumps({"id": request_id})),
2215+
helpers.new_event_raised_event(request_id, json.dumps(response_message)),
2216+
]
2217+
result2 = executor.execute(TEST_INSTANCE_ID, started_events, new_events)
2218+
complete_action = get_and_validate_complete_orchestration_action_list(1, result2.actions)
2219+
assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_COMPLETED
2220+
assert json.loads(complete_action.result.value) == 42
2221+
2222+
20982223
def get_and_validate_complete_orchestration_action_list(expected_action_count: int, actions: list[pb.OrchestratorAction]) -> pb.CompleteOrchestrationAction:
20992224
assert len(actions) == expected_action_count
21002225
assert type(actions[-1]) is pb.OrchestratorAction

0 commit comments

Comments
 (0)