Decision records

ADR-0032: FastCGI connection drivers for managed PHP-FPM pools

Status: accepted (2026-10-06)

Context

Managed PHP-FPM pools use persistent FastCGI connections (FCGI_KEEP_CONN, see docs/benchmarks/2026-10-05-php-profile.md), at most max_children of them, because a kept-alive connection pins an FPM worker. Until now every request acquired a semaphore permit, was run by a freshly spawned task that took an idle connection (from its own event loop’s shard, else another loop’s, re-registering the socket), and released the permit when the response ended.

Measured on the development server (4 static FPM workers on 2 CPUs, 64 clients):

req/sFPM workers busyFPM CPU per request
scalws, persistent connections19.9–23.0k1.19–1.34 CPUs~59 µs
scalws, connection per request21.3–23.5k1.55–1.63 CPUs~71 µs
nginx (connection per request)24.9–27.3k1.73–1.82 CPUs~67 µs

Persistent connections are the cheapest per request, but the workers idled for about a third of the time: after a response ended, the next request reached the connection only after a permit release, a task wake-up, a spawn and possibly a re-registration on another event loop. Behind nginx the next request is already waiting in the socket backlog.

Decision

A managed pool is served by up to max_children connection drivers: tasks that each own one connection and take requests from a shared FIFO queue. When a response ends the driver writes the next queued request immediately, in the same task. Drivers start on demand (on the event loop of the request that needed them) and stop after 10 s without work. Stale-connection detection, the single replay of buffered requests on a fresh connection, fresh connections for streaming bodies and the max_children bound are unchanged. External pools keep one connection per request.

Alternatives measured or rejected

  • Drivers on a dedicated runtime thread (to react faster than a busy event loop): measured worse in four alternating runs; reverted.
  • Pipelining (writing the next request while the worker still runs the current one): rejected without implementation. A request written behind a slow script waits for it even when other workers are free — head-of-line blocking that nginx does not have, and a written request cannot be taken back.

Consequences

Measured A/B (same binary otherwise, four alternating rounds): 17.2–20.4k → 23.6–26.9k req/s, workers busy 1.20–1.31 → 1.40–1.50 CPUs, FPM CPU per request 64–70 → 54–60 µs. A request now waits in the pool queue instead of on a semaphore; the handler’s response timeout still bounds the wait (an abandoned request is skipped by the driver). Driver output reaches the request’s task over the same bounded channel as before.