Skip to content

Commit f3a86b7

Browse files
feat: background workers (config + ensure)
Adds background workers — long-lived non-HTTP PHP scripts that share the FrankenPHP runtime with HTTP workers but stay outside the request cycle. Implements: - WithWorkerBackground option / Caddyfile `worker { background }` declares a worker as a bg worker. $_SERVER['FRANKENPHP_WORKER'] carries the user-facing worker name, $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] is true for bg workers so scripts can branch without checking every function independently. - frankenphp_ensure_background_worker(string|array $name, ?float $timeout) lazy-starts a named bg worker (num=0 declarations stay parked until ensure() is called) or matches a catch-all declaration to spawn a named instance. Accepts an array of names sharing one deadline. Two-mode: fail-fast in HTTP-worker bootstrap so a broken dependency surfaces at boot; tolerant inside requests so the restart cycle can recover from transient boot failures. - Per-php_server scope isolation: each php_server block gets its own Scope (opaque uint64). Workers in distinct scopes can share a name without colliding. The Caddy module resolves a human-friendly label via cascade (route host matcher -> user-set Caddy server name -> first listener address) and registers it via SetScopeLabel so future metric/log emitters can render server="api.example.com". - Catch-all dispatch: a name-less bg worker declaration matches any ensure() name at runtime. max_threads on a catch-all caps how many distinct lazy-started instance names it can host (default 16). - bg-worker bootstrap routes the runtime name through the CGI pipeline so $_SERVER['FRANKENPHP_WORKER'] reflects the user-facing name on every request, not just bg-worker boot. - Bg workers expose a stop pipe (frankenphp_get_worker_handle) so PHP scripts can park on stream_select and exit gracefully when FrankenPHP drains. - max_consecutive_failures cap fails Init fast (HTTP-worker mode) or shuts the bg-worker thread down cleanly, with a deterministic abort message instead of a generic ensure() timeout. Tests cover: Caddyfile + Go-API declarations, ensure() lazy-start, catch-all dispatch + cap, batch ensure with shared deadline, error paths (undeclared name, boot failure metadata, type-validation), per- php_server scope isolation including same-named workers in distinct scopes.
1 parent 24be668 commit f3a86b7

40 files changed

Lines changed: 2367 additions & 23 deletions

bgworker.go

Lines changed: 418 additions & 0 deletions
Large diffs are not rendered by default.

