From 1ae3ab715c6818eb78f2341ee230e75e855f6eb5 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 31 Jul 2026 10:04:39 -0600 Subject: [PATCH 1/2] Fix custom orchestrator output serialization Encode orchestration output before nesting it in the protocol state so custom objects retain the established Durable JSON envelope and remain decodable in strict typing mode. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6463621c-77a2-4711-8cb2-c58051d43064 --- CHANGELOG.md | 9 ++ .../models/TaskOrchestrationExecutor.py | 18 +-- tests/orchestrator/test_serialization.py | 117 +++++++++++++++++- 3 files changed, 132 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96e427d5..c6870f22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ and yanked-release status are based on the ## Unreleased +### Fixed + +- Orchestrators can now return custom objects supported by the Durable Functions + JSON codec. Their output is encoded before it is nested in the orchestration + state, preserving the same class metadata envelope used by activity results + so strict-mode sub-orchestrator callers can reconstruct the declared type. + Strict-mode callers must supply `expected_type` or use a Python v2 + sub-orchestrator whose return annotation can be discovered automatically. + ## 1.7.0 - 2026-07-30 ### Added diff --git a/azure/durable_functions/models/TaskOrchestrationExecutor.py b/azure/durable_functions/models/TaskOrchestrationExecutor.py index 73fd63f4..7df75bc5 100644 --- a/azure/durable_functions/models/TaskOrchestrationExecutor.py +++ b/azure/durable_functions/models/TaskOrchestrationExecutor.py @@ -9,7 +9,7 @@ from collections import namedtuple import json from ..models.entities.ResponseMessage import ResponseMessage -from .utils.df_serialization import df_loads +from .utils.df_serialization import df_dumps, df_loads class TaskOrchestrationExecutor: @@ -326,14 +326,18 @@ def get_orchestrator_state_str(self) -> str: message contains in it the string representation of the orchestration's state """ - if (self.output is not None): + state_output = self.output + if self.output is not None: try: - # Attempt to serialize the output. If serialization fails, raise an - # error indicating that the orchestration output is not serializable, - # which is not permitted in durable Python functions. - json.dumps(self.output) + # Serialize the output before nesting it in OrchestratorState. In + # strict mode, custom objects must be top-level df_dumps values. + # Keeping their envelope in the output field preserves the wire + # format used by activity results and enables typed sub-orchestrator + # decoding without wrapping the OrchestratorState protocol itself. + state_output = json.loads(df_dumps(self.output)) except Exception as e: self.output = None + state_output = None self.exception = e exception_str = None @@ -345,7 +349,7 @@ def get_orchestrator_state_str(self) -> str: state = OrchestratorState( is_done=self.orchestration_invocation_succeeded, actions=self.context._actions, - output=self.output, + output=state_output, replay_schema=self.context._replay_schema, error=exception_str, custom_status=self.context.custom_status diff --git a/tests/orchestrator/test_serialization.py b/tests/orchestrator/test_serialization.py index d92966c1..10e4c47d 100644 --- a/tests/orchestrator/test_serialization.py +++ b/tests/orchestrator/test_serialization.py @@ -1,15 +1,33 @@ +import json +from dataclasses import dataclass + +import pytest + from azure.durable_functions.models.ReplaySchema import ReplaySchema from tests.test_utils.ContextBuilder import ContextBuilder -from .orchestrator_test_utils \ - import get_orchestration_state_result, assert_orchestration_state_equals, assert_valid_schema +from .orchestrator_test_utils import ( + assert_orchestration_state_equals, + assert_valid_schema, + get_orchestration_state_result, +) from azure.durable_functions.models.OrchestratorState import OrchestratorState +from azure.durable_functions.models.utils.df_serialization import df_loads + + +def base_expected_state( + output=None, + replay_schema: ReplaySchema = ReplaySchema.V1) -> OrchestratorState: + return OrchestratorState( + is_done=False, + actions=[], + output=output, + replay_schema=replay_schema.value) -def base_expected_state(output=None, replay_schema: ReplaySchema = ReplaySchema.V1) -> OrchestratorState: - return OrchestratorState(is_done=False, actions=[], output=output, replay_schema=replay_schema.value) def generator_function(context): return False + def test_serialization_of_False(): """Test that an orchestrator can return False.""" @@ -28,4 +46,93 @@ def test_serialization_of_False(): expected["output"] = False assert_valid_schema(result) - assert_orchestration_state_equals(expected, result) \ No newline at end of file + assert_orchestration_state_equals(expected, result) + + +@dataclass +class CustomResult: + message: str + + def to_json(self): + return {"message": self.message} + + @classmethod + def from_json(cls, data): + return cls(message=data["message"]) + + +def orchestrator_with_custom_output(context): + return CustomResult(message="Custom serialization test") + + +@pytest.mark.parametrize("strict", [False, True]) +def test_serialization_of_custom_output(monkeypatch, strict): + if strict: + monkeypatch.setenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", "true") + else: + monkeypatch.delenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", raising=False) + + result = get_orchestration_state_result( + ContextBuilder("serialize custom class"), + orchestrator_with_custom_output) + + assert_valid_schema(result) + assert result["isDone"] is True + assert result["output"] == { + "__class__": "CustomResult", + "__module__": CustomResult.__module__, + "__data__": {"message": "Custom serialization test"}, + } + + +def orchestrator_calling_typed_sub_orchestrator(context): + result = yield context.call_sub_orchestrator( + "CustomOutputOrchestrator", expected_type=CustomResult) + return result.message + + +def orchestrator_calling_untyped_sub_orchestrator(context): + yield context.call_sub_orchestrator("CustomOutputOrchestrator") + + +def test_strict_custom_output_can_be_decoded_by_sub_orchestrator(monkeypatch): + monkeypatch.setenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", "true") + child_state = get_orchestration_state_result( + ContextBuilder("serialize custom class"), + orchestrator_with_custom_output) + child_output = json.dumps(child_state["output"]) + + context_builder = ContextBuilder("consume custom class") + context_builder.add_sub_orchestrator_started_event( + name="CustomOutputOrchestrator", id_=0, input_="") + context_builder.add_orchestrator_completed_event() + context_builder.add_orchestrator_started_event() + context_builder.add_sub_orchestrator_completed_event( + result=child_output, id_=0) + + result = get_orchestration_state_result( + context_builder, orchestrator_calling_typed_sub_orchestrator) + + assert result["isDone"] is True + assert result["output"] == "Custom serialization test" + assert df_loads(child_output, expected_type=CustomResult) == CustomResult( + message="Custom serialization test") + + +def test_strict_custom_output_requires_sub_orchestrator_type(monkeypatch): + monkeypatch.setenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", "true") + child_state = get_orchestration_state_result( + ContextBuilder("serialize custom class"), + orchestrator_with_custom_output) + + context_builder = ContextBuilder("consume custom class") + context_builder.add_sub_orchestrator_started_event( + name="CustomOutputOrchestrator", id_=0, input_="") + context_builder.add_orchestrator_completed_event() + context_builder.add_orchestrator_started_event() + context_builder.add_sub_orchestrator_completed_event( + result=json.dumps(child_state["output"]), id_=0) + + with pytest.raises(TypeError, match="strict mode requires expected_type"): + get_orchestration_state_result( + context_builder, orchestrator_calling_untyped_sub_orchestrator) From 61e6f3c12ad296dc353aab8a03dc7bcbf6a19c10 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 31 Jul 2026 13:59:21 -0600 Subject: [PATCH 2/2] Gate strict serialization test on SDK support The legacy azure-functions fallback does not implement strict typing, so only assert strict decode rejection when the centralized SDK serializers are available. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6463621c-77a2-4711-8cb2-c58051d43064 --- tests/orchestrator/test_serialization.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/orchestrator/test_serialization.py b/tests/orchestrator/test_serialization.py index 10e4c47d..22113463 100644 --- a/tests/orchestrator/test_serialization.py +++ b/tests/orchestrator/test_serialization.py @@ -3,6 +3,7 @@ import pytest +import azure.functions._durable_functions as _sdk from azure.durable_functions.models.ReplaySchema import ReplaySchema from tests.test_utils.ContextBuilder import ContextBuilder from .orchestrator_test_utils import ( @@ -119,6 +120,9 @@ def test_strict_custom_output_can_be_decoded_by_sub_orchestrator(monkeypatch): message="Custom serialization test") +@pytest.mark.skipif( + not hasattr(_sdk, "df_loads"), + reason="strict typing is unavailable with the legacy SDK serializer") def test_strict_custom_output_requires_sub_orchestrator_type(monkeypatch): monkeypatch.setenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", "true") child_state = get_orchestration_state_result(