Skip to content

Commit 0c43682

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 b59399f commit 0c43682

10 files changed

Lines changed: 347 additions & 38 deletions

background_worker.go

Lines changed: 119 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,18 +93,30 @@ 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+
//
102+
// Per-scope namespace collisions are caught here (rather than via the
103+
// global workersByName check in newWorker) because bg workers
104+
// intentionally skip workersByName so two scopes can share a user-facing
105+
// name. That gap means same-scope duplicates wouldn't otherwise be
106+
// detected; same goes for two catch-alls in one scope.
107+
func buildBackgroundWorkerLookups(workers []*worker, opts []workerOpt) (map[BackgroundScope]*backgroundWorkerLookup, error) {
108+
lookups := make(map[BackgroundScope]*backgroundWorkerLookup)
76109

77110
for i, o := range opts {
78111
if !o.isBackgroundWorker {
79112
continue
80113
}
81-
if lookup == nil {
114+
115+
scope := o.backgroundScope
116+
lookup, ok := lookups[scope]
117+
if !ok {
82118
lookup = newBackgroundWorkerLookup()
119+
lookups[scope] = lookup
83120
}
84121

85122
w := workers[i]
@@ -90,14 +127,23 @@ func buildBackgroundWorkerLookup(workers []*worker, opts []workerOpt) *backgroun
90127
// require/__FILE__ identity tests behave differently between the
91128
// two paths.
92129
registry := newBackgroundWorkerRegistry(w.fileName)
130+
registry.scope = scope
93131
registry.env = o.env
94132
registry.watch = o.watch
95133
registry.maxConsecutiveFailures = o.maxConsecutiveFailures
96134
registry.requestOptions = o.requestOptions
97135

136+
w.backgroundScope = scope
98137
phpName := strings.TrimPrefix(w.name, "m#")
99138
if phpName != "" && phpName != w.fileName {
139+
if _, exists := lookup.byName[phpName]; exists {
140+
return nil, fmt.Errorf("duplicate background worker name %q in the same scope", phpName)
141+
}
100142
lookup.byName[phpName] = registry
143+
// Named declaration: remember the *worker so lazy-start can
144+
// reuse it instead of scanning workersByName (which is not
145+
// scope-aware for bg workers).
146+
registry.declared = w
101147
// Eagerly-started named workers (num > 0) register themselves
102148
// so a later ensure() observes the live instance instead of
103149
// trying to spawn a duplicate. Lazy named workers (num == 0)
@@ -106,6 +152,9 @@ func buildBackgroundWorkerLookup(workers []*worker, opts []workerOpt) *backgroun
106152
registry.workers[phpName] = w
107153
}
108154
} else {
155+
if lookup.catchAll != nil {
156+
return nil, fmt.Errorf("duplicate catch-all background worker in the same scope")
157+
}
109158
maxW := defaultMaxBackgroundWorkers
110159
if o.maxThreads > 1 {
111160
maxW = o.maxThreads
@@ -117,22 +166,61 @@ func buildBackgroundWorkerLookup(workers []*worker, opts []workerOpt) *backgroun
117166
w.backgroundRegistry = registry
118167
}
119168

120-
return lookup
169+
if len(lookups) == 0 {
170+
return nil, nil
171+
}
172+
return lookups, nil
173+
}
174+
175+
// getLookup returns the background-worker lookup for the given thread.
176+
// The scope is resolved from the thread's handler (for worker threads
177+
// inheriting their worker's scope) or from the request context (for
178+
// regular HTTP threads with WithRequestBackgroundScope).
179+
//
180+
// If the resolved scope has no workers declared (its lookup is nil), the
181+
// caller falls through to the global/embed scope (0) so that globally-
182+
// declared workers remain reachable from scoped requests. Scopes that
183+
// declared their own workers stay strictly isolated because their lookup
184+
// is non-nil.
185+
func getLookup(thread *phpThread) *backgroundWorkerLookup {
186+
if backgroundLookups == nil {
187+
return nil
188+
}
189+
var scope BackgroundScope
190+
if thread != nil {
191+
switch handler := thread.handler.(type) {
192+
case *workerThread:
193+
scope = handler.worker.backgroundScope
194+
case *backgroundWorkerThread:
195+
scope = handler.worker.backgroundScope
196+
default:
197+
if fc, ok := fromContext(thread.context()); ok {
198+
scope = fc.backgroundScope
199+
}
200+
}
201+
}
202+
if scope != 0 {
203+
if l := backgroundLookups[scope]; l != nil {
204+
return l
205+
}
206+
}
207+
return backgroundLookups[0]
121208
}
122209

123210
// ensureBackgroundWorker lazy-starts the named worker if it is not already
124211
// running. Fire-and-forget: returns once the bg worker thread has been
125212
// launched, without waiting for the PHP script to reach any particular
126213
// state. Safe to call concurrently; only the first caller actually
127214
// allocates the instance, the rest see the existing one.
128-
func ensureBackgroundWorker(bgWorkerName string) error {
215+
func ensureBackgroundWorker(thread *phpThread, bgWorkerName string) error {
129216
if bgWorkerName == "" {
130217
return fmt.Errorf("background worker name must not be empty")
131218
}
132-
if backgroundLookup == nil {
219+
lookup := getLookup(thread)
220+
if lookup == nil {
133221
return fmt.Errorf("no background worker configured")
134222
}
135-
registry := backgroundLookup.resolve(bgWorkerName)
223+
registry := lookup.resolve(bgWorkerName)
136224
if registry == nil || registry.entrypoint == "" {
137225
return fmt.Errorf("no background worker configured for name %q", bgWorkerName)
138226
}
@@ -149,12 +237,16 @@ func ensureBackgroundWorker(bgWorkerName string) error {
149237
}
150238

151239
// A num=0 named declaration already created a worker struct at init
152-
// time; reuse it instead of creating a duplicate. Catch-all instances
153-
// (different names, different worker structs) get freshly built.
240+
// time; reuse it instead of creating a duplicate. Named bg workers
241+
// across distinct scopes share the user-facing PHP name but each has
242+
// its own *worker struct, so we look it up via registry.declared
243+
// rather than the global workersByName map (which is not scope-aware
244+
// for bg workers). For catch-all (registry.declared == nil), each
245+
// lazy-started name gets a fresh *worker.
154246
var w *worker
155247
freshlyBuilt := false
156-
if existing := workersByName[bgWorkerName]; existing != nil && existing.isBackgroundWorker {
157-
w = existing
248+
if registry.declared != nil {
249+
w = registry.declared
158250
} else {
159251
// Clone env and slices: newWorker mutates env (writes
160252
// FRANKENPHP_WORKER) and appends to requestOptions, so sharing
@@ -173,6 +265,7 @@ func ensureBackgroundWorker(bgWorkerName string) error {
173265
fileName: registry.entrypoint,
174266
num: 1,
175267
isBackgroundWorker: true,
268+
backgroundScope: registry.scope,
176269
env: env,
177270
watch: watch,
178271
maxConsecutiveFailures: registry.maxConsecutiveFailures,
@@ -185,6 +278,7 @@ func ensureBackgroundWorker(bgWorkerName string) error {
185278
freshlyBuilt = true
186279
}
187280
w.backgroundRegistry = registry
281+
w.backgroundScope = registry.scope
188282
registry.workers[bgWorkerName] = w
189283
registry.mu.Unlock()
190284

@@ -200,7 +294,9 @@ func ensureBackgroundWorker(bgWorkerName string) error {
200294
scalingMu.Lock()
201295
workers = append(workers, w)
202296
scalingMu.Unlock()
203-
workersByName[bgWorkerName] = w
297+
// Intentionally NOT registered in workersByName: bg workers are
298+
// resolved per-scope via backgroundLookups, not via the global
299+
// name map, so two scopes can share a user-facing name.
204300
}
205301

206302
convertToBackgroundWorkerThread(t, w)
@@ -219,11 +315,11 @@ func ensureBackgroundWorker(bgWorkerName string) error {
219315
// this step, so callers cannot block on the worker reaching any state.
220316
//
221317
//export go_frankenphp_ensure_background_worker
222-
func go_frankenphp_ensure_background_worker(name *C.char, nameLen C.size_t) *C.char {
318+
func go_frankenphp_ensure_background_worker(threadIndex C.uintptr_t, name *C.char, nameLen C.size_t) *C.char {
319+
thread := phpThreads[threadIndex]
223320
goName := C.GoStringN(name, C.int(nameLen))
224-
if err := ensureBackgroundWorker(goName); err != nil {
321+
if err := ensureBackgroundWorker(thread, goName); err != nil {
225322
return C.CString(err.Error())
226323
}
227324
return nil
228325
}
229-

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)