bgworker_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package frankenphp_test
2+
3+
import (
4+
"path/filepath"
5+
"testing"
6+
"time"
7+
8+
"github.com/dunglas/frankenphp"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// TestBackgroundWorkerLifecycle boots a background worker that touches a
14+
// sentinel file then parks on the stop pipe. It proves the bg worker runs
15+
// (sentinel appears) and that Shutdown returns within a reasonable time.
16+
func TestBackgroundWorkerLifecycle(t *testing.T) {
17+
tmp := t.TempDir()
18+
sentinel := filepath.Join(tmp, "bg-lifecycle.sentinel")
19+
20+
require.NoError(t, frankenphp.Init(
21+
frankenphp.WithWorkers("bg-lifecycle", "testdata/bgworker/basic.php", 1,
22+
frankenphp.WithWorkerBackground(),
23+
frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}),
24+
),
25+
frankenphp.WithNumThreads(2),
26+
))
27+
// Note: this test asserts on Shutdown timing, so it manages Shutdown
28+
// itself instead of using setupFrankenPHP's t.Cleanup hook.
29+
30+
requireFileEventually(t, sentinel, "background worker did not touch sentinel")
31+
32+
done := make(chan struct{})
33+
go func() {
34+
frankenphp.Shutdown()
35+
close(done)
36+
}()
37+
38+
select {
39+
case <-done:
40+
case <-time.After(10 * time.Second):
41+
t.Fatalf("Shutdown did not return within 10s")
42+
}
43+
}
44+
45+
// TestBackgroundWorkerCrashRestarts boots a worker that exit(1)s on its
46+
// first run and touches a "restarted" sentinel on its second run. The
47+
// sentinel proves the crash-restart loop fired.
48+
func TestBackgroundWorkerCrashRestarts(t *testing.T) {
49+
tmp := t.TempDir()
50+
crashMarker := filepath.Join(tmp, "bg-crash.marker")
51+
restarted := filepath.Join(tmp, "bg-crash.restarted")
52+
53+
setupFrankenPHP(t,
54+
frankenphp.WithWorkers("bg-crash", "testdata/bgworker/crash.php", 1,
55+
frankenphp.WithWorkerBackground(),
56+
frankenphp.WithWorkerEnv(map[string]string{
57+
"BG_CRASH_MARKER": crashMarker,
58+
"BG_RESTARTED_SENTINEL": restarted,
59+
}),
60+
),
61+
frankenphp.WithNumThreads(2),
62+
)
63+
64+
requireFileEventually(t, restarted, "background worker did not restart after crash")
65+
}
66+
67+
// TestBackgroundWorkerWithoutHTTP confirms that a request to a script
68+
// unrelated to the bg worker still works: the bg worker doesn't intercept
69+
// HTTP traffic.
70+
func TestBackgroundWorkerWithoutHTTP(t *testing.T) {
71+
tmp := t.TempDir()
72+
sentinel := filepath.Join(tmp, "bg-nohttp.sentinel")
73+
74+
testDataDir := setupFrankenPHP(t,
75+
frankenphp.WithWorkers("bg-nohttp", "testdata/bgworker/basic.php", 1,
76+
frankenphp.WithWorkerBackground(),
77+
frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}),
78+
),
79+
frankenphp.WithNumThreads(2),
80+
)
81+
82+
requireFileEventually(t, sentinel, "background worker did not touch sentinel")
83+
84+
body := serveBody(t, testDataDir, "index.php")
85+
assert.NotEmpty(t, body, "expected non-empty body from index.php")
86+
}

