Skip to content

Commit a0e3d83

Browse files
authored
Fix legacy entity protocol: propagate call_entity failures and stop double-encoding results (#167)
* Propagate entity operation failures over legacy entity protocol * Fix double-encoded call_entity result over legacy entity protocol * Pop legacy entity map entries after handling response
1 parent 652e58b commit a0e3d83

4 files changed

Lines changed: 263 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ CHANGED
1919

2020
FIXED
2121

22+
- 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.
23+
- Fixed `OrchestrationContext.call_entity` returning a double-encoded result under the legacy entity protocol. The entity's return value was left as a raw serialized JSON string (for example, a returned string arrived with extra quotes and dicts/lists arrived as strings); it is now fully deserialized and coerced to the requested `return_type`, matching the current entity protocol.
24+
- Fixed unbounded growth of the internal entity request/lock tracking maps when using entities over the legacy entity protocol. Entries are now released as each response is handled, reducing memory use in long-running orchestrations that call or lock entities.
2225
- Fixed schedules created with `durabletask.scheduled` not appearing in the Durable Task Scheduler dashboard. The schedule entity state is now persisted in a format compatible with the .NET SDK: the `status` is serialized as its numeric enum value, the `interval` as a .NET `TimeSpan` string, and the `orchestration_input` as a JSON-encoded string, all using .NET property names, so the dashboard can read it without a JSON deserialization error.
2326

2427
## v1.7.0

durabletask/internal/helpers.py

Lines changed: 54 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,59 @@ 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(
154+
response: dict[str, Any],
155+
error_content: Any = None) -> pb.TaskFailureDetails:
156+
"""Build failure details from a failed legacy-protocol entity ``ResponseMessage``.
157+
158+
Call this only for responses that :func:`is_entity_error_response` reports as
159+
failures. In the WebJobs "old protocol" ``ResponseMessage`` (see
160+
``EntityScheduler/ResponseMessage.cs``), a failed operation serializes the
161+
human-readable content into ``result`` while ``exceptionType`` carries only
162+
the exception's type name (a presence marker) -- it is *not* the message.
163+
This mirrors ``azure-functions-durable-python`` / ``-js``, which read the
164+
message from ``result`` and ignore ``exceptionType``'s value.
165+
166+
Parameters
167+
----------
168+
response:
169+
The deserialized ``ResponseMessage`` dict.
170+
error_content:
171+
The already-deserialized ``result`` payload, used as the failure
172+
message. A structured ``failureDetails`` object, if present, takes
173+
precedence (current-protocol shape).
174+
"""
175+
failure_details = response.get("failureDetails")
176+
if isinstance(failure_details, dict):
177+
return _failure_details_from_core_dict(cast(dict[str, Any], failure_details))
178+
error_type = str(response.get("exceptionType") or "")
179+
error_message = "" if error_content is None else str(error_content)
180+
return pb.TaskFailureDetails(errorType=error_type, errorMessage=error_message)
181+
182+
183+
def is_entity_error_response(response: dict[str, Any]) -> bool:
184+
"""Return ``True`` if a legacy-protocol entity ``ResponseMessage`` is a failure.
185+
186+
In the WebJobs "old protocol" a failed operation is marked by the presence of
187+
an ``exceptionType`` field (successful responses omit it). Current-protocol
188+
payloads may instead carry a structured ``failureDetails`` object.
189+
"""
190+
return "exceptionType" in response or isinstance(response.get("failureDetails"), dict)
191+
192+
139193
def new_event_sent_event(event_id: int, instance_id: str, input: str):
140194
return pb.HistoryEvent(
141195
eventId=event_id,

durabletask/worker.py

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2588,10 +2588,10 @@ def _cancel_timer() -> None:
25882588
raise TypeError("Unexpected sub-orchestration task type")
25892589
elif event.HasField("eventRaised"):
25902590
if event.eventRaised.name in ctx._entity_task_id_map: # pyright: ignore[reportPrivateUsage]
2591-
entity_id, operation, task_id = ctx._entity_task_id_map.get(event.eventRaised.name, (None, None, None)) # pyright: ignore[reportPrivateUsage]
2592-
self._handle_entity_event_raised(ctx, event, entity_id, task_id, False)
2591+
entity_id, operation, task_id = ctx._entity_task_id_map.pop(event.eventRaised.name, (None, None, None)) # pyright: ignore[reportPrivateUsage]
2592+
self._handle_entity_event_raised(ctx, event, entity_id, task_id, False, operation)
25932593
elif event.eventRaised.name in ctx._entity_lock_task_id_map: # pyright: ignore[reportPrivateUsage]
2594-
entity_id, task_id = ctx._entity_lock_task_id_map.get(event.eventRaised.name, (None, None)) # pyright: ignore[reportPrivateUsage]
2594+
entity_id, task_id = ctx._entity_lock_task_id_map.pop(event.eventRaised.name, (None, None)) # pyright: ignore[reportPrivateUsage]
25952595
self._handle_entity_event_raised(ctx, event, entity_id, task_id, True)
25962596
else:
25972597
# event names are case-insensitive
@@ -2818,7 +2818,8 @@ def _handle_entity_event_raised(self,
28182818
event: pb.HistoryEvent,
28192819
entity_id: EntityInstanceId | None,
28202820
task_id: int | None,
2821-
is_lock_event: bool):
2821+
is_lock_event: bool,
2822+
operation: str | None = None):
28222823
# This eventRaised represents the result of an entity operation after being translated to the old
28232824
# entity protocol by the Durable WebJobs extension
28242825
if entity_id is None:
@@ -2828,16 +2829,38 @@ def _handle_entity_event_raised(self,
28282829
entity_task = ctx._pending_tasks.pop(task_id, None) # pyright: ignore[reportPrivateUsage]
28292830
if not entity_task:
28302831
raise RuntimeError(f"Could not retrieve entity task for entity-related eventRaised with ID '{event.eventId}'")
2831-
result = None
2832+
response: dict[str, Any] | None = None
28322833
if not ph.is_empty(event.eventRaised.input):
2833-
# TODO: Investigate why the event result is wrapped in a dict with "result" key
2834-
# The expected type applies to the unwrapped result value, not the
2835-
# transport wrapper. Unwrap first, then coerce the already-parsed
2836-
# inner value to the expected type via the converter (no redundant
2837-
# re-serialization round-trip).
2838-
unwrapped = self._data_converter.deserialize(event.eventRaised.input.value)["result"]
2839-
result = self._data_converter.coerce(
2840-
unwrapped,
2834+
response = self._data_converter.deserialize(event.eventRaised.input.value)
2835+
2836+
# For entity operation calls (lock acquisitions never fail this way), the legacy WebJobs
2837+
# "old protocol" ResponseMessage signals a failed operation via the presence of an
2838+
# "exceptionType" marker (or a structured "failureDetails" object). The human-readable
2839+
# message lives in the *serialized* "result" field -- "exceptionType" is only the exception
2840+
# type name, not the message -- so deserialize "result" to recover it, matching
2841+
# azure-functions-durable-python / -js. Propagate as a task failure so an awaiting
2842+
# call_entity raises, like the current entity protocol and the .NET SDK.
2843+
if not is_lock_event and isinstance(response, dict) and ph.is_entity_error_response(response):
2844+
raw_result = response.get("result")
2845+
error_content = (
2846+
self._data_converter.deserialize(raw_result) if isinstance(raw_result, str) else raw_result
2847+
)
2848+
failure_details = ph.entity_response_failure_details(response, error_content)
2849+
failure = EntityOperationFailedException(entity_id, operation or "", failure_details)
2850+
ctx._entity_context.recover_lock_after_call(entity_id) # pyright: ignore[reportPrivateUsage]
2851+
entity_task.fail(str(failure), failure)
2852+
ctx.resume()
2853+
return
2854+
2855+
result = None
2856+
if response is not None:
2857+
# The legacy protocol wraps the result as {"result": <serialized>},
2858+
# where the value is a serialized JSON string (like the new protocol's
2859+
# entityOperationCompleted.output). Deserialize it -- not coerce -- so
2860+
# the value is fully parsed and the expected type applied; coercing
2861+
# would skip JSON parsing and leave it double-encoded (e.g. '"done"').
2862+
result = self._data_converter.deserialize(
2863+
response["result"],
28412864
entity_task._expected_type, # pyright: ignore[reportPrivateUsage]
28422865
)
28432866
if is_lock_event:

tests/durabletask/test_orchestration_executor.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2139,6 +2139,176 @@ def orchestrator(ctx: task.OrchestrationContext, _):
21392139
assert actions[0].sendEntityMessage.entityOperationCalled.targetInstanceId.value == str(test_entity_id)
21402140

21412141

2142+
def test_entity_call_failure_propagated_over_old_protocol():
2143+
"""A failed entity response carrying a structured ``failureDetails`` object
2144+
(defensively supported, current-protocol shape) must surface as a
2145+
TaskFailedError with the structured message."""
2146+
test_entity_id = entities.EntityInstanceId("Counter", "myCounter")
2147+
2148+
def orchestrator(ctx: task.OrchestrationContext, _):
2149+
try:
2150+
yield ctx.call_entity(test_entity_id, "set", 1)
2151+
except task.TaskFailedError as e:
2152+
return e.details.message
2153+
return "no error"
2154+
2155+
registry = worker._Registry()
2156+
name = registry.add_orchestrator(orchestrator)
2157+
2158+
started_events = [
2159+
helpers.new_orchestrator_started_event(),
2160+
helpers.new_execution_started_event(name, TEST_INSTANCE_ID, None),
2161+
]
2162+
2163+
executor = worker._OrchestrationExecutor(registry, TEST_LOGGER, JsonDataConverter())
2164+
result1 = executor.execute(TEST_INSTANCE_ID, [], started_events)
2165+
actions = result1.actions
2166+
assert len(actions) == 1
2167+
assert actions[0].sendEntityMessage.HasField("entityOperationCalled")
2168+
request_id = actions[0].sendEntityMessage.entityOperationCalled.requestId
2169+
2170+
# A structured FailureDetails object, when present, takes precedence over the
2171+
# WebJobs result/exceptionType fields.
2172+
response_message = {
2173+
"result": None,
2174+
"failureDetails": {
2175+
"ErrorType": "ValueError",
2176+
"ErrorMessage": "Something went wrong!",
2177+
"StackTrace": None,
2178+
"InnerFailure": None,
2179+
"IsNonRetriable": False,
2180+
},
2181+
}
2182+
new_events = [
2183+
helpers.new_event_sent_event(1, str(test_entity_id), json.dumps({"id": request_id})),
2184+
helpers.new_event_raised_event(request_id, json.dumps(response_message)),
2185+
]
2186+
result2 = executor.execute(TEST_INSTANCE_ID, started_events, new_events)
2187+
complete_action = get_and_validate_complete_orchestration_action_list(1, result2.actions)
2188+
assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_COMPLETED
2189+
output = json.loads(complete_action.result.value)
2190+
assert output == (
2191+
"Operation 'set' on entity '@counter@myCounter' failed with error: Something went wrong!"
2192+
)
2193+
2194+
2195+
def test_entity_call_failure_propagated_over_old_protocol_exception_type():
2196+
"""A WebJobs "old protocol" failure carries the message in the serialized
2197+
``result`` field, while ``exceptionType`` is only the exception type name.
2198+
The surfaced message must come from ``result``, not ``exceptionType``."""
2199+
test_entity_id = entities.EntityInstanceId("Counter", "myCounter")
2200+
2201+
def orchestrator(ctx: task.OrchestrationContext, _):
2202+
try:
2203+
yield ctx.call_entity(test_entity_id, "set", 1)
2204+
except task.TaskFailedError as e:
2205+
return e.details.message
2206+
return "no error"
2207+
2208+
registry = worker._Registry()
2209+
name = registry.add_orchestrator(orchestrator)
2210+
2211+
started_events = [
2212+
helpers.new_orchestrator_started_event(),
2213+
helpers.new_execution_started_event(name, TEST_INSTANCE_ID, None),
2214+
]
2215+
2216+
executor = worker._OrchestrationExecutor(registry, TEST_LOGGER, JsonDataConverter())
2217+
result1 = executor.execute(TEST_INSTANCE_ID, [], started_events)
2218+
actions = result1.actions
2219+
assert len(actions) == 1
2220+
request_id = actions[0].sendEntityMessage.entityOperationCalled.requestId
2221+
2222+
# Real WebJobs shape: message serialized into "result"; "exceptionType" is the
2223+
# (assembly-qualified) exception type name and must NOT be used as the message.
2224+
response_message = {
2225+
"result": json.dumps("Something went wrong!"),
2226+
"exceptionType": "System.InvalidOperationException, mscorlib",
2227+
}
2228+
new_events = [
2229+
helpers.new_event_sent_event(1, str(test_entity_id), json.dumps({"id": request_id})),
2230+
helpers.new_event_raised_event(request_id, json.dumps(response_message)),
2231+
]
2232+
result2 = executor.execute(TEST_INSTANCE_ID, started_events, new_events)
2233+
complete_action = get_and_validate_complete_orchestration_action_list(1, result2.actions)
2234+
assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_COMPLETED
2235+
output = json.loads(complete_action.result.value)
2236+
assert output == (
2237+
"Operation 'set' on entity '@counter@myCounter' failed with error: Something went wrong!"
2238+
)
2239+
# Guard against the regression of surfacing the type name instead of the message.
2240+
assert "InvalidOperationException" not in output
2241+
2242+
2243+
def test_entity_call_success_over_old_protocol():
2244+
"""A successful entity operation delivered via the legacy protocol must
2245+
complete the call_entity task with the unwrapped result."""
2246+
test_entity_id = entities.EntityInstanceId("Counter", "myCounter")
2247+
2248+
def orchestrator(ctx: task.OrchestrationContext, _):
2249+
return (yield ctx.call_entity(test_entity_id, "get", return_type=int))
2250+
2251+
registry = worker._Registry()
2252+
name = registry.add_orchestrator(orchestrator)
2253+
2254+
started_events = [
2255+
helpers.new_orchestrator_started_event(),
2256+
helpers.new_execution_started_event(name, TEST_INSTANCE_ID, None),
2257+
]
2258+
2259+
executor = worker._OrchestrationExecutor(registry, TEST_LOGGER, JsonDataConverter())
2260+
result1 = executor.execute(TEST_INSTANCE_ID, [], started_events)
2261+
actions = result1.actions
2262+
assert len(actions) == 1
2263+
request_id = actions[0].sendEntityMessage.entityOperationCalled.requestId
2264+
2265+
response_message = {"result": json.dumps(42)}
2266+
new_events = [
2267+
helpers.new_event_sent_event(1, str(test_entity_id), json.dumps({"id": request_id})),
2268+
helpers.new_event_raised_event(request_id, json.dumps(response_message)),
2269+
]
2270+
result2 = executor.execute(TEST_INSTANCE_ID, started_events, new_events)
2271+
complete_action = get_and_validate_complete_orchestration_action_list(1, result2.actions)
2272+
assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_COMPLETED
2273+
assert json.loads(complete_action.result.value) == 42
2274+
2275+
2276+
@pytest.mark.parametrize("entity_result", ["done", 42, 3.5, True, {"a": 1}, [1, 2, 3]])
2277+
def test_entity_call_success_over_old_protocol_round_trips_result(entity_result):
2278+
"""The legacy-protocol ``result`` field holds a *serialized* JSON string, so
2279+
the caller must receive the fully deserialized value regardless of type and
2280+
without needing an explicit ``return_type`` (i.e. no double-encoding)."""
2281+
test_entity_id = entities.EntityInstanceId("Counter", "myCounter")
2282+
2283+
def orchestrator(ctx: task.OrchestrationContext, _):
2284+
return (yield ctx.call_entity(test_entity_id, "get"))
2285+
2286+
registry = worker._Registry()
2287+
name = registry.add_orchestrator(orchestrator)
2288+
2289+
started_events = [
2290+
helpers.new_orchestrator_started_event(),
2291+
helpers.new_execution_started_event(name, TEST_INSTANCE_ID, None),
2292+
]
2293+
2294+
executor = worker._OrchestrationExecutor(registry, TEST_LOGGER, JsonDataConverter())
2295+
result1 = executor.execute(TEST_INSTANCE_ID, [], started_events)
2296+
actions = result1.actions
2297+
assert len(actions) == 1
2298+
request_id = actions[0].sendEntityMessage.entityOperationCalled.requestId
2299+
2300+
# The entity worker places the *serialized* return value into the "result" field.
2301+
response_message = {"result": json.dumps(entity_result)}
2302+
new_events = [
2303+
helpers.new_event_sent_event(1, str(test_entity_id), json.dumps({"id": request_id})),
2304+
helpers.new_event_raised_event(request_id, json.dumps(response_message)),
2305+
]
2306+
result2 = executor.execute(TEST_INSTANCE_ID, started_events, new_events)
2307+
complete_action = get_and_validate_complete_orchestration_action_list(1, result2.actions)
2308+
assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_COMPLETED
2309+
assert json.loads(complete_action.result.value) == entity_result
2310+
2311+
21422312
def get_and_validate_complete_orchestration_action_list(expected_action_count: int, actions: list[pb.OrchestratorAction]) -> pb.CompleteOrchestrationAction:
21432313
assert len(actions) == expected_action_count
21442314
assert type(actions[-1]) is pb.OrchestratorAction

0 commit comments

Comments
 (0)