Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
473 changes: 473 additions & 0 deletions bgworker_test.go

Large diffs are not rendered by default.

51 changes: 8 additions & 43 deletions caddy/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import (
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/dunglas/frankenphp"
"github.com/dunglas/frankenphp/internal/fastabs"
)

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

opts []frankenphp.Option
metrics frankenphp.Metrics
ctx context.Context
logger *slog.Logger
modules []*FrankenPHPModule
usedWorkerNames map[string]bool
httpApp *caddyhttp.App
hasStarted atomic.Bool
started chan any
opts []frankenphp.Option
metrics frankenphp.Metrics
ctx context.Context
logger *slog.Logger
modules []*FrankenPHPModule
httpApp *caddyhttp.App
hasStarted atomic.Bool
started chan any
}

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

for _, w := range module.Workers {
w.FileName = repl.ReplaceKnown(w.FileName, "")
w.Name = f.createUniqueWorkerName(w, serverName)
workerOptions, err := w.toWorkerOptions()
if err != nil {
return err
Expand All @@ -236,37 +232,6 @@ func (f *FrankenPHPApp) registerModule(repl *caddy.Replacer, module *FrankenPHPM
return nil
}

// avoid name collisions for workers
// on collision, a name is first qualified with the server name
// ("<serverName>:<name>") before falling back to a numeric postfix
func (f *FrankenPHPApp) createUniqueWorkerName(wc workerConfig, serverName string) string {
if f.usedWorkerNames == nil {
f.usedWorkerNames = make(map[string]bool)
}

if wc.Name == "" {
wc.Name, _ = fastabs.FastAbs(wc.FileName)
}

name := wc.Name
suffix := 0
for {
if _, ok := f.usedWorkerNames[name]; !ok {
f.usedWorkerNames[name] = true
break
}
if serverName != "" {
name = serverName + ":" + wc.Name
serverName = ""
continue
}
suffix++
name = fmt.Sprintf("%s_%d", wc.Name, suffix)
}

return name
}

// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
for d.Next() {
Expand Down
53 changes: 46 additions & 7 deletions caddy/caddy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,45 @@ func TestMetrics(t *testing.T) {
require.NoError(t, testutil.GatherAndCompare(ctx.GetMetricsRegistry(), strings.NewReader(expectedMetrics), "frankenphp_total_threads", "frankenphp_busy_threads"))
}

// TestBackgroundWorkerFromCaddyfile starts a background worker from a
// Caddyfile and checks it runs: the sentinel its script touches appears
func TestBackgroundWorkerFromCaddyfile(t *testing.T) {
sentinel := filepath.ToSlash(filepath.Join(t.TempDir(), "bg.sentinel"))
tester := caddytest.NewTester(t)
initServer(t, tester, `
{
skip_install_trust
admin localhost:2999
http_port `+testPort+`
https_port 9443

frankenphp {
worker {
file ../testdata/bgworker/basic.php
num 1
name bg-caddy
background
env BG_SENTINEL `+sentinel+`
}
}
}

localhost:`+testPort+` {
route {
php {
root ../testdata
}
}
}
`, "caddyfile")

require.Eventually(t, func() bool {
_, err := os.Stat(sentinel)

return err == nil
}, 5*time.Second, 25*time.Millisecond, "the background worker declared in the Caddyfile did not run")
}

func TestWorkerMetrics(t *testing.T) {
var wg sync.WaitGroup
tester := caddytest.NewTester(t)
Expand Down Expand Up @@ -839,7 +878,7 @@ func TestWorkerMetrics(t *testing.T) {
# TYPE frankenphp_worker_request_count counter
frankenphp_worker_request_count{worker="` + workerName + `"} 10

# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
# 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
# TYPE frankenphp_ready_workers gauge
frankenphp_ready_workers{worker="` + workerName + `"} 2
`
Expand Down Expand Up @@ -996,7 +1035,7 @@ func TestNamedWorkerMetrics(t *testing.T) {
# TYPE frankenphp_worker_request_count counter
frankenphp_worker_request_count{worker="my_app"} 10

# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
# 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
# TYPE frankenphp_ready_workers gauge
frankenphp_ready_workers{worker="my_app"} 2
`
Expand Down Expand Up @@ -1092,7 +1131,7 @@ func TestAutoWorkerConfig(t *testing.T) {
# TYPE frankenphp_worker_request_count counter
frankenphp_worker_request_count{worker="` + workerName + `"} 10

# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
# 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
# TYPE frankenphp_ready_workers gauge
frankenphp_ready_workers{worker="` + workerName + `"} ` + workers + `
`
Expand Down Expand Up @@ -1460,7 +1499,7 @@ func TestMultiWorkersMetrics(t *testing.T) {
# TYPE frankenphp_worker_request_count counter
frankenphp_worker_request_count{worker="service1"} 10

# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
# 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
# TYPE frankenphp_ready_workers gauge
frankenphp_ready_workers{worker="service1"} 2
frankenphp_ready_workers{worker="service2"} 3
Expand Down Expand Up @@ -1614,7 +1653,7 @@ func TestWorkerRestart(t *testing.T) {

// Check metrics
expectedMetrics := `
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
# 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
# TYPE frankenphp_ready_workers gauge
frankenphp_ready_workers{worker="service"} 1
# HELP frankenphp_total_workers Total number of PHP workers for this worker
Expand Down Expand Up @@ -1642,7 +1681,7 @@ func TestWorkerRestart(t *testing.T) {

// frankenphp_ready_workers should be back to 1 even after worker restarts
expectedMetrics = `
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
# 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
# TYPE frankenphp_ready_workers gauge
frankenphp_ready_workers{worker="service"} 1
# HELP frankenphp_total_workers Total number of PHP workers for this worker
Expand Down Expand Up @@ -2113,7 +2152,7 @@ func TestSymlinkWorkerBehavior(t *testing.T) {

// Accessing the worker script without worker configuration MUST fail
// The script checks $_SERVER['FRANKENPHP_WORKER'] and dies if not set
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")
tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, "Error: This script must be run in worker mode (FRANKENPHP_WORKER not set)\n")
})

t.Run("MultipleRequests", func(t *testing.T) {
Expand Down
100 changes: 67 additions & 33 deletions caddy/config_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package caddy

import (
"path/filepath"
"testing"
"time"

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

func TestCreateUniqueWorkerNames(t *testing.T) {
app := &FrankenPHPApp{}
filename := "../testdata/worker-with-env.php"
absFileName, _ := filepath.Abs(filename)
names := make([]string, 6)
for i := range 3 {
names[i] = app.createUniqueWorkerName(workerConfig{
FileName: filename,
Name: "custom-worker-name",
}, "")
names[i+3] = app.createUniqueWorkerName(workerConfig{
FileName: filename,
}, "")
}

require.Equal(t, "custom-worker-name", names[0])
require.Equal(t, "custom-worker-name_1", names[1])
require.Equal(t, "custom-worker-name_2", names[2])
require.Equal(t, absFileName, names[3])
require.Equal(t, absFileName+"_1", names[4])
require.Equal(t, absFileName+"_2", names[5])
func TestWorkerBackgroundConfig(t *testing.T) {
d := caddyfile.NewTestDispenser(`
{
php_server {
worker {
name jobs
file ../testdata/worker-with-env.php
num 2
background
}
}
}`)
module := &FrankenPHPModule{}

require.NoError(t, module.UnmarshalCaddyfile(d))
require.Len(t, module.Workers, 1)
require.True(t, module.Workers[0].Background)
require.Equal(t, "jobs", module.Workers[0].Name)
}

func TestWorkerBackgroundRequiresName(t *testing.T) {
d := caddyfile.NewTestDispenser(`
{
php_server {
worker {
file ../testdata/worker-with-env.php
background
}
}
}`)
module := &FrankenPHPModule{}

err := module.UnmarshalCaddyfile(d)
require.ErrorContains(t, err, `background workers must have an explicit "name"`)
}

func TestCreateUniqueWorkerNamesQualifiedByServer(t *testing.T) {
app := &FrankenPHPApp{}
wc := workerConfig{FileName: "../testdata/worker-with-env.php", Name: "queue"}

require.Equal(t, "queue", app.createUniqueWorkerName(wc, "one.example.com"))
// on collision, the name is qualified with the server name
require.Equal(t, "two.example.com:queue", app.createUniqueWorkerName(wc, "two.example.com"))
// when the qualified name is also taken, fall back to the numeric postfix
require.Equal(t, "queue_1", app.createUniqueWorkerName(wc, "two.example.com"))
// workers without a server keep the numeric postfix behavior
require.Equal(t, "queue_2", app.createUniqueWorkerName(wc, ""))
func TestWorkerBackgroundRequiresNum(t *testing.T) {
d := caddyfile.NewTestDispenser(`
{
php_server {
worker {
name jobs
file ../testdata/worker-with-env.php
background
}
}
}`)
module := &FrankenPHPModule{}

err := module.UnmarshalCaddyfile(d)
require.ErrorContains(t, err, `background workers must declare "num" >= 1`)
}

func TestWorkerBackgroundRejectsMatch(t *testing.T) {
d := caddyfile.NewTestDispenser(`
{
php_server {
worker {
name jobs
file ../testdata/worker-with-env.php
match /jobs/*
background
}
}
}`)
module := &FrankenPHPModule{}

err := module.UnmarshalCaddyfile(d)
require.ErrorContains(t, err, `"match" is not supported for background workers`)
}
4 changes: 4 additions & 0 deletions caddy/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,10 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
// Check if a worker with this filename already exists in this module
fileNames := make(map[string]struct{}, len(f.Workers))
for _, w := range f.Workers {
// background workers are keyed by name, several may share a script
if w.Background {
continue
}
if _, ok := fileNames[w.FileName]; ok {
return fmt.Errorf(`workers in a single "php" or "php_server" block must not have duplicate filenames: %q`, w.FileName)
}
Expand Down
24 changes: 22 additions & 2 deletions caddy/workerconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import (
type workerConfig struct {
mercureContext

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

options []frankenphp.WorkerOption
}
Expand Down Expand Up @@ -139,15 +141,29 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) {
}

wc.MaxConsecutiveFailures = v
case "background":
wc.Background = true
default:
return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads", v)
return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads, background", v)
}
}

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

if wc.Background {
if wc.Name == "" {
return wc, d.Err(`background workers must have an explicit "name"`)
}
if len(wc.MatchPath) != 0 {
return wc, d.Err(`"match" is not supported for background workers`)
}
if wc.Num < 1 {
return wc, d.Err(`background workers must declare "num" >= 1`)
}
}

if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) {
wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName)
}
Expand All @@ -166,6 +182,10 @@ func (wc *workerConfig) toWorkerOptions() ([]frankenphp.WorkerOption, error) {
// options collected while provisioning the module, e.g. the Mercure hub
opts = append(opts, wc.options...)

if wc.Background {
opts = append(opts, frankenphp.WithWorkerBackground())
}

// copy the caddy match logic and create a unique matcher function for this worker
// inject the matcher into frankenphp
if len(wc.MatchPath) > 0 {
Expand Down
Loading
Loading