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
746 changes: 746 additions & 0 deletions bgworker_test.go

Large diffs are not rendered by default.

11 changes: 7 additions & 4 deletions caddy/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -410,10 +410,13 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
if len(wc.MatchPath) != 0 {
return d.Errf(`"match" can only be used in a php_server worker block, not in a global one: %q`, wc.FileName)
}
// check for duplicate workers
for _, existingWorker := range f.Workers {
if existingWorker.FileName == wc.FileName {
return d.Errf("global workers must not have duplicate filenames: %q", wc.FileName)
// check for duplicate workers; background workers are not
// matched by path, several may share a script
if !wc.Background {
for _, existingWorker := range f.Workers {
if !existingWorker.Background && existingWorker.FileName == wc.FileName {
return d.Errf("global workers must not have duplicate filenames: %q", wc.FileName)
}
}
}

Expand Down
51 changes: 45 additions & 6 deletions caddy/caddy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,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 @@ -870,7 +909,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, WorkerHandle::tick() for background workers
# TYPE frankenphp_ready_workers gauge
frankenphp_ready_workers{worker="` + workerName + `"} 2
`
Expand Down Expand Up @@ -1027,7 +1066,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, WorkerHandle::tick() for background workers
# TYPE frankenphp_ready_workers gauge
frankenphp_ready_workers{worker="my_app"} 2
`
Expand Down Expand Up @@ -1123,7 +1162,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, WorkerHandle::tick() for background workers
# TYPE frankenphp_ready_workers gauge
frankenphp_ready_workers{worker="` + workerName + `"} ` + workers + `
`
Expand Down Expand Up @@ -1498,7 +1537,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, WorkerHandle::tick() for background workers
# TYPE frankenphp_ready_workers gauge
frankenphp_ready_workers{worker="service1"} 2
frankenphp_ready_workers{worker="service2"} 3
Expand Down Expand Up @@ -1652,7 +1691,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, WorkerHandle::tick() 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 @@ -1680,7 +1719,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, WorkerHandle::tick() 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
145 changes: 145 additions & 0 deletions caddy/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,45 @@ func TestModuleWorkerDuplicateFilenamesFail(t *testing.T) {
require.Contains(t, err.Error(), "must not have duplicate filenames", "Error message should mention duplicate filenames")
}

// two global background workers may share a script, like their php_server
// counterparts and the Go API: they are not matched by path
func TestGlobalBackgroundWorkersShareAFilename(t *testing.T) {
d := caddyfile.NewTestDispenser(`
{
frankenphp {
worker {
name first
file ../testdata/worker-with-env.php
num 1
background
}
worker {
name second
file ../testdata/worker-with-env.php
num 1
background
}
}
}`)
app := &FrankenPHPApp{}

require.NoError(t, app.UnmarshalCaddyfile(d))
require.Len(t, app.Workers, 2)
}

func TestGlobalWorkerDuplicateFilenamesFail(t *testing.T) {
d := caddyfile.NewTestDispenser(`
{
frankenphp {
worker ../testdata/worker-with-env.php
worker ../testdata/worker-with-env.php
}
}`)
app := &FrankenPHPApp{}

require.ErrorContains(t, app.UnmarshalCaddyfile(d), "must not have duplicate filenames")
}

func TestModuleWorkersWithDifferentFilenames(t *testing.T) {
// Create a test configuration with different worker filenames
configWithDifferentFilenames := `
Expand Down Expand Up @@ -295,3 +334,109 @@ func TestCreateUniqueWorkerNamesQualifiedByServer(t *testing.T) {
// workers without a server keep the numeric postfix behavior
require.Equal(t, "queue_2", createUniqueWorkerName(usedWorkerNames, wc, ""))
}

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)
}

// the name is generated when the workers are collected, like for an HTTP worker
func TestWorkerBackgroundNameIsOptional(t *testing.T) {
d := caddyfile.NewTestDispenser(`
{
php_server {
worker {
file ../testdata/worker-with-env.php
background
}
}
}`)
module := &FrankenPHPModule{}

require.NoError(t, module.UnmarshalCaddyfile(d))
require.True(t, module.Workers[0].Background)
require.Empty(t, module.Workers[0].Name)
}

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

// num is optional, it defaults to one thread when the workers start
require.NoError(t, module.UnmarshalCaddyfile(d))
}

