Skip to content

Commit fa76422

Browse files
Bernd VerstCopilot
andcommitted
Fix tuple recursion and leaf aliasing in single-pass serializer
Review of the single-pass walker surfaced two defects against the ``dataclasses.asdict`` behavior it replaced. Aliasing: ``asdict`` ended its recursion with ``copy.deepcopy``, so the dict it returned never shared mutable state with the event. The walker fell through with a bare ``return value``, letting sets, bytearrays and custom objects be handed out by reference. A caller mutating the exported structure could reach back into the live event. Tuple recursion: ``asdict`` rebuilt tuples, converting any nested dataclass into a dict. The walker had no tuple branch, so a tuple holding a dataclass came back holding the raw instance -- a different value, and one json.dumps cannot encode. Tuples now recurse exactly like lists, with namedtuples rebuilt through their positional constructor so the concrete type survives. This deliberately diverges from the old two-pass form, which left datetimes inside tuples unconverted and so produced output that was not JSON encodable; that quirk is not reproduced. Both branches are unreachable for SDK-produced events. The only ``dict[str, Any]`` field is ``HistoryStateEvent.orchestration_state``, populated solely by ``json_format.MessageToDict``, which yields JSON-native values. The fix measures at +0.7% over the corpus, within run-to-run noise, and the walker stays 5.8x faster than the original. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6322dc7f-81ee-4d42-be2d-cef84cf62d15
1 parent ea471ec commit fa76422

2 files changed

Lines changed: 201 additions & 8 deletions

File tree

durabletask/history.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from __future__ import annotations
55

