Skip to content

Commit c91c4a6

Browse files
docs: background workers reference
Covers the full public API landed across the preceding steps: the named/catch-all Caddyfile configuration, the two-mode frankenphp_ensure_background_worker() semantics (fail-fast at HTTP bootstrap, tolerant elsewhere) and its batch form, the pure-read frankenphp_get_vars(), frankenphp_set_vars() with its allowed value types (scalars, nested arrays, enum cases), the signaling stream via frankenphp_get_worker_handle(), and runtime behaviour (dedicated threads, $_SERVER flags, crash recovery with stale vars, 5-second grace period followed by force-kill, per-php_server scoping, and the pool / multi-entrypoint limits).
1 parent ece9632 commit c91c4a6

1 file changed

Lines changed: 254 additions & 0 deletions

File tree

docs/background-workers.md

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
# Background Workers
2+
3+
Background workers are long-running PHP scripts that run outside the HTTP request cycle.
4+
They observe their environment and publish variables that HTTP threads (both [workers](worker.md) and classic requests) can read in real time.
5+
6+
## How It Works
7+
8+
1. A background worker runs its own event loop (subscribe to Redis, watch files, poll an API, etc.).
9+
2. It calls `frankenphp_set_vars()` to publish a snapshot of key-value pairs.
10+
3. HTTP threads call `frankenphp_ensure_background_worker()` to declare a dependency and make sure the worker is running (lazy-started if needed, blocks until it has published at least once).
11+
4. HTTP threads then call `frankenphp_get_vars()` to read the latest snapshot (pure read, no blocking, identical zval across repeated reads in one request).
12+
13+
## Configuration
14+
15+
Add `worker` directives with `background` to your [`php_server` or `php` block](config.md#caddyfile-config):
16+
17+
```caddyfile
18+
example.com {
19+
php_server {
20+
# Named background workers
21+
worker /app/bin/console {
22+
background
23+
name config-watcher
24+
}
25+
worker /app/bin/console {
26+
background
27+
name feature-flags
28+
}
29+
30+
# Catch-all: handles any unlisted name via ensure_background_worker()
31+
worker /app/bin/console {
32+
background
33+
}
34+
}
35+
}
36+
```
37+
38+
- **Named** (with `name`): lazy-started on first `ensure_background_worker()` call. If `num` is set to a positive integer, that many threads start eagerly at boot (pool mode); with `num 0` (default) the first `ensure()` starts one thread.
39+
- **Catch-all** (no `name`): lazy-started on demand for any name not matched by a `name` directive. `max_threads` caps the number of distinct names it can lazy-start (default 16). Without a catch-all, only declared names can be ensured.
40+
- Each `php_server` block has its own isolated scope: two blocks can use the same worker names without conflict.
41+
- `max_consecutive_failures`, `env`, and `watch` work the same as for HTTP workers.
42+
43+
## PHP API
44+
45+
### `frankenphp_ensure_background_worker(string|array $name, float $timeout = 30.0): void`
46+
47+
Declares a dependency on one or more background workers. Pass a single name or an array of names for batch dependency declaration; the timeout applies across all names in one call. Behaviour depends on the caller context:
48+
49+
- **In an HTTP worker script, before `frankenphp_handle_request()` (bootstrap)**: lazy-starts the worker (at-most-once) if not already running and blocks until it has called `set_vars()` at least once. Fails fast on boot failure (no exponential-backoff tolerance): if the first boot attempts fail, the exception is thrown right away with the captured details. Use this to declare dependencies up front so broken deps visibly fail the HTTP worker rather than let it serve degraded traffic.
50+
- **Everywhere else (inside `frankenphp_handle_request()`, or classic request-per-process)**: lazy-starts the worker and waits up to `$timeout`, tolerating transient boot failures via exponential backoff. The first caller pays the startup cost; subsequent callers in the same FrankenPHP process see the worker already reserved and return almost immediately. This supports the common pattern of library code loaded after bootstrap declaring its own dependencies lazily.
51+
52+
```php
53+
// HTTP worker, bootstrap phase
54+
frankenphp_ensure_background_worker('redis-watcher'); // fail-fast
55+
56+
while (frankenphp_handle_request(function () {
57+
$cfg = frankenphp_get_vars('redis-watcher'); // pure read
58+
})) { gc_collect_cycles(); }
59+
60+
// Non-worker mode, every request
61+
frankenphp_ensure_background_worker('redis-watcher'); // tolerant
62+
$cfg = frankenphp_get_vars('redis-watcher');
63+
64+
// Batch form, shared deadline across workers
65+
frankenphp_ensure_background_worker(['redis-watcher', 'config-watcher'], 5.0);
66+
```
67+
68+
- Throws `RuntimeException` on timeout, missing entrypoint, or boot failure. The exception contains the captured failure details when available: resolved entrypoint path, exit status, number of attempts, and the last PHP error (message, file, line).
69+
- Pick a short `$timeout` (e.g. `1.0`) to fail fast; pick a longer one to tolerate slow/flaky startups.
70+
- `ValueError` is raised for an empty names array; `TypeError` is raised if the array contains non-strings.
71+
72+
### `frankenphp_get_vars(string $name): array`
73+
74+
Pure read: returns the latest published vars from a running background worker. Does not start workers or wait for readiness.
75+
76+
```php
77+
$redis = frankenphp_get_vars('redis-watcher');
78+
// ['MASTER_HOST' => '10.0.0.1', 'MASTER_PORT' => 6379]
79+
```
80+
81+
- Throws `RuntimeException` if the worker isn't running or hasn't called `set_vars()` yet. Call `frankenphp_ensure_background_worker()` first to ensure readiness.
82+
- Within a single HTTP request, repeated calls with the same name return the **same** cached array: `$a === $b` holds, and the lookup is O(1) after the first call.
83+
- Works in both worker and non-worker mode.
84+
85+
### `frankenphp_set_vars(array $vars): void`
86+
87+
Publishes vars from inside a background worker. Each call **replaces** the entire vars array atomically.
88+
89+
Allowed value types: `null`, scalars (`bool`, `int`, `float`, `string`), nested `array`s whose values are also allowed types, and **enum** instances. Objects (other than enum cases), resources, and references are rejected.
90+
91+
- Throws `RuntimeException` if not called from a background worker thread.
92+
- Throws `ValueError` if values contain unsupported types.
93+
94+
### `frankenphp_get_worker_handle(): resource`
95+
96+
Returns a readable stream for receiving signals from FrankenPHP. On shutdown or restart the write end of the underlying pipe is closed, so `fgets()` returns `false` (EOF). Use `stream_select()` to wait between iterations instead of `sleep()`:
97+
98+
```php
99+
function background_worker_should_stop(float $timeout = 0): bool
100+
{
101+
static $stream;
102+
$stream ??= frankenphp_get_worker_handle();
103+
$s = (int) $timeout;
104+
105+
return match (@stream_select(...[[$stream], [], [], $s, (int) (($timeout - $s) * 1e6)])) {
106+
0 => false, // timeout, keep going
107+
false => true, // error, stop
108+
default => false === fgets($stream), // EOF = stop
109+
};
110+
}
111+
```
112+
113+
> [!WARNING]
114+
> Avoid `sleep()` or `usleep()` in background workers: they block at the C level and cannot be interrupted cleanly. Use `stream_select()` with the signaling stream instead. If a worker ignores the signal, FrankenPHP force-kills it on Linux, FreeBSD and Windows after a 5-second grace period (see `Runtime Behaviour`).
115+
116+
## Examples
117+
118+
### Simple polling worker
119+
120+
```php
121+
<?php
122+
// bin/console dispatches based on worker name
123+
124+
$command = $_SERVER['FRANKENPHP_WORKER_NAME'] ?? '';
125+
126+
match ($command) {
127+
'config-watcher' => run_config_watcher(),
128+
'feature-flags' => run_feature_flags(),
129+
default => throw new \RuntimeException("Unknown background worker: $command"),
130+
};
131+
132+
function run_config_watcher(): void
133+
{
134+
$redis = new Redis();
135+
$redis->pconnect('127.0.0.1');
136+
137+
do {
138+
frankenphp_set_vars([
139+
'maintenance' => (bool) $redis->get('maintenance_mode'),
140+
'feature_flags' => json_decode($redis->get('features'), true),
141+
]);
142+
} while (!background_worker_should_stop(5.0)); // check every 5s
143+
}
144+
```
145+
146+
### Event-driven worker
147+
148+
For real-time subscriptions (Redis pub/sub, SSE, WebSocket), use an async library and register the signaling stream on the event loop:
149+
150+
```php
151+
function run_redis_watcher(): void
152+
{
153+
$signalingStream = frankenphp_get_worker_handle();
154+
$sentinel = Amp\Redis\createRedisClient('tcp://sentinel-host:26379');
155+
156+
$subscription = $sentinel->subscribe('+switch-master');
157+
158+
Amp\async(function () use ($subscription) {
159+
foreach ($subscription as $message) {
160+
[$name, $oldIp, $oldPort, $newIp, $newPort] = explode(' ', $message);
161+
frankenphp_set_vars([
162+
'MASTER_HOST' => $newIp,
163+
'MASTER_PORT' => (int) $newPort,
164+
]);
165+
}
166+
});
167+
168+
$master = $sentinel->rawCommand('SENTINEL', 'get-master-addr-by-name', 'mymaster');
169+
frankenphp_set_vars([
170+
'MASTER_HOST' => $master[0],
171+
'MASTER_PORT' => (int) $master[1],
172+
]);
173+
174+
Amp\EventLoop::onReadable($signalingStream, function ($id) use ($signalingStream) {
175+
if (false === fgets($signalingStream)) {
176+
Amp\EventLoop::cancel($id); // EOF = stop
177+
}
178+
});
179+
Amp\EventLoop::run();
180+
}
181+
```
182+
183+
### HTTP worker depending on a background worker
184+
185+
```php
186+
<?php
187+
// public/index.php
188+
189+
$app = new App();
190+
$app->boot();
191+
192+
// Declare dependencies once at bootstrap (fail-fast)
193+
frankenphp_ensure_background_worker(['config-watcher', 'feature-flags']);
194+
195+
while (frankenphp_handle_request(function () use ($app) {
196+
$config = frankenphp_get_vars('config-watcher'); // pure read
197+
198+
$_SERVER += [
199+
'APP_REDIS_HOST' => $config['MASTER_HOST'],
200+
'APP_REDIS_PORT' => $config['MASTER_PORT'],
201+
];
202+
$app->handle($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER);
203+
})) {
204+
gc_collect_cycles();
205+
}
206+
```
207+
208+
### Non-worker mode
209+
210+
```php
211+
<?php
212+
// public/index.php, classic request-per-process
213+
214+
frankenphp_ensure_background_worker('config-watcher');
215+
$config = frankenphp_get_vars('config-watcher');
216+
// ... handle the request
217+
```
218+
219+
### Graceful degradation in CLI mode
220+
221+
Running scripts with `frankenphp php-cli ...` does not load the `frankenphp` PHP module, so the background-worker functions do not exist. `function_exists()` returns `false` and library code can fall back to alternative sources:
222+
223+
```php
224+
if (function_exists('frankenphp_get_vars')) {
225+
frankenphp_ensure_background_worker('config-watcher');
226+
$config = frankenphp_get_vars('config-watcher');
227+
} else {
228+
$config = ['MASTER_HOST' => getenv('REDIS_HOST') ?: '127.0.0.1'];
229+
}
230+
```
231+
232+
## Runtime Behaviour
233+
234+
- Background workers get dedicated threads: they do not reduce HTTP capacity.
235+
- `max_execution_time` is automatically disabled for background workers.
236+
- `$_SERVER['FRANKENPHP_WORKER_NAME']` carries the worker's declared (or catch-all-resolved) name.
237+
- `$_SERVER['FRANKENPHP_WORKER_BACKGROUND']` is `true` for background workers.
238+
- `$_SERVER['argv'] = [$entrypoint, $name]` in background workers (for `bin/console`-style dispatching).
239+
- Crash recovery: workers are automatically restarted with exponential backoff. During the restart window, `get_vars()` returns the last published data (stale but available) because vars are held in persistent memory across crashes. A warning is logged on crash.
240+
- On shutdown/restart the signaling stream is closed (EOF). Well-behaved workers that check the stream exit within the 5-second grace period. Stuck workers are force-killed on Linux, FreeBSD, and Windows.
241+
242+
## Scoping
243+
244+
Each `php_server` block gets its own isolated background-worker scope, so workers declared with the same `name` in different blocks do not collide. Resolution rules for `ensure()` / `get_vars()`:
245+
246+
- A request inside a `php_server` block resolves first against that block's own declarations. If the block declares any background workers of its own, that lookup is authoritative and scope-isolated from every other block.
247+
- A request inside a `php_server` block that declares **no** background workers falls back to the global/embed scope (workers declared at the top-level `frankenphp` directive or via the Go library). This makes a single globally-declared worker reachable from all otherwise-unconfigured blocks.
248+
- Requests made outside any `php_server` block (e.g. when embedding FrankenPHP as a library) always resolve to the global/embed scope.
249+
250+
## Limits
251+
252+
- Named background workers with `num > 1` spin up a pool of threads that share the same published vars; `get_vars()` sees one consistent snapshot.
253+
- Multiple named background workers in the same block can share the same entrypoint file. Each declaration keeps its own `env`, `watch`, and failure policy.
254+
- Calling `ensure()` on a name that isn't declared and isn't covered by a catch-all raises `RuntimeException`.

0 commit comments

Comments
 (0)