Skip to content

Commit 8aa81a2

Browse files
feat: per-php_server background worker scoping
Adds a BackgroundScope opaque type (int under the hood; obtain values via NextBackgroundWorkerScope) so each php_server block gets its own isolation boundary for background workers. Zero is the global/embed scope. - backgroundLookups map[BackgroundScope]*backgroundWorkerLookup replaces the single global backgroundLookup. Each scope has its own named registry + catch-all so two blocks can declare bg workers with the same user-facing name without colliding. - buildBackgroundWorkerLookups iterates declarations into their scope's lookup; each declaration still owns its own registry. registry.declared remembers the *worker for a named declaration so lazy-start (num=0) reuses it without scanning the global workersByName map (which is not scope-aware for bg workers). - getLookup(thread) resolves the active scope from the calling thread: worker handler -> request context -> global (0). Scopes that declared their own workers stay strictly isolated; an empty scope falls through to the global lookup so embed-mode workers stay reachable. - Go options: WithWorkerBackgroundScope tags a declaration; the new WithRequestBackgroundScope tags a request so ensure() from a regular HTTP request resolves to the right block's lookup. - Caddy wiring: FrankenPHPModule.Provision allocates one scope per module instance (idempotent across re-provisions) and threads it into worker declarations and ServeHTTP. - workersByName collision check now skips bg workers; they resolve via their scope's lookup, so the same PHP-visible name can appear in two scopes without tripping the duplicate guard. - C side: go_frankenphp_ensure_background_worker now takes the calling thread index so getLookup can resolve the scope from the active handler / request context. Tests: - TestNextBackgroundWorkerScopeIsDistinct: counter hands out unique non-zero scopes. - TestBackgroundWorkerSameNameDifferentScope: two named bg workers with the same user-facing name in distinct scopes both Init successfully and own distinct registries. - TestBackgroundWorkerCatchAllPerScope: ensure() in scope A consumes scope A's catch-all only; scope B's catch-all stays empty. Verified by inspecting the per-scope lookup and the live workers slice via package-internal access. Deferred to follow-ups: pools (num > 1 per named worker, max_threads > 1 for named workers), multiple declarations sharing one entrypoint file in one scope, FRANKENPHP_WORKER_BACKGROUND server flag, batch ensure.
1 parent 7f759aa commit 8aa81a2

10 files changed

Lines changed: 331 additions & 38 deletions

background_worker.go

Lines changed: 107 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,33 @@ import (
77
"log/slog"
88
"strings"
99
"sync"
10+
"sync/atomic"
1011
)
1112

1213
// defaultMaxBackgroundWorkers is the default safety cap for catch-all
1314
// background workers when the user doesn't set max_threads. Caps the
1415
// number of distinct lazy-started instances from a single catch-all.
1516
const defaultMaxBackgroundWorkers = 16
1617

17-
// backgroundLookup is the registry that resolves a worker name to either
18-
// a named declaration or the catch-all. Single global scope in this step;
19-
// follow-ups replace this with a per-php_server scope map.
20-
var backgroundLookup *backgroundWorkerLookup
18+
// BackgroundScope identifies an isolation boundary for background workers.
19+
// Each php_server block uses a distinct scope so that two blocks can
20+
// declare workers with the same name without conflict. The zero value is
21+
// the global/embed scope (used when no per-block scope was assigned).
22+
// Representation is opaque; obtain values via NextBackgroundWorkerScope.
23+
type BackgroundScope int
24+
25+
var backgroundScopeCounter atomic.Uint64
26+
27+
// NextBackgroundWorkerScope returns a unique scope for background worker
28+
// isolation. Each php_server block should call this once during
29+
// provisioning.
30+
func NextBackgroundWorkerScope() BackgroundScope {
31+
return BackgroundScope(backgroundScopeCounter.Add(1))
32+
}
33+
34+
// backgroundLookups maps scopes to their background worker lookup.
35+
// Scope 0 is the global/embed scope; each php_server block gets its own.
36+
var backgroundLookups map[BackgroundScope]*backgroundWorkerLookup
2137

