From 009c0aebb8a7fb3bd567751f90d2425b15c1b265 Mon Sep 17 00:00:00 2001 From: Mads Jon Nielsen Date: Fri, 27 Feb 2026 23:06:47 +0100 Subject: [PATCH 1/2] Add configurable max_idle_time for autoscaled threads Replace the hardcoded 5-second idle timeout for autoscaled threads with a configurable max_idle_time directive. This allows users to control how long idle autoscaled threads stay alive before deactivation, reducing cold-start overhead when bursty traffic arrives. --- caddy/app.go | 17 ++++++++++++++++- docs/config.md | 1 + frankenphp.go | 5 +++++ options.go | 10 ++++++++++ scaling.go | 5 ++--- scaling_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 80 insertions(+), 4 deletions(-) diff --git a/caddy/app.go b/caddy/app.go index 22d7e04ca8..582c09c18e 100644 --- a/caddy/app.go +++ b/caddy/app.go @@ -55,6 +55,8 @@ type FrankenPHPApp struct { PhpIni map[string]string `json:"php_ini,omitempty"` // The maximum amount of time a request may be stalled waiting for a thread MaxWaitTime time.Duration `json:"max_wait_time,omitempty"` + // The maximum amount of time an autoscaled thread may be idle before being deactivated + MaxIdleTime time.Duration `json:"max_idle_time,omitempty"` opts []frankenphp.Option metrics frankenphp.Metrics @@ -150,6 +152,7 @@ func (f *FrankenPHPApp) Start() error { frankenphp.WithMetrics(f.metrics), frankenphp.WithPhpIni(f.PhpIni), frankenphp.WithMaxWaitTime(f.MaxWaitTime), + frankenphp.WithMaxIdleTime(f.MaxIdleTime), ) for _, w := range f.Workers { @@ -190,6 +193,7 @@ func (f *FrankenPHPApp) Stop() error { f.Workers = nil f.NumThreads = 0 f.MaxWaitTime = 0 + f.MaxIdleTime = 0 optionsMU.Lock() options = nil @@ -242,6 +246,17 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } f.MaxWaitTime = v + case "max_idle_time": + if !d.NextArg() { + return d.ArgErr() + } + + v, err := time.ParseDuration(d.Val()) + if err != nil { + return d.Err("max_idle_time must be a valid duration (example: 30s)") + } + + f.MaxIdleTime = v case "php_ini": parseIniLine := func(d *caddyfile.Dispenser) error { key := d.Val() @@ -298,7 +313,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { f.Workers = append(f.Workers, wc) default: - return wrongSubDirectiveError("frankenphp", "num_threads, max_threads, php_ini, worker, max_wait_time", d.Val()) + return wrongSubDirectiveError("frankenphp", "num_threads, max_threads, php_ini, worker, max_wait_time, max_idle_time", d.Val()) } } } diff --git a/docs/config.md b/docs/config.md index 0252f54256..77c432b9e5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -96,6 +96,7 @@ You can also explicitly configure FrankenPHP using the [global option](https://c num_threads # Sets the number of PHP threads to start. Default: 2x the number of available CPUs. max_threads # Limits the number of additional PHP threads that can be started at runtime. Default: num_threads. Can be set to 'auto'. max_wait_time # Sets the maximum time a request may wait for a free PHP thread before timing out. Default: disabled. + max_idle_time # Sets the maximum time an autoscaled thread may be idle before being deactivated. Default: 5s. php_ini # Set a php.ini directive. Can be used several times to set multiple directives. worker { file # Sets the path to the worker script. diff --git a/frankenphp.go b/frankenphp.go index 3d571b226c..613b2a6bbd 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -276,6 +276,10 @@ func Init(options ...Option) error { maxWaitTime = opt.maxWaitTime + if opt.maxIdleTime > 0 { + maxIdleTime = opt.maxIdleTime + } + workerThreadCount, err := calculateMaxThreads(opt) if err != nil { Shutdown() @@ -781,5 +785,6 @@ func resetGlobals() { workersByName = nil workersByPath = nil watcherIsEnabled = false + maxIdleTime = 5 * time.Second globalMu.Unlock() } diff --git a/options.go b/options.go index 3b0ea1392b..9ba1f916f6 100644 --- a/options.go +++ b/options.go @@ -30,6 +30,7 @@ type opt struct { metrics Metrics phpIni map[string]string maxWaitTime time.Duration + maxIdleTime time.Duration } type workerOpt struct { @@ -156,6 +157,15 @@ func WithMaxWaitTime(maxWaitTime time.Duration) Option { } } +// WithMaxIdleTime configures the max time an autoscaled thread may be idle before being deactivated. +func WithMaxIdleTime(maxIdleTime time.Duration) Option { + return func(o *opt) error { + o.maxIdleTime = maxIdleTime + + return nil + } +} + // WithWorkerEnv sets environment variables for the worker func WithWorkerEnv(env map[string]string) WorkerOption { return func(w *workerOpt) error { diff --git a/scaling.go b/scaling.go index 3078851ac9..1c70bda14f 100644 --- a/scaling.go +++ b/scaling.go @@ -21,13 +21,12 @@ const ( downScaleCheckTime = 5 * time.Second // max amount of threads stopped in one iteration of downScaleCheckTime maxTerminationCount = 10 - // autoscaled threads waiting for longer than this time are downscaled - maxThreadIdleTime = 5 * time.Second ) var ( ErrMaxThreadsReached = errors.New("max amount of overall threads reached") + maxIdleTime = 5 * time.Second scaleChan chan *frankenPHPContext autoScaledThreads = []*phpThread{} scalingMu = new(sync.RWMutex) @@ -221,7 +220,7 @@ func deactivateThreads() { } // convert threads to inactive if they have been idle for too long - if thread.state.Is(state.Ready) && waitTime > maxThreadIdleTime.Milliseconds() { + if thread.state.Is(state.Ready) && waitTime > maxIdleTime.Milliseconds() { convertToInactiveThread(thread) stoppedThreadCount++ autoScaledThreads = append(autoScaledThreads[:i], autoScaledThreads[i+1:]...) diff --git a/scaling_test.go b/scaling_test.go index 235a4f3a11..8f251c0d33 100644 --- a/scaling_test.go +++ b/scaling_test.go @@ -57,6 +57,52 @@ func TestScaleAWorkerThreadUpAndDown(t *testing.T) { assert.IsType(t, &inactiveThread{}, autoScaledThread.handler) } +func TestMaxIdleTimePreventsEarlyDeactivation(t *testing.T) { + t.Cleanup(Shutdown) + + assert.NoError(t, Init( + WithNumThreads(1), + WithMaxThreads(2), + WithMaxIdleTime(time.Hour), + )) + + autoScaledThread := phpThreads[1] + + // scale up + scaleRegularThread() + assert.Equal(t, state.Ready, autoScaledThread.state.Get()) + + // set wait time to 30 minutes (less than 1 hour max idle time) + autoScaledThread.state.SetWaitTime(time.Now().Add(-30 * time.Minute)) + deactivateThreads() + assert.IsType(t, ®ularThread{}, autoScaledThread.handler, "thread should still be active after 30min with 1h max idle time") + + // set wait time to over 1 hour (exceeds max idle time) + autoScaledThread.state.SetWaitTime(time.Now().Add(-time.Hour - time.Minute)) + deactivateThreads() + assert.IsType(t, &inactiveThread{}, autoScaledThread.handler, "thread should be deactivated after exceeding max idle time") +} + +func TestDefaultMaxIdleTimeIs5Seconds(t *testing.T) { + t.Cleanup(Shutdown) + + assert.NoError(t, Init( + WithNumThreads(1), + WithMaxThreads(2), + )) + + autoScaledThread := phpThreads[1] + + // scale up + scaleRegularThread() + assert.Equal(t, state.Ready, autoScaledThread.state.Get()) + + // set wait time to 10 seconds (exceeds default 5s max idle time) + autoScaledThread.state.SetWaitTime(time.Now().Add(-10 * time.Second)) + deactivateThreads() + assert.IsType(t, &inactiveThread{}, autoScaledThread.handler, "thread should be deactivated after 10s with default 5s max idle time") +} + func setLongWaitTime(t *testing.T, thread *phpThread) { t.Helper() From bcd5fa3cc65d3a467b6b6ba3a28a536f7522d433 Mon Sep 17 00:00:00 2001 From: Mads Jon Nielsen Date: Sun, 1 Mar 2026 17:16:56 +0100 Subject: [PATCH 2/2] Address review feedback: extract defaultMaxIdleTime constant and remove redundant test --- frankenphp.go | 2 +- scaling.go | 4 +++- scaling_test.go | 20 -------------------- 3 files changed, 4 insertions(+), 22 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index 613b2a6bbd..94759bb334 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -785,6 +785,6 @@ func resetGlobals() { workersByName = nil workersByPath = nil watcherIsEnabled = false - maxIdleTime = 5 * time.Second + maxIdleTime = defaultMaxIdleTime globalMu.Unlock() } diff --git a/scaling.go b/scaling.go index 1c70bda14f..190c54d0df 100644 --- a/scaling.go +++ b/scaling.go @@ -21,12 +21,14 @@ const ( downScaleCheckTime = 5 * time.Second // max amount of threads stopped in one iteration of downScaleCheckTime maxTerminationCount = 10 + // default time an autoscaled thread may be idle before being deactivated + defaultMaxIdleTime = 5 * time.Second ) var ( ErrMaxThreadsReached = errors.New("max amount of overall threads reached") - maxIdleTime = 5 * time.Second + maxIdleTime = defaultMaxIdleTime scaleChan chan *frankenPHPContext autoScaledThreads = []*phpThread{} scalingMu = new(sync.RWMutex) diff --git a/scaling_test.go b/scaling_test.go index 8f251c0d33..61795456de 100644 --- a/scaling_test.go +++ b/scaling_test.go @@ -83,26 +83,6 @@ func TestMaxIdleTimePreventsEarlyDeactivation(t *testing.T) { assert.IsType(t, &inactiveThread{}, autoScaledThread.handler, "thread should be deactivated after exceeding max idle time") } -func TestDefaultMaxIdleTimeIs5Seconds(t *testing.T) { - t.Cleanup(Shutdown) - - assert.NoError(t, Init( - WithNumThreads(1), - WithMaxThreads(2), - )) - - autoScaledThread := phpThreads[1] - - // scale up - scaleRegularThread() - assert.Equal(t, state.Ready, autoScaledThread.state.Get()) - - // set wait time to 10 seconds (exceeds default 5s max idle time) - autoScaledThread.state.SetWaitTime(time.Now().Add(-10 * time.Second)) - deactivateThreads() - assert.IsType(t, &inactiveThread{}, autoScaledThread.handler, "thread should be deactivated after 10s with default 5s max idle time") -} - func setLongWaitTime(t *testing.T, thread *phpThread) { t.Helper()