diff --git a/caddy/app.go b/caddy/app.go index 22d7e04ca..582c09c18 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 0252f5425..77c432b9e 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 3d571b226..94759bb33 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 = defaultMaxIdleTime globalMu.Unlock() } diff --git a/options.go b/options.go index 3b0ea1392..9ba1f916f 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 3078851ac..190c54d0d 100644 --- a/scaling.go +++ b/scaling.go @@ -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:]...) diff --git a/scaling_test.go b/scaling_test.go index 235a4f3a1..61795456d 100644 --- a/scaling_test.go +++ b/scaling_test.go @@ -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, ®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 setLongWaitTime(t *testing.T, thread *phpThread) { t.Helper()