2238
// backgroundWorkerLookup maps worker names to their registry, with a
2339
// separate slot for the catch-all (name-less) declaration.
@@ -53,11 +69,20 @@ type backgroundWorkerRegistry struct {
5369
workers map[string]*worker
5470

5571
// Template options preserved so lazy-started workers inherit the same
56-
// env/watch/failure policy as their eagerly-started siblings.
72+
// scope/env/watch/failure policy as their eagerly-started siblings.
73+
scope BackgroundScope
5774
env PreparedEnv
5875
watch []string
5976
maxConsecutiveFailures int
6077
requestOptions []RequestOption
78+
79+
// declared is the pre-existing *worker for a named declaration. It
80+
// lets lazy-start (num=0 named) reuse the already-built worker
81+
// struct without scanning the global workersByName map (which is not
82+
// scope-aware: scoped bg workers sharing a user-facing name would
83+
// otherwise collide). nil for catch-all registries (each lazy-
84+
// started name gets a fresh worker).
85+
declared *worker
6186
}
6287

6388
func newBackgroundWorkerRegistry(entrypoint string) *backgroundWorkerRegistry {
@@ -68,30 +93,42 @@ func newBackgroundWorkerRegistry(entrypoint string) *backgroundWorkerRegistry {
6893
}
6994
}
7095