func TestWorkerBootTimeout(t *testing.T) {
parse := func(t *testing.T, directives string) (*FrankenPHPModule, error) {
t.Helper()
module := &FrankenPHPModule{}
err := module.UnmarshalCaddyfile(caddyfile.NewTestDispenser(`
{
php_server {
worker {
file ../testdata/worker-with-env.php
` + directives + `
}
}
}`))

return module, err
}

module, err := parse(t, "background\nboot_timeout 5s")
require.NoError(t, err)
require.Equal(t, caddy.Duration(5*time.Second), *module.Workers[0].BootTimeout)

// zero waits forever
module, err = parse(t, "background\nboot_timeout 0")
require.NoError(t, err)
require.Equal(t, caddy.Duration(0), *module.Workers[0].BootTimeout)

_, err = parse(t, "boot_timeout 5s")
require.ErrorContains(t, err, `"boot_timeout" is only supported for background workers`)

_, err = parse(t, "background\nboot_timeout -1s")
require.ErrorContains(t, err, "boot_timeout must be >= 0")
}

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 not matched by path, 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
40 changes: 39 additions & 1 deletion caddy/workerconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package caddy
import (
"path/filepath"
"strconv"
"time"

"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
Expand Down Expand Up @@ -38,6 +39,10 @@ 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"`
// BootTimeout bounds how long a background worker may take to reach its first WorkerHandle::tick() before the start fails (defaults to 30s on PHP builds without Zend max execution timers, where max_execution_time is disabled, set to 0 to wait forever)
BootTimeout *caddy.Duration `json:"boot_timeout,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 +144,40 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) {
}

wc.MaxConsecutiveFailures = new(v)
case "boot_timeout":
if !d.NextArg() {
return wc, d.ArgErr()
}

v, err := caddy.ParseDuration(d.Val())
if err != nil {
return wc, d.WrapErr(err)
}
if v < 0 {
return wc, d.Errf("boot_timeout must be >= 0")
}

timeout := caddy.Duration(v)
wc.BootTimeout = &timeout
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, boot_timeout", v)
}
}

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

if !wc.Background && wc.BootTimeout != nil {
return wc, d.Err(`"boot_timeout" is only supported for background workers`)
}

if wc.Background && len(wc.MatchPath) != 0 {
return wc, d.Err(`"match" is not supported for background workers`)
}

if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) {
wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName)
}
Expand All @@ -169,6 +199,14 @@ 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())
Comment thread
nicolas-grekas marked this conversation as resolved.

if wc.BootTimeout != nil {
opts = append(opts, frankenphp.WithWorkerBootTimeout(time.Duration(*wc.BootTimeout)))
}
}

// 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
8 changes: 6 additions & 2 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,13 @@ You can also explicitly configure FrankenPHP using the [global option](https://c
php_ini <key> <value> # Set a php.ini directive. Can be used several times to set multiple directives.
worker {
file <path> # Sets the path to the worker script.
num <num> # Sets the number of PHP threads to start, defaults to 2x the number of available CPUs.
num <num> # Sets the number of PHP threads to start, defaults to 2x the number of available CPUs, 1 for background workers.
env <key> <value> # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables.
watch <path> # Sets the path to watch for file changes. Can be specified more than once for multiple paths.
name <name> # Sets the name of the worker, used in logs and metrics. Default: absolute path of worker file
max_consecutive_failures <num> # Sets the maximum number of consecutive failures before the worker is considered unhealthy, -1 means the worker will always restart. Default: 6.
boot_timeout <duration> # EXPERIMENTAL: how long a background worker may take to reach its first tick() before the start fails, 0 waits forever. Default: 30s on PHP builds without Zend max execution timers, none elsewhere.
background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, whose script ticks its FrankenPHP\WorkerHandle at least once
}
}
}
Expand Down Expand Up @@ -193,11 +195,13 @@ php_server [<matcher>] {
request_body_timeout <duration> # Sets an idle timeout on request body reads: a stalled (slow POST) client is cut off while a steady upload of any size succeeds. Default: 60s. Set to 0 to disable.
worker { # Creates a worker specific to this server. Can be specified more than once for multiple workers.
file <path> # Sets the path to the worker script, can be relative to the php_server root
num <num> # Sets the number of PHP threads to start, defaults to 2x the number of available
num <num> # Sets the number of PHP threads to start, defaults to 2x the number of available CPUs, 1 for background workers.
name <name> # Sets the name for the worker, used in logs and metrics. Default: absolute path of worker file. Postfixed with a number if name is already in use.
watch <path> # Sets the path to watch for file changes. Can be specified more than once for multiple paths.
env <key> <value> # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables. Environment variables for this worker are also inherited from the php_server parent, but can be overwritten here.
match <path> # match the worker to a path pattern. Overrides try_files and can only be used in the php_server directive.
boot_timeout <duration> # EXPERIMENTAL: how long a background worker may take to reach its first tick() before the start fails, 0 waits forever. Default: 30s on PHP builds without Zend max execution timers, none elsewhere.
background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker, whose script ticks its FrankenPHP\WorkerHandle at least once
}
worker <other_file> <num> # Can also use the short form like in the global frankenphp block.
}
Expand Down
2 changes: 2 additions & 0 deletions docs/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ err := frankenphp.Init(

Workers declared without a server scope are global: they match by file path on any server. Since a global worker has no set of requests to match against, combining `WithWorkerMatcher()` with a global worker is a configuration error and `Init()` rejects it.

`WithWorkerBackground()` declares a [background worker](worker.md#background-workers), which runs outside the request cycle.

## Per-request options

`Server.ServeHTTP()` accepts `RequestOption`s to override the server configuration for a single request, e.g. `WithRequestDocumentRoot()`, `WithRequestSplitPath()`, `WithRequestEnv()` or `WithRequestLogger()`.
Expand Down
Loading
Loading