diff --git a/caddy/app.go b/caddy/app.go index 01831c533..687a670da 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -123,6 +123,7 @@ func (f *FrankenPHPApp) Start() error { frankenphp.WithWorkerEnv(w.Env), frankenphp.WithWorkerWatchMode(w.Watch), frankenphp.WithWorkerMaxFailures(w.MaxConsecutiveFailures), + frankenphp.WithWorkerMaxRequests(w.MaxRequests), } opts = append(opts, frankenphp.WithWorkers(w.Name, repl.ReplaceKnown(w.FileName, ""), w.Num, workerOpts...)) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index efd9e30aa..5f1783db3 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -1423,3 +1423,31 @@ func TestWorkerMatchDirectiveWithoutFileServer(t *testing.T) { // the request should completely fall through the php_server module tester.AssertGetResponse("http://localhost:"+testPort+"/static.txt", http.StatusNotFound, "Request falls through") } + +func TestWorkerMaxRequests(t *testing.T) { + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + } + + http://localhost:`+testPort+` { + php { + root ../testdata + worker { + file worker-with-counter.php + match * + num 1 + max_requests 2 + } + } + } + `, "caddyfile") + + tester.AssertGetResponse("http://localhost:"+testPort+"/some-path", http.StatusOK, "requests:1") + tester.AssertGetResponse("http://localhost:"+testPort+"/some-path", http.StatusOK, "requests:2") + + // should reset worker after 2 requests + tester.AssertGetResponse("http://localhost:"+testPort+"/some-path", http.StatusOK, "requests:1") +} diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go index 60a2c6fd3..786ad7c23 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -37,6 +37,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"` + // MaxRequests sets the maximum number of requests a worker will handle before being restarted (defaults to 0, meaning unlimited) + MaxRequests int `json:"max_requests,omitempty"` } func parseWorkerConfig(d *caddyfile.Dispenser) (workerConfig, error) { @@ -122,6 +124,19 @@ func parseWorkerConfig(d *caddyfile.Dispenser) (workerConfig, error) { } wc.MaxConsecutiveFailures = int(v) + case "max_requests": + if !d.NextArg() { + return wc, d.ArgErr() + } + v, err := strconv.Atoi(d.Val()) + if err != nil { + return wc, err + } + if v < 1 { + return wc, errors.New("max_requests must be >= 1") + } + + wc.MaxRequests = int(v) default: allowedDirectives := "name, file, num, env, watch, match, max_consecutive_failures" return wc, wrongSubDirectiveError("worker", allowedDirectives, v) diff --git a/docs/config.md b/docs/config.md index 64654a18f..75fed906c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -71,6 +71,7 @@ You can also explicitly configure FrankenPHP using the [global option](https://c watch # Sets the path to watch for file changes. Can be specified more than once for multiple paths. name # Sets the name of the worker, used in logs and metrics. Default: absolute path of worker file max_consecutive_failures # Sets the maximum number of consecutive failures before the worker is considered unhealthy, -1 means the worker will always restart. Default: 6. + max_requests # Sets the maximum number of requests a worker will handle before being restarted. Default: unlimited (can also be handled on the PHP side) } } } diff --git a/options.go b/options.go index 18c5ba20f..a89311435 100644 --- a/options.go +++ b/options.go @@ -35,6 +35,7 @@ type workerOpt struct { env PreparedEnv watch []string maxConsecutiveFailures int + maxRequests int } // WithNumThreads configures the number of PHP threads to start. @@ -116,6 +117,18 @@ func WithWorkerMaxFailures(maxFailures int) WorkerOption { } } +// WithWorkerMaxRequests sets the maximum number of requests a worker will handle before being restarted +func WithWorkerMaxRequests(maxRequests int) WorkerOption { + return func(w *workerOpt) error { + if maxRequests < 0 { + return fmt.Errorf("max requests must be >= 0, got %d", maxRequests) + } + w.maxRequests = maxRequests + + return nil + } +} + // WithLogger configures the global logger to use. func WithLogger(l *slog.Logger) Option { return func(o *opt) error { diff --git a/threadworker.go b/threadworker.go index b7dc82036..512170bba 100644 --- a/threadworker.go +++ b/threadworker.go @@ -19,6 +19,7 @@ type workerThread struct { dummyContext *frankenPHPContext workerContext *frankenPHPContext backoff *exponentialBackoff + requestCount int // number of requests served (without restarting the worker script) isBootingScript bool // true if the worker has not reached frankenphp_handle_request yet } @@ -94,6 +95,7 @@ func setupWorkerScript(handler *workerThread, worker *worker) { fc.worker = worker handler.dummyContext = fc handler.isBootingScript = true + handler.requestCount = 0 clearSandboxedEnv(handler.thread) logger.LogAttrs(context.Background(), slog.LevelDebug, "starting", slog.String("worker", worker.name), slog.Int("thread", handler.thread.threadIndex)) } @@ -166,6 +168,12 @@ func (handler *workerThread) waitForWorkerRequest() bool { handler.state.set(stateReady) } + // restart the worker if 'max requests' has been reached + if handler.worker.maxRequests > 0 && handler.requestCount >= handler.worker.maxRequests { + logger.LogAttrs(ctx, slog.LevelDebug, "max requests reached, restarting", slog.String("worker", handler.worker.name), slog.Int("thread", handler.thread.threadIndex), slog.Int("max_requests", handler.worker.maxRequests)) + return false + } + handler.state.markAsWaiting(true) var fc *frankenPHPContext @@ -185,6 +193,7 @@ func (handler *workerThread) waitForWorkerRequest() bool { } handler.workerContext = fc + handler.requestCount++ handler.state.markAsWaiting(false) logger.LogAttrs(ctx, slog.LevelDebug, "request handling started", slog.String("worker", handler.worker.name), slog.Int("thread", handler.thread.threadIndex), slog.String("url", fc.request.RequestURI)) diff --git a/worker.go b/worker.go index 04772fa4a..c6803d6cf 100644 --- a/worker.go +++ b/worker.go @@ -23,6 +23,7 @@ type worker struct { threadMutex sync.RWMutex allowPathMatching bool maxConsecutiveFailures int + maxRequests int } var ( @@ -125,6 +126,7 @@ func newWorker(o workerOpt) (*worker, error) { threads: make([]*phpThread, 0, o.num), allowPathMatching: allowPathMatching, maxConsecutiveFailures: o.maxConsecutiveFailures, + maxRequests: o.maxRequests, } return w, nil