71-
// buildBackgroundWorkerLookup constructs the name->registry map + catch-all
72-
// slot from declared worker options. Each declaration gets its own registry
73-
// so shared-entrypoint declarations keep their own template options.
74-
func buildBackgroundWorkerLookup(workers []*worker, opts []workerOpt) *backgroundWorkerLookup {
75-
var lookup *backgroundWorkerLookup
96+
// buildBackgroundWorkerLookups constructs a scope->lookup map from declared
97+
// worker options. Each scope (php_server block, or 0 for global/embed)
98+
// gets its own lookup so workers declared with the same name in different
99+
// blocks don't collide. Each declaration gets its own registry so shared-
100+
// entrypoint declarations keep their own template options.
101+
func buildBackgroundWorkerLookups(workers []*worker, opts []workerOpt) map[BackgroundScope]*backgroundWorkerLookup {
102+
lookups := make(map[BackgroundScope]*backgroundWorkerLookup)
76103

77104
for i, o := range opts {
78105
if !o.isBackgroundWorker {
79106
continue
80107
}
81-
if lookup == nil {
108+
109+
scope := o.backgroundScope
110+
lookup, ok := lookups[scope]
111+
if !ok {
82112
lookup = newBackgroundWorkerLookup()
113+
lookups[scope] = lookup
83114
}
84115

85116
registry := newBackgroundWorkerRegistry(o.fileName)
117+
registry.scope = scope
86118
registry.env = o.env
87119
registry.watch = o.watch
88120
registry.maxConsecutiveFailures = o.maxConsecutiveFailures
89121
registry.requestOptions = o.requestOptions
90122

91123
w := workers[i]
124+
w.backgroundScope = scope
92125
phpName := strings.TrimPrefix(w.name, "m#")
93126
if phpName != "" && phpName != w.fileName {
94127
lookup.byName[phpName] = registry
128+
// Named declaration: remember the *worker so lazy-start can
129+
// reuse it instead of scanning workersByName (which is not
130+
// scope-aware for bg workers).
131+
registry.declared = w
95132
// Eagerly-started named workers (num > 0) register themselves
96133
// so a later ensure() observes the live instance instead of
97134
// trying to spawn a duplicate. Lazy named workers (num == 0)
@@ -111,22 +148,61 @@ func buildBackgroundWorkerLookup(workers []*worker, opts []workerOpt) *backgroun
111148
w.backgroundRegistry = registry
112149
}
113150

114-
return lookup
151+
if len(lookups) == 0 {
152+
return nil
153+
}
154+
return lookups
155+
}
156+
157+
// getLookup returns the background-worker lookup for the given thread.
158+
// The scope is resolved from the thread's handler (for worker threads
159+
// inheriting their worker's scope) or from the request context (for
160+
// regular HTTP threads with WithRequestBackgroundScope).
161+
//
162+
// If the resolved scope has no workers declared (its lookup is nil), the
163+
// caller falls through to the global/embed scope (0) so that globally-
164+
// declared workers remain reachable from scoped requests. Scopes that
165+
// declared their own workers stay strictly isolated because their lookup
166+
// is non-nil.
167+
func getLookup(thread *phpThread) *backgroundWorkerLookup {
168+
if backgroundLookups == nil {
169+
return nil
170+
}
171+
var scope BackgroundScope
172+
if thread != nil {
173+
switch handler := thread.handler.(type) {
174+
case *workerThread:
175+
scope = handler.worker.backgroundScope
176+
case *backgroundWorkerThread:
177+
scope = handler.worker.backgroundScope
178+
default:
179+
if fc, ok := fromContext(thread.context()); ok {
180+
scope = fc.backgroundScope
181+
}
182+
}
183+
}
184+
if scope != 0 {
185+
if l := backgroundLookups[scope]; l != nil {
186+
return l
187+
}
188+
}
189+
return backgroundLookups[0]
115190
}
116191

117192
// ensureBackgroundWorker lazy-starts the named worker if it is not already
118193
// running. Fire-and-forget: returns once the bg worker thread has been
119194
// launched, without waiting for the PHP script to reach any particular
120195
// state. Safe to call concurrently; only the first caller actually
121196
// allocates the instance, the rest see the existing one.
122-
func ensureBackgroundWorker(bgWorkerName string) error {
197+
func ensureBackgroundWorker(thread *phpThread, bgWorkerName string) error {
123198
if bgWorkerName == "" {
124199
return fmt.Errorf("background worker name must not be empty")
125200
}
126-
if backgroundLookup == nil {
201+
lookup := getLookup(thread)
202+
if lookup == nil {
127203
return fmt.Errorf("no background worker configured")
128204
}
129-
registry := backgroundLookup.resolve(bgWorkerName)
205+
registry := lookup.resolve(bgWorkerName)
130206
if registry == nil || registry.entrypoint == "" {
131207
return fmt.Errorf("no background worker configured for name %q", bgWorkerName)
132208
}
@@ -143,12 +219,16 @@ func ensureBackgroundWorker(bgWorkerName string) error {
143219
}
144220

145221
// A num=0 named declaration already created a worker struct at init
146-
// time; reuse it instead of creating a duplicate. Catch-all instances
147-
// (different names, different worker structs) get freshly built.
222+
// time; reuse it instead of creating a duplicate. Named bg workers
223+
// across distinct scopes share the user-facing PHP name but each has
224+
// its own *worker struct, so we look it up via registry.declared
225+
// rather than the global workersByName map (which is not scope-aware
226+
// for bg workers). For catch-all (registry.declared == nil), each
227+
// lazy-started name gets a fresh *worker.
148228
var w *worker
149229
freshlyBuilt := false
150-
if existing := workersByName[bgWorkerName]; existing != nil && existing.isBackgroundWorker {
151-
w = existing
230+
if registry.declared != nil {
231+
w = registry.declared
152232
} else {
153233
// Clone env and slices: newWorker mutates env (writes
154234
// FRANKENPHP_WORKER) and appends to requestOptions, so sharing
@@ -167,6 +247,7 @@ func ensureBackgroundWorker(bgWorkerName string) error {
167247
fileName: registry.entrypoint,
168248
num: 1,
169249
isBackgroundWorker: true,
250+
backgroundScope: registry.scope,
170251
env: env,
171252
watch: watch,
172253
maxConsecutiveFailures: registry.maxConsecutiveFailures,
@@ -179,6 +260,7 @@ func ensureBackgroundWorker(bgWorkerName string) error {
179260
freshlyBuilt = true
180261
}
181262
w.backgroundRegistry = registry
263+
w.backgroundScope = registry.scope
182264
registry.workers[bgWorkerName] = w
183265
registry.mu.Unlock()
184266

@@ -194,7 +276,9 @@ func ensureBackgroundWorker(bgWorkerName string) error {
194276
scalingMu.Lock()
195277
workers = append(workers, w)
196278
scalingMu.Unlock()
197-
workersByName[bgWorkerName] = w
279+
// Intentionally NOT registered in workersByName: bg workers are
280+
// resolved per-scope via backgroundLookups, not via the global
281+
// name map, so two scopes can share a user-facing name.
198282
}
199283

200284
convertToBackgroundWorkerThread(t, w)
@@ -213,11 +297,11 @@ func ensureBackgroundWorker(bgWorkerName string) error {
213297
// this step, so callers cannot block on the worker reaching any state.
214298
//
215299
//export go_frankenphp_ensure_background_worker
216-
func go_frankenphp_ensure_background_worker(name *C.char, nameLen C.size_t) *C.char {
300+
func go_frankenphp_ensure_background_worker(threadIndex C.uintptr_t, name *C.char, nameLen C.size_t) *C.char {
301+
thread := phpThreads[threadIndex]
217302
goName := C.GoStringN(name, C.int(nameLen))
218-
if err := ensureBackgroundWorker(goName); err != nil {
303+
if err := ensureBackgroundWorker(thread, goName); err != nil {
219304
return C.CString(err.Error())
220305
}
221306
return nil
222307
}
223-

background_worker_ensure_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func TestEnsureBackgroundWorkerNamedLazy(t *testing.T) {
4646
_, err := os.Stat(filepath.Join(tmp, "bg-lazy"))
4747
require.True(t, os.IsNotExist(err), "lazy worker should not have started yet")
4848

49-
require.NoError(t, ensureBackgroundWorker("bg-lazy"))
49+
require.NoError(t, ensureBackgroundWorker(nil,"bg-lazy"))
5050
assert.True(t, waitForSentinel(t, filepath.Join(tmp, "bg-lazy"), 5*time.Second),
5151
"ensure() should have lazy-started the named bg worker")
5252
}
@@ -71,7 +71,7 @@ func TestEnsureBackgroundWorkerCatchAll(t *testing.T) {
7171
t.Cleanup(Shutdown)
7272

7373
for _, name := range []string{"job-a", "job-b"} {
74-
require.NoError(t, ensureBackgroundWorker(name), "ensure(%s)", name)
74+
require.NoError(t, ensureBackgroundWorker(nil,name), "ensure(%s)", name)
7575
}
7676

7777
for _, name := range []string{"job-a", "job-b"} {
@@ -98,10 +98,10 @@ func TestEnsureBackgroundWorkerCatchAllCap(t *testing.T) {
9898
))
9999
t.Cleanup(Shutdown)
100100

101-
require.NoError(t, ensureBackgroundWorker("cap-a"))
102-
require.NoError(t, ensureBackgroundWorker("cap-b"))
101+
require.NoError(t, ensureBackgroundWorker(nil,"cap-a"))
102+
require.NoError(t, ensureBackgroundWorker(nil,"cap-b"))
103103

104-
err := ensureBackgroundWorker("cap-c")
104+
err := ensureBackgroundWorker(nil,"cap-c")
105105
require.Error(t, err, "third ensure must hit the catch-all cap")
106106
assert.Contains(t, err.Error(), "limit of 2 reached")
107107
}
@@ -120,7 +120,7 @@ func TestEnsureBackgroundWorkerUndeclared(t *testing.T) {
120120
))
121121
t.Cleanup(Shutdown)
122122

123-
err := ensureBackgroundWorker("other-name")
123+
err := ensureBackgroundWorker(nil,"other-name")
124124
require.Error(t, err)
125125
assert.Contains(t, err.Error(), "no background worker configured for name")
126126
}

0 commit comments

Comments
 (0)