bgworkerbatch_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package frankenphp_test
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/dunglas/frankenphp"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// TestEnsureBackgroundWorkerBatch declares a single catch-all bg worker
14+
// and ensures three distinct names from a single ensure() call. Each
15+
// catch-all instance touches a per-name sentinel; the test asserts that
16+
// all three appear, proving the array form started one worker per name.
17+
func TestEnsureBackgroundWorkerBatch(t *testing.T) {
18+
tmp := t.TempDir()
19+
testDataDir := setupFrankenPHP(t,
20+
frankenphp.WithWorkers("", "testdata/bgworker/named.php", 0,
21+
frankenphp.WithWorkerBackground(),
22+
frankenphp.WithWorkerMaxThreads(8),
23+
frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": tmp}),
24+
),
25+
frankenphp.WithNumThreads(8),
26+
)
27+
28+
body := serveBody(t, testDataDir, "bgworker/batch-ensure.php")
29+
assert.Contains(t, body, "ok", "batch ensure script should echo ok, got: %q", body)
30+
31+
for _, name := range []string{"batch-a", "batch-b", "batch-c"} {
32+
requireFileEventually(t, filepath.Join(tmp, name),
33+
"catch-all instance %q should have written its sentinel", name)
34+
}
35+
}
36+
37+
// TestEnsureBackgroundWorkerBatchEmpty exercises the C-side validation
38+
// that an empty array raises a ValueError before any worker is started.
39+
// The fixture catches the throwable and echoes its class.
40+
func TestEnsureBackgroundWorkerBatchEmpty(t *testing.T) {
41+
testDataDir := setupFrankenPHP(t,
42+
frankenphp.WithWorkers("", "testdata/bgworker/named.php", 0,
43+
frankenphp.WithWorkerBackground(),
44+
),
45+
frankenphp.WithNumThreads(2),
46+
)
47+
48+
body := serveBody(t, testDataDir, "bgworker/batch-errors.php?mode=empty")
49+
assert.Contains(t, body, "ValueError", "empty array should raise ValueError, got: %q", body)
50+
assert.Contains(t, body, "must not be empty")
51+
}
52+
53+
// TestEnsureBackgroundWorkerBatchNonString verifies a non-string element
54+
// raises a TypeError (PHP's standard for argument-type mismatches inside
55+
// our parsed array).
56+
func TestEnsureBackgroundWorkerBatchNonString(t *testing.T) {
57+
testDataDir := setupFrankenPHP(t,
58+
frankenphp.WithWorkers("", "testdata/bgworker/named.php", 0,
59+
frankenphp.WithWorkerBackground(),
60+
),
61+
frankenphp.WithNumThreads(2),
62+
)
63+
64+
body := serveBody(t, testDataDir, "bgworker/batch-errors.php?mode=nonstring")
65+
assert.Contains(t, body, "TypeError", "non-string element should raise TypeError, got: %q", body)
66+
}
67+
68+
// TestEnsureBackgroundWorkerBatchDuplicate verifies that duplicate names
69+
// in the same batch are rejected as a ValueError, matching the e17577e
70+
// reference behavior (no silent dedup).
71+
func TestEnsureBackgroundWorkerBatchDuplicate(t *testing.T) {
72+
testDataDir := setupFrankenPHP(t,
73+
frankenphp.WithWorkers("", "testdata/bgworker/named.php", 0,
74+
frankenphp.WithWorkerBackground(),
75+
),
76+
frankenphp.WithNumThreads(2),
77+
)
78+
79+
body := serveBody(t, testDataDir, "bgworker/batch-errors.php?mode=duplicate")
80+
assert.Contains(t, body, "ValueError", "duplicate name should raise ValueError, got: %q", body)
81+
assert.Contains(t, body, "duplicate")
82+
}
83+
84+
// TestBackgroundWorkerBgFlag asserts that a bg worker script sees
85+
// $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] === true. The fixture writes
86+
// var_export() of the value to a sentinel so the test can read the exact
87+
// PHP-level representation.
88+
func TestBackgroundWorkerBgFlag(t *testing.T) {
89+
tmp := t.TempDir()
90+
sentinel := filepath.Join(tmp, "bg-flag.sentinel")
91+
92+
setupFrankenPHP(t,
93+
frankenphp.WithWorkers("bg-flag", "testdata/bgworker/bg-flag.php", 1,
94+
frankenphp.WithWorkerBackground(),
95+
frankenphp.WithWorkerEnv(map[string]string{"BG_SENTINEL": sentinel}),
96+
),
97+
frankenphp.WithNumThreads(2),
98+
)
99+
100+
requireFileEventually(t, sentinel,
101+
"bg worker should have written the FRANKENPHP_WORKER_BACKGROUND sentinel")
102+
103+
contents, err := os.ReadFile(sentinel)
104+
require.NoError(t, err)
105+
assert.Equal(t, "true", string(contents),
106+
"$_SERVER['FRANKENPHP_WORKER_BACKGROUND'] should be the bool true")
107+
}

