Skip to content

Commit e7fcf08

Browse files
committed
Best-effort breaking change fixes, update changelog
1 parent 0891d7e commit e7fcf08

7 files changed

Lines changed: 124 additions & 58 deletions

File tree

CHANGELOG.md

Lines changed: 54 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -52,65 +52,71 @@ ADDED
5252
uses a deterministic instance ID (`export-job-{job_id}`, exposed via
5353
`orchestrator_instance_id_for(...)`) so callers can correlate a job ID
5454
with its orchestrator for logging, monitoring, and restart.
55-
- Added a pluggable `DataConverter` abstraction (`durabletask.serialization`)
56-
consumed by both the worker and the client. `TaskHubGrpcWorker`,
57-
`TaskHubGrpcClient`, and `AsyncTaskHubGrpcClient` accept a `data_converter`
58-
argument; every payload serialization boundary (inputs, outputs, events,
55+
- Added a pluggable `DataConverter` (`durabletask.serialization`) accepted by
56+
`TaskHubGrpcWorker`, `TaskHubGrpcClient`, and `AsyncTaskHubGrpcClient` via a
57+
`data_converter` argument. Every payload boundary (inputs, outputs, events,
5958
custom status, entity state) routes through it. The default
60-
`JsonDataConverter` preserves existing behavior, so supplying a custom
61-
converter (for example, one backed by pydantic) is purely opt-in. Custom
62-
objects opt in via a `to_json()` hook -- invoked as `type(obj).to_json(obj)`
63-
so both instance methods and `@staticmethod` hooks work -- and a
64-
`from_json(value)` classmethod, matching the `azure-functions-durable`
65-
convention.
66-
- Type-aware deserialization of payloads. `OrchestrationContext.call_activity`,
67-
`call_sub_orchestrator`, and `call_entity` accept an optional `return_type`,
68-
and `wait_for_external_event` accepts an optional `data_type`. When provided,
69-
the result/event payload is coerced to that type: dataclasses are
70-
reconstructed from their dict payloads (including nested dataclass, `Optional`,
71-
and `list` fields), and types exposing a `from_json()` classmethod are rebuilt
72-
via that hook. When omitted, the raw deserialized JSON is returned as before.
73-
The `return_type` / `data_type` argument also refines the static type of the
74-
returned task (e.g. `call_activity(..., return_type=Foo)` is typed as
75-
`CompletableTask[Foo]`).
59+
`JsonDataConverter` preserves existing behavior, so a custom converter (for
60+
example one backed by pydantic) is opt-in. Custom objects can opt in via a
61+
`to_json()` hook and a `from_json(value)` classmethod.
62+
- `OrchestrationContext.call_activity`, `call_sub_orchestrator`, and
63+
`call_entity` accept an optional `return_type`, and `wait_for_external_event`
64+
accepts an optional `data_type`. When provided, the result/event payload is
65+
reconstructed as that type (dataclasses — including nested dataclass,
66+
`Optional`, and `list` fields — and `from_json()`-capable types) and the
67+
returned task is typed accordingly (e.g. `call_activity(..., return_type=Foo)`
68+
yields `CompletableTask[Foo]`). When omitted, the raw deserialized JSON is
69+
returned as before.
7670
- Inbound payloads are reconstructed from function type annotations. When an
77-
orchestrator, activity, or entity operation annotates its input parameter with
78-
a dataclass or a `from_json()`-capable type, the incoming payload is
79-
automatically coerced to that type. Coercion is best-effort: builtins and
80-
unannotated/unknown types are passed through unchanged, and a payload that
81-
cannot be coerced to the requested type falls back to the raw deserialized
82-
value (logged at debug level) rather than raising. This best-effort policy is
83-
owned by the default `JsonDataConverter`; a stricter converter can change it.
84-
- `call_activity` results are reconstructed from the activity's return
85-
annotation. When an activity function reference is passed (not a string name)
86-
and its return type is annotated with a dataclass or `from_json()`-capable
87-
type, the result is automatically coerced to that type. An explicit
88-
`return_type` argument takes precedence over the discovered annotation.
71+
orchestrator, activity, or entity operation annotates its input parameter (or
72+
an activity its return value) with a dataclass or `from_json()`-capable type,
73+
the payload is reconstructed as that type. Builtins and unannotated/unknown
74+
types are passed through unchanged. An explicit `return_type` takes precedence
75+
over a discovered annotation.
8976
- Added typed accessors to `client.OrchestrationState`: `get_input()`,
9077
`get_output()`, and `get_custom_status()` each accept an optional
91-
`expected_type` and deserialize the corresponding `serialized_*` payload,
92-
reconstructing dataclasses and `from_json()`-capable types. The raw
93-
`serialized_input` / `serialized_output` / `serialized_custom_status` string
94-
fields are retained.
78+
`expected_type` and deserialize the corresponding payload, reconstructing
79+
dataclasses and `from_json()`-capable types. The raw `serialized_*` fields are
80+
retained.
9581
- Objects exposing a `to_json()` method are now JSON-serializable when passed as
9682
activity/orchestrator inputs or outputs.
97-
- Entity state retrieval (`get_state(intended_type=...)`) now reconstructs
98-
dataclasses from their stored dict payloads and supports types exposing a
99-
`from_json()` classmethod, in addition to the existing constructor-based
83+
- Added `EntityMetadata.get_typed_state(intended_type=...)`, which deserializes
84+
the entity's persisted state and reconstructs dataclasses and
85+
`from_json()`-capable types. The existing `get_state()` is unchanged: with no
86+
argument it returns the raw serialized JSON payload, and `get_state(some_type)`
87+
applies constructor-based coercion (`some_type(raw)`).
88+
- Entity runtime state retrieval (`EntityContext.get_state(intended_type=...)` /
89+
`DurableEntity.get_state(...)`) now also reconstructs dataclasses and
90+
`from_json()`-capable types, in addition to the existing constructor-based
10091
coercion.
10192

10293
CHANGED
10394

104-
- Custom objects (dataclasses, `SimpleNamespace`) are now serialized as plain
105-
JSON without an internal type marker. Decoding without a `return_type` /
106-
`data_type` therefore yields a plain `dict` (previously a `SimpleNamespace`
107-
for marked payloads). Pass the new type arguments to reconstruct the original
108-
type. Payloads produced by older SDK versions (carrying the legacy marker)
109-
continue to deserialize, including into a `SimpleNamespace` when no type is
110-
supplied.
95+
- Custom objects (dataclasses, `SimpleNamespace`, namedtuples) are now
96+
serialized as plain JSON. Decoding such a payload *without* a type hint now
97+
yields a plain `dict` (previously a `SimpleNamespace`; a namedtuple now
98+
round-trips as a JSON array). To get the original type back, pass the new
99+
`return_type` / `data_type` arguments, annotate the consuming function's
100+
parameter or return type, or use the typed client accessors. Payloads produced
101+
by older SDK versions still deserialize — including into a `SimpleNamespace`
102+
when no type is supplied — so in-flight orchestrations continue to replay
103+
across an upgrade.
111104
- JSON serialization failures now raise a `TypeError` that chains the original
112-
error (`__cause__`) and names the offending type, making serialization issues
113-
easier to diagnose.
105+
error (`__cause__`) and names the offending type.
106+
107+
BREAKING CHANGES (type-level only — no runtime impact for typical users)
108+
109+
These changes do not alter runtime behavior, but because the package ships
110+
`py.typed`, consumers running strict type checkers (pyright/mypy) — or
111+
subclassing the public abstract types — may need to update their code:
112+
113+
- `OrchestrationContext.call_activity`, `call_sub_orchestrator`, `call_entity`,
114+
and `wait_for_external_event` gained new keyword-only parameters
115+
(`return_type` / `data_type`). Subclasses overriding these methods should add
116+
the parameter to match the base signature.
117+
- `client.OrchestrationState` gained a non-public `_data_converter` field
118+
(excluded from equality and `repr`). Code constructing `OrchestrationState`
119+
positionally should pass it via the new field or rely on its default.
114120

115121
## v1.5.0
116122

durabletask/entities/entity_metadata.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,40 @@ def get_state(self, intended_type: None = None) -> Any:
8585
...
8686

8787
def get_state(self, intended_type: type[TState] | None = None) -> TState | Any | None:
88-
"""Get the current state of the entity, optionally converting it to a specified type.
88+
"""Get the entity's raw persisted state, optionally constructor-coerced.
89+
90+
The state is held as the raw serialized JSON payload (a ``str``). With no
91+
argument the raw payload is returned unchanged; passing ``intended_type``
92+
applies the legacy constructor-based coercion (``intended_type(raw)``)
93+
and raises ``TypeError`` if that fails.
94+
95+
This preserves the pre-existing contract. To deserialize the payload and
96+
reconstruct dataclasses or ``from_json()``-capable types, use
97+
:meth:`get_typed_state` instead.
98+
"""
99+
if intended_type is None or self._state is None:
100+
return self._state
101+
102+
if isinstance(self._state, intended_type):
103+
return self._state
104+
105+
try:
106+
return intended_type(self._state) # type: ignore[call-arg]
107+
except Exception as ex:
108+
raise TypeError(
109+
f"Could not convert state of type '{type(self._state).__name__}' to '{intended_type.__name__}'"
110+
) from ex
111+
112+
@overload
113+
def get_typed_state(self, intended_type: type[TState]) -> TState | None:
114+
...
115+
116+
@overload
117+
def get_typed_state(self, intended_type: None = None) -> Any:
118+
...
119+
120+
def get_typed_state(self, intended_type: type[TState] | None = None) -> TState | Any | None:
121+
"""Deserialize the entity's persisted state, optionally reconstructing a type.
89122
90123
The state is stored as its raw serialized JSON payload and deserialized
91124
here. When ``intended_type`` is provided the payload is reconstructed as

durabletask/extensions/history_export/client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ def get_job(self, job_id: str) -> ExportJobDescription | None:
220220
meta = self._client.get_entity(entity_id, include_state=True)
221221
if meta is None:
222222
return None
223-
state = meta.get_state()
223+
state = meta.get_typed_state()
224224
if not state:
225225
return None
226226
if not isinstance(state, dict):
@@ -260,7 +260,7 @@ def list_jobs(
260260
# explicit entity-name check.
261261
if meta.id.entity != ENTITY_NAME.lower():
262262
continue
263-
raw = meta.get_state()
263+
raw = meta.get_typed_state()
264264
if not raw:
265265
logger.warning(
266266
"list_jobs: skipping export-job entity %r with no "

durabletask/internal/entity_state_shim.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,22 @@ def get_state(self, intended_type: type[TState] | None = None, default: TState |
3838
if intended_type is None:
3939
return self._current_state
4040

41-
return self._data_converter.coerce(self._current_state, intended_type)
41+
coerced = self._data_converter.coerce(self._current_state, intended_type)
42+
43+
# An explicit ``intended_type`` is a request to receive that type. The
44+
# default converter is best-effort and would silently return the raw
45+
# value on a failed coercion; restore the stricter contract here by
46+
# raising when a non-None state could not be coerced to a concrete type.
47+
# ``intended_type`` may be a typing generic (e.g. ``list[int]``) at
48+
# runtime, which is not a ``type`` instance, so the guard is required.
49+
if (self._current_state is not None
50+
and isinstance(intended_type, type) # pyright: ignore[reportUnnecessaryIsInstance]
51+
and not isinstance(coerced, intended_type)):
52+
raise TypeError(
53+
f"Could not convert state of type '{type(self._current_state).__name__}' to '{intended_type.__name__}'"
54+
)
55+
56+
return coerced
4257

4358
def set_state(self, state: Any) -> None:
4459
self._current_state = state

durabletask/internal/shared.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@
77

88
import grpc
99
import grpc.aio
10+
11+
# Backwards-compatibility re-exports. The JSON codec moved to
12+
# ``durabletask.internal.json_codec``; these aliases keep older imports from
13+
# ``durabletask.internal.shared`` working.
14+
from durabletask.internal.json_codec import ( # noqa: F401
15+
AUTO_SERIALIZED as AUTO_SERIALIZED,
16+
from_json as from_json,
17+
to_json as to_json,
18+
)
1019
from durabletask.grpc_options import GrpcChannelOptions
1120

1221
ClientInterceptor: TypeAlias = (

tests/durabletask/extensions/history_export/test_entity.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ def _create_payload() -> dict:
8787

8888

8989
def _state_dict(metadata) -> dict:
90-
state = metadata.get_state()
90+
state = metadata.get_typed_state()
9191
assert isinstance(state, dict)
9292
return state
9393

@@ -105,7 +105,7 @@ def _check() -> Optional[dict]:
105105
meta = c.get_entity(entity_id, include_state=True)
106106
if meta is None:
107107
return None
108-
state = meta.get_state()
108+
state = meta.get_typed_state()
109109
if not isinstance(state, dict):
110110
return None
111111
return state if predicate(state) else None

tests/durabletask/test_entity_executor.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,8 +177,11 @@ def from_json(cls, data):
177177
assert isinstance(result, Wrapped)
178178
assert result.n == 3
179179

180-
def test_get_state_invalid_coercion_falls_back_to_raw(self):
181-
# The default converter is best-effort: a coercion failure returns the
182-
# raw value rather than raising.
180+
def test_get_state_invalid_coercion_raises(self):
181+
# An explicit intended_type that the state cannot be coerced to raises,
182+
# restoring the pre-existing strict contract for entity state access.
183+
import pytest
184+
183185
state = StateShim("not-an-int")
184-
assert state.get_state(int) == "not-an-int"
186+
with pytest.raises(TypeError):
187+
state.get_state(int)

0 commit comments

Comments
 (0)