Skip to content

Performance [P3]: Serialize history events in one pass - #201

Merged
berndverst merged 12 commits into
mainfrom
berndverst-issue-191-performance-p3-serialize-history-events-8f7a1a
Jul 27, 2026
Merged

Performance [P3]: Serialize history events in one pass#201
berndverst merged 12 commits into
mainfrom
berndverst-issue-191-performance-p3-serialize-history-events-8f7a1a

Conversation

@berndverst

@berndverst berndverst commented Jul 27, 2026

Copy link
Copy Markdown
Member

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 on asdict() to pre-flatten them.
  • A fast path returns exactly-JSON-native values (str, int, float, bool, None) before any further checks — most event fields hit it.
  • Field-name tuples are cached per dataclass type via functools.lru_cache, since history export walks many events of the same handful of types.

The dataclass check mirrors the private dataclasses._is_dataclass_instance predicate that asdict() used (hasattr(type(v), '__dataclass_fields__')), so dataclass types remain leaves and only instances recurse.

Preserving the asdict contract

Review 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_serializable pass that followed it, and walking values in place silently dropped it. asdict rebuilds list/tuple/dict subclasses through their own constructors, recurses into mapping keys, special-cases namedtuples and defaultdict, and copy.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 real asdict:

@dataclass
class _LegacyBox:
    value: Any

def _asdict_only(value):
    return asdict(_LegacyBox(value))['value']

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_inner rebuilt every dict subclass with type(obj)(<generator>), which defaultdict rejects:

TypeError: first argument must be callable or None

3.12 added a dedicated branch that forwards default_factory. So the legacy pipeline raises for a defaultdict on 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:

input first revision now
datetime subclass overriding __deepcopy__ converted from the original converted from the copy, as before
list subclass constructor skipped constructor re-runs, as before
dict subclass constructor skipped, keys reused constructor re-runs, keys recursed
mapping key with __deepcopy__str left in place, json.dumps raises deep-copied, serializes again
tuple subclass with iterator-only constructor TypeError rebuilt from a generator, as before
unrecognized mutable leaf returned by reference deep-copied

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. TestTupleHandling::test_tuple_datetimes_remain_unencodable_like_legacy pins 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 is HistoryStateEvent.orchestration_state, populated exclusively by _message_to_dictjson_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 under orchestration_state cannot grow it without limit. Measured over 200k lookups, best of 5: unbounded dict 0.0206s, lru_cache 0.0204s, no cache 0.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 of None, 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 with asdict, 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_serializable keeps its name and signature.

Because this is now a pure internal performance change with no observable behavior difference, no changelog entry was added.

Verification

  • Added tests/durabletask/test_history_serialization.py (157 tests), an equivalence suite that pins to_dict() against a verbatim copy of the old two-pass implementation (_to_serializable(asdict(event))) used as a test oracle. It covers all 28 HistoryEvent subclasses 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 nested orchestration_state payload. Each event is compared with both == and repr(), so key ordering and value types are pinned, not just equality. A guard test asserts every exported HistoryEvent subclass is covered.
  • Added 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 is asdict applied 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 the defaultdict behavior change, in addition to CI's five versions.
  • RED → GREEN: these tests were confirmed failing against the previous revision before the fix landed — 30 failures, covering every point raised in review. Two fixtures had to be strengthened first: an idempotent subclass constructor and a datetime subclass overriding only isoformat both pass against the broken code, so they were replaced with a non-idempotent constructor and a __deepcopy__ override respectively.
  • Mutation-checked the equivalence assertions to confirm they have teeth: replacing the serializer with the identity function, and reversing field order, are each detected on all 39 sample events.
  • Core unit tests: 903 passed, 7 skipped (pytest tests/durabletask -q --ignore-glob="*_e2e.py").
  • Provider unit tests: 63 passed (test_sandboxes_extension.py, test_durabletask_grpc_interceptor.py).
  • history_export — the downstream consumer of to_dict() via event_to_dict()76 passed, 1 skipped.
  • flake8 . clean from the repository root, a superset of every per-directory target CI lints.
  • pyright strict at the configured pythonVersion of 3.10 reports 0 errors on both changed files.
  • Benchmarked over the 39-event corpus (2000 iterations, best of 7), timing the real to_dict() rather than a transcription of it, so the number cannot drift from the code under review: original two-pass form 1.730s, single-pass walker 0.287s6.02x faster. Output is asserted byte-identical event by event before any timing runs.

Fixes #191

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
Copilot AI review requested due to automatic review settings July 27, 2026 06:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 exported HistoryEvent types.

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.

Comment thread durabletask/history.py
Bernd Verst and others added 2 commits July 27, 2026 09:35
…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
Copilot AI review requested due to automatic review settings July 27, 2026 16:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment thread durabletask/history.py Outdated
Comment thread durabletask/history.py Outdated
…performance-p3-serialize-history-events-8f7a1a
Copilot AI review requested due to automatic review settings July 27, 2026 17:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

