feat(caddy): configurable max_idle_time for autoscaled threads (#2225)

Add configurable max_idle_time for autoscaled threads

The idle timeout for autoscaled threads is currently hardcoded to 5
seconds. With bursty traffic patterns, this causes threads to be
deactivated too quickly, leading to repeated cold-start overhead when
the next burst arrives.

This PR replaces the hardcoded constant with a configurable
max_idle_time directive, allowing users to tune how long idle
autoscaled threads stay alive before deactivation. The default remains 5
seconds, preserving existing behavior.

  Usage:

  Caddyfile:
````
  frankenphp {
      max_idle_time 30s
  }
````
  JSON config:
```
  {
      "frankenphp": {
          "max_idle_time": "30s"
      }
  }
````

  Changes:
  - New max_idle_time Caddyfile directive and JSON config option
  - New WithMaxIdleTime functional option
- Replaced hardcoded maxThreadIdleTime constant with configurable
maxIdleTime variable
  - Added tests for custom and default idle time behavior
  - Updated docs
This commit is contained in:
Mads Jon Nielsen
2026-03-06 14:43:37 +01:00
committed by GitHub
parent 5d44741041
commit c099d665a2
6 changed files with 62 additions and 4 deletions

View File

@@ -55,6 +55,8 @@ type FrankenPHPApp struct {
PhpIni map[string]string `json:"php_ini,omitempty"` PhpIni map[string]string `json:"php_ini,omitempty"`
// The maximum amount of time a request may be stalled waiting for a thread // The maximum amount of time a request may be stalled waiting for a thread
MaxWaitTime time.Duration `json:"max_wait_time,omitempty"` 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 opts []frankenphp.Option
metrics frankenphp.Metrics metrics frankenphp.Metrics
@@ -150,6 +152,7 @@ func (f *FrankenPHPApp) Start() error {
frankenphp.WithMetrics(f.metrics), frankenphp.WithMetrics(f.metrics),
frankenphp.WithPhpIni(f.PhpIni), frankenphp.WithPhpIni(f.PhpIni),
frankenphp.WithMaxWaitTime(f.MaxWaitTime), frankenphp.WithMaxWaitTime(f.MaxWaitTime),
frankenphp.WithMaxIdleTime(f.MaxIdleTime),
) )
for _, w := range f.Workers { for _, w := range f.Workers {
@@ -190,6 +193,7 @@ func (f *FrankenPHPApp) Stop() error {
f.Workers = nil f.Workers = nil
f.NumThreads = 0 f.NumThreads = 0
f.MaxWaitTime = 0 f.MaxWaitTime = 0
f.MaxIdleTime = 0
optionsMU.Lock() optionsMU.Lock()
options = nil options = nil
@@ -242,6 +246,17 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
} }
f.MaxWaitTime = v 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": case "php_ini":
parseIniLine := func(d *caddyfile.Dispenser) error { parseIniLine := func(d *caddyfile.Dispenser) error {
key := d.Val() key := d.Val()
@@ -298,7 +313,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
f.Workers = append(f.Workers, wc) f.Workers = append(f.Workers, wc)
default: 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())
} }
} }
} }

View File

@@ -96,6 +96,7 @@ You can also explicitly configure FrankenPHP using the [global option](https://c
num_threads <num_threads> # Sets the number of PHP threads to start. Default: 2x the number of available CPUs. num_threads <num_threads> # Sets the number of PHP threads to start. Default: 2x the number of available CPUs.
max_threads <num_threads> # Limits the number of additional PHP threads that can be started at runtime. Default: num_threads. Can be set to 'auto'. max_threads <num_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 <duration> # Sets the maximum time a request may wait for a free PHP thread before timing out. Default: disabled. max_wait_time <duration> # Sets the maximum time a request may wait for a free PHP thread before timing out. Default: disabled.
max_idle_time <duration> # Sets the maximum time an autoscaled thread may be idle before being deactivated. Default: 5s.
php_ini <key> <value> # Set a php.ini directive. Can be used several times to set multiple directives. php_ini <key> <value> # Set a php.ini directive. Can be used several times to set multiple directives.
worker { worker {
file <path> # Sets the path to the worker script. file <path> # Sets the path to the worker script.

View File

@@ -276,6 +276,10 @@ func Init(options ...Option) error {
maxWaitTime = opt.maxWaitTime maxWaitTime = opt.maxWaitTime
if opt.maxIdleTime > 0 {
maxIdleTime = opt.maxIdleTime
}
workerThreadCount, err := calculateMaxThreads(opt) workerThreadCount, err := calculateMaxThreads(opt)
if err != nil { if err != nil {
Shutdown() Shutdown()
@@ -781,5 +785,6 @@ func resetGlobals() {
workersByName = nil workersByName = nil
workersByPath = nil workersByPath = nil
watcherIsEnabled = false watcherIsEnabled = false
maxIdleTime = defaultMaxIdleTime
globalMu.Unlock() globalMu.Unlock()
} }

View File

@@ -30,6 +30,7 @@ type opt struct {
metrics Metrics metrics Metrics
phpIni map[string]string phpIni map[string]string
maxWaitTime time.Duration maxWaitTime time.Duration
maxIdleTime time.Duration
} }
type workerOpt struct { 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 // WithWorkerEnv sets environment variables for the worker
func WithWorkerEnv(env map[string]string) WorkerOption { func WithWorkerEnv(env map[string]string) WorkerOption {
return func(w *workerOpt) error { return func(w *workerOpt) error {

View File

@@ -21,13 +21,14 @@ const (
downScaleCheckTime = 5 * time.Second downScaleCheckTime = 5 * time.Second
// max amount of threads stopped in one iteration of downScaleCheckTime // max amount of threads stopped in one iteration of downScaleCheckTime
maxTerminationCount = 10 maxTerminationCount = 10
// autoscaled threads waiting for longer than this time are downscaled // default time an autoscaled thread may be idle before being deactivated
maxThreadIdleTime = 5 * time.Second defaultMaxIdleTime = 5 * time.Second
) )
var ( var (
ErrMaxThreadsReached = errors.New("max amount of overall threads reached") ErrMaxThreadsReached = errors.New("max amount of overall threads reached")
maxIdleTime = defaultMaxIdleTime
scaleChan chan *frankenPHPContext scaleChan chan *frankenPHPContext
autoScaledThreads = []*phpThread{} autoScaledThreads = []*phpThread{}
scalingMu = new(sync.RWMutex) scalingMu = new(sync.RWMutex)
@@ -221,7 +222,7 @@ func deactivateThreads() {
} }
// convert threads to inactive if they have been idle for too long // 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) convertToInactiveThread(thread)
stoppedThreadCount++ stoppedThreadCount++
autoScaledThreads = append(autoScaledThreads[:i], autoScaledThreads[i+1:]...) autoScaledThreads = append(autoScaledThreads[:i], autoScaledThreads[i+1:]...)

View File

@@ -57,6 +57,32 @@ func TestScaleAWorkerThreadUpAndDown(t *testing.T) {
assert.IsType(t, &inactiveThread{}, autoScaledThread.handler) 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, &regularThread{}, 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 setLongWaitTime(t *testing.T, thread *phpThread) { func setLongWaitTime(t *testing.T, thread *phpThread) {
t.Helper() t.Helper()