6+
import copy
67
from collections.abc import Callable
78
from dataclasses import dataclass, fields
89
from datetime import datetime, timezone
@@ -355,10 +356,10 @@ def _to_serializable(value: Any) -> Any:
355356
This walks dataclass instances directly instead of going through
356357
``dataclasses.asdict``, which would deep-copy the whole event graph
357358
into a throwaway intermediate structure that then has to be walked a
358-
second time. The output is identical to that two-pass form: nested
359-
dataclasses become dicts in field order, datetimes become ISO 8601
360-
strings, lists and dicts are rebuilt, and every other value is
361-
passed through unchanged.
359+
second time. Nested dataclasses become dicts in field order,
360+
datetimes become ISO 8601 strings, lists, tuples and dicts are
361+
rebuilt, and anything else is deep-copied so the result never shares
362+
mutable state with the event it came from.
362363
"""
363364
value_type = cast('type[Any]', type(value))
364365
if value_type in _JSON_NATIVE_TYPES:
@@ -374,12 +375,22 @@ def _to_serializable(value: Any) -> Any:
374375
return value.isoformat()
375376
if isinstance(value, list):
376377
return [_to_serializable(item) for item in cast(list[Any], value)]
378+
if isinstance(value, tuple):
379+
items = [_to_serializable(item) for item in cast(tuple[Any, ...], value)]
380+
# Namedtuples take their fields as positional arguments rather than
381+
# a single iterable, so rebuilding them needs the unpacked form.
382+
if hasattr(value_type, '_fields'):
383+
return value_type(*items)
384+
return value_type(items)
377385
if isinstance(value, dict):
378386
return {
379387
key: _to_serializable(item)
380388
for key, item in cast(dict[Any, Any], value).items()
381389
}
382-
return value
390+
# ``asdict`` ended its recursion with ``copy.deepcopy``. Keeping that
391+
# behavior means callers can freely mutate the exported structure
392+
# without reaching back into the live event.
393+
return copy.deepcopy(value)
383394

384395

385396
_EVENT_CONVERTERS: dict[str, Callable[[pb.HistoryEvent], HistoryEvent]] = {

tests/durabletask/test_history_serialization.py

Lines changed: 185 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
from __future__ import annotations
1414

1515
import json
16-
from dataclasses import asdict
16+
from dataclasses import asdict, dataclass
1717
from datetime import datetime, timezone
18-
from typing import Any, cast
18+
from typing import Any, NamedTuple, cast
1919

2020
import pytest
2121

@@ -49,6 +49,41 @@ def _failure(message: str = 'boom') -> task.FailureDetails:
4949
return task.FailureDetails(message, 'RuntimeError', 'Traceback...')
5050

5151

52+
@dataclass
53+
class _Inner:
54+
"""A dataclass the walker should convert wherever it is nested."""
55+
56+
label: str
57+
when: datetime
58+
59+
60+
class _Point(NamedTuple):
61+
"""A namedtuple, which rebuilds differently from a plain tuple."""
62+
63+
x: Any
64+
y: Any
65+
66+
67+
class _MutableLeaf:
68+
"""A value the walker cannot convert, so it must be copied out."""
69+
70+
def __init__(self, n: int) -> None:
71+
self.n = n
72+
73+
def __eq__(self, other: object) -> bool:
74+
return isinstance(other, _MutableLeaf) and other.n == self.n
75+
76+
def __repr__(self) -> str:
77+
return f'_MutableLeaf({self.n})'
78+
79+
80+
def _state_event(value: Any) -> history.HistoryStateEvent:
81+
"""Wrap ``value`` in the only ``dict[str, Any]`` field on any event."""
82+
return history.HistoryStateEvent(
83+
event_id=-1, timestamp=_TS, orchestration_state={'value': value},
84+
)
85+
86+
5287
def _trace_context() -> history.TraceContext:
5388
return history.TraceContext(
5489
trace_parent='00-trace-span-01',
@@ -270,7 +305,12 @@ def _event_ids(events: list[history.HistoryEvent]) -> list[str]:
270305

271306

272307
class TestToDictEquivalence:
273-
"""``to_dict`` must produce exactly what the old two-pass form produced."""
308+
"""``to_dict`` must produce exactly what the old two-pass form produced.
309+
310+
Tuple-bearing payloads are deliberately excluded from this suite and
311+
covered by :class:`TestTupleHandling` instead: the old two-pass form
312+
mishandled tuples, so matching it there would mean reproducing a bug.
313+
"""
274314

275315
@pytest.mark.parametrize(
276316
'event', _SAMPLE_EVENTS, ids=_event_ids(_SAMPLE_EVENTS),
@@ -392,3 +432,145 @@ def test_lock_set_list_is_rebuilt(self) -> None:
392432
payload = event.to_dict()
393433
assert payload['lock_set'] == lock_set
394434
assert payload['lock_set'] is not lock_set
435+
436+
@pytest.mark.parametrize(
437+
'leaf',
438+
[
439+
pytest.param(_MutableLeaf(1), id='custom-object'),
440+
pytest.param(bytearray(b'abc'), id='bytearray'),
441+
pytest.param({1, 2, 3}, id='set'),
442+
pytest.param((1, 'x'), id='tuple'),
443+
pytest.param(_Point(1, 2), id='namedtuple'),
444+
],
445+
)
446+
def test_mutable_leaf_is_not_aliased(self, leaf: Any) -> None:
447+
"""Values the walker does not recognize must still be copied out.
448+
449+
``dataclasses.asdict`` ended its recursion with ``copy.deepcopy``,
450+
so the dict it produced never shared mutable state with the event.
451+
The single-pass walker has to preserve that isolation, otherwise a
452+
caller mutating the exported dict would corrupt the live event.
453+
"""
454+
event = _state_event(leaf)
455+
exported = event.to_dict()['orchestration_state']['value']
456+
assert exported == leaf
457+
assert exported is not leaf
458+
459+
def test_mutating_exported_leaf_does_not_affect_event(self) -> None:
460+
leaf = _MutableLeaf(1)
461+
event = _state_event(leaf)
462+
463+
exported = event.to_dict()['orchestration_state']['value']
464+
exported.n = 999
465+
466+
assert leaf.n == 1
467+
assert event.orchestration_state['value'].n == 1
468+
469+
def test_mutating_exported_bytearray_does_not_affect_event(self) -> None:
470+
leaf = bytearray(b'abc')
471+
event = _state_event(leaf)
472+
473+
exported = event.to_dict()['orchestration_state']['value']
474+
exported.extend(b'def')
475+
476+
assert leaf == bytearray(b'abc')
477+
478+
def test_nested_mutable_leaf_is_not_aliased(self) -> None:
479+
"""Isolation must hold for leaves buried inside lists and dicts."""
480+
leaf = _MutableLeaf(7)
481+
event = _state_event({'deep': [leaf]})
482+
483+
exported = event.to_dict()['orchestration_state']['value']['deep'][0]
484+
assert exported == leaf
485+
assert exported is not leaf
486+
487+
488+
class TestTupleHandling:
489+
"""Tuples must recurse the same way lists do.
490+
491+
The old two-pass form got this wrong in a way worth spelling out.
492+
``dataclasses.asdict`` *did* rebuild tuples, converting any nested
493+
dataclass into a dict, but the ``_to_serializable`` pass that ran
494+
afterwards had no tuple branch, so datetimes sitting inside a tuple
495+
were never converted. The result was a value that could not be JSON
496+
encoded. The single-pass walker handles tuples exactly like lists,
497+
which diverges from the old output on purpose.
498+
"""
499+
500+
def test_tuple_containing_dataclass_is_converted(self) -> None:
501+
event = _state_event((_Inner('a', _TS),))
502+
exported = event.to_dict()['orchestration_state']['value']
503+
assert exported == ({'label': 'a', 'when': _TS.isoformat()},)
504+
505+
def test_datetime_inside_tuple_is_converted(self) -> None:
506+
event = _state_event((_TS,))
507+
exported = event.to_dict()['orchestration_state']['value']
508+
assert exported == (_TS.isoformat(),)
509+
510+
def test_tuple_type_is_preserved(self) -> None:
511+
event = _state_event((1, 'x'))
512+
exported = event.to_dict()['orchestration_state']['value']
513+
assert isinstance(exported, tuple)
514+
515+
def test_namedtuple_type_is_preserved(self) -> None:
516+
"""Rebuilding a tuple must not flatten a namedtuple into a plain one."""
517+
event = _state_event(_Point(1, 2))
518+
exported = event.to_dict()['orchestration_state']['value']
519+
assert isinstance(exported, _Point)
520+
assert exported == _Point(1, 2)
521+
522+
def test_namedtuple_contents_are_converted(self) -> None:
523+
event = _state_event(_Point(_TS, _Inner('b', _TS)))
524+
exported = event.to_dict()['orchestration_state']['value']
525+
assert exported == _Point(
526+
_TS.isoformat(), {'label': 'b', 'when': _TS.isoformat()},
527+
)
528+
529+
def test_tuple_nested_in_dict_and_list_is_converted(self) -> None:
530+
event = _state_event({'k': [(_Inner('c', _TS),)]})
531+
exported = event.to_dict()['orchestration_state']['value']
532+
assert exported == {'k': [({'label': 'c', 'when': _TS.isoformat()},)]}
533+
534+
def test_empty_tuple_round_trips(self) -> None:
535+
event = _state_event(())
536+
assert event.to_dict()['orchestration_state']['value'] == ()
537+
538+
@pytest.mark.parametrize(
539+
'payload',
540+
[
541+
pytest.param((_Inner('a', _TS),), id='tuple-of-dataclass'),
542+
pytest.param((_TS,), id='tuple-of-datetime'),
543+
pytest.param(_Point(_TS, 2), id='namedtuple-of-datetime'),
544+
pytest.param({'k': [(_TS,)]}, id='tuple-nested-in-dict-and-list'),
545+
],
546+
)
547+
def test_tuple_payloads_are_json_serializable(self, payload: Any) -> None:
548+
"""The old two-pass form produced tuples json.dumps could not encode."""
549+
event = _state_event(payload)
550+
json.dumps(event.to_dict())
551+
552+
@pytest.mark.parametrize(
553+
'payload',
554+
[
555+
pytest.param((_Inner('a', _TS),), id='tuple-of-dataclass'),
556+
pytest.param((_TS,), id='tuple-of-datetime'),
557+
],
558+
)
559+
def test_tuple_output_deliberately_diverges_from_legacy(
560+
self, payload: Any,
561+
) -> None:
562+
"""Pin the divergence so it stays intentional rather than accidental.
563+
564+
The legacy output is not JSON encodable for these payloads; the
565+
new output is. This test fails loudly if anyone ever "restores"
566+
bug-for-bug parity with the old two-pass form.
567+
"""
568+
event = _state_event(payload)
569+
570+
legacy = _legacy_to_dict(event)
571+
with pytest.raises(TypeError):
572+
json.dumps(legacy)
573+
574+
actual = event.to_dict()
575+
assert actual != legacy
576+
json.dumps(actual)

0 commit comments

Comments
 (0)