Skip to content

Commit 3ce5132

Browse files
feat: declared background workers + frankenphp_get_worker_handle()
Background workers run a script in a loop outside the HTTP request cycle, sharing the PHP runtime with the request threads. Rebuilt on Server from #2499: a background worker attaches to a php_server through WithWorkerServerScope() like any other worker. Declared with "background" in a worker block (php_server or global) or WithWorkerBackground() in Go. name is required, match is rejected, num >= 1. The lifecycle mirrors HTTP workers: re-run on a cooperative exit, restart with quadratic backoff on a crash, max_consecutive_failures fails Init() during startup only. drain() runs on shutdown, reboot and handler transitions so a parked script wakes up instead of waiting out the force-kill grace period. Every worker sees its declared name in $_SERVER['FRANKENPHP_WORKER'], HTTP workers included: the documented contract is to test its presence, not its value. Background workers also get $_SERVER['FRANKENPHP_WORKER_BACKGROUND'], so a script serving both roles can tell them apart with isset(). The script gets one handle, frankenphp_get_worker_handle(), a stream that reaches EOF when the worker is drained, meant to carry control messages later. It is backed by a socket pair, not a pipe: on Windows PHP's php_select() only waits properly on sockets before 8.5. Streams do not own the socket (php_sockop_close() would shutdown() it on Windows), so every call returns a fresh stream and closing one never affects another; the read timeout is infinite so a blocking read parks as well as stream_select() does. Both ends are non-inheritable. A worker counts as ready on its first wait on the handle (select cast or read), the background analog of frankenphp_handle_request(): Init() waits for it, ready_workers counts from it, and an exit before it is a boot failure. The handle's stream ops, copied from the socket ops at MINIT, report it once per run. A run gets one stream: every call returns the same resource until the script closes it, so fetching the handle in a loop does not grow the resource list of a request that never ends. Worker names are scoped like paths: unique within a php_server or among global workers. The script sees the declared name; metrics and logs report a scoped worker as "<server name>:<name>", with a numeric suffix on server names when two blocks resolve to the same one, never a name another block configured. The collision-driven renaming in the Caddy module is gone, and WithWorkerName() resolves within the request's server first. FRANKENPHP_WORKER held "1" in HTTP workers before, and workers of a php_server block were reported under their bare name unless it collided: both changes are called out in the docs. Supersedes #2543 and #2398.
1 parent 2e33427 commit 3ce5132

45 files changed

Lines changed: 1751 additions & 195 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bgworker_test.go

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

caddy/app.go

Lines changed: 8 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import (
1717
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
1818
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
1919
"github.com/dunglas/frankenphp"
20-
"github.com/dunglas/frankenphp/internal/fastabs"
2120
)
2221

