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"`
// 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())
}
}
}

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.
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_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.
worker {
file <path> # Sets the path to the worker script.

View File

@@ -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 = defaultMaxIdleTime
globalMu.Unlock()
}

View File

@@ -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 {

View File

@@ -21,13 +21,14 @@ 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
// 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 = defaultMaxIdleTime
scaleChan chan *frankenPHPContext
autoScaledThreads = []*phpThread{}
scalingMu = new(sync.RWMutex)
@@ -221,7 +222,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:]...)

View File

@@ -57,6 +57,32 @@ 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, &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) {
t.Helper()