Skip to content

Commit 069d801

Browse files
feat: require_background_worker refinements
Collapses the three-mode require semantics into two, moves the batch-declaration affordance to require instead of get_vars, and renames require to ensure per @dunglas's concern that "require" collides with PHP's language keyword (which takes a path). - frankenphp_require_background_worker -> frankenphp_ensure_background_worker. "Ensure" captures the "make sure this is running, start it if it isn't" semantic without the keyword collision. - Tolerant lazy-start inside frankenphp_handle_request: runtime ensure now behaves the same as non-worker mode (lazy-start + timeout + backoff tolerance). Bootstrap-before-handle_request keeps its fail-fast discipline. This lets processes start only the workers they actually exercise, instead of over-provisioning by pre-ensuring everything that might be needed. - Multi-name ensure: frankenphp_ensure_background_worker now accepts string|array. Batch declaration with a shared deadline, fail-fast on any worker's boot failure in bootstrap mode. get_vars loses the array form, becoming single-name pure read. - globalCtx.Done() wired into ensure's select cases, so in-flight calls unblock cleanly on FrankenPHP shutdown instead of waiting out their timeout. - Fix: markBackgroundReady() is now called on every set_vars, not just the first. Previously sk.readyOnce gated it, leaving isBootingScript stuck at true after a crash-restart. That misclassified subsequent crashes as boot failures and kept the readyWorkers metric decremented.
1 parent 5b84690 commit 069d801

30 files changed

Lines changed: 444 additions & 354 deletions

background_worker.go

Lines changed: 153 additions & 144 deletions
Large diffs are not rendered by default.

