Skip to content

Commit 3cd8939

Browse files
committed
Address PR feedback
1 parent 2df2b20 commit 3cd8939

5 files changed

Lines changed: 82 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ CHANGED
6969
across an upgrade.
7070
- JSON serialization failures now raise a `TypeError` that chains the original
7171
error (`__cause__`) and names the offending type.
72+
- `EntityContext.get_state()` / `DurableEntity.get_state()` now return a freshly
73+
reconstructed value on every call rather than a reference to a single cached
74+
object. As a result, mutating a value returned by `get_state()` in place no
75+
longer affects the persisted entity state — write the change back with
76+
`set_state()` to persist it. The entity's state is also serialized eagerly at
77+
`set_state()` time, so a value that cannot be serialized surfaces the error
78+
inside the failing operation (which rolls back) instead of after the batch has
79+
run.
7280

7381
FIXED
7482

durabletask/internal/entity_state_shim.py

Lines changed: 51 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -14,35 +14,50 @@
1414
class StateShim:
1515
"""In-memory view of an entity's state during a batch.
1616
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.
17+
The state is held internally as its serialized JSON string at all times.
18+
The raw payload off the wire is stored verbatim; a live value supplied via
19+
:meth:`set_state` (or as a non-serialized constructor argument) is
20+
serialized immediately. Keeping a single, always-serialized representation
21+
has two consequences worth noting:
22+
23+
* Deserialization is deferred to :meth:`get_state`, so the caller's
24+
requested type reaches the data converter together with the original
25+
payload (a custom converter can deserialize the string directly into the
26+
target type), and the unmodified wire payload is handed back by
27+
:meth:`encode_state` without being re-encoded.
28+
* Serialization errors surface inside the failing operation (at
29+
:meth:`set_state`) rather than after the batch has run, so a bad write
30+
rolls back just that operation.
31+
32+
Because the held value is always the serialized form, :meth:`get_state`
33+
returns a freshly reconstructed object on every call; it does **not** return
34+
a reference to a stored live object. Mutating a value read from
35+
:meth:`get_state` therefore has no effect on the persisted state unless it
36+
is written back with :meth:`set_state`.
2837
"""
2938

