feat: declared background workers + frankenphp_get_worker_handle() - #2617
feat: declared background workers + frankenphp_get_worker_handle()#2617nicolas-grekas wants to merge 1 commit into
Conversation
|
Please rewrite the PR description to not be LLM slop reasoning with itself about what it did and why. I've tried reading this three times and I just can't. |
|
Sure, I'll let you know when I'm done, for now I just let it do the rebase 馃槄 |
henderkes
left a comment
There was a problem hiding this comment.
What happens here when a global background worker and a php_server scoped background worker share the same name and are both eligible for the same source file?
henderkes
left a comment
There was a problem hiding this comment.
found another one, anyway, have you tested this on windows?
There was a problem hiding this comment.
Pull request overview
Adds declared background PHP workers with graceful stop-stream handling and Caddy configuration support.
Changes:
- Adds background-worker lifecycle, validation, and thread allocation.
- Exposes
frankenphp_get_worker_handle(). - Adds Caddy integration, documentation, fixtures, and tests.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
worker.go |
Registers and validates background workers. |
threadbackgroundworker.go |
Implements background-worker lifecycle. |
requestoptions.go |
Rejects background workers for HTTP requests. |
phpthread.go |
Drains handlers during shutdown and transitions. |
phpmainthread.go |
Drains handlers during reboot. |
options.go |
Adds WithWorkerBackground(). |
frankenphp.go |
Reserves background-worker threads. |
frankenphp.c |
Implements stop pipes and PHP API. |
frankenphp.h |
Declares C primitives. |
frankenphp.stub.php |
Declares the PHP function. |
frankenphp_arginfo.h |
Registers generated arginfo. |
docs/config.md |
Documents background configuration. |
caddy/workerconfig.go |
Parses background worker blocks. |
caddy/config_test.go |
Tests Caddy parsing and validation. |
bgworker_test.go |
Tests lifecycle, restart, scope, and validation. |
testdata/bgworker/basic.php |
Provides lifecycle fixture. |
testdata/bgworker/crash.php |
Provides restart fixture. |
testdata/bgworker/early-return.php |
Provides startup-failure fixture. |
testdata/bgworker/named.php |
Provides named-worker fixture. |
馃挕 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // background workers reserve their thread budget separately so they | ||
| // don't count against the HTTP-oriented admission checks below; the | ||
| // bump is applied on top of the calculated totals at the end | ||
| reservedThreads := 0 | ||
| defer func() { | ||
| if err != nil { | ||
| return | ||
| } | ||
| opt.numThreads += reservedThreads | ||
| if opt.maxThreads > 0 { | ||
| // in auto mode (maxThreads < 0), the resolved value is floored | ||
| // to numThreads later, which already includes the reservation | ||
| opt.maxThreads += reservedThreads | ||
| } | ||
| numWorkers += reservedThreads | ||
| }() | ||
|
|
||
| for i, w := range opt.workers { | ||
| if w.isBackgroundWorker { | ||
| if w.num < 1 { | ||
| return 0, fmt.Errorf("background worker %q must declare num >= 1", w.name) | ||
| } | ||
| metrics.TotalWorkers(w.name, w.num) | ||
| reservedThreads += w.num | ||
|
|
||
| continue | ||
| } | ||
|
|
||
| if w.num <= 0 { | ||
| // https://github.com/php/frankenphp/issues/126 | ||
| opt.workers[i].num = maxProcs | ||
| } |
There was a problem hiding this comment.
Hmm I don't remember the specific reason why background workers are treated separately here. Wouldn't it make sense to just do this and count it into the general pool, like other workers:
if w.num <= 0 {
if w.isBackgroundWorker {
opt.workers[i].num = 1
} else {
opt.workers[i].num = maxProcs
}
}Worker thread count is already added on top of the general thread count. It would only overflow in case someone sets a general cap on global threads, in which case it should probably still honor that cap.
There was a problem hiding this comment.
The num_threads / max_threads budget exists for autoscaling HTTP workers, and background workers take no part in that: they don't scale, don't queue requests and never compete for a free thread. Counting them into the pool would change what the budget means depending on how many background workers a config declares: declare five and you silently get five fewer HTTP threads, so people would have to bump the budget just to keep the capacity they had, and the setting stops describing HTTP capacity. That's why they're reserved on top: the HTTP admission math is untouched and the totals are bumped afterwards (reservedThreads). Requiring an explicit num keeps that reservation visible in the config rather than defaulted.
| $stream = frankenphp_get_worker_handle(); | ||
| $read = [$stream]; | ||
| $write = null; | ||
| $except = null; | ||
| stream_select($read, $write, $except, null); |
There was a problem hiding this comment.
It looks like currently the handle is only used for shutdown. IIRC in the future you'd also want to use the handle to send messages or even requests.
Would it maybe be cleaner to have a separate handle for each? Makes the api look more like we're selecting over different channels, in other words:
frankenphp_get_shutdown_handle(); # instead of frankenphp_get_worker_handle
frankenphp_get_message_handle(); # future scope: can return a dedicated message
frankenphp_get_request_handle(); # future scope: can return a dedicated request objectThere was a problem hiding this comment.
I'd rather keep one handle. In the prototype built on this primitive, shutdown, messages and requests all arrive on the same stream as typed messages, and the worker loop is a single stream_select() plus a dispatch on what was read; that worked well in practice. One handle per kind means selecting over N streams, N functions to document and keep in sync, and ordering questions between them (a message landing after shutdown was signalled on another stream). Fewer functions is also less API to get wrong. This PR only uses the EOF-on-drain part, but the handle is meant to carry the rest.
ac0896d to
3e93a24
Compare
|
Two review-level items. Name collision between a global and a Windows: the Windows workflow runs the full suite on PRs and it passes here on 8.5.10, background worker tests included. It also surfaced that The branch is squashed to 3e93a24; sha references in earlier replies predate the squash. |
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 and exposed as $_SERVER['FRANKENPHP_WORKER'], match is rejected, num >= 1. The lifecycle mirrors HTTP workers: re-run on a cooperative exit, restart with 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. 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 every call returns a fresh stream and closing one never affects another; 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. 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. The collision-driven renaming in the Caddy module is gone, and WithWorkerName() resolves within the request's server first. Supersedes php#2543 and php#2398.
3e93a24 to
2f9c5b6
Compare
|
Since the replies above, a self-review pass amended into the single commit (2f9c5b6):
CI: all test jobs pass. The Windows job's caddy-suite timeout ( |
Background workers run a script in a loop outside the HTTP request cycle, sharing the PHP runtime with the request threads. This is the smallest useful slice of #2398, rebuilt on the
Serverof #2499: the parallelScopemachinery is gone, a background worker attaches to aphp_serverthroughWithWorkerServerScope()like any other worker.Declared with
backgroundin a worker block (php_serveror global) orWithWorkerBackground()in Go.nameis required, it is the script's identity and is exposed as$_SERVER['FRANKENPHP_WORKER'];matchis rejected;num >= 1, no lazy start here. The lifecycle mirrors HTTP workers: re-run on a cooperative exit, restart with quadratic backoff on a crash,max_consecutive_failuresfailsInit()during startup only.drain()now runs on shutdown, reboot and handler transitions, so a parked script wakes up instead of waiting out the force-kill grace period.The script gets one handle,
frankenphp_get_worker_handle(): resource, a stream that reaches EOF when the worker is drained. It is meant to carry control messages later, hence one handle rather than one per purpose. It is backed by a socket pair, not a pipe: on Windows PHP'sphp_select()only waits properly on sockets before 8.5, and the socket path is version-independent. Streams don't own the socket (php_sockop_close()wouldshutdown()it on Windows), so every call returns a fresh stream and closing one never affects another; the read timeout is infinite so a blocking read parks as well asstream_select()does.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_workerscounts from it, and an exit before it is a boot failure. Fetching the handle is not the ready point, nothing forces a script to fetch it after bootstrapping.Worker names are now scoped like paths: unique within a
php_serveror among global workers, so two blocks may each declarequeue. 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. The collision-driven renaming in the Caddy module is gone.Deferred: lazy start (
frankenphp_ensure_background_worker()), catch-all workers, shared-state APIs, and the orchestrator-style runtime API discussed in #2398.Supersedes #2543 and #2398.