Repository navigation
Optimize deterministic set serialization without JSON parse round trips - #313
Conversation
Normalize set subtrees once, reuse normalized values for output, and skip singleton ordering keys while preserving legacy coercion defaults. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Equivalent surrogate-pair and non-BMP object keys can produce duplicate JSON property names, bypassing last-value-wins normalization and changing payloads beyond the intended ordering change.
1 open issue
What changed in this PR
This follow-up to #310 optimizes deterministic set serialization in the core SDK by removing intermediate JSON parsing while preserving ordinary payload encoding and opt-in set reconstruction.
Changes:
- Normalize set subtrees once, skipping ordering keys for empty and singleton sets.
- Sort JSON object names after string conversion within set elements.
- Add regression coverage for ordering, hook calls, cycles, and committed-history replay.
| File | Description |
|---|---|
tests/durabletask/test_serialization.py |
Tests normalization, ordering, hook behavior, cycles, and encoding compatibility. |
tests/durabletask/test_data_converter.py |
Verifies replay preserves recorded arrays under set annotations. |
durabletask/serialization.py |
Replaces JSON parse round trips with set-subtree normalization. |
CHANGELOG.md |
Documents serialization improvements and object-name ordering changes. |
Review effort: Balanced
Collapse equivalent UTF-16 surrogate-pair and non-BMP names before dictionary insertion, preserve lone surrogates, and consolidate test imports. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Recognize hashable typed tuples recursively and reconstruct bare collections.abc.Set annotations as concrete sets only when enabled. Cover both opt-in names, discovery, activity execution, and unchanged legacy defaults. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Bernd Verst (berndverst)
left a comment
There was a problem hiding this comment.
The implementation looks functionally sound within the documented allowance to change the unreleased set wire format. I did not find another actionable correctness defect, but the dictionary-heavy performance regressions below should be addressed before merging an optimization PR.
One compatibility qualification: only set reconstruction is opt-in. The intentional object-name normalization changes set serialization bytes and potentially element ordering even with the default converter. The recorded-array replay test is useful, but does not establish byte-for-byte compatibility for previously serialized sets; the unreleased status of this behavior is therefore important.
| # leaves lone surrogates intact. Match its object names | ||
| # without parsing a document or losing duplicate names. | ||
| name = str.encode(name, "utf-16-le", "surrogatepass").decode( | ||
| "utf-16-le", "surrogatepass") |
There was a problem hiding this comment.
[P2] Avoid the substantial regression for dictionary-heavy set elements
The Python normalization walk adds significant overhead for dictionary-heavy payloads. In particular, this branch performs a UTF-16 encode/decode for every non-ASCII object name, including ordinary Unicode names that contain no surrogate pairs.
Paired benchmarks against the merged implementation (d2a3e7f) on Windows / CPython 3.13.13, loading both implementations into separate modules in the same process, reproduced these regressions. Each input is a singleton set containing an identity-hashable object whose to_json() returns the indicated value. Outputs matched byte-for-byte. These are medians of seven batches of 30 serializations, alternating implementation order:
| Hook result | Merged | PR | Slowdown |
|---|---|---|---|
{ "\u2603" + str(i): i for i in range(10000) } (the prefix is U+2603) |
7.63 ms | 19.71 ms | 2.58x |
[{"a": i, "b": i + 1, "c": i + 2} for i in range(1000)] |
1.64 ms | 2.71 ms | 1.65x |
These exceed the documented 9% small-object slowdown and also apply with enable_generic_sets=False, since that option gates reconstruction only.
Please add these shapes to the performance comparisons and optimize dictionary normalization before merging. Avoiding UTF-16 conversion when no surrogate composition is needed would address one source of overhead, but the ASCII nested-record case also needs attention.
There was a problem hiding this comment.
Reproduced both regressions against d2a3e7f and pushed a focused optimization in 943516d:
- Ordinary Unicode names and lone surrogates no longer undergo UTF-16 encode/decode; only actual high/low surrogate pairs do.
- Scalar dictionary fields bypass recursive normalization, and scalar-only dictionaries avoid cycle-tracking writes. Compound children still track their parent while traversing it, with new dictionary/list/hook cycle regressions.
I added both shapes to the PR's expanded performance comparisons and regression coverage. On this machine's CPython 3.14.10 (not your 3.13 environment), seven batches of 30 serializations with alternating forward/reverse order across merged / pre-fix / fixed source produced:
| Hook result | Merged d2a3e7f |
Pre-fix 90d8703 |
Fixed 943516d |
|---|---|---|---|
| 10,000 U+2603-prefixed fields | 8.8218 ms | 21.6778 ms | 6.2592 ms |
| 1,000 ASCII three-field records | 1.9748 ms | 3.1389 ms | 2.0472 ms |
Outputs matched byte-for-byte, with one hook call each. Peak traced allocations for the two fixed cases were 1009.3 / 231.2 KiB versus the merged implementation's 1654.0 / 292.6 KiB. ASCII records remain slightly slower: the final implementation measured roughly 4–11% overhead across repeated local runs, so I'm not claiming a universal speedup or hiding that tradeoff. The expanded matrix also retains the small-custom-object regression.
I also clarified the compatibility section: only reconstruction is opt-in; serialization changes affect the default converter, and the recorded-array replay test does not prove compatibility for previously serialized sets. The unreleased status of #310 is the allowance for those wire changes.
Leaving this thread open for confirmation on your environment rather than treating my different-interpreter measurements as sufficient to close your concern.
Compose only actual surrogate pairs, bypass recursive normalization for scalar dictionary fields, and avoid cycle-tracking writes for scalar-only records. Cover reviewer payload shapes and retain per-occurrence hooks and cycle detection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Summary
Focused follow-up to #310, inspected at its merged commit
d2a3e7ftogether with the inline review discussion. Independent security and correctness reviewers also inspected the combined change fromd2a3e7f^through this PR.The merged implementation already calls custom hooks once: it sorts canonical JSON documents, parses each document, and re-encodes the resulting values. This PR removes that intermediate parsing rather than claiming to fix the earlier 1,024-hook-call revision.
list.sort(key=json.dumps)compute and cache one ordering representation per element for each multi-element set. Empty and singleton sets need no ordering representations.collections.abc.Setannotations reconstruct arrays into concrete sets.Compatibility and determinism
Only reconstruction is opt-in. Set serialization changes also affect the default converter. #310's set serialization has not been released; that is the compatibility allowance for the intentional set wire-format changes below. The recorded-array replay regression does not establish byte-for-byte compatibility for previously serialized sets.
Ordinary payload encoding outside set subtrees and default-off set reconstruction behavior are unchanged.
enable_generic_setsand its alias remain opt-in. An executor regression replays committed history whose set-annotated dataclass fields contain ordered arrays with duplicates, and verifies the original activity choice and output bytes.Reconstruction corrections apply only when enabled. Both opt-in names are covered through deserialization/coercion, activity and orchestrator input discovery, activity-result discovery, and activity execution. Fixed-length and homogeneous tuples recurse through supported element types; tuples containing unhashable collection shapes remain excluded.
Lexical JSON ordering is retained, including
{2, 10}->[10, 2], non-finite floats, disjoint/nested frozensets, escaped strings, and dictionary insertion-order collisions. Native-container versus custom-hook precedence is preserved; hooks run once per occurrence, not once per object identity globally.Inside set elements, dictionary names are sorted after conversion to normalized JSON strings. Thus
{2: "two", 10: "ten"}emits{"10": "ten", "2": "two"}, just like those string names. Equivalent surrogate-pair/non-BMP names collapse with the original sorted traversal's last value winning, preserving the previous parser's normalization without parsing a document. Lone surrogates remain intact. This intentionally changes some previously serialized set bytes and potentially element ordering, even with the default converter.Measurements at 943516d
Windows / CPython 3.14.10; merged source and follow-up loaded into separate modules in the same process. Times below are medians of nine batches, alternating implementation order; batches target roughly 100 ms, capped at 1,000 serializations. Peak traced allocations are from a separate single serialization, excluding prebuilt inputs and timing instrumentation. These representative synthetic measurements replace the earlier pre-review measurements at
4dad104; they are not universal performance guarantees.Both implementations call the custom hook once, not 1,024 times, for the ten-layer singleton case. A no-set control uses the unchanged encoding path; timing variation there is not attributed to this PR.
Reviewer payloads: before and after the dictionary optimization
The reviewer correctly identified larger regressions than the original benchmark covered. Their Windows / CPython 3.13 measurements were 2.58x slower for Unicode names and 1.65x slower for ASCII records. The following local comparison uses CPython 3.14.10, not 3.13: seven batches of 30 serializations, alternating forward/reverse order across all three implementations. Each input is a singleton set of an identity-hashable hook object returning the indicated value. Outputs match byte-for-byte, and every implementation invokes the hook once.
{ "\u2603" + str(i): i for i in range(10000) }[{"a": i, "b": i + 1, "c": i + 2} for i in range(1000)]The ASCII case is still slower, not fixed to a guaranteed speedup. Repeated local runs varied; after the final dispatch optimization the residual slowdown measured about 4–11%. The maintainer's performance thread remains open for confirmation on their environment.
Remaining tradeoffs
Multi-element sets still need temporary serialized ordering keys and a final JSON encoding pass. The improvement removes stringify/parse/re-encode round trips and skips keys entirely for singletons; it does not claim zero intermediate strings or a single traversal by the JSON encoder. Python-side normalization is not universally faster: small custom objects and dictionary records retain overhead, as explicitly shown above. A structural-key prototype was rejected because it duplicated subtle JSON comparison rules and substantially regressed large numeric lists.
Reproduce the expanded benchmark matrix
Run from the repository root with the development environment's Python.
BASEidentifies the actual merged implementation, not an earlier revision of #310. To reproduce this snapshot exactly, load the follow-up withgit show 943516d:durabletask/serialization.py. For the three-way reviewer comparison, also load90d8703:durabletask/serialization.py, use seven batches of 30 calls, and alternate(merged, previous, current)with(current, previous, merged).Review follow-up and validation
4e83dee.90d8703, and the same reviewer confirmed no remaining blockers for those fixes with 93 focused tests passing.943516dand the expanded measurements above. The thread remains open for their confirmation of the remaining tradeoff.0,1,2,42, and999, including numeric/string object-name normalization.durabletask/serialization.py, separate core source/test flake8 checks, autopep8 diff, andgit diff --checkare clean.