Performance [P3]: Serialize history events in one pass - #201
Conversation
HistoryEvent.to_dict() called _to_serializable(asdict(self)). asdict() deep-copies the whole event graph into a throwaway structure, which _to_serializable() then walked again. That is two full traversals plus a discarded allocation per event, multiplied across every event in a history export. Walk the dataclass fields directly instead: _to_serializable() now converts dataclass instances itself, field-name tuples are cached per type, and exactly-JSON-native scalars take a fast path. The dataclass check mirrors the dataclasses._is_dataclass_instance predicate asdict() used, so dataclass types stay leaves and only instances recurse. Output is unchanged: same keys, ordering, nesting, isoformat timestamps, and handling of None/enums/nested dataclasses/lists/dicts. Add an equivalence suite pinning to_dict() against the old two-pass form across every HistoryEvent subclass. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6322dc7f-81ee-4d42-be2d-cef84cf62d15
There was a problem hiding this comment.
Pull request overview
This PR optimizes history event serialization in the core Durable Task Python SDK by removing the two-pass asdict() + walker approach and replacing it with a single-pass serializer that walks dataclass fields directly, aiming to reduce allocations and CPU time when exporting large histories.
Changes:
- Reworked
HistoryEvent.to_dict()and_to_serializable()to serialize dataclass instances directly (single pass) and cache per-type field name tuples. - Added a fast-path for JSON-native scalar types to reduce overhead in common cases.
- Added a comprehensive equivalence test suite that pins the new output to the legacy
asdict()-based behavior across all exportedHistoryEventtypes.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
durabletask/history.py |
Replaces asdict()-based serialization with a cached, single-pass dataclass-aware serializer for history events. |
tests/durabletask/test_history_serialization.py |
Adds an oracle-based equivalence and isolation test suite to ensure to_dict() remains behavior-preserving. |
…performance-p3-serialize-history-events-8f7a1a
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
…performance-p3-serialize-history-events-8f7a1a
…performance-p3-serialize-history-events-8f7a1a
…performance-p3-serialize-history-events-8f7a1a
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
durabletask/history.py:389
- In the dict branch, values are rebuilt but keys are passed through unchanged. This breaks the stated isolation guarantee (“result never shares mutable state with the event”) for non-primitive / user-supplied keys, since keys will still alias the original objects (the legacy
asdict(...); _to_serializable(...)path deep-copied dict keys as part ofasdict). Deep-copying keys here keeps behavior closer to the legacy implementation without affecting the SDK’s normalstrkeys.
if isinstance(value, dict):
return {
key: _to_serializable(item)
for key, item in cast(dict[Any, Any], value).items()
}
…performance-p3-serialize-history-events-8f7a1a
There was a problem hiding this comment.
Requesting changes because this PR is described as a performance-only optimization, but the new walker changes serializer behavior for customer-supplied values.
Please keep this PR scoped to serializing history events in one pass while preserving the legacy asdict() + _to_serializable() contract for every input that previously succeeded. Exact built-in fast paths are fine, but subclasses, custom containers, and mapping keys need compatibility handling.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
durabletask/history.py:389
- In the dict branch, only values are rebuilt/serialized; keys are passed through by reference. This breaks the stated “never shares mutable state” guarantee for hashable-but-mutable key objects, and diverges from
dataclasses.asdict(), which deep-copies dict keys during recursion. Consider deep-copying keys when rebuilding the dict.
if isinstance(value, dict):
return {
key: _to_serializable(item)
for key, item in cast(dict[Any, Any], value).items()
}
durabletask/history.py:337
_FIELD_NAMEScaches dataclass field-name tuples in a module-level dict keyed by class objects. Because this holds strong references to classes, it can keep dynamically-created dataclass types alive and grow without bound in long-running processes that serialize many distinct dataclass types (e.g., user-provided payloads). Consider using aweakref.WeakKeyDictionary(or a bounded cache) to avoid retaining classes indefinitely.
# Field names are looked up once per dataclass type. History export walks
# many events of the same handful of types, so caching avoids repeatedly
# rebuilding the tuple returned by ``dataclasses.fields``.
_FIELD_NAMES: dict[type[Any], tuple[str, ...]] = {}
The single-pass walker replaced dataclasses.asdict, but a lot of compatibility behavior lived inside asdict rather than in the conversion pass that followed it: container subclasses were rebuilt through their own constructors, mapping keys were recursed, namedtuples and defaultdict were special-cased, and every leaf was deep-copied. Walking values in place silently dropped all of that. Restrict the inline fast paths to exact built-in types, where walking in place is provably identical to what asdict produced, and hand every other value back to the real asdict via a throwaway box dataclass. Delegating rather than reimplementing matters because those internals have shifted between releases and this package supports 3.10 through 3.14. Also remove the tuple branch added earlier. Normalizing datetimes inside tuples is a real bug fix, but a user-visible one, so it does not belong in a performance change. Tuples now route to the compatibility path and come back exactly as they did before, quirk included. Bound the field-name cache with functools.lru_cache so dynamically created dataclass types cannot pin an unbounded number of entries. It measures identically to the unbounded dict (0.0204s vs 0.0206s per 200k lookups) while dropping the cache entirely is 9.4x slower, so the cache stays but is now capped. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6322dc7f-81ee-4d42-be2d-cef84cf62d15
|
Thanks for the review — the framing was the useful part, and I agree with it. Most of the compatibility behavior of the old pipeline lived inside Rather than reimplement The tuple branch is removed outright rather than repaired, per your point that it belongs in a dedicated bug-fix PR. Two things worth flagging because they changed how I tested this:
Both fixtures were strengthened before the fix landed. The new tests fail 30 times against the previous revision and pass now.
Re-benchmarked as requested: still 6.1x, in fact marginally faster than before, since removing the tuple branch removed a check from the hot path. The compatibility work costs nothing on real payloads because those paths are unreachable — CHANGELOG left alone: with the tuple branch gone there is no user-visible behavior change left to document. |
| An earlier revision of this change added a dedicated tuple branch that | ||
| normalized datetimes inside tuples. That was a behavior change rather | ||
| than a performance change, so it was removed: tuples now route to the | ||
| compatibility path and come back exactly as they did before. The | ||
| quirk that a datetime nested in a tuple is left unconverted is |
There was a problem hiding this comment.
Already reconciled — in the direction of your first option, matching the preserved-quirk behavior.
That text was from an earlier revision. The tuple branch was removed after review feedback that normalizing datetimes inside tuples is user-visible behavior and does not belong in a performance-only change, and the description was rewritten at the same time. It currently reads:
The tuple branch is removed entirely. Normalizing datetimes inside tuples is a real bug — the legacy output for those payloads cannot be JSON encoded — but it is user-visible behavior, so it does not belong in a performance change and will be raised separately. Tuples now route through the compatibility path and come back exactly as before, quirk included.
So description and implementation agree: tuples are unchanged from main. These tests are the intended guard rather than a contradiction — test_tuple_datetimes_remain_unencodable_like_legacy pins the quirk so the follow-up fix has to be a deliberate act rather than an accidental side effect of some later refactor.
| def _to_serializable(value: Any) -> Any: | ||
| """Recursively convert *value* into a JSON-safe structure. | ||
|
|
||
| This walks dataclass instances directly instead of going through |
There was a problem hiding this comment.
Agreed, and fixed in 15c29eb.
The wording predates the compatibility rework and was accurate when written — at that point the walker converted everything it touched. Once tuples and other exotic shapes started routing back through _legacy_compat to preserve their original behavior, "JSON-safe" became a promise the function no longer keeps. A tuple holding a datetime is the clearest counterexample: it comes back with the datetime intact and json.dumps rejects it, exactly as before this PR.
The docstring now says which values are JSON-native and which are deliberately left alone:
Values the SDK itself produces convert to JSON-native types. Arbitrary values can also arrive through
dict[str, Any]fields, and those keep whatever the original pipeline did with them -- which for a few shapes, such as a tuple holding adatetime, is not JSON-encodable. That is preserved on purpose rather than fixed here; see_legacy_compat.
Worth being explicit that this is a docstring correction, not a behavior change: the non-encodable output for those shapes is what main produces today, and preserving it is the point of the compatibility path.
`dataclasses.asdict` rebuilt every dict subclass with `type(obj)(<generator>)` until 3.12, which `defaultdict` rejects with a TypeError; 3.12 added a branch forwarding `default_factory`. The legacy pipeline therefore raises for a `defaultdict` on 3.10 and 3.11 and succeeds from 3.12 on. The walker delegates to the running interpreter's `asdict`, so it already reproduces both behaviors exactly -- the parity test asserting they fail the same way passes on every version. Only the strict-equality test was wrong: it assumed the legacy pipeline always produces a value to compare against. It now skips when legacy cannot serialize the payload at all, leaving that case to the parity test. Adds a named regression test for the split, since hand-rolling `_asdict_inner`'s branches would succeed on 3.10 and 3.11 where the legacy pipeline raises, and that divergence would only surface on one CI matrix leg. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6322dc7f-81ee-4d42-be2d-cef84cf62d15
|
Follow-up: the 3.10 and 3.11 legs went red on the previous push, and the cause is worth recording here because it is direct evidence for the design choice above.
3.12 added a dedicated branch forwarding The implementation was not at fault and is unchanged. Because it delegates to the running interpreter's This is the failure mode that argued against hand-rolling Verified locally on 3.11 and 3.13, either side of the change, in addition to CI: 156 passed / 1 skipped on 3.11, 157 passed on 3.13. Head is |
|
Benchmark correction, since I quoted a specific figure above. My earlier number came from a harness that re-implemented both walkers side by side. That is fine for isolating one branch, but it measures a transcription rather than the code under review, and the transcription had drifted — it still carried the tuple branch this PR no longer has. Re-measured by importing 6.02x, not the 6.1x I quoted. The compatibility fallback costs nothing measurable because it never executes on this corpus — every value reaches an exact-type fast path, which is the expected result given PR description updated to the measured figures. |
andystaples
left a comment
There was a problem hiding this comment.
The previous findings are addressed and the PR is substantially closer to a performance-only change. One compatibility gap remains: applying the legacy fallback independently to child values does not preserve the old pipeline's ordering (asdict(event) completes before _to_serializable begins).
This can turn a previously successful customer value into an exception, so I think it still needs to be resolved before approval. Please use the complete legacy pipeline for an event/container whenever its nested graph is not wholly eligible for the exact-type fast paths, and add coverage for interactions among multiple custom values rather than only isolated leaves.
The docstring promised a "JSON-safe structure", which the function does not guarantee: values reached through dict[str, Any] fields keep their original behavior, and a few shapes -- a tuple holding a datetime, for one -- were never JSON-encodable. Say that plainly rather than implying every output can be encoded. The list and dict branches test value_type rather than the value, so the value is never narrowed and iterating it directly is already clean under strict type checking. The casts there did nothing but call typing.cast on every container. The cast on the type lookup is load-bearing and now says so. type(value) is type[Unknown] to a checker because value is Any. Two details that look like noise are deliberate: the quoted annotation avoids building a throwaway GenericAlias per call, and the lookup stays type(value) rather than the cheaper value.__class__ because asdict dispatched on type(obj). Dispatching on __class__ would let an object claiming to be a str take the JSON-native fast path and be returned by reference, where the original pipeline deep-copied it. A regression test pins that, and fails if the swap is made. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6322dc7f-81ee-4d42-be2d-cef84cf62d15
asdict rebuilt a mapping's keys and collapsed any that compared equal
afterwards, and only then did the conversion pass run. An entry that lost
such a collision was therefore discarded before its value was ever
converted.
The single-pass walker fused those two phases, converting each value as it
iterated, so it visited entries the original pipeline had already dropped.
That is observable whenever converting the discarded value raises or has a
side effect: with two keys that deep-copy to the same string and a datetime
subclass whose isoformat raises, the original returns {'same-key':
'survivor'} while the walker propagated the exception.
Only a key that asdict would rebuild can collide -- native keys pass
through untouched and a mapping's keys are already distinct -- so the
presence of one non-native key hands the whole mapping to the legacy path.
The check completes before any value is converted, because bailing out
partway would already have visited earlier entries.
Mappings with native keys, which is every mapping the SDK produces, stay
on the fast path; the speedup is unchanged at 6.2x.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6322dc7f-81ee-4d42-be2d-cef84cf62d15
…e-history-events-8f7a1a
| # ``asdict`` rebuilt keys and collapsed any that compared equal | ||
| # afterwards, and only then did the conversion pass run, so a value | ||
| # whose entry lost a collision was never converted. Converting | ||
| # inline would visit those dropped entries, which is observable if | ||
| # conversion raises or has side effects. Only a key that ``asdict`` | ||
| # would rebuild can collide -- native keys pass through untouched | ||
| # and a mapping's keys are already distinct -- so the presence of | ||
| # one is the signal to hand the whole mapping to the legacy path. | ||
| # This is checked before any value is converted, because bailing | ||
| # out partway would already have visited earlier entries. |
Summary
HistoryEvent.to_dict()was implemented as_to_serializable(asdict(self)).dataclasses.asdict()recursively deep-copies the entire event graph into a throwaway intermediate structure, and_to_serializable()then walked that copy again to convert datetimes and rebuild containers. That is two full traversals plus a discarded allocation per event, multiplied across every event in a history export.This replaces it with a single-pass recursive serializer that reads dataclass fields directly:
_to_serializable()now handles dataclass instances itself (converting them to dicts in declaration order) instead of relying onasdict()to pre-flatten them.str,int,float,bool,None) before any further checks — most event fields hit it.functools.lru_cache, since history export walks many events of the same handful of types.The dataclass check mirrors the private
dataclasses._is_dataclass_instancepredicate thatasdict()used (hasattr(type(v), '__dataclass_fields__')), so dataclass types remain leaves and only instances recurse.Preserving the
asdictcontractReview correctly caught that the first version of this change did more than optimize. Most of the compatibility behavior of the old pipeline lived inside
asdict, not in the_to_serializablepass that followed it, and walking values in place silently dropped it.asdictrebuildslist/tuple/dictsubclasses through their own constructors, recurses into mapping keys, special-cases namedtuples anddefaultdict, andcopy.deepcopys every leaf.The fix is to make the inline fast paths exact-type rather than
isinstance, so they apply only where walking in place is provably identical to the old output, and to hand every other value back to the realasdict:Boxing a single value in a throwaway dataclass re-enters the interpreter's own
asdict, so subclass constructor protocols, key recursion,defaultdict, namedtuples and leaf deep-copying all behave exactly as before. Delegating rather than reimplementing matters because those internals have shifted between releases and this package supports 3.10 through 3.14 — a hand-rolled copy risks divergence that only shows up on one CI matrix leg.That is not hypothetical, and this PR hit it. Until 3.12,
_asdict_innerrebuilt everydictsubclass withtype(obj)(<generator>), whichdefaultdictrejects:3.12 added a dedicated branch that forwards
default_factory. So the legacy pipeline raises for adefaultdicton 3.10 and 3.11 and succeeds from 3.12 on. Delegation reproduces both for free — the parity test asserting the walker fails the same way legacy does passes on every version. Had the branches been hand-rolled from a 3.13 reading of_asdict_inner, the walker would have happily serialized a value that the code it replaced could not, on exactly two of the five matrix legs.Concretely, versus the first revision of this PR:
datetimesubclass overriding__deepcopy__listsubclassdictsubclass__deepcopy__→strjson.dumpsraisesTypeErrorThe tuple branch is removed entirely. Normalizing datetimes inside tuples is a real bug — the legacy output for those payloads cannot be JSON encoded — but it is user-visible behavior, so it does not belong in a performance change and will be raised separately. Tuples now route through the compatibility path and come back exactly as before, quirk included.
TestTupleHandling::test_tuple_datetimes_remain_unencodable_like_legacypins the current behavior so that follow-up has to be deliberate rather than accidental.Cost
None. These paths are unreachable for SDK-produced events, so they were latent rather than live: the only
dict[str, Any]field on any event isHistoryStateEvent.orchestration_state, populated exclusively by_message_to_dict→json_format.MessageToDict(...), which yields JSON-native values only. Re-benchmarked against the shipped module after the change, the walker is 6.0x faster than the original — the compatibility fallback never executes on the real corpus.The field-name cache is now bounded (
lru_cache(maxsize=256)) so that arbitrary user dataclass types nested underorchestration_statecannot grow it without limit. Measured over 200k lookups, best of 5: unbounded dict0.0206s,lru_cache0.0204s, no cache0.1942s. The bound is free; removing the cache is not an option at 9.4x slower.Output compatibility
Output is unchanged for every input that previously succeeded — same keys, same field ordering, same nesting, same
datetime.isoformat()timestamps, same handling ofNone, enums, nested dataclasses, lists, and dicts — and inputs that previously raised still raise the same exception type.to_dict()still returns freshly built containers, and as withasdict, mutating any part of the result never touches the event.No public API signature changed, nothing was renamed or removed, and no import path moved.
_to_serializablekeeps its name and signature.Because this is now a pure internal performance change with no observable behavior difference, no changelog entry was added.
Verification
tests/durabletask/test_history_serialization.py(157 tests), an equivalence suite that pinsto_dict()against a verbatim copy of the old two-pass implementation (_to_serializable(asdict(event))) used as a test oracle. It covers all 28HistoryEventsubclasses plus the base class (39 sample instances: fully populated and minimally populated variants), with nested dataclasses, nested dicts/lists, naive and aware datetimes, empty containers, and a deeply nestedorchestration_statepayload. Each event is compared with both==andrepr(), so key ordering and value types are pinned, not just equality. A guard test asserts every exportedHistoryEventsubclass is covered.TestLegacyCompatibility, a parametrized corpus of 22 exotic values — container subclasses, namedtuples,defaultdict, iterator-only tuple constructors, custom mapping keys, sets, bytearrays and custom objects — asserting both that output matches the legacy pipeline and that failures match it too. The oracle isasdictapplied to the whole event, never to an individual value, so it cannot become circular with the implementation's own delegation. Verified on 3.11 and 3.13 locally, either side of thedefaultdictbehavior change, in addition to CI's five versions.datetimesubclass overriding onlyisoformatboth pass against the broken code, so they were replaced with a non-idempotent constructor and a__deepcopy__override respectively.903 passed, 7 skipped(pytest tests/durabletask -q --ignore-glob="*_e2e.py").63 passed(test_sandboxes_extension.py,test_durabletask_grpc_interceptor.py).history_export— the downstream consumer ofto_dict()viaevent_to_dict()—76 passed, 1 skipped.flake8 .clean from the repository root, a superset of every per-directory target CI lints.pyrightstrict at the configuredpythonVersionof 3.10 reports 0 errors on both changed files.to_dict()rather than a transcription of it, so the number cannot drift from the code under review: original two-pass form1.730s, single-pass walker0.287s— 6.02x faster. Output is asserted byte-identical event by event before any timing runs.Fixes #191