bgworkerensure_test.go

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
package frankenphp
2+
3+
import (
4+
"path/filepath"
5+
"strings"
6+
"sync"
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// TestEnsureBackgroundWorkerNamedLazy declares a num=0 named worker, then
15+
// calls ensure() to lazy-start it. The fixture writes a sentinel named
16+
// after FRANKENPHP_WORKER so we can confirm the right instance ran.
17+
func TestEnsureBackgroundWorkerNamedLazy(t *testing.T) {
18+
tmp := t.TempDir()
19+
setupBgWorker(t,
20+
WithWorkers("bg-lazy", "testdata/bgworker/named.php", 0,
21+
WithWorkerBackground(),
22+
WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": tmp}),
23+
),
24+
WithNumThreads(2),
25+
)
26+
27+
// num=0 means no eager start: the sentinel should not exist yet.
28+
require.NoFileExists(t, filepath.Join(tmp, "bg-lazy"), "lazy worker should not have started yet")
29+
30+
require.NoError(t, ensureBackgroundWorker(nil, "bg-lazy", 5*time.Second))
31+
requireSentinelEventually(t, filepath.Join(tmp, "bg-lazy"),
32+
"ensure() should have lazy-started the named bg worker")
33+
}
34+
35+
// TestEnsureBackgroundWorkerCatchAll declares a single catch-all (no name)
36+
// and invokes ensure() with two distinct names. Each name should spawn an
37+
// independent instance from the same entrypoint and write its own sentinel.
38+
func TestEnsureBackgroundWorkerCatchAll(t *testing.T) {
39+
tmp := t.TempDir()
40+
setupBgWorker(t,
41+
// Name-less bg worker = catch-all.
42+
WithWorkers("", "testdata/bgworker/named.php", 0,
43+
WithWorkerBackground(),
44+
WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": tmp}),
45+
),
46+
WithNumThreads(4),
47+
)
48+
49+
for _, name := range []string{"job-a", "job-b"} {
50+
require.NoError(t, ensureBackgroundWorker(nil, name, 5*time.Second), "ensure(%s)", name)
51+
}
52+
53+
for _, name := range []string{"job-a", "job-b"} {
54+
requireSentinelEventually(t, filepath.Join(tmp, name),
55+
"catch-all instance %q should have written its sentinel", name)
56+
}
57+
}
58+
59+
// TestEnsureBackgroundWorkerCatchAllCap exercises max_threads on the
60+
// catch-all: third distinct name beyond the cap should error.
61+
func TestEnsureBackgroundWorkerCatchAllCap(t *testing.T) {
62+
tmp := t.TempDir()
63+
setupBgWorker(t,
64+
WithWorkers("", "testdata/bgworker/named.php", 0,
65+
WithWorkerBackground(),
66+
WithWorkerMaxThreads(2),
67+
WithWorkerEnv(map[string]string{"BG_SENTINEL_DIR": tmp}),
68+
),
69+
WithNumThreads(4),
70+
)
71+
72+
require.NoError(t, ensureBackgroundWorker(nil, "cap-a", 5*time.Second))
73+
require.NoError(t, ensureBackgroundWorker(nil, "cap-b", 5*time.Second))
74+
75+
require.ErrorContains(t,
76+
ensureBackgroundWorker(nil, "cap-c", 5*time.Second),
77+
"limit of 2 reached",
78+
"third ensure must hit the catch-all cap")
79+
}
80+
81+
// TestEnsureBackgroundWorkerUndeclared confirms ensure() on an undeclared
82+
// name with no catch-all returns the configuration error.
83+
func TestEnsureBackgroundWorkerUndeclared(t *testing.T) {
84+
setupBgWorker(t,
85+
WithWorkers("bg-known", "testdata/bgworker/named.php", 0,
86+
WithWorkerBackground(),
87+
),
88+
WithNumThreads(2),
89+
)
90+
91+
require.ErrorContains(t,
92+
ensureBackgroundWorker(nil, "other-name", 5*time.Second),
93+
"no background worker configured for name")
94+
}
95+
96+
// TestEnsureBackgroundWorkerConcurrent confirms the doc claim that ensure()
97+
// is safe to call concurrently: 16 goroutines hitting the same lazy-named
98+
// declaration produce exactly one spawned thread. Without serialising the
99+
// lazy-start gate (bgLazyStartMu), the second caller could observe the
100+
// flag before the first caller has completed thread reservation, leaving
101+
// the worker in an inconsistent state.
102+
func TestEnsureBackgroundWorkerConcurrent(t *testing.T) {
103+
setupBgWorker(t,
104+
WithWorkers("bg-concurrent", "testdata/bgworker/named.php", 0,
105+
WithWorkerBackground(),
106+
),
107+
WithNumThreads(8),
108+
)
109+
110+
const goroutines = 16
111+
var wg sync.WaitGroup
112+
wg.Add(goroutines)
113+
errs := make([]error, goroutines)
114+
start := make(chan struct{})
115+
for i := 0; i < goroutines; i++ {
116+
go func(idx int) {
117+
defer wg.Done()
118+
<-start
119+
errs[idx] = ensureBackgroundWorker(nil, "bg-concurrent", 5*time.Second)
120+
}(i)
121+
}
122+
close(start)
123+
wg.Wait()
124+
125+
for i, err := range errs {
126+
require.NoError(t, err, "goroutine %d", i)
127+
}
128+
129+
// The lazy-named declaration should resolve to its single *worker,
130+
// and exactly one thread should have been spawned by the lazy-start
131+
// path despite the 16 concurrent ensure() callers.
132+
lookup := backgroundLookups[0]
133+
require.NotNil(t, lookup)
134+
w := lookup.byName["bg-concurrent"]
135+
require.NotNil(t, w)
136+
assert.Equal(t, 1, w.countThreads(), "exactly one worker thread expected")
137+
}
138+
139+
// TestEnsureBackgroundWorkerTimeout proves ensure() blocks until either
140+
// the worker hits its readiness boundary (frankenphp_get_worker_handle())
141+
// or the timeout expires. The fixture sleep()s without ever calling the
142+
// readiness function, so the second branch must fire.
143+
func TestEnsureBackgroundWorkerTimeout(t *testing.T) {
144+
setupBgWorker(t,
145+
WithWorkers("bg-no-handle", "testdata/bgworker/no-handle.php", 0,
146+
WithWorkerBackground(),
147+
),
148+
WithNumThreads(2),
149+
)
150+
151+
start := time.Now()
152+
err := ensureBackgroundWorker(nil, "bg-no-handle", 1*time.Second)
153+
deadline := start.Add(1 * time.Second)
154+
155+
require.ErrorContains(t, err,
156+
"did not call frankenphp_get_worker_handle()",
157+
"ensure() must time out at the readiness boundary")
158+
// Lower bound: timer must actually have run; allow a little slop for
159+
// timer scheduling.
160+
assert.GreaterOrEqual(t, time.Since(start), 900*time.Millisecond, "ensure() must wait the full timeout")
161+
// Upper bound: ensure() returned close to the deadline (didn't hang).
162+
assert.WithinDuration(t, deadline, time.Now(), 4*time.Second, "ensure() must return within a small slack window after the timeout")
163+
}
164+
165+
// TestEnsureBackgroundWorkerBootFailure declares a worker whose entrypoint
166+
// throws on its very first line. ensure() should surface the boot crash
167+
// metadata (entrypoint, exit status, attempt count) instead of just
168+
// reporting a generic timeout. We use a max_consecutive_failures cap so
169+
// the worker stops respawning and the abort path fires deterministically.
170+
func TestEnsureBackgroundWorkerBootFailure(t *testing.T) {
171+
setupBgWorker(t,
172+
WithWorkers("bg-boot-fail", "testdata/bgworker/boot-fail.php", 0,
173+
WithWorkerBackground(),
174+
WithWorkerMaxFailures(2),
175+
),
176+
WithNumThreads(2),
177+
)
178+
179+
err := ensureBackgroundWorker(nil, "bg-boot-fail", 5*time.Second)
180+
require.Error(t, err, "ensure() must surface the boot failure")
181+
msg := err.Error()
182+
// One of two paths: either the abort fired (cap reached) and the error
183+
// mentions max_consecutive_failures, or the timeout fired with the
184+
// bootFailureInfo attached so the message mentions exit status.
185+
assert.True(t,
186+
strings.Contains(msg, "exit status") || strings.Contains(msg, "max_consecutive_failures") || strings.Contains(msg, "failed to start"),
187+
"ensure() error must reflect the boot crash, got: %s", msg)
188+
}

0 commit comments

Comments
 (0)