Skip to content

Commit 2df2b20

Browse files
andystaplesCopilot
andcommitted
Fix custom serialization gaps from #154
Close several round-tripping gaps left by the type-aware custom serialization work in #154, without introducing new breaking changes versus 1.5.0 or any serialization-related security concerns. Serialize side: - Prefer a to_json() hook over the built-in dataclass / SimpleNamespace handling so a dataclass (or namespace) with a non-serializable field can opt in, mirroring the decode side which already prefers from_json(). - Encode dataclasses via a shallow field mapping instead of dataclasses.asdict(), so nested to_json() hooks are honored and leaf values are not deep-copied. - Serialize enum.Enum values to their underlying .value so non-int enums round-trip (IntEnum already serialized as integers). Deserialize side: - Recurse type-directed reconstruction into dict/Mapping values and tuple elements, in addition to the existing list / Optional / Union / dataclass recursion. - Optionally pass the active DataConverter to a from_json(cls, value, converter) hook so it can rebuild nested typed values the built-in recursion does not cover. Entity state: - Defer deserialization of an entity's wire state until get_state() is called, so the caller's requested type reaches the converter together with the raw payload. Track whether the held value is still the raw serialized string and pass it back through unchanged on persist to avoid double-encoding. - Replace a redundant serialize/deserialize round-trip in the legacy entity event path with converter.coerce(). Module structure / deprecation: - Merge the internal json_codec module into durabletask.serialization and make the codec functions private; the supported surface is the pluggable DataConverter. - Deprecate durabletask.internal.shared.to_json / from_json with a DeprecationWarning; they continue to work for backwards compatibility. Adds a comprehensive JsonDataConverter round-trip test suite plus targeted tests for each fix, and documents intentional limitations (multi-member Union, types needing a custom converter such as datetime/Decimal/set). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 532479d commit 2df2b20

10 files changed

Lines changed: 1446 additions & 275 deletions

File tree

CHANGELOG.md

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ ADDED
2020
`call_entity` accept an optional `return_type`, and `wait_for_external_event`
2121
accepts an optional `data_type`. When provided, the result/event payload is
2222
reconstructed as that type (dataclasses — including nested dataclass,
23-
`Optional`, and `list` fields — and `from_json()`-capable types) and the
24-
returned task is typed accordingly (e.g. `call_activity(..., return_type=Foo)`
25-
yields `CompletableTask[Foo]`). When omitted, the raw deserialized JSON is
26-
returned as before.
23+
`Optional`, `list`, `dict`/`Mapping`, and `tuple` fields — and
24+
`from_json()`-capable types) and the returned task is typed accordingly (e.g.
25+
`call_activity(..., return_type=Foo)` yields `CompletableTask[Foo]`). When
26+
omitted, the raw deserialized JSON is returned as before.
2727
- Inbound payloads are reconstructed from function type annotations. When an
2828
orchestrator, activity, or entity operation annotates its input parameter (or
2929
an activity its return value) with a dataclass or `from_json()`-capable type,
@@ -37,6 +37,15 @@ ADDED
3737
retained.
3838
- Objects exposing a `to_json()` method are now JSON-serializable when passed as
3939
activity/orchestrator inputs or outputs.
40+
- `enum.Enum` values now serialize (to their underlying `.value`) and, when a
41+
target type is supplied, deserialize back to the enum member. This covers
42+
string-valued and other non-`int` enums as activity/orchestrator/entity inputs
43+
and outputs, including as dataclass fields and inside `list` / `dict` /
44+
`tuple` containers. (`IntEnum` / `IntFlag` already serialized as integers.)
45+
- A `from_json()` classmethod may now optionally accept the active
46+
`DataConverter` as a second parameter (`from_json(cls, value, converter)`),
47+
letting it reconstruct nested typed values via `converter.coerce(...)` /
48+
`converter.deserialize(...)`. The single-argument form remains supported.
4049
- Added `EntityMetadata.get_typed_state(intended_type=...)`, which deserializes
4150
the entity's persisted state and reconstructs dataclasses and
4251
`from_json()`-capable types. The existing `get_state()` is unchanged: with no
@@ -63,11 +72,35 @@ CHANGED
6372

6473
FIXED
6574

75+
- A dataclass or `SimpleNamespace` that defines a `to_json()` hook now uses it
76+
when serialized. Previously the built-in dataclass / `SimpleNamespace`
77+
handling ran first, so the hook was ignored — and a dataclass with a field
78+
that was not JSON-serializable on its own would fail to serialize even when it
79+
provided a `to_json()` hook to handle that field. The serialize side now
80+
prefers `to_json()`, mirroring the deserialize side, which already prefers
81+
`from_json()`.
82+
- Nested `to_json()` hooks are now honored when an object is serialized inside a
83+
dataclass. Custom objects (including nested dataclasses with their own
84+
`to_json()`) are now encoded recursively instead of being flattened to their
85+
raw fields, so values that reshape themselves via `to_json()` round-trip
86+
correctly.
87+
- Type-directed deserialization now recurses into `dict`/`Mapping` values and
88+
`tuple` elements, in addition to the existing `list`, `Optional`/`Union`, and
89+
dataclass-field recursion. A `dict[str, Foo]` or `tuple[Foo, ...]` hint now
90+
reconstructs the contained `Foo` values.
6691
- Falsy entity states (`0`, `""`, `[]`, `{}`) are no longer dropped when an
6792
entity batch is persisted. Previously a falsy current state was treated as
6893
"no state" and written as `None`, effectively deleting it; only an actual
6994
`None` state now clears the persisted entity state.
7095

