Repository navigation
Commit 3281edc
authored
perf: preload the app and fork production granian workers (#7079)
* perf: preload the app and fork production granian workers
On Linux the production backend now forces the "fork" start method,
imports the app in the supervisor, and freezes the GC before granian
spawns workers, so every worker shares the framework and app pages
copy-on-write instead of re-importing ~1,800 modules privately.
A forked child inherits the telemetry ThreadPoolExecutor without its
worker thread, so events submitted in a worker would never be sent;
an at-fork hook drops the inherited pool so the child creates its own.
REFLEX_BACKEND_START_METHOD overrides the start method for apps that
are not fork-safe.
Blank app, GRANIAN_WORKERS=4, median of 3 runs (PSS over the process
tree from /proc/<pid>/smaps_rollup after serving requests):
before: 721 MB PSS, 692 MB private dirty, port up in 1.82 s
after: 223 MB PSS, 70 MB private dirty, port up in 1.07 s
Claude-Session: https://claude.ai/code/session_01CEb3ocfFLeHkjkAKKH8YCy
* Defer run telemetry past the fork and drain the executor first
Address the review asking not to fork granian workers while a telemetry
thread is alive:
- run_backend_prod, run_frontend_prod, run_granian_backend_prod and
run_uvicorn_backend_prod take an on_started callback; `reflex run
--env prod` sends the "run-prod" event from it instead of before the
server starts. Granian calls it from Server.startup() once the workers
are spawned; uvicorn/gunicorn runs in a separate interpreter, so it is
called right before that process is launched.
- Before forking, the supervisor finishes Plotly serializer setup
(_prepare_serializers_for_fork) and drains and shuts down the telemetry
executor, so no telemetry thread exists at the fork.
- The at-fork child hook also recreates the executor creation lock, so a
fork taken while another thread held it cannot block the child's first
telemetry send forever.
Claude-Session: https://claude.ai/code/session_01VDzz2un9mtjmCY24zSGmca
* Bound the pre-fork telemetry drain
_shutdown_executor() waited on the telemetry executor without a limit, so
a send stalled on DNS (urlopen's timeout does not cover getaddrinfo) or a
slow event collector could block production startup forever. It now
waits up to two seconds through _flush(), then cancels what is still
queued and stops the pool without waiting for a stalled send; the forked
children reset the executor and its lock through the at-fork hook.
Claude-Session: https://claude.ai/code/session_01VDzz2un9mtjmCY24zSGmca
* Keep the telemetry pool published while it shuts down
_shutdown_executor() cleared _executor before the old pool had stopped,
so a send() racing the pre-fork shutdown could start a replacement pool
whose thread was then alive at the fork. The pool now stays published
until it has stopped (a racing submit fails and is suppressed) and is
only then cleared.
The shutdown test now checks the threads of the pool it stops instead
of every "reflex-telemetry" thread in the process, and the fork-reset
tests stop the pools they orphan, so the check no longer depends on
test order.
Claude-Session: https://claude.ai/code/session_01VDzz2un9mtjmCY24zSGmca
* Spawn production workers when a telemetry send outlives the drain
After the bounded drain, a stalled telemetry send (e.g. blocked in
getaddrinfo) keeps its thread alive, and that thread can hold
process-wide locks such as the resolver's across a fork. Forking then
trades a startup hang for a worker hang.
_shutdown_executor() now reports whether the pool's thread actually
stopped, _preload_for_fork() passes that on, and
run_granian_backend_prod() starts its workers with "spawn" instead of
"fork" when it did not, so the supervisor never forks with a live
telemetry thread and startup waits at most the drain timeout.
Claude-Session: https://claude.ai/code/session_01VDzz2un9mtjmCY24zSGmca
* Set the start method before preloading and deflake the stalled drain test
run_granian_backend_prod() imported the app before selecting the start
method, so multiprocessing objects the app creates at import time used
the interpreter default context (forkserver on Python 3.14 Linux) rather
than the requested one. The requested method is now set first, and only
switched to "spawn" when the telemetry drain leaves a live thread.
The stalled-drain test now waits until the worker is running the stalled
task before shutting down, and joins the worker instead of calling
result() on a future that shutdown may have cancelled.
Claude-Session: https://claude.ai/code/session_01VDzz2un9mtjmCY24zSGmca
* Keep telemetry from starting a thread between the drain and the fork
Once _shutdown_executor() had stopped the pool, a send() racing the
pending fork could create a fresh pool and thread that granian then
forked. The shutdown now pauses pool creation (checked under the
executor lock; such sends are dropped with a debug log) until the next
fork, which lifts it in the parent through an after_in_parent hook and
in the child through the existing after_in_child reset. A caller that
keeps forking can pass resume_after_fork=False to stay paused.
When the workers are forked, the supervisor stops the pool again after
on_started has sent "run-prod", so granian respawns do not fork while a
send is in flight.
Claude-Session: https://claude.ai/code/session_01VDzz2un9mtjmCY24zSGmca
* Silence the multithreaded-fork warning in the telemetry fork test
Claude-Session: https://claude.ai/code/session_01VDzz2un9mtjmCY24zSGmca
* Send the deferred run event from a detached process
The supervisor that forks granian workers also forks again to respawn
them, so it must never hold a telemetry thread: a "run-prod" send that
stalled (DNS, the lscpu collector) after on_started kept one alive across
those respawns, and the earlier bounded re-shutdown could not help.
Once the supervisor drains and stops its pool before the first fork, it
now stays paused for good (in-process sends are dropped with a debug
log; forked children reset it). on_started hands "run-prod" to
telemetry._send_detached(), which runs the event collection and delivery
in a fresh interpreter: Popen with close_fds (so the server sockets stay
in the supervisor) runs a snippet that forks once and exits, so the
supervisor reaps its direct child at once and the sender is reparented
to init. No process starts when telemetry is disabled. The after-fork
parent hook and the post-callback shutdown are gone.
Claude-Session: https://claude.ai/code/session_01VDzz2un9mtjmCY24zSGmca
* Send the detached run event in-process where nothing forks
Windows has no os.fork, so the detaching snippet cannot run there; the
supervisor never forks on that platform either, so send in-process.
Claude-Session: https://claude.ai/code/session_01VDzz2un9mtjmCY24zSGmca
* Use the detached sender only in a process paused to fork
_send_detached() started a fresh interpreter and waited for it even when
nothing forks the process: the uvicorn/gunicorn path calls on_started
right before launching the server, so the launch waited on that child.
Only a process that paused telemetry before forking its workers needs
the detached sender; every other process (uvicorn, granian with spawn or
forkserver, a failed pre-fork drain that fell back to spawn) sends
in-process as before.
Claude-Session: https://claude.ai/code/session_01VDzz2un9mtjmCY24zSGmca
* Reap the detached sender reliably and keep its tests offline
- A direct child that outlives the wait is now killed and reaped instead
of being left behind when TimeoutExpired was suppressed.
- As PID 1 (a container without an init) or a Linux child subreaper, the
double-forked sender would be reparented to the supervisor, which never
reaps it. There the single child sends itself and is waited for
(bounded, then killed and reaped), which only delays the supervisor's
management loop, never the workers.
- The snippet is split into its detaching prologue and its send body, so
the tests swap in a self-contained body instead of slicing the real
snippet, which could have posted real telemetry if it were reworded.
Claude-Session: https://claude.ai/code/session_01VDzz2un9mtjmCY24zSGmca
* Keep the run event's sender attached under a PID 1 parent
`reflex run --json` re-runs itself below an output supervisor; as a
container's PID 1 (e.g. `CMD exec reflex run --json`) that supervisor
inherits the double-forked sender but only waits for its own child, so
the sender stayed a zombie for the server's lifetime. Orphans always go
to PID 1 (absent a subreaper), so whenever PID 1 is our own parent the
sender may linger: _reaps_orphans() now also returns True for
os.getppid() == 1, which makes the server send attached and reap the
child itself. This covers reflex's output supervisor and any other
non-reaping PID 1 parent; a false positive (e.g. a service under
systemd) only costs a bounded wait in the supervisor.
The ordinary-process test no longer depends on the runner's PIDs or
subreaper flag: it fakes getpid/getppid and prctl.
Claude-Session: https://claude.ai/code/session_01VDzz2un9mtjmCY24zSGmca
* Only stay attached under reflex's own output supervisor as PID 1
aebe6ec sent attached under any PID 1 parent, which includes a working
init such as systemd, where the detached sender is reaped fine and the
attached send only made the supervisor wait. The PID 1 parent case now
requires the REFLEX_OUTPUT_SUPERVISED marker, i.e. the parent is the
`reflex run --json` output supervisor, which only waits for its own
child. PID 1 itself and child subreapers are unchanged.
Claude-Session: https://claude.ai/code/session_01VDzz2un9mtjmCY24zSGmca
* Skip the attached-sender test where os.fork is missing
On Windows _send_detached sends in-process and never calls Popen, so the
test's popen.call_args lookup raised. It now carries the same os.fork
guard as the other detached-sender tests; running the module with
os.fork deleted shows every test passing or skipping.
Claude-Session: https://claude.ai/code/session_01VDzz2un9mtjmCY24zSGmca
* Shorten the prod fork preload news fragment to two sentences
Claude-Session: https://claude.ai/code/session_01VDzz2un9mtjmCY24zSGmca
* Send the run-prod telemetry event synchronously instead of via on_started
Address masenf's review: the supervisor sends the run-prod event on its own
thread before granian starts, so no telemetry thread exists at the fork and
nothing needs to run after the workers start. This removes the on_started
callback threaded through run_backend_prod, run_frontend_prod,
run_uvicorn_backend_prod and run_granian_backend_prod, the NotifyingGranian
subclass, telemetry._send_detached with its detached sender script,
_reaps_orphans and the _paused flag.
The pre-fork drain (_shutdown_executor) and the at-fork executor reset stay:
the compile event is still queued on the thread pool, and a forked child must
not inherit a pool whose worker thread it does not own.
Claude-Session: https://claude.ai/code/session_01GbqrTagh776xkQvsopdytF
* Keep the alias migration on the synchronous run-prod send and floor reflex-base
A backend-only production run skips the compile event, so its run-prod event
is the only one that can carry the one-time $create_alias migration. Send it
through _process_event, and deliver the alias on the calling thread with
_send so a process about to fork still starts no telemetry thread. From the
worker thread this changes nothing but the order: the alias now precedes the
event it rides on instead of being queued behind it.
REFLEX_BACKEND_START_METHOD is new in reflex-base, so reflex's floor on it
moves to 0.10.1.dev0, the next version after the reflex-base-v0.10.0 tag.
Claude-Session: https://claude.ai/code/session_01GbqrTagh776xkQvsopdytF1 parent f615739 commit 3281edc
10 files changed
Lines changed: 440 additions & 15 deletions
File tree
- news
- packages/reflex-base
- news
- src/reflex_base
- reflex
- utils
- tests/units
- utils
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
815 | 815 | | |
816 | 816 | | |
817 | 817 | | |
| 818 | + | |
| 819 | + | |
| 820 | + | |
| 821 | + | |
| 822 | + | |
| 823 | + | |
| 824 | + | |
818 | 825 | | |
819 | 826 | | |
820 | 827 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
35 | | - | |
| 35 | + | |
36 | 36 | | |
37 | 37 | | |
38 | 38 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
565 | 565 | | |
566 | 566 | | |
567 | 567 | | |
568 | | - | |
569 | | - | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
570 | 571 | | |
571 | 572 | | |
572 | 573 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1028 | 1028 | | |
1029 | 1029 | | |
1030 | 1030 | | |
| 1031 | + | |
| 1032 | + | |
| 1033 | + | |
| 1034 | + | |
| 1035 | + | |
| 1036 | + | |
| 1037 | + | |
| 1038 | + | |
| 1039 | + | |
| 1040 | + | |
| 1041 | + | |
| 1042 | + | |
| 1043 | + | |
| 1044 | + | |
| 1045 | + | |
| 1046 | + | |
| 1047 | + | |
| 1048 | + | |
| 1049 | + | |
| 1050 | + | |
| 1051 | + | |
| 1052 | + | |
| 1053 | + | |
| 1054 | + | |
| 1055 | + | |
| 1056 | + | |
| 1057 | + | |
| 1058 | + | |
| 1059 | + | |
| 1060 | + | |
| 1061 | + | |
| 1062 | + | |
| 1063 | + | |
| 1064 | + | |
| 1065 | + | |
| 1066 | + | |
| 1067 | + | |
| 1068 | + | |
| 1069 | + | |
| 1070 | + | |
| 1071 | + | |
| 1072 | + | |
| 1073 | + | |
| 1074 | + | |
| 1075 | + | |
| 1076 | + | |
| 1077 | + | |
| 1078 | + | |
| 1079 | + | |
1031 | 1080 | | |
1032 | 1081 | | |
1033 | 1082 | | |
| |||
1039 | 1088 | | |
1040 | 1089 | | |
1041 | 1090 | | |
| 1091 | + | |
| 1092 | + | |
1042 | 1093 | | |
1043 | 1094 | | |
1044 | 1095 | | |
1045 | 1096 | | |
1046 | 1097 | | |
1047 | 1098 | | |
| 1099 | + | |
| 1100 | + | |
| 1101 | + | |
| 1102 | + | |
| 1103 | + | |
| 1104 | + | |
| 1105 | + | |
| 1106 | + | |
1048 | 1107 | | |
1049 | 1108 | | |
1050 | 1109 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
501 | 501 | | |
502 | 502 | | |
503 | 503 | | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
504 | 544 | | |
505 | 545 | | |
506 | 546 | | |
| |||
634 | 674 | | |
635 | 675 | | |
636 | 676 | | |
637 | | - | |
638 | | - | |
| 677 | + | |
639 | 678 | | |
640 | 679 | | |
641 | 680 | | |
| |||
647 | 686 | | |
648 | 687 | | |
649 | 688 | | |
650 | | - | |
651 | | - | |
| 689 | + | |
| 690 | + | |
| 691 | + | |
| 692 | + | |
652 | 693 | | |
653 | 694 | | |
654 | 695 | | |
| |||
668 | 709 | | |
669 | 710 | | |
670 | 711 | | |
671 | | - | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
672 | 715 | | |
673 | 716 | | |
674 | 717 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
173 | 173 | | |
174 | 174 | | |
175 | 175 | | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
176 | 215 | | |
177 | 216 | | |
178 | 217 | | |
| |||
0 commit comments