Skip to content

Commit 072151d

Browse files
feat: Adds automatic thread scaling at runtime and php_ini configuration in Caddyfile (#1266)
Adds option to scale threads at runtime Adds php_ini configuration in Caddyfile
1 parent 965fa65 commit 072151d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+1772
-208
lines changed

.github/actions/watcher/action.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ runs:
1919
name: Compile e-dant/watcher
2020
run: |
2121
mkdir watcher
22-
gh release download --repo e-dant/watcher -A tar.gz -O - | tar -xz -C watcher --strip-components 1
22+
gh release download 0.13.2 --repo e-dant/watcher -A tar.gz -O - | tar -xz -C watcher --strip-components 1
2323
cd watcher
2424
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
2525
cmake --build build

caddy/admin.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package caddy
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"github.com/caddyserver/caddy/v2"
7+
"github.com/dunglas/frankenphp"
8+
"net/http"
9+
)
10+
11+
type FrankenPHPAdmin struct{}
12+
13+
// if the id starts with "admin.api" the module will register AdminRoutes via module.Routes()
14+
func (FrankenPHPAdmin) CaddyModule() caddy.ModuleInfo {
15+
return caddy.ModuleInfo{
16+
ID: "admin.api.frankenphp",
17+
New: func() caddy.Module { return new(FrankenPHPAdmin) },
18+
}
19+
}
20+
21+
// EXPERIMENTAL: These routes are not yet stable and may change in the future.
22+
func (admin FrankenPHPAdmin) Routes() []caddy.AdminRoute {
23+
return []caddy.AdminRoute{
24+
{
25+
Pattern: "/frankenphp/workers/restart",
26+
Handler: caddy.AdminHandlerFunc(admin.restartWorkers),
27+
},
28+
{
29+
Pattern: "/frankenphp/threads",
30+
Handler: caddy.AdminHandlerFunc(admin.threads),
31+
},
32+
}
33+
}
34+
35+
func (admin *FrankenPHPAdmin) restartWorkers(w http.ResponseWriter, r *http.Request) error {
36+
if r.Method != http.MethodPost {
37+
return admin.error(http.StatusMethodNotAllowed, fmt.Errorf("method not allowed"))
38+
}
39+
40+
frankenphp.RestartWorkers()
41+
caddy.Log().Info("workers restarted from admin api")
42+
admin.success(w, "workers restarted successfully\n")
43+
44+
return nil
45+
}
46+
47+
func (admin *FrankenPHPAdmin) threads(w http.ResponseWriter, r *http.Request) error {
48+
debugState := frankenphp.DebugState()
49+
prettyJson, err := json.MarshalIndent(debugState, "", " ")
50+
if err != nil {
51+
return admin.error(http.StatusInternalServerError, err)
52+
}
53+
54+
return admin.success(w, string(prettyJson))
55+
}
56+
57+
func (admin *FrankenPHPAdmin) success(w http.ResponseWriter, message string) error {
58+
w.WriteHeader(http.StatusOK)
59+
_, err := w.Write([]byte(message))
60+
return err
61+
}
62+
63+
func (admin *FrankenPHPAdmin) error(statusCode int, err error) error {
64+
return caddy.APIError{HTTPStatus: statusCode, Err: err}
65+
}

