feat: metrics for the tasks of background workers - #2637
Open
nicolas-grekas wants to merge 20 commits into
Open
Conversation
Member
|
Didn't GitHub introduce support for stacked PRs lately? I think that it would greatly help reviewing those |
Contributor
Author
|
They do exist since July, but a stack needs all its branches in the same repository, cross-fork stacks aren't supported, and I can't push branches to php/frankenphp. If a maintainer pushes the four branches here (or gives me push rights), I'll recreate these as a proper stack, one diff per layer. Until then each PR says which commit to review, and the Commits tab shows that one alone. |
nicolas-grekas
force-pushed
the
bgworker-metrics
branch
9 times, most recently
from
September 10, 2026 10:35
ca231f4 to
d7b8faa
Compare
Background workers run a script in a loop outside the HTTP request cycle, sharing the PHP runtime with the request threads. Rebuilt on Server from php#2499: a background worker attaches to a php_server through WithWorkerServerScope() like any other worker. Declared with "background" in a worker block (php_server or global) or WithWorkerBackground() in Go. name is required, match is rejected, num >= 1. The lifecycle mirrors HTTP workers: re-run on a cooperative exit, restart with a capped quadratic backoff on a crash, max_consecutive_failures fails Init() during startup only. drain() runs on shutdown, reboot and handler transitions so a parked script wakes up instead of waiting out the force-kill grace period. Their threads live outside the num_threads / max_threads budget, which describes HTTP capacity: those settings size the pool background workers never draw from, so calculateMaxThreads() resolves them against the HTTP workers alone and returns the background threads separately, for Init() to add to the totals. Nothing is subtracted back out. Every worker sees its declared name in $_SERVER['FRANKENPHP_WORKER'], HTTP workers included: the documented contract is to test its presence, not its value. Background workers also get $_SERVER['FRANKENPHP_WORKER_BACKGROUND'], so a script serving both roles can tell them apart with isset(). Both names are reserved: an env of the worker or of its server never leaks either into a worker of the other kind. The script gets one handle, frankenphp_get_worker_handle(), a stream that reaches EOF when the worker is drained, meant to carry control messages later. It is backed by a socket pair, not a pipe: on Windows PHP's php_select() only waits properly on sockets before 8.5. Streams do not own the socket (php_sockop_close() would shutdown() it on Windows), so a stream can be closed and fetched again without losing the drain signal; the read timeout is infinite so a blocking read parks as well as stream_select() does. Both ends are non-inheritable. A worker counts as ready on its first wait on the handle (select cast or read), the background analog of frankenphp_handle_request(): Init() waits for it, ready_workers counts from it, and an exit before it is a boot failure. The handle's stream ops, copied from the socket ops at MINIT, report it once per run. A run gets one stream: every call returns the same resource until the script closes it, so fetching the handle in a loop does not grow the resource list of a request that never ends. Worker names are scoped like paths: unique within a php_server or among global workers. The script sees the declared name; metrics and logs report a scoped worker as "<server name>:<name>", with a numeric suffix on server names when two blocks resolve to the same one, never a name another block configured. The collision-driven renaming in the Caddy module is gone, and WithWorkerName() resolves within the request's server first. FRANKENPHP_WORKER held "1" in HTTP workers before, and workers of a php_server block were reported under their bare name unless it collided: both changes are called out in the docs. Two places absorb the new worker kind rather than growing a copy of what exists. The states a worker thread walks through between two runs live in workerLifecycle, embedded by both handlers, which supply only what differs: how a run starts, and what a reboot resets. And a worker without a scope now belongs to the fallback server, the one already serving the requests that have no server either, so a lookup is always a lookup in a server and the parallel registry of global workers is gone. Supersedes php#2543 and php#2398.
nicolas-grekas
force-pushed
the
bgworker-metrics
branch
from
September 10, 2026 20:57
d7b8faa to
de9d9a3
Compare
stream_socket_recvfrom() and the other transport receives never reach the read op: they go through the stream's transport API, which the handle inherited unchanged from the socket ops. A script parking that way was therefore never reported ready and Init() waited for it forever. The set_option op is now wrapped too, reporting the wait on a receive. The new fixture hangs Init() without it.
FRANKENPHP_WORKER and FRANKENPHP_WORKER_BACKGROUND say what is running the script, so FrankenPHP owns them. Dropping the background flag from the worker's own env was not enough: $_SERVER is built from the process environment first, then the php_server env, then the worker's, so a value from any layer below survived and an HTTP worker answered the documented isset() check. They are now removed after all the layers are merged, for the kind of thread that must not carry them. The test declares the flag in the worker env, the server env and the process environment.
…olds Two small ones on the restart path. The quadratic backoff multiplied before capping, which overflows a duration of nanoseconds past some 300k consecutive failures and sleeps for a negative one; a background worker crashing past its ready point counts without bound and reaches that in a few days of retries. The cap now comes first, for the same schedule. Closing the Go side of a handle only lands as EOF on the script's end while no other process holds a copy, and a pcntl_fork() child inherits every descriptor of the process, the pairs of the other threads included. Shutting the write direction down first sends the FIN regardless, so a parked script still wakes up instead of waiting out the force-kill. Also drops a platform conditional: php_network.h maps closesocket to close outside Windows.
An explicit max_threads gets the reservation added on top, so the HTTP capacity it describes is preserved. The automatic limit did not: it resolved from a num_threads that already included the background threads, so declaring three of them turned a limit of 4 into 10, and a memory-derived estimate could be swallowed whole, leaving no room for HTTP autoscaling at all. The main thread now knows what part of its count is reserved, resolves the estimate on the rest, and adds the reservation back, like the explicit path.
resetForReboot() was redundant, setupWorkerScript() already resets the request count before every run, so the lifecycle interface is down to the one step that differs. A worker always has a server now, so the extension dispatch has a single path. zend_alter_ini_entry_chars() takes the literal, and php_network.h maps closesocket to close outside Windows. The ready_workers help text and docs said fetching the handle marks a background worker ready; the first wait on its stream does, as the validation test asserts. A parked fixture no longer disables max_execution_time itself, which is how the engine disabling it went untested, and a new test parks past a one-second limit with max_input_time set, the case where php_execute_script() re-arms it.
Two paths the suite took for granted. A worker parked on its handle must survive default_socket_timeout as well as max_execution_time, so the fixture that disables neither now runs with both set to one second. And a run gets one handle: the second fetch is the same stream, a fetch after closing it is a fresh one, and the drain still reaches the script through that one.
The directive, the Go option and the docs section carry the flag, the function did not.
…d workers Readiness rode on intercepting every PHP path that waits on the handle: the read op, the select cast, the transport receive missed at first, and whatever a future PHP adds. The contract was also read three different ways during review. It is now a call the script makes, the background analog of frankenphp_handle_request(): the first frankenphp_worker_tick() of a run marks the worker ready, every call returns false once the worker is drained, and the read, cast and transport hooks are gone. The tick never blocks and never hands out work. It consumes whatever the runtime wrote on the handle to wake the script up, so the script only ever selects on the handle, alone or with its own streams, and the protocol on it stays private.
…ched FRANKENPHP_WORKER stays what it always was, "1" in HTTP workers, and is not set in background workers, where FRANKENPHP_WORKER_BACKGROUND holds the declared name instead. A script serving both roles tests which of the two is set. The removal of inherited values is gone with the change that motivated it: nothing about HTTP workers moves in this PR anymore.
A script that registers its handle with an event loop and runs it only ticks when the handle is readable, so it never became ready before the drain and Init() waited for it. One wake-up written at run setup makes such a loop tick on its own: readiness then means the loop serviced the handle once. The first frankenphp_worker_tick() consumes it, and a script parked in a blocking read without ticking now fails its boot fast instead of hanging the start.
The shared lifecycle keeps its struct, embedded by both worker handlers; the interface that named the one step they supply is gone, that step is a parameter.
The channel was set to nil once Init() had decided, read without synchronization by the handlers, and the send blocked on a buffer sized to the thread count. A background worker can tick, exit and fail its next boot while Init() finishes, which an HTTP worker cannot since it blocks once ready: that exit could race the nil write, or block its thread on a full buffer. The channel now stays, an atomic startup flag gates the sends, and the send never blocks.
Until its first frankenphp_worker_tick(), a run is under the limit like any request: a setup that outlives it ends as a boot failure, with the backoff and the cap. The first tick disarms the timer, and nothing re-arms it past that point, so the loop has no time limit, like the CLI. This replaces the per-request ini override, which exempted the bootstrap too.
Cancelling the handle's watcher only removes that one callback, and run() keeps going while any other referenced watcher exists. A drained worker has to leave its loop, which is the driver's stop().
… fire The limit is PHP's: on Windows CI the busy bootstrap ran its full five seconds without the timer ending it, so the test now runs only with the Zend max execution timers of ZTS builds on Linux, where it passes.
The shared-state half of php#2287, on top of the background workers: a worker publishes a snapshot with frankenphp_set_vars(), requests and other workers read it with frankenphp_get_vars(). The persistent-zval toolkit from php#2366 does the cross-thread copies; this adds the two functions and a per-worker slot. set_vars() validates the tree, persists it and swaps it into the slot under a write lock; readers copy it into request memory under the read lock, so the previous table is only freed once no reader is on it. The slot belongs to the worker rather than a thread: it survives script restarts, serving the last snapshot meanwhile, and several threads of one worker simply publish last-writer-wins. The tables are freed in drainPHPThreads() once every PHP thread is gone and before the engine is, since freeing walks string headers. get_vars() resolves the name the way requests do, within the caller's server then among global workers. It blocks until the worker reached its ready point once: activateServers() runs after initWorkers(), so requests never wait, and a blocked caller is another background worker still booting. Those waits form a graph and a cycle is refused with an exception instead of deadlocking Init(); the wait also aborts on shutdown. A ready worker that never published throws. Publishing before the first wait on the handle therefore guarantees the snapshot exists before the server accepts requests. Being the first consumer keeping persistent trees across requests and exposing them repeatedly, this also fixes two fast paths of the toolkit: opcache-immutable arrays were exposed through refcounted zvals, and opcache only keeps their refcount at 2, so the second reader's release destroyed shared memory; and every interned string was shared by pointer, while only permanent ones (opcache, startup) outlive the request that interned them, so trees built from request-interned literals dangled once that request ended (the Windows job runs the embed without opcache). Immutable arrays now go through zvals without type flags, as php-src does for literals, and sharing a string requires IS_STR_PERMANENT. Left out on purpose, see php#2287: the per-request cache with === identity, the unchanged-data skip in set_vars(), ensure_background_worker() and lazy or catch-all workers, CLI hiding of the functions.
The task half of php#2319, on top of the background workers and their shared vars: a request, an HTTP worker or another background worker hands work to a named background worker with frankenphp_send_task(), which returns a stream carrying the updates the worker sends back with frankenphp_update_task(). The worker dequeues tasks with frankenphp_receive_task() once frankenphp_worker_tick() returned: the one handle of php#2617 carries both the drain EOF and the wake-ups, so a script keeps a single stream_select() loop, and the tick consumes what the runtime wrote on it. A wake-up is not a count: a pool wakes one thread per task and the others get null, and in a pool it may belong to a task a sibling took. The tick also parks the thread for the senders, or makes the coming wait return at once when tasks are already queued. send_task() blocks until a thread of the worker picks the task up and throws on timeout, so a busy worker pushes back on its senders instead of queueing without bounds; tasks queued while a thread restarts are signaled again on its next run. The wait also ends when the sender's own thread is drained for a restart or the shutdown, since the target's threads are drained too. Names resolve like frankenphp_get_vars() does. Each task gets a socket pair. The sender's stream is a socket stream over one end, one byte per update and EOF at completion, so stream_select() bounds the wait or multiplexes tasks, and a blocking read parks as well; closing it abandons the task. The receiver's stream is a socket stream over the other end: updates go through update_task(), the stream itself reports the sender's close as EOF to stream_select() and feof(), so a long task learns that nobody waits for its result, and update_task() throws. Closing it completes the task, unless the close is the resource cleanup of request shutdown, which means the script ended with the task open: the sender's next read throws instead of returning null. Sixteen updates are buffered per task, past that update_task() waits for the sender to read. Waking threads is what a task costs, so wake-ups are kept to a minimum. A send wakes one parked thread of the worker, round-robin, with the line on its handle; a thread that reads its handle while tasks are queued gets the line from the read op itself, so no wake-up is lost whichever loop shape the script uses, and after 10ms without pickup every thread is woken as a fallback. The sender waits for the pickup in the kernel, on its end of the task's pair, rather than in a Go select: waking a PHP thread parked inside a cgo callback costs Go a P hand-off, a byte on a socket does not. The thread taking the task writes that byte, a watcher goroutine does when the wait must end without a pickup. The queue mutex is never held across a syscall and taken once per wake-up, as a thread inside a cgo callback that loses it parks the same expensive way. In the Docker builder image this takes a task from 549 to 285us with one thread, a pool of 8 from 1745 to 320us, and 8 senders on 8 threads from 2k to 32k tasks/s. Each side of a task waits on its own descriptor of the task's channel, an eventfd on Linux, one end of a socket pair elsewhere for Windows's php_select(): the streams carry no data, they are what stream_select() waits on and what fclose() ends, the Go side holds the state the functions report. The descriptors belong to the task until both sides closed, then the pair is drained and pooled, so a task costs no socketpair, fcntl or close: about 12 syscalls instead of 18, 13% off the latency and up to a third more throughput under load in the same measurement. Payloads and updates follow the set_vars() whitelist and travel as persistent tables through the Go side, which owns them until they are copied into request memory. The streams reference their task through a cgo handle; the task is freed once both sides closed, or by the sender when no thread picked it up. The stop sockets of a worker's threads are now guarded by its task queue mutex, since senders write to them. Compared to php#2319: no queue ahead of pickup and no cancellation before it, no dedicated signaling stream, no global task table.
The existing worker metrics apply to background workers as they are:
busy_workers counts a thread holding a task, from pickup to the close of
the task's stream, and worker_queue_depth counts the tasks waiting for a
thread, which is the only queue a background worker has. Two new ones
break tasks down: worker_task_count{worker,outcome} with completed,
aborted (the script ended with the task open), abandoned (the sender
closed its stream first) or timeout (no thread picked the task up in
time), settled by whichever side closes first so every task counts once,
and worker_task_time, the seconds spent on tasks from pickup to close.
The threads endpoint follows: a background thread is busy while it holds
a task and waiting otherwise, counted per thread since a script may hold
several.
nicolas-grekas
force-pushed
the
bgworker-metrics
branch
from
September 14, 2026 06:16
de9d9a3 to
2514be8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #2617, #2635 and #2636: the first three commits are those PRs', review the fourth one (
2514be8).Metrics for the tasks of #2636, reusing what exists where the meaning carries over.
frankenphp_busy_workersnow counts a background worker thread holding a task, from pickup to the close of the task's stream, andfrankenphp_worker_queue_depthcounts the tasks waiting for a thread, the only queue a background worker has; their help texts say so. Two counters break tasks down:frankenphp_worker_task_count{worker,outcome}withcompleted,aborted(the script ended with the task open),abandoned(the sender closed its stream first) andtimeout(no thread picked the task up in time), settled by whichever side closes first so every task counts exactly once, andfrankenphp_worker_task_time{worker}, the seconds spent on tasks. The/frankenphp/threadsendpoint follows: a background thread is busy while it holds a task, waiting otherwise.The
Metricsinterface gainsStartWorkerTask,StopWorkerTaskandWorkerTaskOutcome. Tested through a Prometheus registry passed withWithMetrics(): one task of each outcome, then the gauges back to zero.