perf: stop deep-copying proxy-free event chaining payloads#6739
Conversation
Event.from_event_type deep-copied every mutable payload value passed to a chained handler (yield OtherState.handler(rows)), even values that were never attached to any state. The copy only exists to detach state-bound MutableProxy instances (which can raise ImmutableStateError after a background task's lock is released, or silently alias live state data). Replace the unconditional deepcopy with a copy-on-write detach pass: plain list/tuple/dict subtrees without proxies pass through by reference, proxied subtrees are deep-copied off the proxy (as before), and opaque mutable objects keep the deepcopy fallback since they may hold proxies internally. reflex.istate.proxy registers MutableProxy with reflex_base at import time to keep the dependency direction intact. Detaching a 10k-row plain payload drops from ~35ms to ~7ms (4.9x); payloads that genuinely reference state data are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sns7EuBPYvfGCxRSRZ1bUx
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sns7EuBPYvfGCxRSRZ1bUx
Greptile SummaryThis PR optimizes event chaining payload handling. The main changes are:
Confidence Score: 5/5This looks safe to merge.
Important Files Changed
Reviews (4): Last reviewed commit: "fix: reach _detach_state_proxies through..." | Re-trigger Greptile |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 148960ea17
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| pass | ||
|
|
||
| fn_with_arg.__qualname__ = "fn_with_arg" | ||
| event = fix_events([EventHandler(fn=fn_with_arg)(value)])[0] |
There was a problem hiding this comment.
Call the code path under test in new tests
These new detachment tests go through the deprecated fix_events() helper, but that function still builds payloads with v._decode() and never calls Event.from_event_type()/_detach_state_proxies. In the proxied cases _payload_for(proxied) therefore returns the MutableProxy, so the assertions at lines 215 and 235 fail, and the new tests also don't exercise the changed code path.
Useful? React with 👍 / 👎.
| if copied is None: | ||
| return value |
There was a problem hiding this comment.
Preserve snapshots for plain mutable payloads
Returning the original plain list/tuple here makes queued chained events share live mutable arguments. When a handler returns/yields multiple events with the same local list, or mutates the list after yielding while chain_updates has only enqueued the child event, later handlers observe those mutations; the previous deepcopy created a snapshot for each event payload at creation time.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
This is intentional, per ENG-10099: only state-bound (proxied) values need snapshotting — the deepcopy's documented purpose was detaching state proxies, not providing snapshot semantics for values the handler constructed itself. Sharing a local list the caller keeps mutating after yield now behaves like normal Python argument passing. State-derived data (any proxied subtree, including elements of list(self.rows)) is still snapshotted at yield time.
Generated by Claude Code
The proxy-type registration broke reflex against released reflex_base versions (ModuleNotFoundError on import). It was also unnecessary: a MutableProxy's exact type is never a plain container type, so proxies always take the opaque-object deepcopy fallback, which detaches them via __deepcopy__. Also guard the copy-on-write scan against self-referential containers (falling back to plain deepcopy, which preserves cycles), and point the new payload tests at Event.from_event_type instead of the deprecated fix_events shim, which builds payloads separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sns7EuBPYvfGCxRSRZ1bUx
Merging this PR will not alter performance
Comparing Footnotes
|
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sns7EuBPYvfGCxRSRZ1bUx
reflex_base.event replaces itself in sys.modules with the EventNamespace class, so private module names cannot be imported from it directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sns7EuBPYvfGCxRSRZ1bUx
All Submissions:
Type of change
Changes To Core Features:
Event.from_event_typedeep-copied every mutable payload value passed to a chained handler (yield OtherState.handler(rows)). The copy exists only to detach state-boundMutableProxyvalues — which can raiseImmutableStateErrorafter a background task's lock is released, or silently alias live state data — but it was paid even for values that were never near a state.Fix: a copy-on-write detach pass replaces the unconditional deepcopy:
list/tuple/dictsubtrees containing no proxies pass through by reference (zero copies);copy.deepcopyfallback, whose__deepcopy__unwraps and snapshots the wrapped data — no cross-package proxy registration needed (an earlier iteration registeredMutableProxyfromreflex.istate.proxy, which brokereflexagainst releasedreflex_baseversions; removed). This detachment cannot be skipped for proxies:MutableProxy.__iter__/__getitem__wrap nested mutables, so e.g.list(self.rows)is a plain list full of proxies, and passing those through would let a receiving handler silently mutate the sender's state;copy.deepcopy, which preserves cycles via its memo (old behavior).Intentional semantics note: payload values that contain no state proxies are now shared by reference instead of snapshotted, per ENG-10099 ("only proxied values need copying") — standard Python argument-passing semantics for values the user constructed themselves.
Micro-benchmark (Python 3.13, the real implementation extracted via AST vs the old unconditional deepcopy, median of 30 runs; proxy stand-in implements
MutableProxy's__deepcopy__contract):self.rows)list(self.rows))[1, 2, 3])The all-proxied worst case pays ~8% for the scan — the trade for making fully-detached payloads (the common projection pattern, JSON-ish local data) free of copies.
Unit tests pin the semantics through
Event.from_event_type: plain payloads pass by reference; top-level proxies detach (mutation does not touch or dirty the state); nested proxies inside plain containers detach while clean siblings stay shared; opaque objects still snapshot; cyclic lists/dicts still work and preserve their cycles. The existing background-task tests (test_yielded_event_arg_from_background_state_is_mutable) cover the end-to-end unwrap requirement.Linear: ENG-10099
🤖 Generated with Claude Code
https://claude.ai/code/session_01Sns7EuBPYvfGCxRSRZ1bUx