caddy/admin_test.go

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
package caddy_test
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
"net/http"
7+
"sync"
8+
"testing"
9+
10+
"github.com/caddyserver/caddy/v2/caddytest"
11+
"github.com/dunglas/frankenphp"
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
func TestRestartWorkerViaAdminApi(t *testing.T) {
16+
tester := caddytest.NewTester(t)
17+
tester.InitServer(`
18+
{
19+
skip_install_trust
20+
admin localhost:2999
21+
http_port `+testPort+`
22+
23+
frankenphp {
24+
worker ../testdata/worker-with-counter.php 1
25+
}
26+
}
27+
28+
localhost:`+testPort+` {
29+
route {
30+
root ../testdata
31+
rewrite worker-with-counter.php
32+
php
33+
}
34+
}
35+
`, "caddyfile")
36+
37+
tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1")
38+
tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:2")
39+
40+
assertAdminResponse(t, tester, "POST", "workers/restart", http.StatusOK, "workers restarted successfully\n")
41+
42+
tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1")
43+
}
44+
45+
func TestShowTheCorrectThreadDebugStatus(t *testing.T) {
46+
tester := caddytest.NewTester(t)
47+
tester.InitServer(`
48+
{
49+
skip_install_trust
50+
admin localhost:2999
51+
http_port `+testPort+`
52+
53+
frankenphp {
54+
num_threads 3
55+
max_threads 6
56+
worker ../testdata/worker-with-counter.php 1
57+
worker ../testdata/index.php 1
58+
}
59+
}
60+
61+
localhost:`+testPort+` {
62+
route {
63+
root ../testdata
64+
rewrite worker-with-counter.php
65+
php
66+
}
67+
}
68+
`, "caddyfile")
69+
70+
debugState := getDebugState(t, tester)
71+
72+
// assert that the correct threads are present in the thread info
73+
assert.Equal(t, debugState.ThreadDebugStates[0].State, "ready")
74+
assert.Contains(t, debugState.ThreadDebugStates[1].Name, "worker-with-counter.php")
75+
assert.Contains(t, debugState.ThreadDebugStates[2].Name, "index.php")
76+
assert.Equal(t, debugState.ReservedThreadCount, 3)
77+
assert.Len(t, debugState.ThreadDebugStates, 3)
78+
}
79+
80+
func TestAutoScaleWorkerThreads(t *testing.T) {
81+
wg := sync.WaitGroup{}
82+
maxTries := 10
83+
requestsPerTry := 200
84+
tester := caddytest.NewTester(t)
85+
tester.InitServer(`
86+
{
87+
skip_install_trust
88+
admin localhost:2999
89+
http_port `+testPort+`
90+
91+
frankenphp {
92+
max_threads 10
93+
num_threads 2
94+
worker ../testdata/sleep.php 1
95+
}
96+
}
97+
98+
localhost:`+testPort+` {
99+
route {
100+
root ../testdata
101+
rewrite sleep.php
102+
php
103+
}
104+
}
105+
`, "caddyfile")
106+
107+
// spam an endpoint that simulates IO
108+
endpoint := "http://localhost:" + testPort + "/?sleep=2&work=1000"
109+
amountOfThreads := len(getDebugState(t, tester).ThreadDebugStates)
110+
111+
// try to spawn the additional threads by spamming the server
112+
for tries := 0; tries < maxTries; tries++ {
113+
wg.Add(requestsPerTry)
114+
for i := 0; i < requestsPerTry; i++ {
115+
go func() {
116+
tester.AssertGetResponse(endpoint, http.StatusOK, "slept for 2 ms and worked for 1000 iterations")
117+
wg.Done()
118+
}()
119+
}
120+
wg.Wait()
121+
122+
amountOfThreads = len(getDebugState(t, tester).ThreadDebugStates)
123+
if amountOfThreads > 2 {
124+
break
125+
}
126+
}
127+
128+
// assert that there are now more threads than before
129+
assert.NotEqual(t, amountOfThreads, 2)
130+
}
131+
132+
// Note this test requires at least 2x40MB available memory for the process
133+
func TestAutoScaleRegularThreadsOnAutomaticThreadLimit(t *testing.T) {
134+
wg := sync.WaitGroup{}
135+
maxTries := 10
136+
requestsPerTry := 200
137+
tester := caddytest.NewTester(t)
138+
tester.InitServer(`
139+
{
140+
skip_install_trust
141+
admin localhost:2999
142+
http_port `+testPort+`
143+
144+
frankenphp {
145+
max_threads auto
146+
num_threads 1
147+
php_ini memory_limit 40M # a reasonable limit for the test
148+
}
149+
}
150+
151+
localhost:`+testPort+` {
152+
route {
153+
root ../testdata
154+
php
155+
}
156+
}
157+
`, "caddyfile")
158+
159+
// spam an endpoint that simulates IO
160+
endpoint := "http://localhost:" + testPort + "/sleep.php?sleep=2&work=1000"
161+
amountOfThreads := len(getDebugState(t, tester).ThreadDebugStates)
162+
163+
// try to spawn the additional threads by spamming the server
164+
for tries := 0; tries < maxTries; tries++ {
165+
wg.Add(requestsPerTry)
166+
for i := 0; i < requestsPerTry; i++ {
167+
go func() {
168+
tester.AssertGetResponse(endpoint, http.StatusOK, "slept for 2 ms and worked for 1000 iterations")
169+
wg.Done()
170+
}()
171+
}
172+
wg.Wait()
173+
174+
amountOfThreads = len(getDebugState(t, tester).ThreadDebugStates)
175+
if amountOfThreads > 1 {
176+
break
177+
}
178+
}
179+
180+
// assert that there are now more threads present
181+
assert.NotEqual(t, amountOfThreads, 1)
182+
}
183+
184+
func assertAdminResponse(t *testing.T, tester *caddytest.Tester, method string, path string, expectedStatus int, expectedBody string) {
185+
adminUrl := "http://localhost:2999/frankenphp/"
186+
r, err := http.NewRequest(method, adminUrl+path, nil)
187+
assert.NoError(t, err)
188+
if expectedBody == "" {
189+
_ = tester.AssertResponseCode(r, expectedStatus)
190+
return
191+
}
192+
_, _ = tester.AssertResponse(r, expectedStatus, expectedBody)
193+
}
194+
195+
func getAdminResponseBody(t *testing.T, tester *caddytest.Tester, method string, path string) string {
196+
adminUrl := "http://localhost:2999/frankenphp/"
197+
r, err := http.NewRequest(method, adminUrl+path, nil)
198+
assert.NoError(t, err)
199+
resp := tester.AssertResponseCode(r, http.StatusOK)
200+
defer resp.Body.Close()
201+
bytes, err := io.ReadAll(resp.Body)
202+
assert.NoError(t, err)
203+
204+
return string(bytes)
205+
}
206+
207+
func getDebugState(t *testing.T, tester *caddytest.Tester) frankenphp.FrankenPHPDebugState {
208+
threadStates := getAdminResponseBody(t, tester, "GET", "threads")
209+
210+
var debugStates frankenphp.FrankenPHPDebugState
211+
err := json.Unmarshal([]byte(threadStates), &debugStates)
212+
assert.NoError(t, err)
213+
214+
return debugStates
215+
}

0 commit comments

Comments
 (0)