3039
def __init__(self, start_state: Any, data_converter: "DataConverter | None" = None,
3140
*, 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
36-
self._current_state: Any = start_state
37-
self._current_is_serialized: bool = serialized
38-
self._checkpoint_state: Any = start_state
39-
self._checkpoint_is_serialized: bool = serialized
40-
self._operation_actions: list[pb.OperationAction] = []
41-
self._actions_checkpoint_state: int = 0
4241
if data_converter is None:
4342
from durabletask.serialization import JsonDataConverter
4443
data_converter = JsonDataConverter()
4544
self._data_converter = data_converter
45+
# The state is normalized to its serialized string form. ``is_serialized``
46+
# marks ``start_state`` as a raw payload already off the wire (stored
47+
# verbatim); otherwise a live value is serialized now. ``None`` stays
48+
# ``None`` (no persisted state).
49+
serialized_start = self._serialize(start_state, is_serialized)
50+
self._current_state: str | None = serialized_start
51+
self._checkpoint_state: str | None = serialized_start
52+
self._operation_actions: list[pb.OperationAction] = []
53+
self._actions_checkpoint_state: int = 0
54+
55+
def _serialize(self, state: Any, is_serialized: bool = False) -> str | None:
56+
if state is None:
57+
return None
58+
if is_serialized:
59+
return state
60+
return self._data_converter.serialize(state)
4661

4762
@overload
4863
def get_state(self, intended_type: type[TState], default: TState) -> TState:
@@ -60,16 +75,11 @@ def get_state(self, intended_type: type[TState] | None = None, default: TState |
6075
if self._current_state is None:
6176
return default
6277

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)
78+
# Deferred deserialization: the converter receives the raw payload
79+
# together with the requested type.
80+
if intended_type is None:
81+
return self._data_converter.deserialize(self._current_state)
82+
result = self._data_converter.deserialize(self._current_state, intended_type)
7383

7484
# An explicit ``intended_type`` is a request to receive that type. The
7585
# default converter is best-effort and would silently return the raw
@@ -80,30 +90,25 @@ def get_state(self, intended_type: type[TState] | None = None, default: TState |
8090
if (isinstance(intended_type, type) # pyright: ignore[reportUnnecessaryIsInstance]
8191
and not isinstance(result, intended_type)):
8292
raise TypeError(
83-
f"Could not convert state of type '{type(self._current_state).__name__}' to '{intended_type.__name__}'"
93+
f"Could not convert state of type '{type(result).__name__}' to '{intended_type.__name__}'"
8494
)
8595

8696
return result
8797

8898
def set_state(self, state: Any) -> None:
89-
# A value set in-process is a live Python object, not a serialized payload.
90-
self._current_state = state
91-
self._current_is_serialized = False
99+
# Serialize eagerly so the held value is always the wire form and any
100+
# serialization error surfaces here, inside the failing operation.
101+
self._current_state = self._serialize(state)
92102

93103
def encode_state(self) -> str | None:
94-
"""Serialize the current state for persistence back to the wire.
104+
"""Return the serialized current state for persistence back to the wire.
95105
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.
106+
The state is already held in serialized form, so this is the stored
107+
value verbatim: ``None`` when there is no state (which clears the
108+
persisted entity state), otherwise the JSON string. No re-encoding
109+
occurs, so a payload that was never modified round-trips unchanged.
101110
"""
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)
111+
return self._current_state
107112

108113
def add_operation_action(self, action: pb.OperationAction) -> None:
109114
self._operation_actions.append(action)
@@ -113,18 +118,14 @@ def get_operation_actions(self) -> list[pb.OperationAction]:
113118

114119
def commit(self) -> None:
115120
self._checkpoint_state = self._current_state
116-
self._checkpoint_is_serialized = self._current_is_serialized
117121
self._actions_checkpoint_state = len(self._operation_actions)
118122

119123
def rollback(self) -> None:
120124
self._current_state = self._checkpoint_state
121-
self._current_is_serialized = self._checkpoint_is_serialized
122125
self._operation_actions = self._operation_actions[:self._actions_checkpoint_state]
123126

124127
def reset(self) -> None:
125128
self._current_state = None
126-
self._current_is_serialized = False
127129
self._checkpoint_state = None
128-
self._checkpoint_is_serialized = False
129130
self._operation_actions = []
130131
self._actions_checkpoint_state = 0

durabletask/serialization.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -417,10 +417,13 @@ def _coerce_generic(value: Any, expected_type: Any, origin: Any,
417417
if len(args) == 2 and args[1] is Ellipsis:
418418
return tuple(_coerce_to_type(item, args[0], converter) for item in cast(list[Any], value))
419419
# Fixed-length ``tuple[T1, T2, ...]`` -- coerce element-wise by position.
420-
return tuple(
421-
_coerce_to_type(item, t, converter)
422-
for item, t in zip(cast(list[Any], value), args)
423-
)
420+
arr = cast(list[Any], value)
421+
if len(arr) != len(args):
422+
raise TypeError(
423+
f"Could not coerce JSON array of length {len(arr)} to "
424+
f"tuple of length {len(args)}"
425+
)
426+
return tuple(_coerce_to_type(item, t, converter) for item, t in zip(arr, args))
424427
# Other generics are returned as parsed JSON.
425428
return value
426429

tests/durabletask/test_entity_executor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,14 +269,14 @@ def test_encode_state_none_when_state_is_none(self):
269269
state = StateShim(None, is_serialized=True)
270270
assert state.encode_state() is None
271271

272-
def test_commit_preserves_serialized_flag(self):
272+
def test_commit_preserves_unmodified_payload(self):
273273
state = StateShim('{"value": 7}', is_serialized=True)
274274
state.commit()
275275
# After commit, the (unmodified) state still round-trips without
276276
# double-encoding.
277277
assert state.encode_state() == '{"value": 7}'
278278

279-
def test_rollback_restores_serialized_flag(self):
279+
def test_rollback_restores_unmodified_payload(self):
280280
state = StateShim('{"value": 7}', is_serialized=True)
281281
state.commit()
282282
state.set_state({"value": 99})

tests/durabletask/test_serialization.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,20 @@ def test_from_json_coerces_homogeneous_tuple():
364364
assert result[1] == Address("c", "d")
365365

366366

367+
def test_coerce_to_type_fixed_length_tuple_too_long_raises():
368+
# A JSON array longer than the fixed-length tuple type must fail fast rather
369+
# than silently dropping the trailing element(s).
370+
with pytest.raises(TypeError):
371+
coerce_to_type([1, 2, 3], tuple[int, int])
372+
373+
374+
def test_coerce_to_type_fixed_length_tuple_too_short_raises():
375+
# A JSON array shorter than the fixed-length tuple type must fail fast rather
376+
# than silently producing a short tuple.
377+
with pytest.raises(TypeError):
378+
coerce_to_type([1], tuple[int, int])
379+
380+
367381
# ----- coerce_to_type -----
368382

369383

0 commit comments

Comments
 (0)