Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 11 additions & 7 deletions azure/durable_functions/models/TaskOrchestrationExecutor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
121 changes: 116 additions & 5 deletions tests/orchestrator/test_serialization.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,34 @@
import json
from dataclasses import dataclass

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 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."""

Expand All @@ -28,4 +47,96 @@ def test_serialization_of_False():
expected["output"] = False

assert_valid_schema(result)
assert_orchestration_state_equals(expected, result)
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")


@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(
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)
Loading