Skip to content

Commit a2c02af

Browse files
committed
docs: document worker mode and the statelessness it requires
Worker mode needs no package and no code change — symfony/runtime has shipped FrankenPhpWorkerRunner since 7.4 and the Caddyfile already reads {$FRANKENPHP_CONFIG} — so it is documented as an environment variable, with what it measured here and the caveats on those numbers. The statelessness rules go in claude.md as a section rather than a bullet, because messenger:consume is already long-running in production and the rules apply whether or not worker mode is on. Each rule points at the service in this codebase that follows it.
1 parent c973f0e commit a2c02af

3 files changed

Lines changed: 85 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4747
consumer, which in a worker outlives the request
4848
- Cover all of it with tests: the factories had none, and neither the
4949
dashboard nor the Security Contract CRUD was in the admin smoke test
50+
- Document worker mode and the statelessness it requires in `README.md` and
51+
`claude.md`. It stays off: measured here it gives roughly 20% more requests
52+
per second on `/admin` and half the median latency, but around 40% fewer on
53+
`/health/live`, and the numbers come from a laptop sharing CPU with other
54+
containers
5055
- [#96](https://github.com/itk-dev/devops_itksites/pull/96)
5156
Show the Service Agreements monthly price as Danish kroner,
5257
`12.500,50 kr.`, on index and detail

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,45 @@ does not carry: this build has `console`, `json`, `append`, `filter` and
154154
`journald`. JSON also matches supercronic, which the fpm image already runs with
155155
`-json`.
156156

157+
#### Worker mode
158+
159+
Worker mode is off. Turning it on needs no PHP package and no code change:
160+
`symfony/runtime` has shipped `FrankenPhpWorkerRunner` since 7.4, FrankenPHP
161+
sets `FRANKENPHP_WORKER=1` for a worker script, and `SymfonyRuntime::getRunner()`
162+
switches on that. `.docker/Caddyfile` reads `{$FRANKENPHP_CONFIG}`, so the switch
163+
is an environment variable on the `frankenphp` service:
164+
165+
```yaml
166+
environment:
167+
FRANKENPHP_CONFIG: worker /app/public/index.php
168+
```
169+
170+
Add a count – `worker /app/public/index.php 8` – to override the default, which
171+
is twice the number of CPU cores. Keep `num_threads` × `memory_limit` below the
172+
memory available to the container.
173+
174+
What it bought here, measured on this project in `prod` with a warm OPcache:
175+
about 20% more requests per second on `/admin` and half the median latency,
176+
against about 40% *fewer* on `/health/live`. The trivial endpoint is worker
177+
mode's worst case – there is no per-request work for the saved kernel boot to be
178+
weighed against, and the runner's `gc_collect_cycles()` on every request is not
179+
free. The numbers come from a laptop sharing CPU with other containers and
180+
running the application over a bind mount, so treat them as a shape rather than
181+
a figure, and measure again on a server before adopting.
182+
183+
**Services must not carry request state.** Under php-fpm a service instance died
184+
with the request; in a worker it does not, so anything a service remembers leaks
185+
into the next request. Prefer keeping services stateless. Where state is
186+
deliberate, implement `Symfony\Contracts\Service\ResetInterface` –
187+
`autoconfigure` tags it `kernel.reset` and Symfony calls it between requests.
188+
For an object you do not own, clear it at the call site, the way every
189+
`AdminUrlGenerator` chain here opens with `unsetAll()`.
190+
191+
`FRANKENPHP_RESET_KERNEL=1`, on Symfony 8.1 and later, clones the kernel between
192+
requests instead. It hides this class of bug at the cost of a boot per request,
193+
which is most of what worker mode is for – useful to compare against, not to
194+
depend on.
195+
157196
#### Metrics
158197

159198
`/metrics` serves Prometheus metrics from Caddy, behind the `ITKMetricsAuth@file`

claude.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,44 @@ docker compose exec frankenphp composer queues
8585
docker compose run --rm node yarn install && docker compose run --rm node yarn build
8686
```
8787

88+
## Long-running processes
89+
90+
The site is served by a single FrankenPHP container in place of phpfpm and
91+
nginx. Worker mode is off but available — `symfony/runtime` has shipped
92+
`FrankenPhpWorkerRunner` since 7.4, and `.docker/Caddyfile` reads
93+
`{$FRANKENPHP_CONFIG}`, so it is an environment variable, not a code change:
94+
95+
```yaml
96+
FRANKENPHP_CONFIG: worker /app/public/index.php
97+
```
98+
99+
`messenger:consume` is already long-running in production regardless, so the
100+
rules below apply whether or not worker mode is on.
101+
102+
**Writing a service that has to remember something:**
103+
104+
1. Prefer statelessness. If the state only needs to live for one method call,
105+
make it a local and thread it through the private helpers — see
106+
`PackageVersionFactory`, whose deduplication buffers work this way. The
107+
`ResetInterface` docblock advises this over the interface where possible.
108+
2. Where the state is deliberate, implement
109+
`Symfony\Contracts\Service\ResetInterface` and clear everything in
110+
`reset()`. `autoconfigure` tags it `kernel.reset` with no manual tagging —
111+
see `LeantimeService`, which caches the Leantime user directory because
112+
`resolveUserName()` runs in a loop.
113+
3. For an object you do not own, clear it where you use it. Every
114+
`AdminUrlGenerator` chain in this codebase opens with `unsetAll()` for this
115+
reason: EasyAdmin registers it `shared: no`, but the services holding it are
116+
shared, so the instance outlives the request.
117+
118+
Things that break a worker and have no place here: `exit()`/`die()`, writes to
119+
superglobals, `__destruct()` on a shared service, and mutable `static`
120+
properties.
121+
122+
`FRANKENPHP_RESET_KERNEL=1` (Symfony 8.1+) clones the kernel between requests
123+
and papers over all of this, at the cost of a boot per request. Treat it as a
124+
measurement baseline, not a fix.
125+
88126
## Quality Checks
89127

90128
All commands run inside Docker containers:
@@ -146,3 +184,6 @@ Pull requests run these checks:
146184
- Async processing uses Symfony Messenger with AMQP transport
147185
- Environment-specific config goes in `.env.local` (not committed)
148186
- API specs (`public/api-spec-v1.yaml` and `.json`) must be regenerated and committed when API changes
187+
- Services must not carry request state. The web container and the messenger
188+
consumer are both long-running, so anything a service remembers outlives the
189+
request that put it there

0 commit comments

Comments
 (0)