…performance-p3-serialize-history-events-8f7a1a
Copilot AI review requested due to automatic review settings July 27, 2026 18:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread durabletask/history.py Outdated
…performance-p3-serialize-history-events-8f7a1a
Copilot AI review requested due to automatic review settings July 27, 2026 19:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 of asdict). Deep-copying keys here keeps behavior closer to the legacy implementation without affecting the SDK’s normal str keys.
    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

@andystaples andystaples left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread durabletask/history.py
Comment thread durabletask/history.py Outdated
Comment thread durabletask/history.py Outdated
Comment thread durabletask/history.py Outdated
Copilot AI review requested due to automatic review settings July 27, 2026 20:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_NAMES caches 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 a weakref.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
Copilot AI review requested due to automatic review settings July 27, 2026 20:44
@berndverst

Copy link
Copy Markdown
Member Author

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 asdict rather than in the _to_serializable pass that followed it, and walking values in place quietly dropped it. That is a behavior change riding along with a performance change, which is not what this PR is for.

Rather than reimplement _asdict_inner's branches, the fast paths are now exact-type checks and everything else is handed back to the real asdict through a throwaway box dataclass. That inherits whatever the running interpreter does, which matters given CI spans 3.10 through 3.14 and those internals have moved between releases.

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:

  • My first regression test for the datetime subclass case passed against the broken code. Overriding only isoformat is not enough, since calling it on the original and on a deep copy agree. It only goes red with a __deepcopy__ override, which is the case you actually named.
  • Same trap for the container subclasses: a constructor that only dedupes or uppercases is idempotent, so skipping it is invisible. The fixtures now append a marker each time they run, making the skipped constructor directly observable.

Both fixtures were strengthened before the fix landed. The new tests fail 30 times against the previous revision and pass now.

TestLegacyCompatibility is the durable artifact you asked for: a parametrized corpus of exotic inputs asserting the walker matches _to_serializable(asdict(...)), including matching failures, so the contract is executable and the next optimization cannot silently break it. The oracle applies asdict to the whole event rather than to individual values, so it never becomes circular with the implementation's own delegation.

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 — orchestration_state is fed exclusively by MessageToDict, which emits JSON-native types only.

CHANGELOG left alone: with the tuple branch gone there is no user-visible behavior change left to document.

@berndverst
berndverst requested a review from andystaples July 27, 2026 20:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment on lines +747 to +751
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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread durabletask/history.py
Comment on lines +391 to +394
def _to_serializable(value: Any) -> Any:
"""Recursively convert *value* into a JSON-safe structure.

This walks dataclass instances directly instead of going through

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 a datetime, 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
Copilot AI review requested due to automatic review settings July 27, 2026 20:56
@berndverst

Copy link
Copy Markdown
Member Author

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.

dataclasses.asdict rebuilt every dict subclass with type(obj)(<generator>) until 3.12, and defaultdict rejects that:

TypeError: first argument must be callable or None

3.12 added a dedicated branch forwarding default_factory. So the legacy pipeline raises for a defaultdict on 3.10 and 3.11 and succeeds from 3.12 on.

The implementation was not at fault and is unchanged. Because it delegates to the running interpreter's asdict, it reproduced both behaviors exactly — test_exotic_payload_raises_the_same_way_as_legacy[defaultdict] passed on 3.10, which is the assertion that actually matters. What failed was my strict-equality test, which assumed the legacy pipeline always yields a value to compare against. It now skips when legacy cannot serialize the payload at all and leaves that case to the parity test.

This is the failure mode that argued against hand-rolling _asdict_inner's branches in the first place. A version-agnostic reimplementation written against a 3.13 reading of _asdict_inner would have serialized a defaultdict successfully on 3.10 and 3.11 — where the code it replaced raises — and that divergence would have been invisible on three of the five matrix legs. Added test_defaultdict_follows_the_running_interpreter so the next person to look at this finds the reason attached to the behavior.

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 e31bb48, test-only.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread durabletask/history.py
@berndverst

Copy link
Copy Markdown
Member Author

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 durabletask.history and timing the real HistoryEvent.to_dict(), so the figure cannot diverge from what ships:

corpus: 39 history events
legacy and head agree byte-for-byte on the real corpus

 legacy: best 1.730s  median 1.745s  (2000 iterations x 39 events)
   head: best 0.287s  median 0.290s  (2000 iterations x 39 events)

legacy -> head : 6.02x faster

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 orchestration_state comes only from MessageToDict. The harness asserts byte-identical output event by event before it times anything, so a regression cannot be reported as a speedup.

PR description updated to the measured figures.

@andystaples andystaples left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread durabletask/history.py Outdated
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
Copilot AI review requested due to automatic review settings July 27, 2026 21:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

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
Copilot AI review requested due to automatic review settings July 27, 2026 21:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings July 27, 2026 21:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread durabletask/history.py
Comment on lines +436 to +445
# ``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.
@berndverst
berndverst merged commit 4bc616c into main Jul 27, 2026
25 checks passed
@berndverst
berndverst deleted the berndverst-issue-191-performance-p3-serialize-history-events-8f7a1a branch July 27, 2026 21:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Performance [P3]: Serialize history events in one pass

3 participants