Skip to content

Commit c099d66

Browse files
authored
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
1 parent 5d44741 commit c099d66

6 files changed

Lines changed: 62 additions & 4 deletions

File tree

caddy/app.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ type FrankenPHPApp struct {
5555
PhpIni map[string]string `json:"php_ini,omitempty"`
5656
// The maximum amount of time a request may be stalled waiting for a thread
5757
MaxWaitTime time.Duration `json:"max_wait_time,omitempty"`
58+
// The maximum amount of time an autoscaled thread may be idle before being deactivated
59+
MaxIdleTime time.Duration `json:"max_idle_time,omitempty"`
5860

5961
opts []frankenphp.Option
6062
metrics frankenphp.Metrics
@@ -150,6 +152,7 @@ func (f *FrankenPHPApp) Start() error {
150152
frankenphp.WithMetrics(f.metrics),
151153
frankenphp.WithPhpIni(f.PhpIni),
152154
frankenphp.WithMaxWaitTime(f.MaxWaitTime),
155+
frankenphp.WithMaxIdleTime(f.MaxIdleTime),
153156
)
154157

155158
for _, w := range f.Workers {
@@ -190,6 +193,7 @@ func (f *FrankenPHPApp) Stop() error {
190193
f.Workers = nil
191194
f.NumThreads = 0
192195
f.MaxWaitTime = 0
196+
f.MaxIdleTime = 0
193197

194198
optionsMU.Lock()
195199
options = nil
@@ -242,6 +246,17 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
242246
}
243247

244248
f.MaxWaitTime = v
249+
case "max_idle_time":
250+
if !d.NextArg() {
251+
return d.ArgErr()
252+
}
253+
254+
v, err := time.ParseDuration(d.Val())
255+
if err != nil {
256+
return d.Err("max_idle_time must be a valid duration (example: 30s)")
257+
}
258+
259+
f.MaxIdleTime = v
245260
case "php_ini":
246261
parseIniLine := func(d *caddyfile.Dispenser) error {
247262
key := d.Val()
@@ -298,7 +313,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
298313

299314
f.Workers = append(f.Workers, wc)
300315
default:
301-
return wrongSubDirectiveError("frankenphp", "num_threads, max_threads, php_ini, worker, max_wait_time", d.Val())
316+
return wrongSubDirectiveError("frankenphp", "num_threads, max_threads, php_ini, worker, max_wait_time, max_idle_time", d.Val())
302317
}
303318
}
304319
}

docs/config.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ You can also explicitly configure FrankenPHP using the [global option](https://c
9696
num_threads <num_threads> # Sets the number of PHP threads to start. Default: 2x the number of available CPUs.
9797
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'.
9898
max_wait_time <duration> # Sets the maximum time a request may wait for a free PHP thread before timing out. Default: disabled.
99+
max_idle_time <duration> # Sets the maximum time an autoscaled thread may be idle before being deactivated. Default: 5s.
99100
php_ini <key> <value> # Set a php.ini directive. Can be used several times to set multiple directives.
100101
worker {
101102
file <path> # Sets the path to the worker script.

frankenphp.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,10 @@ func Init(options ...Option) error {
276276

277277
maxWaitTime = opt.maxWaitTime
278278

279+
if opt.maxIdleTime > 0 {
280+
maxIdleTime = opt.maxIdleTime
281+
}
282+
279283
workerThreadCount, err := calculateMaxThreads(opt)
280284
if err != nil {
281285
Shutdown()
@@ -781,5 +785,6 @@ func resetGlobals() {
781785
workersByName = nil
782786
workersByPath = nil
783787
watcherIsEnabled = false
788+
maxIdleTime = defaultMaxIdleTime
784789
globalMu.Unlock()
785790
}

options.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type opt struct {
3030
metrics Metrics
3131
phpIni map[string]string
3232
maxWaitTime time.Duration
33+
maxIdleTime time.Duration
3334
}
3435

3536
type workerOpt struct {
@@ -156,6 +157,15 @@ func WithMaxWaitTime(maxWaitTime time.Duration) Option {
156157
}
157158
}
158159

160+
// WithMaxIdleTime configures the max time an autoscaled thread may be idle before being deactivated.
161+
func WithMaxIdleTime(maxIdleTime time.Duration) Option {
162+
return func(o *opt) error {
163+
o.maxIdleTime = maxIdleTime
164+
165+
return nil
166+
}
167+
}
168+
159169
// WithWorkerEnv sets environment variables for the worker
160170
func WithWorkerEnv(env map[string]string) WorkerOption {
161171
return func(w *workerOpt) error {

scaling.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@ const (
2121
downScaleCheckTime = 5 * time.Second
2222
// max amount of threads stopped in one iteration of downScaleCheckTime
2323
maxTerminationCount = 10
24-
// autoscaled threads waiting for longer than this time are downscaled
25-
maxThreadIdleTime = 5 * time.Second
24+
// default time an autoscaled thread may be idle before being deactivated
25+
defaultMaxIdleTime = 5 * time.Second
2626
)
2727

2828
var (
2929
ErrMaxThreadsReached = errors.New("max amount of overall threads reached")
3030

31+
maxIdleTime = defaultMaxIdleTime
3132
scaleChan chan *frankenPHPContext
3233
autoScaledThreads = []*phpThread{}
3334
scalingMu = new(sync.RWMutex)
@@ -221,7 +222,7 @@ func deactivateThreads() {
221222
}
222223

223224
// convert threads to inactive if they have been idle for too long
224-
if thread.state.Is(state.Ready) && waitTime > maxThreadIdleTime.Milliseconds() {
225+
if thread.state.Is(state.Ready) && waitTime > maxIdleTime.Milliseconds() {
225226
convertToInactiveThread(thread)
226227
stoppedThreadCount++
227228
autoScaledThreads = append(autoScaledThreads[:i], autoScaledThreads[i+1:]...)

scaling_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,32 @@ func TestScaleAWorkerThreadUpAndDown(t *testing.T) {
5757
assert.IsType(t, &inactiveThread{}, autoScaledThread.handler)
5858
}
5959

60+
func TestMaxIdleTimePreventsEarlyDeactivation(t *testing.T) {
61+
t.Cleanup(Shutdown)
62+
63+
assert.NoError(t, Init(
64+
WithNumThreads(1),
65+
WithMaxThreads(2),
66+
WithMaxIdleTime(time.Hour),
67+
))
68+
69+
autoScaledThread := phpThreads[1]
70+
71+
// scale up
72+
scaleRegularThread()
73+
assert.Equal(t, state.Ready, autoScaledThread.state.Get())
74+
75+
// set wait time to 30 minutes (less than 1 hour max idle time)
76+
autoScaledThread.state.SetWaitTime(time.Now().Add(-30 * time.Minute))
77+
deactivateThreads()
78+
assert.IsType(t, &regularThread{}, autoScaledThread.handler, "thread should still be active after 30min with 1h max idle time")
79+
80+
// set wait time to over 1 hour (exceeds max idle time)
81+
autoScaledThread.state.SetWaitTime(time.Now().Add(-time.Hour - time.Minute))
82+
deactivateThreads()
83+
assert.IsType(t, &inactiveThread{}, autoScaledThread.handler, "thread should be deactivated after exceeding max idle time")
84+
}
85+
6086
func setLongWaitTime(t *testing.T, thread *phpThread) {
6187
t.Helper()
6288

0 commit comments

Comments
 (0)