background_worker_test.go

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func (m *backgroundWorkerTestMetrics) DequeuedRequest() {}
4949
func TestStartBackgroundWorkerFailureIsRetryable(t *testing.T) {
5050
lookup := newBackgroundWorkerLookup()
5151
lookup.catchAll = newBackgroundWorkerRegistry(testDataPath + "/background-worker-with-argv.php")
52-
backgroundLookups = map[string]*backgroundWorkerLookup{"": lookup}
52+
backgroundLookups = map[BackgroundScope]*backgroundWorkerLookup{0: lookup}
5353
thread := newPHPThread(0)
5454
thread.state.Set(state.Ready)
5555
thread.handler = &workerThread{
@@ -94,6 +94,54 @@ func TestBackgroundWorkerSetVarsMarksWorkerReady(t *testing.T) {
9494
assert.Equal(t, 1, testMetrics.readyCalls)
9595
}
9696

97+
func TestBackgroundWorkerRestartReReachesReady(t *testing.T) {
98+
// After a crash-restart cycle, setupScript flips isBootingScript back to
99+
// true and the next set_vars must flip it back to false. Otherwise any
100+
// subsequent crash would be misclassified as a boot failure and the
101+
// readyWorkers metric would stay decremented.
102+
originalMetrics := metrics
103+
testMetrics := &backgroundWorkerTestMetrics{}
104+
metrics = testMetrics
105+
t.Cleanup(func() {
106+
metrics = originalMetrics
107+
})
108+
109+
handler := &backgroundWorkerThread{
110+
thread: newPHPThread(0),
111+
worker: &worker{
112+
name: "background-worker",
113+
fileName: "background-worker.php",
114+
maxConsecutiveFailures: -1,
115+
backgroundWorker: &backgroundWorkerState{ready: make(chan struct{})},
116+
},
117+
isBootingScript: true,
118+
}
119+
120+
// First boot reaches ready.
121+
handler.markBackgroundReady()
122+
assert.False(t, handler.isBootingScript)
123+
assert.Equal(t, 1, testMetrics.readyCalls)
124+
125+
// Worker crashes after ready: exit status != 0, isBootingScript == false.
126+
handler.afterScriptExecution(1)
127+
require.Len(t, testMetrics.stopCalls, 1)
128+
assert.Equal(t, StopReason(StopReasonCrash), testMetrics.stopCalls[0])
129+
130+
// Restart: setupScript would set isBootingScript = true again.
131+
handler.isBootingScript = true
132+
133+
// Next set_vars call must flip it back to false and increment ReadyWorker.
134+
handler.markBackgroundReady()
135+
assert.False(t, handler.isBootingScript)
136+
assert.Equal(t, 2, testMetrics.readyCalls)
137+
138+
// A second crash at this point must still be classified as Crash, not BootFailure.
139+
testMetrics.stopCalls = nil
140+
handler.afterScriptExecution(1)
141+
require.Len(t, testMetrics.stopCalls, 1)
142+
assert.Equal(t, StopReason(StopReasonCrash), testMetrics.stopCalls[0])
143+
}
144+
97145
func TestBackgroundWorkerBootFailureStaysBootFailureUntilReady(t *testing.T) {
98146
originalMetrics := metrics
99147
testMetrics := &backgroundWorkerTestMetrics{}

caddy/workerconfig.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,11 +166,12 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) {
166166
if wc.Num > 1 {
167167
return wc, d.Err(`"num" > 1 is not yet supported for background workers`)
168168
}
169-
if wc.MaxThreads > 1 {
170-
return wc, d.Err(`"max_threads" > 1 is not yet supported for background workers`)
169+
// For named bg workers, max_threads means threads-per-worker (not yet
170+
// supported beyond 1). For catch-all (no name), it means the max
171+
// number of lazily-started instances, which is a legitimate user knob.
172+
if wc.Name != "" && wc.MaxThreads > 1 {
173+
return wc, d.Err(`"max_threads" > 1 is not yet supported for named background workers`)
171174
}
172-
// MaxConsecutiveFailures defaults to 6 (same as HTTP workers)
173-
// via defaultMaxConsecutiveFailures in options.go
174175
}
175176

176177
if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) {

cli_test.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,15 @@ func TestBackgroundWorkerFunctionsHiddenInCLI(t *testing.T) {
4141
stdoutStderr, err := cmd.CombinedOutput()
4242
assert.NoError(t, err)
4343

44+
// The frankenphp module is not loaded in CLI mode (the embed SAPI is used
45+
// without registering the frankenphp extension), so all frankenphp_*
46+
// functions are absent. Library code can rely on function_exists() to
47+
// degrade gracefully.
4448
out := string(stdoutStderr)
45-
assert.Contains(t, out, "frankenphp_require_background_worker:hidden")
49+
assert.Contains(t, out, "frankenphp_ensure_background_worker:hidden")
4650
assert.Contains(t, out, "frankenphp_set_vars:hidden")
4751
assert.Contains(t, out, "frankenphp_get_vars:hidden")
4852
assert.Contains(t, out, "frankenphp_get_worker_handle:hidden")
49-
// unrelated functions must remain available
50-
assert.Contains(t, out, "frankenphp_log:exists")
5153
}
5254

5355
func TestExecuteCLICode(t *testing.T) {

docs/background-workers.md

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ They observe their environment and publish variables that HTTP threads (both [wo
77

88
1. A background worker runs its own event loop (subscribe to Redis, watch files, poll an API...)
99
2. It calls `frankenphp_set_vars()` to publish a snapshot of key-value pairs
10-
3. HTTP threads call `frankenphp_require_background_worker()` to declare a dependency and ensure the worker is running (lazy-started if needed, blocks until it has published at least once)
10+
3. HTTP threads call `frankenphp_ensure_background_worker()` to declare a dependency and ensure the worker is running (lazy-started if needed, blocks until it has published at least once)
1111
4. HTTP threads then call `frankenphp_get_vars()` to read the latest snapshot (pure read, no blocking)
1212

1313
## Configuration
@@ -27,59 +27,56 @@ example.com {
2727
name feature-flags
2828
}
2929
30-
# Catch-all - handles any unlisted name via require_background_worker()
30+
# Catch-all - handles any unlisted name via ensure_background_worker()
3131
worker /app/bin/console {
3232
background
3333
}
3434
}
3535
}
3636
```
3737

38-
- **Named** (with `name`): lazy-started on first `require_background_worker()` call, or auto-started at boot if `num 1` is set.
38+
- **Named** (with `name`): lazy-started on first `ensure_background_worker()` call, or auto-started at boot if `num 1` is set.
3939
- **Catch-all** (no `name`): lazy-started on demand too, for any name not matched by a `name` directive. Use `max_threads` to cap how many can be created (defaults to 16). Not declaring a catch-all forbids unlisted names.
4040
- Each `php_server` block has its own isolated scope - two blocks can use the same worker names without conflict.
4141
- `max_consecutive_failures`, `env`, and `watch` work the same as HTTP workers.
4242

4343
## PHP API
4444

45-
### `frankenphp_require_background_worker(string $name, float $timeout = 30.0): void`
45+
### `frankenphp_ensure_background_worker(string|array $name, float $timeout = 30.0): void`
4646

47-
Declares a dependency on a background worker. Behavior depends on the caller context:
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 a single call. Behavior depends on the caller context:
4848

49-
- **In an HTTP worker script, before `frankenphp_handle_request()` (bootstrap)**: lazy-starts the worker (at-most-once) if not already running, blocks until it has called `set_vars()` at least once. Fails fast on boot failure (no exponential-backoff tolerance): if the worker's first boot attempts fail, the exception is thrown right away with the captured details. Use this to declare dependencies up front.
50-
- **In an HTTP worker script, inside `frankenphp_handle_request()` (runtime)**: assert-only. The worker must already be running (declared with `num 1` in Caddyfile or previously required during bootstrap). Never lazy-starts. Throws immediately if the name isn't known. Use this to assert a dependency without starting anything.
51-
- **In non-worker mode (classic request-per-process)**: lazy-starts the worker and waits up to `$timeout`, tolerating transient boot failures via exponential backoff. The first request to a given name pays the startup cost; subsequent requests in the same FrankenPHP process see the worker already reserved and return almost immediately.
49+
- **In an HTTP worker script, before `frankenphp_handle_request()` (bootstrap)**: lazy-starts the worker (at-most-once) if not already running, blocks until it has called `set_vars()` at least once. Fails fast on boot failure (no exponential-backoff tolerance): if the worker's 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.
5251

5352
```php
5453
// HTTP worker (bootstrap)
55-
frankenphp_require_background_worker('redis-watcher'); // fail-fast
54+
frankenphp_ensure_background_worker('redis-watcher'); // fail-fast
5655

5756
while (frankenphp_handle_request(function () {
5857
$cfg = frankenphp_get_vars('redis-watcher'); // pure read
5958
})) { gc_collect_cycles(); }
6059

6160
// Non-worker mode (every request)
62-
frankenphp_require_background_worker('redis-watcher'); // tolerant
61+
frankenphp_ensure_background_worker('redis-watcher'); // tolerant
6362
$cfg = frankenphp_get_vars('redis-watcher');
6463
```
6564

6665
- 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).
6766
- Pick a short `$timeout` (e.g. `1.0`) to fail fast; pick a longer one to tolerate slow/flaky startups.
6867

69-
### `frankenphp_get_vars(string|array $name): array`
68+
### `frankenphp_get_vars(string $name): array`
7069

7170
Pure read: returns the latest published vars from a running background worker. Does not start workers or wait for them to become ready.
7271

7372
```php
7473
$redis = frankenphp_get_vars('redis-watcher');
7574
// ['MASTER_HOST' => '10.0.0.1', 'MASTER_PORT' => 6379]
76-
77-
$all = frankenphp_get_vars(['redis-watcher', 'feature-flags']);
78-
// ['redis-watcher' => [...], 'feature-flags' => [...]]
7975
```
8076

81-
- Throws `RuntimeException` if the worker isn't running or hasn't called `set_vars()` yet. Call `frankenphp_require_background_worker()` first to ensure readiness.
77+
- Throws `RuntimeException` if the worker isn't running or hasn't called `set_vars()` yet. Call `frankenphp_ensure_background_worker()` first to ensure readiness.
8278
- Within a single HTTP request, repeated calls with the same name return the same cached array, `===` comparisons are O(1).
79+
- To read several workers, call `get_vars()` multiple times (each call is an O(1) lock-free read after the first per request).
8380
- Works in both worker and non-worker mode.
8481

8582
### `frankenphp_set_vars(array $vars): void`
@@ -197,7 +194,7 @@ $app = new App();
197194
$app->boot();
198195

199196
// Declare dependencies once at bootstrap
200-
frankenphp_require_background_worker('config-watcher');
197+
frankenphp_ensure_background_worker('config-watcher');
201198

202199
while (frankenphp_handle_request(function () use ($app) {
203200
$config = frankenphp_get_vars('config-watcher'); // pure read
@@ -215,18 +212,18 @@ while (frankenphp_handle_request(function () use ($app) {
215212
<?php
216213
// public/index.php - classic request-per-process
217214

218-
frankenphp_require_background_worker('config-watcher');
215+
frankenphp_ensure_background_worker('config-watcher');
219216
$config = frankenphp_get_vars('config-watcher');
220217
// ... handle the request
221218
```
222219

223220
### Graceful Degradation
224221

225-
The `frankenphp_require_background_worker`, `frankenphp_set_vars`, `frankenphp_get_vars`, and `frankenphp_get_worker_handle` functions are only exposed when FrankenPHP runs in SAPI mode (HTTP server). In CLI mode (`frankenphp php-cli ...`), they are hidden, so `function_exists()` returns `false`. Library code can rely on this to degrade gracefully:
222+
In CLI mode (`frankenphp php-cli ...`), the frankenphp extension is not loaded, so `frankenphp_ensure_background_worker` and friends are absent. `function_exists()` returns `false`, allowing library code to degrade gracefully:
226223

227224
```php
228225
if (function_exists('frankenphp_get_vars')) {
229-
frankenphp_require_background_worker('config-watcher');
226+
frankenphp_ensure_background_worker('config-watcher');
230227
$config = frankenphp_get_vars('config-watcher');
231228
} else {
232229
$config = ['MASTER_HOST' => getenv('REDIS_HOST') ?: '127.0.0.1'];

0 commit comments

Comments
 (0)