2322
var (
@@ -60,15 +59,14 @@ type FrankenPHPApp struct {
6059
// EXPERIMENTAL: MaxRequests sets the maximum number of requests a PHP thread handles before restarting (0 = unlimited)
6160
MaxRequests int `json:"max_requests,omitempty"`
6261

63-
opts []frankenphp.Option
64-
metrics frankenphp.Metrics
65-
ctx context.Context
66-
logger *slog.Logger
67-
modules []*FrankenPHPModule
68-
usedWorkerNames map[string]bool
69-
httpApp *caddyhttp.App
70-
hasStarted atomic.Bool
71-
started chan any
62+
opts []frankenphp.Option
63+
metrics frankenphp.Metrics
64+
ctx context.Context
65+
logger *slog.Logger
66+
modules []*FrankenPHPModule
67+
httpApp *caddyhttp.App
68+
hasStarted atomic.Bool
69+
started chan any
7270
}
7371

7472
var errIni = errors.New(`"php_ini" must be in the format: php_ini "<key>" "<value>"`)
@@ -133,7 +131,6 @@ func (f *FrankenPHPApp) Start() error {
133131
// register global workers
134132
for _, w := range f.Workers {
135133
w.FileName = repl.ReplaceKnown(w.FileName, "")
136-
w.Name = f.createUniqueWorkerName(w, "")
137134
opts, err := w.toWorkerOptions()
138135
if err != nil {
139136
return err
@@ -224,7 +221,6 @@ func (f *FrankenPHPApp) registerModule(repl *caddy.Replacer, module *FrankenPHPM
224221

225222
for _, w := range module.Workers {
226223
w.FileName = repl.ReplaceKnown(w.FileName, "")
227-
w.Name = f.createUniqueWorkerName(w, serverName)
228224
workerOptions, err := w.toWorkerOptions()
229225
if err != nil {
230226
return err
@@ -236,37 +232,6 @@ func (f *FrankenPHPApp) registerModule(repl *caddy.Replacer, module *FrankenPHPM
236232
return nil
237233
}
238234

239-
// avoid name collisions for workers
240-
// on collision, a name is first qualified with the server name
241-
// ("<serverName>:<name>") before falling back to a numeric postfix
242-
func (f *FrankenPHPApp) createUniqueWorkerName(wc workerConfig, serverName string) string {
243-
if f.usedWorkerNames == nil {
244-
f.usedWorkerNames = make(map[string]bool)
245-
}
246-
247-
if wc.Name == "" {
248-
wc.Name, _ = fastabs.FastAbs(wc.FileName)
249-
}
250-
251-
name := wc.Name
252-
suffix := 0
253-
for {
254-
if _, ok := f.usedWorkerNames[name]; !ok {
255-
f.usedWorkerNames[name] = true
256-
break
257-
}
258-
if serverName != "" {
259-
name = serverName + ":" + wc.Name
260-
serverName = ""
261-
continue
262-
}
263-
suffix++
264-
name = fmt.Sprintf("%s_%d", wc.Name, suffix)
265-
}
266-
267-
return name
268-
}
269-
270235
// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
271236
func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
272237
for d.Next() {

caddy/caddy_test.go

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,45 @@ func TestMetrics(t *testing.T) {
757757
require.NoError(t, testutil.GatherAndCompare(ctx.GetMetricsRegistry(), strings.NewReader(expectedMetrics), "frankenphp_total_threads", "frankenphp_busy_threads"))
758758
}
759759

760+
// TestBackgroundWorkerFromCaddyfile starts a background worker from a
761+
// Caddyfile and checks it runs: the sentinel its script touches appears
762+
func TestBackgroundWorkerFromCaddyfile(t *testing.T) {
763+
sentinel := filepath.ToSlash(filepath.Join(t.TempDir(), "bg.sentinel"))
764+
tester := caddytest.NewTester(t)
765+
initServer(t, tester, `
766+
{
767+
skip_install_trust
768+
admin localhost:2999
769+
http_port `+testPort+`
770+
https_port 9443
771+
772+
frankenphp {
773+
worker {
774+
file ../testdata/bgworker/basic.php
775+
num 1
776+
name bg-caddy
777+
background
778+
env BG_SENTINEL `+sentinel+`
779+
}
780+
}
781+
}
782+
783+
localhost:`+testPort+` {
784+
route {
785+
php {
786+
root ../testdata
787+
}
788+
}
789+
}
790+
`, "caddyfile")
791+
792+
require.Eventually(t, func() bool {
793+
_, err := os.Stat(sentinel)
794+
795+
return err == nil
796+
}, 5*time.Second, 25*time.Millisecond, "the background worker declared in the Caddyfile did not run")
797+
}
798+
760799
func TestWorkerMetrics(t *testing.T) {
761800
var wg sync.WaitGroup
762801
tester := caddytest.NewTester(t)
@@ -839,7 +878,7 @@ func TestWorkerMetrics(t *testing.T) {
839878
# TYPE frankenphp_worker_request_count counter
840879
frankenphp_worker_request_count{worker="` + workerName + `"} 10
841880
842-
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
881+
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers
843882
# TYPE frankenphp_ready_workers gauge
844883
frankenphp_ready_workers{worker="` + workerName + `"} 2
845884
`
@@ -996,7 +1035,7 @@ func TestNamedWorkerMetrics(t *testing.T) {
9961035
# TYPE frankenphp_worker_request_count counter
9971036
frankenphp_worker_request_count{worker="my_app"} 10
9981037
999-
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
1038+
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers
10001039
# TYPE frankenphp_ready_workers gauge
10011040
frankenphp_ready_workers{worker="my_app"} 2
10021041
`
@@ -1092,7 +1131,7 @@ func TestAutoWorkerConfig(t *testing.T) {
10921131
# TYPE frankenphp_worker_request_count counter
10931132
frankenphp_worker_request_count{worker="` + workerName + `"} 10
10941133
1095-
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
1134+
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers
10961135
# TYPE frankenphp_ready_workers gauge
10971136
frankenphp_ready_workers{worker="` + workerName + `"} ` + workers + `
10981137
`
@@ -1460,7 +1499,7 @@ func TestMultiWorkersMetrics(t *testing.T) {
14601499
# TYPE frankenphp_worker_request_count counter
14611500
frankenphp_worker_request_count{worker="service1"} 10
14621501
1463-
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
1502+
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers
14641503
# TYPE frankenphp_ready_workers gauge
14651504
frankenphp_ready_workers{worker="service1"} 2
14661505
frankenphp_ready_workers{worker="service2"} 3
@@ -1614,7 +1653,7 @@ func TestWorkerRestart(t *testing.T) {
16141653

16151654
// Check metrics
16161655
expectedMetrics := `
1617-
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
1656+
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers
16181657
# TYPE frankenphp_ready_workers gauge
16191658
frankenphp_ready_workers{worker="service"} 1
16201659
# HELP frankenphp_total_workers Total number of PHP workers for this worker
@@ -1642,7 +1681,7 @@ func TestWorkerRestart(t *testing.T) {
16421681

16431682
// frankenphp_ready_workers should be back to 1 even after worker restarts
16441683
expectedMetrics = `
1645-
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
1684+
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers
16461685
# TYPE frankenphp_ready_workers gauge
16471686
frankenphp_ready_workers{worker="service"} 1
16481687
# HELP frankenphp_total_workers Total number of PHP workers for this worker
@@ -2113,7 +2152,7 @@ func TestSymlinkWorkerBehavior(t *testing.T) {
21132152

21142153
// Accessing the worker script without worker configuration MUST fail
21152154
// The script checks $_SERVER['FRANKENPHP_WORKER'] and dies if not set
2116-
tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, "Error: This script must be run in worker mode (FRANKENPHP_WORKER not set to '1')\n")
2155+
tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, "Error: This script must be run in worker mode (FRANKENPHP_WORKER not set)\n")
21172156
})
21182157

21192158
t.Run("MultipleRequests", func(t *testing.T) {

caddy/config_test.go

Lines changed: 67 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package caddy
22

33
import (
4-
"path/filepath"
54
"testing"
65
"time"
76

@@ -249,38 +248,73 @@ func TestModuleWorkerWithCustomName(t *testing.T) {
249248
require.Equal(t, "../testdata/worker-with-env.php", module.Workers[0].FileName, "Worker should have the correct filename")
250249
}
251250

252-
func TestCreateUniqueWorkerNames(t *testing.T) {
253-
app := &FrankenPHPApp{}
254-
filename := "../testdata/worker-with-env.php"
255-
absFileName, _ := filepath.Abs(filename)
256-
names := make([]string, 6)
257-
for i := range 3 {
258-
names[i] = app.createUniqueWorkerName(workerConfig{
259-
FileName: filename,
260-
Name: "custom-worker-name",
261-
}, "")
262-
names[i+3] = app.createUniqueWorkerName(workerConfig{
263-
FileName: filename,
264-
}, "")
265-
}
266-
267-
require.Equal(t, "custom-worker-name", names[0])
268-
require.Equal(t, "custom-worker-name_1", names[1])
269-
require.Equal(t, "custom-worker-name_2", names[2])
270-
require.Equal(t, absFileName, names[3])
271-
require.Equal(t, absFileName+"_1", names[4])
272-
require.Equal(t, absFileName+"_2", names[5])
251+
func TestWorkerBackgroundConfig(t *testing.T) {
252+
d := caddyfile.NewTestDispenser(`
253+
{
254+
php_server {
255+
worker {
256+
name jobs
257+
file ../testdata/worker-with-env.php
258+
num 2
259+
background
260+
}
261+
}
262+
}`)
263+
module := &FrankenPHPModule{}
264+
265+
require.NoError(t, module.UnmarshalCaddyfile(d))
266+
require.Len(t, module.Workers, 1)
267+
require.True(t, module.Workers[0].Background)
268+
require.Equal(t, "jobs", module.Workers[0].Name)
269+
}
270+
271+
func TestWorkerBackgroundRequiresName(t *testing.T) {
272+
d := caddyfile.NewTestDispenser(`
273+
{
274+
php_server {
275+
worker {
276+
file ../testdata/worker-with-env.php
277+
background
278+
}
279+
}
280+
}`)
281+
module := &FrankenPHPModule{}
282+
283+
err := module.UnmarshalCaddyfile(d)
284+
require.ErrorContains(t, err, `background workers must have an explicit "name"`)
273285
}
274286

275-
func TestCreateUniqueWorkerNamesQualifiedByServer(t *testing.T) {
276-
app := &FrankenPHPApp{}
277-
wc := workerConfig{FileName: "../testdata/worker-with-env.php", Name: "queue"}
278-
279-
require.Equal(t, "queue", app.createUniqueWorkerName(wc, "one.example.com"))
280-
// on collision, the name is qualified with the server name
281-
require.Equal(t, "two.example.com:queue", app.createUniqueWorkerName(wc, "two.example.com"))
282-
// when the qualified name is also taken, fall back to the numeric postfix
283-
require.Equal(t, "queue_1", app.createUniqueWorkerName(wc, "two.example.com"))
284-
// workers without a server keep the numeric postfix behavior
285-
require.Equal(t, "queue_2", app.createUniqueWorkerName(wc, ""))
287+
func TestWorkerBackgroundRequiresNum(t *testing.T) {
288+
d := caddyfile.NewTestDispenser(`
289+
{
290+
php_server {
291+
worker {
292+
name jobs
293+
file ../testdata/worker-with-env.php
294+
background
295+
}
296+
}
297+
}`)
298+
module := &FrankenPHPModule{}
299+
300+
err := module.UnmarshalCaddyfile(d)
301+
require.ErrorContains(t, err, `background workers must declare "num" >= 1`)
302+
}
303+
304+
func TestWorkerBackgroundRejectsMatch(t *testing.T) {
305+
d := caddyfile.NewTestDispenser(`
306+
{
307+
php_server {
308+
worker {
309+
name jobs
310+
file ../testdata/worker-with-env.php
311+
match /jobs/*
312+
background
313+
}
314+
}
315+
}`)
316+
module := &FrankenPHPModule{}
317+
318+
err := module.UnmarshalCaddyfile(d)
319+
require.ErrorContains(t, err, `"match" is not supported for background workers`)
286320
}

caddy/module.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,10 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
315315
// Check if a worker with this filename already exists in this module
316316
fileNames := make(map[string]struct{}, len(f.Workers))
317317
for _, w := range f.Workers {
318+
// background workers are keyed by name, several may share a script
319+
if w.Background {
320+
continue
321+
}
318322
if _, ok := fileNames[w.FileName]; ok {
319323
return fmt.Errorf(`workers in a single "php" or "php_server" block must not have duplicate filenames: %q`, w.FileName)
320324
}

caddy/workerconfig.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import (
2222
type workerConfig struct {
2323
mercureContext
2424

25-
// Name for the worker. Default: the absolute path of the worker file, postfixed with a number if the name is already used.
25+
// Name for the worker, unique within its php_server (or among global workers). Default: the absolute path of the worker file.
2626
Name string `json:"name,omitempty"`
2727
// FileName sets the path to the worker script.
2828
FileName string `json:"file_name,omitempty"`
@@ -38,6 +38,8 @@ type workerConfig struct {
3838
MatchPath []string `json:"match_path,omitempty"`
3939
// MaxConsecutiveFailures sets the maximum number of consecutive failures before panicking (defaults to 6, set to -1 to never panick)
4040
MaxConsecutiveFailures int `json:"max_consecutive_failures,omitempty"`
41+
// Background marks this worker as a background (non-HTTP) worker.
42+
Background bool `json:"background,omitempty"`
4143

4244
options []frankenphp.WorkerOption
4345
}
@@ -139,15 +141,29 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) {
139141
}
140142

141143
wc.MaxConsecutiveFailures = v
144+
case "background":
145+
wc.Background = true
142146
default:
143-
return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads", v)
147+
return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads, background", v)
144148
}
145149
}
146150

147151
if wc.FileName == "" {
148152
return wc, d.Err(`the "file" argument must be specified`)
149153
}
150154

155+
if wc.Background {
156+
if wc.Name == "" {
157+
return wc, d.Err(`background workers must have an explicit "name"`)
158+
}
159+
if len(wc.MatchPath) != 0 {
160+
return wc, d.Err(`"match" is not supported for background workers`)
161+
}
162+
if wc.Num < 1 {
163+
return wc, d.Err(`background workers must declare "num" >= 1`)
164+
}
165+
}
166+
151167
if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) {
152168
wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName)
153169
}
@@ -166,6 +182,10 @@ func (wc *workerConfig) toWorkerOptions() ([]frankenphp.WorkerOption, error) {
166182
// options collected while provisioning the module, e.g. the Mercure hub
167183
opts = append(opts, wc.options...)
168184

185+
if wc.Background {
186+
opts = append(opts, frankenphp.WithWorkerBackground())
187+
}
188+
169189
// copy the caddy match logic and create a unique matcher function for this worker
170190
// inject the matcher into frankenphp
171191
if len(wc.MatchPath) > 0 {

0 commit comments

Comments
 (0)