D106 — Postgres safety valves, a pre-flight guard + kill switch, and dev-stack speed levers for the parallel suite (RESOLVED)
Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. The mechanics this ADR describes live in
App\Providers\ParallelTestingServiceProvider, theMakefile,docker-compose.yml, anddocker/Dockerfile.dev; the operational view is on Testing & the gates and Local development.
Context
A 20-hour investigation into make test-parallel runs that wedge indefinitely (never completing,
never failing — just stuck, with no Postgres deadlock ever logged) found three independent, stacked
problems:
- A cross-connection self-deadlock invisible to Postgres's own deadlock detector. Each worker's
two connections (
pgsql,pgsql_logs) are two separate sessions onto the SAME token database. Some code path leaves thepgsql_logssession idle-in-transaction; that session holds a lock the between-testTRUNCATE ... CASCADE(issued on thepgsqlsession) needs, sopgsqlblocks. This is a real deadlock in effect — one worker permanently wedged — but Postgres's cycle-detection algorithm never sees it, because the two sessions never form a cycle IT can observe (the idle-in-transaction session isn't itself waiting on anything Postgres tracks as a lock request). Nothing times out; the run just never finishes. - Interrupted non-TTY runs leave zombie pest processes. Killing a
make test-parallelinvocation that has no controlling terminal — the agent/CI case; an interactive terminal run already gets a TTY via the Makefile's existingTTY_FLAGand Ctrl-C propagates into the container through it — does not tear down the paratest master + worker processes inside the app container.docker compose execsimply detaches; the exec'd process keeps running, orphaned, holding database connections and CPU that the next run then contends with. - The suite is DB-bound. Each worker's per-test reset is a ~69-table
TRUNCATE ... CASCADEinside one transaction, plus whichever tests callmigrate:fresh; N workers doing this concurrently means N concurrent large transactions racing the same physical disk for WAL fsyncs, on a dev/CI stack where durability past the run buys nothing (state is rebuilt from migrations every time regardless).
Decision
Four independent, composable fixes — none depends on the others, each is individually revertible.
1. Postgres safety valves (ParallelTestingServiceProvider)
ALTER DATABASE "<db>" SET idle_in_transaction_session_timeout = '120s' and
ALTER DATABASE "<db>" SET lock_timeout = '120s', applied:
- To every token database, in the
setUpProcesshook, right after it is created (or confirmed to already exist from a prior run) — idempotentALTER DATABASE … SET, safe to re-issue. This part was never racy: each worker's token database is distinct, so no two processes everALTERthe same one. - To the base
stables_testingdatabase —setUpProcessonly fires under--parallel(the framework guards everyParallelTestinghook inwhenRunningInParallel()), so serialmake testneeds a separate application, and where each fires is deliberately split to avoid a cross-process race (caught by QA on the first real full-suite run, not by review — see below):boot()applies it, but ONLY whenParallelTesting::token()is falsy — true for a genuine serial run (no parallel machinery at all) and, harmlessly, for the paratest master process (it carries no token of its own either, but is a single process running sequentially before any worker starts, so it cannot race itself). Every WORKER process carries its own truthy token, so this branch is never reached there.setUpProcessapplies it too, guarded by the same static once-applied flag, because that hook runs sequentially in the single parent process, once per token, before any worker starts — the same reasoning the pre-existingensureRlsRoleExists()call already relied on.- The bug this replaced: the first version of this code applied the base-database valves
unconditionally in
boot(), guarded only by the static flag. That flag is a private static property — process-local memory, not shared state — and each of the N parallel workers is a SEPARATE PHP process with its own copy, defaulting tofalse. Every worker'sboot()therefore independently decided "not yet applied" and issuedALTER DATABASE "stables_testing" SET …concurrently against the one shared base database, which Postgres serializes at the catalog level — losing races intermittently threwSQLSTATE[XX000] tuple concurrently updated, failing whichever test happened to be booting in the losing worker (reproduced 2/2 full-suite runs; a different test failed each time —CustodyCommandsRequireProviderTestin one run,ExampleTestin the other — because WHICH worker loses the race, and what it happens to be doing at that moment, is non-deterministic). The fix moves the only-ever-racy path (an unconditional per-workerboot()call) onto the two paths that are either genuinely single-process (serial) or genuinely sequential-within-one-process (the parent'ssetUpProcessloop) — never onto a path any worker actually takes.
Both settings are per-database defaults applied to every future session against that database —
not the issuing connection's own session — so they take effect for exactly the sessions that matter
(the worker's pgsql/pgsql_logs pair) without needing every call site to set them itself. A stuck
idle-in-transaction session now aborts after 2 minutes instead of wedging the run indefinitely; a
TRUNCATE that can't acquire its lock within 2 minutes fails loudly instead of hanging silently.
2 minutes was chosen as comfortably longer than any single test or between-test reset should ever
take, so it can't misfire on a merely-slow-but-healthy run.
2. Pre-flight guard + make test-kill
make test and make test-parallel both depend on a new test-guard prerequisite target that
checks the app container for an already-running suite and aborts with a pointer to make test-kill
rather than starting a second run on top of a wedged one (compounding the DB contention that
motivated fix #3 below). make test-kill finds and kills the pest master + worker processes inside
the app container; idempotent (exits 0 whether or not anything was running).
Both use pgrep -f '[p]est/bin' — verified against this app's real process list, not assumed:
php artisan test --parallel spawns a master at vendor/pestphp/pest/bin/pest and N workers at
vendor/pestphp/pest/bin/worker.php (Pest's own parallel runner; this app never shells out to a
literal vendor/bin/paratest binary), so a pattern of bin/pest alone — the first thing tried —
matches the master but misses every worker (bin/worker.php, not bin/pest). pest/bin is the
substring both processes' command lines actually share. The bracketed first character
([p]est/bin) is the classic ps | grep '[p]attern' self-exclusion idiom: pgrep -f "pest/bin"
matches its OWN argv, because the string pest/bin appears verbatim in the very command line pgrep
is scanning; splitting the first character into a single-character regex class keeps the match
semantically identical while making the invoking command's own text ([p]est/bin, brackets
included) fail to contain the literal substring the pattern requires.
Both test and test-parallel also trap INT/TERM and run the same in-container kill on
interrupt, so a killed non-TTY run (fix #2 in Context) cannot leave zombies. Two things were needed
to make the trap actually fire, not just look like it does — both verified empirically against
dash (this Makefile's /bin/sh):
- The long-running command runs backgrounded and
waited (cmd & pid=$!; wait "$pid"), not plain foreground. POSIX shells defer a trapped signal's action until the current FOREGROUND command exits — so a trap wrapping a genuinely wedgeddocker compose execwould install correctly, receive the signal correctly, and still never run, because the deferred action waits for a child that (by definition, for the run this exists to fix) may never exit on its own.waitis the documented POSIX exception: a trapped signal interrupts it immediately. Confirmed by timed reproduction: a plain-foreground trap took 0 visible effect within 15s ofSIGTERMagainst a 30-second sleep; the backgrounded-waitform fired within 0.5s of the same signal. - The cleanup itself is a second, short-lived
docker compose exec, because the pest processes live inside the container — killing the client-sidedockerprocess alone leaves them running, orphaned, which is exactly the zombie state this exists to prevent. - Not
$(MAKE) test-kill. GNU Make always executes any recipe line whose text actually EXPANDS the$(MAKE)variable, even under-n(so a recursivemakeinvocation still sees the dry-run flag) — confirmed by direct reproduction: a trap calling back into$(MAKE) test-killmademake -n test-parallelactually run the full suite. The kill logic is duplicated inline instead (mentioningmake test-killin a comment, with no actual variable expansion, does not trip this — also confirmed by direct reproduction).
3. Postgres non-durability flags (docker-compose.yml)
The postgres service's command: adds -c fsync=off -c synchronous_commit=off -c full_page_writes=off, alongside the existing max_connections=200 -c max_locks_per_transaction=256 tuning. A deliberate dev/CI-only durability tradeoff: a crash means
re-seed, which is an acceptable cost on a stack that rebuilds its schema from migrations every run
and never persists anything that matters past a down -v. Verified before making this change that
nothing production-facing consumes this file: production and preview both build from
docker/production/Dockerfile via .github/workflows/build-image.yml; docker-compose.yml is
dev-only, with CI layering docker-compose.ci.yml on top of the identical base (D99) — there is no
path from this file to a deployed environment.
4. OPcache for the CLI SAPI (docker/Dockerfile.dev)
docker/opcache-cli.ini sets opcache.enable_cli=1 plus a file cache
(opcache.file_cache=/tmp/opcache, directory created in the image), copied into
/usr/local/etc/php/conf.d/. Investigated rather than assumed: php:8.5-cli-alpine compiles OPcache
directly into the PHP binary — php -m shows Zend OPcache loaded with no matching file under the
extension directory, and docker-php-ext-enable opcache itself refuses ("already compiled into
PHP") — so neither docker-php-ext-enable nor -install applies; only opcache.enable_cli (off by
default; opcache.enable is already on, but that setting governs the web/FPM SAPIs, not CLI) needed
flipping. The file cache exists because long-lived paratest workers are not this app's only
CLI-heavy pattern: every freshly spawned artisan/pest invocation — a new process each time — gets
no benefit from an in-memory-only opcode cache scoped to one process's lifetime, but does benefit
from a cache that persists compiled opcodes on disk across process boundaries. Deliberately not
touching Xdebug: XDEBUG_MODE=off already keeps it cheap at runtime, and enabling it was out of
scope for this change.
Consequences
- A wedged parallel run under the traced failure mode now surfaces as a loud, bounded failure (a session/lock timeout inside 2 minutes) instead of an indefinite hang with no diagnostic signal.
make test-killand the pre-flight guard turn "is something already running" from a manualdocker exec ... ps auxinvestigation into one command with a clear message.- An agent/CI run that gets killed non-interactively no longer leaves zombie pest workers holding database connections for the next run to contend with.
- The dev/CI Postgres and PHP CLI runtime both trade a little safety/durability that this stack never needed for real speed on the exact workload (TRUNCATE/migrate-heavy, process-spawn-heavy) that dominates suite wall-clock time.
- Speed was investigated but not benchmarked as part of this change. The rationale for each
lever (WAL fsyncs on a TRUNCATE-heavy workload; re-parsing the framework on every process spawn) is
sound on its face, but no before/after suite-timing numbers were captured here — the director
schedules the actual
make test-paralleltiming run separately (after the image rebuild this change requires), one suite run at a time, per this repo's shared-stables_testing-DB constraint. If a re-benchmark ever shows one of these levers is not paying for itself, it can be reverted independently — none of the four fixes depends on any other.
Alternatives rejected
- A
SELECT pg_terminate_backend(...)sweep instead ofidle_in_transaction_session_timeout. Would require something to run the sweep (a cron, a pre-test hook) and still races the exact window where the deadlock forms; a per-database session default requires nothing to remember to run it and applies to every future connection unconditionally. pkill -f pestwithout the bracket trick. Matches its own invocation's argv (the literal string appears in the very command line being scanned), sotest-guardwould report a phantom "already running" suite on every invocation, andtest-killwould never report "nothing to kill" even when nothing was running. Rejected outright once the self-match was reproduced.pgrep -f 'bin/pest'alone (no worker coverage). The first pattern tried; verified against the real process list to miss every worker process (bin/worker.php), which would silently defeat bothtest-guard(never detects a running suite because only the short-lived master transiently matches) andtest-kill(leaves all N workers running after "killing" only the master).$(MAKE) test-killfrom the interrupt trap, for the obvious DRY win over duplicating the kill logic inline. Rejected once reproduced that it breaksmake -nfor bothtestandtest-parallel(GNU Make's documented always-execute-under--nspecial case for any recipe line that expands$(MAKE)), which the verification step for this change explicitly checks.- A plain foreground
trap 'cleanup' INT TERM; long_running_cmd(the textbook idiom, and the first thing tried here). Reproduced to not run its cleanup within 15 seconds ofSIGTERMagainst a foreground child that ignores the signal itself — dash defers the trap until the foreground command exits, which is precisely the condition that doesn't hold for a wedged run. Rejected in favour of the backgrounded-waitform once the deferred-trap behavior was confirmed empirically. - Enabling Xdebug tuning as part of this change.
XDEBUG_MODE=offalready keeps it cheap; adding scope here would mix an unrelated lever into a change whose Context section is specifically about the traced wedge + the DB-bound workload. Left for its own change if ever warranted. - Tuning the default worker count. The investigation's report named this as a lever, and it is
deliberately not touched here: the right count is a property of the machine running the suite (CPU
cores, RAM, how much else is competing for them), not of the repo, so there is no single default
that is right for everyone. It is already steerable per-run without any code change —
make test-parallel ARGS="--processes=N"— and the Makefile's own comment on that target already documents the ≤16 ceiling (Valkey's logical DB count) and the "on a host with >16 cores" case. Left as a per-developer/CI-runner tuning knob, not a repo default to chase. - Quiescing the dev stack (
docker compose stop vite/scheduler/worker) during a run. Also named in the investigation's report, and also deliberately left out: whether those services are worth stopping depends on what else is running on the SAME machine at the SAME time (a solo dev running nothing else loses little either way; a CI runner sharing the box with other jobs might gain more), which makes it a machine-local operational choice rather than something with one correct repo-wide default. It is also not free to make default: stoppingvitemid-run would break anyone iterating on the frontend in a second terminal while the suite runs, and stoppingscheduler/workerwould silently change what a test that happens to dispatch a queued job actually observes. Left as a manual, situational step —docker compose stop vite worker schedulerneeds no new Makefile target — not wired intotest-parallelitself. - Raising the safety-valve timeouts well above 2 minutes "to be safe." A timeout long enough to never legitimately fire is a timeout that also takes that long to surface a genuine wedge — the entire point of a bounded failure over an indefinite hang. 2 minutes was chosen as comfortably longer than any healthy test or reset should take, not as a generous margin against uncertainty.