96+
DEPRECATED
97+
98+
- `durabletask.internal.shared.to_json` and `durabletask.internal.shared.from_json`
99+
are deprecated and now emit a `DeprecationWarning`. Use a
100+
`durabletask.serialization.DataConverter` (for example the default
101+
`JsonDataConverter`) instead. The functions continue to work for backwards
102+
compatibility.
103+
71104
BREAKING CHANGES (type-level only — no runtime impact for typical users)
72105

73106
These changes do not alter runtime behavior, but because the package ships

durabletask/internal/entity_state_shim.py

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,31 @@
1212

1313

1414
class StateShim:
15-
def __init__(self, start_state: Any, data_converter: "DataConverter | None" = None):
15+
"""In-memory view of an entity's state during a batch.
16+
17+
The state arriving from the wire is held as its raw serialized JSON string
18+
and is **not** deserialized in the constructor: deserialization is deferred
19+
until :meth:`get_state` is called, so the caller's requested type reaches the
20+
data converter together with the original payload (a custom converter can
21+
then deserialize the string directly into the target type). Once the state
22+
has been read into a Python value or replaced via :meth:`set_state`, it is
23+
held as that live object instead.
24+
25+
Tracking whether the current value is still the raw serialized string also
26+
lets :meth:`encode_state` pass an unmodified payload straight back to the
27+
wire instead of re-serializing it, which would double-encode the JSON.
28+
"""
29+
30+
def __init__(self, start_state: Any, data_converter: "DataConverter | None" = None,
31+
*, is_serialized: bool = False):
32+
# ``is_serialized`` marks ``start_state`` as a raw serialized payload
33+
# (the value off the wire) whose deserialization should be deferred. A
34+
# ``None`` state is never treated as serialized.
35+
serialized = is_serialized and start_state is not None
1636
self._current_state: Any = start_state
37+
self._current_is_serialized: bool = serialized
1738
self._checkpoint_state: Any = start_state
39+
self._checkpoint_is_serialized: bool = serialized
1840
self._operation_actions: list[pb.OperationAction] = []
1941
self._actions_checkpoint_state: int = 0
2042
if data_converter is None:
@@ -35,31 +57,53 @@ def get_state(self, intended_type: None = None, default: Any = None) -> Any:
3557
...
3658

3759
def get_state(self, intended_type: type[TState] | None = None, default: TState | None = None) -> TState | Any | None:
38-
if self._current_state is None and default is not None:
60+
if self._current_state is None:
3961
return default
4062

41-
if intended_type is None:
42-
return self._current_state
43-
44-
coerced = self._data_converter.coerce(self._current_state, intended_type)
63+
if self._current_is_serialized:
64+
# Deferred deserialization: the converter receives the raw payload
65+
# together with the requested type.
66+
if intended_type is None:
67+
return self._data_converter.deserialize(self._current_state)
68+
result = self._data_converter.deserialize(self._current_state, intended_type)
69+
else:
70+
if intended_type is None:
71+
return self._current_state
72+
result = self._data_converter.coerce(self._current_state, intended_type)
4573

4674
# An explicit ``intended_type`` is a request to receive that type. The
4775
# default converter is best-effort and would silently return the raw
4876
# value on a failed coercion; restore the stricter contract here by
4977
# raising when a non-None state could not be coerced to a concrete type.
5078
# ``intended_type`` may be a typing generic (e.g. ``list[int]``) at
5179
# runtime, which is not a ``type`` instance, so the guard is required.
52-
if (self._current_state is not None
53-
and isinstance(intended_type, type) # pyright: ignore[reportUnnecessaryIsInstance]
54-
and not isinstance(coerced, intended_type)):
80+
if (isinstance(intended_type, type) # pyright: ignore[reportUnnecessaryIsInstance]
81+
and not isinstance(result, intended_type)):
5582
raise TypeError(
5683
f"Could not convert state of type '{type(self._current_state).__name__}' to '{intended_type.__name__}'"
5784
)
5885

59-
return coerced
86+
return result
6087

6188
def set_state(self, state: Any) -> None:
89+
# A value set in-process is a live Python object, not a serialized payload.
6290
self._current_state = state
91+
self._current_is_serialized = False
92+
93+
def encode_state(self) -> str | None:
94+
"""Serialize the current state for persistence back to the wire.
95+
96+
Returns ``None`` only when the state is actually ``None`` (which clears
97+
the persisted entity state). When the current value is still the raw
98+
serialized payload (the state was never modified), it is returned
99+
unchanged to avoid double-encoding; otherwise the live value is
100+
serialized.
101+
"""
102+
if self._current_state is None:
103+
return None
104+
if self._current_is_serialized:
105+
return self._current_state
106+
return self._data_converter.serialize(self._current_state)
63107

64108
def add_operation_action(self, action: pb.OperationAction) -> None:
65109
self._operation_actions.append(action)
@@ -69,14 +113,18 @@ def get_operation_actions(self) -> list[pb.OperationAction]:
69113

70114
def commit(self) -> None:
71115
self._checkpoint_state = self._current_state
116+
self._checkpoint_is_serialized = self._current_is_serialized
72117
self._actions_checkpoint_state = len(self._operation_actions)
73118

74119
def rollback(self) -> None:
75120
self._current_state = self._checkpoint_state
121+
self._current_is_serialized = self._checkpoint_is_serialized
76122
self._operation_actions = self._operation_actions[:self._actions_checkpoint_state]
77123

78124
def reset(self) -> None:
79125
self._current_state = None
126+
self._current_is_serialized = False
80127
self._checkpoint_state = None
128+
self._checkpoint_is_serialized = False
81129
self._operation_actions = []
82130
self._actions_checkpoint_state = 0

durabletask/internal/json_codec.py

Lines changed: 0 additions & 183 deletions
This file was deleted.

0 commit comments

Comments
 (0)