Repository navigation
perf: drop task wrapper in EventNamespace.emit_update, keep flush tick - #6734
Conversation
Greptile SummaryThis PR reduces per-update websocket emit overhead while keeping interim updates flushable. The main changes are:
Confidence Score: 5/5This looks safe to merge.
|
| Filename | Overview |
|---|---|
| reflex/app.py | Updates EventNamespace.emit_update to await socket emits directly and yield once afterward. |
| news/6734.performance.md | Adds a news fragment describing the emit performance improvement. |
Reviews (4): Last reviewed commit: "chore: add news fragment for #6734" | Re-trigger Greptile
Merging this PR will not alter performance
Comparing Footnotes
|
bab3d82 to
3763baa
Compare
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
846a0ee to
34610c2
Compare
Awaiting a freshly created task blocks the caller exactly like awaiting the coroutine directly, so the wrapper never provided the claimed non-blocking behavior -- it only added task creation/scheduling overhead (~4-7us, 2-3x) on every outgoing state update. The task wrapper did have one load-bearing side effect: it forced an event loop tick, giving the engineio writer task a chance to flush the queued packet before the caller resumes (important when a sync event handler blocks the loop right after yielding an interim update). Keep that guarantee with an explicit asyncio.sleep(0) after the emit, which is still ~2x cheaper than the task wrapper. 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
34610c2 to
24c3f7a
Compare
Independent review: this change cannot reduce concurrencyI dug into this because the 1. The wrapper never provided concurrency
2. Nothing about the websocket intake path runs through
|
| Scenario | OLD create_task+await |
PR await + sleep(0) |
bare await |
|---|---|---|---|
| py ≥ 3.12, real socketio emit | flushed in time | flushed in time | flushed in time |
| py ≥ 3.12, non-suspending emit | flushed in time | flushed in time | lost |
| py 3.10/3.11, real socketio emit | flushed in time | flushed in time | lost |
| py 3.10/3.11, non-suspending emit | lost | lost | lost |
The OLD and PR columns are identical in every cell, including the corner where both fail — the strongest available statement of behavior preservation. Two corollaries: the await asyncio.sleep(0) is genuinely load-bearing (this repo supports >=3.10 and integration_app_harness.yml runs 3.11, which is exactly why the first iteration failed test_yield_state_update[click_yield_interim_value]), and a naive "just await it" simplification would have been a real regression.
5. Performance
Benchmarking the real emit path (not a stand-in): old ≈ 60.5 µs/update vs PR ≈ 55 µs/update, matching the delta in the description. Modest, but it's paid while the per-token state lock is held (_execute_event → modify_state_with_links → process_event → chain_updates → emit_delta → emit_update), so same-token event pipelining gets marginally better, not worse. Exception propagation and cancellation outcomes are equivalent — in both versions the inner socketio send-tasks survive caller cancellation, so packet delivery is unchanged.
Minor, non-blocking notes
- The description's mechanism ("
sio.emitcan complete without ever suspending") is slightly imprecise for the pinned range — socketio>=5.12always suspends via internalasyncio.wait; the actual culprit on 3.10/3.11 is thewait_forextra-task hop above. The code comment reads fine either way, since non-suspending emits are real for custom managers. - "Cutting scheduling overhead roughly in half" in the news fragment is true of the await-pattern overhead itself, which is ~9% of end-to-end emit cost. Defensible, just worth knowing.
- The named
reflex_emit_event|token|sid|tstask disappears from asyncio task dumps — a small debugging-observability loss, no behavior impact.
Verdict: the change is correct, the retained sleep(0) is the right call, and concurrency is unaffected. 👍
Generated by Claude Code
End-to-end confirmation of the #6920 background-task delta race fix (0.9.8 baseline loses one foreground delta per background completion; 0.9.9a1 renders every value across 20 hammer rounds in dev and prod), plus #6734 streaming flush behavior, #6830 lock isolation, and client_state-under-concurrency checks. Two anomalies noted in NOTES.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EMjBXPozsNeQNSBZecNH8x
All Submissions:
Type of change
Changes To Core Features:
EventNamespace.emit_updatewrapped the socket emit inasyncio.create_task(...)and then immediately awaited it, with a comment claiming the task "prevents the update from being blocked behind other coroutines". Awaiting a freshly created task blocks the caller exactly like awaiting the coroutine directly — the wrapper never provided any concurrency; it only added per-update task creation/scheduling overhead (plus an f-string task name with atime.time()call). Sinceemit_updateruns while the state lock is held (viamodify_state/ the event processor'semit_deltapath), that overhead was paid on every outgoing state update.The one load-bearing side effect of the wrapper (caught by
test_yield_state_update[click_yield_interim_value]on the first iteration of this PR): awaiting a fresh task forces an event-loop tick, which lets engineio's per-connection writer task flush the queued packet before the caller resumes.sio.emititself can complete without ever suspending (the packet is only put on a non-full queue), so a sync handler that blocks the loop right after yielding (e.g.yield→time.sleep(...)) would otherwise hold the interim update hostage in the write queue. The fix keeps that guarantee explicitly withawait asyncio.sleep(0)after the emit, with a regression note explaining why the tick must stay.Micro-benchmark (Python 3.13, replicating the exact await patterns with a stand-in emit coroutine, 20k iterations after warmup):
await asyncio.create_task(emit(...), name=f"...{time.time()}")await emit(...)+await asyncio.sleep(0)1.9x less overhead per outgoing update. A writer-task ordering simulation confirms the interim packet is flushed before a loop-blocking caller resumes in both the old and new versions (and is not flushed with a plain
await emit(...)alone — which is why the sleep(0) is load-bearing).Linear: ENG-10110
🤖 Generated with Claude Code
https://claude.ai/code/session_01Sns7EuBPYvfGCxRSRZ1bUx