Skip to content

Commit 0f35c3e

Browse files
committed
Fix double-encoded call_entity result over legacy entity protocol
The legacy entity protocol delivers the operation result as a serialized JSON string inside ResponseMessage.result. The success path used coerce, which does not parse JSON strings, so string/dict/list results arrived double-encoded and only numeric results with an explicit return_type worked. Deserialize the result string (type-directed) to match the current entity protocol. Adds parametrized legacy-protocol success round-trip tests.
1 parent 94ec7a8 commit 0f35c3e

3 files changed

Lines changed: 46 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ CHANGED
1414
FIXED
1515

1616
- 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+
- 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.
1718

1819
## v1.7.0
1920

durabletask/worker.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2814,7 +2814,7 @@ def _handle_entity_event_raised(self,
28142814
entity_task = ctx._pending_tasks.pop(task_id, None) # pyright: ignore[reportPrivateUsage]
28152815
if not entity_task:
28162816
raise RuntimeError(f"Could not retrieve entity task for entity-related eventRaised with ID '{event.eventId}'")
2817-
response: Any | None = None
2817+
response: dict[str, Any] | None = None
28182818
if not ph.is_empty(event.eventRaised.input):
28192819
response = self._data_converter.deserialize(event.eventRaised.input.value)
28202820

@@ -2823,7 +2823,7 @@ def _handle_entity_event_raised(self,
28232823
# "failureDetails" field. Propagate that as a task failure so an awaiting call_entity raises,
28242824
# matching the new entity protocol and the .NET SDK.
28252825
if not is_lock_event and isinstance(response, dict):
2826-
failure_details = ph.entity_response_failure_details(cast(dict[str, Any], response))
2826+
failure_details = ph.entity_response_failure_details(response)
28272827
if failure_details is not None:
28282828
failure = EntityOperationFailedException(entity_id, operation or "", failure_details)
28292829
ctx._entity_context.recover_lock_after_call(entity_id) # pyright: ignore[reportPrivateUsage]
@@ -2833,14 +2833,13 @@ def _handle_entity_event_raised(self,
28332833

28342834
result = None
28352835
if response is not None:
2836-
# TODO: Investigate why the event result is wrapped in a dict with "result" key
2837-
# The expected type applies to the unwrapped result value, not the
2838-
# transport wrapper. Unwrap first, then coerce the already-parsed
2839-
# inner value to the expected type via the converter (no redundant
2840-
# re-serialization round-trip).
2841-
unwrapped: Any = cast(Any, response)["result"]
2842-
result = self._data_converter.coerce(
2843-
unwrapped,
2836+
# The legacy protocol wraps the result as {"result": <serialized>},
2837+
# where the value is a serialized JSON string (like the new protocol's
2838+
# entityOperationCompleted.output). Deserialize it -- not coerce -- so
2839+
# the value is fully parsed and the expected type applied; coercing
2840+
# would skip JSON parsing and leave it double-encoded (e.g. '"done"').
2841+
result = self._data_converter.deserialize(
2842+
response["result"],
28442843
entity_task._expected_type, # pyright: ignore[reportPrivateUsage]
28452844
)
28462845
if is_lock_event:

tests/durabletask/test_orchestration_executor.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2220,6 +2220,42 @@ def orchestrator(ctx: task.OrchestrationContext, _):
22202220
assert json.loads(complete_action.result.value) == 42
22212221

22222222

2223+
@pytest.mark.parametrize("entity_result", ["done", 42, 3.5, True, {"a": 1}, [1, 2, 3]])
2224+
def test_entity_call_success_over_old_protocol_round_trips_result(entity_result):
2225+
"""The legacy-protocol ``result`` field holds a *serialized* JSON string, so
2226+
the caller must receive the fully deserialized value regardless of type and
2227+
without needing an explicit ``return_type`` (i.e. no double-encoding)."""
2228+
test_entity_id = entities.EntityInstanceId("Counter", "myCounter")
2229+
2230+
def orchestrator(ctx: task.OrchestrationContext, _):
2231+
return (yield ctx.call_entity(test_entity_id, "get"))
2232+
2233+
registry = worker._Registry()
2234+
name = registry.add_orchestrator(orchestrator)
2235+
2236+
started_events = [
2237+
helpers.new_orchestrator_started_event(),
2238+
helpers.new_execution_started_event(name, TEST_INSTANCE_ID, None),
2239+
]
2240+
2241+
executor = worker._OrchestrationExecutor(registry, TEST_LOGGER, JsonDataConverter())
2242+
result1 = executor.execute(TEST_INSTANCE_ID, [], started_events)
2243+
actions = result1.actions
2244+
assert len(actions) == 1
2245+
request_id = actions[0].sendEntityMessage.entityOperationCalled.requestId
2246+
2247+
# The entity worker places the *serialized* return value into the "result" field.
2248+
response_message = {"result": json.dumps(entity_result)}
2249+
new_events = [
2250+
helpers.new_event_sent_event(1, str(test_entity_id), json.dumps({"id": request_id})),
2251+
helpers.new_event_raised_event(request_id, json.dumps(response_message)),
2252+
]
2253+
result2 = executor.execute(TEST_INSTANCE_ID, started_events, new_events)
2254+
complete_action = get_and_validate_complete_orchestration_action_list(1, result2.actions)
2255+
assert complete_action.orchestrationStatus == pb.ORCHESTRATION_STATUS_COMPLETED
2256+
assert json.loads(complete_action.result.value) == entity_result
2257+
2258+
22232259
def get_and_validate_complete_orchestration_action_list(expected_action_count: int, actions: list[pb.OrchestratorAction]) -> pb.CompleteOrchestrationAction:
22242260
assert len(actions) == expected_action_count
22252261
assert type(actions[-1]) is pb.OrchestratorAction

0 commit comments

Comments
 (0)