Skip to content

perf: stop deep-copying proxy-free event chaining payloads#6739

Open
Alek99 wants to merge 5 commits into
mainfrom
claude/reflex-perf-optimizations-21kg8y-event-copy
Open

perf: stop deep-copying proxy-free event chaining payloads#6739
Alek99 wants to merge 5 commits into
mainfrom
claude/reflex-perf-optimizations-21kg8y-event-copy

Conversation

@Alek99

@Alek99 Alek99 commented Jul 10, 2026

Copy link
Copy Markdown
Member

All Submissions:

  • Have you followed the guidelines stated in CONTRIBUTING.md file?
  • Have you checked to ensure there aren't any other open Pull Requests for the desired changed?

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Changes To Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?

Event.from_event_type deep-copied every mutable payload value passed to a chained handler (yield OtherState.handler(rows)). The copy exists only to detach state-bound MutableProxy values — which can raise ImmutableStateError after 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:

  • plain list/tuple/dict subtrees containing no proxies pass through by reference (zero copies);
  • proxied subtrees are deep-copied exactly as before. A proxy's exact type is never a plain container type, so proxies naturally take the opaque-object copy.deepcopy fallback, whose __deepcopy__ unwraps and snapshots the wrapped data — no cross-package proxy registration needed (an earlier iteration registered MutableProxy from reflex.istate.proxy, which broke reflex against released reflex_base versions; 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;
  • opaque mutable objects (dataclass/model instances, subclassed containers) keep the deepcopy fallback since they may hold proxies internally — unchanged from today;
  • self-referential containers are detected by the scan and fall back to a plain 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):

payload before after speedup
plain 10k-row list of dicts (local data) 35.6 ms 10.1 ms 3.5x
proxied 10k-row list (self.rows) 35.0 ms 35.4 ms 1.0x (must copy)
list of 10k proxied rows (list(self.rows)) 42.1 ms 45.7 ms 0.9x (must copy; ~8% scan overhead)
small plain payload ([1, 2, 3]) 2.1 µs 0.8 µs 2.6x

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

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
@Alek99 Alek99 requested a review from a team as a code owner July 10, 2026 19:57
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sns7EuBPYvfGCxRSRZ1bUx
@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR optimizes event chaining payload handling. The main changes are:

  • Avoids deep-copying payload subtrees that contain no state-bound proxies.
  • Detaches proxied payload values with copy-on-write scanning.
  • Falls back to deepcopy for cyclic payload containers.
  • Adds unit coverage for plain values, proxied values, opaque objects, and cycles.
  • Adds release notes for the performance change.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
packages/reflex-base/src/reflex_base/event/init.py Adds copy-on-write event payload detachment and cycle fallback handling.
tests/units/test_event.py Adds tests for shared plain payloads, proxy detachment, opaque object copying, and cyclic containers.
news/6739.performance.md Adds a release note for faster event chaining payload handling.
packages/reflex-base/news/6739.performance.md Adds the package-level release note for faster event chaining payload handling.

Reviews (4): Last reviewed commit: "fix: reach _detach_state_proxies through..." | Re-trigger Greptile

Comment thread packages/reflex-base/src/reflex_base/event/__init__.py Outdated
Comment thread packages/reflex-base/src/reflex_base/event/__init__.py Outdated
@linear-code

linear-code Bot commented Jul 10, 2026

Copy link
Copy Markdown

ENG-10099

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread tests/units/test_event.py Outdated
pass

fn_with_arg.__qualname__ = "fn_with_arg"
event = fix_events([EventHandler(fn=fn_with_arg)(value)])[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment on lines +237 to +238
if copied is None:
return value

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

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.

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
Comment thread packages/reflex-base/src/reflex_base/event/__init__.py Outdated
@codspeed-hq

codspeed-hq Bot commented Jul 10, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 26 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing claude/reflex-perf-optimizations-21kg8y-event-copy (dbfafb0) with main (9a5c4d3)

Open in CodSpeed

Footnotes

  1. 8 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

claude added 2 commits July 10, 2026 20:09
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
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.

2 participants