From fe1158fb6747a2804b34278e0a48ad9754243e06 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 1 Nov 2024 23:10:31 +0100 Subject: [PATCH 001/190] Decouple workers. --- frankenphp.c | 84 ++++++++++++++++++++++++++++----------------- frankenphp.go | 36 ++++++++++++++++++-- frankenphp.h | 4 ++- php_thread.go | 15 +++++++- worker.go | 94 +++++++++++++++++++++++++++++++++++++++++++-------- 5 files changed, 183 insertions(+), 50 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 54c149763..ce71f7bec 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -838,10 +838,10 @@ static void set_thread_name(char *thread_name) { #endif } -static void *php_thread(void *arg) { - char thread_name[16] = {0}; - snprintf(thread_name, 16, "php-%" PRIxPTR, (uintptr_t)arg); +static void init_php_thread(void *arg) { thread_index = (uintptr_t)arg; + char thread_name[16] = {0}; + snprintf(thread_name, 16, "php-%" PRIxPTR, thread_index); set_thread_name(thread_name); #ifdef ZTS @@ -853,14 +853,41 @@ static void *php_thread(void *arg) { #endif local_ctx = malloc(sizeof(frankenphp_server_context)); +} +static void shutdown_php_thread(void) { + //free(local_ctx); + //local_ctx = NULL; +#ifdef ZTS + ts_free_thread(); +#endif +} +static void *php_thread(void *arg) { + init_php_thread(arg); + + // handle requests until the channel is closed while (go_handle_request(thread_index)) { } -#ifdef ZTS - ts_free_thread(); -#endif + shutdown_php_thread(); + return NULL; +} + +static void *php_worker_thread(void *arg) { + init_php_thread(arg); + + // run the loop that executes the worker script + while (true) { + char *script_name = go_before_worker_script(thread_index); + if (script_name == NULL) { + break; + } + frankenphp_execute_script(script_name); + go_after_worker_script(thread_index); + } + shutdown_php_thread(); + go_shutdown_woker_thread(thread_index); return NULL; } @@ -912,28 +939,7 @@ static void *php_main(void *arg) { frankenphp_sapi_module.startup(&frankenphp_sapi_module); - pthread_t *threads = malloc(num_threads * sizeof(pthread_t)); - if (threads == NULL) { - perror("malloc failed"); - exit(EXIT_FAILURE); - } - - for (uintptr_t i = 0; i < num_threads; i++) { - if (pthread_create(&(*(threads + i)), NULL, &php_thread, (void *)i) != 0) { - perror("failed to create PHP thread"); - free(threads); - exit(EXIT_FAILURE); - } - } - - for (int i = 0; i < num_threads; i++) { - if (pthread_join((*(threads + i)), NULL) != 0) { - perror("failed to join PHP thread"); - free(threads); - exit(EXIT_FAILURE); - } - } - free(threads); + go_listen_for_shutdown(); /* channel closed, shutdown gracefully */ frankenphp_sapi_module.shutdown(&frankenphp_sapi_module); @@ -955,19 +961,35 @@ static void *php_main(void *arg) { return NULL; } -int frankenphp_init(int num_threads) { +int frankenphp_new_main_thread(int num_threads) { pthread_t thread; if (pthread_create(&thread, NULL, &php_main, (void *)(intptr_t)num_threads) != 0) { go_shutdown(); - return -1; } - return pthread_detach(thread); } +int frankenphp_new_worker_thread(uintptr_t thread_index){ + pthread_t thread; + if (pthread_create(&thread, NULL, &php_worker_thread, (void *)thread_index) != 0){ + return 1; + } + pthread_detach(thread); + return 0; +} + +int frankenphp_new_php_thread(uintptr_t thread_index){ + pthread_t thread; + if (pthread_create(&thread, NULL, &php_thread, (void *)thread_index) != 0){ + return 1; + } + pthread_detach(thread); + return 0; +} + int frankenphp_request_startup() { if (php_request_startup() == SUCCESS) { return SUCCESS; diff --git a/frankenphp.go b/frankenphp.go index 2882a1c17..5bbbaeae0 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -65,6 +65,7 @@ var ( requestChan chan *http.Request done chan struct{} + mainThreadWG sync.WaitGroup shutdownWG sync.WaitGroup loggerMu sync.RWMutex @@ -336,8 +337,13 @@ func Init(options ...Option) error { requestChan = make(chan *http.Request) initPHPThreads(opt.numThreads) - if C.frankenphp_init(C.int(opt.numThreads)) != 0 { - return MainThreadCreationError + startMainThread(opt.numThreads) + + // TODO: calc num threads + for i := 0; i < 1; i++ { + if err := startNewThread(); err != nil { + return err + } } if err := initWorkers(opt.workers); err != nil { @@ -386,6 +392,24 @@ func drainThreads() { phpThreads = nil } +func startMainThread(numThreads int) error { + mainThreadWG.Add(1) + if C.frankenphp_new_main_thread(C.int(numThreads)) != 0 { + return MainThreadCreationError + } + mainThreadWG.Wait() + return nil +} + +func startNewThread() error { + thread := getInactiveThread() + thread.isActive = true + if C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) != 0 { + return fmt.Errorf("error creating thread %d", thread.threadIndex) + } + return nil +} + func getLogger() *zap.Logger { loggerMu.RLock() defer loggerMu.RUnlock() @@ -505,6 +529,14 @@ func ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) error return nil } +//export go_listen_for_shutdown +func go_listen_for_shutdown(){ + mainThreadWG.Done() + select{ + case <-done: + } +} + //export go_putenv func go_putenv(str *C.char, length C.int) C.bool { // Create a byte slice from C string with a specified length diff --git a/frankenphp.h b/frankenphp.h index a0c54936d..7470ba00e 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -40,7 +40,9 @@ typedef struct frankenphp_config { } frankenphp_config; frankenphp_config frankenphp_get_config(); -int frankenphp_init(int num_threads); +int frankenphp_new_main_thread(int num_threads); +int frankenphp_new_php_thread(uintptr_t thread_index); +int frankenphp_new_worker_thread(uintptr_t thread_index); int frankenphp_update_server_context( bool create, bool has_main_request, bool has_active_request, diff --git a/php_thread.go b/php_thread.go index 5b9c29970..608040b93 100644 --- a/php_thread.go +++ b/php_thread.go @@ -15,15 +15,28 @@ type phpThread struct { mainRequest *http.Request workerRequest *http.Request worker *worker + isActive bool + isReady bool + threadIndex int } func initPHPThreads(numThreads int) { phpThreads = make([]*phpThread, 0, numThreads) for i := 0; i < numThreads; i++ { - phpThreads = append(phpThreads, &phpThread{}) + phpThreads = append(phpThreads, &phpThread{threadIndex: i}) } } +func getInactiveThread() *phpThread { + for _, thread := range phpThreads { + if !thread.isActive { + return thread + } + } + + return nil +} + func (thread phpThread) getActiveRequest() *http.Request { if thread.workerRequest != nil { return thread.workerRequest diff --git a/worker.go b/worker.go index 38e4b60a4..0dd71d1fb 100644 --- a/worker.go +++ b/worker.go @@ -47,9 +47,10 @@ func initWorkers(opt []workerOpt) error { if err != nil { return err } - workersReadyWG.Add(worker.num) for i := 0; i < worker.num; i++ { - go worker.startNewWorkerThread() + if err := worker.startNewThread(nil); err != nil { + return err + } } } @@ -82,6 +83,19 @@ func newWorker(o workerOpt) (*worker, error) { return w, nil } +func (worker *worker) startNewThread(r *http.Request) error { + workersReadyWG.Add(1) + workerShutdownWG.Add(1) + thread := getInactiveThread() + thread.worker = worker + thread.isActive = true + if C.frankenphp_new_worker_thread(C.uintptr_t(thread.threadIndex)) != 0 { + return fmt.Errorf("failed to create worker thread") + } + + return nil +} + func (worker *worker) startNewWorkerThread() { workerShutdownWG.Add(1) defer workerShutdownWG.Done() @@ -232,26 +246,76 @@ func restartWorkers(workerOpts []workerOpt) { } func assignThreadToWorker(thread *phpThread) { - fc := thread.mainRequest.Context().Value(contextKey).(*FrankenPHPContext) - metrics.ReadyWorker(fc.scriptFilename) - worker, ok := workers[fc.scriptFilename] - if !ok { - panic("worker not found for script: " + fc.scriptFilename) - } - thread.worker = worker - if !workersAreReady.Load() { - workersReadyWG.Done() - } + metrics.ReadyWorker(thread.worker.fileName) + thread.isReady = true + workersReadyWG.Done() // TODO: we can also store all threads assigned to the worker if needed } +//export go_before_worker_script +func go_before_worker_script(threadIndex C.uintptr_t) *C.char { + thread := phpThreads[threadIndex] + worker := thread.worker + + // if we are done, exit the loop that restarts the worker script + if workersAreDone.Load() { + return nil + } + metrics.StartWorker(worker.fileName) + + // Create main dummy request + r, err := http.NewRequest(http.MethodGet, filepath.Base(worker.fileName), nil) + if err != nil { + panic(err) + } + + r, err = NewRequestWithContext( + r, + WithRequestDocumentRoot(filepath.Dir(worker.fileName), false), + WithRequestPreparedEnv(worker.env), + ) + if err != nil { + panic(err) + } + thread.mainRequest = r + if c := logger.Check(zapcore.DebugLevel, "starting"); c != nil { + c.Write(zap.String("worker", worker.fileName), zap.Int("num", worker.num)) + } + + if err := updateServerContext(r, true, false); err != nil { + panic(err) + } + return C.CString(worker.fileName) +} + +//export go_after_worker_script +func go_after_worker_script(threadIndex C.uintptr_t) { + thread := phpThreads[threadIndex] + fc := thread.mainRequest.Context().Value(contextKey).(*FrankenPHPContext) + + // on exit status 0 we just run the worker script again + if fc.exitStatus == 0 { + // TODO: make the max restart configurable + if c := logger.Check(zapcore.InfoLevel, "restarting"); c != nil { + c.Write(zap.String("worker", thread.worker.fileName)) + } + metrics.StopWorker(thread.worker.fileName, StopReasonRestart) + } +} + +//export go_shutdown_woker_thread +func go_shutdown_woker_thread(threadIndex C.uintptr_t) { + workerShutdownWG.Done() +} + //export go_frankenphp_worker_handle_request_start func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { thread := phpThreads[threadIndex] - // we assign a worker to the thread if it doesn't have one already - if thread.worker == nil { - assignThreadToWorker(thread) + if !thread.isReady { + thread.isReady = true + workersReadyWG.Done() + metrics.ReadyWorker(thread.worker.fileName) } if c := logger.Check(zapcore.DebugLevel, "waiting for request"); c != nil { From ad34140027c311b6e11d4e9755a691be98c7ace6 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 2 Nov 2024 00:43:59 +0100 Subject: [PATCH 002/190] Moves code to separate file. --- frankenphp.c | 18 ++++---- frankenphp.go | 65 +++++++-------------------- php_thread.go | 19 -------- php_thread_test.go | 16 +------ php_threads.go | 108 +++++++++++++++++++++++++++++++++++++++++++++ worker.go | 36 ++++++--------- 6 files changed, 148 insertions(+), 114 deletions(-) create mode 100644 php_threads.go diff --git a/frankenphp.c b/frankenphp.c index ce71f7bec..93c283e3d 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -855,8 +855,8 @@ static void init_php_thread(void *arg) { local_ctx = malloc(sizeof(frankenphp_server_context)); } static void shutdown_php_thread(void) { - //free(local_ctx); - //local_ctx = NULL; + free(local_ctx); + local_ctx = NULL; #ifdef ZTS ts_free_thread(); #endif @@ -870,6 +870,7 @@ static void *php_thread(void *arg) { } shutdown_php_thread(); + go_shutdown_php_thread(thread_index); return NULL; } @@ -882,12 +883,12 @@ static void *php_worker_thread(void *arg) { if (script_name == NULL) { break; } - frankenphp_execute_script(script_name); - go_after_worker_script(thread_index); + int exit_status = frankenphp_execute_script(script_name); + go_after_worker_script(thread_index, exit_status); } shutdown_php_thread(); - go_shutdown_woker_thread(thread_index); + go_shutdown_worker_thread(thread_index); return NULL; } @@ -939,7 +940,7 @@ static void *php_main(void *arg) { frankenphp_sapi_module.startup(&frankenphp_sapi_module); - go_listen_for_shutdown(); + go_main_thread_is_ready(); /* channel closed, shutdown gracefully */ frankenphp_sapi_module.shutdown(&frankenphp_sapi_module); @@ -955,9 +956,7 @@ static void *php_main(void *arg) { frankenphp_sapi_module.ini_entries = NULL; } #endif - - go_shutdown(); - + go_shutdown_main_thread(); return NULL; } @@ -966,7 +965,6 @@ int frankenphp_new_main_thread(int num_threads) { if (pthread_create(&thread, NULL, &php_main, (void *)(intptr_t)num_threads) != 0) { - go_shutdown(); return -1; } return pthread_detach(thread); diff --git a/frankenphp.go b/frankenphp.go index 5bbbaeae0..f53870cbb 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -65,8 +65,6 @@ var ( requestChan chan *http.Request done chan struct{} - mainThreadWG sync.WaitGroup - shutdownWG sync.WaitGroup loggerMu sync.RWMutex logger *zap.Logger @@ -332,16 +330,19 @@ func Init(options ...Option) error { logger.Warn(`ZTS is not enabled, only 1 thread will be available, recompile PHP using the "--enable-zts" configuration option or performance will be degraded`) } - shutdownWG.Add(1) done = make(chan struct{}) requestChan = make(chan *http.Request) - initPHPThreads(opt.numThreads) + if err:= initPHPThreads(opt.numThreads); err != nil { + return err + } - startMainThread(opt.numThreads) + totalWorkers := 0 + for _, w := range opt.workers { + totalWorkers += w.num + } - // TODO: calc num threads - for i := 0; i < 1; i++ { - if err := startNewThread(); err != nil { + for i := 0; i < opt.numThreads - totalWorkers; i++ { + if err := startNewPHPThread(); err != nil { return err } } @@ -349,6 +350,7 @@ func Init(options ...Option) error { if err := initWorkers(opt.workers); err != nil { return err } + readyWG.Wait() if err := restartWorkersOnFileChanges(opt.workers); err != nil { return err @@ -369,7 +371,7 @@ func Init(options ...Option) error { // Shutdown stops the workers and the PHP runtime. func Shutdown() { drainWorkers() - drainThreads() + drainPHPThreads() metrics.Shutdown() requestChan = nil @@ -381,35 +383,6 @@ func Shutdown() { logger.Debug("FrankenPHP shut down") } -//export go_shutdown -func go_shutdown() { - shutdownWG.Done() -} - -func drainThreads() { - close(done) - shutdownWG.Wait() - phpThreads = nil -} - -func startMainThread(numThreads int) error { - mainThreadWG.Add(1) - if C.frankenphp_new_main_thread(C.int(numThreads)) != 0 { - return MainThreadCreationError - } - mainThreadWG.Wait() - return nil -} - -func startNewThread() error { - thread := getInactiveThread() - thread.isActive = true - if C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) != 0 { - return fmt.Errorf("error creating thread %d", thread.threadIndex) - } - return nil -} - func getLogger() *zap.Logger { loggerMu.RLock() defer loggerMu.RUnlock() @@ -486,9 +459,6 @@ func updateServerContext(request *http.Request, create bool, isWorkerRequest boo // ServeHTTP executes a PHP script according to the given context. func ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) error { - shutdownWG.Add(1) - defer shutdownWG.Done() - fc, ok := FromContext(request.Context()) if !ok { return InvalidRequestError @@ -529,14 +499,6 @@ func ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) error return nil } -//export go_listen_for_shutdown -func go_listen_for_shutdown(){ - mainThreadWG.Done() - select{ - case <-done: - } -} - //export go_putenv func go_putenv(str *C.char, length C.int) C.bool { // Create a byte slice from C string with a specified length @@ -609,6 +571,11 @@ func go_getenv(threadIndex C.uintptr_t, name *C.go_string) (C.bool, *C.go_string //export go_handle_request func go_handle_request(threadIndex C.uintptr_t) bool { + thread := phpThreads[threadIndex] + if !thread.isReady { + thread.isReady = true + readyWG.Done() + } select { case <-done: return false diff --git a/php_thread.go b/php_thread.go index 608040b93..5611a1d04 100644 --- a/php_thread.go +++ b/php_thread.go @@ -7,8 +7,6 @@ import ( "runtime" ) -var phpThreads []*phpThread - type phpThread struct { runtime.Pinner @@ -20,23 +18,6 @@ type phpThread struct { threadIndex int } -func initPHPThreads(numThreads int) { - phpThreads = make([]*phpThread, 0, numThreads) - for i := 0; i < numThreads; i++ { - phpThreads = append(phpThreads, &phpThread{threadIndex: i}) - } -} - -func getInactiveThread() *phpThread { - for _, thread := range phpThreads { - if !thread.isActive { - return thread - } - } - - return nil -} - func (thread phpThread) getActiveRequest() *http.Request { if thread.workerRequest != nil { return thread.workerRequest diff --git a/php_thread_test.go b/php_thread_test.go index 63afe4d89..eba873d5b 100644 --- a/php_thread_test.go +++ b/php_thread_test.go @@ -7,20 +7,9 @@ import ( "github.com/stretchr/testify/assert" ) -func TestInitializeTwoPhpThreadsWithoutRequests(t *testing.T) { - initPHPThreads(2) - - assert.Len(t, phpThreads, 2) - assert.NotNil(t, phpThreads[0]) - assert.NotNil(t, phpThreads[1]) - assert.Nil(t, phpThreads[0].mainRequest) - assert.Nil(t, phpThreads[0].workerRequest) -} - func TestMainRequestIsActiveRequest(t *testing.T) { mainRequest := &http.Request{} - initPHPThreads(1) - thread := phpThreads[0] + thread := phpThread{} thread.mainRequest = mainRequest @@ -30,8 +19,7 @@ func TestMainRequestIsActiveRequest(t *testing.T) { func TestWorkerRequestIsActiveRequest(t *testing.T) { mainRequest := &http.Request{} workerRequest := &http.Request{} - initPHPThreads(1) - thread := phpThreads[0] + thread := phpThread{} thread.mainRequest = mainRequest thread.workerRequest = workerRequest diff --git a/php_threads.go b/php_threads.go new file mode 100644 index 000000000..417bfa75e --- /dev/null +++ b/php_threads.go @@ -0,0 +1,108 @@ +package frankenphp + +// #include +// #include "frankenphp.h" +import "C" +import ( + "fmt" + "sync" +) + +var ( + phpThreads []*phpThread + mainThreadWG sync.WaitGroup + terminationWG sync.WaitGroup + mainThreadShutdownWG sync.WaitGroup + readyWG sync.WaitGroup + shutdownWG sync.WaitGroup +) + +// reserve a fixed number of PHP threads on the go side +func initPHPThreads(numThreads int) error { + phpThreads = make([]*phpThread, numThreads) + for i := 0; i < numThreads; i++ { + phpThreads[i] = &phpThread{threadIndex: i} + } + return startMainThread(numThreads) +} + +func drainPHPThreads() { + close(done) + shutdownWG.Wait() + phpThreads = nil + mainThreadShutdownWG.Done() + terminationWG.Wait() +} + +func startMainThread(numThreads int) error { + mainThreadWG.Add(1) + mainThreadShutdownWG.Add(1) + terminationWG.Add(1) + if C.frankenphp_new_main_thread(C.int(numThreads)) != 0 { + return MainThreadCreationError + } + mainThreadWG.Wait() + return nil +} + +func startNewPHPThread() error { + readyWG.Add(1) + shutdownWG.Add(1) + thread := getInactiveThread() + thread.isActive = true + if C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) != 0 { + return fmt.Errorf("error creating thread %d", thread.threadIndex) + } + return nil +} + +func startNewWorkerThread(worker *worker) error { + workersReadyWG.Add(1) + workerShutdownWG.Add(1) + thread := getInactiveThread() + thread.worker = worker + thread.isActive = true + if C.frankenphp_new_worker_thread(C.uintptr_t(thread.threadIndex)) != 0 { + return fmt.Errorf("failed to create worker thread") + } + + return nil +} + +func getInactiveThread() *phpThread { + for _, thread := range phpThreads { + if !thread.isActive { + return thread + } + } + + return nil +} + +//export go_main_thread_is_ready +func go_main_thread_is_ready(){ + mainThreadWG.Done() + mainThreadShutdownWG.Wait() +} + +//export go_shutdown_main_thread +func go_shutdown_main_thread(){ + terminationWG.Done() +} + +//export go_shutdown_php_thread +func go_shutdown_php_thread(threadIndex C.uintptr_t) { + thread := phpThreads[threadIndex] + thread.Unpin() + thread.isActive = false + shutdownWG.Done() +} + +//export go_shutdown_worker_thread +func go_shutdown_worker_thread(threadIndex C.uintptr_t) { + thread := phpThreads[threadIndex] + thread.Unpin() + thread.isActive = false + thread.worker = nil + workerShutdownWG.Done() +} \ No newline at end of file diff --git a/worker.go b/worker.go index 0dd71d1fb..eaa8e2a6d 100644 --- a/worker.go +++ b/worker.go @@ -48,7 +48,7 @@ func initWorkers(opt []workerOpt) error { return err } for i := 0; i < worker.num; i++ { - if err := worker.startNewThread(nil); err != nil { + if err := startNewWorkerThread(worker); err != nil { return err } } @@ -83,20 +83,7 @@ func newWorker(o workerOpt) (*worker, error) { return w, nil } -func (worker *worker) startNewThread(r *http.Request) error { - workersReadyWG.Add(1) - workerShutdownWG.Add(1) - thread := getInactiveThread() - thread.worker = worker - thread.isActive = true - if C.frankenphp_new_worker_thread(C.uintptr_t(thread.threadIndex)) != 0 { - return fmt.Errorf("failed to create worker thread") - } - - return nil -} - -func (worker *worker) startNewWorkerThread() { +func (worker *worker) asdasd() { workerShutdownWG.Add(1) defer workerShutdownWG.Done() @@ -289,10 +276,14 @@ func go_before_worker_script(threadIndex C.uintptr_t) *C.char { } //export go_after_worker_script -func go_after_worker_script(threadIndex C.uintptr_t) { +func go_after_worker_script(threadIndex C.uintptr_t, exitStatus C.int) { thread := phpThreads[threadIndex] fc := thread.mainRequest.Context().Value(contextKey).(*FrankenPHPContext) + fc.exitStatus = exitStatus + if fc.exitStatus < 0 { + panic(ScriptExecutionError) + } // on exit status 0 we just run the worker script again if fc.exitStatus == 0 { // TODO: make the max restart configurable @@ -300,12 +291,14 @@ func go_after_worker_script(threadIndex C.uintptr_t) { c.Write(zap.String("worker", thread.worker.fileName)) } metrics.StopWorker(thread.worker.fileName, StopReasonRestart) + return + } else { + time.Sleep(1 * time.Millisecond) + logger.Error("worker script exited with non-zero status") } -} - -//export go_shutdown_woker_thread -func go_shutdown_woker_thread(threadIndex C.uintptr_t) { - workerShutdownWG.Done() + maybeCloseContext(fc) + thread.mainRequest = nil + thread.Unpin() } //export go_frankenphp_worker_handle_request_start @@ -328,7 +321,6 @@ func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { if c := logger.Check(zapcore.DebugLevel, "shutting down"); c != nil { c.Write(zap.String("worker", thread.worker.fileName)) } - thread.worker = nil executePHPFunction("opcache_reset") return C.bool(false) From 89b211d678e328a7de4995406119a781be90e80a Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 2 Nov 2024 12:53:31 +0100 Subject: [PATCH 003/190] Cleans up the exponential backoff. --- exponential_backoff.go | 60 +++++++++++ frankenphp.go | 13 ++- php_thread.go | 21 +++- php_threads.go | 50 ++++----- worker.go | 232 +++++++++++------------------------------ 5 files changed, 170 insertions(+), 206 deletions(-) create mode 100644 exponential_backoff.go diff --git a/exponential_backoff.go b/exponential_backoff.go new file mode 100644 index 000000000..359e2bd4f --- /dev/null +++ b/exponential_backoff.go @@ -0,0 +1,60 @@ +package frankenphp + +import ( + "sync" + "time" +) + +const maxBackoff = 1 * time.Second +const minBackoff = 100 * time.Millisecond +const maxConsecutiveFailures = 6 + +type exponentialBackoff struct { + backoff time.Duration + failureCount int + mu sync.RWMutex + upFunc sync.Once +} + +func newExponentialBackoff() *exponentialBackoff { + return &exponentialBackoff{backoff: minBackoff} +} + +func (e *exponentialBackoff) reset() { + e.mu.Lock() + e.upFunc = sync.Once{} + wait := e.backoff * 2 + e.mu.Unlock() + go func() { + time.Sleep(wait) + e.mu.Lock() + defer e.mu.Unlock() + e.upFunc.Do(func() { + // if we come back to a stable state, reset the failure count + if e.backoff == minBackoff { + e.failureCount = 0 + } + + // earn back the backoff over time + if e.failureCount > 0 { + e.backoff = max(e.backoff/2, minBackoff) + } + }) + }() +} + +func (e *exponentialBackoff) trigger(onMaxFailures func(failureCount int)) { + e.mu.RLock() + e.upFunc.Do(func() { + if e.failureCount >= maxConsecutiveFailures { + onMaxFailures(e.failureCount) + } + e.failureCount += 1 + }) + wait := e.backoff + e.mu.RUnlock() + time.Sleep(wait) + e.mu.Lock() + e.backoff = min(e.backoff*2, maxBackoff) + e.mu.Unlock() +} diff --git a/frankenphp.go b/frankenphp.go index f53870cbb..df8d99af6 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -332,7 +332,7 @@ func Init(options ...Option) error { done = make(chan struct{}) requestChan = make(chan *http.Request) - if err:= initPHPThreads(opt.numThreads); err != nil { + if err := initPHPThreads(opt.numThreads); err != nil { return err } @@ -341,7 +341,7 @@ func Init(options ...Option) error { totalWorkers += w.num } - for i := 0; i < opt.numThreads - totalWorkers; i++ { + for i := 0; i < opt.numThreads-totalWorkers; i++ { if err := startNewPHPThread(); err != nil { return err } @@ -350,7 +350,9 @@ func Init(options ...Option) error { if err := initWorkers(opt.workers); err != nil { return err } - readyWG.Wait() + + // wait for all regular and worker threads to be ready for requests + threadsReadyWG.Wait() if err := restartWorkersOnFileChanges(opt.workers); err != nil { return err @@ -572,10 +574,7 @@ func go_getenv(threadIndex C.uintptr_t, name *C.go_string) (C.bool, *C.go_string //export go_handle_request func go_handle_request(threadIndex C.uintptr_t) bool { thread := phpThreads[threadIndex] - if !thread.isReady { - thread.isReady = true - readyWG.Done() - } + thread.setReadyForRequests() select { case <-done: return false diff --git a/php_thread.go b/php_thread.go index 5611a1d04..811c7a677 100644 --- a/php_thread.go +++ b/php_thread.go @@ -13,9 +13,10 @@ type phpThread struct { mainRequest *http.Request workerRequest *http.Request worker *worker - isActive bool - isReady bool - threadIndex int + isActive bool // whether the thread is currently running + isReady bool // whether the thread is ready to accept requests + threadIndex int // the index of the thread in the phpThreads slice + backoff *exponentialBackoff // backoff for worker failures } func (thread phpThread) getActiveRequest() *http.Request { @@ -25,3 +26,17 @@ func (thread phpThread) getActiveRequest() *http.Request { return thread.mainRequest } + +func (thread *phpThread) setReadyForRequests() { + if thread.isReady { + return + } + + thread.isReady = true + threadsReadyWG.Done() + if thread.worker != nil { + metrics.ReadyWorker(thread.worker.fileName) + } +} + + diff --git a/php_threads.go b/php_threads.go index 417bfa75e..55e19f53f 100644 --- a/php_threads.go +++ b/php_threads.go @@ -9,12 +9,11 @@ import ( ) var ( - phpThreads []*phpThread - mainThreadWG sync.WaitGroup - terminationWG sync.WaitGroup + phpThreads []*phpThread + terminationWG sync.WaitGroup mainThreadShutdownWG sync.WaitGroup - readyWG sync.WaitGroup - shutdownWG sync.WaitGroup + threadsReadyWG sync.WaitGroup + shutdownWG sync.WaitGroup ) // reserve a fixed number of PHP threads on the go side @@ -35,18 +34,18 @@ func drainPHPThreads() { } func startMainThread(numThreads int) error { - mainThreadWG.Add(1) + threadsReadyWG.Add(1) mainThreadShutdownWG.Add(1) terminationWG.Add(1) - if C.frankenphp_new_main_thread(C.int(numThreads)) != 0 { - return MainThreadCreationError - } - mainThreadWG.Wait() - return nil + if C.frankenphp_new_main_thread(C.int(numThreads)) != 0 { + return MainThreadCreationError + } + threadsReadyWG.Wait() + return nil } func startNewPHPThread() error { - readyWG.Add(1) + threadsReadyWG.Add(1) shutdownWG.Add(1) thread := getInactiveThread() thread.isActive = true @@ -57,14 +56,15 @@ func startNewPHPThread() error { } func startNewWorkerThread(worker *worker) error { - workersReadyWG.Add(1) - workerShutdownWG.Add(1) + threadsReadyWG.Add(1) + workerShutdownWG.Add(1) thread := getInactiveThread() - thread.worker = worker - thread.isActive = true - if C.frankenphp_new_worker_thread(C.uintptr_t(thread.threadIndex)) != 0 { - return fmt.Errorf("failed to create worker thread") - } + thread.worker = worker + thread.backoff = newExponentialBackoff() + thread.isActive = true + if C.frankenphp_new_worker_thread(C.uintptr_t(thread.threadIndex)) != 0 { + return fmt.Errorf("failed to create worker thread") + } return nil } @@ -80,13 +80,13 @@ func getInactiveThread() *phpThread { } //export go_main_thread_is_ready -func go_main_thread_is_ready(){ - mainThreadWG.Done() +func go_main_thread_is_ready() { + threadsReadyWG.Done() mainThreadShutdownWG.Wait() } //export go_shutdown_main_thread -func go_shutdown_main_thread(){ +func go_shutdown_main_thread() { terminationWG.Done() } @@ -95,6 +95,7 @@ func go_shutdown_php_thread(threadIndex C.uintptr_t) { thread := phpThreads[threadIndex] thread.Unpin() thread.isActive = false + thread.isReady = false shutdownWG.Done() } @@ -103,6 +104,7 @@ func go_shutdown_worker_thread(threadIndex C.uintptr_t) { thread := phpThreads[threadIndex] thread.Unpin() thread.isActive = false + thread.isReady = false thread.worker = nil - workerShutdownWG.Done() -} \ No newline at end of file + workerShutdownWG.Done() +} diff --git a/worker.go b/worker.go index eaa8e2a6d..96acc82c7 100644 --- a/worker.go +++ b/worker.go @@ -9,7 +9,6 @@ import ( "path/filepath" "sync" "sync/atomic" - "time" "github.com/dunglas/frankenphp/internal/watcher" "go.uber.org/zap" @@ -23,15 +22,9 @@ type worker struct { requestChan chan *http.Request } -const maxWorkerErrorBackoff = 1 * time.Second -const minWorkerErrorBackoff = 100 * time.Millisecond -const maxWorkerConsecutiveFailures = 6 - var ( watcherIsEnabled bool - workersReadyWG sync.WaitGroup workerShutdownWG sync.WaitGroup - workersAreReady atomic.Bool workersAreDone atomic.Bool workersDone chan interface{} workers = make(map[string]*worker) @@ -39,7 +32,6 @@ var ( func initWorkers(opt []workerOpt) error { workersDone = make(chan interface{}) - workersAreReady.Store(false) workersAreDone.Store(false) for _, o := range opt { @@ -54,9 +46,6 @@ func initWorkers(opt []workerOpt) error { } } - workersReadyWG.Wait() - workersAreReady.Store(true) - return nil } @@ -83,113 +72,6 @@ func newWorker(o workerOpt) (*worker, error) { return w, nil } -func (worker *worker) asdasd() { - workerShutdownWG.Add(1) - defer workerShutdownWG.Done() - - backoff := minWorkerErrorBackoff - failureCount := 0 - backingOffLock := sync.RWMutex{} - - for { - - // if the worker can stay up longer than backoff*2, it is probably an application error - upFunc := sync.Once{} - go func() { - backingOffLock.RLock() - wait := backoff * 2 - backingOffLock.RUnlock() - time.Sleep(wait) - upFunc.Do(func() { - backingOffLock.Lock() - defer backingOffLock.Unlock() - // if we come back to a stable state, reset the failure count - if backoff == minWorkerErrorBackoff { - failureCount = 0 - } - - // earn back the backoff over time - if failureCount > 0 { - backoff = max(backoff/2, 100*time.Millisecond) - } - }) - }() - - metrics.StartWorker(worker.fileName) - - // Create main dummy request - r, err := http.NewRequest(http.MethodGet, filepath.Base(worker.fileName), nil) - if err != nil { - panic(err) - } - - r, err = NewRequestWithContext( - r, - WithRequestDocumentRoot(filepath.Dir(worker.fileName), false), - WithRequestPreparedEnv(worker.env), - ) - if err != nil { - panic(err) - } - - if c := logger.Check(zapcore.DebugLevel, "starting"); c != nil { - c.Write(zap.String("worker", worker.fileName), zap.Int("num", worker.num)) - } - - if err := ServeHTTP(nil, r); err != nil { - panic(err) - } - - fc := r.Context().Value(contextKey).(*FrankenPHPContext) - - // if we are done, exit the loop that restarts the worker script - if workersAreDone.Load() { - break - } - - // on exit status 0 we just run the worker script again - if fc.exitStatus == 0 { - // TODO: make the max restart configurable - if c := logger.Check(zapcore.InfoLevel, "restarting"); c != nil { - c.Write(zap.String("worker", worker.fileName)) - } - metrics.StopWorker(worker.fileName, StopReasonRestart) - continue - } - - // on exit status 1 we log the error and apply an exponential backoff when restarting - upFunc.Do(func() { - backingOffLock.Lock() - defer backingOffLock.Unlock() - // if we end up here, the worker has not been up for backoff*2 - // this is probably due to a syntax error or another fatal error - if failureCount >= maxWorkerConsecutiveFailures { - if !watcherIsEnabled { - panic(fmt.Errorf("workers %q: too many consecutive failures", worker.fileName)) - } - logger.Warn("many consecutive worker failures", zap.String("worker", worker.fileName), zap.Int("failures", failureCount)) - } - failureCount += 1 - }) - backingOffLock.RLock() - wait := backoff - backingOffLock.RUnlock() - time.Sleep(wait) - backingOffLock.Lock() - backoff *= 2 - backoff = min(backoff, maxWorkerErrorBackoff) - backingOffLock.Unlock() - metrics.StopWorker(worker.fileName, StopReasonCrash) - } - - metrics.StopWorker(worker.fileName, StopReasonShutdown) - - // TODO: check if the termination is expected - if c := logger.Check(zapcore.DebugLevel, "terminated"); c != nil { - c.Write(zap.String("worker", worker.fileName)) - } -} - func stopWorkers() { workersAreDone.Store(true) close(workersDone) @@ -232,13 +114,6 @@ func restartWorkers(workerOpts []workerOpt) { logger.Info("workers restarted successfully") } -func assignThreadToWorker(thread *phpThread) { - metrics.ReadyWorker(thread.worker.fileName) - thread.isReady = true - workersReadyWG.Done() - // TODO: we can also store all threads assigned to the worker if needed -} - //export go_before_worker_script func go_before_worker_script(threadIndex C.uintptr_t) *C.char { thread := phpThreads[threadIndex] @@ -246,32 +121,37 @@ func go_before_worker_script(threadIndex C.uintptr_t) *C.char { // if we are done, exit the loop that restarts the worker script if workersAreDone.Load() { - return nil - } + return nil + } + + // if we are restarting the worker, reset the exponential failure backoff + thread.backoff.reset() metrics.StartWorker(worker.fileName) - // Create main dummy request - r, err := http.NewRequest(http.MethodGet, filepath.Base(worker.fileName), nil) - if err != nil { - panic(err) - } - - r, err = NewRequestWithContext( - r, - WithRequestDocumentRoot(filepath.Dir(worker.fileName), false), - WithRequestPreparedEnv(worker.env), - ) - if err != nil { - panic(err) - } - thread.mainRequest = r - if c := logger.Check(zapcore.DebugLevel, "starting"); c != nil { - c.Write(zap.String("worker", worker.fileName), zap.Int("num", worker.num)) - } - - if err := updateServerContext(r, true, false); err != nil { - panic(err) - } + // Create a dummy request to set up the worker + r, err := http.NewRequest(http.MethodGet, filepath.Base(worker.fileName), nil) + if err != nil { + panic(err) + } + + r, err = NewRequestWithContext( + r, + WithRequestDocumentRoot(filepath.Dir(worker.fileName), false), + WithRequestPreparedEnv(worker.env), + ) + if err != nil { + panic(err) + } + + if err := updateServerContext(r, true, false); err != nil { + panic(err) + } + + thread.mainRequest = r + if c := logger.Check(zapcore.DebugLevel, "starting"); c != nil { + c.Write(zap.String("worker", worker.fileName), zap.Int("num", worker.num)) + } + return C.CString(worker.fileName) } @@ -282,34 +162,42 @@ func go_after_worker_script(threadIndex C.uintptr_t, exitStatus C.int) { fc.exitStatus = exitStatus if fc.exitStatus < 0 { - panic(ScriptExecutionError) - } - // on exit status 0 we just run the worker script again - if fc.exitStatus == 0 { - // TODO: make the max restart configurable - if c := logger.Check(zapcore.InfoLevel, "restarting"); c != nil { - c.Write(zap.String("worker", thread.worker.fileName)) - } - metrics.StopWorker(thread.worker.fileName, StopReasonRestart) - return - } else { - time.Sleep(1 * time.Millisecond) - logger.Error("worker script exited with non-zero status") - } - maybeCloseContext(fc) - thread.mainRequest = nil - thread.Unpin() + panic(ScriptExecutionError) + } + + defer func() { + maybeCloseContext(fc) + thread.mainRequest = nil + thread.Unpin() + }() + + // on exit status 0 we just run the worker script again + if fc.exitStatus == 0 { + // TODO: make the max restart configurable + metrics.StopWorker(thread.worker.fileName, StopReasonRestart) + + if c := logger.Check(zapcore.InfoLevel, "restarting"); c != nil { + c.Write(zap.String("worker", thread.worker.fileName)) + } + return + } + + // on exit status 1 we apply an exponential backoff when restarting + metrics.StopWorker(thread.worker.fileName, StopReasonCrash) + thread.backoff.trigger(func(failureCount int) { + // if we end up here, the worker has not been up for backoff*2 + // this is probably due to a syntax error or another fatal error + if !watcherIsEnabled { + panic(fmt.Errorf("workers %q: too many consecutive failures", thread.worker.fileName)) + } + logger.Warn("many consecutive worker failures", zap.String("worker", thread.worker.fileName), zap.Int("failures", failureCount)) + }) } //export go_frankenphp_worker_handle_request_start func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { thread := phpThreads[threadIndex] - - if !thread.isReady { - thread.isReady = true - workersReadyWG.Done() - metrics.ReadyWorker(thread.worker.fileName) - } + thread.setReadyForRequests() if c := logger.Check(zapcore.DebugLevel, "waiting for request"); c != nil { c.Write(zap.String("worker", thread.worker.fileName)) From 7d2ab8cc99af0bd992be3cdabf8c190e7768f29f Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 2 Nov 2024 14:28:52 +0100 Subject: [PATCH 004/190] Initial working implementation. --- frankenphp.go | 2 -- php_threads.go | 6 +++-- php_threads_test.go | 60 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 php_threads_test.go diff --git a/frankenphp.go b/frankenphp.go index df8d99af6..765c48784 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -64,7 +64,6 @@ var ( ScriptExecutionError = errors.New("error during PHP script execution") requestChan chan *http.Request - done chan struct{} loggerMu sync.RWMutex logger *zap.Logger @@ -330,7 +329,6 @@ func Init(options ...Option) error { logger.Warn(`ZTS is not enabled, only 1 thread will be available, recompile PHP using the "--enable-zts" configuration option or performance will be degraded`) } - done = make(chan struct{}) requestChan = make(chan *http.Request) if err := initPHPThreads(opt.numThreads); err != nil { return err diff --git a/php_threads.go b/php_threads.go index 55e19f53f..14718ed41 100644 --- a/php_threads.go +++ b/php_threads.go @@ -12,12 +12,14 @@ var ( phpThreads []*phpThread terminationWG sync.WaitGroup mainThreadShutdownWG sync.WaitGroup - threadsReadyWG sync.WaitGroup + threadsReadyWG sync.WaitGroup shutdownWG sync.WaitGroup + done chan struct{} ) // reserve a fixed number of PHP threads on the go side func initPHPThreads(numThreads int) error { + done = make(chan struct{}) phpThreads = make([]*phpThread, numThreads) for i := 0; i < numThreads; i++ { phpThreads[i] = &phpThread{threadIndex: i} @@ -28,9 +30,9 @@ func initPHPThreads(numThreads int) error { func drainPHPThreads() { close(done) shutdownWG.Wait() - phpThreads = nil mainThreadShutdownWG.Done() terminationWG.Wait() + phpThreads = nil } func startMainThread(numThreads int) error { diff --git a/php_threads_test.go b/php_threads_test.go new file mode 100644 index 000000000..912485309 --- /dev/null +++ b/php_threads_test.go @@ -0,0 +1,60 @@ +package frankenphp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +func ATestStartAndStopTheMainThread(t *testing.T) { + logger = zap.NewNop() + initPHPThreads(1) // reserve 1 thread + + assert.Equal(t, 1, len(phpThreads)) + assert.Equal(t, 0, phpThreads[0].threadIndex) + assert.False(t, phpThreads[0].isActive) + assert.False(t, phpThreads[0].isReady) + assert.Nil(t, phpThreads[0].worker) + + drainPHPThreads() + assert.Nil(t, phpThreads) +} + +func ATestStartAndStopARegularThread(t *testing.T) { + logger = zap.NewNop() // the logger needs to not be nil + initPHPThreads(1) // reserve 1 thread + + startNewPHPThread() + threadsReadyWG.Wait() + + assert.Equal(t, 1, len(phpThreads)) + assert.True(t, phpThreads[0].isActive) + assert.True(t, phpThreads[0].isReady) + assert.Nil(t, phpThreads[0].worker) + + drainPHPThreads() + assert.Nil(t, phpThreads) +} + +func ATestStartAndStopAWorkerThread(t *testing.T) { + logger = zap.NewNop() // the logger needs to not be nil + initPHPThreads(1) // reserve 1 thread + + initWorkers([]workerOpt{workerOpt { + fileName: "testdata/worker.php", + num: 1, + env: make(map[string]string, 0), + watch: make([]string, 0), + }}) + threadsReadyWG.Wait() + + assert.Equal(t, 1, len(phpThreads)) + assert.True(t, phpThreads[0].isActive) + assert.True(t, phpThreads[0].isReady) + assert.NotNil(t, phpThreads[0].worker) + + drainPHPThreads() + assert.Nil(t, phpThreads) +} + From f7e7d41f8766e7c64f6f1624957300c3f4b41f2f Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 2 Nov 2024 21:27:43 +0100 Subject: [PATCH 005/190] Refactors php threads to take callbacks. --- frankenphp.c | 67 +++++++------------------- frankenphp.go | 37 +++++++-------- frankenphp.h | 1 - php_thread.go | 65 ++++++++++++++++++++++---- php_threads.go | 57 +++-------------------- php_threads_test.go | 111 ++++++++++++++++++++++++++++++++------------ testdata/sleep.php | 4 ++ worker.go | 61 +++++++++++++++--------- 8 files changed, 217 insertions(+), 186 deletions(-) create mode 100644 testdata/sleep.php diff --git a/frankenphp.c b/frankenphp.c index 93c283e3d..988404e81 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -838,7 +838,7 @@ static void set_thread_name(char *thread_name) { #endif } -static void init_php_thread(void *arg) { +static void *php_thread(void *arg) { thread_index = (uintptr_t)arg; char thread_name[16] = {0}; snprintf(thread_name, 16, "php-%" PRIxPTR, thread_index); @@ -851,44 +851,20 @@ static void init_php_thread(void *arg) { ZEND_TSRMLS_CACHE_UPDATE(); #endif #endif - local_ctx = malloc(sizeof(frankenphp_server_context)); -} -static void shutdown_php_thread(void) { - free(local_ctx); - local_ctx = NULL; -#ifdef ZTS - ts_free_thread(); -#endif -} -static void *php_thread(void *arg) { - init_php_thread(arg); + go_frankenphp_on_thread_startup(thread_index); - // handle requests until the channel is closed - while (go_handle_request(thread_index)) { + // perform work until go signals to stop + while (go_frankenphp_on_thread_work(thread_index)) { } - shutdown_php_thread(); - go_shutdown_php_thread(thread_index); - return NULL; -} +#ifdef ZTS + ts_free_thread(); +#endif -static void *php_worker_thread(void *arg) { - init_php_thread(arg); - - // run the loop that executes the worker script - while (true) { - char *script_name = go_before_worker_script(thread_index); - if (script_name == NULL) { - break; - } - int exit_status = frankenphp_execute_script(script_name); - go_after_worker_script(thread_index, exit_status); - } + go_frankenphp_on_thread_shutdown(thread_index); - shutdown_php_thread(); - go_shutdown_worker_thread(thread_index); return NULL; } @@ -940,7 +916,7 @@ static void *php_main(void *arg) { frankenphp_sapi_module.startup(&frankenphp_sapi_module); - go_main_thread_is_ready(); + go_frankenphp_main_thread_is_ready(); /* channel closed, shutdown gracefully */ frankenphp_sapi_module.shutdown(&frankenphp_sapi_module); @@ -956,7 +932,7 @@ static void *php_main(void *arg) { frankenphp_sapi_module.ini_entries = NULL; } #endif - go_shutdown_main_thread(); + go_frankenphp_shutdown_main_thread(); return NULL; } @@ -970,22 +946,13 @@ int frankenphp_new_main_thread(int num_threads) { return pthread_detach(thread); } -int frankenphp_new_worker_thread(uintptr_t thread_index){ - pthread_t thread; - if (pthread_create(&thread, NULL, &php_worker_thread, (void *)thread_index) != 0){ - return 1; - } - pthread_detach(thread); - return 0; -} - -int frankenphp_new_php_thread(uintptr_t thread_index){ - pthread_t thread; - if (pthread_create(&thread, NULL, &php_thread, (void *)thread_index) != 0){ - return 1; - } - pthread_detach(thread); - return 0; +int frankenphp_new_php_thread(uintptr_t thread_index) { + pthread_t thread; + if (pthread_create(&thread, NULL, &php_thread, (void *)thread_index) != 0) { + return 1; + } + pthread_detach(thread); + return 0; } int frankenphp_request_startup() { diff --git a/frankenphp.go b/frankenphp.go index 765c48784..53bc9b2c3 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -340,7 +340,9 @@ func Init(options ...Option) error { } for i := 0; i < opt.numThreads-totalWorkers; i++ { - if err := startNewPHPThread(); err != nil { + thread := getInactivePHPThread() + thread.onWork = handleRequest + if err := thread.run(); err != nil { return err } } @@ -569,16 +571,12 @@ func go_getenv(threadIndex C.uintptr_t, name *C.go_string) (C.bool, *C.go_string return true, value // Return 1 to indicate success } -//export go_handle_request -func go_handle_request(threadIndex C.uintptr_t) bool { - thread := phpThreads[threadIndex] - thread.setReadyForRequests() +func handleRequest(thread *phpThread) bool { select { case <-done: return false case r := <-requestChan: - thread := phpThreads[threadIndex] thread.mainRequest = r fc, ok := FromContext(r.Context()) @@ -595,11 +593,7 @@ func go_handle_request(threadIndex C.uintptr_t) bool { panic(err) } - // scriptFilename is freed in frankenphp_execute_script() - fc.exitStatus = C.frankenphp_execute_script(C.CString(fc.scriptFilename)) - if fc.exitStatus < 0 { - panic(ScriptExecutionError) - } + fc.exitStatus = executeScriptCGI(fc.scriptFilename) return true } @@ -880,6 +874,15 @@ func go_log(message *C.char, level C.int) { } } +func executeScriptCGI(script string) C.int { + // scriptFilename is freed in frankenphp_execute_script() + exitStatus := C.frankenphp_execute_script(C.CString(script)) + if exitStatus < 0 { + panic(ScriptExecutionError) + } + return exitStatus +} + // ExecuteScriptCLI executes the PHP script passed as parameter. // It returns the exit status code of the script. func ExecuteScriptCLI(script string, args []string) int { @@ -907,19 +910,11 @@ func freeArgs(argv []*C.char) { } } -func executePHPFunction(functionName string) { +func executePHPFunction(functionName string) bool { cFunctionName := C.CString(functionName) defer C.free(unsafe.Pointer(cFunctionName)) success := C.frankenphp_execute_php_function(cFunctionName) - if success == 1 { - if c := logger.Check(zapcore.DebugLevel, "php function call successful"); c != nil { - c.Write(zap.String("function", functionName)) - } - } else { - if c := logger.Check(zapcore.ErrorLevel, "php function call failed"); c != nil { - c.Write(zap.String("function", functionName)) - } - } + return success == 1 } diff --git a/frankenphp.h b/frankenphp.h index 7470ba00e..38d408fe6 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -42,7 +42,6 @@ frankenphp_config frankenphp_get_config(); int frankenphp_new_main_thread(int num_threads); int frankenphp_new_php_thread(uintptr_t thread_index); -int frankenphp_new_worker_thread(uintptr_t thread_index); int frankenphp_update_server_context( bool create, bool has_main_request, bool has_active_request, diff --git a/php_thread.go b/php_thread.go index 811c7a677..309107736 100644 --- a/php_thread.go +++ b/php_thread.go @@ -1,8 +1,11 @@ package frankenphp // #include +// #include +// #include "frankenphp.h" import "C" import ( + "fmt" "net/http" "runtime" ) @@ -13,10 +16,13 @@ type phpThread struct { mainRequest *http.Request workerRequest *http.Request worker *worker - isActive bool // whether the thread is currently running - isReady bool // whether the thread is ready to accept requests - threadIndex int // the index of the thread in the phpThreads slice - backoff *exponentialBackoff // backoff for worker failures + isActive bool // whether the thread is currently running + isReady bool // whether the thread is ready to accept requests + threadIndex int // the index of the thread in the phpThreads slice + onStartup func(*phpThread) // the function to run on startup + onWork func(*phpThread) bool // the function to run in the thread + onShutdown func(*phpThread) // the function to run after shutdown + backoff *exponentialBackoff // backoff for worker failures } func (thread phpThread) getActiveRequest() *http.Request { @@ -27,16 +33,55 @@ func (thread phpThread) getActiveRequest() *http.Request { return thread.mainRequest } -func (thread *phpThread) setReadyForRequests() { +func (thread *phpThread) run() error { + if thread.isActive { + return fmt.Errorf("thread is already running %d", thread.threadIndex) + } + threadsReadyWG.Add(1) + shutdownWG.Add(1) + thread.isActive = true + if C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) != 0 { + return fmt.Errorf("error creating thread %d", thread.threadIndex) + } + return nil +} + +func (thread *phpThread) setReady() { if thread.isReady { return } - thread.isReady = true - threadsReadyWG.Done() - if thread.worker != nil { - metrics.ReadyWorker(thread.worker.fileName) - } + thread.isReady = true + threadsReadyWG.Done() + if thread.worker != nil { + metrics.ReadyWorker(thread.worker.fileName) + } } +//export go_frankenphp_on_thread_startup +func go_frankenphp_on_thread_startup(threadIndex C.uintptr_t) { + thread := phpThreads[threadIndex] + thread.isReady = true + if thread.onStartup != nil { + thread.onStartup(thread) + } + threadsReadyWG.Done() +} + +//export go_frankenphp_on_thread_work +func go_frankenphp_on_thread_work(threadIndex C.uintptr_t) C.bool { + thread := phpThreads[threadIndex] + return C.bool(thread.onWork(thread)) +} +//export go_frankenphp_on_thread_shutdown +func go_frankenphp_on_thread_shutdown(threadIndex C.uintptr_t) { + thread := phpThreads[threadIndex] + thread.isActive = false + thread.isReady = false + thread.Unpin() + if thread.onShutdown != nil { + thread.onShutdown(thread) + } + shutdownWG.Done() +} diff --git a/php_threads.go b/php_threads.go index 14718ed41..137eb4ee8 100644 --- a/php_threads.go +++ b/php_threads.go @@ -4,7 +4,6 @@ package frankenphp // #include "frankenphp.h" import "C" import ( - "fmt" "sync" ) @@ -14,7 +13,7 @@ var ( mainThreadShutdownWG sync.WaitGroup threadsReadyWG sync.WaitGroup shutdownWG sync.WaitGroup - done chan struct{} + done chan struct{} ) // reserve a fixed number of PHP threads on the go side @@ -46,32 +45,7 @@ func startMainThread(numThreads int) error { return nil } -func startNewPHPThread() error { - threadsReadyWG.Add(1) - shutdownWG.Add(1) - thread := getInactiveThread() - thread.isActive = true - if C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) != 0 { - return fmt.Errorf("error creating thread %d", thread.threadIndex) - } - return nil -} - -func startNewWorkerThread(worker *worker) error { - threadsReadyWG.Add(1) - workerShutdownWG.Add(1) - thread := getInactiveThread() - thread.worker = worker - thread.backoff = newExponentialBackoff() - thread.isActive = true - if C.frankenphp_new_worker_thread(C.uintptr_t(thread.threadIndex)) != 0 { - return fmt.Errorf("failed to create worker thread") - } - - return nil -} - -func getInactiveThread() *phpThread { +func getInactivePHPThread() *phpThread { for _, thread := range phpThreads { if !thread.isActive { return thread @@ -81,32 +55,13 @@ func getInactiveThread() *phpThread { return nil } -//export go_main_thread_is_ready -func go_main_thread_is_ready() { +//export go_frankenphp_main_thread_is_ready +func go_frankenphp_main_thread_is_ready() { threadsReadyWG.Done() mainThreadShutdownWG.Wait() } -//export go_shutdown_main_thread -func go_shutdown_main_thread() { +//export go_frankenphp_shutdown_main_thread +func go_frankenphp_shutdown_main_thread() { terminationWG.Done() } - -//export go_shutdown_php_thread -func go_shutdown_php_thread(threadIndex C.uintptr_t) { - thread := phpThreads[threadIndex] - thread.Unpin() - thread.isActive = false - thread.isReady = false - shutdownWG.Done() -} - -//export go_shutdown_worker_thread -func go_shutdown_worker_thread(threadIndex C.uintptr_t) { - thread := phpThreads[threadIndex] - thread.Unpin() - thread.isActive = false - thread.isReady = false - thread.worker = nil - workerShutdownWG.Done() -} diff --git a/php_threads_test.go b/php_threads_test.go index 912485309..837486054 100644 --- a/php_threads_test.go +++ b/php_threads_test.go @@ -1,60 +1,111 @@ package frankenphp import ( + "net/http" + "path/filepath" + "sync" + "sync/atomic" "testing" "github.com/stretchr/testify/assert" "go.uber.org/zap" ) -func ATestStartAndStopTheMainThread(t *testing.T) { - logger = zap.NewNop() +func TestStartAndStopTheMainThread(t *testing.T) { initPHPThreads(1) // reserve 1 thread assert.Equal(t, 1, len(phpThreads)) assert.Equal(t, 0, phpThreads[0].threadIndex) - assert.False(t, phpThreads[0].isActive) - assert.False(t, phpThreads[0].isReady) - assert.Nil(t, phpThreads[0].worker) + assert.False(t, phpThreads[0].isActive) + assert.False(t, phpThreads[0].isReady) + assert.Nil(t, phpThreads[0].worker) drainPHPThreads() assert.Nil(t, phpThreads) } -func ATestStartAndStopARegularThread(t *testing.T) { - logger = zap.NewNop() // the logger needs to not be nil - initPHPThreads(1) // reserve 1 thread +// We'll start 100 threads and check that their hooks work correctly +// onStartup => before the thread is ready +// onWork => while the thread is working +// onShutdown => after the thread is done +func TestStartAndStop100PHPThreadsThatDoNothing(t *testing.T) { + numThreads := 100 + readyThreads := atomic.Uint64{} + finishedThreads := atomic.Uint64{} + workingThreads := atomic.Uint64{} + initPHPThreads(numThreads) + + for i := 0; i < numThreads; i++ { + newThread := getInactivePHPThread() + newThread.onStartup = func(thread *phpThread) { + if thread.threadIndex == newThread.threadIndex { + readyThreads.Add(1) + } + } + newThread.onWork = func(thread *phpThread) bool { + if thread.threadIndex == newThread.threadIndex { + workingThreads.Add(1) + } + return false // stop immediately + } + newThread.onShutdown = func(thread *phpThread) { + if thread.threadIndex == newThread.threadIndex { + finishedThreads.Add(1) + } + } + newThread.run() + } - startNewPHPThread() threadsReadyWG.Wait() - assert.Equal(t, 1, len(phpThreads)) - assert.True(t, phpThreads[0].isActive) - assert.True(t, phpThreads[0].isReady) - assert.Nil(t, phpThreads[0].worker) + assert.Equal(t, numThreads, int(readyThreads.Load())) drainPHPThreads() - assert.Nil(t, phpThreads) + + assert.Equal(t, numThreads, int(workingThreads.Load())) + assert.Equal(t, numThreads, int(finishedThreads.Load())) } -func ATestStartAndStopAWorkerThread(t *testing.T) { +// This test calls sleep() 10.000 times for 1ms (completes in ~200ms) +func TestSleep10000TimesIn100Threads(t *testing.T) { logger = zap.NewNop() // the logger needs to not be nil - initPHPThreads(1) // reserve 1 thread - - initWorkers([]workerOpt{workerOpt { - fileName: "testdata/worker.php", - num: 1, - env: make(map[string]string, 0), - watch: make([]string, 0), - }}) - threadsReadyWG.Wait() + numThreads := 100 + maxExecutions := 10000 + executionMutex := sync.Mutex{} + executionCount := 0 + scriptPath, _ := filepath.Abs("./testdata/sleep.php") + initPHPThreads(numThreads) - assert.Equal(t, 1, len(phpThreads)) - assert.True(t, phpThreads[0].isActive) - assert.True(t, phpThreads[0].isReady) - assert.NotNil(t, phpThreads[0].worker) + for i := 0; i < numThreads; i++ { + newThread := getInactivePHPThread() + + // fake a request on startup (like a worker would do) + newThread.onStartup = func(thread *phpThread) { + r, _ := http.NewRequest(http.MethodGet, "sleep.php", nil) + r, _ = NewRequestWithContext(r, WithRequestDocumentRoot("/", false)) + assert.NoError(t, updateServerContext(r, true, false)) + thread.mainRequest = r + } + + // execute the php script until we reach the maxExecutions + newThread.onWork = func(thread *phpThread) bool { + executionMutex.Lock() + if executionCount >= maxExecutions { + executionMutex.Unlock() + return false + } + executionCount++ + executionMutex.Unlock() + if int(executeScriptCGI(scriptPath)) != 0 { + return false + } + + return true + } + newThread.run() + } drainPHPThreads() - assert.Nil(t, phpThreads) -} + assert.Equal(t, maxExecutions, executionCount) +} diff --git a/testdata/sleep.php b/testdata/sleep.php new file mode 100644 index 000000000..1b1a66d02 --- /dev/null +++ b/testdata/sleep.php @@ -0,0 +1,4 @@ + Date: Sat, 2 Nov 2024 22:04:53 +0100 Subject: [PATCH 006/190] Cleanup. --- frankenphp.c | 4 ++-- frankenphp.go | 21 ++++++++------------- php_thread.go | 25 +++++++------------------ php_threads_test.go | 3 +-- worker.go | 33 ++++++++++++++++++--------------- 5 files changed, 36 insertions(+), 50 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 988404e81..42bdfca39 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -253,7 +253,7 @@ PHP_FUNCTION(frankenphp_finish_request) { /* {{{ */ php_header(); if (ctx->has_active_request) { - go_frankenphp_finish_request(thread_index, false); + go_frankenphp_finish_request_manually(thread_index); } ctx->finished = true; @@ -453,7 +453,7 @@ PHP_FUNCTION(frankenphp_handle_request) { frankenphp_worker_request_shutdown(); ctx->has_active_request = false; - go_frankenphp_finish_request(thread_index, true); + go_frankenphp_finish_worker_request(thread_index); RETURN_TRUE; } diff --git a/frankenphp.go b/frankenphp.go index 53bc9b2c3..1db0714bc 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -242,7 +242,7 @@ func Config() PHPConfig { // MaxThreads is internally used during tests. It is written to, but never read and may go away in the future. var MaxThreads int -func calculateMaxThreads(opt *opt) error { +func calculateMaxThreads(opt *opt) (int, int, error) { maxProcs := runtime.GOMAXPROCS(0) * 2 var numWorkers int @@ -264,13 +264,13 @@ func calculateMaxThreads(opt *opt) error { opt.numThreads = maxProcs } } else if opt.numThreads <= numWorkers { - return NotEnoughThreads + return opt.numThreads, numWorkers, NotEnoughThreads } metrics.TotalThreads(opt.numThreads) MaxThreads = opt.numThreads - return nil + return opt.numThreads, numWorkers, nil } // Init starts the PHP runtime and the configured workers. @@ -309,7 +309,7 @@ func Init(options ...Option) error { metrics = opt.metrics } - err := calculateMaxThreads(opt) + totalThreadCount, workerThreadCount, err := calculateMaxThreads(opt) if err != nil { return err } @@ -325,21 +325,16 @@ func Init(options ...Option) error { logger.Warn(`Zend Max Execution Timers are not enabled, timeouts (e.g. "max_execution_time") are disabled, recompile PHP with the "--enable-zend-max-execution-timers" configuration option to fix this issue`) } } else { - opt.numThreads = 1 + totalThreadCount = 1 logger.Warn(`ZTS is not enabled, only 1 thread will be available, recompile PHP using the "--enable-zts" configuration option or performance will be degraded`) } requestChan = make(chan *http.Request) - if err := initPHPThreads(opt.numThreads); err != nil { + if err := initPHPThreads(totalThreadCount); err != nil { return err } - totalWorkers := 0 - for _, w := range opt.workers { - totalWorkers += w.num - } - - for i := 0; i < opt.numThreads-totalWorkers; i++ { + for i := 0; i < totalThreadCount-workerThreadCount; i++ { thread := getInactivePHPThread() thread.onWork = handleRequest if err := thread.run(); err != nil { @@ -359,7 +354,7 @@ func Init(options ...Option) error { } if c := logger.Check(zapcore.InfoLevel, "FrankenPHP started 🐘"); c != nil { - c.Write(zap.String("php_version", Version().Version), zap.Int("num_threads", opt.numThreads)) + c.Write(zap.String("php_version", Version().Version), zap.Int("num_threads", totalThreadCount)) } if EmbeddedAppPath != "" { if c := logger.Check(zapcore.InfoLevel, "embedded PHP app 📦"); c != nil { diff --git a/php_thread.go b/php_thread.go index 309107736..351b7e356 100644 --- a/php_thread.go +++ b/php_thread.go @@ -16,11 +16,10 @@ type phpThread struct { mainRequest *http.Request workerRequest *http.Request worker *worker - isActive bool // whether the thread is currently running - isReady bool // whether the thread is ready to accept requests threadIndex int // the index of the thread in the phpThreads slice - onStartup func(*phpThread) // the function to run on startup - onWork func(*phpThread) bool // the function to run in the thread + isActive bool // whether the thread is currently running + onStartup func(*phpThread) // the function to run when ready + onWork func(*phpThread) bool // the function to run in a loop when ready onShutdown func(*phpThread) // the function to run after shutdown backoff *exponentialBackoff // backoff for worker failures } @@ -37,31 +36,22 @@ func (thread *phpThread) run() error { if thread.isActive { return fmt.Errorf("thread is already running %d", thread.threadIndex) } + if thread.onWork == nil { + return fmt.Errorf("thread.onWork must be defined %d", thread.threadIndex) + } threadsReadyWG.Add(1) shutdownWG.Add(1) thread.isActive = true if C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) != 0 { return fmt.Errorf("error creating thread %d", thread.threadIndex) } - return nil -} - -func (thread *phpThread) setReady() { - if thread.isReady { - return - } - thread.isReady = true - threadsReadyWG.Done() - if thread.worker != nil { - metrics.ReadyWorker(thread.worker.fileName) - } + return nil } //export go_frankenphp_on_thread_startup func go_frankenphp_on_thread_startup(threadIndex C.uintptr_t) { thread := phpThreads[threadIndex] - thread.isReady = true if thread.onStartup != nil { thread.onStartup(thread) } @@ -78,7 +68,6 @@ func go_frankenphp_on_thread_work(threadIndex C.uintptr_t) C.bool { func go_frankenphp_on_thread_shutdown(threadIndex C.uintptr_t) { thread := phpThreads[threadIndex] thread.isActive = false - thread.isReady = false thread.Unpin() if thread.onShutdown != nil { thread.onShutdown(thread) diff --git a/php_threads_test.go b/php_threads_test.go index 837486054..3344e901f 100644 --- a/php_threads_test.go +++ b/php_threads_test.go @@ -17,7 +17,6 @@ func TestStartAndStopTheMainThread(t *testing.T) { assert.Equal(t, 1, len(phpThreads)) assert.Equal(t, 0, phpThreads[0].threadIndex) assert.False(t, phpThreads[0].isActive) - assert.False(t, phpThreads[0].isReady) assert.Nil(t, phpThreads[0].worker) drainPHPThreads() @@ -66,7 +65,7 @@ func TestStartAndStop100PHPThreadsThatDoNothing(t *testing.T) { assert.Equal(t, numThreads, int(finishedThreads.Load())) } -// This test calls sleep() 10.000 times for 1ms (completes in ~200ms) +// This test calls sleep() 10.000 times for 1ms in 100 PHP threads. func TestSleep10000TimesIn100Threads(t *testing.T) { logger = zap.NewNop() // the logger needs to not be nil numThreads := 100 diff --git a/worker.go b/worker.go index bffed0327..b4497de3e 100644 --- a/worker.go +++ b/worker.go @@ -210,7 +210,6 @@ func afterWorkerScript(thread *phpThread, exitStatus C.int) { //export go_frankenphp_worker_handle_request_start func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { thread := phpThreads[threadIndex] - thread.setReady() if c := logger.Check(zapcore.DebugLevel, "waiting for request"); c != nil { c.Write(zap.String("worker", thread.worker.fileName)) @@ -247,28 +246,32 @@ func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { return C.bool(true) } -//export go_frankenphp_finish_request -func go_frankenphp_finish_request(threadIndex C.uintptr_t, isWorkerRequest bool) { +//export go_frankenphp_finish_worker_request +func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t) { thread := phpThreads[threadIndex] r := thread.getActiveRequest() fc := r.Context().Value(contextKey).(*FrankenPHPContext) - - if isWorkerRequest { - thread.workerRequest = nil - } + thread.workerRequest = nil maybeCloseContext(fc) if c := fc.logger.Check(zapcore.DebugLevel, "request handling finished"); c != nil { - var fields []zap.Field - if isWorkerRequest { - fields = append(fields, zap.String("worker", fc.scriptFilename), zap.String("url", r.RequestURI)) - } else { - fields = append(fields, zap.String("url", r.RequestURI)) - } - - c.Write(fields...) + c.Write(zap.String("worker", fc.scriptFilename), zap.String("url", r.RequestURI)) } thread.Unpin() } + +// when frankenphp_finish_request() is directly called from PHP +// +//export go_frankenphp_finish_request_manually +func go_frankenphp_finish_request_manually(threadIndex C.uintptr_t) { + thread := phpThreads[threadIndex] + r := thread.getActiveRequest() + fc := r.Context().Value(contextKey).(*FrankenPHPContext) + maybeCloseContext(fc) + + if c := fc.logger.Check(zapcore.DebugLevel, "request handling finished"); c != nil { + c.Write(zap.String("url", r.RequestURI)) + } +} From a9857dc82eeb7cfb69a8d818aabbf0645c3bba47 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 2 Nov 2024 22:18:47 +0100 Subject: [PATCH 007/190] Cleanup. --- php_threads.go | 3 +-- worker.go | 31 ++++++++++++++++++------------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/php_threads.go b/php_threads.go index 137eb4ee8..180594d66 100644 --- a/php_threads.go +++ b/php_threads.go @@ -51,8 +51,7 @@ func getInactivePHPThread() *phpThread { return thread } } - - return nil + panic("not enough threads reserved") } //export go_frankenphp_main_thread_is_ready diff --git a/worker.go b/worker.go index b4497de3e..1ba3e110c 100644 --- a/worker.go +++ b/worker.go @@ -75,16 +75,33 @@ func newWorker(o workerOpt) (*worker, error) { func startNewWorkerThread(worker *worker) error { workerShutdownWG.Add(1) thread := getInactivePHPThread() + + // onStartup => right before the thread is ready thread.onStartup = func(thread *phpThread) { thread.worker = worker metrics.ReadyWorker(worker.fileName) thread.backoff = newExponentialBackoff() } - thread.onWork = runWorkerScript + + // onWork => while the thread is working (in a loop) + thread.onWork = func(thread *phpThread) bool { + if workersAreDone.Load() { + return false + } + beforeWorkerScript(thread) + exitStatus := executeScriptCGI(thread.worker.fileName) + afterWorkerScript(thread, exitStatus) + + return true + } + + // onShutdown => after the thread is done thread.onShutdown = func(thread *phpThread) { thread.worker = nil + thread.backoff = nil workerShutdownWG.Done() } + return thread.run() } @@ -130,18 +147,6 @@ func restartWorkers(workerOpts []workerOpt) { logger.Info("workers restarted successfully") } -func runWorkerScript(thread *phpThread) bool { - // if workers are done, we stop the loop that runs the worker script - if workersAreDone.Load() { - return false - } - beforeWorkerScript(thread) - exitStatus := executeScriptCGI(thread.worker.fileName) - afterWorkerScript(thread, exitStatus) - - return true -} - func beforeWorkerScript(thread *phpThread) { worker := thread.worker From bac9555d91b5463fc315fa5936afc376d76eba47 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 2 Nov 2024 22:20:54 +0100 Subject: [PATCH 008/190] Cleanup. --- php_threads_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/php_threads_test.go b/php_threads_test.go index 3344e901f..3a074818a 100644 --- a/php_threads_test.go +++ b/php_threads_test.go @@ -86,7 +86,7 @@ func TestSleep10000TimesIn100Threads(t *testing.T) { thread.mainRequest = r } - // execute the php script until we reach the maxExecutions + // execute the sleep.php script until we reach maxExecutions newThread.onWork = func(thread *phpThread) bool { executionMutex.Lock() if executionCount >= maxExecutions { From a2f8d59dc6992c022027849e4cd4ee653b3729a0 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 2 Nov 2024 22:23:58 +0100 Subject: [PATCH 009/190] Cleanup. --- php_threads_test.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/php_threads_test.go b/php_threads_test.go index 3a074818a..2b0e25e34 100644 --- a/php_threads_test.go +++ b/php_threads_test.go @@ -24,9 +24,6 @@ func TestStartAndStopTheMainThread(t *testing.T) { } // We'll start 100 threads and check that their hooks work correctly -// onStartup => before the thread is ready -// onWork => while the thread is working -// onShutdown => after the thread is done func TestStartAndStop100PHPThreadsThatDoNothing(t *testing.T) { numThreads := 100 readyThreads := atomic.Uint64{} @@ -36,17 +33,23 @@ func TestStartAndStop100PHPThreadsThatDoNothing(t *testing.T) { for i := 0; i < numThreads; i++ { newThread := getInactivePHPThread() + + // onStartup => before the thread is ready newThread.onStartup = func(thread *phpThread) { if thread.threadIndex == newThread.threadIndex { readyThreads.Add(1) } } + + // onWork => while the thread is running (we stop here immediately) newThread.onWork = func(thread *phpThread) bool { if thread.threadIndex == newThread.threadIndex { workingThreads.Add(1) } return false // stop immediately } + + // onShutdown => after the thread is done newThread.onShutdown = func(thread *phpThread) { if thread.threadIndex == newThread.threadIndex { finishedThreads.Add(1) From 08254531d4f40da32e291a49619a4e75c3431a66 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 3 Nov 2024 23:35:51 +0100 Subject: [PATCH 010/190] Adjusts watcher logic. --- frankenphp.go | 27 +++++++++++---------------- php_thread.go | 1 + php_threads_test.go | 2 ++ worker.go | 22 +++++++++++++--------- 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index 1db0714bc..b7fd24000 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -464,33 +464,28 @@ func ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) error fc.responseWriter = responseWriter fc.startedAt = time.Now() - isWorker := fc.responseWriter == nil isWorkerRequest := false rc := requestChan // Detect if a worker is available to handle this request - if !isWorker { - if worker, ok := workers[fc.scriptFilename]; ok { - isWorkerRequest = true - metrics.StartWorkerRequest(fc.scriptFilename) - rc = worker.requestChan - } else { - metrics.StartRequest() - } + if worker, ok := workers[fc.scriptFilename]; ok { + isWorkerRequest = true + metrics.StartWorkerRequest(fc.scriptFilename) + rc = worker.requestChan + } else { + metrics.StartRequest() } - + select { case <-done: case rc <- request: <-fc.done } - if !isWorker { - if isWorkerRequest { - metrics.StopWorkerRequest(fc.scriptFilename, time.Since(fc.startedAt)) - } else { - metrics.StopRequest() - } + if isWorkerRequest { + metrics.StopWorkerRequest(fc.scriptFilename, time.Since(fc.startedAt)) + } else { + metrics.StopRequest() } return nil diff --git a/php_thread.go b/php_thread.go index 351b7e356..0b7fdf12b 100644 --- a/php_thread.go +++ b/php_thread.go @@ -16,6 +16,7 @@ type phpThread struct { mainRequest *http.Request workerRequest *http.Request worker *worker + requestChan chan *http.Request threadIndex int // the index of the thread in the phpThreads slice isActive bool // whether the thread is currently running onStartup func(*phpThread) // the function to run when ready diff --git a/php_threads_test.go b/php_threads_test.go index 2b0e25e34..d91fde628 100644 --- a/php_threads_test.go +++ b/php_threads_test.go @@ -98,6 +98,8 @@ func TestSleep10000TimesIn100Threads(t *testing.T) { } executionCount++ executionMutex.Unlock() + + // exit the loop and fail the test if the script fails if int(executeScriptCGI(scriptPath)) != 0 { return false } diff --git a/worker.go b/worker.go index 1ba3e110c..d77430b1d 100644 --- a/worker.go +++ b/worker.go @@ -79,6 +79,7 @@ func startNewWorkerThread(worker *worker) error { // onStartup => right before the thread is ready thread.onStartup = func(thread *phpThread) { thread.worker = worker + thread.requestChan = chan(*http.Request) metrics.ReadyWorker(worker.fileName) thread.backoff = newExponentialBackoff() } @@ -138,7 +139,6 @@ func restartWorkersOnFileChanges(workerOpts []workerOpt) error { } func restartWorkers(workerOpts []workerOpt) { - stopWorkers() workerShutdownWG.Wait() if err := initWorkers(workerOpts); err != nil { logger.Error("failed to restart workers when watching files") @@ -226,14 +226,20 @@ func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { if c := logger.Check(zapcore.DebugLevel, "shutting down"); c != nil { c.Write(zap.String("worker", thread.worker.fileName)) } - if !executePHPFunction("opcache_reset") { - logger.Warn("opcache_reset failed") - } return C.bool(false) case r = <-thread.worker.requestChan: } + // a nil request is a signal for the worker to restart + if r == nil { + if !executePHPFunction("opcache_reset") { + logger.Warn("opcache_reset failed") + } + + return C.bool(false) + } + thread.workerRequest = r if c := logger.Check(zapcore.DebugLevel, "request handling started"); c != nil { @@ -256,23 +262,21 @@ func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t) { thread := phpThreads[threadIndex] r := thread.getActiveRequest() fc := r.Context().Value(contextKey).(*FrankenPHPContext) - thread.workerRequest = nil maybeCloseContext(fc) + thread.workerRequest = nil + thread.Unpin() if c := fc.logger.Check(zapcore.DebugLevel, "request handling finished"); c != nil { c.Write(zap.String("worker", fc.scriptFilename), zap.String("url", r.RequestURI)) } - - thread.Unpin() } // when frankenphp_finish_request() is directly called from PHP // //export go_frankenphp_finish_request_manually func go_frankenphp_finish_request_manually(threadIndex C.uintptr_t) { - thread := phpThreads[threadIndex] - r := thread.getActiveRequest() + r := phpThreads[threadIndex].getActiveRequest() fc := r.Context().Value(contextKey).(*FrankenPHPContext) maybeCloseContext(fc) From 17d5cbe59f09538765c86474a297d5bf6eca4d08 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 4 Nov 2024 00:29:44 +0100 Subject: [PATCH 011/190] Adjusts the watcher logic. --- frankenphp.c | 44 ++++++++++++++++++---------------- frankenphp.go | 36 +++++++++------------------- frankenphp.h | 4 +--- php_thread.go | 9 +++---- php_threads.go | 2 +- php_threads_test.go | 4 ++-- worker.go | 57 +++++++++++++++++++++++++++++++-------------- 7 files changed, 83 insertions(+), 73 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 42bdfca39..3818ef44e 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -965,7 +965,26 @@ int frankenphp_request_startup() { return FAILURE; } -int frankenphp_execute_script(char *file_name) { +int frankenphp_execute_php_function(const char *php_function) { + zval retval = {0}; + zend_fcall_info fci = {0}; + zend_fcall_info_cache fci_cache = {0}; + zend_string *func_name = + zend_string_init(php_function, strlen(php_function), 0); + ZVAL_STR(&fci.function_name, func_name); + fci.size = sizeof fci; + fci.retval = &retval; + int success = 0; + + zend_try { success = zend_call_function(&fci, &fci_cache) == SUCCESS; } + zend_end_try(); + + zend_string_release(func_name); + + return success; +} + +int frankenphp_execute_script(char *file_name, bool clear_op_cache) { if (frankenphp_request_startup() == FAILURE) { free(file_name); file_name = NULL; @@ -1002,6 +1021,10 @@ int frankenphp_execute_script(char *file_name) { frankenphp_free_request_context(); frankenphp_request_shutdown(); + if (clear_op_cache) { + frankenphp_execute_php_function("opcache_reset"); + } + return status; } @@ -1160,22 +1183,3 @@ int frankenphp_execute_script_cli(char *script, int argc, char **argv) { return (intptr_t)exit_status; } - -int frankenphp_execute_php_function(const char *php_function) { - zval retval = {0}; - zend_fcall_info fci = {0}; - zend_fcall_info_cache fci_cache = {0}; - zend_string *func_name = - zend_string_init(php_function, strlen(php_function), 0); - ZVAL_STR(&fci.function_name, func_name); - fci.size = sizeof fci; - fci.retval = &retval; - int success = 0; - - zend_try { success = zend_call_function(&fci, &fci_cache) == SUCCESS; } - zend_end_try(); - - zend_string_release(func_name); - - return success; -} diff --git a/frankenphp.go b/frankenphp.go index b7fd24000..d7a279742 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -464,29 +464,24 @@ func ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) error fc.responseWriter = responseWriter fc.startedAt = time.Now() - isWorkerRequest := false - - rc := requestChan // Detect if a worker is available to handle this request if worker, ok := workers[fc.scriptFilename]; ok { - isWorkerRequest = true metrics.StartWorkerRequest(fc.scriptFilename) - rc = worker.requestChan - } else { - metrics.StartRequest() + worker.handleRequest(request) + <-fc.done + metrics.StopWorkerRequest(fc.scriptFilename, time.Since(fc.startedAt)) + return nil } + + metrics.StartRequest() select { case <-done: - case rc <- request: + case requestChan <- request: <-fc.done } - if isWorkerRequest { - metrics.StopWorkerRequest(fc.scriptFilename, time.Since(fc.startedAt)) - } else { - metrics.StopRequest() - } + metrics.StopRequest() return nil } @@ -583,7 +578,7 @@ func handleRequest(thread *phpThread) bool { panic(err) } - fc.exitStatus = executeScriptCGI(fc.scriptFilename) + fc.exitStatus = executeScriptCGI(fc.scriptFilename, false) return true } @@ -864,9 +859,9 @@ func go_log(message *C.char, level C.int) { } } -func executeScriptCGI(script string) C.int { +func executeScriptCGI(script string, clearOpCache bool) C.int { // scriptFilename is freed in frankenphp_execute_script() - exitStatus := C.frankenphp_execute_script(C.CString(script)) + exitStatus := C.frankenphp_execute_script(C.CString(script), C.bool(clearOpCache)) if exitStatus < 0 { panic(ScriptExecutionError) } @@ -899,12 +894,3 @@ func freeArgs(argv []*C.char) { C.free(unsafe.Pointer(arg)) } } - -func executePHPFunction(functionName string) bool { - cFunctionName := C.CString(functionName) - defer C.free(unsafe.Pointer(cFunctionName)) - - success := C.frankenphp_execute_php_function(cFunctionName) - - return success == 1 -} diff --git a/frankenphp.h b/frankenphp.h index 38d408fe6..e12be3dd7 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -50,13 +50,11 @@ int frankenphp_update_server_context( char *path_translated, char *request_uri, const char *content_type, char *auth_user, char *auth_password, int proto_num); int frankenphp_request_startup(); -int frankenphp_execute_script(char *file_name); +int frankenphp_execute_script(char *file_name, bool clear_opcache); void frankenphp_register_bulk_variables(go_string known_variables[27], php_variable *dynamic_variables, size_t size, zval *track_vars_array); int frankenphp_execute_script_cli(char *script, int argc, char **argv); -int frankenphp_execute_php_function(const char *php_function); - #endif diff --git a/php_thread.go b/php_thread.go index 0b7fdf12b..39eeb6cf3 100644 --- a/php_thread.go +++ b/php_thread.go @@ -7,6 +7,7 @@ import "C" import ( "fmt" "net/http" + "sync/atomic" "runtime" ) @@ -18,7 +19,7 @@ type phpThread struct { worker *worker requestChan chan *http.Request threadIndex int // the index of the thread in the phpThreads slice - isActive bool // whether the thread is currently running + isActive atomic.Bool // whether the thread is currently running onStartup func(*phpThread) // the function to run when ready onWork func(*phpThread) bool // the function to run in a loop when ready onShutdown func(*phpThread) // the function to run after shutdown @@ -34,7 +35,7 @@ func (thread phpThread) getActiveRequest() *http.Request { } func (thread *phpThread) run() error { - if thread.isActive { + if thread.isActive.Load() { return fmt.Errorf("thread is already running %d", thread.threadIndex) } if thread.onWork == nil { @@ -42,7 +43,7 @@ func (thread *phpThread) run() error { } threadsReadyWG.Add(1) shutdownWG.Add(1) - thread.isActive = true + thread.isActive.Store(true) if C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) != 0 { return fmt.Errorf("error creating thread %d", thread.threadIndex) } @@ -68,7 +69,7 @@ func go_frankenphp_on_thread_work(threadIndex C.uintptr_t) C.bool { //export go_frankenphp_on_thread_shutdown func go_frankenphp_on_thread_shutdown(threadIndex C.uintptr_t) { thread := phpThreads[threadIndex] - thread.isActive = false + thread.isActive.Store(false) thread.Unpin() if thread.onShutdown != nil { thread.onShutdown(thread) diff --git a/php_threads.go b/php_threads.go index 180594d66..405e1fb55 100644 --- a/php_threads.go +++ b/php_threads.go @@ -47,7 +47,7 @@ func startMainThread(numThreads int) error { func getInactivePHPThread() *phpThread { for _, thread := range phpThreads { - if !thread.isActive { + if !thread.isActive.Load() { return thread } } diff --git a/php_threads_test.go b/php_threads_test.go index d91fde628..b3df3b938 100644 --- a/php_threads_test.go +++ b/php_threads_test.go @@ -16,7 +16,7 @@ func TestStartAndStopTheMainThread(t *testing.T) { assert.Equal(t, 1, len(phpThreads)) assert.Equal(t, 0, phpThreads[0].threadIndex) - assert.False(t, phpThreads[0].isActive) + assert.False(t, phpThreads[0].isActive.Load()) assert.Nil(t, phpThreads[0].worker) drainPHPThreads() @@ -100,7 +100,7 @@ func TestSleep10000TimesIn100Threads(t *testing.T) { executionMutex.Unlock() // exit the loop and fail the test if the script fails - if int(executeScriptCGI(scriptPath)) != 0 { + if int(executeScriptCGI(scriptPath, false)) != 0 { return false } diff --git a/worker.go b/worker.go index d77430b1d..0292ca919 100644 --- a/worker.go +++ b/worker.go @@ -9,6 +9,7 @@ import ( "path/filepath" "sync" "sync/atomic" + "time" "github.com/dunglas/frankenphp/internal/watcher" "go.uber.org/zap" @@ -20,6 +21,8 @@ type worker struct { num int env PreparedEnv requestChan chan *http.Request + threads []*phpThread + threadMutex sync.RWMutex } var ( @@ -79,9 +82,12 @@ func startNewWorkerThread(worker *worker) error { // onStartup => right before the thread is ready thread.onStartup = func(thread *phpThread) { thread.worker = worker - thread.requestChan = chan(*http.Request) + thread.requestChan = make(chan *http.Request) metrics.ReadyWorker(worker.fileName) thread.backoff = newExponentialBackoff() + worker.threadMutex.Lock() + worker.threads = append(worker.threads, thread) + worker.threadMutex.Unlock() } // onWork => while the thread is working (in a loop) @@ -90,7 +96,8 @@ func startNewWorkerThread(worker *worker) error { return false } beforeWorkerScript(thread) - exitStatus := executeScriptCGI(thread.worker.fileName) + // TODO: opcache reset only if watcher is enabled + exitStatus := executeScriptCGI(thread.worker.fileName, true) afterWorkerScript(thread, exitStatus) return true @@ -119,6 +126,18 @@ func drainWorkers() { workers = make(map[string]*worker) } +// send a nil requests to workers to signal a restart +func restartWorkers() { + for _, worker := range workers { + worker.threadMutex.RLock() + for _, thread := range worker.threads { + thread.requestChan <- nil + } + worker.threadMutex.RUnlock() + } + time.Sleep(100 * time.Millisecond) // wait a bit before allowing another restart +} + func restartWorkersOnFileChanges(workerOpts []workerOpt) error { var directoriesToWatch []string for _, w := range workerOpts { @@ -128,9 +147,6 @@ func restartWorkersOnFileChanges(workerOpts []workerOpt) error { if !watcherIsEnabled { return nil } - restartWorkers := func() { - restartWorkers(workerOpts) - } if err := watcher.InitWatcher(directoriesToWatch, restartWorkers, getLogger()); err != nil { return err } @@ -138,15 +154,6 @@ func restartWorkersOnFileChanges(workerOpts []workerOpt) error { return nil } -func restartWorkers(workerOpts []workerOpt) { - workerShutdownWG.Wait() - if err := initWorkers(workerOpts); err != nil { - logger.Error("failed to restart workers when watching files") - panic(err) - } - logger.Info("workers restarted successfully") -} - func beforeWorkerScript(thread *phpThread) { worker := thread.worker @@ -212,6 +219,23 @@ func afterWorkerScript(thread *phpThread, exitStatus C.int) { }) } +func (worker *worker) handleRequest(r *http.Request) { + worker.threadMutex.RLock() + // dispatch requests to all worker threads in order + for _, thread := range worker.threads { + select { + case thread.requestChan <- r: + worker.threadMutex.RUnlock() + return + default: + } + } + worker.threadMutex.RUnlock() + // if no thread was available, fan the request out to all threads + // TODO: theoretically there could be autoscaling of threads here + worker.requestChan <- r +} + //export go_frankenphp_worker_handle_request_start func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { thread := phpThreads[threadIndex] @@ -228,15 +252,12 @@ func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { } return C.bool(false) + case r = <-thread.requestChan: case r = <-thread.worker.requestChan: } // a nil request is a signal for the worker to restart if r == nil { - if !executePHPFunction("opcache_reset") { - logger.Warn("opcache_reset failed") - } - return C.bool(false) } From 09e0ca677c14c3a20e90d4b38b52edb7199ac55b Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 4 Nov 2024 20:02:47 +0100 Subject: [PATCH 012/190] Fix opcache_reset race condition. --- frankenphp.c | 44 ++++++++++++-------------- frankenphp.go | 19 +++++++----- frankenphp.h | 3 +- php_threads_test.go | 2 +- worker.go | 76 +++++++++++++++++++++------------------------ 5 files changed, 70 insertions(+), 74 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 3818ef44e..42bdfca39 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -965,26 +965,7 @@ int frankenphp_request_startup() { return FAILURE; } -int frankenphp_execute_php_function(const char *php_function) { - zval retval = {0}; - zend_fcall_info fci = {0}; - zend_fcall_info_cache fci_cache = {0}; - zend_string *func_name = - zend_string_init(php_function, strlen(php_function), 0); - ZVAL_STR(&fci.function_name, func_name); - fci.size = sizeof fci; - fci.retval = &retval; - int success = 0; - - zend_try { success = zend_call_function(&fci, &fci_cache) == SUCCESS; } - zend_end_try(); - - zend_string_release(func_name); - - return success; -} - -int frankenphp_execute_script(char *file_name, bool clear_op_cache) { +int frankenphp_execute_script(char *file_name) { if (frankenphp_request_startup() == FAILURE) { free(file_name); file_name = NULL; @@ -1021,10 +1002,6 @@ int frankenphp_execute_script(char *file_name, bool clear_op_cache) { frankenphp_free_request_context(); frankenphp_request_shutdown(); - if (clear_op_cache) { - frankenphp_execute_php_function("opcache_reset"); - } - return status; } @@ -1183,3 +1160,22 @@ int frankenphp_execute_script_cli(char *script, int argc, char **argv) { return (intptr_t)exit_status; } + +int frankenphp_execute_php_function(const char *php_function) { + zval retval = {0}; + zend_fcall_info fci = {0}; + zend_fcall_info_cache fci_cache = {0}; + zend_string *func_name = + zend_string_init(php_function, strlen(php_function), 0); + ZVAL_STR(&fci.function_name, func_name); + fci.size = sizeof fci; + fci.retval = &retval; + int success = 0; + + zend_try { success = zend_call_function(&fci, &fci_cache) == SUCCESS; } + zend_end_try(); + + zend_string_release(func_name); + + return success; +} diff --git a/frankenphp.go b/frankenphp.go index d7a279742..1688a2eb4 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -349,10 +349,6 @@ func Init(options ...Option) error { // wait for all regular and worker threads to be ready for requests threadsReadyWG.Wait() - if err := restartWorkersOnFileChanges(opt.workers); err != nil { - return err - } - if c := logger.Check(zapcore.InfoLevel, "FrankenPHP started 🐘"); c != nil { c.Write(zap.String("php_version", Version().Version), zap.Int("num_threads", totalThreadCount)) } @@ -474,7 +470,7 @@ func ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) error } metrics.StartRequest() - + select { case <-done: case requestChan <- request: @@ -578,7 +574,7 @@ func handleRequest(thread *phpThread) bool { panic(err) } - fc.exitStatus = executeScriptCGI(fc.scriptFilename, false) + fc.exitStatus = executeScriptCGI(fc.scriptFilename) return true } @@ -859,9 +855,9 @@ func go_log(message *C.char, level C.int) { } } -func executeScriptCGI(script string, clearOpCache bool) C.int { +func executeScriptCGI(script string) C.int { // scriptFilename is freed in frankenphp_execute_script() - exitStatus := C.frankenphp_execute_script(C.CString(script), C.bool(clearOpCache)) + exitStatus := C.frankenphp_execute_script(C.CString(script)) if exitStatus < 0 { panic(ScriptExecutionError) } @@ -894,3 +890,10 @@ func freeArgs(argv []*C.char) { C.free(unsafe.Pointer(arg)) } } + +func executePHPFunction(functionName string) bool { + cFunctionName := C.CString(functionName) + defer C.free(unsafe.Pointer(cFunctionName)) + + return C.frankenphp_execute_php_function(cFunctionName) == 1 +} diff --git a/frankenphp.h b/frankenphp.h index e12be3dd7..b903148dc 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -50,11 +50,12 @@ int frankenphp_update_server_context( char *path_translated, char *request_uri, const char *content_type, char *auth_user, char *auth_password, int proto_num); int frankenphp_request_startup(); -int frankenphp_execute_script(char *file_name, bool clear_opcache); +int frankenphp_execute_script(char *file_name); void frankenphp_register_bulk_variables(go_string known_variables[27], php_variable *dynamic_variables, size_t size, zval *track_vars_array); int frankenphp_execute_script_cli(char *script, int argc, char **argv); +int frankenphp_execute_php_function(const char *php_function); #endif diff --git a/php_threads_test.go b/php_threads_test.go index b3df3b938..f80c6ba82 100644 --- a/php_threads_test.go +++ b/php_threads_test.go @@ -100,7 +100,7 @@ func TestSleep10000TimesIn100Threads(t *testing.T) { executionMutex.Unlock() // exit the loop and fail the test if the script fails - if int(executeScriptCGI(scriptPath, false)) != 0 { + if int(executeScriptCGI(scriptPath)) != 0 { return false } diff --git a/worker.go b/worker.go index 0292ca919..df39edb7d 100644 --- a/worker.go +++ b/worker.go @@ -9,7 +9,6 @@ import ( "path/filepath" "sync" "sync/atomic" - "time" "github.com/dunglas/frankenphp/internal/watcher" "go.uber.org/zap" @@ -27,15 +26,20 @@ type worker struct { var ( watcherIsEnabled bool - workerShutdownWG sync.WaitGroup workersAreDone atomic.Bool workersDone chan interface{} - workers = make(map[string]*worker) + workers map[string]*worker + isRestarting atomic.Bool + workerRestartWG sync.WaitGroup + workerShutdownWG sync.WaitGroup ) func initWorkers(opt []workerOpt) error { + workers = make(map[string]*worker, len(opt)) workersDone = make(chan interface{}) workersAreDone.Store(false) + directoriesToWatch := getDirectoriesToWatch(opt) + watcherIsEnabled = len(directoriesToWatch) > 0 for _, o := range opt { worker, err := newWorker(o) @@ -49,6 +53,14 @@ func initWorkers(opt []workerOpt) error { } } + if len(directoriesToWatch) == 0 { + return nil + } + + if err := watcher.InitWatcher(directoriesToWatch, restartWorkers, getLogger()); err != nil { + return err + } + return nil } @@ -58,12 +70,6 @@ func newWorker(o workerOpt) (*worker, error) { return nil, fmt.Errorf("worker filename is invalid %q: %w", o.fileName, err) } - // if the worker already exists, return it - // it's necessary since we don't want to destroy the channels when restarting on file changes - if w, ok := workers[absFileName]; ok { - return w, nil - } - if o.env == nil { o.env = make(PreparedEnv, 1) } @@ -76,7 +82,6 @@ func newWorker(o workerOpt) (*worker, error) { } func startNewWorkerThread(worker *worker) error { - workerShutdownWG.Add(1) thread := getInactivePHPThread() // onStartup => right before the thread is ready @@ -86,8 +91,8 @@ func startNewWorkerThread(worker *worker) error { metrics.ReadyWorker(worker.fileName) thread.backoff = newExponentialBackoff() worker.threadMutex.Lock() - worker.threads = append(worker.threads, thread) - worker.threadMutex.Unlock() + worker.threads = append(worker.threads, thread) + worker.threadMutex.Unlock() } // onWork => while the thread is working (in a loop) @@ -95,9 +100,12 @@ func startNewWorkerThread(worker *worker) error { if workersAreDone.Load() { return false } + if watcherIsEnabled && isRestarting.Load() { + workerShutdownWG.Done() + workerRestartWG.Wait() + } beforeWorkerScript(thread) - // TODO: opcache reset only if watcher is enabled - exitStatus := executeScriptCGI(thread.worker.fileName, true) + exitStatus := executeScriptCGI(thread.worker.fileName) afterWorkerScript(thread, exitStatus) return true @@ -107,7 +115,6 @@ func startNewWorkerThread(worker *worker) error { thread.onShutdown = func(thread *phpThread) { thread.worker = nil thread.backoff = nil - workerShutdownWG.Done() } return thread.run() @@ -122,36 +129,27 @@ func drainWorkers() { watcher.DrainWatcher() watcherIsEnabled = false stopWorkers() - workerShutdownWG.Wait() - workers = make(map[string]*worker) } -// send a nil requests to workers to signal a restart func restartWorkers() { + workerRestartWG.Add(1) for _, worker := range workers { - worker.threadMutex.RLock() - for _, thread := range worker.threads { - thread.requestChan <- nil - } - worker.threadMutex.RUnlock() + workerShutdownWG.Add(worker.num) } - time.Sleep(100 * time.Millisecond) // wait a bit before allowing another restart + isRestarting.Store(true) + close(workersDone) + workerShutdownWG.Wait() + workersDone = make(chan interface{}) + isRestarting.Store(false) + workerRestartWG.Done() } -func restartWorkersOnFileChanges(workerOpts []workerOpt) error { - var directoriesToWatch []string +func getDirectoriesToWatch(workerOpts []workerOpt) []string { + directoriesToWatch := []string{} for _, w := range workerOpts { directoriesToWatch = append(directoriesToWatch, w.watch...) } - watcherIsEnabled = len(directoriesToWatch) > 0 - if !watcherIsEnabled { - return nil - } - if err := watcher.InitWatcher(directoriesToWatch, restartWorkers, getLogger()); err != nil { - return err - } - - return nil + return directoriesToWatch } func beforeWorkerScript(thread *phpThread) { @@ -250,17 +248,15 @@ func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { if c := logger.Check(zapcore.DebugLevel, "shutting down"); c != nil { c.Write(zap.String("worker", thread.worker.fileName)) } + if isRestarting.Load() && !executePHPFunction("opcache_reset") { + logger.Error("failed to call opcache_reset") + } return C.bool(false) case r = <-thread.requestChan: case r = <-thread.worker.requestChan: } - // a nil request is a signal for the worker to restart - if r == nil { - return C.bool(false) - } - thread.workerRequest = r if c := logger.Check(zapcore.DebugLevel, "request handling started"); c != nil { From 7f13ada3e6a451f3310f3b24f99afb94dcfb4896 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 4 Nov 2024 20:33:37 +0100 Subject: [PATCH 013/190] Fixing merge conflicts and formatting. --- php_thread.go | 24 ++++++++++++------------ php_threads_test.go | 2 +- worker.go | 31 +++++++++++++++---------------- 3 files changed, 28 insertions(+), 29 deletions(-) diff --git a/php_thread.go b/php_thread.go index f5771209d..9692f2243 100644 --- a/php_thread.go +++ b/php_thread.go @@ -8,24 +8,24 @@ import "C" import ( "fmt" "net/http" - "sync/atomic" "runtime" + "sync/atomic" "unsafe" ) type phpThread struct { runtime.Pinner - mainRequest *http.Request - workerRequest *http.Request - worker *worker - requestChan chan *http.Request - threadIndex int // the index of the thread in the phpThreads slice - isActive atomic.Bool // whether the thread is currently running - onStartup func(*phpThread) // the function to run when ready - onWork func(*phpThread) bool // the function to run in a loop when ready - onShutdown func(*phpThread) // the function to run after shutdown - backoff *exponentialBackoff // backoff for worker failures + mainRequest *http.Request + workerRequest *http.Request + worker *worker + requestChan chan *http.Request + threadIndex int // the index of the thread in the phpThreads slice + isActive atomic.Bool // whether the thread is currently running + onStartup func(*phpThread) // the function to run when ready + onWork func(*phpThread) bool // the function to run in a loop when ready + onShutdown func(*phpThread) // the function to run after shutdown + backoff *exponentialBackoff // backoff for worker failures knownVariableKeys map[string]*C.zend_string } @@ -64,7 +64,7 @@ func (thread *phpThread) pinString(s string) *C.char { // C strings must be null-terminated func (thread *phpThread) pinCString(s string) *C.char { - return thread.pinString(s+"\x00") + return thread.pinString(s + "\x00") } //export go_frankenphp_on_thread_startup diff --git a/php_threads_test.go b/php_threads_test.go index f80c6ba82..c33932f7d 100644 --- a/php_threads_test.go +++ b/php_threads_test.go @@ -85,7 +85,7 @@ func TestSleep10000TimesIn100Threads(t *testing.T) { newThread.onStartup = func(thread *phpThread) { r, _ := http.NewRequest(http.MethodGet, "sleep.php", nil) r, _ = NewRequestWithContext(r, WithRequestDocumentRoot("/", false)) - assert.NoError(t, updateServerContext(r, true, false)) + assert.NoError(t, updateServerContext(thread, r, true, false)) thread.mainRequest = r } diff --git a/worker.go b/worker.go index f74fd3eee..360cd4953 100644 --- a/worker.go +++ b/worker.go @@ -25,13 +25,13 @@ type worker struct { } var ( - watcherIsEnabled bool - workersAreDone atomic.Bool - workersDone chan interface{} - workers map[string]*worker - isRestarting atomic.Bool - workerRestartWG sync.WaitGroup - workerShutdownWG sync.WaitGroup + workers map[string]*worker + workersDone chan interface{} + watcherIsEnabled bool + workersAreDone atomic.Bool + workersAreRestarting atomic.Bool + workerRestartWG sync.WaitGroup + workerShutdownWG sync.WaitGroup ) func initWorkers(opt []workerOpt) error { @@ -101,7 +101,7 @@ func startNewWorkerThread(worker *worker) error { if workersAreDone.Load() { return false } - if watcherIsEnabled && isRestarting.Load() { + if watcherIsEnabled && workersAreRestarting.Load() { workerShutdownWG.Done() workerRestartWG.Wait() } @@ -134,15 +134,15 @@ func drainWorkers() { func restartWorkers() { workerRestartWG.Add(1) + defer workerRestartWG.Done() for _, worker := range workers { workerShutdownWG.Add(worker.num) } - isRestarting.Store(true) + workersAreRestarting.Store(true) close(workersDone) workerShutdownWG.Wait() workersDone = make(chan interface{}) - isRestarting.Store(false) - workerRestartWG.Done() + workersAreRestarting.Store(false) } func getDirectoriesToWatch(workerOpts []workerOpt) []string { @@ -175,7 +175,7 @@ func beforeWorkerScript(thread *phpThread) { panic(err) } - if err := updateServerContext(r, true, false); err != nil { + if err := updateServerContext(thread, r, true, false); err != nil { panic(err) } @@ -249,14 +249,15 @@ func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { if c := logger.Check(zapcore.DebugLevel, "shutting down"); c != nil { c.Write(zap.String("worker", thread.worker.fileName)) } - if isRestarting.Load() && !executePHPFunction("opcache_reset") { + + // execute opcache_reset if the restart was triggered by the watcher + if watcherIsEnabled && workersAreRestarting.Load() && !executePHPFunction("opcache_reset") { logger.Error("failed to call opcache_reset") } return C.bool(false) case r = <-thread.requestChan: case r = <-thread.worker.requestChan: - case r = <-thread.requestChan: } thread.workerRequest = r @@ -307,6 +308,4 @@ func go_frankenphp_finish_request_manually(threadIndex C.uintptr_t) { if c := fc.logger.Check(zapcore.DebugLevel, "request handling finished"); c != nil { c.Write(zap.String("url", r.RequestURI)) } - - thread.Unpin() } From 13fb4bb729d143f625638320e877f6d0433143bf Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 5 Nov 2024 11:39:51 +0100 Subject: [PATCH 014/190] Prevents overlapping of TSRM reservation and script execution. --- frankenphp.c | 6 +- frankenphp.go | 16 ++--- php_thread.go | 81 ++++++++++++++--------- php_threads.go | 24 ++++++- php_threads_test.go | 153 +++++++++++++++++++++++++++++--------------- worker.go | 73 +++++++++------------ 6 files changed, 210 insertions(+), 143 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 7403cabb3..79bcfb989 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -822,8 +822,6 @@ static void *php_thread(void *arg) { cfg_get_string("filter.default", &default_filter); should_filter_var = default_filter != NULL; - go_frankenphp_on_thread_startup(thread_index); - // perform work until go signals to stop while (go_frankenphp_on_thread_work(thread_index)) { } @@ -853,13 +851,11 @@ static void *php_main(void *arg) { exit(EXIT_FAILURE); } - intptr_t num_threads = (intptr_t)arg; - set_thread_name("php-main"); #ifdef ZTS #if (PHP_VERSION_ID >= 80300) - php_tsrm_startup_ex(num_threads); + php_tsrm_startup_ex((intptr_t)arg); #else php_tsrm_startup(); #endif diff --git a/frankenphp.go b/frankenphp.go index a405d0af3..a9cab8ced 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -336,19 +336,13 @@ func Init(options ...Option) error { for i := 0; i < totalThreadCount-workerThreadCount; i++ { thread := getInactivePHPThread() - thread.onWork = handleRequest - if err := thread.run(); err != nil { - return err - } + thread.setHooks(nil, handleRequest, nil) } if err := initWorkers(opt.workers); err != nil { return err } - // wait for all regular and worker threads to be ready for requests - threadsReadyWG.Wait() - if c := logger.Check(zapcore.InfoLevel, "FrankenPHP started 🐘"); c != nil { c.Write(zap.String("php_version", Version().Version), zap.Int("num_threads", totalThreadCount)) } @@ -556,10 +550,10 @@ func go_getenv(threadIndex C.uintptr_t, name *C.go_string) (C.bool, *C.go_string return true, value // Return 1 to indicate success } -func handleRequest(thread *phpThread) bool { +func handleRequest(thread *phpThread) { select { case <-done: - return false + return case r := <-requestChan: thread.mainRequest = r @@ -576,12 +570,10 @@ func handleRequest(thread *phpThread) bool { if err := updateServerContext(thread, r, true, false); err != nil { rejectRequest(fc.responseWriter, err.Error()) - return true + return } fc.exitStatus = executeScriptCGI(fc.scriptFilename) - - return true } } diff --git a/php_thread.go b/php_thread.go index 9692f2243..d07cf4c1a 100644 --- a/php_thread.go +++ b/php_thread.go @@ -6,7 +6,6 @@ package frankenphp // #include "frankenphp.h" import "C" import ( - "fmt" "net/http" "runtime" "sync/atomic" @@ -20,12 +19,14 @@ type phpThread struct { workerRequest *http.Request worker *worker requestChan chan *http.Request - threadIndex int // the index of the thread in the phpThreads slice - isActive atomic.Bool // whether the thread is currently running - onStartup func(*phpThread) // the function to run when ready - onWork func(*phpThread) bool // the function to run in a loop when ready - onShutdown func(*phpThread) // the function to run after shutdown - backoff *exponentialBackoff // backoff for worker failures + done chan struct{} // to signal the thread to stop the + threadIndex int // the index of the thread in the phpThreads slice + isActive atomic.Bool // whether the thread is currently running + isReady atomic.Bool // whether the thread is ready for work + onStartup func(*phpThread) // the function to run when ready + onWork func(*phpThread) // the function to run in a loop when ready + onShutdown func(*phpThread) // the function to run after shutdown + backoff *exponentialBackoff // backoff for worker failures knownVariableKeys map[string]*C.zend_string } @@ -37,21 +38,36 @@ func (thread phpThread) getActiveRequest() *http.Request { return thread.mainRequest } -func (thread *phpThread) run() error { - if thread.isActive.Load() { - return fmt.Errorf("thread is already running %d", thread.threadIndex) - } - if thread.onWork == nil { - return fmt.Errorf("thread.onWork must be defined %d", thread.threadIndex) +func (thread *phpThread) setInactive() { + thread.isActive.Store(false) + thread.onWork = func(thread *phpThread) { + thread.requestChan = make(chan *http.Request) + select { + case <-done: + case <-thread.done: + } } - threadsReadyWG.Add(1) - shutdownWG.Add(1) +} + +func (thread *phpThread) setHooks(onStartup func(*phpThread), onWork func(*phpThread), onShutdown func(*phpThread)) { thread.isActive.Store(true) - if C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) != 0 { - return fmt.Errorf("error creating thread %d", thread.threadIndex) + + // to avoid race conditions, the thread sets its own hooks on startup + thread.onStartup = func(thread *phpThread) { + if thread.onShutdown != nil { + thread.onShutdown(thread) + } + thread.onStartup = onStartup + thread.onWork = onWork + thread.onShutdown = onShutdown + if thread.onStartup != nil { + thread.onStartup(thread) + } } - return nil + threadsReadyWG.Add(1) + close(thread.done) + thread.isReady.Store(false) } // Pin a string that is not null-terminated @@ -67,25 +83,32 @@ func (thread *phpThread) pinCString(s string) *C.char { return thread.pinString(s + "\x00") } -//export go_frankenphp_on_thread_startup -func go_frankenphp_on_thread_startup(threadIndex C.uintptr_t) { - thread := phpThreads[threadIndex] - if thread.onStartup != nil { - thread.onStartup(thread) - } - threadsReadyWG.Done() -} - //export go_frankenphp_on_thread_work func go_frankenphp_on_thread_work(threadIndex C.uintptr_t) C.bool { + // first check if FrankPHP is shutting down + if threadsAreDone.Load() { + return C.bool(false) + } thread := phpThreads[threadIndex] - return C.bool(thread.onWork(thread)) + + // if the thread is not ready, set it up + if !thread.isReady.Load() { + thread.isReady.Store(true) + thread.done = make(chan struct{}) + if thread.onStartup != nil { + thread.onStartup(thread) + } + threadsReadyWG.Done() + } + + // do the actual work + thread.onWork(thread) + return C.bool(true) } //export go_frankenphp_on_thread_shutdown func go_frankenphp_on_thread_shutdown(threadIndex C.uintptr_t) { thread := phpThreads[threadIndex] - thread.isActive.Store(false) thread.Unpin() if thread.onShutdown != nil { thread.onShutdown(thread) diff --git a/php_threads.go b/php_threads.go index 405e1fb55..76f23b173 100644 --- a/php_threads.go +++ b/php_threads.go @@ -4,7 +4,9 @@ package frankenphp // #include "frankenphp.h" import "C" import ( + "fmt" "sync" + "sync/atomic" ) var ( @@ -14,19 +16,39 @@ var ( threadsReadyWG sync.WaitGroup shutdownWG sync.WaitGroup done chan struct{} + threadsAreDone atomic.Bool ) // reserve a fixed number of PHP threads on the go side func initPHPThreads(numThreads int) error { + threadsAreDone.Store(false) done = make(chan struct{}) phpThreads = make([]*phpThread, numThreads) for i := 0; i < numThreads; i++ { phpThreads[i] = &phpThread{threadIndex: i} } - return startMainThread(numThreads) + logger.Warn("initializing main thread") + if err := startMainThread(numThreads); err != nil { + return err + } + + // initialize all threads as inactive + threadsReadyWG.Add(len(phpThreads)) + shutdownWG.Add(len(phpThreads)) + for _, thread := range phpThreads { + logger.Warn("initializing thread") + thread.setInactive() + logger.Warn("thread initialized") + if C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) != 0 { + return fmt.Errorf("unable to create thread %d", thread.threadIndex) + } + } + threadsReadyWG.Wait() + return nil } func drainPHPThreads() { + threadsAreDone.Store(true) close(done) shutdownWG.Wait() mainThreadShutdownWG.Done() diff --git a/php_threads_test.go b/php_threads_test.go index c33932f7d..f745b427b 100644 --- a/php_threads_test.go +++ b/php_threads_test.go @@ -12,7 +12,8 @@ import ( ) func TestStartAndStopTheMainThread(t *testing.T) { - initPHPThreads(1) // reserve 1 thread + logger = zap.NewNop() // the logger needs to not be nil + initPHPThreads(1) // reserve 1 thread assert.Equal(t, 1, len(phpThreads)) assert.Equal(t, 0, phpThreads[0].threadIndex) @@ -25,45 +26,45 @@ func TestStartAndStopTheMainThread(t *testing.T) { // We'll start 100 threads and check that their hooks work correctly func TestStartAndStop100PHPThreadsThatDoNothing(t *testing.T) { + logger = zap.NewNop() // the logger needs to not be nil numThreads := 100 readyThreads := atomic.Uint64{} finishedThreads := atomic.Uint64{} workingThreads := atomic.Uint64{} initPHPThreads(numThreads) + workWG := sync.WaitGroup{} + workWG.Add(numThreads) for i := 0; i < numThreads; i++ { newThread := getInactivePHPThread() - - // onStartup => before the thread is ready - newThread.onStartup = func(thread *phpThread) { - if thread.threadIndex == newThread.threadIndex { - readyThreads.Add(1) - } - } - - // onWork => while the thread is running (we stop here immediately) - newThread.onWork = func(thread *phpThread) bool { - if thread.threadIndex == newThread.threadIndex { - workingThreads.Add(1) - } - return false // stop immediately - } - - // onShutdown => after the thread is done - newThread.onShutdown = func(thread *phpThread) { - if thread.threadIndex == newThread.threadIndex { - finishedThreads.Add(1) - } - } - newThread.run() + newThread.setHooks( + // onStartup => before the thread is ready + func(thread *phpThread) { + if thread.threadIndex == newThread.threadIndex { + readyThreads.Add(1) + } + }, + // onWork => while the thread is running (we stop here immediately) + func(thread *phpThread) { + if thread.threadIndex == newThread.threadIndex { + workingThreads.Add(1) + } + workWG.Done() + newThread.setInactive() + }, + // onShutdown => after the thread is done + func(thread *phpThread) { + if thread.threadIndex == newThread.threadIndex { + finishedThreads.Add(1) + } + }, + ) } - threadsReadyWG.Wait() - - assert.Equal(t, numThreads, int(readyThreads.Load())) - + workWG.Wait() drainPHPThreads() + assert.Equal(t, numThreads, int(readyThreads.Load())) assert.Equal(t, numThreads, int(workingThreads.Load())) assert.Equal(t, numThreads, int(finishedThreads.Load())) } @@ -77,39 +78,87 @@ func TestSleep10000TimesIn100Threads(t *testing.T) { executionCount := 0 scriptPath, _ := filepath.Abs("./testdata/sleep.php") initPHPThreads(numThreads) + workWG := sync.WaitGroup{} + workWG.Add(maxExecutions) for i := 0; i < numThreads; i++ { - newThread := getInactivePHPThread() + getInactivePHPThread().setHooks( + // onStartup => fake a request on startup (like a worker would do) + func(thread *phpThread) { + r, _ := http.NewRequest(http.MethodGet, "sleep.php", nil) + r, _ = NewRequestWithContext(r, WithRequestDocumentRoot("/", false)) + assert.NoError(t, updateServerContext(thread, r, true, false)) + thread.mainRequest = r + }, + // onWork => execute the sleep.php script until we reach maxExecutions + func(thread *phpThread) { + executionMutex.Lock() + if executionCount >= maxExecutions { + executionMutex.Unlock() + thread.setInactive() + return + } + executionCount++ + workWG.Done() + executionMutex.Unlock() - // fake a request on startup (like a worker would do) - newThread.onStartup = func(thread *phpThread) { - r, _ := http.NewRequest(http.MethodGet, "sleep.php", nil) - r, _ = NewRequestWithContext(r, WithRequestDocumentRoot("/", false)) - assert.NoError(t, updateServerContext(thread, r, true, false)) - thread.mainRequest = r - } + // exit the loop and fail the test if the script fails + if int(executeScriptCGI(scriptPath)) != 0 { + panic("script execution failed: " + scriptPath) + } + }, + // onShutdown => nothing to do here + nil, + ) + } - // execute the sleep.php script until we reach maxExecutions - newThread.onWork = func(thread *phpThread) bool { - executionMutex.Lock() - if executionCount >= maxExecutions { - executionMutex.Unlock() - return false - } - executionCount++ - executionMutex.Unlock() + workWG.Wait() + drainPHPThreads() - // exit the loop and fail the test if the script fails - if int(executeScriptCGI(scriptPath)) != 0 { - return false - } + assert.Equal(t, maxExecutions, executionCount) +} - return true +func TestStart100ThreadsAndConvertThemToDifferentThreads10Times(t *testing.T) { + logger = zap.NewNop() // the logger needs to not be nil + numThreads := 100 + numConversions := 10 + startUpTypes := make([]atomic.Uint64, numConversions) + workTypes := make([]atomic.Uint64, numConversions) + shutdownTypes := make([]atomic.Uint64, numConversions) + workWG := sync.WaitGroup{} + + initPHPThreads(numThreads) + + for i := 0; i < numConversions; i++ { + workWG.Add(numThreads) + numberOfConversion := i + for j := 0; j < numThreads; j++ { + getInactivePHPThread().setHooks( + // onStartup => before the thread is ready + func(thread *phpThread) { + startUpTypes[numberOfConversion].Add(1) + }, + // onWork => while the thread is running + func(thread *phpThread) { + workTypes[numberOfConversion].Add(1) + thread.setInactive() + workWG.Done() + }, + // onShutdown => after the thread is done + func(thread *phpThread) { + shutdownTypes[numberOfConversion].Add(1) + }, + ) } - newThread.run() + workWG.Wait() } drainPHPThreads() - assert.Equal(t, maxExecutions, executionCount) + // each type of thread needs to have started, worked and stopped the same amount of times + for i := 0; i < numConversions; i++ { + assert.Equal(t, numThreads, int(startUpTypes[i].Load())) + assert.Equal(t, numThreads, int(workTypes[i].Load())) + assert.Equal(t, numThreads, int(shutdownTypes[i].Load())) + } } diff --git a/worker.go b/worker.go index 360cd4953..953b77f3c 100644 --- a/worker.go +++ b/worker.go @@ -28,7 +28,6 @@ var ( workers map[string]*worker workersDone chan interface{} watcherIsEnabled bool - workersAreDone atomic.Bool workersAreRestarting atomic.Bool workerRestartWG sync.WaitGroup workerShutdownWG sync.WaitGroup @@ -37,7 +36,6 @@ var ( func initWorkers(opt []workerOpt) error { workers = make(map[string]*worker, len(opt)) workersDone = make(chan interface{}) - workersAreDone.Store(false) directoriesToWatch := getDirectoriesToWatch(opt) watcherIsEnabled = len(directoriesToWatch) > 0 @@ -48,9 +46,7 @@ func initWorkers(opt []workerOpt) error { return err } for i := 0; i < worker.num; i++ { - if err := startNewWorkerThread(worker); err != nil { - return err - } + worker.startNewThread() } } @@ -82,53 +78,42 @@ func newWorker(o workerOpt) (*worker, error) { return w, nil } -func startNewWorkerThread(worker *worker) error { - thread := getInactivePHPThread() - - // onStartup => right before the thread is ready - thread.onStartup = func(thread *phpThread) { - thread.worker = worker - thread.requestChan = make(chan *http.Request) - metrics.ReadyWorker(worker.fileName) - thread.backoff = newExponentialBackoff() - worker.threadMutex.Lock() - worker.threads = append(worker.threads, thread) - worker.threadMutex.Unlock() - } - - // onWork => while the thread is working (in a loop) - thread.onWork = func(thread *phpThread) bool { - if workersAreDone.Load() { - return false - } - if watcherIsEnabled && workersAreRestarting.Load() { - workerShutdownWG.Done() - workerRestartWG.Wait() - } - beforeWorkerScript(thread) - exitStatus := executeScriptCGI(thread.worker.fileName) - afterWorkerScript(thread, exitStatus) - - return true - } - - // onShutdown => after the thread is done - thread.onShutdown = func(thread *phpThread) { - thread.worker = nil - thread.backoff = nil - } - - return thread.run() +func (worker *worker) startNewThread() { + getInactivePHPThread().setHooks( + // onStartup => right before the thread is ready + func(thread *phpThread) { + thread.worker = worker + thread.requestChan = make(chan *http.Request) + metrics.ReadyWorker(worker.fileName) + thread.backoff = newExponentialBackoff() + worker.threadMutex.Lock() + worker.threads = append(worker.threads, thread) + worker.threadMutex.Unlock() + }, + // onWork => while the thread is working (in a loop) + func(thread *phpThread) { + if watcherIsEnabled && workersAreRestarting.Load() { + workerShutdownWG.Done() + workerRestartWG.Wait() + } + beforeWorkerScript(thread) + exitStatus := executeScriptCGI(thread.worker.fileName) + afterWorkerScript(thread, exitStatus) + }, + // onShutdown => after the thread is done + func(thread *phpThread) { + thread.worker = nil + thread.backoff = nil + }, + ) } func stopWorkers() { - workersAreDone.Store(true) close(workersDone) } func drainWorkers() { watcher.DrainWatcher() - watcherIsEnabled = false stopWorkers() } From a8a00c83724281f687e6a6cb63e13714bcd802b9 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 5 Nov 2024 13:07:36 +0100 Subject: [PATCH 015/190] Adjustments as suggested by @dunglas. --- frankenphp.c | 8 ++++---- frankenphp.go | 4 ++-- frankenphp.h | 2 +- php_threads.go | 4 ++-- php_threads_test.go | 6 +++--- testdata/sleep.php | 2 +- worker.go | 6 +++--- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 79bcfb989..7a357e093 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -243,7 +243,7 @@ PHP_FUNCTION(frankenphp_finish_request) { /* {{{ */ php_header(); if (ctx->has_active_request) { - go_frankenphp_finish_request_manually(thread_index); + go_frankenphp_finish_php_request(thread_index); } ctx->finished = true; @@ -913,13 +913,13 @@ int frankenphp_new_main_thread(int num_threads) { return pthread_detach(thread); } -int frankenphp_new_php_thread(uintptr_t thread_index) { +bool frankenphp_new_php_thread(uintptr_t thread_index) { pthread_t thread; if (pthread_create(&thread, NULL, &php_thread, (void *)thread_index) != 0) { - return 1; + return false; } pthread_detach(thread); - return 0; + return true; } int frankenphp_request_startup() { diff --git a/frankenphp.go b/frankenphp.go index a9cab8ced..ebaf95584 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -573,7 +573,7 @@ func handleRequest(thread *phpThread) { return } - fc.exitStatus = executeScriptCGI(fc.scriptFilename) + fc.exitStatus = executeScriptClassic(fc.scriptFilename) } } @@ -787,7 +787,7 @@ func go_log(message *C.char, level C.int) { } } -func executeScriptCGI(script string) C.int { +func executeScriptClassic(script string) C.int { // scriptFilename is freed in frankenphp_execute_script() exitStatus := C.frankenphp_execute_script(C.CString(script)) if exitStatus < 0 { diff --git a/frankenphp.h b/frankenphp.h index ca91fc2d4..6d2e4efe2 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -41,7 +41,7 @@ typedef struct frankenphp_config { frankenphp_config frankenphp_get_config(); int frankenphp_new_main_thread(int num_threads); -int frankenphp_new_php_thread(uintptr_t thread_index); +bool frankenphp_new_php_thread(uintptr_t thread_index); int frankenphp_update_server_context( bool create, bool has_main_request, bool has_active_request, diff --git a/php_threads.go b/php_threads.go index 76f23b173..07da30db0 100644 --- a/php_threads.go +++ b/php_threads.go @@ -39,8 +39,8 @@ func initPHPThreads(numThreads int) error { logger.Warn("initializing thread") thread.setInactive() logger.Warn("thread initialized") - if C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) != 0 { - return fmt.Errorf("unable to create thread %d", thread.threadIndex) + if !C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) { + panic(fmt.Sprintf("unable to create thread %d", thread.threadIndex)) } } threadsReadyWG.Wait() diff --git a/php_threads_test.go b/php_threads_test.go index f745b427b..627947dee 100644 --- a/php_threads_test.go +++ b/php_threads_test.go @@ -11,11 +11,11 @@ import ( "go.uber.org/zap" ) -func TestStartAndStopTheMainThread(t *testing.T) { +func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { logger = zap.NewNop() // the logger needs to not be nil initPHPThreads(1) // reserve 1 thread - assert.Equal(t, 1, len(phpThreads)) + assert.Len(t, phpThreads, 1) assert.Equal(t, 0, phpThreads[0].threadIndex) assert.False(t, phpThreads[0].isActive.Load()) assert.Nil(t, phpThreads[0].worker) @@ -103,7 +103,7 @@ func TestSleep10000TimesIn100Threads(t *testing.T) { executionMutex.Unlock() // exit the loop and fail the test if the script fails - if int(executeScriptCGI(scriptPath)) != 0 { + if int(executeScriptClassic(scriptPath)) != 0 { panic("script execution failed: " + scriptPath) } }, diff --git a/testdata/sleep.php b/testdata/sleep.php index 1b1a66d02..d2c78b865 100644 --- a/testdata/sleep.php +++ b/testdata/sleep.php @@ -1,4 +1,4 @@ after the thread is done @@ -284,8 +284,8 @@ func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t) { // when frankenphp_finish_request() is directly called from PHP // -//export go_frankenphp_finish_request_manually -func go_frankenphp_finish_request_manually(threadIndex C.uintptr_t) { +//export go_frankenphp_finish_php_request +func go_frankenphp_finish_php_request(threadIndex C.uintptr_t) { r := phpThreads[threadIndex].getActiveRequest() fc := r.Context().Value(contextKey).(*FrankenPHPContext) maybeCloseContext(fc) From b4dd1382a7358cd793c508c6b6a114a0df7518a4 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 5 Nov 2024 13:31:27 +0100 Subject: [PATCH 016/190] Adds error assertions. --- php_threads_test.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/php_threads_test.go b/php_threads_test.go index 627947dee..ea31ef287 100644 --- a/php_threads_test.go +++ b/php_threads_test.go @@ -13,7 +13,7 @@ import ( func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { logger = zap.NewNop() // the logger needs to not be nil - initPHPThreads(1) // reserve 1 thread + assert.NoError(t, initPHPThreads(1)) // reserve 1 thread assert.Len(t, phpThreads, 1) assert.Equal(t, 0, phpThreads[0].threadIndex) @@ -31,10 +31,11 @@ func TestStartAndStop100PHPThreadsThatDoNothing(t *testing.T) { readyThreads := atomic.Uint64{} finishedThreads := atomic.Uint64{} workingThreads := atomic.Uint64{} - initPHPThreads(numThreads) workWG := sync.WaitGroup{} workWG.Add(numThreads) + assert.NoError(t, initPHPThreads(numThreads)) + for i := 0; i < numThreads; i++ { newThread := getInactivePHPThread() newThread.setHooks( @@ -77,10 +78,11 @@ func TestSleep10000TimesIn100Threads(t *testing.T) { executionMutex := sync.Mutex{} executionCount := 0 scriptPath, _ := filepath.Abs("./testdata/sleep.php") - initPHPThreads(numThreads) workWG := sync.WaitGroup{} workWG.Add(maxExecutions) + assert.NoError(t, initPHPThreads(numThreads)) + for i := 0; i < numThreads; i++ { getInactivePHPThread().setHooks( // onStartup => fake a request on startup (like a worker would do) @@ -118,6 +120,7 @@ func TestSleep10000TimesIn100Threads(t *testing.T) { assert.Equal(t, maxExecutions, executionCount) } +// TODO: Make this test more chaotic func TestStart100ThreadsAndConvertThemToDifferentThreads10Times(t *testing.T) { logger = zap.NewNop() // the logger needs to not be nil numThreads := 100 @@ -127,7 +130,7 @@ func TestStart100ThreadsAndConvertThemToDifferentThreads10Times(t *testing.T) { shutdownTypes := make([]atomic.Uint64, numConversions) workWG := sync.WaitGroup{} - initPHPThreads(numThreads) + assert.NoError(t, initPHPThreads(numThreads)) for i := 0; i < numConversions; i++ { workWG.Add(numThreads) From 03f98fadb09c9585a27ee5da54905467bb531fb1 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 5 Nov 2024 14:41:43 +0100 Subject: [PATCH 017/190] Adds comments. --- php_thread.go | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/php_thread.go b/php_thread.go index d07cf4c1a..8e9232c78 100644 --- a/php_thread.go +++ b/php_thread.go @@ -15,18 +15,28 @@ import ( type phpThread struct { runtime.Pinner - mainRequest *http.Request - workerRequest *http.Request - worker *worker - requestChan chan *http.Request - done chan struct{} // to signal the thread to stop the - threadIndex int // the index of the thread in the phpThreads slice - isActive atomic.Bool // whether the thread is currently running - isReady atomic.Bool // whether the thread is ready for work - onStartup func(*phpThread) // the function to run when ready - onWork func(*phpThread) // the function to run in a loop when ready - onShutdown func(*phpThread) // the function to run after shutdown - backoff *exponentialBackoff // backoff for worker failures + mainRequest *http.Request + workerRequest *http.Request + requestChan chan *http.Request + worker *worker + + // the index in the phpThreads slice + threadIndex int + // whether the thread has work assigned to it + isActive atomic.Bool + // whether the thread is ready for work + isReady atomic.Bool + // right before the first work iteration + onStartup func(*phpThread) + // the actual work iteration (done in a loop) + onWork func(*phpThread) + // after the thread is done + onShutdown func(*phpThread) + // chan to signal the thread to stop the current work iteration + done chan struct{} + // exponential backoff for worker failures + backoff *exponentialBackoff + // known $_SERVER key names knownVariableKeys map[string]*C.zend_string } @@ -38,6 +48,7 @@ func (thread phpThread) getActiveRequest() *http.Request { return thread.mainRequest } +// TODO: Also consider this case: work => inactive => work func (thread *phpThread) setInactive() { thread.isActive.Store(false) thread.onWork = func(thread *phpThread) { @@ -65,6 +76,7 @@ func (thread *phpThread) setHooks(onStartup func(*phpThread), onWork func(*phpTh } } + // we signal to the thread to stop it's current execution and call the onStartup hook threadsReadyWG.Add(1) close(thread.done) thread.isReady.Store(false) From e52dd0fedb9875a41dcd3b28be5d575c5d0bd78a Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 5 Nov 2024 14:46:57 +0100 Subject: [PATCH 018/190] Removes logs and explicitly compares to C.false. --- php_threads.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/php_threads.go b/php_threads.go index 07da30db0..63b96c4d3 100644 --- a/php_threads.go +++ b/php_threads.go @@ -27,7 +27,6 @@ func initPHPThreads(numThreads int) error { for i := 0; i < numThreads; i++ { phpThreads[i] = &phpThread{threadIndex: i} } - logger.Warn("initializing main thread") if err := startMainThread(numThreads); err != nil { return err } @@ -36,10 +35,8 @@ func initPHPThreads(numThreads int) error { threadsReadyWG.Add(len(phpThreads)) shutdownWG.Add(len(phpThreads)) for _, thread := range phpThreads { - logger.Warn("initializing thread") thread.setInactive() - logger.Warn("thread initialized") - if !C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) { + if C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) == C.false { panic(fmt.Sprintf("unable to create thread %d", thread.threadIndex)) } } From cd98e33e973a23cbd658888149b0ecd6af0df03a Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 5 Nov 2024 14:49:10 +0100 Subject: [PATCH 019/190] Resets check. --- php_threads.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/php_threads.go b/php_threads.go index 63b96c4d3..6282b19f2 100644 --- a/php_threads.go +++ b/php_threads.go @@ -36,7 +36,7 @@ func initPHPThreads(numThreads int) error { shutdownWG.Add(len(phpThreads)) for _, thread := range phpThreads { thread.setInactive() - if C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) == C.false { + if !C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) { panic(fmt.Sprintf("unable to create thread %d", thread.threadIndex)) } } From 4e2a2c61a294580c4e79b18d79feb5e19fd6ef72 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 5 Nov 2024 14:52:02 +0100 Subject: [PATCH 020/190] Adds cast for safety. --- php_threads.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/php_threads.go b/php_threads.go index 6282b19f2..6233adf1c 100644 --- a/php_threads.go +++ b/php_threads.go @@ -36,7 +36,7 @@ func initPHPThreads(numThreads int) error { shutdownWG.Add(len(phpThreads)) for _, thread := range phpThreads { thread.setInactive() - if !C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) { + if !bool(C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex))) { panic(fmt.Sprintf("unable to create thread %d", thread.threadIndex)) } } From c51eb931949484903198b4c0a6e052e97ccae8f5 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 5 Nov 2024 20:33:03 +0100 Subject: [PATCH 021/190] Fixes waitgroup overflow. --- php_thread.go | 2 +- php_threads.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/php_thread.go b/php_thread.go index 8e9232c78..259eca587 100644 --- a/php_thread.go +++ b/php_thread.go @@ -76,7 +76,7 @@ func (thread *phpThread) setHooks(onStartup func(*phpThread), onWork func(*phpTh } } - // we signal to the thread to stop it's current execution and call the onStartup hook + // signal to the thread to stop it's current execution and call the onStartup hook threadsReadyWG.Add(1) close(thread.done) thread.isReady.Store(false) diff --git a/php_threads.go b/php_threads.go index 6233adf1c..edc2bbfda 100644 --- a/php_threads.go +++ b/php_threads.go @@ -21,6 +21,7 @@ var ( // reserve a fixed number of PHP threads on the go side func initPHPThreads(numThreads int) error { + threadsReadyWG = sync.WaitGroup{} threadsAreDone.Store(false) done = make(chan struct{}) phpThreads = make([]*phpThread, numThreads) @@ -36,7 +37,7 @@ func initPHPThreads(numThreads int) error { shutdownWG.Add(len(phpThreads)) for _, thread := range phpThreads { thread.setInactive() - if !bool(C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex))) { + if !C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) { panic(fmt.Sprintf("unable to create thread %d", thread.threadIndex)) } } From 89d8e267d8df3664c7c01ff2af9fddbd3a74b9de Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 6 Nov 2024 13:45:13 +0100 Subject: [PATCH 022/190] Resolves waitgroup race condition on startup. --- frankenphp.go | 3 +-- php_thread.go | 10 ++++++---- php_threads.go | 5 +++++ php_threads_test.go | 6 +++--- worker.go | 4 ++-- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index ebaf95584..15870a9e4 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -335,8 +335,7 @@ func Init(options ...Option) error { } for i := 0; i < totalThreadCount-workerThreadCount; i++ { - thread := getInactivePHPThread() - thread.setHooks(nil, handleRequest, nil) + getInactivePHPThread().setActive(nil, handleRequest, nil) } if err := initWorkers(opt.workers); err != nil { diff --git a/php_thread.go b/php_thread.go index 259eca587..183a31ca6 100644 --- a/php_thread.go +++ b/php_thread.go @@ -40,7 +40,7 @@ type phpThread struct { knownVariableKeys map[string]*C.zend_string } -func (thread phpThread) getActiveRequest() *http.Request { +func (thread *phpThread) getActiveRequest() *http.Request { if thread.workerRequest != nil { return thread.workerRequest } @@ -60,7 +60,7 @@ func (thread *phpThread) setInactive() { } } -func (thread *phpThread) setHooks(onStartup func(*phpThread), onWork func(*phpThread), onShutdown func(*phpThread)) { +func (thread *phpThread) setActive(onStartup func(*phpThread), onWork func(*phpThread), onShutdown func(*phpThread)) { thread.isActive.Store(true) // to avoid race conditions, the thread sets its own hooks on startup @@ -77,7 +77,6 @@ func (thread *phpThread) setHooks(onStartup func(*phpThread), onWork func(*phpTh } // signal to the thread to stop it's current execution and call the onStartup hook - threadsReadyWG.Add(1) close(thread.done) thread.isReady.Store(false) } @@ -110,7 +109,10 @@ func go_frankenphp_on_thread_work(threadIndex C.uintptr_t) C.bool { if thread.onStartup != nil { thread.onStartup(thread) } - threadsReadyWG.Done() + if threadsAreBooting.Load() { + threadsReadyWG.Done() + threadsReadyWG.Wait() + } } // do the actual work diff --git a/php_threads.go b/php_threads.go index edc2bbfda..c968c20ab 100644 --- a/php_threads.go +++ b/php_threads.go @@ -17,6 +17,7 @@ var ( shutdownWG sync.WaitGroup done chan struct{} threadsAreDone atomic.Bool + threadsAreBooting atomic.Bool ) // reserve a fixed number of PHP threads on the go side @@ -35,6 +36,8 @@ func initPHPThreads(numThreads int) error { // initialize all threads as inactive threadsReadyWG.Add(len(phpThreads)) shutdownWG.Add(len(phpThreads)) + threadsAreBooting.Store(true) + for _, thread := range phpThreads { thread.setInactive() if !C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) { @@ -42,6 +45,8 @@ func initPHPThreads(numThreads int) error { } } threadsReadyWG.Wait() + threadsAreBooting.Store(false) + return nil } diff --git a/php_threads_test.go b/php_threads_test.go index ea31ef287..c8f70b6a7 100644 --- a/php_threads_test.go +++ b/php_threads_test.go @@ -38,7 +38,7 @@ func TestStartAndStop100PHPThreadsThatDoNothing(t *testing.T) { for i := 0; i < numThreads; i++ { newThread := getInactivePHPThread() - newThread.setHooks( + newThread.setActive( // onStartup => before the thread is ready func(thread *phpThread) { if thread.threadIndex == newThread.threadIndex { @@ -84,7 +84,7 @@ func TestSleep10000TimesIn100Threads(t *testing.T) { assert.NoError(t, initPHPThreads(numThreads)) for i := 0; i < numThreads; i++ { - getInactivePHPThread().setHooks( + getInactivePHPThread().setActive( // onStartup => fake a request on startup (like a worker would do) func(thread *phpThread) { r, _ := http.NewRequest(http.MethodGet, "sleep.php", nil) @@ -136,7 +136,7 @@ func TestStart100ThreadsAndConvertThemToDifferentThreads10Times(t *testing.T) { workWG.Add(numThreads) numberOfConversion := i for j := 0; j < numThreads; j++ { - getInactivePHPThread().setHooks( + getInactivePHPThread().setActive( // onStartup => before the thread is ready func(thread *phpThread) { startUpTypes[numberOfConversion].Add(1) diff --git a/worker.go b/worker.go index 31c8d3063..6fd2787eb 100644 --- a/worker.go +++ b/worker.go @@ -79,7 +79,7 @@ func newWorker(o workerOpt) (*worker, error) { } func (worker *worker) startNewThread() { - getInactivePHPThread().setHooks( + getInactivePHPThread().setActive( // onStartup => right before the thread is ready func(thread *phpThread) { thread.worker = worker @@ -185,7 +185,7 @@ func afterWorkerScript(thread *phpThread, exitStatus C.int) { // TODO: make the max restart configurable metrics.StopWorker(thread.worker.fileName, StopReasonRestart) - if c := logger.Check(zapcore.InfoLevel, "restarting"); c != nil { + if c := logger.Check(zapcore.DebugLevel, "restarting"); c != nil { c.Write(zap.String("worker", thread.worker.fileName)) } return From 3587243f59fe2fbd5fc4df56be80edfec7c606ce Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 7 Nov 2024 09:25:31 +0100 Subject: [PATCH 023/190] Moves worker request logic to worker.go. --- frankenphp.go | 5 +---- worker.go | 10 ++++++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index 15870a9e4..67e3b667c 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -459,10 +459,7 @@ func ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) error // Detect if a worker is available to handle this request if worker, ok := workers[fc.scriptFilename]; ok { - metrics.StartWorkerRequest(fc.scriptFilename) - worker.handleRequest(request) - <-fc.done - metrics.StopWorkerRequest(fc.scriptFilename, time.Since(fc.startedAt)) + worker.handleRequest(request, fc) return nil } diff --git a/worker.go b/worker.go index 6fd2787eb..37225ddfb 100644 --- a/worker.go +++ b/worker.go @@ -9,6 +9,7 @@ import ( "path/filepath" "sync" "sync/atomic" + "time" "github.com/dunglas/frankenphp/internal/watcher" "go.uber.org/zap" @@ -203,13 +204,17 @@ func afterWorkerScript(thread *phpThread, exitStatus C.int) { }) } -func (worker *worker) handleRequest(r *http.Request) { - worker.threadMutex.RLock() +func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { + metrics.StartWorkerRequest(fc.scriptFilename) + defer metrics.StopWorkerRequest(fc.scriptFilename, time.Since(fc.startedAt)) + // dispatch requests to all worker threads in order + worker.threadMutex.RLock() for _, thread := range worker.threads { select { case thread.requestChan <- r: worker.threadMutex.RUnlock() + <-fc.done return default: } @@ -218,6 +223,7 @@ func (worker *worker) handleRequest(r *http.Request) { // if no thread was available, fan the request out to all threads // TODO: theoretically there could be autoscaling of threads here worker.requestChan <- r + <-fc.done } //export go_frankenphp_worker_handle_request_start From ec32f0cc55f52dc08c1b61173272d427cf54031e Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 7 Nov 2024 11:07:41 +0100 Subject: [PATCH 024/190] Removes defer. --- worker.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/worker.go b/worker.go index 37225ddfb..1a1563324 100644 --- a/worker.go +++ b/worker.go @@ -206,7 +206,6 @@ func afterWorkerScript(thread *phpThread, exitStatus C.int) { func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { metrics.StartWorkerRequest(fc.scriptFilename) - defer metrics.StopWorkerRequest(fc.scriptFilename, time.Since(fc.startedAt)) // dispatch requests to all worker threads in order worker.threadMutex.RLock() @@ -215,15 +214,18 @@ func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { case thread.requestChan <- r: worker.threadMutex.RUnlock() <-fc.done + metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) return default: } } worker.threadMutex.RUnlock() + // if no thread was available, fan the request out to all threads // TODO: theoretically there could be autoscaling of threads here worker.requestChan <- r <-fc.done + metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) } //export go_frankenphp_worker_handle_request_start From 4e356989cd2d6597ba2b606403b54c87d4290117 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 11 Nov 2024 19:20:30 +0100 Subject: [PATCH 025/190] Removes call from go to c. --- frankenphp.c | 18 +++++++++++++----- frankenphp.go | 38 ++++++++++++++++---------------------- php_thread.go | 28 ++++++++++++++++++++++++---- php_threads_test.go | 10 +++++++--- worker.go | 7 +++++-- 5 files changed, 65 insertions(+), 36 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 7a357e093..374607b05 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -823,7 +823,19 @@ static void *php_thread(void *arg) { should_filter_var = default_filter != NULL; // perform work until go signals to stop - while (go_frankenphp_on_thread_work(thread_index)) { + while (true) { + char *scriptName = go_frankenphp_on_thread_work(thread_index); + + // if the script name is NULL, the thread should exit + if (scriptName == NULL) { + break; + } + + // if the script name is not empty, execute the PHP script + if (strlen(scriptName) != 0) { + int exit_status = frankenphp_execute_script(scriptName); + go_frankenphp_after_thread_work(thread_index, exit_status); + } } go_frankenphp_release_known_variable_keys(thread_index); @@ -934,8 +946,6 @@ int frankenphp_request_startup() { int frankenphp_execute_script(char *file_name) { if (frankenphp_request_startup() == FAILURE) { - free(file_name); - file_name = NULL; return FAILURE; } @@ -944,8 +954,6 @@ int frankenphp_execute_script(char *file_name) { zend_file_handle file_handle; zend_stream_init_filename(&file_handle, file_name); - free(file_name); - file_name = NULL; file_handle.primary_script = 1; diff --git a/frankenphp.go b/frankenphp.go index 67e3b667c..d7e4d2992 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -121,7 +121,7 @@ type FrankenPHPContext struct { closed sync.Once responseWriter http.ResponseWriter - exitStatus C.int + exitStatus int done chan interface{} startedAt time.Time @@ -335,7 +335,7 @@ func Init(options ...Option) error { } for i := 0; i < totalThreadCount-workerThreadCount; i++ { - getInactivePHPThread().setActive(nil, handleRequest, nil) + getInactivePHPThread().setActive(nil, handleRequest, afterRequest, nil) } if err := initWorkers(opt.workers); err != nil { @@ -549,30 +549,33 @@ func go_getenv(threadIndex C.uintptr_t, name *C.go_string) (C.bool, *C.go_string func handleRequest(thread *phpThread) { select { case <-done: + thread.scriptName = "" return case r := <-requestChan: thread.mainRequest = r - - fc, ok := FromContext(r.Context()) - if !ok { - panic(InvalidRequestError) - } - defer func() { - maybeCloseContext(fc) - thread.mainRequest = nil - thread.Unpin() - }() + fc := r.Context().Value(contextKey).(*FrankenPHPContext) if err := updateServerContext(thread, r, true, false); err != nil { rejectRequest(fc.responseWriter, err.Error()) + thread.scriptName = "" + afterRequest(thread, 0) return } - fc.exitStatus = executeScriptClassic(fc.scriptFilename) + // set the scriptName that should be executed + thread.scriptName = fc.scriptFilename } } +func afterRequest(thread *phpThread, exitStatus int) { + fc := thread.mainRequest.Context().Value(contextKey).(*FrankenPHPContext) + fc.exitStatus = exitStatus + maybeCloseContext(fc) + thread.mainRequest = nil + thread.Unpin() +} + func maybeCloseContext(fc *FrankenPHPContext) { fc.closed.Do(func() { close(fc.done) @@ -783,15 +786,6 @@ func go_log(message *C.char, level C.int) { } } -func executeScriptClassic(script string) C.int { - // scriptFilename is freed in frankenphp_execute_script() - exitStatus := C.frankenphp_execute_script(C.CString(script)) - if exitStatus < 0 { - panic(ScriptExecutionError) - } - return exitStatus -} - // ExecuteScriptCLI executes the PHP script passed as parameter. // It returns the exit status code of the script. func ExecuteScriptCLI(script string, args []string) int { diff --git a/php_thread.go b/php_thread.go index 183a31ca6..d19abc31e 100644 --- a/php_thread.go +++ b/php_thread.go @@ -20,6 +20,8 @@ type phpThread struct { requestChan chan *http.Request worker *worker + // the script name for the current request + scriptName string // the index in the phpThreads slice threadIndex int // whether the thread has work assigned to it @@ -30,6 +32,8 @@ type phpThread struct { onStartup func(*phpThread) // the actual work iteration (done in a loop) onWork func(*phpThread) + // after the work iteration is done + onWorkDone func(*phpThread, int) // after the thread is done onShutdown func(*phpThread) // chan to signal the thread to stop the current work iteration @@ -51,6 +55,7 @@ func (thread *phpThread) getActiveRequest() *http.Request { // TODO: Also consider this case: work => inactive => work func (thread *phpThread) setInactive() { thread.isActive.Store(false) + thread.scriptName = "" thread.onWork = func(thread *phpThread) { thread.requestChan = make(chan *http.Request) select { @@ -60,7 +65,7 @@ func (thread *phpThread) setInactive() { } } -func (thread *phpThread) setActive(onStartup func(*phpThread), onWork func(*phpThread), onShutdown func(*phpThread)) { +func (thread *phpThread) setActive(onStartup func(*phpThread), onWork func(*phpThread), onWorkDone func(*phpThread, int), onShutdown func(*phpThread)) { thread.isActive.Store(true) // to avoid race conditions, the thread sets its own hooks on startup @@ -71,6 +76,7 @@ func (thread *phpThread) setActive(onStartup func(*phpThread), onWork func(*phpT thread.onStartup = onStartup thread.onWork = onWork thread.onShutdown = onShutdown + thread.onWorkDone = onWorkDone if thread.onStartup != nil { thread.onStartup(thread) } @@ -95,10 +101,10 @@ func (thread *phpThread) pinCString(s string) *C.char { } //export go_frankenphp_on_thread_work -func go_frankenphp_on_thread_work(threadIndex C.uintptr_t) C.bool { +func go_frankenphp_on_thread_work(threadIndex C.uintptr_t) *C.char { // first check if FrankPHP is shutting down if threadsAreDone.Load() { - return C.bool(false) + return nil } thread := phpThreads[threadIndex] @@ -117,7 +123,21 @@ func go_frankenphp_on_thread_work(threadIndex C.uintptr_t) C.bool { // do the actual work thread.onWork(thread) - return C.bool(true) + + // return the name of the PHP script that should be executed + return thread.pinCString(thread.scriptName) +} + +//export go_frankenphp_after_thread_work +func go_frankenphp_after_thread_work(threadIndex C.uintptr_t, exitStatus C.int) { + thread := phpThreads[threadIndex] + if exitStatus < 0 { + panic(ScriptExecutionError) + } + if thread.onWorkDone != nil { + thread.onWorkDone(thread, int(exitStatus)) + } + thread.Unpin() } //export go_frankenphp_on_thread_shutdown diff --git a/php_threads_test.go b/php_threads_test.go index c8f70b6a7..2eb251c9a 100644 --- a/php_threads_test.go +++ b/php_threads_test.go @@ -53,6 +53,7 @@ func TestStartAndStop100PHPThreadsThatDoNothing(t *testing.T) { workWG.Done() newThread.setInactive() }, + nil, // onShutdown => after the thread is done func(thread *phpThread) { if thread.threadIndex == newThread.threadIndex { @@ -91,6 +92,7 @@ func TestSleep10000TimesIn100Threads(t *testing.T) { r, _ = NewRequestWithContext(r, WithRequestDocumentRoot("/", false)) assert.NoError(t, updateServerContext(thread, r, true, false)) thread.mainRequest = r + thread.scriptName = scriptPath }, // onWork => execute the sleep.php script until we reach maxExecutions func(thread *phpThread) { @@ -103,9 +105,10 @@ func TestSleep10000TimesIn100Threads(t *testing.T) { executionCount++ workWG.Done() executionMutex.Unlock() - - // exit the loop and fail the test if the script fails - if int(executeScriptClassic(scriptPath)) != 0 { + }, + // onWorkDone => check the exit status of the script + func(thread *phpThread, existStatus int) { + if int(existStatus) != 0 { panic("script execution failed: " + scriptPath) } }, @@ -147,6 +150,7 @@ func TestStart100ThreadsAndConvertThemToDifferentThreads10Times(t *testing.T) { thread.setInactive() workWG.Done() }, + nil, // onShutdown => after the thread is done func(thread *phpThread) { shutdownTypes[numberOfConversion].Add(1) diff --git a/worker.go b/worker.go index 1a1563324..3f50c0113 100644 --- a/worker.go +++ b/worker.go @@ -90,6 +90,7 @@ func (worker *worker) startNewThread() { worker.threadMutex.Lock() worker.threads = append(worker.threads, thread) worker.threadMutex.Unlock() + thread.scriptName = worker.fileName }, // onWork => while the thread is working (in a loop) func(thread *phpThread) { @@ -98,7 +99,9 @@ func (worker *worker) startNewThread() { workerRestartWG.Wait() } beforeWorkerScript(thread) - exitStatus := executeScriptClassic(thread.worker.fileName) + }, + // onWorkDone => after the work iteration is done + func(thread *phpThread, exitStatus int) { afterWorkerScript(thread, exitStatus) }, // onShutdown => after the thread is done @@ -171,7 +174,7 @@ func beforeWorkerScript(thread *phpThread) { } } -func afterWorkerScript(thread *phpThread, exitStatus C.int) { +func afterWorkerScript(thread *phpThread, exitStatus int) { fc := thread.mainRequest.Context().Value(contextKey).(*FrankenPHPContext) fc.exitStatus = exitStatus From 8a272cba7c382ffb204e75c6f765eba267285208 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 15 Nov 2024 12:58:06 +0100 Subject: [PATCH 026/190] Fixes merge conflict. --- frankenphp.go | 3 +-- php_threads_test.go | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index a61b826a7..b5d2bca48 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -558,8 +558,7 @@ func go_sapi_getenv(threadIndex C.uintptr_t, name *C.go_string) *C.char { return phpThreads[threadIndex].pinCString(envValue) } -//export go_handle_request -func go_handle_request(threadIndex C.uintptr_t) bool { +func handleRequest(thread *phpThread) { select { case <-done: thread.scriptName = "" diff --git a/php_threads_test.go b/php_threads_test.go index 2eb251c9a..51228a695 100644 --- a/php_threads_test.go +++ b/php_threads_test.go @@ -12,8 +12,8 @@ import ( ) func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { - logger = zap.NewNop() // the logger needs to not be nil - assert.NoError(t, initPHPThreads(1)) // reserve 1 thread + logger = zap.NewNop() // the logger needs to not be nil + assert.NoError(t, initPHPThreads(1)) // reserve 1 thread assert.Len(t, phpThreads, 1) assert.Equal(t, 0, phpThreads[0].threadIndex) @@ -53,7 +53,7 @@ func TestStartAndStop100PHPThreadsThatDoNothing(t *testing.T) { workWG.Done() newThread.setInactive() }, - nil, + nil, // onShutdown => after the thread is done func(thread *phpThread) { if thread.threadIndex == newThread.threadIndex { From ecce5d52b45b50994085abd73dfc9d9de56daf56 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 15 Nov 2024 13:00:48 +0100 Subject: [PATCH 027/190] Adds fibers test back in. --- frankenphp_test.go | 17 +++++++++++++++++ testdata/fiber-basic.php | 9 +++++++++ 2 files changed, 26 insertions(+) create mode 100644 testdata/fiber-basic.php diff --git a/frankenphp_test.go b/frankenphp_test.go index 9ca6b1520..436b96b19 100644 --- a/frankenphp_test.go +++ b/frankenphp_test.go @@ -592,6 +592,23 @@ func testFiberNoCgo(t *testing.T, opts *testOptions) { }, opts) } +func TestFiberBasic_module(t *testing.T) { testFiberBasic(t, &testOptions{}) } +func TestFiberBasic_worker(t *testing.T) { + testFiberBasic(t, &testOptions{workerScript: "fiber-basic.php"}) +} +func testFiberBasic(t *testing.T, opts *testOptions) { + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) { + req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/fiber-basic.php?i=%d", i), nil) + w := httptest.NewRecorder() + handler(w, req) + + resp := w.Result() + body, _ := io.ReadAll(resp.Body) + + assert.Equal(t, string(body), fmt.Sprintf("Fiber %d", i)) + }, opts) +} + func TestRequestHeaders_module(t *testing.T) { testRequestHeaders(t, &testOptions{}) } func TestRequestHeaders_worker(t *testing.T) { testRequestHeaders(t, &testOptions{workerScript: "request-headers.php"}) diff --git a/testdata/fiber-basic.php b/testdata/fiber-basic.php new file mode 100644 index 000000000..bdb52336f --- /dev/null +++ b/testdata/fiber-basic.php @@ -0,0 +1,9 @@ +start(); +}; From 06ebd67cf4b7519db9537775926b9172c608d6ed Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 15 Nov 2024 19:39:30 +0100 Subject: [PATCH 028/190] Refactors new thread loop approach. --- env.go | 84 +++++++++++++++++++++++++++++++++++++++++++ frankenphp.c | 4 +-- frankenphp.go | 88 +++------------------------------------------ php_thread.go | 38 ++++++++++---------- php_threads.go | 1 - php_threads_test.go | 18 ++++++---- worker.go | 87 ++++++++++++++++++++++---------------------- 7 files changed, 163 insertions(+), 157 deletions(-) create mode 100644 env.go diff --git a/env.go b/env.go new file mode 100644 index 000000000..f95c6fd13 --- /dev/null +++ b/env.go @@ -0,0 +1,84 @@ +package frankenphp + +// #include "frankenphp.h" +import "C" +import ( + "os" + "strings" + "unsafe" +) + +//export go_putenv +func go_putenv(str *C.char, length C.int) C.bool { + // Create a byte slice from C string with a specified length + s := C.GoBytes(unsafe.Pointer(str), length) + + // Convert byte slice to string + envString := string(s) + + // Check if '=' is present in the string + if key, val, found := strings.Cut(envString, "="); found { + if os.Setenv(key, val) != nil { + return false // Failure + } + } else { + // No '=', unset the environment variable + if os.Unsetenv(envString) != nil { + return false // Failure + } + } + + return true // Success +} + +//export go_getfullenv +func go_getfullenv(threadIndex C.uintptr_t) (*C.go_string, C.size_t) { + thread := phpThreads[threadIndex] + + env := os.Environ() + goStrings := make([]C.go_string, len(env)*2) + + for i, envVar := range env { + key, val, _ := strings.Cut(envVar, "=") + goStrings[i*2] = C.go_string{C.size_t(len(key)), thread.pinString(key)} + goStrings[i*2+1] = C.go_string{C.size_t(len(val)), thread.pinString(val)} + } + + value := unsafe.SliceData(goStrings) + thread.Pin(value) + + return value, C.size_t(len(env)) +} + +//export go_getenv +func go_getenv(threadIndex C.uintptr_t, name *C.go_string) (C.bool, *C.go_string) { + thread := phpThreads[threadIndex] + + // Create a byte slice from C string with a specified length + envName := C.GoStringN(name.data, C.int(name.len)) + + // Get the environment variable value + envValue, exists := os.LookupEnv(envName) + if !exists { + // Environment variable does not exist + return false, nil // Return 0 to indicate failure + } + + // Convert Go string to C string + value := &C.go_string{C.size_t(len(envValue)), thread.pinString(envValue)} + thread.Pin(value) + + return true, value // Return 1 to indicate success +} + +//export go_sapi_getenv +func go_sapi_getenv(threadIndex C.uintptr_t, name *C.go_string) *C.char { + envName := C.GoStringN(name.data, C.int(name.len)) + + envValue, exists := os.LookupEnv(envName) + if !exists { + return nil + } + + return phpThreads[threadIndex].pinCString(envValue) +} diff --git a/frankenphp.c b/frankenphp.c index 8492dcda0..0b249e152 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -830,7 +830,7 @@ static void *php_thread(void *arg) { // perform work until go signals to stop while (true) { - char *scriptName = go_frankenphp_on_thread_work(thread_index); + char *scriptName = go_frankenphp_before_script_execution(thread_index); // if the script name is NULL, the thread should exit if (scriptName == NULL) { @@ -840,7 +840,7 @@ static void *php_thread(void *arg) { // if the script name is not empty, execute the PHP script if (strlen(scriptName) != 0) { int exit_status = frankenphp_execute_script(scriptName); - go_frankenphp_after_thread_work(thread_index, exit_status); + go_frankenphp_after_script_execution(thread_index, exit_status); } } diff --git a/frankenphp.go b/frankenphp.go index b5d2bca48..7b7f61b6a 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -476,91 +476,10 @@ func ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) error return nil } -//export go_putenv -func go_putenv(str *C.char, length C.int) C.bool { - // Create a byte slice from C string with a specified length - s := C.GoBytes(unsafe.Pointer(str), length) - - // Convert byte slice to string - envString := string(s) - - // Check if '=' is present in the string - if key, val, found := strings.Cut(envString, "="); found { - if os.Setenv(key, val) != nil { - return false // Failure - } - } else { - // No '=', unset the environment variable - if os.Unsetenv(envString) != nil { - return false // Failure - } - } - - return true // Success -} - -//export go_getfullenv -func go_getfullenv(threadIndex C.uintptr_t) (*C.go_string, C.size_t) { - thread := phpThreads[threadIndex] - - env := os.Environ() - goStrings := make([]C.go_string, len(env)*2) - - for i, envVar := range env { - key, val, _ := strings.Cut(envVar, "=") - k := unsafe.StringData(key) - v := unsafe.StringData(val) - thread.Pin(k) - thread.Pin(v) - - goStrings[i*2] = C.go_string{C.size_t(len(key)), (*C.char)(unsafe.Pointer(k))} - goStrings[i*2+1] = C.go_string{C.size_t(len(val)), (*C.char)(unsafe.Pointer(v))} - } - - value := unsafe.SliceData(goStrings) - thread.Pin(value) - - return value, C.size_t(len(env)) -} - -//export go_getenv -func go_getenv(threadIndex C.uintptr_t, name *C.go_string) (C.bool, *C.go_string) { - thread := phpThreads[threadIndex] - - // Create a byte slice from C string with a specified length - envName := C.GoStringN(name.data, C.int(name.len)) - - // Get the environment variable value - envValue, exists := os.LookupEnv(envName) - if !exists { - // Environment variable does not exist - return false, nil // Return 0 to indicate failure - } - - // Convert Go string to C string - val := unsafe.StringData(envValue) - thread.Pin(val) - value := &C.go_string{C.size_t(len(envValue)), (*C.char)(unsafe.Pointer(val))} - thread.Pin(value) - - return true, value // Return 1 to indicate success -} - -//export go_sapi_getenv -func go_sapi_getenv(threadIndex C.uintptr_t, name *C.go_string) *C.char { - envName := C.GoStringN(name.data, C.int(name.len)) - - envValue, exists := os.LookupEnv(envName) - if !exists { - return nil - } - - return phpThreads[threadIndex].pinCString(envValue) -} - func handleRequest(thread *phpThread) { select { case <-done: + // no script should be executed if the server is shutting down thread.scriptName = "" return @@ -570,8 +489,10 @@ func handleRequest(thread *phpThread) { if err := updateServerContext(thread, r, true, false); err != nil { rejectRequest(fc.responseWriter, err.Error()) - thread.scriptName = "" afterRequest(thread, 0) + thread.Unpin() + // no script should be executed if the request was rejected + thread.scriptName = "" return } @@ -585,7 +506,6 @@ func afterRequest(thread *phpThread, exitStatus int) { fc.exitStatus = exitStatus maybeCloseContext(fc) thread.mainRequest = nil - thread.Unpin() } func maybeCloseContext(fc *FrankenPHPContext) { diff --git a/php_thread.go b/php_thread.go index d19abc31e..5c00959b0 100644 --- a/php_thread.go +++ b/php_thread.go @@ -1,8 +1,5 @@ package frankenphp -// #include -// #include -// #include // #include "frankenphp.h" import "C" import ( @@ -31,9 +28,9 @@ type phpThread struct { // right before the first work iteration onStartup func(*phpThread) // the actual work iteration (done in a loop) - onWork func(*phpThread) + beforeScriptExecution func(*phpThread) // after the work iteration is done - onWorkDone func(*phpThread, int) + afterScriptExecution func(*phpThread, int) // after the thread is done onShutdown func(*phpThread) // chan to signal the thread to stop the current work iteration @@ -56,7 +53,7 @@ func (thread *phpThread) getActiveRequest() *http.Request { func (thread *phpThread) setInactive() { thread.isActive.Store(false) thread.scriptName = "" - thread.onWork = func(thread *phpThread) { + thread.beforeScriptExecution = func(thread *phpThread) { thread.requestChan = make(chan *http.Request) select { case <-done: @@ -65,7 +62,12 @@ func (thread *phpThread) setInactive() { } } -func (thread *phpThread) setActive(onStartup func(*phpThread), onWork func(*phpThread), onWorkDone func(*phpThread, int), onShutdown func(*phpThread)) { +func (thread *phpThread) setActive( + onStartup func(*phpThread), + beforeScriptExecution func(*phpThread), + afterScriptExecution func(*phpThread, int), + onShutdown func(*phpThread), +) { thread.isActive.Store(true) // to avoid race conditions, the thread sets its own hooks on startup @@ -74,9 +76,9 @@ func (thread *phpThread) setActive(onStartup func(*phpThread), onWork func(*phpT thread.onShutdown(thread) } thread.onStartup = onStartup - thread.onWork = onWork + thread.beforeScriptExecution = beforeScriptExecution thread.onShutdown = onShutdown - thread.onWorkDone = onWorkDone + thread.afterScriptExecution = afterScriptExecution if thread.onStartup != nil { thread.onStartup(thread) } @@ -100,9 +102,9 @@ func (thread *phpThread) pinCString(s string) *C.char { return thread.pinString(s + "\x00") } -//export go_frankenphp_on_thread_work -func go_frankenphp_on_thread_work(threadIndex C.uintptr_t) *C.char { - // first check if FrankPHP is shutting down +//export go_frankenphp_before_script_execution +func go_frankenphp_before_script_execution(threadIndex C.uintptr_t) *C.char { + // returning nil signals the thread to stop if threadsAreDone.Load() { return nil } @@ -121,21 +123,21 @@ func go_frankenphp_on_thread_work(threadIndex C.uintptr_t) *C.char { } } - // do the actual work - thread.onWork(thread) + // execute a hook before the script is executed + thread.beforeScriptExecution(thread) // return the name of the PHP script that should be executed return thread.pinCString(thread.scriptName) } -//export go_frankenphp_after_thread_work -func go_frankenphp_after_thread_work(threadIndex C.uintptr_t, exitStatus C.int) { +//export go_frankenphp_after_script_execution +func go_frankenphp_after_script_execution(threadIndex C.uintptr_t, exitStatus C.int) { thread := phpThreads[threadIndex] if exitStatus < 0 { panic(ScriptExecutionError) } - if thread.onWorkDone != nil { - thread.onWorkDone(thread, int(exitStatus)) + if thread.afterScriptExecution != nil { + thread.afterScriptExecution(thread, int(exitStatus)) } thread.Unpin() } diff --git a/php_threads.go b/php_threads.go index c968c20ab..11826ba5a 100644 --- a/php_threads.go +++ b/php_threads.go @@ -1,6 +1,5 @@ package frankenphp -// #include // #include "frankenphp.h" import "C" import ( diff --git a/php_threads_test.go b/php_threads_test.go index 51228a695..b290e0c77 100644 --- a/php_threads_test.go +++ b/php_threads_test.go @@ -45,7 +45,7 @@ func TestStartAndStop100PHPThreadsThatDoNothing(t *testing.T) { readyThreads.Add(1) } }, - // onWork => while the thread is running (we stop here immediately) + // beforeScriptExecution => we stop here immediately func(thread *phpThread) { if thread.threadIndex == newThread.threadIndex { workingThreads.Add(1) @@ -53,7 +53,10 @@ func TestStartAndStop100PHPThreadsThatDoNothing(t *testing.T) { workWG.Done() newThread.setInactive() }, - nil, + // afterScriptExecution => no script is executed, we shouldn't reach here + func(thread *phpThread, exitStatus int) { + panic("hook afterScriptExecution should not be called here") + }, // onShutdown => after the thread is done func(thread *phpThread) { if thread.threadIndex == newThread.threadIndex { @@ -94,7 +97,7 @@ func TestSleep10000TimesIn100Threads(t *testing.T) { thread.mainRequest = r thread.scriptName = scriptPath }, - // onWork => execute the sleep.php script until we reach maxExecutions + // beforeScriptExecution => execute the sleep.php script until we reach maxExecutions func(thread *phpThread) { executionMutex.Lock() if executionCount >= maxExecutions { @@ -106,9 +109,9 @@ func TestSleep10000TimesIn100Threads(t *testing.T) { workWG.Done() executionMutex.Unlock() }, - // onWorkDone => check the exit status of the script - func(thread *phpThread, existStatus int) { - if int(existStatus) != 0 { + // afterScriptExecution => check the exit status of the script + func(thread *phpThread, exitStatus int) { + if int(exitStatus) != 0 { panic("script execution failed: " + scriptPath) } }, @@ -144,12 +147,13 @@ func TestStart100ThreadsAndConvertThemToDifferentThreads10Times(t *testing.T) { func(thread *phpThread) { startUpTypes[numberOfConversion].Add(1) }, - // onWork => while the thread is running + // beforeScriptExecution => while the thread is running func(thread *phpThread) { workTypes[numberOfConversion].Add(1) thread.setInactive() workWG.Done() }, + // afterScriptExecution => we don't execute a script nil, // onShutdown => after the thread is done func(thread *phpThread) { diff --git a/worker.go b/worker.go index 53e03c85d..01ef153aa 100644 --- a/worker.go +++ b/worker.go @@ -1,6 +1,5 @@ package frankenphp -// #include // #include "frankenphp.h" import "C" import ( @@ -80,39 +79,6 @@ func newWorker(o workerOpt) (*worker, error) { return w, nil } -func (worker *worker) startNewThread() { - getInactivePHPThread().setActive( - // onStartup => right before the thread is ready - func(thread *phpThread) { - thread.worker = worker - thread.requestChan = make(chan *http.Request) - metrics.ReadyWorker(worker.fileName) - thread.backoff = newExponentialBackoff() - worker.threadMutex.Lock() - worker.threads = append(worker.threads, thread) - worker.threadMutex.Unlock() - thread.scriptName = worker.fileName - }, - // onWork => while the thread is working (in a loop) - func(thread *phpThread) { - if watcherIsEnabled && workersAreRestarting.Load() { - workerShutdownWG.Done() - workerRestartWG.Wait() - } - beforeWorkerScript(thread) - }, - // onWorkDone => after the work iteration is done - func(thread *phpThread, exitStatus int) { - afterWorkerScript(thread, exitStatus) - }, - // onShutdown => after the thread is done - func(thread *phpThread) { - thread.worker = nil - thread.backoff = nil - }, - ) -} - func stopWorkers() { close(workersDone) } @@ -129,7 +95,7 @@ func restartWorkers() { workerShutdownWG.Add(worker.num) } workersAreRestarting.Store(true) - close(workersDone) + stopWorkers() workerShutdownWG.Wait() workersDone = make(chan interface{}) workersAreRestarting.Store(false) @@ -143,10 +109,42 @@ func getDirectoriesToWatch(workerOpts []workerOpt) []string { return directoriesToWatch } -func beforeWorkerScript(thread *phpThread) { - worker := thread.worker +func (worker *worker) startNewThread() { + getInactivePHPThread().setActive( + // onStartup => right before the thread is ready + func(thread *phpThread) { + thread.worker = worker + thread.scriptName = worker.fileName + thread.requestChan = make(chan *http.Request) + thread.backoff = newExponentialBackoff() + worker.threadMutex.Lock() + worker.threads = append(worker.threads, thread) + worker.threadMutex.Unlock() + metrics.ReadyWorker(worker.fileName) + }, + // beforeScriptExecution => set up the worker with a fake request + func(thread *phpThread) { + worker.beforeScript(thread) + }, + // afterScriptExecution => tear down the worker + func(thread *phpThread, exitStatus int) { + worker.afterScript(thread, exitStatus) + }, + // onShutdown => after the thread is done + func(thread *phpThread) { + thread.worker = nil + thread.backoff = nil + }, + ) +} + +func (worker *worker) beforeScript(thread *phpThread) { + // if we are restarting due to file watching, wait for all workers to finish first + if watcherIsEnabled && workersAreRestarting.Load() { + workerShutdownWG.Done() + workerRestartWG.Wait() + } - // if we are restarting the worker, reset the exponential failure backoff thread.backoff.reset() metrics.StartWorker(worker.fileName) @@ -175,36 +173,35 @@ func beforeWorkerScript(thread *phpThread) { } } -func afterWorkerScript(thread *phpThread, exitStatus int) { +func (worker *worker) afterScript(thread *phpThread, exitStatus int) { fc := thread.mainRequest.Context().Value(contextKey).(*FrankenPHPContext) fc.exitStatus = exitStatus defer func() { maybeCloseContext(fc) thread.mainRequest = nil - thread.Unpin() }() // on exit status 0 we just run the worker script again if fc.exitStatus == 0 { // TODO: make the max restart configurable - metrics.StopWorker(thread.worker.fileName, StopReasonRestart) + metrics.StopWorker(worker.fileName, StopReasonRestart) if c := logger.Check(zapcore.DebugLevel, "restarting"); c != nil { - c.Write(zap.String("worker", thread.worker.fileName)) + c.Write(zap.String("worker", worker.fileName)) } return } // on exit status 1 we apply an exponential backoff when restarting - metrics.StopWorker(thread.worker.fileName, StopReasonCrash) + metrics.StopWorker(worker.fileName, StopReasonCrash) thread.backoff.trigger(func(failureCount int) { // if we end up here, the worker has not been up for backoff*2 // this is probably due to a syntax error or another fatal error if !watcherIsEnabled { - panic(fmt.Errorf("workers %q: too many consecutive failures", thread.worker.fileName)) + panic(fmt.Errorf("workers %q: too many consecutive failures", worker.fileName)) } - logger.Warn("many consecutive worker failures", zap.String("worker", thread.worker.fileName), zap.Int("failures", failureCount)) + logger.Warn("many consecutive worker failures", zap.String("worker", worker.fileName), zap.Int("failures", failureCount)) }) } From c811f4a167cde72eed5651cfc7ed3cfea859b262 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 16 Nov 2024 16:57:45 +0100 Subject: [PATCH 029/190] Removes redundant check. --- worker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/worker.go b/worker.go index 01ef153aa..1b245b2da 100644 --- a/worker.go +++ b/worker.go @@ -140,7 +140,7 @@ func (worker *worker) startNewThread() { func (worker *worker) beforeScript(thread *phpThread) { // if we are restarting due to file watching, wait for all workers to finish first - if watcherIsEnabled && workersAreRestarting.Load() { + if workersAreRestarting.Load() { workerShutdownWG.Done() workerRestartWG.Wait() } From 6bd047a4cc6b69c1acfa45b819786e0706259d96 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 16 Nov 2024 16:58:00 +0100 Subject: [PATCH 030/190] Adds compareAndSwap. --- php_thread.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/php_thread.go b/php_thread.go index 5c00959b0..a8d64ce41 100644 --- a/php_thread.go +++ b/php_thread.go @@ -111,8 +111,7 @@ func go_frankenphp_before_script_execution(threadIndex C.uintptr_t) *C.char { thread := phpThreads[threadIndex] // if the thread is not ready, set it up - if !thread.isReady.Load() { - thread.isReady.Store(true) + if thread.isReady.CompareAndSwap(false, true) { thread.done = make(chan struct{}) if thread.onStartup != nil { thread.onStartup(thread) From 55ad8ba8bcde8937374ee849302a291fcda21220 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 17 Nov 2024 22:39:57 +0100 Subject: [PATCH 031/190] Refactor: removes global waitgroups and uses a 'thread state' abstraction instead. --- frankenphp.c | 2 + php_thread.go | 47 ++++++++++---------- php_threads.go | 71 +++++++++++++++-------------- php_threads_test.go | 4 +- thread_state.go | 103 +++++++++++++++++++++++++++++++++++++++++++ thread_state_test.go | 43 ++++++++++++++++++ worker.go | 40 +++++++++-------- 7 files changed, 233 insertions(+), 77 deletions(-) create mode 100644 thread_state.go create mode 100644 thread_state_test.go diff --git a/frankenphp.c b/frankenphp.c index 0b249e152..73a0dc0be 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -828,6 +828,8 @@ static void *php_thread(void *arg) { cfg_get_string("filter.default", &default_filter); should_filter_var = default_filter != NULL; + go_frankenphp_on_thread_startup(thread_index); + // perform work until go signals to stop while (true) { char *scriptName = go_frankenphp_before_script_execution(thread_index); diff --git a/php_thread.go b/php_thread.go index a8d64ce41..bd260f6c7 100644 --- a/php_thread.go +++ b/php_thread.go @@ -39,6 +39,8 @@ type phpThread struct { backoff *exponentialBackoff // known $_SERVER key names knownVariableKeys map[string]*C.zend_string + // the state handler + state *threadStateHandler } func (thread *phpThread) getActiveRequest() *http.Request { @@ -49,16 +51,11 @@ func (thread *phpThread) getActiveRequest() *http.Request { return thread.mainRequest } -// TODO: Also consider this case: work => inactive => work func (thread *phpThread) setInactive() { - thread.isActive.Store(false) thread.scriptName = "" - thread.beforeScriptExecution = func(thread *phpThread) { - thread.requestChan = make(chan *http.Request) - select { - case <-done: - case <-thread.done: - } + // TODO: handle this in a state machine + if !thread.state.is(stateShuttingDown) { + thread.state.set(stateInactive) } } @@ -68,8 +65,6 @@ func (thread *phpThread) setActive( afterScriptExecution func(*phpThread, int), onShutdown func(*phpThread), ) { - thread.isActive.Store(true) - // to avoid race conditions, the thread sets its own hooks on startup thread.onStartup = func(thread *phpThread) { if thread.onShutdown != nil { @@ -83,10 +78,7 @@ func (thread *phpThread) setActive( thread.onStartup(thread) } } - - // signal to the thread to stop it's current execution and call the onStartup hook - close(thread.done) - thread.isReady.Store(false) + thread.state.set(stateActive) } // Pin a string that is not null-terminated @@ -102,24 +94,31 @@ func (thread *phpThread) pinCString(s string) *C.char { return thread.pinString(s + "\x00") } +//export go_frankenphp_on_thread_startup +func go_frankenphp_on_thread_startup(threadIndex C.uintptr_t) { + phpThreads[threadIndex].setInactive() +} + //export go_frankenphp_before_script_execution func go_frankenphp_before_script_execution(threadIndex C.uintptr_t) *C.char { + thread := phpThreads[threadIndex] + + // if the state is inactive, wait for it to be active + if thread.state.is(stateInactive) { + thread.state.waitFor(stateActive, stateShuttingDown) + } + // returning nil signals the thread to stop - if threadsAreDone.Load() { + if thread.state.is(stateShuttingDown) { return nil } - thread := phpThreads[threadIndex] - // if the thread is not ready, set it up - if thread.isReady.CompareAndSwap(false, true) { - thread.done = make(chan struct{}) + // if the thread is not ready yet, set it up + if !thread.state.is(stateReady) { + thread.state.set(stateReady) if thread.onStartup != nil { thread.onStartup(thread) } - if threadsAreBooting.Load() { - threadsReadyWG.Done() - threadsReadyWG.Wait() - } } // execute a hook before the script is executed @@ -148,5 +147,5 @@ func go_frankenphp_on_thread_shutdown(threadIndex C.uintptr_t) { if thread.onShutdown != nil { thread.onShutdown(thread) } - shutdownWG.Done() + thread.state.set(stateDone) } diff --git a/php_threads.go b/php_threads.go index 11826ba5a..9ef71fde8 100644 --- a/php_threads.go +++ b/php_threads.go @@ -5,73 +5,78 @@ import "C" import ( "fmt" "sync" - "sync/atomic" ) var ( - phpThreads []*phpThread - terminationWG sync.WaitGroup - mainThreadShutdownWG sync.WaitGroup - threadsReadyWG sync.WaitGroup - shutdownWG sync.WaitGroup - done chan struct{} - threadsAreDone atomic.Bool - threadsAreBooting atomic.Bool + phpThreads []*phpThread + done chan struct{} + mainThreadState *threadStateHandler ) // reserve a fixed number of PHP threads on the go side func initPHPThreads(numThreads int) error { - threadsReadyWG = sync.WaitGroup{} - threadsAreDone.Store(false) done = make(chan struct{}) phpThreads = make([]*phpThread, numThreads) for i := 0; i < numThreads; i++ { - phpThreads[i] = &phpThread{threadIndex: i} + phpThreads[i] = &phpThread{ + threadIndex: i, + state: &threadStateHandler{currentState: stateBooting}, + } } if err := startMainThread(numThreads); err != nil { return err } // initialize all threads as inactive - threadsReadyWG.Add(len(phpThreads)) - shutdownWG.Add(len(phpThreads)) - threadsAreBooting.Store(true) + ready := sync.WaitGroup{} + ready.Add(len(phpThreads)) for _, thread := range phpThreads { - thread.setInactive() - if !C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) { - panic(fmt.Sprintf("unable to create thread %d", thread.threadIndex)) - } + go func() { + if !C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) { + panic(fmt.Sprintf("unable to create thread %d", thread.threadIndex)) + } + thread.state.waitFor(stateInactive) + ready.Done() + }() } - threadsReadyWG.Wait() - threadsAreBooting.Store(false) + + ready.Wait() return nil } func drainPHPThreads() { - threadsAreDone.Store(true) + doneWG := sync.WaitGroup{} + doneWG.Add(len(phpThreads)) + for _, thread := range phpThreads { + thread.state.set(stateShuttingDown) + } close(done) - shutdownWG.Wait() - mainThreadShutdownWG.Done() - terminationWG.Wait() + for _, thread := range phpThreads { + go func(thread *phpThread) { + thread.state.waitFor(stateDone) + doneWG.Done() + }(thread) + } + doneWG.Wait() + mainThreadState.set(stateShuttingDown) + mainThreadState.waitFor(stateDone) phpThreads = nil } func startMainThread(numThreads int) error { - threadsReadyWG.Add(1) - mainThreadShutdownWG.Add(1) - terminationWG.Add(1) + mainThreadState = &threadStateHandler{currentState: stateBooting} if C.frankenphp_new_main_thread(C.int(numThreads)) != 0 { return MainThreadCreationError } - threadsReadyWG.Wait() + mainThreadState.waitFor(stateActive) return nil } func getInactivePHPThread() *phpThread { for _, thread := range phpThreads { - if !thread.isActive.Load() { + if thread.state.is(stateInactive) { return thread } } @@ -80,11 +85,11 @@ func getInactivePHPThread() *phpThread { //export go_frankenphp_main_thread_is_ready func go_frankenphp_main_thread_is_ready() { - threadsReadyWG.Done() - mainThreadShutdownWG.Wait() + mainThreadState.set(stateActive) + mainThreadState.waitFor(stateShuttingDown) } //export go_frankenphp_shutdown_main_thread func go_frankenphp_shutdown_main_thread() { - terminationWG.Done() + mainThreadState.set(stateDone) } diff --git a/php_threads_test.go b/php_threads_test.go index b290e0c77..ab85c783f 100644 --- a/php_threads_test.go +++ b/php_threads_test.go @@ -17,7 +17,7 @@ func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { assert.Len(t, phpThreads, 1) assert.Equal(t, 0, phpThreads[0].threadIndex) - assert.False(t, phpThreads[0].isActive.Load()) + assert.True(t, phpThreads[0].state.is(stateInactive)) assert.Nil(t, phpThreads[0].worker) drainPHPThreads() @@ -76,7 +76,7 @@ func TestStartAndStop100PHPThreadsThatDoNothing(t *testing.T) { // This test calls sleep() 10.000 times for 1ms in 100 PHP threads. func TestSleep10000TimesIn100Threads(t *testing.T) { - logger = zap.NewNop() // the logger needs to not be nil + logger, _ = zap.NewDevelopment() // the logger needs to not be nil numThreads := 100 maxExecutions := 10000 executionMutex := sync.Mutex{} diff --git a/thread_state.go b/thread_state.go new file mode 100644 index 000000000..00540610b --- /dev/null +++ b/thread_state.go @@ -0,0 +1,103 @@ +package frankenphp + +import ( + "slices" + "sync" +) + +type threadState int + +const ( + stateBooting threadState = iota + stateInactive + stateActive + stateReady + stateWorking + stateShuttingDown + stateDone + stateRestarting +) + +type threadStateHandler struct { + currentState threadState + mu sync.RWMutex + subscribers []stateSubscriber +} + +type stateSubscriber struct { + states []threadState + ch chan struct{} + yieldFor *sync.WaitGroup +} + +func (h *threadStateHandler) is(state threadState) bool { + h.mu.RLock() + defer h.mu.RUnlock() + return h.currentState == state +} + +func (h *threadStateHandler) get() threadState { + h.mu.RLock() + defer h.mu.RUnlock() + return h.currentState +} + +func (h *threadStateHandler) set(nextState threadState) { + h.mu.Lock() + defer h.mu.Unlock() + if h.currentState == nextState { + // TODO: do we return here or inform subscribers? + // TODO: should we ever reach here? + return + } + + h.currentState = nextState + + if len(h.subscribers) == 0 { + return + } + + newSubscribers := []stateSubscriber{} + // TODO: do we even need multiple subscribers? + // notify subscribers to the state change + for _, sub := range h.subscribers { + if !slices.Contains(sub.states, nextState) { + newSubscribers = append(newSubscribers, sub) + continue + } + close(sub.ch) + // yield for the subscriber + if sub.yieldFor != nil { + defer sub.yieldFor.Wait() + } + } + h.subscribers = newSubscribers +} + +// wait for the thread to reach a certain state +func (h *threadStateHandler) waitFor(states ...threadState) { + h.waitForStates(states, nil) +} + +// make the thread yield to a WaitGroup once it reaches the state +// this makes sure all threads are in sync both ways +func (h *threadStateHandler) waitForAndYield(yieldFor *sync.WaitGroup, states ...threadState) { + h.waitForStates(states, yieldFor) +} + +// subscribe to a state and wait until the thread reaches it +func (h *threadStateHandler) waitForStates(states []threadState, yieldFor *sync.WaitGroup) { + h.mu.Lock() + if slices.Contains(states, h.currentState) { + h.mu.Unlock() + return + } + sub := stateSubscriber{ + states: states, + ch: make(chan struct{}), + yieldFor: yieldFor, + } + h.subscribers = append(h.subscribers, sub) + h.mu.Unlock() + <-sub.ch +} diff --git a/thread_state_test.go b/thread_state_test.go new file mode 100644 index 000000000..10d42635a --- /dev/null +++ b/thread_state_test.go @@ -0,0 +1,43 @@ +package frankenphp + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +func TestYieldToEachOtherViaThreadStates(t *testing.T) { + threadState := &threadStateHandler{currentState: stateBooting} + + go func() { + threadState.waitFor(stateInactive) + assert.True(t, threadState.is(stateInactive)) + threadState.set(stateActive) + }() + + threadState.set(stateInactive) + threadState.waitFor(stateActive) + assert.True(t, threadState.is(stateActive)) +} + +func TestYieldToAWaitGroupPassedByThreadState(t *testing.T) { + logger, _ = zap.NewDevelopment() + threadState := &threadStateHandler{currentState: stateBooting} + hasYielded := false + wg := sync.WaitGroup{} + wg.Add(1) + + go func() { + threadState.set(stateInactive) + threadState.waitForAndYield(&wg, stateActive) + hasYielded = true + wg.Done() + }() + + threadState.waitFor(stateInactive) + threadState.set(stateActive) + // the state should be 'ready' since we are also yielding to the WaitGroup + assert.True(t, hasYielded) +} diff --git a/worker.go b/worker.go index 1b245b2da..60722867c 100644 --- a/worker.go +++ b/worker.go @@ -8,7 +8,6 @@ import ( "net/http" "path/filepath" "sync" - "sync/atomic" "time" "github.com/dunglas/frankenphp/internal/watcher" @@ -26,12 +25,9 @@ type worker struct { } var ( - workers map[string]*worker - workersDone chan interface{} - watcherIsEnabled bool - workersAreRestarting atomic.Bool - workerRestartWG sync.WaitGroup - workerShutdownWG sync.WaitGroup + workers map[string]*worker + workersDone chan interface{} + watcherIsEnabled bool ) func initWorkers(opt []workerOpt) error { @@ -89,16 +85,25 @@ func drainWorkers() { } func restartWorkers() { - workerRestartWG.Add(1) - defer workerRestartWG.Done() + restart := sync.WaitGroup{} + restart.Add(1) + ready := sync.WaitGroup{} for _, worker := range workers { - workerShutdownWG.Add(worker.num) + worker.threadMutex.RLock() + ready.Add(len(worker.threads)) + for _, thread := range worker.threads { + thread.state.set(stateRestarting) + go func(thread *phpThread) { + thread.state.waitForAndYield(&restart, stateReady) + ready.Done() + }(thread) + } + worker.threadMutex.RUnlock() } - workersAreRestarting.Store(true) stopWorkers() - workerShutdownWG.Wait() + ready.Wait() workersDone = make(chan interface{}) - workersAreRestarting.Store(false) + restart.Done() } func getDirectoriesToWatch(workerOpts []workerOpt) []string { @@ -139,10 +144,9 @@ func (worker *worker) startNewThread() { } func (worker *worker) beforeScript(thread *phpThread) { - // if we are restarting due to file watching, wait for all workers to finish first - if workersAreRestarting.Load() { - workerShutdownWG.Done() - workerRestartWG.Wait() + // if we are restarting due to file watching, set the state back to ready + if thread.state.is(stateRestarting) { + thread.state.set(stateReady) } thread.backoff.reset() @@ -245,7 +249,7 @@ func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { } // execute opcache_reset if the restart was triggered by the watcher - if watcherIsEnabled && workersAreRestarting.Load() && !executePHPFunction("opcache_reset") { + if watcherIsEnabled && thread.state.is(stateRestarting) && !executePHPFunction("opcache_reset") { logger.Error("failed to call opcache_reset") } From 01ed92bc3becc127639827e5de702fc5051263db Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 17 Nov 2024 22:58:31 +0100 Subject: [PATCH 032/190] Removes unnecessary method. --- thread_state.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/thread_state.go b/thread_state.go index 00540610b..a66947254 100644 --- a/thread_state.go +++ b/thread_state.go @@ -36,12 +36,6 @@ func (h *threadStateHandler) is(state threadState) bool { return h.currentState == state } -func (h *threadStateHandler) get() threadState { - h.mu.RLock() - defer h.mu.RUnlock() - return h.currentState -} - func (h *threadStateHandler) set(nextState threadState) { h.mu.Lock() defer h.mu.Unlock() From 790cccc1641e555ca4a97d787243cb1a6ab1d8fe Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 17 Nov 2024 23:15:31 +0100 Subject: [PATCH 033/190] Updates comment. --- thread_state_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/thread_state_test.go b/thread_state_test.go index 10d42635a..d9ff5fcdb 100644 --- a/thread_state_test.go +++ b/thread_state_test.go @@ -38,6 +38,6 @@ func TestYieldToAWaitGroupPassedByThreadState(t *testing.T) { threadState.waitFor(stateInactive) threadState.set(stateActive) - // the state should be 'ready' since we are also yielding to the WaitGroup + // 'set' should have yielded to the wait group assert.True(t, hasYielded) } From 0dd26051493dffbdddd6455f5c21f7109f5a12b1 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 18 Nov 2024 09:29:17 +0100 Subject: [PATCH 034/190] Removes unnecessary booleans. --- php_thread.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/php_thread.go b/php_thread.go index bd260f6c7..1692c6d3c 100644 --- a/php_thread.go +++ b/php_thread.go @@ -5,7 +5,6 @@ import "C" import ( "net/http" "runtime" - "sync/atomic" "unsafe" ) @@ -21,10 +20,6 @@ type phpThread struct { scriptName string // the index in the phpThreads slice threadIndex int - // whether the thread has work assigned to it - isActive atomic.Bool - // whether the thread is ready for work - isReady atomic.Bool // right before the first work iteration onStartup func(*phpThread) // the actual work iteration (done in a loop) @@ -33,8 +28,6 @@ type phpThread struct { afterScriptExecution func(*phpThread, int) // after the thread is done onShutdown func(*phpThread) - // chan to signal the thread to stop the current work iteration - done chan struct{} // exponential backoff for worker failures backoff *exponentialBackoff // known $_SERVER key names From 60a66b4128820f99f2e967e2189cc539ca6476d1 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 24 Nov 2024 15:27:37 +0100 Subject: [PATCH 035/190] test --- thread_state.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/thread_state.go b/thread_state.go index a66947254..5a79cd23f 100644 --- a/thread_state.go +++ b/thread_state.go @@ -24,6 +24,13 @@ type threadStateHandler struct { subscribers []stateSubscriber } +type threadStateMachine interface { + onStartup(*phpThread) + beforeScriptExecution(*phpThread) + afterScriptExecution(*phpThread, int) + onShutdown(*phpThread) +} + type stateSubscriber struct { states []threadState ch chan struct{} From 4719fa8ea15a41b461e7fde99a13870c8d56c14d Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 25 Nov 2024 22:09:35 +0100 Subject: [PATCH 036/190] First state machine steps. --- php_thread.go | 41 +++++----- thread_state.go | 14 ++-- worker.go | 67 +--------------- worker_state_machine.go | 166 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 93 deletions(-) create mode 100644 worker_state_machine.go diff --git a/php_thread.go b/php_thread.go index 1692c6d3c..f0e25eb33 100644 --- a/php_thread.go +++ b/php_thread.go @@ -34,6 +34,7 @@ type phpThread struct { knownVariableKeys map[string]*C.zend_string // the state handler state *threadStateHandler + stateMachine *workerStateMachine } func (thread *phpThread) getActiveRequest() *http.Request { @@ -89,34 +90,38 @@ func (thread *phpThread) pinCString(s string) *C.char { //export go_frankenphp_on_thread_startup func go_frankenphp_on_thread_startup(threadIndex C.uintptr_t) { - phpThreads[threadIndex].setInactive() + phpThreads[threadIndex].state.set(stateInactive) } //export go_frankenphp_before_script_execution func go_frankenphp_before_script_execution(threadIndex C.uintptr_t) *C.char { thread := phpThreads[threadIndex] + thread.state.set(stateReady) // if the state is inactive, wait for it to be active - if thread.state.is(stateInactive) { - thread.state.waitFor(stateActive, stateShuttingDown) - } + //if thread.state.is(stateInactive) { + // thread.state.waitFor(stateActive, stateShuttingDown) + //} // returning nil signals the thread to stop - if thread.state.is(stateShuttingDown) { - return nil - } + //if thread.state.is(stateShuttingDown) { + // return nil + //} // if the thread is not ready yet, set it up - if !thread.state.is(stateReady) { - thread.state.set(stateReady) - if thread.onStartup != nil { - thread.onStartup(thread) - } - } + //if !thread.state.is(stateReady) { + // thread.state.set(stateReady) + // if thread.onStartup != nil { + // thread.onStartup(thread) + // } + //} // execute a hook before the script is executed - thread.beforeScriptExecution(thread) + //thread.beforeScriptExecution(thread) + if thread.stateMachine.done { + return nil + } // return the name of the PHP script that should be executed return thread.pinCString(thread.scriptName) } @@ -127,18 +132,10 @@ func go_frankenphp_after_script_execution(threadIndex C.uintptr_t, exitStatus C. if exitStatus < 0 { panic(ScriptExecutionError) } - if thread.afterScriptExecution != nil { - thread.afterScriptExecution(thread, int(exitStatus)) - } thread.Unpin() } //export go_frankenphp_on_thread_shutdown func go_frankenphp_on_thread_shutdown(threadIndex C.uintptr_t) { - thread := phpThreads[threadIndex] - thread.Unpin() - if thread.onShutdown != nil { - thread.onShutdown(thread) - } thread.state.set(stateDone) } diff --git a/thread_state.go b/thread_state.go index 5a79cd23f..ea1ba833b 100644 --- a/thread_state.go +++ b/thread_state.go @@ -12,7 +12,7 @@ const ( stateInactive stateActive stateReady - stateWorking + stateBusy stateShuttingDown stateDone stateRestarting @@ -25,10 +25,8 @@ type threadStateHandler struct { } type threadStateMachine interface { - onStartup(*phpThread) - beforeScriptExecution(*phpThread) - afterScriptExecution(*phpThread, int) - onShutdown(*phpThread) + handleState(state threadState) + isDone() bool } type stateSubscriber struct { @@ -43,6 +41,12 @@ func (h *threadStateHandler) is(state threadState) bool { return h.currentState == state } +func (h *threadStateHandler) get(state threadState) threadState { + h.mu.RLock() + defer h.mu.RUnlock() + return h.currentState +} + func (h *threadStateHandler) set(nextState threadState) { h.mu.Lock() defer h.mu.Unlock() diff --git a/worker.go b/worker.go index b5b7195ca..fe6d0031f 100644 --- a/worker.go +++ b/worker.go @@ -24,6 +24,7 @@ type worker struct { threadMutex sync.RWMutex } + var ( workers map[string]*worker workersDone chan interface{} @@ -143,72 +144,6 @@ func (worker *worker) startNewThread() { ) } -func (worker *worker) beforeScript(thread *phpThread) { - // if we are restarting due to file watching, set the state back to ready - if thread.state.is(stateRestarting) { - thread.state.set(stateReady) - } - - thread.backoff.reset() - metrics.StartWorker(worker.fileName) - - // Create a dummy request to set up the worker - r, err := http.NewRequest(http.MethodGet, filepath.Base(worker.fileName), nil) - if err != nil { - panic(err) - } - - r, err = NewRequestWithContext( - r, - WithRequestDocumentRoot(filepath.Dir(worker.fileName), false), - WithRequestPreparedEnv(worker.env), - ) - if err != nil { - panic(err) - } - - if err := updateServerContext(thread, r, true, false); err != nil { - panic(err) - } - - thread.mainRequest = r - if c := logger.Check(zapcore.DebugLevel, "starting"); c != nil { - c.Write(zap.String("worker", worker.fileName), zap.Int("thread", thread.threadIndex)) - } -} - -func (worker *worker) afterScript(thread *phpThread, exitStatus int) { - fc := thread.mainRequest.Context().Value(contextKey).(*FrankenPHPContext) - fc.exitStatus = exitStatus - - defer func() { - maybeCloseContext(fc) - thread.mainRequest = nil - }() - - // on exit status 0 we just run the worker script again - if fc.exitStatus == 0 { - // TODO: make the max restart configurable - metrics.StopWorker(worker.fileName, StopReasonRestart) - - if c := logger.Check(zapcore.DebugLevel, "restarting"); c != nil { - c.Write(zap.String("worker", worker.fileName)) - } - return - } - - // on exit status 1 we apply an exponential backoff when restarting - metrics.StopWorker(worker.fileName, StopReasonCrash) - thread.backoff.trigger(func(failureCount int) { - // if we end up here, the worker has not been up for backoff*2 - // this is probably due to a syntax error or another fatal error - if !watcherIsEnabled { - panic(fmt.Errorf("workers %q: too many consecutive failures", worker.fileName)) - } - logger.Warn("many consecutive worker failures", zap.String("worker", worker.fileName), zap.Int("failures", failureCount)) - }) -} - func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { metrics.StartWorkerRequest(fc.scriptFilename) diff --git a/worker_state_machine.go b/worker_state_machine.go new file mode 100644 index 000000000..33a2fcf52 --- /dev/null +++ b/worker_state_machine.go @@ -0,0 +1,166 @@ +package frankenphp + +import ( + "net/http" + "sync" + "strconv" + + "golang.uber.org/zap" +) + +type workerStateMachine struct { + state *threadStateHandler + thread *phpThread + worker *worker + isDone bool +} + + +func (w *workerStateMachine) isDone() threadState { + return w.isDone +} + +func (w *workerStateMachine) handleState(nextState threadState) { + previousState := w.state.get() + + switch previousState { + case stateBooting: + switch nextState { + case stateInactive: + w.state.set(stateInactive) + // waiting for external signal to start + w.state.waitFor(stateReady, stateShuttingDown) + return + } + case stateInactive: + switch nextState { + case stateReady: + w.state.set(stateReady) + beforeScript(w.thread) + return + case stateShuttingDown: + w.shutdown() + return + } + case stateReady: + switch nextState { + case stateBusy: + w.state.set(stateBusy) + return + case stateShuttingDown: + w.shutdown() + return + } + case stateBusy: + afterScript(w.thread, w.worker) + switch nextState { + case stateReady: + w.state.set(stateReady) + beforeScript(w.thread, w.worker) + return + case stateShuttingDown: + w.shutdown() + return + case stateRestarting: + w.state.set(stateRestarting) + return + } + case stateShuttingDown: + switch nextState { + case stateDone: + w.thread.Unpin() + w.state.set(stateDone) + return + case stateRestarting: + w.state.set(stateRestarting) + return + } + case stateDone: + panic("Worker is done") + case stateRestarting: + switch nextState { + case stateReady: + // wait for external ready signal + w.state.waitFor(stateReady) + return + case stateShuttingDown: + w.shutdown() + return + } + } + + panic("Invalid state transition from", zap.Int("from", int(previousState)), zap.Int("to", int(nextState))) +} + +func (w *workerStateMachine) shutdown() { + w.thread.scriptName = "" + workerStateMachine.done = true + w.thread.state.set(stateShuttingDown) +} + +func beforeScript(thread *phpThread, worker *worker) { + thread.worker = worker + // if we are restarting due to file watching, set the state back to ready + if thread.state.is(stateRestarting) { + thread.state.set(stateReady) + } + + thread.backoff.reset() + metrics.StartWorker(worker.fileName) + + // Create a dummy request to set up the worker + r, err := http.NewRequest(http.MethodGet, filepath.Base(worker.fileName), nil) + if err != nil { + panic(err) + } + + r, err = NewRequestWithContext( + r, + WithRequestDocumentRoot(filepath.Dir(worker.fileName), false), + WithRequestPreparedEnv(worker.env), + ) + if err != nil { + panic(err) + } + + if err := updateServerContext(thread, r, true, false); err != nil { + panic(err) + } + + thread.mainRequest = r + if c := logger.Check(zapcore.DebugLevel, "starting"); c != nil { + c.Write(zap.String("worker", worker.fileName), zap.Int("thread", thread.threadIndex)) + } +} + +func (worker *worker) afterScript(thread *phpThread, exitStatus int) { + fc := thread.mainRequest.Context().Value(contextKey).(*FrankenPHPContext) + fc.exitStatus = exitStatus + + defer func() { + maybeCloseContext(fc) + thread.mainRequest = nil + }() + + // on exit status 0 we just run the worker script again + if fc.exitStatus == 0 { + // TODO: make the max restart configurable + metrics.StopWorker(worker.fileName, StopReasonRestart) + + if c := logger.Check(zapcore.DebugLevel, "restarting"); c != nil { + c.Write(zap.String("worker", worker.fileName)) + } + return + } + + // on exit status 1 we apply an exponential backoff when restarting + metrics.StopWorker(worker.fileName, StopReasonCrash) + thread.backoff.trigger(func(failureCount int) { + // if we end up here, the worker has not been up for backoff*2 + // this is probably due to a syntax error or another fatal error + if !watcherIsEnabled { + panic(fmt.Errorf("workers %q: too many consecutive failures", worker.fileName)) + } + logger.Warn("many consecutive worker failures", zap.String("worker", worker.fileName), zap.Int("failures", failureCount)) + }) +} From f72e8cbb8ac7320b489da5196818f73c1f6abb89 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 6 Dec 2024 19:46:54 +0100 Subject: [PATCH 037/190] Splits threads. --- frankenphp.go | 21 ----- php_regular_thread.go | 128 +++++++++++++++++++++++++ php_thread.go | 53 ++++------- php_worker_thread.go | 204 ++++++++++++++++++++++++++++++++++++++++ thread_state.go | 4 - worker.go | 45 +-------- worker_state_machine.go | 166 -------------------------------- 7 files changed, 350 insertions(+), 271 deletions(-) create mode 100644 php_regular_thread.go create mode 100644 php_worker_thread.go delete mode 100644 worker_state_machine.go diff --git a/frankenphp.go b/frankenphp.go index 7b7f61b6a..ca3e79874 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -477,28 +477,7 @@ func ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) error } func handleRequest(thread *phpThread) { - select { - case <-done: - // no script should be executed if the server is shutting down - thread.scriptName = "" - return - case r := <-requestChan: - thread.mainRequest = r - fc := r.Context().Value(contextKey).(*FrankenPHPContext) - - if err := updateServerContext(thread, r, true, false); err != nil { - rejectRequest(fc.responseWriter, err.Error()) - afterRequest(thread, 0) - thread.Unpin() - // no script should be executed if the request was rejected - thread.scriptName = "" - return - } - - // set the scriptName that should be executed - thread.scriptName = fc.scriptFilename - } } func afterRequest(thread *phpThread, exitStatus int) { diff --git a/php_regular_thread.go b/php_regular_thread.go new file mode 100644 index 000000000..bc11447a1 --- /dev/null +++ b/php_regular_thread.go @@ -0,0 +1,128 @@ +package frankenphp + +import ( + "net/http" + "sync" + "strconv" + + "go.uber.org/zap" +) + +type phpRegularThread struct { + state *threadStateHandler + thread *phpThread + worker *worker + isDone bool + pinner *runtime.Pinner + getActiveRequest *http.Request + knownVariableKeys map[string]*C.zend_string +} + +// this is done once +func (thread *phpWorkerThread) onStartup(){ + // do nothing +} + +func (thread *phpWorkerThread) pinner() *runtime.Pinner { + return thread.pinner +} + +func (thread *phpWorkerThread) getActiveRequest() *http.Request { + return thread.activeRequest +} + +// return the name of the script or an empty string if no script should be executed +func (thread *phpWorkerThread) beforeScriptExecution() string { + currentState := w.state.get() + switch currentState { + case stateInactive: + thread.state.waitFor(stateActive, stateShuttingDown) + return thread.beforeScriptExecution() + case stateShuttingDown: + return "" + case stateReady, stateActive: + return waitForScriptExecution(thread) + } +} + +// return true if the worker should continue to run +func (thread *phpWorkerThread) afterScriptExecution() bool { + tearDownWorkerScript(thread, thread.worker) + currentState := w.state.get() + switch currentState { + case stateDrain: + thread.requestChan = make(chan *http.Request) + return true + case stateShuttingDown: + return false + } + return true +} + +func (thread *phpWorkerThread) onShutdown(){ + state.set(stateDone) +} + +func waitForScriptExecution(thread *phpThread) string { + select { + case <-done: + // no script should be executed if the server is shutting down + thread.scriptName = "" + return + + case r := <-requestChan: + thread.mainRequest = r + fc := r.Context().Value(contextKey).(*FrankenPHPContext) + + if err := updateServerContext(thread, r, true, false); err != nil { + rejectRequest(fc.responseWriter, err.Error()) + afterRequest(thread, 0) + thread.Unpin() + // no script should be executed if the request was rejected + return "" + } + + // set the scriptName that should be executed + return fc.scriptFilename + } +} + +func tearDownWorkerScript(thread *phpThread, exitStatus int) { + fc := thread.mainRequest.Context().Value(contextKey).(*FrankenPHPContext) + fc.exitStatus = exitStatus + + defer func() { + maybeCloseContext(fc) + thread.mainRequest = nil + }() + + // on exit status 0 we just run the worker script again + worker := thread.worker + if fc.exitStatus == 0 { + // TODO: make the max restart configurable + metrics.StopWorker(worker.fileName, StopReasonRestart) + + if c := logger.Check(zapcore.DebugLevel, "restarting"); c != nil { + c.Write(zap.String("worker", worker.fileName)) + } + return + } + + // on exit status 1 we apply an exponential backoff when restarting + metrics.StopWorker(worker.fileName, StopReasonCrash) + thread.backoff.trigger(func(failureCount int) { + // if we end up here, the worker has not been up for backoff*2 + // this is probably due to a syntax error or another fatal error + if !watcherIsEnabled { + panic(fmt.Errorf("workers %q: too many consecutive failures", worker.fileName)) + } + logger.Warn("many consecutive worker failures", zap.String("worker", worker.fileName), zap.Int("failures", failureCount)) + }) +} + +func (thread *phpWorkerThread) getKnownVariableKeys() map[string]*C.zend_string{ + return thread.knownVariableKeys +} +func (thread *phpWorkerThread) setKnownVariableKeys(map[string]*C.zend_string){ + thread.knownVariableKeys = knownVariableKeys +} \ No newline at end of file diff --git a/php_thread.go b/php_thread.go index f0e25eb33..bac7707c7 100644 --- a/php_thread.go +++ b/php_thread.go @@ -8,6 +8,17 @@ import ( "unsafe" ) +type phpThread interface { + onStartup() + beforeScriptExecution() string + afterScriptExecution(exitStatus int) bool + onShutdown() + pinner() *runtime.Pinner + getActiveRequest() *http.Request + getKnownVariableKeys() map[string]*C.zend_string + setKnownVariableKeys(map[string]*C.zend_string) +} + type phpThread struct { runtime.Pinner @@ -45,14 +56,6 @@ func (thread *phpThread) getActiveRequest() *http.Request { return thread.mainRequest } -func (thread *phpThread) setInactive() { - thread.scriptName = "" - // TODO: handle this in a state machine - if !thread.state.is(stateShuttingDown) { - thread.state.set(stateInactive) - } -} - func (thread *phpThread) setActive( onStartup func(*phpThread), beforeScriptExecution func(*phpThread), @@ -90,40 +93,15 @@ func (thread *phpThread) pinCString(s string) *C.char { //export go_frankenphp_on_thread_startup func go_frankenphp_on_thread_startup(threadIndex C.uintptr_t) { - phpThreads[threadIndex].state.set(stateInactive) + phpThreads[threadIndex].stateMachine.onStartup() } //export go_frankenphp_before_script_execution func go_frankenphp_before_script_execution(threadIndex C.uintptr_t) *C.char { thread := phpThreads[threadIndex] - thread.state.set(stateReady) - - // if the state is inactive, wait for it to be active - //if thread.state.is(stateInactive) { - // thread.state.waitFor(stateActive, stateShuttingDown) - //} - - // returning nil signals the thread to stop - //if thread.state.is(stateShuttingDown) { - // return nil - //} - - // if the thread is not ready yet, set it up - //if !thread.state.is(stateReady) { - // thread.state.set(stateReady) - // if thread.onStartup != nil { - // thread.onStartup(thread) - // } - //} - - // execute a hook before the script is executed - //thread.beforeScriptExecution(thread) - - if thread.stateMachine.done { - return nil - } + scriptName := thread.stateMachine.beforeScriptExecution() // return the name of the PHP script that should be executed - return thread.pinCString(thread.scriptName) + return thread.pinCString(scriptName) } //export go_frankenphp_after_script_execution @@ -132,10 +110,11 @@ func go_frankenphp_after_script_execution(threadIndex C.uintptr_t, exitStatus C. if exitStatus < 0 { panic(ScriptExecutionError) } + thread.stateMachine.afterScriptExecution(int(exitStatus)) thread.Unpin() } //export go_frankenphp_on_thread_shutdown func go_frankenphp_on_thread_shutdown(threadIndex C.uintptr_t) { - thread.state.set(stateDone) + thread.stateMachine.onShutdown() } diff --git a/php_worker_thread.go b/php_worker_thread.go new file mode 100644 index 000000000..c9421151f --- /dev/null +++ b/php_worker_thread.go @@ -0,0 +1,204 @@ +package frankenphp + +import ( + "net/http" + "sync" + "strconv" + + "go.uber.org/zap" +) + +type phpWorkerThread struct { + state *threadStateHandler + thread *phpThread + worker *worker + isDone bool + pinner *runtime.Pinner + mainRequest *http.Request + workerRequest *http.Request + knownVariableKeys map[string]*C.zend_string +} + +// this is done once +func (thread *phpWorkerThread) onStartup(){ + thread.requestChan = make(chan *http.Request) + thread.backoff = newExponentialBackoff() + thread.worker.threadMutex.Lock() + thread.worker.threads = append(worker.threads, thread) + thread.worker.threadMutex.Unlock() +} + +func (thread *phpWorkerThread) pinner() *runtime.Pinner { + return thread.pinner +} + +func (thread *phpWorkerThread) getActiveRequest() *http.Request { + if thread.workerRequest != nil { + return thread.workerRequest + } + + return thread.mainRequest +} + +// return the name of the script or an empty string if no script should be executed +func (thread *phpWorkerThread) beforeScriptExecution() string { + currentState := w.state.get() + switch currentState { + case stateInactive: + thread.state.waitFor(stateActive, stateShuttingDown) + return thread.beforeScriptExecution() + case stateShuttingDown: + return "" + case stateRestarting: + thread.state.waitFor(stateReady, stateShuttingDown) + setUpWorkerScript(thread, thread.worker) + return thread.worker.fileName + case stateReady, stateActive: + setUpWorkerScript(w.thread, w.worker) + return thread.worker.fileName + } +} + +func (thread *phpWorkerThread) waitForWorkerRequest() bool { + + if c := logger.Check(zapcore.DebugLevel, "waiting for request"); c != nil { + c.Write(zap.String("worker", thread.worker.fileName)) + } + + if !thread.state.is(stateReady) { + metrics.ReadyWorker(w.worker.fileName) + thread.state.set(stateReady) + } + + var r *http.Request + select { + case <-workersDone: + if c := logger.Check(zapcore.DebugLevel, "shutting down"); c != nil { + c.Write(zap.String("worker", thread.worker.fileName)) + } + + // execute opcache_reset if the restart was triggered by the watcher + if watcherIsEnabled && thread.state.is(stateRestarting) { + C.frankenphp_reset_opcache() + } + + return false + case r = <-thread.requestChan: + case r = <-thread.worker.requestChan: + } + + thread.workerRequest = r + + if c := logger.Check(zapcore.DebugLevel, "request handling started"); c != nil { + c.Write(zap.String("worker", thread.worker.fileName), zap.String("url", r.RequestURI)) + } + + if err := updateServerContext(thread, r, false, true); err != nil { + // Unexpected error + if c := logger.Check(zapcore.DebugLevel, "unexpected error"); c != nil { + c.Write(zap.String("worker", thread.worker.fileName), zap.String("url", r.RequestURI), zap.Error(err)) + } + fc := r.Context().Value(contextKey).(*FrankenPHPContext) + rejectRequest(fc.responseWriter, err.Error()) + maybeCloseContext(fc) + thread.workerRequest = nil + thread.Unpin() + + return go_frankenphp_worker_handle_request_start(threadIndex) + } + return true +} + +// return true if the worker should continue to run +func (thread *phpWorkerThread) afterScriptExecution() bool { + tearDownWorkerScript(thread, thread.worker) + currentState := w.state.get() + switch currentState { + case stateDrain: + thread.requestChan = make(chan *http.Request) + return true + } + case stateShuttingDown: + return false + } + return true +} + +func (thread *phpWorkerThread) onShutdown(){ + state.set(stateDone) +} + +func setUpWorkerScript(thread *phpThread, worker *worker) { + thread.worker = worker + // if we are restarting due to file watching, set the state back to ready + if thread.state.is(stateRestarting) { + thread.state.set(stateReady) + } + + thread.backoff.reset() + metrics.StartWorker(worker.fileName) + + // Create a dummy request to set up the worker + r, err := http.NewRequest(http.MethodGet, filepath.Base(worker.fileName), nil) + if err != nil { + panic(err) + } + + r, err = NewRequestWithContext( + r, + WithRequestDocumentRoot(filepath.Dir(worker.fileName), false), + WithRequestPreparedEnv(worker.env), + ) + if err != nil { + panic(err) + } + + if err := updateServerContext(thread, r, true, false); err != nil { + panic(err) + } + + thread.mainRequest = r + if c := logger.Check(zapcore.DebugLevel, "starting"); c != nil { + c.Write(zap.String("worker", worker.fileName), zap.Int("thread", thread.threadIndex)) + } +} + +func tearDownWorkerScript(thread *phpThread, exitStatus int) { + fc := thread.mainRequest.Context().Value(contextKey).(*FrankenPHPContext) + fc.exitStatus = exitStatus + + defer func() { + maybeCloseContext(fc) + thread.mainRequest = nil + }() + + // on exit status 0 we just run the worker script again + worker := thread.worker + if fc.exitStatus == 0 { + // TODO: make the max restart configurable + metrics.StopWorker(worker.fileName, StopReasonRestart) + + if c := logger.Check(zapcore.DebugLevel, "restarting"); c != nil { + c.Write(zap.String("worker", worker.fileName)) + } + return + } + + // on exit status 1 we apply an exponential backoff when restarting + metrics.StopWorker(worker.fileName, StopReasonCrash) + thread.backoff.trigger(func(failureCount int) { + // if we end up here, the worker has not been up for backoff*2 + // this is probably due to a syntax error or another fatal error + if !watcherIsEnabled { + panic(fmt.Errorf("workers %q: too many consecutive failures", worker.fileName)) + } + logger.Warn("many consecutive worker failures", zap.String("worker", worker.fileName), zap.Int("failures", failureCount)) + }) +} + +func (thread *phpWorkerThread) getKnownVariableKeys() map[string]*C.zend_string{ + return thread.knownVariableKeys +} +func (thread *phpWorkerThread) setKnownVariableKeys(map[string]*C.zend_string){ + thread.knownVariableKeys = knownVariableKeys +} \ No newline at end of file diff --git a/thread_state.go b/thread_state.go index ea1ba833b..f26f7d653 100644 --- a/thread_state.go +++ b/thread_state.go @@ -24,10 +24,6 @@ type threadStateHandler struct { subscribers []stateSubscriber } -type threadStateMachine interface { - handleState(state threadState) - isDone() bool -} type stateSubscriber struct { states []threadState diff --git a/worker.go b/worker.go index fe6d0031f..35c862298 100644 --- a/worker.go +++ b/worker.go @@ -170,49 +170,8 @@ func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { //export go_frankenphp_worker_handle_request_start func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { - thread := phpThreads[threadIndex] - - if c := logger.Check(zapcore.DebugLevel, "waiting for request"); c != nil { - c.Write(zap.String("worker", thread.worker.fileName)) - } - - var r *http.Request - select { - case <-workersDone: - if c := logger.Check(zapcore.DebugLevel, "shutting down"); c != nil { - c.Write(zap.String("worker", thread.worker.fileName)) - } - - // execute opcache_reset if the restart was triggered by the watcher - if watcherIsEnabled && thread.state.is(stateRestarting) { - C.frankenphp_reset_opcache() - } - - return C.bool(false) - case r = <-thread.requestChan: - case r = <-thread.worker.requestChan: - } - - thread.workerRequest = r - - if c := logger.Check(zapcore.DebugLevel, "request handling started"); c != nil { - c.Write(zap.String("worker", thread.worker.fileName), zap.String("url", r.RequestURI)) - } - - if err := updateServerContext(thread, r, false, true); err != nil { - // Unexpected error - if c := logger.Check(zapcore.DebugLevel, "unexpected error"); c != nil { - c.Write(zap.String("worker", thread.worker.fileName), zap.String("url", r.RequestURI), zap.Error(err)) - } - fc := r.Context().Value(contextKey).(*FrankenPHPContext) - rejectRequest(fc.responseWriter, err.Error()) - maybeCloseContext(fc) - thread.workerRequest = nil - thread.Unpin() - - return go_frankenphp_worker_handle_request_start(threadIndex) - } - return C.bool(true) + thread := phpWorkerThread(phpThreads[threadIndex]) + return C.bool(thread.stateMachine.waitForWorkerRequest(stateReady)) } //export go_frankenphp_finish_worker_request diff --git a/worker_state_machine.go b/worker_state_machine.go deleted file mode 100644 index 33a2fcf52..000000000 --- a/worker_state_machine.go +++ /dev/null @@ -1,166 +0,0 @@ -package frankenphp - -import ( - "net/http" - "sync" - "strconv" - - "golang.uber.org/zap" -) - -type workerStateMachine struct { - state *threadStateHandler - thread *phpThread - worker *worker - isDone bool -} - - -func (w *workerStateMachine) isDone() threadState { - return w.isDone -} - -func (w *workerStateMachine) handleState(nextState threadState) { - previousState := w.state.get() - - switch previousState { - case stateBooting: - switch nextState { - case stateInactive: - w.state.set(stateInactive) - // waiting for external signal to start - w.state.waitFor(stateReady, stateShuttingDown) - return - } - case stateInactive: - switch nextState { - case stateReady: - w.state.set(stateReady) - beforeScript(w.thread) - return - case stateShuttingDown: - w.shutdown() - return - } - case stateReady: - switch nextState { - case stateBusy: - w.state.set(stateBusy) - return - case stateShuttingDown: - w.shutdown() - return - } - case stateBusy: - afterScript(w.thread, w.worker) - switch nextState { - case stateReady: - w.state.set(stateReady) - beforeScript(w.thread, w.worker) - return - case stateShuttingDown: - w.shutdown() - return - case stateRestarting: - w.state.set(stateRestarting) - return - } - case stateShuttingDown: - switch nextState { - case stateDone: - w.thread.Unpin() - w.state.set(stateDone) - return - case stateRestarting: - w.state.set(stateRestarting) - return - } - case stateDone: - panic("Worker is done") - case stateRestarting: - switch nextState { - case stateReady: - // wait for external ready signal - w.state.waitFor(stateReady) - return - case stateShuttingDown: - w.shutdown() - return - } - } - - panic("Invalid state transition from", zap.Int("from", int(previousState)), zap.Int("to", int(nextState))) -} - -func (w *workerStateMachine) shutdown() { - w.thread.scriptName = "" - workerStateMachine.done = true - w.thread.state.set(stateShuttingDown) -} - -func beforeScript(thread *phpThread, worker *worker) { - thread.worker = worker - // if we are restarting due to file watching, set the state back to ready - if thread.state.is(stateRestarting) { - thread.state.set(stateReady) - } - - thread.backoff.reset() - metrics.StartWorker(worker.fileName) - - // Create a dummy request to set up the worker - r, err := http.NewRequest(http.MethodGet, filepath.Base(worker.fileName), nil) - if err != nil { - panic(err) - } - - r, err = NewRequestWithContext( - r, - WithRequestDocumentRoot(filepath.Dir(worker.fileName), false), - WithRequestPreparedEnv(worker.env), - ) - if err != nil { - panic(err) - } - - if err := updateServerContext(thread, r, true, false); err != nil { - panic(err) - } - - thread.mainRequest = r - if c := logger.Check(zapcore.DebugLevel, "starting"); c != nil { - c.Write(zap.String("worker", worker.fileName), zap.Int("thread", thread.threadIndex)) - } -} - -func (worker *worker) afterScript(thread *phpThread, exitStatus int) { - fc := thread.mainRequest.Context().Value(contextKey).(*FrankenPHPContext) - fc.exitStatus = exitStatus - - defer func() { - maybeCloseContext(fc) - thread.mainRequest = nil - }() - - // on exit status 0 we just run the worker script again - if fc.exitStatus == 0 { - // TODO: make the max restart configurable - metrics.StopWorker(worker.fileName, StopReasonRestart) - - if c := logger.Check(zapcore.DebugLevel, "restarting"); c != nil { - c.Write(zap.String("worker", worker.fileName)) - } - return - } - - // on exit status 1 we apply an exponential backoff when restarting - metrics.StopWorker(worker.fileName, StopReasonCrash) - thread.backoff.trigger(func(failureCount int) { - // if we end up here, the worker has not been up for backoff*2 - // this is probably due to a syntax error or another fatal error - if !watcherIsEnabled { - panic(fmt.Errorf("workers %q: too many consecutive failures", worker.fileName)) - } - logger.Warn("many consecutive worker failures", zap.String("worker", worker.fileName), zap.Int("failures", failureCount)) - }) -} From d20e70677bc27021974f16b07235d5da0f0a0ee3 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 6 Dec 2024 23:07:19 +0100 Subject: [PATCH 038/190] Minimal working implementation with broken tests. --- frankenphp.go | 12 +-- php_inactive_thread.go | 49 ++++++++++++ php_regular_thread.go | 106 +++++++++--------------- php_thread.go | 83 +++++-------------- php_threads.go | 9 ++- php_worker_thread.go | 178 +++++++++++++++++++++++++---------------- thread_state.go | 24 ++++-- thread_state_test.go | 4 +- worker.go | 74 ++--------------- 9 files changed, 251 insertions(+), 288 deletions(-) create mode 100644 php_inactive_thread.go diff --git a/frankenphp.go b/frankenphp.go index ca3e79874..10210b2f9 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -335,7 +335,8 @@ func Init(options ...Option) error { } for i := 0; i < totalThreadCount-workerThreadCount; i++ { - getInactivePHPThread().setActive(nil, handleRequest, afterRequest, nil) + thread := getInactivePHPThread() + convertToRegularThread(thread) } if err := initWorkers(opt.workers); err != nil { @@ -480,13 +481,6 @@ func handleRequest(thread *phpThread) { } -func afterRequest(thread *phpThread, exitStatus int) { - fc := thread.mainRequest.Context().Value(contextKey).(*FrankenPHPContext) - fc.exitStatus = exitStatus - maybeCloseContext(fc) - thread.mainRequest = nil -} - func maybeCloseContext(fc *FrankenPHPContext) { fc.closed.Do(func() { close(fc.done) @@ -538,7 +532,7 @@ func go_apache_request_headers(threadIndex C.uintptr_t, hasActiveRequest bool) ( if !hasActiveRequest { // worker mode, not handling a request - mfc := thread.mainRequest.Context().Value(contextKey).(*FrankenPHPContext) + mfc := thread.getActiveRequest().Context().Value(contextKey).(*FrankenPHPContext) if c := mfc.logger.Check(zapcore.DebugLevel, "apache_request_headers() called in non-HTTP context"); c != nil { c.Write(zap.String("worker", mfc.scriptFilename)) diff --git a/php_inactive_thread.go b/php_inactive_thread.go new file mode 100644 index 000000000..ff816f7b9 --- /dev/null +++ b/php_inactive_thread.go @@ -0,0 +1,49 @@ +package frankenphp + +import ( + "net/http" + "strconv" +) + +type phpInactiveThread struct { + state *stateHandler +} + +func convertToInactiveThread(thread *phpThread) { + thread.handler = &phpInactiveThread{state: thread.state} +} + +func (t *phpInactiveThread) isReadyToTransition() bool { + return true +} + +// this is done once +func (thread *phpInactiveThread) onStartup(){ + // do nothing +} + +func (thread *phpInactiveThread) getActiveRequest() *http.Request { + panic("idle threads have no requests") +} + +func (thread *phpInactiveThread) beforeScriptExecution() string { + thread.state.set(stateInactive) + return "" +} + +func (thread *phpInactiveThread) afterScriptExecution(exitStatus int) bool { + // wait for external signal to start or shut down + thread.state.waitFor(stateActive, stateShuttingDown) + switch thread.state.get() { + case stateActive: + return true + case stateShuttingDown: + return false + } + panic("unexpected state: "+strconv.Itoa(int(thread.state.get()))) +} + +func (thread *phpInactiveThread) onShutdown(){ + thread.state.set(stateDone) +} + diff --git a/php_regular_thread.go b/php_regular_thread.go index bc11447a1..001f5304f 100644 --- a/php_regular_thread.go +++ b/php_regular_thread.go @@ -1,39 +1,42 @@ package frankenphp +// #include "frankenphp.h" +import "C" import ( "net/http" - "sync" - "strconv" - - "go.uber.org/zap" ) type phpRegularThread struct { - state *threadStateHandler + state *stateHandler thread *phpThread - worker *worker - isDone bool - pinner *runtime.Pinner - getActiveRequest *http.Request - knownVariableKeys map[string]*C.zend_string + activeRequest *http.Request } -// this is done once -func (thread *phpWorkerThread) onStartup(){ - // do nothing +func convertToRegularThread(thread *phpThread) { + thread.handler = &phpRegularThread{ + thread: thread, + state: thread.state, + } + thread.handler.onStartup() + thread.state.set(stateActive) } -func (thread *phpWorkerThread) pinner() *runtime.Pinner { - return thread.pinner +func (t *phpRegularThread) isReadyToTransition() bool { + return false +} + +// this is done once +func (thread *phpRegularThread) onStartup(){ + // do nothing } -func (thread *phpWorkerThread) getActiveRequest() *http.Request { +func (thread *phpRegularThread) getActiveRequest() *http.Request { return thread.activeRequest } // return the name of the script or an empty string if no script should be executed -func (thread *phpWorkerThread) beforeScriptExecution() string { - currentState := w.state.get() +func (thread *phpRegularThread) beforeScriptExecution() string { + currentState := thread.state.get() switch currentState { case stateInactive: thread.state.waitFor(stateActive, stateShuttingDown) @@ -43,15 +46,16 @@ func (thread *phpWorkerThread) beforeScriptExecution() string { case stateReady, stateActive: return waitForScriptExecution(thread) } + return "" } // return true if the worker should continue to run -func (thread *phpWorkerThread) afterScriptExecution() bool { - tearDownWorkerScript(thread, thread.worker) - currentState := w.state.get() +func (thread *phpRegularThread) afterScriptExecution(exitStatus int) bool { + thread.afterRequest(exitStatus) + + currentState := thread.state.get() switch currentState { case stateDrain: - thread.requestChan = make(chan *http.Request) return true case stateShuttingDown: return false @@ -59,25 +63,24 @@ func (thread *phpWorkerThread) afterScriptExecution() bool { return true } -func (thread *phpWorkerThread) onShutdown(){ - state.set(stateDone) +func (thread *phpRegularThread) onShutdown(){ + thread.state.set(stateDone) } -func waitForScriptExecution(thread *phpThread) string { +func waitForScriptExecution(thread *phpRegularThread) string { select { case <-done: // no script should be executed if the server is shutting down - thread.scriptName = "" - return + return "" case r := <-requestChan: - thread.mainRequest = r + thread.activeRequest = r fc := r.Context().Value(contextKey).(*FrankenPHPContext) - if err := updateServerContext(thread, r, true, false); err != nil { + if err := updateServerContext(thread.thread, r, true, false); err != nil { rejectRequest(fc.responseWriter, err.Error()) - afterRequest(thread, 0) - thread.Unpin() + thread.afterRequest(0) + thread.thread.Unpin() // no script should be executed if the request was rejected return "" } @@ -87,42 +90,9 @@ func waitForScriptExecution(thread *phpThread) string { } } -func tearDownWorkerScript(thread *phpThread, exitStatus int) { - fc := thread.mainRequest.Context().Value(contextKey).(*FrankenPHPContext) +func (thread *phpRegularThread) afterRequest(exitStatus int) { + fc := thread.activeRequest.Context().Value(contextKey).(*FrankenPHPContext) fc.exitStatus = exitStatus - - defer func() { - maybeCloseContext(fc) - thread.mainRequest = nil - }() - - // on exit status 0 we just run the worker script again - worker := thread.worker - if fc.exitStatus == 0 { - // TODO: make the max restart configurable - metrics.StopWorker(worker.fileName, StopReasonRestart) - - if c := logger.Check(zapcore.DebugLevel, "restarting"); c != nil { - c.Write(zap.String("worker", worker.fileName)) - } - return - } - - // on exit status 1 we apply an exponential backoff when restarting - metrics.StopWorker(worker.fileName, StopReasonCrash) - thread.backoff.trigger(func(failureCount int) { - // if we end up here, the worker has not been up for backoff*2 - // this is probably due to a syntax error or another fatal error - if !watcherIsEnabled { - panic(fmt.Errorf("workers %q: too many consecutive failures", worker.fileName)) - } - logger.Warn("many consecutive worker failures", zap.String("worker", worker.fileName), zap.Int("failures", failureCount)) - }) -} - -func (thread *phpWorkerThread) getKnownVariableKeys() map[string]*C.zend_string{ - return thread.knownVariableKeys + maybeCloseContext(fc) + thread.activeRequest = nil } -func (thread *phpWorkerThread) setKnownVariableKeys(map[string]*C.zend_string){ - thread.knownVariableKeys = knownVariableKeys -} \ No newline at end of file diff --git a/php_thread.go b/php_thread.go index bac7707c7..4d1fd5b3a 100644 --- a/php_thread.go +++ b/php_thread.go @@ -8,73 +8,34 @@ import ( "unsafe" ) -type phpThread interface { - onStartup() - beforeScriptExecution() string - afterScriptExecution(exitStatus int) bool - onShutdown() - pinner() *runtime.Pinner - getActiveRequest() *http.Request - getKnownVariableKeys() map[string]*C.zend_string - setKnownVariableKeys(map[string]*C.zend_string) -} - +// representation of the actual underlying PHP thread +// identified by the index in the phpThreads slice type phpThread struct { runtime.Pinner - mainRequest *http.Request - workerRequest *http.Request - requestChan chan *http.Request - worker *worker - - // the script name for the current request - scriptName string - // the index in the phpThreads slice threadIndex int - // right before the first work iteration - onStartup func(*phpThread) - // the actual work iteration (done in a loop) - beforeScriptExecution func(*phpThread) - // after the work iteration is done - afterScriptExecution func(*phpThread, int) - // after the thread is done - onShutdown func(*phpThread) - // exponential backoff for worker failures - backoff *exponentialBackoff - // known $_SERVER key names knownVariableKeys map[string]*C.zend_string - // the state handler - state *threadStateHandler - stateMachine *workerStateMachine + requestChan chan *http.Request + handler threadHandler + state *stateHandler } -func (thread *phpThread) getActiveRequest() *http.Request { - if thread.workerRequest != nil { - return thread.workerRequest - } +// interface that defines how the callbacks from the C thread should be handled +type threadHandler interface { + onStartup() + beforeScriptExecution() string + afterScriptExecution(exitStatus int) bool + onShutdown() + getActiveRequest() *http.Request + isReadyToTransition() bool +} - return thread.mainRequest +func (thread *phpThread) getActiveRequest() *http.Request { + return thread.handler.getActiveRequest() } -func (thread *phpThread) setActive( - onStartup func(*phpThread), - beforeScriptExecution func(*phpThread), - afterScriptExecution func(*phpThread, int), - onShutdown func(*phpThread), -) { - // to avoid race conditions, the thread sets its own hooks on startup - thread.onStartup = func(thread *phpThread) { - if thread.onShutdown != nil { - thread.onShutdown(thread) - } - thread.onStartup = onStartup - thread.beforeScriptExecution = beforeScriptExecution - thread.onShutdown = onShutdown - thread.afterScriptExecution = afterScriptExecution - if thread.onStartup != nil { - thread.onStartup(thread) - } - } +func (thread *phpThread) setHandler(handler threadHandler) { + thread.handler = handler thread.state.set(stateActive) } @@ -93,13 +54,13 @@ func (thread *phpThread) pinCString(s string) *C.char { //export go_frankenphp_on_thread_startup func go_frankenphp_on_thread_startup(threadIndex C.uintptr_t) { - phpThreads[threadIndex].stateMachine.onStartup() + phpThreads[threadIndex].handler.onStartup() } //export go_frankenphp_before_script_execution func go_frankenphp_before_script_execution(threadIndex C.uintptr_t) *C.char { thread := phpThreads[threadIndex] - scriptName := thread.stateMachine.beforeScriptExecution() + scriptName := thread.handler.beforeScriptExecution() // return the name of the PHP script that should be executed return thread.pinCString(scriptName) } @@ -110,11 +71,11 @@ func go_frankenphp_after_script_execution(threadIndex C.uintptr_t, exitStatus C. if exitStatus < 0 { panic(ScriptExecutionError) } - thread.stateMachine.afterScriptExecution(int(exitStatus)) + thread.handler.afterScriptExecution(int(exitStatus)) thread.Unpin() } //export go_frankenphp_on_thread_shutdown func go_frankenphp_on_thread_shutdown(threadIndex C.uintptr_t) { - thread.stateMachine.onShutdown() + phpThreads[threadIndex].handler.onShutdown() } diff --git a/php_threads.go b/php_threads.go index 9ef71fde8..67486a259 100644 --- a/php_threads.go +++ b/php_threads.go @@ -10,7 +10,7 @@ import ( var ( phpThreads []*phpThread done chan struct{} - mainThreadState *threadStateHandler + mainThreadState *stateHandler ) // reserve a fixed number of PHP threads on the go side @@ -20,8 +20,9 @@ func initPHPThreads(numThreads int) error { for i := 0; i < numThreads; i++ { phpThreads[i] = &phpThread{ threadIndex: i, - state: &threadStateHandler{currentState: stateBooting}, + state: newStateHandler(), } + convertToInactiveThread(phpThreads[i]) } if err := startMainThread(numThreads); err != nil { return err @@ -66,7 +67,7 @@ func drainPHPThreads() { } func startMainThread(numThreads int) error { - mainThreadState = &threadStateHandler{currentState: stateBooting} + mainThreadState = newStateHandler() if C.frankenphp_new_main_thread(C.int(numThreads)) != 0 { return MainThreadCreationError } @@ -76,7 +77,7 @@ func startMainThread(numThreads int) error { func getInactivePHPThread() *phpThread { for _, thread := range phpThreads { - if thread.state.is(stateInactive) { + if thread.handler.isReadyToTransition() { return thread } } diff --git a/php_worker_thread.go b/php_worker_thread.go index c9421151f..0a87f3de0 100644 --- a/php_worker_thread.go +++ b/php_worker_thread.go @@ -1,141 +1,147 @@ package frankenphp +// #include "frankenphp.h" +import "C" import ( "net/http" - "sync" - "strconv" + "path/filepath" + "fmt" "go.uber.org/zap" + "go.uber.org/zap/zapcore" ) type phpWorkerThread struct { - state *threadStateHandler + state *stateHandler thread *phpThread worker *worker - isDone bool - pinner *runtime.Pinner mainRequest *http.Request workerRequest *http.Request - knownVariableKeys map[string]*C.zend_string + backoff *exponentialBackoff } -// this is done once -func (thread *phpWorkerThread) onStartup(){ - thread.requestChan = make(chan *http.Request) - thread.backoff = newExponentialBackoff() - thread.worker.threadMutex.Lock() - thread.worker.threads = append(worker.threads, thread) - thread.worker.threadMutex.Unlock() +func convertToWorkerThread(thread *phpThread, worker *worker) { + thread.handler = &phpWorkerThread{ + state: thread.state, + thread: thread, + worker: worker, + } + thread.handler.onStartup() + thread.state.set(stateActive) } -func (thread *phpWorkerThread) pinner() *runtime.Pinner { - return thread.pinner +// this is done once +func (handler *phpWorkerThread) onStartup(){ + handler.thread.requestChan = make(chan *http.Request) + handler.backoff = newExponentialBackoff() + handler.worker.threadMutex.Lock() + handler.worker.threads = append(handler.worker.threads, handler.thread) + handler.worker.threadMutex.Unlock() } -func (thread *phpWorkerThread) getActiveRequest() *http.Request { - if thread.workerRequest != nil { - return thread.workerRequest +func (handler *phpWorkerThread) getActiveRequest() *http.Request { + if handler.workerRequest != nil { + return handler.workerRequest } - return thread.mainRequest + return handler.mainRequest +} + +func (t *phpWorkerThread) isReadyToTransition() bool { + return false } // return the name of the script or an empty string if no script should be executed -func (thread *phpWorkerThread) beforeScriptExecution() string { - currentState := w.state.get() +func (handler *phpWorkerThread) beforeScriptExecution() string { + currentState := handler.state.get() switch currentState { case stateInactive: - thread.state.waitFor(stateActive, stateShuttingDown) - return thread.beforeScriptExecution() + handler.state.waitFor(stateActive, stateShuttingDown) + return handler.beforeScriptExecution() case stateShuttingDown: return "" case stateRestarting: - thread.state.waitFor(stateReady, stateShuttingDown) - setUpWorkerScript(thread, thread.worker) - return thread.worker.fileName + handler.state.set(stateYielding) + handler.state.waitFor(stateReady, stateShuttingDown) + return handler.beforeScriptExecution() case stateReady, stateActive: - setUpWorkerScript(w.thread, w.worker) - return thread.worker.fileName + setUpWorkerScript(handler, handler.worker) + return handler.worker.fileName } + // TODO: panic? + return "" } -func (thread *phpWorkerThread) waitForWorkerRequest() bool { +func (handler *phpWorkerThread) waitForWorkerRequest() bool { if c := logger.Check(zapcore.DebugLevel, "waiting for request"); c != nil { - c.Write(zap.String("worker", thread.worker.fileName)) + c.Write(zap.String("worker", handler.worker.fileName)) } - if !thread.state.is(stateReady) { - metrics.ReadyWorker(w.worker.fileName) - thread.state.set(stateReady) + if !handler.state.is(stateReady) { + metrics.ReadyWorker(handler.worker.fileName) + handler.state.set(stateReady) } var r *http.Request select { case <-workersDone: if c := logger.Check(zapcore.DebugLevel, "shutting down"); c != nil { - c.Write(zap.String("worker", thread.worker.fileName)) + c.Write(zap.String("worker", handler.worker.fileName)) } // execute opcache_reset if the restart was triggered by the watcher - if watcherIsEnabled && thread.state.is(stateRestarting) { + if watcherIsEnabled && handler.state.is(stateRestarting) { C.frankenphp_reset_opcache() } return false - case r = <-thread.requestChan: - case r = <-thread.worker.requestChan: + case r = <-handler.thread.requestChan: + case r = <-handler.worker.requestChan: } - thread.workerRequest = r + handler.workerRequest = r if c := logger.Check(zapcore.DebugLevel, "request handling started"); c != nil { - c.Write(zap.String("worker", thread.worker.fileName), zap.String("url", r.RequestURI)) + c.Write(zap.String("worker", handler.worker.fileName), zap.String("url", r.RequestURI)) } - if err := updateServerContext(thread, r, false, true); err != nil { + if err := updateServerContext(handler.thread, r, false, true); err != nil { // Unexpected error if c := logger.Check(zapcore.DebugLevel, "unexpected error"); c != nil { - c.Write(zap.String("worker", thread.worker.fileName), zap.String("url", r.RequestURI), zap.Error(err)) + c.Write(zap.String("worker", handler.worker.fileName), zap.String("url", r.RequestURI), zap.Error(err)) } fc := r.Context().Value(contextKey).(*FrankenPHPContext) rejectRequest(fc.responseWriter, err.Error()) maybeCloseContext(fc) - thread.workerRequest = nil - thread.Unpin() + handler.workerRequest = nil + handler.thread.Unpin() - return go_frankenphp_worker_handle_request_start(threadIndex) + return handler.waitForWorkerRequest() } return true } // return true if the worker should continue to run -func (thread *phpWorkerThread) afterScriptExecution() bool { - tearDownWorkerScript(thread, thread.worker) - currentState := w.state.get() +func (handler *phpWorkerThread) afterScriptExecution(exitStatus int) bool { + tearDownWorkerScript(handler, exitStatus) + currentState := handler.state.get() switch currentState { case stateDrain: - thread.requestChan = make(chan *http.Request) + handler.thread.requestChan = make(chan *http.Request) return true - } case stateShuttingDown: return false } return true } -func (thread *phpWorkerThread) onShutdown(){ - state.set(stateDone) +func (handler *phpWorkerThread) onShutdown(){ + handler.state.set(stateDone) } -func setUpWorkerScript(thread *phpThread, worker *worker) { - thread.worker = worker - // if we are restarting due to file watching, set the state back to ready - if thread.state.is(stateRestarting) { - thread.state.set(stateReady) - } - - thread.backoff.reset() +func setUpWorkerScript(handler *phpWorkerThread, worker *worker) { + handler.backoff.reset() metrics.StartWorker(worker.fileName) // Create a dummy request to set up the worker @@ -153,27 +159,28 @@ func setUpWorkerScript(thread *phpThread, worker *worker) { panic(err) } - if err := updateServerContext(thread, r, true, false); err != nil { + if err := updateServerContext(handler.thread, r, true, false); err != nil { panic(err) } - thread.mainRequest = r + handler.mainRequest = r if c := logger.Check(zapcore.DebugLevel, "starting"); c != nil { - c.Write(zap.String("worker", worker.fileName), zap.Int("thread", thread.threadIndex)) + c.Write(zap.String("worker", worker.fileName), zap.Int("thread", handler.thread.threadIndex)) } } -func tearDownWorkerScript(thread *phpThread, exitStatus int) { - fc := thread.mainRequest.Context().Value(contextKey).(*FrankenPHPContext) +func tearDownWorkerScript(handler *phpWorkerThread, exitStatus int) { + fc := handler.mainRequest.Context().Value(contextKey).(*FrankenPHPContext) fc.exitStatus = exitStatus defer func() { maybeCloseContext(fc) - thread.mainRequest = nil + handler.mainRequest = nil + handler.workerRequest = nil }() // on exit status 0 we just run the worker script again - worker := thread.worker + worker := handler.worker if fc.exitStatus == 0 { // TODO: make the max restart configurable metrics.StopWorker(worker.fileName, StopReasonRestart) @@ -184,9 +191,11 @@ func tearDownWorkerScript(thread *phpThread, exitStatus int) { return } + // TODO: error status + // on exit status 1 we apply an exponential backoff when restarting metrics.StopWorker(worker.fileName, StopReasonCrash) - thread.backoff.trigger(func(failureCount int) { + handler.backoff.trigger(func(failureCount int) { // if we end up here, the worker has not been up for backoff*2 // this is probably due to a syntax error or another fatal error if !watcherIsEnabled { @@ -196,9 +205,36 @@ func tearDownWorkerScript(thread *phpThread, exitStatus int) { }) } -func (thread *phpWorkerThread) getKnownVariableKeys() map[string]*C.zend_string{ - return thread.knownVariableKeys +//export go_frankenphp_worker_handle_request_start +func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { + handler := phpThreads[threadIndex].handler.(*phpWorkerThread) + return C.bool(handler.waitForWorkerRequest()) } -func (thread *phpWorkerThread) setKnownVariableKeys(map[string]*C.zend_string){ - thread.knownVariableKeys = knownVariableKeys + +//export go_frankenphp_finish_worker_request +func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t) { + thread := phpThreads[threadIndex] + r := thread.getActiveRequest() + fc := r.Context().Value(contextKey).(*FrankenPHPContext) + + maybeCloseContext(fc) + thread.handler.(*phpWorkerThread).workerRequest = nil + thread.Unpin() + + if c := fc.logger.Check(zapcore.DebugLevel, "request handling finished"); c != nil { + c.Write(zap.String("worker", fc.scriptFilename), zap.String("url", r.RequestURI)) + } +} + +// when frankenphp_finish_request() is directly called from PHP +// +//export go_frankenphp_finish_php_request +func go_frankenphp_finish_php_request(threadIndex C.uintptr_t) { + r := phpThreads[threadIndex].getActiveRequest() + fc := r.Context().Value(contextKey).(*FrankenPHPContext) + maybeCloseContext(fc) + + if c := fc.logger.Check(zapcore.DebugLevel, "request handling finished"); c != nil { + c.Write(zap.String("url", r.RequestURI)) + } } \ No newline at end of file diff --git a/thread_state.go b/thread_state.go index f26f7d653..c15cbb0a2 100644 --- a/thread_state.go +++ b/thread_state.go @@ -16,9 +16,11 @@ const ( stateShuttingDown stateDone stateRestarting + stateDrain + stateYielding ) -type threadStateHandler struct { +type stateHandler struct { currentState threadState mu sync.RWMutex subscribers []stateSubscriber @@ -31,19 +33,27 @@ type stateSubscriber struct { yieldFor *sync.WaitGroup } -func (h *threadStateHandler) is(state threadState) bool { +func newStateHandler() *stateHandler { + return &stateHandler{ + currentState: stateBooting, + subscribers: []stateSubscriber{}, + mu: sync.RWMutex{}, + } +} + +func (h *stateHandler) is(state threadState) bool { h.mu.RLock() defer h.mu.RUnlock() return h.currentState == state } -func (h *threadStateHandler) get(state threadState) threadState { +func (h *stateHandler) get() threadState { h.mu.RLock() defer h.mu.RUnlock() return h.currentState } -func (h *threadStateHandler) set(nextState threadState) { +func (h *stateHandler) set(nextState threadState) { h.mu.Lock() defer h.mu.Unlock() if h.currentState == nextState { @@ -76,18 +86,18 @@ func (h *threadStateHandler) set(nextState threadState) { } // wait for the thread to reach a certain state -func (h *threadStateHandler) waitFor(states ...threadState) { +func (h *stateHandler) waitFor(states ...threadState) { h.waitForStates(states, nil) } // make the thread yield to a WaitGroup once it reaches the state // this makes sure all threads are in sync both ways -func (h *threadStateHandler) waitForAndYield(yieldFor *sync.WaitGroup, states ...threadState) { +func (h *stateHandler) waitForAndYield(yieldFor *sync.WaitGroup, states ...threadState) { h.waitForStates(states, yieldFor) } // subscribe to a state and wait until the thread reaches it -func (h *threadStateHandler) waitForStates(states []threadState, yieldFor *sync.WaitGroup) { +func (h *stateHandler) waitForStates(states []threadState, yieldFor *sync.WaitGroup) { h.mu.Lock() if slices.Contains(states, h.currentState) { h.mu.Unlock() diff --git a/thread_state_test.go b/thread_state_test.go index d9ff5fcdb..693e1e3ba 100644 --- a/thread_state_test.go +++ b/thread_state_test.go @@ -9,7 +9,7 @@ import ( ) func TestYieldToEachOtherViaThreadStates(t *testing.T) { - threadState := &threadStateHandler{currentState: stateBooting} + threadState := &stateHandler{currentState: stateBooting} go func() { threadState.waitFor(stateInactive) @@ -24,7 +24,7 @@ func TestYieldToEachOtherViaThreadStates(t *testing.T) { func TestYieldToAWaitGroupPassedByThreadState(t *testing.T) { logger, _ = zap.NewDevelopment() - threadState := &threadStateHandler{currentState: stateBooting} + threadState := &stateHandler{currentState: stateBooting} hasYielded := false wg := sync.WaitGroup{} wg.Add(1) diff --git a/worker.go b/worker.go index 35c862298..b7757acb3 100644 --- a/worker.go +++ b/worker.go @@ -6,13 +6,10 @@ import ( "fmt" "github.com/dunglas/frankenphp/internal/fastabs" "net/http" - "path/filepath" "sync" "time" "github.com/dunglas/frankenphp/internal/watcher" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" ) type worker struct { @@ -86,8 +83,6 @@ func drainWorkers() { } func restartWorkers() { - restart := sync.WaitGroup{} - restart.Add(1) ready := sync.WaitGroup{} for _, worker := range workers { worker.threadMutex.RLock() @@ -95,7 +90,7 @@ func restartWorkers() { for _, thread := range worker.threads { thread.state.set(stateRestarting) go func(thread *phpThread) { - thread.state.waitForAndYield(&restart, stateReady) + thread.state.waitFor(stateYielding) ready.Done() }(thread) } @@ -103,8 +98,12 @@ func restartWorkers() { } stopWorkers() ready.Wait() + for _, worker := range workers { + for _, thread := range worker.threads { + thread.state.set(stateReady) + } + } workersDone = make(chan interface{}) - restart.Done() } func getDirectoriesToWatch(workerOpts []workerOpt) []string { @@ -116,32 +115,8 @@ func getDirectoriesToWatch(workerOpts []workerOpt) []string { } func (worker *worker) startNewThread() { - getInactivePHPThread().setActive( - // onStartup => right before the thread is ready - func(thread *phpThread) { - thread.worker = worker - thread.scriptName = worker.fileName - thread.requestChan = make(chan *http.Request) - thread.backoff = newExponentialBackoff() - worker.threadMutex.Lock() - worker.threads = append(worker.threads, thread) - worker.threadMutex.Unlock() - metrics.ReadyWorker(worker.fileName) - }, - // beforeScriptExecution => set up the worker with a fake request - func(thread *phpThread) { - worker.beforeScript(thread) - }, - // afterScriptExecution => tear down the worker - func(thread *phpThread, exitStatus int) { - worker.afterScript(thread, exitStatus) - }, - // onShutdown => after the thread is done - func(thread *phpThread) { - thread.worker = nil - thread.backoff = nil - }, - ) + thread := getInactivePHPThread() + convertToWorkerThread(thread, worker) } func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { @@ -168,36 +143,3 @@ func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) } -//export go_frankenphp_worker_handle_request_start -func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { - thread := phpWorkerThread(phpThreads[threadIndex]) - return C.bool(thread.stateMachine.waitForWorkerRequest(stateReady)) -} - -//export go_frankenphp_finish_worker_request -func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t) { - thread := phpThreads[threadIndex] - r := thread.getActiveRequest() - fc := r.Context().Value(contextKey).(*FrankenPHPContext) - - maybeCloseContext(fc) - thread.workerRequest = nil - thread.Unpin() - - if c := fc.logger.Check(zapcore.DebugLevel, "request handling finished"); c != nil { - c.Write(zap.String("worker", fc.scriptFilename), zap.String("url", r.RequestURI)) - } -} - -// when frankenphp_finish_request() is directly called from PHP -// -//export go_frankenphp_finish_php_request -func go_frankenphp_finish_php_request(threadIndex C.uintptr_t) { - r := phpThreads[threadIndex].getActiveRequest() - fc := r.Context().Value(contextKey).(*FrankenPHPContext) - maybeCloseContext(fc) - - if c := fc.logger.Check(zapcore.DebugLevel, "request handling finished"); c != nil { - c.Write(zap.String("url", r.RequestURI)) - } -} From 6747d15616118a03e705146112ba49ceae62c529 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 6 Dec 2024 23:54:23 +0100 Subject: [PATCH 039/190] Fixes tests. --- frankenphp.c | 14 ++-- php_regular_thread.go | 23 +++++-- php_thread.go | 6 +- php_thread_test.go | 28 -------- php_threads.go | 4 +- php_threads_test.go | 155 ------------------------------------------ php_worker_thread.go | 30 +++++--- worker.go | 3 + 8 files changed, 55 insertions(+), 208 deletions(-) delete mode 100644 php_thread_test.go diff --git a/frankenphp.c b/frankenphp.c index c033366b6..f2c50dd71 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -834,15 +834,15 @@ static void *php_thread(void *arg) { while (true) { char *scriptName = go_frankenphp_before_script_execution(thread_index); - // if the script name is NULL, the thread should exit - if (scriptName == NULL) { - break; - } - + int exit_status = 0; // if the script name is not empty, execute the PHP script if (strlen(scriptName) != 0) { - int exit_status = frankenphp_execute_script(scriptName); - go_frankenphp_after_script_execution(thread_index, exit_status); + exit_status = frankenphp_execute_script(scriptName); + } + + // if go signals to stop, break the loop + if(!go_frankenphp_after_script_execution(thread_index, exit_status)){ + break; } } diff --git a/php_regular_thread.go b/php_regular_thread.go index 001f5304f..e97583585 100644 --- a/php_regular_thread.go +++ b/php_regular_thread.go @@ -4,6 +4,7 @@ package frankenphp import "C" import ( "net/http" + "go.uber.org/zap" ) type phpRegularThread struct { @@ -39,11 +40,14 @@ func (thread *phpRegularThread) beforeScriptExecution() string { currentState := thread.state.get() switch currentState { case stateInactive: + logger.Info("waiting for activation", zap.Int("threadIndex", thread.thread.threadIndex),zap.Int("state", int(thread.state.get()))) thread.state.waitFor(stateActive, stateShuttingDown) + logger.Info("activated", zap.Int("threadIndex", thread.thread.threadIndex),zap.Int("state", int(thread.state.get()))) return thread.beforeScriptExecution() case stateShuttingDown: return "" case stateReady, stateActive: + logger.Info("beforeScriptExecution", zap.Int("state", int(thread.state.get()))) return waitForScriptExecution(thread) } return "" @@ -67,20 +71,21 @@ func (thread *phpRegularThread) onShutdown(){ thread.state.set(stateDone) } -func waitForScriptExecution(thread *phpRegularThread) string { +func waitForScriptExecution(handler *phpRegularThread) string { select { - case <-done: + case <-handler.thread.drainChan: + logger.Info("drainChan", zap.Int("threadIndex", handler.thread.threadIndex)) // no script should be executed if the server is shutting down return "" case r := <-requestChan: - thread.activeRequest = r + handler.activeRequest = r fc := r.Context().Value(contextKey).(*FrankenPHPContext) - if err := updateServerContext(thread.thread, r, true, false); err != nil { + if err := updateServerContext(handler.thread, r, true, false); err != nil { rejectRequest(fc.responseWriter, err.Error()) - thread.afterRequest(0) - thread.thread.Unpin() + handler.afterRequest(0) + handler.thread.Unpin() // no script should be executed if the request was rejected return "" } @@ -91,6 +96,12 @@ func waitForScriptExecution(thread *phpRegularThread) string { } func (thread *phpRegularThread) afterRequest(exitStatus int) { + + // if the request is nil, no script was executed + if thread.activeRequest == nil { + return + } + fc := thread.activeRequest.Context().Value(contextKey).(*FrankenPHPContext) fc.exitStatus = exitStatus maybeCloseContext(fc) diff --git a/php_thread.go b/php_thread.go index 4d1fd5b3a..39f09fafe 100644 --- a/php_thread.go +++ b/php_thread.go @@ -16,6 +16,7 @@ type phpThread struct { threadIndex int knownVariableKeys map[string]*C.zend_string requestChan chan *http.Request + drainChan chan struct{} handler threadHandler state *stateHandler } @@ -66,13 +67,14 @@ func go_frankenphp_before_script_execution(threadIndex C.uintptr_t) *C.char { } //export go_frankenphp_after_script_execution -func go_frankenphp_after_script_execution(threadIndex C.uintptr_t, exitStatus C.int) { +func go_frankenphp_after_script_execution(threadIndex C.uintptr_t, exitStatus C.int) C.bool { thread := phpThreads[threadIndex] if exitStatus < 0 { panic(ScriptExecutionError) } - thread.handler.afterScriptExecution(int(exitStatus)) + shouldContinueExecution := thread.handler.afterScriptExecution(int(exitStatus)) thread.Unpin() + return C.bool(shouldContinueExecution) } //export go_frankenphp_on_thread_shutdown diff --git a/php_thread_test.go b/php_thread_test.go deleted file mode 100644 index eba873d5b..000000000 --- a/php_thread_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package frankenphp - -import ( - "net/http" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestMainRequestIsActiveRequest(t *testing.T) { - mainRequest := &http.Request{} - thread := phpThread{} - - thread.mainRequest = mainRequest - - assert.Equal(t, mainRequest, thread.getActiveRequest()) -} - -func TestWorkerRequestIsActiveRequest(t *testing.T) { - mainRequest := &http.Request{} - workerRequest := &http.Request{} - thread := phpThread{} - - thread.mainRequest = mainRequest - thread.workerRequest = workerRequest - - assert.Equal(t, workerRequest, thread.getActiveRequest()) -} diff --git a/php_threads.go b/php_threads.go index 67486a259..ecd69f7d5 100644 --- a/php_threads.go +++ b/php_threads.go @@ -20,7 +20,8 @@ func initPHPThreads(numThreads int) error { for i := 0; i < numThreads; i++ { phpThreads[i] = &phpThread{ threadIndex: i, - state: newStateHandler(), + drainChan: make(chan struct{}), + state: newStateHandler(), } convertToInactiveThread(phpThreads[i]) } @@ -52,6 +53,7 @@ func drainPHPThreads() { doneWG.Add(len(phpThreads)) for _, thread := range phpThreads { thread.state.set(stateShuttingDown) + close(thread.drainChan) } close(done) for _, thread := range phpThreads { diff --git a/php_threads_test.go b/php_threads_test.go index ab85c783f..74aa75145 100644 --- a/php_threads_test.go +++ b/php_threads_test.go @@ -1,10 +1,6 @@ package frankenphp import ( - "net/http" - "path/filepath" - "sync" - "sync/atomic" "testing" "github.com/stretchr/testify/assert" @@ -18,158 +14,7 @@ func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { assert.Len(t, phpThreads, 1) assert.Equal(t, 0, phpThreads[0].threadIndex) assert.True(t, phpThreads[0].state.is(stateInactive)) - assert.Nil(t, phpThreads[0].worker) drainPHPThreads() assert.Nil(t, phpThreads) } - -// We'll start 100 threads and check that their hooks work correctly -func TestStartAndStop100PHPThreadsThatDoNothing(t *testing.T) { - logger = zap.NewNop() // the logger needs to not be nil - numThreads := 100 - readyThreads := atomic.Uint64{} - finishedThreads := atomic.Uint64{} - workingThreads := atomic.Uint64{} - workWG := sync.WaitGroup{} - workWG.Add(numThreads) - - assert.NoError(t, initPHPThreads(numThreads)) - - for i := 0; i < numThreads; i++ { - newThread := getInactivePHPThread() - newThread.setActive( - // onStartup => before the thread is ready - func(thread *phpThread) { - if thread.threadIndex == newThread.threadIndex { - readyThreads.Add(1) - } - }, - // beforeScriptExecution => we stop here immediately - func(thread *phpThread) { - if thread.threadIndex == newThread.threadIndex { - workingThreads.Add(1) - } - workWG.Done() - newThread.setInactive() - }, - // afterScriptExecution => no script is executed, we shouldn't reach here - func(thread *phpThread, exitStatus int) { - panic("hook afterScriptExecution should not be called here") - }, - // onShutdown => after the thread is done - func(thread *phpThread) { - if thread.threadIndex == newThread.threadIndex { - finishedThreads.Add(1) - } - }, - ) - } - - workWG.Wait() - drainPHPThreads() - - assert.Equal(t, numThreads, int(readyThreads.Load())) - assert.Equal(t, numThreads, int(workingThreads.Load())) - assert.Equal(t, numThreads, int(finishedThreads.Load())) -} - -// This test calls sleep() 10.000 times for 1ms in 100 PHP threads. -func TestSleep10000TimesIn100Threads(t *testing.T) { - logger, _ = zap.NewDevelopment() // the logger needs to not be nil - numThreads := 100 - maxExecutions := 10000 - executionMutex := sync.Mutex{} - executionCount := 0 - scriptPath, _ := filepath.Abs("./testdata/sleep.php") - workWG := sync.WaitGroup{} - workWG.Add(maxExecutions) - - assert.NoError(t, initPHPThreads(numThreads)) - - for i := 0; i < numThreads; i++ { - getInactivePHPThread().setActive( - // onStartup => fake a request on startup (like a worker would do) - func(thread *phpThread) { - r, _ := http.NewRequest(http.MethodGet, "sleep.php", nil) - r, _ = NewRequestWithContext(r, WithRequestDocumentRoot("/", false)) - assert.NoError(t, updateServerContext(thread, r, true, false)) - thread.mainRequest = r - thread.scriptName = scriptPath - }, - // beforeScriptExecution => execute the sleep.php script until we reach maxExecutions - func(thread *phpThread) { - executionMutex.Lock() - if executionCount >= maxExecutions { - executionMutex.Unlock() - thread.setInactive() - return - } - executionCount++ - workWG.Done() - executionMutex.Unlock() - }, - // afterScriptExecution => check the exit status of the script - func(thread *phpThread, exitStatus int) { - if int(exitStatus) != 0 { - panic("script execution failed: " + scriptPath) - } - }, - // onShutdown => nothing to do here - nil, - ) - } - - workWG.Wait() - drainPHPThreads() - - assert.Equal(t, maxExecutions, executionCount) -} - -// TODO: Make this test more chaotic -func TestStart100ThreadsAndConvertThemToDifferentThreads10Times(t *testing.T) { - logger = zap.NewNop() // the logger needs to not be nil - numThreads := 100 - numConversions := 10 - startUpTypes := make([]atomic.Uint64, numConversions) - workTypes := make([]atomic.Uint64, numConversions) - shutdownTypes := make([]atomic.Uint64, numConversions) - workWG := sync.WaitGroup{} - - assert.NoError(t, initPHPThreads(numThreads)) - - for i := 0; i < numConversions; i++ { - workWG.Add(numThreads) - numberOfConversion := i - for j := 0; j < numThreads; j++ { - getInactivePHPThread().setActive( - // onStartup => before the thread is ready - func(thread *phpThread) { - startUpTypes[numberOfConversion].Add(1) - }, - // beforeScriptExecution => while the thread is running - func(thread *phpThread) { - workTypes[numberOfConversion].Add(1) - thread.setInactive() - workWG.Done() - }, - // afterScriptExecution => we don't execute a script - nil, - // onShutdown => after the thread is done - func(thread *phpThread) { - shutdownTypes[numberOfConversion].Add(1) - }, - ) - } - workWG.Wait() - } - - drainPHPThreads() - - // each type of thread needs to have started, worked and stopped the same amount of times - for i := 0; i < numConversions; i++ { - assert.Equal(t, numThreads, int(startUpTypes[i].Load())) - assert.Equal(t, numThreads, int(workTypes[i].Load())) - assert.Equal(t, numThreads, int(shutdownTypes[i].Load())) - } -} diff --git a/php_worker_thread.go b/php_worker_thread.go index 0a87f3de0..7226beba0 100644 --- a/php_worker_thread.go +++ b/php_worker_thread.go @@ -15,7 +15,7 @@ type phpWorkerThread struct { state *stateHandler thread *phpThread worker *worker - mainRequest *http.Request + fakeRequest *http.Request workerRequest *http.Request backoff *exponentialBackoff } @@ -44,7 +44,7 @@ func (handler *phpWorkerThread) getActiveRequest() *http.Request { return handler.workerRequest } - return handler.mainRequest + return handler.fakeRequest } func (t *phpWorkerThread) isReadyToTransition() bool { @@ -78,14 +78,14 @@ func (handler *phpWorkerThread) waitForWorkerRequest() bool { c.Write(zap.String("worker", handler.worker.fileName)) } - if !handler.state.is(stateReady) { + if handler.state.is(stateActive) { metrics.ReadyWorker(handler.worker.fileName) handler.state.set(stateReady) } var r *http.Request select { - case <-workersDone: + case <-handler.thread.drainChan: if c := logger.Check(zapcore.DebugLevel, "shutting down"); c != nil { c.Write(zap.String("worker", handler.worker.fileName)) } @@ -163,20 +163,32 @@ func setUpWorkerScript(handler *phpWorkerThread, worker *worker) { panic(err) } - handler.mainRequest = r + handler.fakeRequest = r if c := logger.Check(zapcore.DebugLevel, "starting"); c != nil { c.Write(zap.String("worker", worker.fileName), zap.Int("thread", handler.thread.threadIndex)) } } func tearDownWorkerScript(handler *phpWorkerThread, exitStatus int) { - fc := handler.mainRequest.Context().Value(contextKey).(*FrankenPHPContext) - fc.exitStatus = exitStatus - defer func() { + // if the fake request is nil, no script was executed + if handler.fakeRequest == nil { + return + } + + // if the worker request is not nil, the script might have crashed + // make sure to close the worker request context + if handler.workerRequest != nil { + fc := handler.workerRequest.Context().Value(contextKey).(*FrankenPHPContext) maybeCloseContext(fc) - handler.mainRequest = nil handler.workerRequest = nil + } + + fc := handler.fakeRequest.Context().Value(contextKey).(*FrankenPHPContext) + fc.exitStatus = exitStatus + + defer func() { + handler.fakeRequest = nil }() // on exit status 0 we just run the worker script again diff --git a/worker.go b/worker.go index b7757acb3..a533624d3 100644 --- a/worker.go +++ b/worker.go @@ -12,6 +12,7 @@ import ( "github.com/dunglas/frankenphp/internal/watcher" ) +// represents a worker script and can have many threads assigned to it type worker struct { fileName string num int @@ -89,6 +90,7 @@ func restartWorkers() { ready.Add(len(worker.threads)) for _, thread := range worker.threads { thread.state.set(stateRestarting) + close(thread.drainChan) go func(thread *phpThread) { thread.state.waitFor(stateYielding) ready.Done() @@ -100,6 +102,7 @@ func restartWorkers() { ready.Wait() for _, worker := range workers { for _, thread := range worker.threads { + thread.drainChan = make(chan struct{}) thread.state.set(stateReady) } } From 54dc2675baddd3f4e3e48cb99257d0006b918524 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 7 Dec 2024 10:58:51 +0100 Subject: [PATCH 040/190] Refactoring. --- frankenphp.c | 2 - inactive-thread.go | 47 ++++++++++ php_inactive_thread.go | 49 ---------- php_regular_thread.go | 109 ---------------------- php_thread.go | 10 +- php_threads.go | 6 +- regular-thread.go | 101 ++++++++++++++++++++ thread-state.go | 90 ++++++++++++++++++ thread-state_test.go | 22 +++++ thread_state.go | 114 ----------------------- thread_state_test.go | 43 --------- php_worker_thread.go => worker-thread.go | 46 ++++----- 12 files changed, 288 insertions(+), 351 deletions(-) create mode 100644 inactive-thread.go delete mode 100644 php_inactive_thread.go delete mode 100644 php_regular_thread.go create mode 100644 regular-thread.go create mode 100644 thread-state.go create mode 100644 thread-state_test.go delete mode 100644 thread_state.go delete mode 100644 thread_state_test.go rename php_worker_thread.go => worker-thread.go (85%) diff --git a/frankenphp.c b/frankenphp.c index f2c50dd71..40002a534 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -828,8 +828,6 @@ static void *php_thread(void *arg) { cfg_get_string("filter.default", &default_filter); should_filter_var = default_filter != NULL; - go_frankenphp_on_thread_startup(thread_index); - // perform work until go signals to stop while (true) { char *scriptName = go_frankenphp_before_script_execution(thread_index); diff --git a/inactive-thread.go b/inactive-thread.go new file mode 100644 index 000000000..cfb589706 --- /dev/null +++ b/inactive-thread.go @@ -0,0 +1,47 @@ +package frankenphp + +import ( + "net/http" + "strconv" +) + +// representation of a thread with no work assigned to it +// implements the threadHandler interface +type inactiveThread struct { + state *threadState +} + +func convertToInactiveThread(thread *phpThread) { + thread.handler = &inactiveThread{state: thread.state} +} + +func (t *inactiveThread) isReadyToTransition() bool { + return true +} + +func (thread *inactiveThread) getActiveRequest() *http.Request { + panic("idle threads have no requests") +} + +func (thread *inactiveThread) beforeScriptExecution() string { + // no script execution for inactive threads + return "" +} + +func (thread *inactiveThread) afterScriptExecution(exitStatus int) bool { + thread.state.set(stateInactive) + // wait for external signal to start or shut down + thread.state.waitFor(stateActive, stateShuttingDown) + switch thread.state.get() { + case stateActive: + return true + case stateShuttingDown: + return false + } + panic("unexpected state: "+strconv.Itoa(int(thread.state.get()))) +} + +func (thread *inactiveThread) onShutdown(){ + thread.state.set(stateDone) +} + diff --git a/php_inactive_thread.go b/php_inactive_thread.go deleted file mode 100644 index ff816f7b9..000000000 --- a/php_inactive_thread.go +++ /dev/null @@ -1,49 +0,0 @@ -package frankenphp - -import ( - "net/http" - "strconv" -) - -type phpInactiveThread struct { - state *stateHandler -} - -func convertToInactiveThread(thread *phpThread) { - thread.handler = &phpInactiveThread{state: thread.state} -} - -func (t *phpInactiveThread) isReadyToTransition() bool { - return true -} - -// this is done once -func (thread *phpInactiveThread) onStartup(){ - // do nothing -} - -func (thread *phpInactiveThread) getActiveRequest() *http.Request { - panic("idle threads have no requests") -} - -func (thread *phpInactiveThread) beforeScriptExecution() string { - thread.state.set(stateInactive) - return "" -} - -func (thread *phpInactiveThread) afterScriptExecution(exitStatus int) bool { - // wait for external signal to start or shut down - thread.state.waitFor(stateActive, stateShuttingDown) - switch thread.state.get() { - case stateActive: - return true - case stateShuttingDown: - return false - } - panic("unexpected state: "+strconv.Itoa(int(thread.state.get()))) -} - -func (thread *phpInactiveThread) onShutdown(){ - thread.state.set(stateDone) -} - diff --git a/php_regular_thread.go b/php_regular_thread.go deleted file mode 100644 index e97583585..000000000 --- a/php_regular_thread.go +++ /dev/null @@ -1,109 +0,0 @@ -package frankenphp - -// #include "frankenphp.h" -import "C" -import ( - "net/http" - "go.uber.org/zap" -) - -type phpRegularThread struct { - state *stateHandler - thread *phpThread - activeRequest *http.Request -} - -func convertToRegularThread(thread *phpThread) { - thread.handler = &phpRegularThread{ - thread: thread, - state: thread.state, - } - thread.handler.onStartup() - thread.state.set(stateActive) -} - -func (t *phpRegularThread) isReadyToTransition() bool { - return false -} - -// this is done once -func (thread *phpRegularThread) onStartup(){ - // do nothing -} - -func (thread *phpRegularThread) getActiveRequest() *http.Request { - return thread.activeRequest -} - -// return the name of the script or an empty string if no script should be executed -func (thread *phpRegularThread) beforeScriptExecution() string { - currentState := thread.state.get() - switch currentState { - case stateInactive: - logger.Info("waiting for activation", zap.Int("threadIndex", thread.thread.threadIndex),zap.Int("state", int(thread.state.get()))) - thread.state.waitFor(stateActive, stateShuttingDown) - logger.Info("activated", zap.Int("threadIndex", thread.thread.threadIndex),zap.Int("state", int(thread.state.get()))) - return thread.beforeScriptExecution() - case stateShuttingDown: - return "" - case stateReady, stateActive: - logger.Info("beforeScriptExecution", zap.Int("state", int(thread.state.get()))) - return waitForScriptExecution(thread) - } - return "" -} - -// return true if the worker should continue to run -func (thread *phpRegularThread) afterScriptExecution(exitStatus int) bool { - thread.afterRequest(exitStatus) - - currentState := thread.state.get() - switch currentState { - case stateDrain: - return true - case stateShuttingDown: - return false - } - return true -} - -func (thread *phpRegularThread) onShutdown(){ - thread.state.set(stateDone) -} - -func waitForScriptExecution(handler *phpRegularThread) string { - select { - case <-handler.thread.drainChan: - logger.Info("drainChan", zap.Int("threadIndex", handler.thread.threadIndex)) - // no script should be executed if the server is shutting down - return "" - - case r := <-requestChan: - handler.activeRequest = r - fc := r.Context().Value(contextKey).(*FrankenPHPContext) - - if err := updateServerContext(handler.thread, r, true, false); err != nil { - rejectRequest(fc.responseWriter, err.Error()) - handler.afterRequest(0) - handler.thread.Unpin() - // no script should be executed if the request was rejected - return "" - } - - // set the scriptName that should be executed - return fc.scriptFilename - } -} - -func (thread *phpRegularThread) afterRequest(exitStatus int) { - - // if the request is nil, no script was executed - if thread.activeRequest == nil { - return - } - - fc := thread.activeRequest.Context().Value(contextKey).(*FrankenPHPContext) - fc.exitStatus = exitStatus - maybeCloseContext(fc) - thread.activeRequest = nil -} diff --git a/php_thread.go b/php_thread.go index 39f09fafe..a16e1a573 100644 --- a/php_thread.go +++ b/php_thread.go @@ -18,12 +18,11 @@ type phpThread struct { requestChan chan *http.Request drainChan chan struct{} handler threadHandler - state *stateHandler + state *threadState } // interface that defines how the callbacks from the C thread should be handled type threadHandler interface { - onStartup() beforeScriptExecution() string afterScriptExecution(exitStatus int) bool onShutdown() @@ -53,11 +52,6 @@ func (thread *phpThread) pinCString(s string) *C.char { return thread.pinString(s + "\x00") } -//export go_frankenphp_on_thread_startup -func go_frankenphp_on_thread_startup(threadIndex C.uintptr_t) { - phpThreads[threadIndex].handler.onStartup() -} - //export go_frankenphp_before_script_execution func go_frankenphp_before_script_execution(threadIndex C.uintptr_t) *C.char { thread := phpThreads[threadIndex] @@ -79,5 +73,5 @@ func go_frankenphp_after_script_execution(threadIndex C.uintptr_t, exitStatus C. //export go_frankenphp_on_thread_shutdown func go_frankenphp_on_thread_shutdown(threadIndex C.uintptr_t) { - phpThreads[threadIndex].handler.onShutdown() + phpThreads[threadIndex].state.set(stateDone) } diff --git a/php_threads.go b/php_threads.go index ecd69f7d5..40f787621 100644 --- a/php_threads.go +++ b/php_threads.go @@ -10,7 +10,7 @@ import ( var ( phpThreads []*phpThread done chan struct{} - mainThreadState *stateHandler + mainThreadState *threadState ) // reserve a fixed number of PHP threads on the go side @@ -21,7 +21,7 @@ func initPHPThreads(numThreads int) error { phpThreads[i] = &phpThread{ threadIndex: i, drainChan: make(chan struct{}), - state: newStateHandler(), + state: newThreadState(), } convertToInactiveThread(phpThreads[i]) } @@ -69,7 +69,7 @@ func drainPHPThreads() { } func startMainThread(numThreads int) error { - mainThreadState = newStateHandler() + mainThreadState = newThreadState() if C.frankenphp_new_main_thread(C.int(numThreads)) != 0 { return MainThreadCreationError } diff --git a/regular-thread.go b/regular-thread.go new file mode 100644 index 000000000..371ae517d --- /dev/null +++ b/regular-thread.go @@ -0,0 +1,101 @@ +package frankenphp + +// #include "frankenphp.h" +import "C" +import ( + "net/http" +) + +// representation of a non-worker PHP thread +// executes PHP scripts in a web context +// implements the threadHandler interface +type regularThread struct { + state *threadState + thread *phpThread + activeRequest *http.Request +} + +func convertToRegularThread(thread *phpThread) { + thread.handler = ®ularThread{ + thread: thread, + state: thread.state, + } + thread.state.set(stateActive) +} + +func (t *regularThread) isReadyToTransition() bool { + return false +} + +func (handler *regularThread) getActiveRequest() *http.Request { + return handler.activeRequest +} + +// return the name of the script or an empty string if no script should be executed +func (handler *regularThread) beforeScriptExecution() string { + currentState := handler.state.get() + switch currentState { + case stateInactive: + handler.state.waitFor(stateActive, stateShuttingDown) + return handler.beforeScriptExecution() + case stateShuttingDown: + return "" + case stateReady, stateActive: + return handler.waitForScriptExecution() + } + return "" +} + +// return true if the worker should continue to run +func (handler *regularThread) afterScriptExecution(exitStatus int) bool { + handler.afterRequest(exitStatus) + + currentState := handler.state.get() + switch currentState { + case stateDrain: + return true + case stateShuttingDown: + return false + } + return true +} + +func (handler *regularThread) onShutdown(){ + handler.state.set(stateDone) +} + +func (handler *regularThread) waitForScriptExecution() string { + select { + case <-handler.thread.drainChan: + // no script should be executed if the server is shutting down + return "" + + case r := <-requestChan: + handler.activeRequest = r + fc := r.Context().Value(contextKey).(*FrankenPHPContext) + + if err := updateServerContext(handler.thread, r, true, false); err != nil { + rejectRequest(fc.responseWriter, err.Error()) + handler.afterRequest(0) + handler.thread.Unpin() + // no script should be executed if the request was rejected + return "" + } + + // set the scriptName that should be executed + return fc.scriptFilename + } +} + +func (handler *regularThread) afterRequest(exitStatus int) { + + // if the request is nil, no script was executed + if handler.activeRequest == nil { + return + } + + fc := handler.activeRequest.Context().Value(contextKey).(*FrankenPHPContext) + fc.exitStatus = exitStatus + maybeCloseContext(fc) + handler.activeRequest = nil +} diff --git a/thread-state.go b/thread-state.go new file mode 100644 index 000000000..4abf00c5e --- /dev/null +++ b/thread-state.go @@ -0,0 +1,90 @@ +package frankenphp + +import ( + "slices" + "sync" +) + +type stateID int + +const ( + stateBooting stateID = iota + stateInactive + stateActive + stateReady + stateBusy + stateShuttingDown + stateDone + stateRestarting + stateDrain + stateYielding +) + +type threadState struct { + currentState stateID + mu sync.RWMutex + subscribers []stateSubscriber +} + + +type stateSubscriber struct { + states []stateID + ch chan struct{} +} + +func newThreadState() *threadState { + return &threadState{ + currentState: stateBooting, + subscribers: []stateSubscriber{}, + mu: sync.RWMutex{}, + } +} + +func (h *threadState) is(state stateID) bool { + h.mu.RLock() + defer h.mu.RUnlock() + return h.currentState == state +} + +func (h *threadState) get() stateID { + h.mu.RLock() + defer h.mu.RUnlock() + return h.currentState +} + +func (h *threadState) set(nextState stateID) { + h.mu.Lock() + defer h.mu.Unlock() + h.currentState = nextState + + if len(h.subscribers) == 0 { + return + } + + newSubscribers := []stateSubscriber{} + // notify subscribers to the state change + for _, sub := range h.subscribers { + if !slices.Contains(sub.states, nextState) { + newSubscribers = append(newSubscribers, sub) + continue + } + close(sub.ch) + } + h.subscribers = newSubscribers +} + +// block until the thread reaches a certain state +func (h *threadState) waitFor(states ...stateID) { + h.mu.Lock() + if slices.Contains(states, h.currentState) { + h.mu.Unlock() + return + } + sub := stateSubscriber{ + states: states, + ch: make(chan struct{}), + } + h.subscribers = append(h.subscribers, sub) + h.mu.Unlock() + <-sub.ch +} diff --git a/thread-state_test.go b/thread-state_test.go new file mode 100644 index 000000000..29c68a810 --- /dev/null +++ b/thread-state_test.go @@ -0,0 +1,22 @@ +package frankenphp + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestYieldToEachOtherViaThreadStates(t *testing.T) { + threadState := &threadState{currentState: stateBooting} + + go func() { + threadState.waitFor(stateInactive) + assert.True(t, threadState.is(stateInactive)) + threadState.set(stateActive) + }() + + threadState.set(stateInactive) + threadState.waitFor(stateActive) + assert.True(t, threadState.is(stateActive)) +} + diff --git a/thread_state.go b/thread_state.go deleted file mode 100644 index c15cbb0a2..000000000 --- a/thread_state.go +++ /dev/null @@ -1,114 +0,0 @@ -package frankenphp - -import ( - "slices" - "sync" -) - -type threadState int - -const ( - stateBooting threadState = iota - stateInactive - stateActive - stateReady - stateBusy - stateShuttingDown - stateDone - stateRestarting - stateDrain - stateYielding -) - -type stateHandler struct { - currentState threadState - mu sync.RWMutex - subscribers []stateSubscriber -} - - -type stateSubscriber struct { - states []threadState - ch chan struct{} - yieldFor *sync.WaitGroup -} - -func newStateHandler() *stateHandler { - return &stateHandler{ - currentState: stateBooting, - subscribers: []stateSubscriber{}, - mu: sync.RWMutex{}, - } -} - -func (h *stateHandler) is(state threadState) bool { - h.mu.RLock() - defer h.mu.RUnlock() - return h.currentState == state -} - -func (h *stateHandler) get() threadState { - h.mu.RLock() - defer h.mu.RUnlock() - return h.currentState -} - -func (h *stateHandler) set(nextState threadState) { - h.mu.Lock() - defer h.mu.Unlock() - if h.currentState == nextState { - // TODO: do we return here or inform subscribers? - // TODO: should we ever reach here? - return - } - - h.currentState = nextState - - if len(h.subscribers) == 0 { - return - } - - newSubscribers := []stateSubscriber{} - // TODO: do we even need multiple subscribers? - // notify subscribers to the state change - for _, sub := range h.subscribers { - if !slices.Contains(sub.states, nextState) { - newSubscribers = append(newSubscribers, sub) - continue - } - close(sub.ch) - // yield for the subscriber - if sub.yieldFor != nil { - defer sub.yieldFor.Wait() - } - } - h.subscribers = newSubscribers -} - -// wait for the thread to reach a certain state -func (h *stateHandler) waitFor(states ...threadState) { - h.waitForStates(states, nil) -} - -// make the thread yield to a WaitGroup once it reaches the state -// this makes sure all threads are in sync both ways -func (h *stateHandler) waitForAndYield(yieldFor *sync.WaitGroup, states ...threadState) { - h.waitForStates(states, yieldFor) -} - -// subscribe to a state and wait until the thread reaches it -func (h *stateHandler) waitForStates(states []threadState, yieldFor *sync.WaitGroup) { - h.mu.Lock() - if slices.Contains(states, h.currentState) { - h.mu.Unlock() - return - } - sub := stateSubscriber{ - states: states, - ch: make(chan struct{}), - yieldFor: yieldFor, - } - h.subscribers = append(h.subscribers, sub) - h.mu.Unlock() - <-sub.ch -} diff --git a/thread_state_test.go b/thread_state_test.go deleted file mode 100644 index 693e1e3ba..000000000 --- a/thread_state_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package frankenphp - -import ( - "sync" - "testing" - - "github.com/stretchr/testify/assert" - "go.uber.org/zap" -) - -func TestYieldToEachOtherViaThreadStates(t *testing.T) { - threadState := &stateHandler{currentState: stateBooting} - - go func() { - threadState.waitFor(stateInactive) - assert.True(t, threadState.is(stateInactive)) - threadState.set(stateActive) - }() - - threadState.set(stateInactive) - threadState.waitFor(stateActive) - assert.True(t, threadState.is(stateActive)) -} - -func TestYieldToAWaitGroupPassedByThreadState(t *testing.T) { - logger, _ = zap.NewDevelopment() - threadState := &stateHandler{currentState: stateBooting} - hasYielded := false - wg := sync.WaitGroup{} - wg.Add(1) - - go func() { - threadState.set(stateInactive) - threadState.waitForAndYield(&wg, stateActive) - hasYielded = true - wg.Done() - }() - - threadState.waitFor(stateInactive) - threadState.set(stateActive) - // 'set' should have yielded to the wait group - assert.True(t, hasYielded) -} diff --git a/php_worker_thread.go b/worker-thread.go similarity index 85% rename from php_worker_thread.go rename to worker-thread.go index 7226beba0..9a50b84d3 100644 --- a/php_worker_thread.go +++ b/worker-thread.go @@ -11,8 +11,11 @@ import ( "go.uber.org/zap/zapcore" ) -type phpWorkerThread struct { - state *stateHandler +// representation of a thread assigned to a worker script +// executes the PHP worker script in a loop +// implements the threadHandler interface +type workerThread struct { + state *threadState thread *phpThread worker *worker fakeRequest *http.Request @@ -21,25 +24,22 @@ type phpWorkerThread struct { } func convertToWorkerThread(thread *phpThread, worker *worker) { - thread.handler = &phpWorkerThread{ + handler := &workerThread{ state: thread.state, thread: thread, worker: worker, + backoff: newExponentialBackoff(), } - thread.handler.onStartup() - thread.state.set(stateActive) -} + thread.handler = handler + thread.requestChan = make(chan *http.Request) + worker.threadMutex.Lock() + worker.threads = append(worker.threads, thread) + worker.threadMutex.Unlock() -// this is done once -func (handler *phpWorkerThread) onStartup(){ - handler.thread.requestChan = make(chan *http.Request) - handler.backoff = newExponentialBackoff() - handler.worker.threadMutex.Lock() - handler.worker.threads = append(handler.worker.threads, handler.thread) - handler.worker.threadMutex.Unlock() + thread.state.set(stateActive) } -func (handler *phpWorkerThread) getActiveRequest() *http.Request { +func (handler *workerThread) getActiveRequest() *http.Request { if handler.workerRequest != nil { return handler.workerRequest } @@ -47,12 +47,12 @@ func (handler *phpWorkerThread) getActiveRequest() *http.Request { return handler.fakeRequest } -func (t *phpWorkerThread) isReadyToTransition() bool { +func (t *workerThread) isReadyToTransition() bool { return false } // return the name of the script or an empty string if no script should be executed -func (handler *phpWorkerThread) beforeScriptExecution() string { +func (handler *workerThread) beforeScriptExecution() string { currentState := handler.state.get() switch currentState { case stateInactive: @@ -72,7 +72,7 @@ func (handler *phpWorkerThread) beforeScriptExecution() string { return "" } -func (handler *phpWorkerThread) waitForWorkerRequest() bool { +func (handler *workerThread) waitForWorkerRequest() bool { if c := logger.Check(zapcore.DebugLevel, "waiting for request"); c != nil { c.Write(zap.String("worker", handler.worker.fileName)) @@ -123,7 +123,7 @@ func (handler *phpWorkerThread) waitForWorkerRequest() bool { } // return true if the worker should continue to run -func (handler *phpWorkerThread) afterScriptExecution(exitStatus int) bool { +func (handler *workerThread) afterScriptExecution(exitStatus int) bool { tearDownWorkerScript(handler, exitStatus) currentState := handler.state.get() switch currentState { @@ -136,11 +136,11 @@ func (handler *phpWorkerThread) afterScriptExecution(exitStatus int) bool { return true } -func (handler *phpWorkerThread) onShutdown(){ +func (handler *workerThread) onShutdown(){ handler.state.set(stateDone) } -func setUpWorkerScript(handler *phpWorkerThread, worker *worker) { +func setUpWorkerScript(handler *workerThread, worker *worker) { handler.backoff.reset() metrics.StartWorker(worker.fileName) @@ -169,7 +169,7 @@ func setUpWorkerScript(handler *phpWorkerThread, worker *worker) { } } -func tearDownWorkerScript(handler *phpWorkerThread, exitStatus int) { +func tearDownWorkerScript(handler *workerThread, exitStatus int) { // if the fake request is nil, no script was executed if handler.fakeRequest == nil { @@ -219,7 +219,7 @@ func tearDownWorkerScript(handler *phpWorkerThread, exitStatus int) { //export go_frankenphp_worker_handle_request_start func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { - handler := phpThreads[threadIndex].handler.(*phpWorkerThread) + handler := phpThreads[threadIndex].handler.(*workerThread) return C.bool(handler.waitForWorkerRequest()) } @@ -230,7 +230,7 @@ func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t) { fc := r.Context().Value(contextKey).(*FrankenPHPContext) maybeCloseContext(fc) - thread.handler.(*phpWorkerThread).workerRequest = nil + thread.handler.(*workerThread).workerRequest = nil thread.Unpin() if c := fc.logger.Check(zapcore.DebugLevel, "request handling finished"); c != nil { From 62147546562a80f00668ccf1e2966c0c0d65eb86 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 7 Dec 2024 11:13:05 +0100 Subject: [PATCH 041/190] Fixes merge conflicts. --- exponential_backoff.go | 60 ------------------------------------------ worker-thread.go | 25 ++++++++++-------- worker.go | 3 +-- 3 files changed, 15 insertions(+), 73 deletions(-) delete mode 100644 exponential_backoff.go diff --git a/exponential_backoff.go b/exponential_backoff.go deleted file mode 100644 index 359e2bd4f..000000000 --- a/exponential_backoff.go +++ /dev/null @@ -1,60 +0,0 @@ -package frankenphp - -import ( - "sync" - "time" -) - -const maxBackoff = 1 * time.Second -const minBackoff = 100 * time.Millisecond -const maxConsecutiveFailures = 6 - -type exponentialBackoff struct { - backoff time.Duration - failureCount int - mu sync.RWMutex - upFunc sync.Once -} - -func newExponentialBackoff() *exponentialBackoff { - return &exponentialBackoff{backoff: minBackoff} -} - -func (e *exponentialBackoff) reset() { - e.mu.Lock() - e.upFunc = sync.Once{} - wait := e.backoff * 2 - e.mu.Unlock() - go func() { - time.Sleep(wait) - e.mu.Lock() - defer e.mu.Unlock() - e.upFunc.Do(func() { - // if we come back to a stable state, reset the failure count - if e.backoff == minBackoff { - e.failureCount = 0 - } - - // earn back the backoff over time - if e.failureCount > 0 { - e.backoff = max(e.backoff/2, minBackoff) - } - }) - }() -} - -func (e *exponentialBackoff) trigger(onMaxFailures func(failureCount int)) { - e.mu.RLock() - e.upFunc.Do(func() { - if e.failureCount >= maxConsecutiveFailures { - onMaxFailures(e.failureCount) - } - e.failureCount += 1 - }) - wait := e.backoff - e.mu.RUnlock() - time.Sleep(wait) - e.mu.Lock() - e.backoff = min(e.backoff*2, maxBackoff) - e.mu.Unlock() -} diff --git a/worker-thread.go b/worker-thread.go index 9a50b84d3..4d2907606 100644 --- a/worker-thread.go +++ b/worker-thread.go @@ -6,6 +6,7 @@ import ( "net/http" "path/filepath" "fmt" + "time" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -28,7 +29,11 @@ func convertToWorkerThread(thread *phpThread, worker *worker) { state: thread.state, thread: thread, worker: worker, - backoff: newExponentialBackoff(), + backoff: &exponentialBackoff{ + maxBackoff: 1 * time.Second, + minBackoff: 100 * time.Millisecond, + maxConsecutiveFailures: 6, + }, } thread.handler = handler thread.requestChan = make(chan *http.Request) @@ -141,7 +146,7 @@ func (handler *workerThread) onShutdown(){ } func setUpWorkerScript(handler *workerThread, worker *worker) { - handler.backoff.reset() + handler.backoff.wait() metrics.StartWorker(worker.fileName) // Create a dummy request to set up the worker @@ -196,7 +201,7 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { if fc.exitStatus == 0 { // TODO: make the max restart configurable metrics.StopWorker(worker.fileName, StopReasonRestart) - + handler.backoff.recordSuccess() if c := logger.Check(zapcore.DebugLevel, "restarting"); c != nil { c.Write(zap.String("worker", worker.fileName)) } @@ -207,14 +212,12 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { // on exit status 1 we apply an exponential backoff when restarting metrics.StopWorker(worker.fileName, StopReasonCrash) - handler.backoff.trigger(func(failureCount int) { - // if we end up here, the worker has not been up for backoff*2 - // this is probably due to a syntax error or another fatal error - if !watcherIsEnabled { - panic(fmt.Errorf("workers %q: too many consecutive failures", worker.fileName)) - } - logger.Warn("many consecutive worker failures", zap.String("worker", worker.fileName), zap.Int("failures", failureCount)) - }) + if handler.backoff.recordFailure() { + if !watcherIsEnabled { + panic(fmt.Errorf("workers %q: too many consecutive failures", worker.fileName)) + } + logger.Warn("many consecutive worker failures", zap.String("worker", worker.fileName), zap.Int("failures", handler.backoff.failureCount)) + } } //export go_frankenphp_worker_handle_request_start diff --git a/worker.go b/worker.go index 916341318..8ee25cff0 100644 --- a/worker.go +++ b/worker.go @@ -73,7 +73,6 @@ func newWorker(o workerOpt) (*worker, error) { num: o.num, env: o.env, requestChan: make(chan *http.Request), - ready: make(chan struct{}, o.num), } workers[absFileName] = w @@ -102,7 +101,6 @@ func restartWorkers() { ready.Done() }(thread) } - worker.threadMutex.RUnlock() } stopWorkers() ready.Wait() @@ -111,6 +109,7 @@ func restartWorkers() { thread.drainChan = make(chan struct{}) thread.state.set(stateReady) } + worker.threadMutex.RUnlock() } workersDone = make(chan interface{}) } From 00eb83401fedc0b4c8cdc209e1902370891e370c Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 7 Dec 2024 11:27:58 +0100 Subject: [PATCH 042/190] Formatting --- frankenphp.go | 2 +- inactive-thread.go | 9 +- php_threads.go => main-thread.go | 41 +++--- php_threads_test.go => main-thread_test.go | 0 phpthread.go | 10 +- regular-thread.go | 52 +++---- thread-state.go | 29 ++-- thread-state_test.go | 1 - worker-thread.go | 150 ++++++++++----------- worker.go | 12 +- 10 files changed, 155 insertions(+), 151 deletions(-) rename php_threads.go => main-thread.go (69%) rename php_threads_test.go => main-thread_test.go (100%) diff --git a/frankenphp.go b/frankenphp.go index 10210b2f9..43ae16a7b 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -467,7 +467,7 @@ func ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) error metrics.StartRequest() select { - case <-done: + case <-mainThread.done: case requestChan <- request: <-fc.done } diff --git a/inactive-thread.go b/inactive-thread.go index cfb589706..80921ebcb 100644 --- a/inactive-thread.go +++ b/inactive-thread.go @@ -8,7 +8,7 @@ import ( // representation of a thread with no work assigned to it // implements the threadHandler interface type inactiveThread struct { - state *threadState + state *threadState } func convertToInactiveThread(thread *phpThread) { @@ -38,10 +38,9 @@ func (thread *inactiveThread) afterScriptExecution(exitStatus int) bool { case stateShuttingDown: return false } - panic("unexpected state: "+strconv.Itoa(int(thread.state.get()))) + panic("unexpected state: " + strconv.Itoa(int(thread.state.get()))) } -func (thread *inactiveThread) onShutdown(){ - thread.state.set(stateDone) +func (thread *inactiveThread) onShutdown() { + thread.state.set(stateDone) } - diff --git a/php_threads.go b/main-thread.go similarity index 69% rename from php_threads.go rename to main-thread.go index 40f787621..687455945 100644 --- a/php_threads.go +++ b/main-thread.go @@ -7,16 +7,26 @@ import ( "sync" ) +type mainPHPThread struct { + state *threadState + done chan struct{} + numThreads int +} + var ( - phpThreads []*phpThread - done chan struct{} - mainThreadState *threadState + phpThreads []*phpThread + mainThread *mainPHPThread ) // reserve a fixed number of PHP threads on the go side func initPHPThreads(numThreads int) error { - done = make(chan struct{}) + mainThread = &mainPHPThread{ + state: newThreadState(), + done: make(chan struct{}), + numThreads: numThreads, + } phpThreads = make([]*phpThread, numThreads) + for i := 0; i < numThreads; i++ { phpThreads[i] = &phpThread{ threadIndex: i, @@ -25,13 +35,13 @@ func initPHPThreads(numThreads int) error { } convertToInactiveThread(phpThreads[i]) } - if err := startMainThread(numThreads); err != nil { + if err := mainThread.start(); err != nil { return err } // initialize all threads as inactive ready := sync.WaitGroup{} - ready.Add(len(phpThreads)) + ready.Add(numThreads) for _, thread := range phpThreads { go func() { @@ -55,7 +65,7 @@ func drainPHPThreads() { thread.state.set(stateShuttingDown) close(thread.drainChan) } - close(done) + close(mainThread.done) for _, thread := range phpThreads { go func(thread *phpThread) { thread.state.waitFor(stateDone) @@ -63,17 +73,16 @@ func drainPHPThreads() { }(thread) } doneWG.Wait() - mainThreadState.set(stateShuttingDown) - mainThreadState.waitFor(stateDone) + mainThread.state.set(stateShuttingDown) + mainThread.state.waitFor(stateDone) phpThreads = nil } -func startMainThread(numThreads int) error { - mainThreadState = newThreadState() - if C.frankenphp_new_main_thread(C.int(numThreads)) != 0 { +func (mainThread *mainPHPThread) start() error { + if C.frankenphp_new_main_thread(C.int(mainThread.numThreads)) != 0 { return MainThreadCreationError } - mainThreadState.waitFor(stateActive) + mainThread.state.waitFor(stateActive) return nil } @@ -88,11 +97,11 @@ func getInactivePHPThread() *phpThread { //export go_frankenphp_main_thread_is_ready func go_frankenphp_main_thread_is_ready() { - mainThreadState.set(stateActive) - mainThreadState.waitFor(stateShuttingDown) + mainThread.state.set(stateActive) + mainThread.state.waitFor(stateShuttingDown) } //export go_frankenphp_shutdown_main_thread func go_frankenphp_shutdown_main_thread() { - mainThreadState.set(stateDone) + mainThread.state.set(stateDone) } diff --git a/php_threads_test.go b/main-thread_test.go similarity index 100% rename from php_threads_test.go rename to main-thread_test.go diff --git a/phpthread.go b/phpthread.go index a16e1a573..465a1eb17 100644 --- a/phpthread.go +++ b/phpthread.go @@ -13,12 +13,12 @@ import ( type phpThread struct { runtime.Pinner - threadIndex int + threadIndex int knownVariableKeys map[string]*C.zend_string - requestChan chan *http.Request - drainChan chan struct{} - handler threadHandler - state *threadState + requestChan chan *http.Request + drainChan chan struct{} + handler threadHandler + state *threadState } // interface that defines how the callbacks from the C thread should be handled diff --git a/regular-thread.go b/regular-thread.go index 371ae517d..ef82c568d 100644 --- a/regular-thread.go +++ b/regular-thread.go @@ -10,15 +10,15 @@ import ( // executes PHP scripts in a web context // implements the threadHandler interface type regularThread struct { - state *threadState - thread *phpThread + state *threadState + thread *phpThread activeRequest *http.Request } func convertToRegularThread(thread *phpThread) { thread.handler = ®ularThread{ thread: thread, - state: thread.state, + state: thread.state, } thread.state.set(stateActive) } @@ -40,7 +40,7 @@ func (handler *regularThread) beforeScriptExecution() string { return handler.beforeScriptExecution() case stateShuttingDown: return "" - case stateReady, stateActive: + case stateReady, stateActive: return handler.waitForScriptExecution() } return "" @@ -53,38 +53,38 @@ func (handler *regularThread) afterScriptExecution(exitStatus int) bool { currentState := handler.state.get() switch currentState { case stateDrain: - return true + return true case stateShuttingDown: return false } return true } -func (handler *regularThread) onShutdown(){ - handler.state.set(stateDone) +func (handler *regularThread) onShutdown() { + handler.state.set(stateDone) } func (handler *regularThread) waitForScriptExecution() string { select { - case <-handler.thread.drainChan: - // no script should be executed if the server is shutting down - return "" - - case r := <-requestChan: - handler.activeRequest = r - fc := r.Context().Value(contextKey).(*FrankenPHPContext) - - if err := updateServerContext(handler.thread, r, true, false); err != nil { - rejectRequest(fc.responseWriter, err.Error()) - handler.afterRequest(0) - handler.thread.Unpin() - // no script should be executed if the request was rejected - return "" - } - - // set the scriptName that should be executed - return fc.scriptFilename - } + case <-handler.thread.drainChan: + // no script should be executed if the server is shutting down + return "" + + case r := <-requestChan: + handler.activeRequest = r + fc := r.Context().Value(contextKey).(*FrankenPHPContext) + + if err := updateServerContext(handler.thread, r, true, false); err != nil { + rejectRequest(fc.responseWriter, err.Error()) + handler.afterRequest(0) + handler.thread.Unpin() + // no script should be executed if the request was rejected + return "" + } + + // set the scriptName that should be executed + return fc.scriptFilename + } } func (handler *regularThread) afterRequest(exitStatus int) { diff --git a/thread-state.go b/thread-state.go index 4abf00c5e..5ca9443dd 100644 --- a/thread-state.go +++ b/thread-state.go @@ -26,17 +26,16 @@ type threadState struct { subscribers []stateSubscriber } - type stateSubscriber struct { - states []stateID - ch chan struct{} + states []stateID + ch chan struct{} } func newThreadState() *threadState { return &threadState{ currentState: stateBooting, subscribers: []stateSubscriber{}, - mu: sync.RWMutex{}, + mu: sync.RWMutex{}, } } @@ -76,15 +75,15 @@ func (h *threadState) set(nextState stateID) { // block until the thread reaches a certain state func (h *threadState) waitFor(states ...stateID) { h.mu.Lock() - if slices.Contains(states, h.currentState) { - h.mu.Unlock() - return - } - sub := stateSubscriber{ - states: states, - ch: make(chan struct{}), - } - h.subscribers = append(h.subscribers, sub) - h.mu.Unlock() - <-sub.ch + if slices.Contains(states, h.currentState) { + h.mu.Unlock() + return + } + sub := stateSubscriber{ + states: states, + ch: make(chan struct{}), + } + h.subscribers = append(h.subscribers, sub) + h.mu.Unlock() + <-sub.ch } diff --git a/thread-state_test.go b/thread-state_test.go index 29c68a810..f71e940b4 100644 --- a/thread-state_test.go +++ b/thread-state_test.go @@ -19,4 +19,3 @@ func TestYieldToEachOtherViaThreadStates(t *testing.T) { threadState.waitFor(stateActive) assert.True(t, threadState.is(stateActive)) } - diff --git a/worker-thread.go b/worker-thread.go index 4d2907606..4d83f1cc6 100644 --- a/worker-thread.go +++ b/worker-thread.go @@ -3,9 +3,9 @@ package frankenphp // #include "frankenphp.h" import "C" import ( + "fmt" "net/http" "path/filepath" - "fmt" "time" "go.uber.org/zap" @@ -16,30 +16,30 @@ import ( // executes the PHP worker script in a loop // implements the threadHandler interface type workerThread struct { - state *threadState - thread *phpThread - worker *worker - fakeRequest *http.Request + state *threadState + thread *phpThread + worker *worker + fakeRequest *http.Request workerRequest *http.Request - backoff *exponentialBackoff + backoff *exponentialBackoff } func convertToWorkerThread(thread *phpThread, worker *worker) { handler := &workerThread{ - state: thread.state, + state: thread.state, thread: thread, worker: worker, backoff: &exponentialBackoff{ - maxBackoff: 1 * time.Second, - minBackoff: 100 * time.Millisecond, - maxConsecutiveFailures: 6, - }, + maxBackoff: 1 * time.Second, + minBackoff: 100 * time.Millisecond, + maxConsecutiveFailures: 6, + }, } thread.handler = handler thread.requestChan = make(chan *http.Request) worker.threadMutex.Lock() - worker.threads = append(worker.threads, thread) - worker.threadMutex.Unlock() + worker.threads = append(worker.threads, thread) + worker.threadMutex.Unlock() thread.state.set(stateActive) } @@ -68,65 +68,15 @@ func (handler *workerThread) beforeScriptExecution() string { case stateRestarting: handler.state.set(stateYielding) handler.state.waitFor(stateReady, stateShuttingDown) - return handler.beforeScriptExecution() - case stateReady, stateActive: + return handler.beforeScriptExecution() + case stateReady, stateActive: setUpWorkerScript(handler, handler.worker) return handler.worker.fileName } - // TODO: panic? + // TODO: panic? return "" } -func (handler *workerThread) waitForWorkerRequest() bool { - - if c := logger.Check(zapcore.DebugLevel, "waiting for request"); c != nil { - c.Write(zap.String("worker", handler.worker.fileName)) - } - - if handler.state.is(stateActive) { - metrics.ReadyWorker(handler.worker.fileName) - handler.state.set(stateReady) - } - - var r *http.Request - select { - case <-handler.thread.drainChan: - if c := logger.Check(zapcore.DebugLevel, "shutting down"); c != nil { - c.Write(zap.String("worker", handler.worker.fileName)) - } - - // execute opcache_reset if the restart was triggered by the watcher - if watcherIsEnabled && handler.state.is(stateRestarting) { - C.frankenphp_reset_opcache() - } - - return false - case r = <-handler.thread.requestChan: - case r = <-handler.worker.requestChan: - } - - handler.workerRequest = r - - if c := logger.Check(zapcore.DebugLevel, "request handling started"); c != nil { - c.Write(zap.String("worker", handler.worker.fileName), zap.String("url", r.RequestURI)) - } - - if err := updateServerContext(handler.thread, r, false, true); err != nil { - // Unexpected error - if c := logger.Check(zapcore.DebugLevel, "unexpected error"); c != nil { - c.Write(zap.String("worker", handler.worker.fileName), zap.String("url", r.RequestURI), zap.Error(err)) - } - fc := r.Context().Value(contextKey).(*FrankenPHPContext) - rejectRequest(fc.responseWriter, err.Error()) - maybeCloseContext(fc) - handler.workerRequest = nil - handler.thread.Unpin() - - return handler.waitForWorkerRequest() - } - return true -} - // return true if the worker should continue to run func (handler *workerThread) afterScriptExecution(exitStatus int) bool { tearDownWorkerScript(handler, exitStatus) @@ -134,15 +84,15 @@ func (handler *workerThread) afterScriptExecution(exitStatus int) bool { switch currentState { case stateDrain: handler.thread.requestChan = make(chan *http.Request) - return true + return true case stateShuttingDown: return false } return true } -func (handler *workerThread) onShutdown(){ - handler.state.set(stateDone) +func (handler *workerThread) onShutdown() { + handler.state.set(stateDone) } func setUpWorkerScript(handler *workerThread, worker *worker) { @@ -213,11 +163,61 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { // on exit status 1 we apply an exponential backoff when restarting metrics.StopWorker(worker.fileName, StopReasonCrash) if handler.backoff.recordFailure() { - if !watcherIsEnabled { - panic(fmt.Errorf("workers %q: too many consecutive failures", worker.fileName)) - } - logger.Warn("many consecutive worker failures", zap.String("worker", worker.fileName), zap.Int("failures", handler.backoff.failureCount)) - } + if !watcherIsEnabled { + panic(fmt.Errorf("workers %q: too many consecutive failures", worker.fileName)) + } + logger.Warn("many consecutive worker failures", zap.String("worker", worker.fileName), zap.Int("failures", handler.backoff.failureCount)) + } +} + +func (handler *workerThread) waitForWorkerRequest() bool { + + if c := logger.Check(zapcore.DebugLevel, "waiting for request"); c != nil { + c.Write(zap.String("worker", handler.worker.fileName)) + } + + if handler.state.is(stateActive) { + metrics.ReadyWorker(handler.worker.fileName) + handler.state.set(stateReady) + } + + var r *http.Request + select { + case <-handler.thread.drainChan: + if c := logger.Check(zapcore.DebugLevel, "shutting down"); c != nil { + c.Write(zap.String("worker", handler.worker.fileName)) + } + + // execute opcache_reset if the restart was triggered by the watcher + if watcherIsEnabled && handler.state.is(stateRestarting) { + C.frankenphp_reset_opcache() + } + + return false + case r = <-handler.thread.requestChan: + case r = <-handler.worker.requestChan: + } + + handler.workerRequest = r + + if c := logger.Check(zapcore.DebugLevel, "request handling started"); c != nil { + c.Write(zap.String("worker", handler.worker.fileName), zap.String("url", r.RequestURI)) + } + + if err := updateServerContext(handler.thread, r, false, true); err != nil { + // Unexpected error + if c := logger.Check(zapcore.DebugLevel, "unexpected error"); c != nil { + c.Write(zap.String("worker", handler.worker.fileName), zap.String("url", r.RequestURI), zap.Error(err)) + } + fc := r.Context().Value(contextKey).(*FrankenPHPContext) + rejectRequest(fc.responseWriter, err.Error()) + maybeCloseContext(fc) + handler.workerRequest = nil + handler.thread.Unpin() + + return handler.waitForWorkerRequest() + } + return true } //export go_frankenphp_worker_handle_request_start @@ -252,4 +252,4 @@ func go_frankenphp_finish_php_request(threadIndex C.uintptr_t) { if c := fc.logger.Check(zapcore.DebugLevel, "request handling finished"); c != nil { c.Write(zap.String("url", r.RequestURI)) } -} \ No newline at end of file +} diff --git a/worker.go b/worker.go index 8ee25cff0..b2646e9b0 100644 --- a/worker.go +++ b/worker.go @@ -22,7 +22,6 @@ type worker struct { threadMutex sync.RWMutex } - var ( workers map[string]*worker workersDone chan interface{} @@ -105,12 +104,12 @@ func restartWorkers() { stopWorkers() ready.Wait() for _, worker := range workers { - for _, thread := range worker.threads { - thread.drainChan = make(chan struct{}) - thread.state.set(stateReady) - } + for _, thread := range worker.threads { + thread.drainChan = make(chan struct{}) + thread.state.set(stateReady) + } worker.threadMutex.RUnlock() - } + } workersDone = make(chan interface{}) } @@ -150,4 +149,3 @@ func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { <-fc.done metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) } - From 02b73b169632bfc9ebdebed26e65670be25aae39 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 7 Dec 2024 11:31:52 +0100 Subject: [PATCH 043/190] C formatting. --- frankenphp.c | 6 +++--- frankenphp_arginfo.h | 31 +++++++++++++------------------ 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index c4739e8e9..292156881 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -835,15 +835,15 @@ static void *php_thread(void *arg) { while (true) { char *scriptName = go_frankenphp_before_script_execution(thread_index); - int exit_status = 0; + int exit_status = 0; // if the script name is not empty, execute the PHP script if (strlen(scriptName) != 0) { exit_status = frankenphp_execute_script(scriptName); } // if go signals to stop, break the loop - if(!go_frankenphp_after_script_execution(thread_index, exit_status)){ - break; + if (!go_frankenphp_after_script_execution(thread_index, exit_status)) { + break; } } diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index ec97502e7..cecffd88d 100644 --- a/frankenphp_arginfo.h +++ b/frankenphp_arginfo.h @@ -36,22 +36,17 @@ ZEND_FUNCTION(frankenphp_finish_request); ZEND_FUNCTION(frankenphp_request_headers); ZEND_FUNCTION(frankenphp_response_headers); +// clang-format off static const zend_function_entry ext_functions[] = { - ZEND_FE(frankenphp_handle_request, arginfo_frankenphp_handle_request) - ZEND_FE(headers_send, arginfo_headers_send) ZEND_FE( - frankenphp_finish_request, arginfo_frankenphp_finish_request) - ZEND_FALIAS(fastcgi_finish_request, frankenphp_finish_request, - arginfo_fastcgi_finish_request) - ZEND_FE(frankenphp_request_headers, - arginfo_frankenphp_request_headers) - ZEND_FALIAS(apache_request_headers, - frankenphp_request_headers, - arginfo_apache_request_headers) - ZEND_FALIAS(getallheaders, frankenphp_request_headers, - arginfo_getallheaders) - ZEND_FE(frankenphp_response_headers, - arginfo_frankenphp_response_headers) - ZEND_FALIAS(apache_response_headers, - frankenphp_response_headers, - arginfo_apache_response_headers) - ZEND_FE_END}; + ZEND_FE(frankenphp_handle_request, arginfo_frankenphp_handle_request) + ZEND_FE(headers_send, arginfo_headers_send) + ZEND_FE(frankenphp_finish_request, arginfo_frankenphp_finish_request) + ZEND_FALIAS(fastcgi_finish_request, frankenphp_finish_request, arginfo_fastcgi_finish_request) + ZEND_FE(frankenphp_request_headers, arginfo_frankenphp_request_headers) + ZEND_FALIAS(apache_request_headers, frankenphp_request_headers, arginfo_apache_request_headers) + ZEND_FALIAS(getallheaders, frankenphp_request_headers, arginfo_getallheaders) + ZEND_FE(frankenphp_response_headers, arginfo_frankenphp_response_headers) + ZEND_FALIAS(apache_response_headers, frankenphp_response_headers, arginfo_apache_response_headers) + ZEND_FE_END +}; +// clang-format on \ No newline at end of file From 421904e8794a2bd3a97a307595e506aa6b77691c Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 7 Dec 2024 11:45:15 +0100 Subject: [PATCH 044/190] More cleanup. --- frankenphp.go | 4 ---- main-thread.go | 2 ++ worker.go | 11 +---------- 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index 43ae16a7b..809e4af7d 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -477,10 +477,6 @@ func ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) error return nil } -func handleRequest(thread *phpThread) { - -} - func maybeCloseContext(fc *FrankenPHPContext) { fc.closed.Do(func() { close(fc.done) diff --git a/main-thread.go b/main-thread.go index 687455945..bf117d193 100644 --- a/main-thread.go +++ b/main-thread.go @@ -7,6 +7,8 @@ import ( "sync" ) +// represents the main PHP thread +// the thread needs to keep running as long as all other threads are running type mainPHPThread struct { state *threadState done chan struct{} diff --git a/worker.go b/worker.go index b2646e9b0..ba7ec9fb6 100644 --- a/worker.go +++ b/worker.go @@ -24,13 +24,11 @@ type worker struct { var ( workers map[string]*worker - workersDone chan interface{} watcherIsEnabled bool ) func initWorkers(opt []workerOpt) error { workers = make(map[string]*worker, len(opt)) - workersDone = make(chan interface{}) directoriesToWatch := getDirectoriesToWatch(opt) watcherIsEnabled = len(directoriesToWatch) > 0 @@ -45,7 +43,7 @@ func initWorkers(opt []workerOpt) error { } } - if len(directoriesToWatch) == 0 { + if !watcherIsEnabled { return nil } @@ -78,13 +76,8 @@ func newWorker(o workerOpt) (*worker, error) { return w, nil } -func stopWorkers() { - close(workersDone) -} - func drainWorkers() { watcher.DrainWatcher() - stopWorkers() } func restartWorkers() { @@ -101,7 +94,6 @@ func restartWorkers() { }(thread) } } - stopWorkers() ready.Wait() for _, worker := range workers { for _, thread := range worker.threads { @@ -110,7 +102,6 @@ func restartWorkers() { } worker.threadMutex.RUnlock() } - workersDone = make(chan interface{}) } func getDirectoriesToWatch(workerOpts []workerOpt) []string { From cca2a00ac6755cf2b9ea88900c1ac1825675c456 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 7 Dec 2024 14:59:34 +0100 Subject: [PATCH 045/190] Allows for clean state transitions. --- frankenphp.c | 15 ++--- inactive-thread.go | 46 ---------------- main-thread_test.go | 20 ------- main-thread.go => phpmainthread.go | 28 +++++----- phpmainthread_test.go | 76 ++++++++++++++++++++++++++ phpthread.go | 24 +++++--- thread-inactive.go | 42 ++++++++++++++ regular-thread.go => thread-regular.go | 66 ++++++++-------------- thread-state.go | 42 ++++++++++---- thread-state_test.go | 6 +- worker-thread.go => thread-worker.go | 65 +++++++--------------- worker.go | 17 ++++++ 12 files changed, 252 insertions(+), 195 deletions(-) delete mode 100644 inactive-thread.go delete mode 100644 main-thread_test.go rename main-thread.go => phpmainthread.go (86%) create mode 100644 phpmainthread_test.go create mode 100644 thread-inactive.go rename regular-thread.go => thread-regular.go (62%) rename worker-thread.go => thread-worker.go (85%) diff --git a/frankenphp.c b/frankenphp.c index 292156881..b4cde79cd 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -835,16 +835,13 @@ static void *php_thread(void *arg) { while (true) { char *scriptName = go_frankenphp_before_script_execution(thread_index); - int exit_status = 0; - // if the script name is not empty, execute the PHP script - if (strlen(scriptName) != 0) { - exit_status = frankenphp_execute_script(scriptName); - } - // if go signals to stop, break the loop - if (!go_frankenphp_after_script_execution(thread_index, exit_status)) { - break; - } + if (scriptName == NULL) { + break; + } + + int exit_status = frankenphp_execute_script(scriptName); + go_frankenphp_after_script_execution(thread_index, exit_status); } go_frankenphp_release_known_variable_keys(thread_index); diff --git a/inactive-thread.go b/inactive-thread.go deleted file mode 100644 index 80921ebcb..000000000 --- a/inactive-thread.go +++ /dev/null @@ -1,46 +0,0 @@ -package frankenphp - -import ( - "net/http" - "strconv" -) - -// representation of a thread with no work assigned to it -// implements the threadHandler interface -type inactiveThread struct { - state *threadState -} - -func convertToInactiveThread(thread *phpThread) { - thread.handler = &inactiveThread{state: thread.state} -} - -func (t *inactiveThread) isReadyToTransition() bool { - return true -} - -func (thread *inactiveThread) getActiveRequest() *http.Request { - panic("idle threads have no requests") -} - -func (thread *inactiveThread) beforeScriptExecution() string { - // no script execution for inactive threads - return "" -} - -func (thread *inactiveThread) afterScriptExecution(exitStatus int) bool { - thread.state.set(stateInactive) - // wait for external signal to start or shut down - thread.state.waitFor(stateActive, stateShuttingDown) - switch thread.state.get() { - case stateActive: - return true - case stateShuttingDown: - return false - } - panic("unexpected state: " + strconv.Itoa(int(thread.state.get()))) -} - -func (thread *inactiveThread) onShutdown() { - thread.state.set(stateDone) -} diff --git a/main-thread_test.go b/main-thread_test.go deleted file mode 100644 index 74aa75145..000000000 --- a/main-thread_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package frankenphp - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "go.uber.org/zap" -) - -func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { - logger = zap.NewNop() // the logger needs to not be nil - assert.NoError(t, initPHPThreads(1)) // reserve 1 thread - - assert.Len(t, phpThreads, 1) - assert.Equal(t, 0, phpThreads[0].threadIndex) - assert.True(t, phpThreads[0].state.is(stateInactive)) - - drainPHPThreads() - assert.Nil(t, phpThreads) -} diff --git a/main-thread.go b/phpmainthread.go similarity index 86% rename from main-thread.go rename to phpmainthread.go index bf117d193..e9378070a 100644 --- a/main-thread.go +++ b/phpmainthread.go @@ -4,12 +4,13 @@ package frankenphp import "C" import ( "fmt" + "net/http" "sync" ) // represents the main PHP thread // the thread needs to keep running as long as all other threads are running -type mainPHPThread struct { +type phpMainThread struct { state *threadState done chan struct{} numThreads int @@ -17,34 +18,36 @@ type mainPHPThread struct { var ( phpThreads []*phpThread - mainThread *mainPHPThread + mainThread *phpMainThread ) // reserve a fixed number of PHP threads on the go side func initPHPThreads(numThreads int) error { - mainThread = &mainPHPThread{ + mainThread = &phpMainThread{ state: newThreadState(), done: make(chan struct{}), numThreads: numThreads, } phpThreads = make([]*phpThread, numThreads) + if err := mainThread.start(); err != nil { + return err + } + + // initialize all threads as inactive for i := 0; i < numThreads; i++ { phpThreads[i] = &phpThread{ threadIndex: i, drainChan: make(chan struct{}), + requestChan: make(chan *http.Request), state: newThreadState(), } convertToInactiveThread(phpThreads[i]) } - if err := mainThread.start(); err != nil { - return err - } - // initialize all threads as inactive + // start the underlying C threads ready := sync.WaitGroup{} ready.Add(numThreads) - for _, thread := range phpThreads { go func() { if !C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) { @@ -54,7 +57,6 @@ func initPHPThreads(numThreads int) error { ready.Done() }() } - ready.Wait() return nil @@ -80,17 +82,17 @@ func drainPHPThreads() { phpThreads = nil } -func (mainThread *mainPHPThread) start() error { +func (mainThread *phpMainThread) start() error { if C.frankenphp_new_main_thread(C.int(mainThread.numThreads)) != 0 { return MainThreadCreationError } - mainThread.state.waitFor(stateActive) + mainThread.state.waitFor(stateReady) return nil } func getInactivePHPThread() *phpThread { for _, thread := range phpThreads { - if thread.handler.isReadyToTransition() { + if thread.state.is(stateInactive) { return thread } } @@ -99,7 +101,7 @@ func getInactivePHPThread() *phpThread { //export go_frankenphp_main_thread_is_ready func go_frankenphp_main_thread_is_ready() { - mainThread.state.set(stateActive) + mainThread.state.set(stateReady) mainThread.state.waitFor(stateShuttingDown) } diff --git a/phpmainthread_test.go b/phpmainthread_test.go new file mode 100644 index 000000000..f9f46cc15 --- /dev/null +++ b/phpmainthread_test.go @@ -0,0 +1,76 @@ +package frankenphp + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { + logger = zap.NewNop() // the logger needs to not be nil + assert.NoError(t, initPHPThreads(1)) // reserve 1 thread + + assert.Len(t, phpThreads, 1) + assert.Equal(t, 0, phpThreads[0].threadIndex) + assert.True(t, phpThreads[0].state.is(stateInactive)) + + drainPHPThreads() + assert.Nil(t, phpThreads) +} + +func TestTransition2RegularThreadsToWorkerThreadsAndBack(t *testing.T) { + numThreads := 2 + logger, _ = zap.NewDevelopment() + assert.NoError(t, initPHPThreads(numThreads)) + + // transition to worker thread + for i := 0; i < numThreads; i++ { + convertToRegularThread(phpThreads[i]) + assert.IsType(t, ®ularThread{}, phpThreads[i].handler) + } + + // transition to worker thread + worker := getDummyWorker() + for i := 0; i < numThreads; i++ { + convertToWorkerThread(phpThreads[i], worker) + assert.IsType(t, &workerThread{}, phpThreads[i].handler) + } + assert.Len(t, worker.threads, numThreads) + + // transition back to regular thread + for i := 0; i < numThreads; i++ { + convertToRegularThread(phpThreads[i]) + assert.IsType(t, ®ularThread{}, phpThreads[i].handler) + } + assert.Len(t, worker.threads, 0) + + drainPHPThreads() + assert.Nil(t, phpThreads) +} + +func TestTransitionAThreadBetween2DifferentWorkers(t *testing.T) { + logger, _ = zap.NewDevelopment() + assert.NoError(t, initPHPThreads(1)) + + // convert to first worker thread + firstWorker := getDummyWorker() + convertToWorkerThread(phpThreads[0], firstWorker) + firstHandler := phpThreads[0].handler.(*workerThread) + assert.Same(t, firstWorker, firstHandler.worker) + + // convert to second worker thread + secondWorker := getDummyWorker() + convertToWorkerThread(phpThreads[0], secondWorker) + secondHandler := phpThreads[0].handler.(*workerThread) + assert.Same(t, secondWorker, secondHandler.worker) + + drainPHPThreads() + assert.Nil(t, phpThreads) +} + +func getDummyWorker() *worker { + path, _ := filepath.Abs("./testdata/index.php") + return &worker{fileName: path} +} diff --git a/phpthread.go b/phpthread.go index 465a1eb17..5ee1ff34a 100644 --- a/phpthread.go +++ b/phpthread.go @@ -6,6 +6,8 @@ import ( "net/http" "runtime" "unsafe" + + "go.uber.org/zap" ) // representation of the actual underlying PHP thread @@ -24,19 +26,23 @@ type phpThread struct { // interface that defines how the callbacks from the C thread should be handled type threadHandler interface { beforeScriptExecution() string - afterScriptExecution(exitStatus int) bool - onShutdown() + afterScriptExecution(exitStatus int) getActiveRequest() *http.Request - isReadyToTransition() bool } func (thread *phpThread) getActiveRequest() *http.Request { return thread.handler.getActiveRequest() } +// change the thread handler safely func (thread *phpThread) setHandler(handler threadHandler) { + logger.Debug("transitioning thread", zap.Int("threadIndex", thread.threadIndex)) + thread.state.set(stateTransitionRequested) + close(thread.drainChan) + thread.state.waitFor(stateTransitionInProgress) thread.handler = handler - thread.state.set(stateActive) + thread.drainChan = make(chan struct{}) + thread.state.set(stateTransitionComplete) } // Pin a string that is not null-terminated @@ -56,19 +62,23 @@ func (thread *phpThread) pinCString(s string) *C.char { func go_frankenphp_before_script_execution(threadIndex C.uintptr_t) *C.char { thread := phpThreads[threadIndex] scriptName := thread.handler.beforeScriptExecution() + + // if no scriptName is passed, shut down + if scriptName == "" { + return nil + } // return the name of the PHP script that should be executed return thread.pinCString(scriptName) } //export go_frankenphp_after_script_execution -func go_frankenphp_after_script_execution(threadIndex C.uintptr_t, exitStatus C.int) C.bool { +func go_frankenphp_after_script_execution(threadIndex C.uintptr_t, exitStatus C.int) { thread := phpThreads[threadIndex] if exitStatus < 0 { panic(ScriptExecutionError) } - shouldContinueExecution := thread.handler.afterScriptExecution(int(exitStatus)) + thread.handler.afterScriptExecution(int(exitStatus)) thread.Unpin() - return C.bool(shouldContinueExecution) } //export go_frankenphp_on_thread_shutdown diff --git a/thread-inactive.go b/thread-inactive.go new file mode 100644 index 000000000..311ecabed --- /dev/null +++ b/thread-inactive.go @@ -0,0 +1,42 @@ +package frankenphp + +import ( + "net/http" +) + +// representation of a thread with no work assigned to it +// implements the threadHandler interface +type inactiveThread struct { + thread *phpThread +} + +func convertToInactiveThread(thread *phpThread) { + thread.handler = &inactiveThread{thread: thread} +} + +func (thread *inactiveThread) getActiveRequest() *http.Request { + panic("inactive threads have no requests") +} + +func (handler *inactiveThread) beforeScriptExecution() string { + thread := handler.thread + thread.state.set(stateInactive) + + // wait for external signal to start or shut down + thread.state.waitFor(stateTransitionRequested, stateShuttingDown) + switch thread.state.get() { + case stateTransitionRequested: + thread.state.set(stateTransitionInProgress) + thread.state.waitFor(stateTransitionComplete, stateShuttingDown) + // execute beforeScriptExecution of the new handler + return thread.handler.beforeScriptExecution() + case stateShuttingDown: + // signal to stop + return "" + } + panic("unexpected state: " + thread.state.name()) +} + +func (thread *inactiveThread) afterScriptExecution(exitStatus int) { + panic("inactive threads should not execute scripts") +} diff --git a/regular-thread.go b/thread-regular.go similarity index 62% rename from regular-thread.go rename to thread-regular.go index ef82c568d..ee9839d2c 100644 --- a/regular-thread.go +++ b/thread-regular.go @@ -16,59 +16,47 @@ type regularThread struct { } func convertToRegularThread(thread *phpThread) { - thread.handler = ®ularThread{ + thread.setHandler(®ularThread{ thread: thread, state: thread.state, - } - thread.state.set(stateActive) -} - -func (t *regularThread) isReadyToTransition() bool { - return false -} - -func (handler *regularThread) getActiveRequest() *http.Request { - return handler.activeRequest + }) } // return the name of the script or an empty string if no script should be executed func (handler *regularThread) beforeScriptExecution() string { - currentState := handler.state.get() - switch currentState { - case stateInactive: - handler.state.waitFor(stateActive, stateShuttingDown) - return handler.beforeScriptExecution() + switch handler.state.get() { + case stateTransitionRequested: + thread := handler.thread + thread.state.set(stateTransitionInProgress) + thread.state.waitFor(stateTransitionComplete, stateShuttingDown) + // execute beforeScriptExecution of the new handler + return thread.handler.beforeScriptExecution() + case stateTransitionComplete: + handler.state.set(stateReady) + return handler.waitForRequest() case stateShuttingDown: + // signal to stop return "" - case stateReady, stateActive: - return handler.waitForScriptExecution() + case stateReady: + return handler.waitForRequest() } - return "" + panic("unexpected state: " + handler.state.name()) } // return true if the worker should continue to run -func (handler *regularThread) afterScriptExecution(exitStatus int) bool { +func (handler *regularThread) afterScriptExecution(exitStatus int) { handler.afterRequest(exitStatus) - - currentState := handler.state.get() - switch currentState { - case stateDrain: - return true - case stateShuttingDown: - return false - } - return true } -func (handler *regularThread) onShutdown() { - handler.state.set(stateDone) +func (handler *regularThread) getActiveRequest() *http.Request { + return handler.activeRequest } -func (handler *regularThread) waitForScriptExecution() string { +func (handler *regularThread) waitForRequest() string { select { case <-handler.thread.drainChan: - // no script should be executed if the server is shutting down - return "" + // go back to beforeScriptExecution + return handler.beforeScriptExecution() case r := <-requestChan: handler.activeRequest = r @@ -78,8 +66,8 @@ func (handler *regularThread) waitForScriptExecution() string { rejectRequest(fc.responseWriter, err.Error()) handler.afterRequest(0) handler.thread.Unpin() - // no script should be executed if the request was rejected - return "" + // go back to beforeScriptExecution + return handler.beforeScriptExecution() } // set the scriptName that should be executed @@ -88,12 +76,6 @@ func (handler *regularThread) waitForScriptExecution() string { } func (handler *regularThread) afterRequest(exitStatus int) { - - // if the request is nil, no script was executed - if handler.activeRequest == nil { - return - } - fc := handler.activeRequest.Context().Value(contextKey).(*FrankenPHPContext) fc.exitStatus = exitStatus maybeCloseContext(fc) diff --git a/thread-state.go b/thread-state.go index 5ca9443dd..28a9085ea 100644 --- a/thread-state.go +++ b/thread-state.go @@ -2,22 +2,28 @@ package frankenphp import ( "slices" + "strconv" "sync" ) type stateID int const ( + // initial state stateBooting stateID = iota stateInactive - stateActive stateReady - stateBusy stateShuttingDown stateDone + + // states necessary for restarting workers stateRestarting - stateDrain stateYielding + + // states necessary for transitioning + stateTransitionRequested + stateTransitionInProgress + stateTransitionComplete ) type threadState struct { @@ -39,16 +45,26 @@ func newThreadState() *threadState { } } -func (h *threadState) is(state stateID) bool { - h.mu.RLock() - defer h.mu.RUnlock() - return h.currentState == state +func (ts *threadState) is(state stateID) bool { + ts.mu.RLock() + defer ts.mu.RUnlock() + return ts.currentState == state } -func (h *threadState) get() stateID { - h.mu.RLock() - defer h.mu.RUnlock() - return h.currentState +func (ts *threadState) compareAndSwap(compareTo stateID, swapTo stateID) bool { + ts.mu.Lock() + defer ts.mu.Unlock() + if ts.currentState == compareTo { + ts.currentState = swapTo + return true + } + return false +} + +func (ts *threadState) get() stateID { + ts.mu.RLock() + defer ts.mu.RUnlock() + return ts.currentState } func (h *threadState) set(nextState stateID) { @@ -72,6 +88,10 @@ func (h *threadState) set(nextState stateID) { h.subscribers = newSubscribers } +func (ts *threadState) name() string { + return "state:" + strconv.Itoa(int(ts.get())) +} + // block until the thread reaches a certain state func (h *threadState) waitFor(states ...stateID) { h.mu.Lock() diff --git a/thread-state_test.go b/thread-state_test.go index f71e940b4..28bb3a693 100644 --- a/thread-state_test.go +++ b/thread-state_test.go @@ -12,10 +12,10 @@ func TestYieldToEachOtherViaThreadStates(t *testing.T) { go func() { threadState.waitFor(stateInactive) assert.True(t, threadState.is(stateInactive)) - threadState.set(stateActive) + threadState.set(stateReady) }() threadState.set(stateInactive) - threadState.waitFor(stateActive) - assert.True(t, threadState.is(stateActive)) + threadState.waitFor(stateReady) + assert.True(t, threadState.is(stateReady)) } diff --git a/worker-thread.go b/thread-worker.go similarity index 85% rename from worker-thread.go rename to thread-worker.go index 4d83f1cc6..be70334d7 100644 --- a/worker-thread.go +++ b/thread-worker.go @@ -25,7 +25,7 @@ type workerThread struct { } func convertToWorkerThread(thread *phpThread, worker *worker) { - handler := &workerThread{ + thread.setHandler(&workerThread{ state: thread.state, thread: thread, worker: worker, @@ -34,14 +34,11 @@ func convertToWorkerThread(thread *phpThread, worker *worker) { minBackoff: 100 * time.Millisecond, maxConsecutiveFailures: 6, }, + }) + worker.addThread(thread) + if worker.fileName == "" { + panic("worker script is empty") } - thread.handler = handler - thread.requestChan = make(chan *http.Request) - worker.threadMutex.Lock() - worker.threads = append(worker.threads, thread) - worker.threadMutex.Unlock() - - thread.state.set(stateActive) } func (handler *workerThread) getActiveRequest() *http.Request { @@ -52,47 +49,33 @@ func (handler *workerThread) getActiveRequest() *http.Request { return handler.fakeRequest } -func (t *workerThread) isReadyToTransition() bool { - return false -} - // return the name of the script or an empty string if no script should be executed func (handler *workerThread) beforeScriptExecution() string { - currentState := handler.state.get() - switch currentState { - case stateInactive: - handler.state.waitFor(stateActive, stateShuttingDown) - return handler.beforeScriptExecution() + switch handler.state.get() { + case stateTransitionRequested: + thread := handler.thread + handler.worker.removeThread(handler.thread) + thread.state.set(stateTransitionInProgress) + thread.state.waitFor(stateTransitionComplete, stateShuttingDown) + + // execute beforeScriptExecution of the new handler + return thread.handler.beforeScriptExecution() case stateShuttingDown: + // signal to stop return "" case stateRestarting: handler.state.set(stateYielding) handler.state.waitFor(stateReady, stateShuttingDown) return handler.beforeScriptExecution() - case stateReady, stateActive: + case stateReady, stateTransitionComplete: setUpWorkerScript(handler, handler.worker) return handler.worker.fileName } - // TODO: panic? - return "" + panic("unexpected state: " + handler.state.name()) } -// return true if the worker should continue to run -func (handler *workerThread) afterScriptExecution(exitStatus int) bool { +func (handler *workerThread) afterScriptExecution(exitStatus int) { tearDownWorkerScript(handler, exitStatus) - currentState := handler.state.get() - switch currentState { - case stateDrain: - handler.thread.requestChan = make(chan *http.Request) - return true - case stateShuttingDown: - return false - } - return true -} - -func (handler *workerThread) onShutdown() { - handler.state.set(stateDone) } func setUpWorkerScript(handler *workerThread, worker *worker) { @@ -126,11 +109,7 @@ func setUpWorkerScript(handler *workerThread, worker *worker) { func tearDownWorkerScript(handler *workerThread, exitStatus int) { - // if the fake request is nil, no script was executed - if handler.fakeRequest == nil { - return - } - + logger.Info("tear down worker script") // if the worker request is not nil, the script might have crashed // make sure to close the worker request context if handler.workerRequest != nil { @@ -171,14 +150,12 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { } func (handler *workerThread) waitForWorkerRequest() bool { - if c := logger.Check(zapcore.DebugLevel, "waiting for request"); c != nil { c.Write(zap.String("worker", handler.worker.fileName)) } - if handler.state.is(stateActive) { + if handler.state.compareAndSwap(stateTransitionComplete, stateReady) { metrics.ReadyWorker(handler.worker.fileName) - handler.state.set(stateReady) } var r *http.Request @@ -205,7 +182,7 @@ func (handler *workerThread) waitForWorkerRequest() bool { } if err := updateServerContext(handler.thread, r, false, true); err != nil { - // Unexpected error + // Unexpected error or invalid request if c := logger.Check(zapcore.DebugLevel, "unexpected error"); c != nil { c.Write(zap.String("worker", handler.worker.fileName), zap.String("url", r.RequestURI), zap.Error(err)) } diff --git a/worker.go b/worker.go index ba7ec9fb6..0ef61d4c0 100644 --- a/worker.go +++ b/worker.go @@ -140,3 +140,20 @@ func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { <-fc.done metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) } + +func (worker *worker) addThread(thread *phpThread) { + worker.threadMutex.Lock() + worker.threads = append(worker.threads, thread) + worker.threadMutex.Unlock() +} + +func (worker *worker) removeThread(thread *phpThread) { + worker.threadMutex.Lock() + for i, t := range worker.threads { + if t == thread { + worker.threads = append(worker.threads[:i], worker.threads[i+1:]...) + break + } + } + worker.threadMutex.Unlock() +} From ec8aeb7bd11fddc62fe4a997fbb244fc06c3c3d2 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 7 Dec 2024 15:14:32 +0100 Subject: [PATCH 046/190] Adds state tests. --- thread-state.go => state.go | 0 state_test.go | 48 +++++++++++++++++++++++++++++++++++++ thread-state_test.go | 21 ---------------- thread-worker.go | 4 ++-- worker.go | 38 +++++++++++++---------------- 5 files changed, 67 insertions(+), 44 deletions(-) rename thread-state.go => state.go (100%) create mode 100644 state_test.go delete mode 100644 thread-state_test.go diff --git a/thread-state.go b/state.go similarity index 100% rename from thread-state.go rename to state.go diff --git a/state_test.go b/state_test.go new file mode 100644 index 000000000..47b68d410 --- /dev/null +++ b/state_test.go @@ -0,0 +1,48 @@ +package frankenphp + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func Test2GoroutinesYieldToEachOtherViaStates(t *testing.T) { + threadState := &threadState{currentState: stateBooting} + + go func() { + threadState.waitFor(stateInactive) + assert.True(t, threadState.is(stateInactive)) + threadState.set(stateReady) + }() + + threadState.set(stateInactive) + threadState.waitFor(stateReady) + assert.True(t, threadState.is(stateReady)) +} + +func TestStateShouldHaveCorrectAmountOfSubscribers(t *testing.T) { + threadState := &threadState{currentState: stateBooting} + + // 3 subscribers waiting for different states + go threadState.waitFor(stateInactive) + go threadState.waitFor(stateInactive, stateShuttingDown) + go threadState.waitFor(stateShuttingDown) + + time.Sleep(1 * time.Millisecond) + assertNumberOfSubscribers(t, threadState, 3) + + threadState.set(stateInactive) + time.Sleep(1 * time.Millisecond) + assertNumberOfSubscribers(t, threadState, 1) + + threadState.set(stateShuttingDown) + time.Sleep(1 * time.Millisecond) + assertNumberOfSubscribers(t, threadState, 0) +} + +func assertNumberOfSubscribers(t *testing.T, threadState *threadState, expected int) { + threadState.mu.RLock() + assert.Len(t, threadState.subscribers, expected) + threadState.mu.RUnlock() +} diff --git a/thread-state_test.go b/thread-state_test.go deleted file mode 100644 index 28bb3a693..000000000 --- a/thread-state_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package frankenphp - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestYieldToEachOtherViaThreadStates(t *testing.T) { - threadState := &threadState{currentState: stateBooting} - - go func() { - threadState.waitFor(stateInactive) - assert.True(t, threadState.is(stateInactive)) - threadState.set(stateReady) - }() - - threadState.set(stateInactive) - threadState.waitFor(stateReady) - assert.True(t, threadState.is(stateReady)) -} diff --git a/thread-worker.go b/thread-worker.go index be70334d7..75d2433f1 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -35,7 +35,7 @@ func convertToWorkerThread(thread *phpThread, worker *worker) { maxConsecutiveFailures: 6, }, }) - worker.addThread(thread) + worker.attachThread(thread) if worker.fileName == "" { panic("worker script is empty") } @@ -54,7 +54,7 @@ func (handler *workerThread) beforeScriptExecution() string { switch handler.state.get() { case stateTransitionRequested: thread := handler.thread - handler.worker.removeThread(handler.thread) + handler.worker.detachThread(handler.thread) thread.state.set(stateTransitionInProgress) thread.state.waitFor(stateTransitionComplete, stateShuttingDown) diff --git a/worker.go b/worker.go index 0ef61d4c0..374361591 100644 --- a/worker.go +++ b/worker.go @@ -39,7 +39,8 @@ func initWorkers(opt []workerOpt) error { return err } for i := 0; i < worker.num; i++ { - worker.startNewThread() + thread := getInactivePHPThread() + convertToWorkerThread(thread, worker) } } @@ -112,9 +113,21 @@ func getDirectoriesToWatch(workerOpts []workerOpt) []string { return directoriesToWatch } -func (worker *worker) startNewThread() { - thread := getInactivePHPThread() - convertToWorkerThread(thread, worker) +func (worker *worker) attachThread(thread *phpThread) { + worker.threadMutex.Lock() + worker.threads = append(worker.threads, thread) + worker.threadMutex.Unlock() +} + +func (worker *worker) detachThread(thread *phpThread) { + worker.threadMutex.Lock() + for i, t := range worker.threads { + if t == thread { + worker.threads = append(worker.threads[:i], worker.threads[i+1:]...) + break + } + } + worker.threadMutex.Unlock() } func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { @@ -140,20 +153,3 @@ func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { <-fc.done metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) } - -func (worker *worker) addThread(thread *phpThread) { - worker.threadMutex.Lock() - worker.threads = append(worker.threads, thread) - worker.threadMutex.Unlock() -} - -func (worker *worker) removeThread(thread *phpThread) { - worker.threadMutex.Lock() - for i, t := range worker.threads { - if t == thread { - worker.threads = append(worker.threads[:i], worker.threads[i+1:]...) - break - } - } - worker.threadMutex.Unlock() -} From b598bd344ffc0561487266c74bb6d1293cfbe826 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 7 Dec 2024 17:59:50 +0100 Subject: [PATCH 047/190] Adds support for thread transitioning. --- phpmainthread.go | 10 +-- phpmainthread_test.go | 131 +++++++++++++++++++++++++------ phpthread.go | 22 +++++- testdata/sleep.php | 4 - testdata/transition-regular.php | 3 + testdata/transition-worker-1.php | 7 ++ testdata/transition-worker-2.php | 8 ++ 7 files changed, 145 insertions(+), 40 deletions(-) delete mode 100644 testdata/sleep.php create mode 100644 testdata/transition-regular.php create mode 100644 testdata/transition-worker-1.php create mode 100644 testdata/transition-worker-2.php diff --git a/phpmainthread.go b/phpmainthread.go index e9378070a..c0ffb1614 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -4,7 +4,6 @@ package frankenphp import "C" import ( "fmt" - "net/http" "sync" ) @@ -36,12 +35,7 @@ func initPHPThreads(numThreads int) error { // initialize all threads as inactive for i := 0; i < numThreads; i++ { - phpThreads[i] = &phpThread{ - threadIndex: i, - drainChan: make(chan struct{}), - requestChan: make(chan *http.Request), - state: newThreadState(), - } + phpThreads[i] = newPHPThread(i) convertToInactiveThread(phpThreads[i]) } @@ -66,6 +60,7 @@ func drainPHPThreads() { doneWG := sync.WaitGroup{} doneWG.Add(len(phpThreads)) for _, thread := range phpThreads { + thread.mu.Lock() thread.state.set(stateShuttingDown) close(thread.drainChan) } @@ -73,6 +68,7 @@ func drainPHPThreads() { for _, thread := range phpThreads { go func(thread *phpThread) { thread.state.waitFor(stateDone) + thread.mu.Unlock() doneWG.Done() }(thread) } diff --git a/phpmainthread_test.go b/phpmainthread_test.go index f9f46cc15..25e448f2c 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -1,8 +1,14 @@ package frankenphp import ( + "io" + "math/rand/v2" + "net/http/httptest" "path/filepath" + "sync" + "sync/atomic" "testing" + "time" "github.com/stretchr/testify/assert" "go.uber.org/zap" @@ -20,30 +26,23 @@ func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { assert.Nil(t, phpThreads) } -func TestTransition2RegularThreadsToWorkerThreadsAndBack(t *testing.T) { - numThreads := 2 - logger, _ = zap.NewDevelopment() - assert.NoError(t, initPHPThreads(numThreads)) +func TestTransitionRegularThreadToWorkerThread(t *testing.T) { + logger = zap.NewNop() + assert.NoError(t, initPHPThreads(1)) - // transition to worker thread - for i := 0; i < numThreads; i++ { - convertToRegularThread(phpThreads[i]) - assert.IsType(t, ®ularThread{}, phpThreads[i].handler) - } + // transition to regular thread + convertToRegularThread(phpThreads[0]) + assert.IsType(t, ®ularThread{}, phpThreads[0].handler) // transition to worker thread - worker := getDummyWorker() - for i := 0; i < numThreads; i++ { - convertToWorkerThread(phpThreads[i], worker) - assert.IsType(t, &workerThread{}, phpThreads[i].handler) - } - assert.Len(t, worker.threads, numThreads) + worker := getDummyWorker("worker-transition-1.php") + convertToWorkerThread(phpThreads[0], worker) + assert.IsType(t, &workerThread{}, phpThreads[0].handler) + assert.Len(t, worker.threads, 1) // transition back to regular thread - for i := 0; i < numThreads; i++ { - convertToRegularThread(phpThreads[i]) - assert.IsType(t, ®ularThread{}, phpThreads[i].handler) - } + convertToRegularThread(phpThreads[0]) + assert.IsType(t, ®ularThread{}, phpThreads[0].handler) assert.Len(t, worker.threads, 0) drainPHPThreads() @@ -51,26 +50,108 @@ func TestTransition2RegularThreadsToWorkerThreadsAndBack(t *testing.T) { } func TestTransitionAThreadBetween2DifferentWorkers(t *testing.T) { - logger, _ = zap.NewDevelopment() + logger = zap.NewNop() assert.NoError(t, initPHPThreads(1)) + firstWorker := getDummyWorker("worker-transition-1.php") + secondWorker := getDummyWorker("worker-transition-2.php") // convert to first worker thread - firstWorker := getDummyWorker() convertToWorkerThread(phpThreads[0], firstWorker) firstHandler := phpThreads[0].handler.(*workerThread) assert.Same(t, firstWorker, firstHandler.worker) + assert.Len(t, firstWorker.threads, 1) + assert.Len(t, secondWorker.threads, 0) // convert to second worker thread - secondWorker := getDummyWorker() convertToWorkerThread(phpThreads[0], secondWorker) secondHandler := phpThreads[0].handler.(*workerThread) assert.Same(t, secondWorker, secondHandler.worker) + assert.Len(t, firstWorker.threads, 0) + assert.Len(t, secondWorker.threads, 1) drainPHPThreads() assert.Nil(t, phpThreads) } -func getDummyWorker() *worker { - path, _ := filepath.Abs("./testdata/index.php") - return &worker{fileName: path} +func TestTransitionThreadsWhileDoingRequests(t *testing.T) { + numThreads := 10 + numRequestsPerThread := 100 + isRunning := atomic.Bool{} + isRunning.Store(true) + wg := sync.WaitGroup{} + worker1Path, _ := filepath.Abs("./testdata/transition-worker-1.php") + worker2Path, _ := filepath.Abs("./testdata/transition-worker-2.php") + + Init( + WithNumThreads(numThreads), + WithWorkers(worker1Path, 4, map[string]string{"ENV1": "foo"}, []string{}), + WithWorkers(worker2Path, 4, map[string]string{"ENV1": "foo"}, []string{}), + WithLogger(zap.NewNop()), + ) + + // randomly transition threads between regular and 2 worker threads + go func() { + for { + for i := 0; i < numThreads; i++ { + switch rand.IntN(3) { + case 0: + convertToRegularThread(phpThreads[i]) + case 1: + convertToWorkerThread(phpThreads[i], workers[worker1Path]) + case 2: + convertToWorkerThread(phpThreads[i], workers[worker2Path]) + } + time.Sleep(time.Millisecond) + if !isRunning.Load() { + return + } + } + } + }() + + // randomly do requests to the 3 endpoints + wg.Add(numThreads) + for i := 0; i < numThreads; i++ { + go func(i int) { + for j := 0; j < numRequestsPerThread; j++ { + switch rand.IntN(3) { + case 0: + assertRequestBody(t, "http://localhost/transition-worker-1.php", "Hello from worker 1") + case 1: + assertRequestBody(t, "http://localhost/transition-worker-2.php", "Hello from worker 2") + case 2: + assertRequestBody(t, "http://localhost/transition-regular.php", "Hello from regular thread") + } + } + wg.Done() + }(i) + } + + wg.Wait() + isRunning.Store(false) + Shutdown() +} + +func getDummyWorker(fileName string) *worker { + if workers == nil { + workers = make(map[string]*worker) + } + absFileName, _ := filepath.Abs("./testdata/" + fileName) + worker, _ := newWorker(workerOpt{ + fileName: absFileName, + num: 1, + }) + return worker +} + +func assertRequestBody(t *testing.T, url string, expected string) { + r := httptest.NewRequest("GET", url, nil) + w := httptest.NewRecorder() + req, err := NewRequestWithContext(r, WithRequestDocumentRoot("/go/src/app/testdata", false)) + assert.NoError(t, err) + err = ServeHTTP(w, req) + assert.NoError(t, err) + resp := w.Result() + body, _ := io.ReadAll(resp.Body) + assert.Equal(t, expected, string(body)) } diff --git a/phpthread.go b/phpthread.go index 5ee1ff34a..55e96a6de 100644 --- a/phpthread.go +++ b/phpthread.go @@ -5,9 +5,8 @@ import "C" import ( "net/http" "runtime" + "sync" "unsafe" - - "go.uber.org/zap" ) // representation of the actual underlying PHP thread @@ -21,6 +20,7 @@ type phpThread struct { drainChan chan struct{} handler threadHandler state *threadState + mu *sync.Mutex } // interface that defines how the callbacks from the C thread should be handled @@ -30,16 +30,30 @@ type threadHandler interface { getActiveRequest() *http.Request } +func newPHPThread(threadIndex int) *phpThread { + return &phpThread{ + threadIndex: threadIndex, + drainChan: make(chan struct{}), + requestChan: make(chan *http.Request), + mu: &sync.Mutex{}, + state: newThreadState(), + } +} + func (thread *phpThread) getActiveRequest() *http.Request { return thread.handler.getActiveRequest() } // change the thread handler safely func (thread *phpThread) setHandler(handler threadHandler) { - logger.Debug("transitioning thread", zap.Int("threadIndex", thread.threadIndex)) + thread.mu.Lock() + defer thread.mu.Unlock() + if thread.state.is(stateShuttingDown) { + return + } thread.state.set(stateTransitionRequested) close(thread.drainChan) - thread.state.waitFor(stateTransitionInProgress) + thread.state.waitFor(stateTransitionInProgress, stateShuttingDown) thread.handler = handler thread.drainChan = make(chan struct{}) thread.state.set(stateTransitionComplete) diff --git a/testdata/sleep.php b/testdata/sleep.php deleted file mode 100644 index d2c78b865..000000000 --- a/testdata/sleep.php +++ /dev/null @@ -1,4 +0,0 @@ - Date: Sat, 7 Dec 2024 18:06:34 +0100 Subject: [PATCH 048/190] Fixes the testdata path. --- phpmainthread_test.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/phpmainthread_test.go b/phpmainthread_test.go index 25e448f2c..85458323f 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -14,6 +14,8 @@ import ( "go.uber.org/zap" ) +var testDataPath, _ = filepath.Abs("./testdata") + func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { logger = zap.NewNop() // the logger needs to not be nil assert.NoError(t, initPHPThreads(1)) // reserve 1 thread @@ -79,8 +81,8 @@ func TestTransitionThreadsWhileDoingRequests(t *testing.T) { isRunning := atomic.Bool{} isRunning.Store(true) wg := sync.WaitGroup{} - worker1Path, _ := filepath.Abs("./testdata/transition-worker-1.php") - worker2Path, _ := filepath.Abs("./testdata/transition-worker-2.php") + worker1Path := testDataPath + "/transition-worker-1.php" + worker2Path := testDataPath + "/transition-worker-2.php" Init( WithNumThreads(numThreads), @@ -136,9 +138,8 @@ func getDummyWorker(fileName string) *worker { if workers == nil { workers = make(map[string]*worker) } - absFileName, _ := filepath.Abs("./testdata/" + fileName) worker, _ := newWorker(workerOpt{ - fileName: absFileName, + fileName: testDataPath + "/" + fileName, num: 1, }) return worker @@ -147,7 +148,8 @@ func getDummyWorker(fileName string) *worker { func assertRequestBody(t *testing.T, url string, expected string) { r := httptest.NewRequest("GET", url, nil) w := httptest.NewRecorder() - req, err := NewRequestWithContext(r, WithRequestDocumentRoot("/go/src/app/testdata", false)) + + req, err := NewRequestWithContext(r, WithRequestDocumentRoot(testDataPath, false)) assert.NoError(t, err) err = ServeHTTP(w, req) assert.NoError(t, err) From 06af5d580c5662689ad130480d9d96f7ac86e768 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 7 Dec 2024 18:10:52 +0100 Subject: [PATCH 049/190] Formatting. --- frankenphp.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index b4cde79cd..d3780b49d 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -837,8 +837,8 @@ static void *php_thread(void *arg) { // if go signals to stop, break the loop if (scriptName == NULL) { - break; - } + break; + } int exit_status = frankenphp_execute_script(scriptName); go_frankenphp_after_script_execution(thread_index, exit_status); From 71c16bc1527fb248e520ab8269e88a1911ffe5fb Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 7 Dec 2024 18:40:43 +0100 Subject: [PATCH 050/190] Allows transitioning back to inactive state. --- phpmainthread_test.go | 6 +++--- state.go | 5 +++-- thread-inactive.go | 15 +++++++++++---- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/phpmainthread_test.go b/phpmainthread_test.go index 85458323f..d59b46235 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -42,9 +42,9 @@ func TestTransitionRegularThreadToWorkerThread(t *testing.T) { assert.IsType(t, &workerThread{}, phpThreads[0].handler) assert.Len(t, worker.threads, 1) - // transition back to regular thread - convertToRegularThread(phpThreads[0]) - assert.IsType(t, ®ularThread{}, phpThreads[0].handler) + // transition back to inactive thread + convertToInactiveThread(phpThreads[0]) + assert.IsType(t, &inactiveThread{}, phpThreads[0].handler) assert.Len(t, worker.threads, 0) drainPHPThreads() diff --git a/state.go b/state.go index 28a9085ea..4f881b0dc 100644 --- a/state.go +++ b/state.go @@ -9,7 +9,7 @@ import ( type stateID int const ( - // initial state + // livecycle states of a thread stateBooting stateID = iota stateInactive stateReady @@ -20,7 +20,7 @@ const ( stateRestarting stateYielding - // states necessary for transitioning + // states necessary for transitioning between different handlers stateTransitionRequested stateTransitionInProgress stateTransitionComplete @@ -89,6 +89,7 @@ func (h *threadState) set(nextState stateID) { } func (ts *threadState) name() string { + // TODO: return the actual name for logging/metrics return "state:" + strconv.Itoa(int(ts.get())) } diff --git a/thread-inactive.go b/thread-inactive.go index 311ecabed..c2e552262 100644 --- a/thread-inactive.go +++ b/thread-inactive.go @@ -11,7 +11,11 @@ type inactiveThread struct { } func convertToInactiveThread(thread *phpThread) { - thread.handler = &inactiveThread{thread: thread} + if thread.handler == nil { + thread.handler = &inactiveThread{thread: thread} + return + } + thread.setHandler(&inactiveThread{thread: thread}) } func (thread *inactiveThread) getActiveRequest() *http.Request { @@ -20,16 +24,19 @@ func (thread *inactiveThread) getActiveRequest() *http.Request { func (handler *inactiveThread) beforeScriptExecution() string { thread := handler.thread - thread.state.set(stateInactive) - // wait for external signal to start or shut down - thread.state.waitFor(stateTransitionRequested, stateShuttingDown) switch thread.state.get() { case stateTransitionRequested: thread.state.set(stateTransitionInProgress) thread.state.waitFor(stateTransitionComplete, stateShuttingDown) // execute beforeScriptExecution of the new handler return thread.handler.beforeScriptExecution() + case stateBooting, stateTransitionComplete: + // TODO: there's a tiny race condition here between checking and setting + thread.state.set(stateInactive) + // wait for external signal to start or shut down + thread.state.waitFor(stateTransitionRequested, stateShuttingDown) + return handler.beforeScriptExecution() case stateShuttingDown: // signal to stop return "" From 5095342a2b35518226911155725a7181c4288be2 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 7 Dec 2024 19:19:29 +0100 Subject: [PATCH 051/190] Fixes go linting. --- phpmainthread_test.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/phpmainthread_test.go b/phpmainthread_test.go index d59b46235..d826366ee 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -84,24 +84,26 @@ func TestTransitionThreadsWhileDoingRequests(t *testing.T) { worker1Path := testDataPath + "/transition-worker-1.php" worker2Path := testDataPath + "/transition-worker-2.php" - Init( + assert.NoError(t, Init( WithNumThreads(numThreads), - WithWorkers(worker1Path, 4, map[string]string{"ENV1": "foo"}, []string{}), - WithWorkers(worker2Path, 4, map[string]string{"ENV1": "foo"}, []string{}), + WithWorkers(worker1Path, 1, map[string]string{"ENV1": "foo"}, []string{}), + WithWorkers(worker2Path, 1, map[string]string{"ENV1": "foo"}, []string{}), WithLogger(zap.NewNop()), - ) + )) - // randomly transition threads between regular and 2 worker threads + // randomly transition threads between regular, inactive and 2 worker threads go func() { for { for i := 0; i < numThreads; i++ { - switch rand.IntN(3) { + switch rand.IntN(4) { case 0: convertToRegularThread(phpThreads[i]) case 1: convertToWorkerThread(phpThreads[i], workers[worker1Path]) case 2: convertToWorkerThread(phpThreads[i], workers[worker2Path]) + case 3: + convertToInactiveThread(phpThreads[i]) } time.Sleep(time.Millisecond) if !isRunning.Load() { From 4b1805939418e87ed365ec7fe8c00eb79030be9a Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 7 Dec 2024 19:25:07 +0100 Subject: [PATCH 052/190] Formatting. --- phpmainthread_test.go | 2 +- phpthread.go | 8 ++++---- state.go | 10 +++++----- thread-inactive.go | 8 ++++---- thread-worker.go | 20 ++++++++++---------- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/phpmainthread_test.go b/phpmainthread_test.go index d826366ee..601218ee2 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -103,7 +103,7 @@ func TestTransitionThreadsWhileDoingRequests(t *testing.T) { case 2: convertToWorkerThread(phpThreads[i], workers[worker2Path]) case 3: - convertToInactiveThread(phpThreads[i]) + convertToInactiveThread(phpThreads[i]) } time.Sleep(time.Millisecond) if !isRunning.Load() { diff --git a/phpthread.go b/phpthread.go index 55e96a6de..6844d4cd3 100644 --- a/phpthread.go +++ b/phpthread.go @@ -40,10 +40,6 @@ func newPHPThread(threadIndex int) *phpThread { } } -func (thread *phpThread) getActiveRequest() *http.Request { - return thread.handler.getActiveRequest() -} - // change the thread handler safely func (thread *phpThread) setHandler(handler threadHandler) { thread.mu.Lock() @@ -59,6 +55,10 @@ func (thread *phpThread) setHandler(handler threadHandler) { thread.state.set(stateTransitionComplete) } +func (thread *phpThread) getActiveRequest() *http.Request { + return thread.handler.getActiveRequest() +} + // Pin a string that is not null-terminated // PHP's zend_string may contain null-bytes func (thread *phpThread) pinString(s string) *C.char { diff --git a/state.go b/state.go index 4f881b0dc..ee9951841 100644 --- a/state.go +++ b/state.go @@ -61,6 +61,11 @@ func (ts *threadState) compareAndSwap(compareTo stateID, swapTo stateID) bool { return false } +func (ts *threadState) name() string { + // TODO: return the actual name for logging/metrics + return "state:" + strconv.Itoa(int(ts.get())) +} + func (ts *threadState) get() stateID { ts.mu.RLock() defer ts.mu.RUnlock() @@ -88,11 +93,6 @@ func (h *threadState) set(nextState stateID) { h.subscribers = newSubscribers } -func (ts *threadState) name() string { - // TODO: return the actual name for logging/metrics - return "state:" + strconv.Itoa(int(ts.get())) -} - // block until the thread reaches a certain state func (h *threadState) waitFor(states ...stateID) { h.mu.Lock() diff --git a/thread-inactive.go b/thread-inactive.go index c2e552262..f648b6a2f 100644 --- a/thread-inactive.go +++ b/thread-inactive.go @@ -18,10 +18,6 @@ func convertToInactiveThread(thread *phpThread) { thread.setHandler(&inactiveThread{thread: thread}) } -func (thread *inactiveThread) getActiveRequest() *http.Request { - panic("inactive threads have no requests") -} - func (handler *inactiveThread) beforeScriptExecution() string { thread := handler.thread @@ -47,3 +43,7 @@ func (handler *inactiveThread) beforeScriptExecution() string { func (thread *inactiveThread) afterScriptExecution(exitStatus int) { panic("inactive threads should not execute scripts") } + +func (thread *inactiveThread) getActiveRequest() *http.Request { + panic("inactive threads have no requests") +} diff --git a/thread-worker.go b/thread-worker.go index 75d2433f1..d51d1b9e6 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -41,14 +41,6 @@ func convertToWorkerThread(thread *phpThread, worker *worker) { } } -func (handler *workerThread) getActiveRequest() *http.Request { - if handler.workerRequest != nil { - return handler.workerRequest - } - - return handler.fakeRequest -} - // return the name of the script or an empty string if no script should be executed func (handler *workerThread) beforeScriptExecution() string { switch handler.state.get() { @@ -68,7 +60,7 @@ func (handler *workerThread) beforeScriptExecution() string { handler.state.waitFor(stateReady, stateShuttingDown) return handler.beforeScriptExecution() case stateReady, stateTransitionComplete: - setUpWorkerScript(handler, handler.worker) + setupWorkerScript(handler, handler.worker) return handler.worker.fileName } panic("unexpected state: " + handler.state.name()) @@ -78,7 +70,15 @@ func (handler *workerThread) afterScriptExecution(exitStatus int) { tearDownWorkerScript(handler, exitStatus) } -func setUpWorkerScript(handler *workerThread, worker *worker) { +func (handler *workerThread) getActiveRequest() *http.Request { + if handler.workerRequest != nil { + return handler.workerRequest + } + + return handler.fakeRequest +} + +func setupWorkerScript(handler *workerThread, worker *worker) { handler.backoff.wait() metrics.StartWorker(worker.fileName) From 15429d99b690ec0440593949aa652647f3c4480d Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 7 Dec 2024 21:36:10 +0100 Subject: [PATCH 053/190] Removes duplication. --- phpthread.go | 10 ++++++++++ thread-inactive.go | 5 +---- thread-regular.go | 10 +++------- thread-worker.go | 13 ++++--------- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/phpthread.go b/phpthread.go index 6844d4cd3..4bfcffb66 100644 --- a/phpthread.go +++ b/phpthread.go @@ -41,6 +41,7 @@ func newPHPThread(threadIndex int) *phpThread { } // change the thread handler safely +// must be called from outside of the PHP thread func (thread *phpThread) setHandler(handler threadHandler) { thread.mu.Lock() defer thread.mu.Unlock() @@ -55,6 +56,15 @@ func (thread *phpThread) setHandler(handler threadHandler) { thread.state.set(stateTransitionComplete) } +// transition to a new handler safely +// is triggered by setHandler and executed on the PHP thread +func (thread *phpThread) transitionToNewHandler() string { + thread.state.set(stateTransitionInProgress) + thread.state.waitFor(stateTransitionComplete, stateShuttingDown) + // execute beforeScriptExecution of the new handler + return thread.handler.beforeScriptExecution() +} + func (thread *phpThread) getActiveRequest() *http.Request { return thread.handler.getActiveRequest() } diff --git a/thread-inactive.go b/thread-inactive.go index f648b6a2f..d5cfdece7 100644 --- a/thread-inactive.go +++ b/thread-inactive.go @@ -23,10 +23,7 @@ func (handler *inactiveThread) beforeScriptExecution() string { switch thread.state.get() { case stateTransitionRequested: - thread.state.set(stateTransitionInProgress) - thread.state.waitFor(stateTransitionComplete, stateShuttingDown) - // execute beforeScriptExecution of the new handler - return thread.handler.beforeScriptExecution() + return thread.transitionToNewHandler() case stateBooting, stateTransitionComplete: // TODO: there's a tiny race condition here between checking and setting thread.state.set(stateInactive) diff --git a/thread-regular.go b/thread-regular.go index ee9839d2c..6b9dd9569 100644 --- a/thread-regular.go +++ b/thread-regular.go @@ -26,19 +26,15 @@ func convertToRegularThread(thread *phpThread) { func (handler *regularThread) beforeScriptExecution() string { switch handler.state.get() { case stateTransitionRequested: - thread := handler.thread - thread.state.set(stateTransitionInProgress) - thread.state.waitFor(stateTransitionComplete, stateShuttingDown) - // execute beforeScriptExecution of the new handler - return thread.handler.beforeScriptExecution() + return handler.thread.transitionToNewHandler() case stateTransitionComplete: handler.state.set(stateReady) return handler.waitForRequest() + case stateReady: + return handler.waitForRequest() case stateShuttingDown: // signal to stop return "" - case stateReady: - return handler.waitForRequest() } panic("unexpected state: " + handler.state.name()) } diff --git a/thread-worker.go b/thread-worker.go index d51d1b9e6..bf0f7a2e2 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -45,16 +45,8 @@ func convertToWorkerThread(thread *phpThread, worker *worker) { func (handler *workerThread) beforeScriptExecution() string { switch handler.state.get() { case stateTransitionRequested: - thread := handler.thread handler.worker.detachThread(handler.thread) - thread.state.set(stateTransitionInProgress) - thread.state.waitFor(stateTransitionComplete, stateShuttingDown) - - // execute beforeScriptExecution of the new handler - return thread.handler.beforeScriptExecution() - case stateShuttingDown: - // signal to stop - return "" + return handler.thread.transitionToNewHandler() case stateRestarting: handler.state.set(stateYielding) handler.state.waitFor(stateReady, stateShuttingDown) @@ -62,6 +54,9 @@ func (handler *workerThread) beforeScriptExecution() string { case stateReady, stateTransitionComplete: setupWorkerScript(handler, handler.worker) return handler.worker.fileName + case stateShuttingDown: + // signal to stop + return "" } panic("unexpected state: " + handler.state.name()) } From c080608661025a79913b92d453b118a790c9bf7b Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 7 Dec 2024 21:51:29 +0100 Subject: [PATCH 054/190] Applies suggestions by @dunglas --- frankenphp.c | 2 ++ frankenphp.h | 1 + frankenphp_arginfo.h | 2 +- phpmainthread.go | 7 ++++--- testdata/transition-regular.php | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index d3780b49d..9a5d029de 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -823,6 +823,7 @@ static void *php_thread(void *arg) { ZEND_TSRMLS_CACHE_UPDATE(); #endif #endif + local_ctx = malloc(sizeof(frankenphp_server_context)); /* check if a default filter is set in php.ini and only filter if @@ -928,6 +929,7 @@ int frankenphp_new_main_thread(int num_threads) { 0) { return -1; } + return pthread_detach(thread); } diff --git a/frankenphp.h b/frankenphp.h index 2ed926d96..5e498b6c7 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -53,6 +53,7 @@ int frankenphp_request_startup(); int frankenphp_execute_script(char *file_name); int frankenphp_execute_script_cli(char *script, int argc, char **argv); + int frankenphp_execute_php_function(const char *php_function); void frankenphp_register_variables_from_request_info( diff --git a/frankenphp_arginfo.h b/frankenphp_arginfo.h index cecffd88d..c1bd7b550 100644 --- a/frankenphp_arginfo.h +++ b/frankenphp_arginfo.h @@ -49,4 +49,4 @@ static const zend_function_entry ext_functions[] = { ZEND_FALIAS(apache_response_headers, frankenphp_response_headers, arginfo_apache_response_headers) ZEND_FE_END }; -// clang-format on \ No newline at end of file +// clang-format on diff --git a/phpmainthread.go b/phpmainthread.go index c0ffb1614..3039ae064 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -3,8 +3,9 @@ package frankenphp // #include "frankenphp.h" import "C" import ( - "fmt" "sync" + + "go.uber.org/zap" ) // represents the main PHP thread @@ -20,7 +21,7 @@ var ( mainThread *phpMainThread ) -// reserve a fixed number of PHP threads on the go side +// reserve a fixed number of PHP threads on the Go side func initPHPThreads(numThreads int) error { mainThread = &phpMainThread{ state: newThreadState(), @@ -45,7 +46,7 @@ func initPHPThreads(numThreads int) error { for _, thread := range phpThreads { go func() { if !C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) { - panic(fmt.Sprintf("unable to create thread %d", thread.threadIndex)) + logger.Panic("unable to create thread", zap.Int("threadIndex", thread.threadIndex)) } thread.state.waitFor(stateInactive) ready.Done() diff --git a/testdata/transition-regular.php b/testdata/transition-regular.php index c6f3efa95..31c7f436c 100644 --- a/testdata/transition-regular.php +++ b/testdata/transition-regular.php @@ -1,3 +1,3 @@ Date: Sat, 7 Dec 2024 21:57:50 +0100 Subject: [PATCH 055/190] Removes redundant check. --- thread-worker.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/thread-worker.go b/thread-worker.go index bf0f7a2e2..620cfc676 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -36,9 +36,6 @@ func convertToWorkerThread(thread *phpThread, worker *worker) { }, }) worker.attachThread(thread) - if worker.fileName == "" { - panic("worker script is empty") - } } // return the name of the script or an empty string if no script should be executed From 9491e6b25d2a0026b9cca2e1ca98e928098b23fe Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 7 Dec 2024 22:08:22 +0100 Subject: [PATCH 056/190] Locks the handler on restart. --- phpmainthread.go | 4 ++-- phpthread.go | 8 ++++---- worker.go | 2 ++ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/phpmainthread.go b/phpmainthread.go index 3039ae064..d8376883c 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -61,7 +61,7 @@ func drainPHPThreads() { doneWG := sync.WaitGroup{} doneWG.Add(len(phpThreads)) for _, thread := range phpThreads { - thread.mu.Lock() + thread.handlerMu.Lock() thread.state.set(stateShuttingDown) close(thread.drainChan) } @@ -69,7 +69,7 @@ func drainPHPThreads() { for _, thread := range phpThreads { go func(thread *phpThread) { thread.state.waitFor(stateDone) - thread.mu.Unlock() + thread.handlerMu.Unlock() doneWG.Done() }(thread) } diff --git a/phpthread.go b/phpthread.go index 4bfcffb66..f94b498fe 100644 --- a/phpthread.go +++ b/phpthread.go @@ -18,9 +18,9 @@ type phpThread struct { knownVariableKeys map[string]*C.zend_string requestChan chan *http.Request drainChan chan struct{} + handlerMu *sync.Mutex handler threadHandler state *threadState - mu *sync.Mutex } // interface that defines how the callbacks from the C thread should be handled @@ -35,7 +35,7 @@ func newPHPThread(threadIndex int) *phpThread { threadIndex: threadIndex, drainChan: make(chan struct{}), requestChan: make(chan *http.Request), - mu: &sync.Mutex{}, + handlerMu: &sync.Mutex{}, state: newThreadState(), } } @@ -43,8 +43,8 @@ func newPHPThread(threadIndex int) *phpThread { // change the thread handler safely // must be called from outside of the PHP thread func (thread *phpThread) setHandler(handler threadHandler) { - thread.mu.Lock() - defer thread.mu.Unlock() + thread.handlerMu.Lock() + defer thread.handlerMu.Unlock() if thread.state.is(stateShuttingDown) { return } diff --git a/worker.go b/worker.go index 374361591..49ddcc3be 100644 --- a/worker.go +++ b/worker.go @@ -87,6 +87,7 @@ func restartWorkers() { worker.threadMutex.RLock() ready.Add(len(worker.threads)) for _, thread := range worker.threads { + thread.handlerMu.Lock() thread.state.set(stateRestarting) close(thread.drainChan) go func(thread *phpThread) { @@ -100,6 +101,7 @@ func restartWorkers() { for _, thread := range worker.threads { thread.drainChan = make(chan struct{}) thread.state.set(stateReady) + thread.handlerMu.Unlock() } worker.threadMutex.RUnlock() } From e795c86933cbda3fd23f16d96d918a673619b12c Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 8 Dec 2024 01:01:26 +0100 Subject: [PATCH 057/190] Removes unnecessary log. --- thread-worker.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/thread-worker.go b/thread-worker.go index 620cfc676..2f1f8d8fc 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -100,8 +100,6 @@ func setupWorkerScript(handler *workerThread, worker *worker) { } func tearDownWorkerScript(handler *workerThread, exitStatus int) { - - logger.Info("tear down worker script") // if the worker request is not nil, the script might have crashed // make sure to close the worker request context if handler.workerRequest != nil { From 68fa1240392ad5b70dc9c65d4f06588f41271b23 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 8 Dec 2024 13:35:47 +0100 Subject: [PATCH 058/190] Adds frankenphp admin api. --- caddy/admin.go | 71 +++++++++++++++++++++++++++++++++++++ caddy/admin_test.go | 85 +++++++++++++++++++++++++++++++++++++++++++++ caddy/caddy.go | 1 + phpmainthread.go | 2 +- worker.go | 54 ++++++++++++++++++++++++++-- 5 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 caddy/admin.go create mode 100644 caddy/admin_test.go diff --git a/caddy/admin.go b/caddy/admin.go new file mode 100644 index 000000000..a2697174f --- /dev/null +++ b/caddy/admin.go @@ -0,0 +1,71 @@ +package caddy + +import ( + "github.com/caddyserver/caddy/v2" + "github.com/dunglas/frankenphp" + "net/http" + "fmt" +) + +type FrankenPHPAdmin struct{} + +// if the ID starts with admin.api, the module will register AdminRoutes via module.Routes() +func (FrankenPHPAdmin) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "admin.api.frankenphp", + New: func() caddy.Module { return new(FrankenPHPAdmin) }, + } +} + +func (admin FrankenPHPAdmin) Routes() []caddy.AdminRoute { + return []caddy.AdminRoute{ + { + Pattern: "/frankenphp/workers/restart", + Handler: caddy.AdminHandlerFunc(admin.restartWorkers), + }, + { + Pattern: "/frankenphp/workers/add", + Handler: caddy.AdminHandlerFunc(admin.addWorker), + }, + { + Pattern: "/frankenphp/workers/remove", + Handler: caddy.AdminHandlerFunc(admin.removeWorker), + }, + } +} + +func (admin *FrankenPHPAdmin) restartWorkers(w http.ResponseWriter, r *http.Request) error { + caddy.Log().Info("restarting workers from admin api") + frankenphp.RestartWorkers() + _, _ = w.Write([]byte("workers restarted successfully\n")) + + return nil +} + +// experimental +func (admin *FrankenPHPAdmin) addWorker(w http.ResponseWriter, r *http.Request) error { + caddy.Log().Info("adding workers from admin api") + workerPattern := r.URL.Query().Get("filename") + workerFilename, threadCount, err := frankenphp.AddWorkerThread(workerPattern) + if err != nil { + return err + } + message := fmt.Sprintf("New thread count: %d %s\n", threadCount, workerFilename) + _, _ = w.Write([]byte(message)) + + return nil +} + +// experimental +func (admin *FrankenPHPAdmin) removeWorker(w http.ResponseWriter, r *http.Request) error { + caddy.Log().Info("removing workers from admin api") + workerPattern := r.URL.Query().Get("filename") + workerFilename, threadCount, err := frankenphp.RemoveWorkerThread(workerPattern) + if err != nil { + return err + } + message := fmt.Sprintf("New thread count: %d %s\n", threadCount, workerFilename) + _, _ = w.Write([]byte(message)) + + return nil +} \ No newline at end of file diff --git a/caddy/admin_test.go b/caddy/admin_test.go new file mode 100644 index 000000000..af48e3e8e --- /dev/null +++ b/caddy/admin_test.go @@ -0,0 +1,85 @@ +package caddy_test + +import ( + "github.com/caddyserver/caddy/v2/caddytest" + "net/http" + "testing" + "fmt" + "path/filepath" +) + +func TestRestartingWorkerViaAdminApi(t *testing.T) { + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + https_port 9443 + + frankenphp { + worker ../testdata/worker-with-watcher.php 1 + } + } + + localhost:`+testPort+` { + route { + root ../testdata + rewrite worker-with-watcher.php + php + } + } + `, "caddyfile") + + tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1") + tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:2") + + tester.AssertGetResponse("http://localhost:2999/frankenphp/workers/restart", http.StatusOK, "workers restarted successfully\n") + + tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1") +} + +func TestRemovingAndAddingAThreadViaAdminApi(t *testing.T) { + absWorkerPath, _ := filepath.Abs("../testdata/worker-with-watcher.php") + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + https_port 9443 + + frankenphp { + worker ../testdata/worker-with-watcher.php 2 + } + } + + localhost:`+testPort+` { + route { + root ../testdata + rewrite worker-with-watcher.php + php + } + } + `, "caddyfile") + + // make a request to the worker to make sure it's running + tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1") + + // remove a thread + expectedMessage := fmt.Sprintf("New thread count: 1 %s\n",absWorkerPath) + tester.AssertGetResponse("http://localhost:2999/frankenphp/workers/remove", http.StatusOK, expectedMessage) + + // TODO: try removing the last thread + //tester.AssertResponseCode("http://localhost:2999/frankenphp/workers/remove", http.StatusInternalServerError) + + // make a request to the worker to make sure it's still running + tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:2") + + // add a thread + expectedMessage = fmt.Sprintf("New thread count: 2 %s\n",absWorkerPath) + tester.AssertGetResponse("http://localhost:2999/frankenphp/workers/add", http.StatusOK, expectedMessage) + + // make a request to the worker to make sure it's still running + tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:3") +} \ No newline at end of file diff --git a/caddy/caddy.go b/caddy/caddy.go index 4c9998677..ce6e9d1e7 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -30,6 +30,7 @@ const defaultDocumentRoot = "public" func init() { caddy.RegisterModule(FrankenPHPApp{}) caddy.RegisterModule(FrankenPHPModule{}) + caddy.RegisterModule(FrankenPHPAdmin{}) httpcaddyfile.RegisterGlobalOption("frankenphp", parseGlobalOption) diff --git a/phpmainthread.go b/phpmainthread.go index d8376883c..9a5f41192 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -93,7 +93,7 @@ func getInactivePHPThread() *phpThread { return thread } } - panic("not enough threads reserved") + return nil } //export go_frankenphp_main_thread_is_ready diff --git a/worker.go b/worker.go index 49ddcc3be..d6cc468f3 100644 --- a/worker.go +++ b/worker.go @@ -3,10 +3,12 @@ package frankenphp // #include "frankenphp.h" import "C" import ( + "errors" "fmt" "github.com/dunglas/frankenphp/internal/fastabs" "net/http" "sync" + "strings" "time" "github.com/dunglas/frankenphp/internal/watcher" @@ -48,7 +50,7 @@ func initWorkers(opt []workerOpt) error { return nil } - if err := watcher.InitWatcher(directoriesToWatch, restartWorkers, getLogger()); err != nil { + if err := watcher.InitWatcher(directoriesToWatch, RestartWorkers, getLogger()); err != nil { return err } @@ -81,7 +83,48 @@ func drainWorkers() { watcher.DrainWatcher() } -func restartWorkers() { +func AddWorkerThread(pattern string) (string, int, error) { + worker := getWorkerByFilePattern(pattern) + if worker == nil { + return "", 0, errors.New("worker not found") + } + thread := getInactivePHPThread() + if thread == nil { + return "", 0, errors.New("no inactive threads available") + } + convertToWorkerThread(thread, worker) + return worker.fileName, worker.countThreads(), nil +} + +func RemoveWorkerThread(pattern string) (string, int, error) { + worker := getWorkerByFilePattern(pattern) + if worker == nil { + return "", 0, errors.New("worker not found") + } + + worker.threadMutex.RLock() + if len(worker.threads) <= 1 { + worker.threadMutex.RUnlock() + return worker.fileName, 0, errors.New("cannot remove last thread") + } + thread := worker.threads[len(worker.threads)-1] + worker.threadMutex.RUnlock() + convertToInactiveThread(thread) + + return worker.fileName, worker.countThreads(), nil +} + +func getWorkerByFilePattern(pattern string) *worker { + for _, worker := range workers { + if pattern == "" || strings.HasSuffix(worker.fileName, pattern) { + return worker + } + } + + return nil +} + +func RestartWorkers() { ready := sync.WaitGroup{} for _, worker := range workers { worker.threadMutex.RLock() @@ -132,6 +175,13 @@ func (worker *worker) detachThread(thread *phpThread) { worker.threadMutex.Unlock() } +func (worker *worker) countThreads() int { + worker.threadMutex.RLock() + defer worker.threadMutex.RUnlock() + + return len(worker.threads) +} + func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { metrics.StartWorkerRequest(fc.scriptFilename) From b6cbfae304ad9dce653def726587ae5fecd0ae54 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 8 Dec 2024 15:48:32 +0100 Subject: [PATCH 059/190] Allows booting threads at runtime. --- frankenphp.go | 2 +- phpmainthread.go | 29 ++++++++++++++++++----------- phpmainthread_test.go | 8 ++++---- phpthread.go | 15 +++++++++++++++ state.go | 5 +++-- worker.go | 38 +++++++++++++++++++++----------------- 6 files changed, 62 insertions(+), 35 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index 809e4af7d..6337eda1d 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -330,7 +330,7 @@ func Init(options ...Option) error { } requestChan = make(chan *http.Request, opt.numThreads) - if err := initPHPThreads(totalThreadCount); err != nil { + if err := initPHPThreads(totalThreadCount, totalThreadCount); err != nil { return err } diff --git a/phpmainthread.go b/phpmainthread.go index 9a5f41192..477734b96 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -4,8 +4,6 @@ package frankenphp import "C" import ( "sync" - - "go.uber.org/zap" ) // represents the main PHP thread @@ -22,20 +20,20 @@ var ( ) // reserve a fixed number of PHP threads on the Go side -func initPHPThreads(numThreads int) error { +func initPHPThreads(numThreads int, numReservedThreads int) error { mainThread = &phpMainThread{ state: newThreadState(), done: make(chan struct{}), numThreads: numThreads, } - phpThreads = make([]*phpThread, numThreads) + phpThreads = make([]*phpThread, numThreads+numReservedThreads) if err := mainThread.start(); err != nil { return err } // initialize all threads as inactive - for i := 0; i < numThreads; i++ { + for i := 0; i < numThreads+numReservedThreads; i++ { phpThreads[i] = newPHPThread(i) convertToInactiveThread(phpThreads[i]) } @@ -43,12 +41,10 @@ func initPHPThreads(numThreads int) error { // start the underlying C threads ready := sync.WaitGroup{} ready.Add(numThreads) - for _, thread := range phpThreads { + for i := 0; i < numThreads; i++ { + thread := phpThreads[i] go func() { - if !C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) { - logger.Panic("unable to create thread", zap.Int("threadIndex", thread.threadIndex)) - } - thread.state.waitFor(stateInactive) + thread.boot() ready.Done() }() } @@ -61,12 +57,19 @@ func drainPHPThreads() { doneWG := sync.WaitGroup{} doneWG.Add(len(phpThreads)) for _, thread := range phpThreads { + if thread.state.is(stateReserved) { + doneWG.Done() + continue + } thread.handlerMu.Lock() thread.state.set(stateShuttingDown) close(thread.drainChan) } close(mainThread.done) for _, thread := range phpThreads { + if thread.state.is(stateReserved) { + continue + } go func(thread *phpThread) { thread.state.waitFor(stateDone) thread.handlerMu.Unlock() @@ -88,8 +91,12 @@ func (mainThread *phpMainThread) start() error { } func getInactivePHPThread() *phpThread { + return getPHPThreadAtState(stateInactive) +} + +func getPHPThreadAtState(state stateID) *phpThread { for _, thread := range phpThreads { - if thread.state.is(stateInactive) { + if thread.state.is(state) { return thread } } diff --git a/phpmainthread_test.go b/phpmainthread_test.go index 601218ee2..c1e4b913b 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -17,8 +17,8 @@ import ( var testDataPath, _ = filepath.Abs("./testdata") func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { - logger = zap.NewNop() // the logger needs to not be nil - assert.NoError(t, initPHPThreads(1)) // reserve 1 thread + logger = zap.NewNop() // the logger needs to not be nil + assert.NoError(t, initPHPThreads(1, 0)) // boot 1 thread assert.Len(t, phpThreads, 1) assert.Equal(t, 0, phpThreads[0].threadIndex) @@ -30,7 +30,7 @@ func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { func TestTransitionRegularThreadToWorkerThread(t *testing.T) { logger = zap.NewNop() - assert.NoError(t, initPHPThreads(1)) + assert.NoError(t, initPHPThreads(1, 0)) // transition to regular thread convertToRegularThread(phpThreads[0]) @@ -53,7 +53,7 @@ func TestTransitionRegularThreadToWorkerThread(t *testing.T) { func TestTransitionAThreadBetween2DifferentWorkers(t *testing.T) { logger = zap.NewNop() - assert.NoError(t, initPHPThreads(1)) + assert.NoError(t, initPHPThreads(1, 0)) firstWorker := getDummyWorker("worker-transition-1.php") secondWorker := getDummyWorker("worker-transition-2.php") diff --git a/phpthread.go b/phpthread.go index f94b498fe..0bcf9ddd0 100644 --- a/phpthread.go +++ b/phpthread.go @@ -7,6 +7,8 @@ import ( "runtime" "sync" "unsafe" + + "go.uber.org/zap" ) // representation of the actual underlying PHP thread @@ -40,6 +42,19 @@ func newPHPThread(threadIndex int) *phpThread { } } +// boot the underlying PHP thread +func (thread *phpThread) boot() { + // thread must be in reserved state to boot + if !thread.state.compareAndSwap(stateReserved, stateBooting) { + logger.Error("thread is not in reserved state", zap.Int("threadIndex", thread.threadIndex), zap.Int("state", int(thread.state.get()))) + return + } + if !C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) { + logger.Panic("unable to create thread", zap.Int("threadIndex", thread.threadIndex)) + } + thread.state.waitFor(stateInactive) +} + // change the thread handler safely // must be called from outside of the PHP thread func (thread *phpThread) setHandler(handler threadHandler) { diff --git a/state.go b/state.go index ee9951841..3ff1d6064 100644 --- a/state.go +++ b/state.go @@ -10,7 +10,8 @@ type stateID int const ( // livecycle states of a thread - stateBooting stateID = iota + stateReserved stateID = iota + stateBooting stateInactive stateReady stateShuttingDown @@ -39,7 +40,7 @@ type stateSubscriber struct { func newThreadState() *threadState { return &threadState{ - currentState: stateBooting, + currentState: stateReserved, subscribers: []stateSubscriber{}, mu: sync.RWMutex{}, } diff --git a/worker.go b/worker.go index d6cc468f3..4750cd403 100644 --- a/worker.go +++ b/worker.go @@ -7,8 +7,8 @@ import ( "fmt" "github.com/dunglas/frankenphp/internal/fastabs" "net/http" - "sync" "strings" + "sync" "time" "github.com/dunglas/frankenphp/internal/watcher" @@ -86,32 +86,36 @@ func drainWorkers() { func AddWorkerThread(pattern string) (string, int, error) { worker := getWorkerByFilePattern(pattern) if worker == nil { - return "", 0, errors.New("worker not found") - } + return "", 0, errors.New("worker not found") + } thread := getInactivePHPThread() if thread == nil { - return "", 0, errors.New("no inactive threads available") + thread = getPHPThreadAtState(stateReserved) + if thread == nil { + return "", 0, fmt.Errorf("not enough threads reserved: %d", len(phpThreads)) + } + thread.boot() } - convertToWorkerThread(thread, worker) - return worker.fileName, worker.countThreads(), nil + convertToWorkerThread(thread, worker) + return worker.fileName, worker.countThreads(), nil } func RemoveWorkerThread(pattern string) (string, int, error) { worker := getWorkerByFilePattern(pattern) if worker == nil { - return "", 0, errors.New("worker not found") - } + return "", 0, errors.New("worker not found") + } worker.threadMutex.RLock() - if len(worker.threads) <= 1 { - worker.threadMutex.RUnlock() - return worker.fileName, 0, errors.New("cannot remove last thread") - } - thread := worker.threads[len(worker.threads)-1] - worker.threadMutex.RUnlock() - convertToInactiveThread(thread) - - return worker.fileName, worker.countThreads(), nil + if len(worker.threads) <= 1 { + worker.threadMutex.RUnlock() + return worker.fileName, 0, errors.New("cannot remove last thread") + } + thread := worker.threads[len(worker.threads)-1] + worker.threadMutex.RUnlock() + convertToInactiveThread(thread) + + return worker.fileName, worker.countThreads(), nil } func getWorkerByFilePattern(pattern string) *worker { From f185279272c4b253e31746c0fcb1270058d58297 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 8 Dec 2024 22:14:46 +0100 Subject: [PATCH 060/190] Adds proper admin status codes and tests. --- caddy/admin.go | 113 +++++++++++++++++++++++++++++++------------- caddy/admin_test.go | 81 ++++++++++++++++++++++++++----- phpmainthread.go | 11 ++++- worker.go | 15 +++--- 4 files changed, 165 insertions(+), 55 deletions(-) diff --git a/caddy/admin.go b/caddy/admin.go index a2697174f..7906c2fb7 100644 --- a/caddy/admin.go +++ b/caddy/admin.go @@ -1,15 +1,16 @@ package caddy import ( + "fmt" "github.com/caddyserver/caddy/v2" "github.com/dunglas/frankenphp" "net/http" - "fmt" + "strconv" ) type FrankenPHPAdmin struct{} -// if the ID starts with admin.api, the module will register AdminRoutes via module.Routes() +// if the id starts with "admin.api" the module will register AdminRoutes via module.Routes() func (FrankenPHPAdmin) CaddyModule() caddy.ModuleInfo { return caddy.ModuleInfo{ ID: "admin.api.frankenphp", @@ -23,49 +24,97 @@ func (admin FrankenPHPAdmin) Routes() []caddy.AdminRoute { Pattern: "/frankenphp/workers/restart", Handler: caddy.AdminHandlerFunc(admin.restartWorkers), }, - { - Pattern: "/frankenphp/workers/add", - Handler: caddy.AdminHandlerFunc(admin.addWorker), - }, { - Pattern: "/frankenphp/workers/remove", - Handler: caddy.AdminHandlerFunc(admin.removeWorker), - }, + Pattern: "/frankenphp/workers/add", + Handler: caddy.AdminHandlerFunc(admin.addWorkerThreads), + }, + { + Pattern: "/frankenphp/workers/remove", + Handler: caddy.AdminHandlerFunc(admin.removeWorkerThreads), + }, } } func (admin *FrankenPHPAdmin) restartWorkers(w http.ResponseWriter, r *http.Request) error { - caddy.Log().Info("restarting workers from admin api") + if r.Method != http.MethodPost { + return caddy.APIError{ + HTTPStatus: http.StatusMethodNotAllowed, + Err: fmt.Errorf("method not allowed"), + } + } + frankenphp.RestartWorkers() - _, _ = w.Write([]byte("workers restarted successfully\n")) + caddy.Log().Info("workers restarted from admin api") + admin.respond(w, http.StatusOK, "workers restarted successfully\n") return nil } // experimental -func (admin *FrankenPHPAdmin) addWorker(w http.ResponseWriter, r *http.Request) error { - caddy.Log().Info("adding workers from admin api") - workerPattern := r.URL.Query().Get("filename") - workerFilename, threadCount, err := frankenphp.AddWorkerThread(workerPattern) - if err != nil { - return err +func (admin *FrankenPHPAdmin) addWorkerThreads(w http.ResponseWriter, r *http.Request) error { + if r.Method != http.MethodPost { + return caddy.APIError{ + HTTPStatus: http.StatusMethodNotAllowed, + Err: fmt.Errorf("method not allowed"), + } } - message := fmt.Sprintf("New thread count: %d %s\n", threadCount, workerFilename) - _, _ = w.Write([]byte(message)) - return nil + workerPattern := r.URL.Query().Get("file") + message := "" + for i := 0; i < admin.getCountFromRequest(r); i++ { + workerFilename, threadCount, err := frankenphp.AddWorkerThread(workerPattern) + if err != nil { + return caddy.APIError{ + HTTPStatus: http.StatusBadRequest, + Err: err, + } + } + message = fmt.Sprintf("New thread count: %d %s\n", threadCount, workerFilename) + } + + caddy.Log().Debug(message) + return admin.respond(w, http.StatusOK, message) } -// experimental -func (admin *FrankenPHPAdmin) removeWorker(w http.ResponseWriter, r *http.Request) error { - caddy.Log().Info("removing workers from admin api") - workerPattern := r.URL.Query().Get("filename") - workerFilename, threadCount, err := frankenphp.RemoveWorkerThread(workerPattern) - if err != nil { - return err - } - message := fmt.Sprintf("New thread count: %d %s\n", threadCount, workerFilename) - _, _ = w.Write([]byte(message)) +func (admin *FrankenPHPAdmin) removeWorkerThreads(w http.ResponseWriter, r *http.Request) error { + if r.Method != http.MethodPost { + return caddy.APIError{ + HTTPStatus: http.StatusMethodNotAllowed, + Err: fmt.Errorf("method not allowed"), + } + } - return nil -} \ No newline at end of file + workerPattern := r.URL.Query().Get("file") + message := "" + for i := 0; i < admin.getCountFromRequest(r); i++ { + workerFilename, threadCount, err := frankenphp.RemoveWorkerThread(workerPattern) + if err != nil { + return caddy.APIError{ + HTTPStatus: http.StatusBadRequest, + Err: err, + } + } + message = fmt.Sprintf("New thread count: %d %s\n", threadCount, workerFilename) + } + + caddy.Log().Debug(message) + return admin.respond(w, http.StatusOK, message) +} + +func (admin *FrankenPHPAdmin) respond(w http.ResponseWriter, statusCode int, message string) error { + w.WriteHeader(statusCode) + _, err := w.Write([]byte(message)) + return err +} + +func (admin *FrankenPHPAdmin) getCountFromRequest(r *http.Request) int { + value := r.URL.Query().Get("count") + if value == "" { + return 1 + } + i, err := strconv.Atoi(value) + if err != nil { + return 1 + } + return i +} diff --git a/caddy/admin_test.go b/caddy/admin_test.go index af48e3e8e..9b371f148 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -1,11 +1,11 @@ package caddy_test import ( + "fmt" "github.com/caddyserver/caddy/v2/caddytest" "net/http" - "testing" - "fmt" "path/filepath" + "testing" ) func TestRestartingWorkerViaAdminApi(t *testing.T) { @@ -34,12 +34,12 @@ func TestRestartingWorkerViaAdminApi(t *testing.T) { tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1") tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:2") - tester.AssertGetResponse("http://localhost:2999/frankenphp/workers/restart", http.StatusOK, "workers restarted successfully\n") + assertAdminResponse(tester, "POST", "restart", http.StatusOK, "workers restarted successfully\n") tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1") } -func TestRemovingAndAddingAThreadViaAdminApi(t *testing.T) { +func TestRemoveThreadsViaAdminApi(t *testing.T) { absWorkerPath, _ := filepath.Abs("../testdata/worker-with-watcher.php") tester := caddytest.NewTester(t) tester.InitServer(` @@ -50,7 +50,7 @@ func TestRemovingAndAddingAThreadViaAdminApi(t *testing.T) { https_port 9443 frankenphp { - worker ../testdata/worker-with-watcher.php 2 + worker ../testdata/worker-with-watcher.php 4 } } @@ -67,19 +67,74 @@ func TestRemovingAndAddingAThreadViaAdminApi(t *testing.T) { tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1") // remove a thread - expectedMessage := fmt.Sprintf("New thread count: 1 %s\n",absWorkerPath) - tester.AssertGetResponse("http://localhost:2999/frankenphp/workers/remove", http.StatusOK, expectedMessage) + expectedMessage := fmt.Sprintf("New thread count: 3 %s\n", absWorkerPath) + assertAdminResponse(tester, "POST", "remove", http.StatusOK, expectedMessage) + + // remove 2 threads + expectedMessage = fmt.Sprintf("New thread count: 1 %s\n", absWorkerPath) + assertAdminResponse(tester, "POST", "remove?count=2", http.StatusOK, expectedMessage) - // TODO: try removing the last thread - //tester.AssertResponseCode("http://localhost:2999/frankenphp/workers/remove", http.StatusInternalServerError) + // get 400 status if removing the last thread + assertAdminResponse(tester, "POST", "remove", http.StatusBadRequest, "") // make a request to the worker to make sure it's still running tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:2") +} + +func TestAddThreadsViaAdminApi(t *testing.T) { + absWorkerPath, _ := filepath.Abs("../testdata/worker-with-watcher.php") + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + https_port 9443 + + frankenphp { + worker ../testdata/worker-with-watcher.php 1 + } + } + + localhost:`+testPort+` { + route { + root ../testdata + rewrite worker-with-watcher.php + php + } + } + `, "caddyfile") + + // make a request to the worker to make sure it's running + tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1") + + // get 400 status if the filename is wrong + assertAdminResponse(tester, "POST", "add?file=wrong.php", http.StatusBadRequest, "") // add a thread - expectedMessage = fmt.Sprintf("New thread count: 2 %s\n",absWorkerPath) - tester.AssertGetResponse("http://localhost:2999/frankenphp/workers/add", http.StatusOK, expectedMessage) + expectedMessage := fmt.Sprintf("New thread count: 2 %s\n", absWorkerPath) + assertAdminResponse(tester, "POST", "add", http.StatusOK, expectedMessage) + + // add 2 threads + expectedMessage = fmt.Sprintf("New thread count: 4 %s\n", absWorkerPath) + assertAdminResponse(tester, "POST", "add?count=2", http.StatusOK, expectedMessage) + + // get 400 status if adding too many threads + assertAdminResponse(tester, "POST", "add?count=100", http.StatusBadRequest, "") // make a request to the worker to make sure it's still running - tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:3") -} \ No newline at end of file + tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:2") +} + +func assertAdminResponse(tester *caddytest.Tester, method string, path string, expectedStatus int, expectedBody string) { + adminUrl := "http://localhost:2999/frankenphp/workers/" + r, err := http.NewRequest(method, adminUrl+path, nil) + if err != nil { + panic(err) + } + if expectedBody == "" { + tester.AssertResponseCode(r, expectedStatus) + } else { + tester.AssertResponse(r, expectedStatus, expectedBody) + } +} diff --git a/phpmainthread.go b/phpmainthread.go index 477734b96..ef6a63fe2 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -91,7 +91,16 @@ func (mainThread *phpMainThread) start() error { } func getInactivePHPThread() *phpThread { - return getPHPThreadAtState(stateInactive) + thread := getPHPThreadAtState(stateInactive) + if thread != nil { + return thread + } + thread = getPHPThreadAtState(stateReserved) + if thread == nil { + return nil + } + thread.boot() + return thread } func getPHPThreadAtState(state stateID) *phpThread { diff --git a/worker.go b/worker.go index 4750cd403..f3e39a9c5 100644 --- a/worker.go +++ b/worker.go @@ -83,25 +83,21 @@ func drainWorkers() { watcher.DrainWatcher() } -func AddWorkerThread(pattern string) (string, int, error) { - worker := getWorkerByFilePattern(pattern) +func AddWorkerThread(workerFileName string) (string, int, error) { + worker := getWorkerByFilePattern(workerFileName) if worker == nil { return "", 0, errors.New("worker not found") } thread := getInactivePHPThread() if thread == nil { - thread = getPHPThreadAtState(stateReserved) - if thread == nil { - return "", 0, fmt.Errorf("not enough threads reserved: %d", len(phpThreads)) - } - thread.boot() + return "", 0, fmt.Errorf("max amount of threads reached: %d", len(phpThreads)) } convertToWorkerThread(thread, worker) return worker.fileName, worker.countThreads(), nil } -func RemoveWorkerThread(pattern string) (string, int, error) { - worker := getWorkerByFilePattern(pattern) +func RemoveWorkerThread(workerFileName string) (string, int, error) { + worker := getWorkerByFilePattern(workerFileName) if worker == nil { return "", 0, errors.New("worker not found") } @@ -118,6 +114,7 @@ func RemoveWorkerThread(pattern string) (string, int, error) { return worker.fileName, worker.countThreads(), nil } +// get the first worker ending in the given pattern func getWorkerByFilePattern(pattern string) *worker { for _, worker := range workers { if pattern == "" || strings.HasSuffix(worker.fileName, pattern) { From ea0a4fe303ba9922a145e9ecff6b970824136bb9 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 8 Dec 2024 22:18:35 +0100 Subject: [PATCH 061/190] Makes config smaller. --- caddy/admin_test.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/caddy/admin_test.go b/caddy/admin_test.go index 9b371f148..46177e7b8 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -15,7 +15,6 @@ func TestRestartingWorkerViaAdminApi(t *testing.T) { skip_install_trust admin localhost:2999 http_port `+testPort+` - https_port 9443 frankenphp { worker ../testdata/worker-with-watcher.php 1 @@ -47,7 +46,6 @@ func TestRemoveThreadsViaAdminApi(t *testing.T) { skip_install_trust admin localhost:2999 http_port `+testPort+` - https_port 9443 frankenphp { worker ../testdata/worker-with-watcher.php 4 @@ -89,7 +87,6 @@ func TestAddThreadsViaAdminApi(t *testing.T) { skip_install_trust admin localhost:2999 http_port `+testPort+` - https_port 9443 frankenphp { worker ../testdata/worker-with-watcher.php 1 From fcb5f8c188b363c62751bbbc659b6c6a1dfa312d Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 8 Dec 2024 23:20:41 +0100 Subject: [PATCH 062/190] Adds max threads option and debug status. --- caddy/admin.go | 11 ++++++++++- caddy/caddy.go | 20 +++++++++++++++++++- docs/config.md | 1 + frankenphp.go | 18 +++++++++++++----- options.go | 9 +++++++++ phpmainthread.go | 25 +++++++++++++++++++++---- phpmainthread_test.go | 6 +++--- phpthread.go | 14 ++++++++++++++ state.go | 18 +++++++++++++++--- worker.go | 1 + 10 files changed, 106 insertions(+), 17 deletions(-) diff --git a/caddy/admin.go b/caddy/admin.go index 7906c2fb7..f0daacd7d 100644 --- a/caddy/admin.go +++ b/caddy/admin.go @@ -24,6 +24,10 @@ func (admin FrankenPHPAdmin) Routes() []caddy.AdminRoute { Pattern: "/frankenphp/workers/restart", Handler: caddy.AdminHandlerFunc(admin.restartWorkers), }, + { + Pattern: "/frankenphp/threads/status", + Handler: caddy.AdminHandlerFunc(admin.showThreadStatus), + }, { Pattern: "/frankenphp/workers/add", Handler: caddy.AdminHandlerFunc(admin.addWorkerThreads), @@ -50,7 +54,12 @@ func (admin *FrankenPHPAdmin) restartWorkers(w http.ResponseWriter, r *http.Requ return nil } -// experimental +func (admin *FrankenPHPAdmin) showThreadStatus(w http.ResponseWriter, r *http.Request) error { + admin.respond(w, http.StatusOK, frankenphp.ThreadDebugStatus()) + + return nil +} + func (admin *FrankenPHPAdmin) addWorkerThreads(w http.ResponseWriter, r *http.Request) error { if r.Method != http.MethodPost { return caddy.APIError{ diff --git a/caddy/caddy.go b/caddy/caddy.go index ce6e9d1e7..10d6be737 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -71,6 +71,8 @@ type workerConfig struct { type FrankenPHPApp struct { // NumThreads sets the number of PHP threads to start. Default: 2x the number of available CPUs. NumThreads int `json:"num_threads,omitempty"` + // MaxThreads limits how many threads can be started at runtime. Default 2x NumThreads + MaxThreads int `json:"max_threads,omitempty"` // Workers configures the worker scripts to start. Workers []workerConfig `json:"workers,omitempty"` } @@ -87,7 +89,12 @@ func (f *FrankenPHPApp) Start() error { repl := caddy.NewReplacer() logger := caddy.Log() - opts := []frankenphp.Option{frankenphp.WithNumThreads(f.NumThreads), frankenphp.WithLogger(logger), frankenphp.WithMetrics(metrics)} + opts := []frankenphp.Option{ + frankenphp.WithNumThreads(f.NumThreads), + frankenphp.WithMaxThreads(f.MaxThreads), + frankenphp.WithLogger(logger), + frankenphp.WithMetrics(metrics), + } for _, w := range f.Workers { opts = append(opts, frankenphp.WithWorkers(repl.ReplaceKnown(w.FileName, ""), w.Num, w.Env, w.Watch)) } @@ -138,6 +145,17 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } f.NumThreads = v + case "max_threads": + if !d.NextArg() { + return d.ArgErr() + } + + v, err := strconv.Atoi(d.Val()) + if err != nil { + return err + } + + f.MaxThreads = v case "worker": wc := workerConfig{} if d.NextArg() { diff --git a/docs/config.md b/docs/config.md index f79ea07fa..d39d6da35 100644 --- a/docs/config.md +++ b/docs/config.md @@ -51,6 +51,7 @@ Optionally, the number of threads to create and [worker scripts](worker.md) to s { frankenphp { 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: 2x the number of num_threads. worker { file # Sets the path to the worker script. num # Sets the number of PHP threads to start, defaults to 2x the number of available CPUs. diff --git a/frankenphp.go b/frankenphp.go index 6337eda1d..8e63b979c 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -242,7 +242,7 @@ func Config() PHPConfig { // MaxThreads is internally used during tests. It is written to, but never read and may go away in the future. var MaxThreads int -func calculateMaxThreads(opt *opt) (int, int, error) { +func calculateMaxThreads(opt *opt) (int, int, int, error) { maxProcs := runtime.GOMAXPROCS(0) * 2 var numWorkers int @@ -264,13 +264,21 @@ func calculateMaxThreads(opt *opt) (int, int, error) { opt.numThreads = maxProcs } } else if opt.numThreads <= numWorkers { - return opt.numThreads, numWorkers, NotEnoughThreads + return opt.numThreads, numWorkers, opt.maxThreads, NotEnoughThreads + } + + // default maxThreads to 2x the number of threads + if opt.maxThreads == 0 { + opt.maxThreads = 2 * opt.numThreads + } + if opt.maxThreads < opt.numThreads { + opt.maxThreads = opt.numThreads } metrics.TotalThreads(opt.numThreads) MaxThreads = opt.numThreads - return opt.numThreads, numWorkers, nil + return opt.numThreads, numWorkers, opt.maxThreads, nil } // Init starts the PHP runtime and the configured workers. @@ -309,7 +317,7 @@ func Init(options ...Option) error { metrics = opt.metrics } - totalThreadCount, workerThreadCount, err := calculateMaxThreads(opt) + totalThreadCount, workerThreadCount, maxThreadCount, err := calculateMaxThreads(opt) if err != nil { return err } @@ -330,7 +338,7 @@ func Init(options ...Option) error { } requestChan = make(chan *http.Request, opt.numThreads) - if err := initPHPThreads(totalThreadCount, totalThreadCount); err != nil { + if err := initPHPThreads(totalThreadCount, maxThreadCount); err != nil { return err } diff --git a/options.go b/options.go index 724e75c8b..7795399be 100644 --- a/options.go +++ b/options.go @@ -12,6 +12,7 @@ type Option func(h *opt) error // If you change this, also update the Caddy module and the documentation. type opt struct { numThreads int + maxThreads int workers []workerOpt logger *zap.Logger metrics Metrics @@ -33,6 +34,14 @@ func WithNumThreads(numThreads int) Option { } } +func WithMaxThreads(maxThreads int) Option { + return func(o *opt) error { + o.maxThreads = maxThreads + + return nil + } +} + func WithMetrics(m Metrics) Option { return func(o *opt) error { o.metrics = m diff --git a/phpmainthread.go b/phpmainthread.go index ef6a63fe2..109a5c057 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -3,6 +3,7 @@ package frankenphp // #include "frankenphp.h" import "C" import ( + "fmt" "sync" ) @@ -19,21 +20,23 @@ var ( mainThread *phpMainThread ) -// reserve a fixed number of PHP threads on the Go side -func initPHPThreads(numThreads int, numReservedThreads int) error { +// start the main PHP thread +// start a fixed number of inactive PHP threads +// reserve a fixed number of possible PHP threads +func initPHPThreads(numThreads int, numMaxThreads int) error { mainThread = &phpMainThread{ state: newThreadState(), done: make(chan struct{}), numThreads: numThreads, } - phpThreads = make([]*phpThread, numThreads+numReservedThreads) + phpThreads = make([]*phpThread, numMaxThreads) if err := mainThread.start(); err != nil { return err } // initialize all threads as inactive - for i := 0; i < numThreads+numReservedThreads; i++ { + for i := 0; i < numMaxThreads; i++ { phpThreads[i] = newPHPThread(i) convertToInactiveThread(phpThreads[i]) } @@ -53,6 +56,20 @@ func initPHPThreads(numThreads int, numReservedThreads int) error { return nil } +func ThreadDebugStatus() string { + statusMessage := "" + reservedThreadCount := 0 + for _, thread := range phpThreads { + if thread.state.is(stateReserved) { + reservedThreadCount++ + continue + } + statusMessage += thread.debugStatus() + "\n" + } + statusMessage += fmt.Sprintf("%d additional threads can be started at runtime\n", reservedThreadCount) + return statusMessage +} + func drainPHPThreads() { doneWG := sync.WaitGroup{} doneWG.Add(len(phpThreads)) diff --git a/phpmainthread_test.go b/phpmainthread_test.go index c1e4b913b..fdd2cf4d7 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -18,7 +18,7 @@ var testDataPath, _ = filepath.Abs("./testdata") func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { logger = zap.NewNop() // the logger needs to not be nil - assert.NoError(t, initPHPThreads(1, 0)) // boot 1 thread + assert.NoError(t, initPHPThreads(1, 1)) // boot 1 thread assert.Len(t, phpThreads, 1) assert.Equal(t, 0, phpThreads[0].threadIndex) @@ -30,7 +30,7 @@ func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { func TestTransitionRegularThreadToWorkerThread(t *testing.T) { logger = zap.NewNop() - assert.NoError(t, initPHPThreads(1, 0)) + assert.NoError(t, initPHPThreads(1, 1)) // transition to regular thread convertToRegularThread(phpThreads[0]) @@ -53,7 +53,7 @@ func TestTransitionRegularThreadToWorkerThread(t *testing.T) { func TestTransitionAThreadBetween2DifferentWorkers(t *testing.T) { logger = zap.NewNop() - assert.NoError(t, initPHPThreads(1, 0)) + assert.NoError(t, initPHPThreads(1, 1)) firstWorker := getDummyWorker("worker-transition-1.php") secondWorker := getDummyWorker("worker-transition-2.php") diff --git a/phpthread.go b/phpthread.go index 0bcf9ddd0..4fc038b8a 100644 --- a/phpthread.go +++ b/phpthread.go @@ -3,6 +3,7 @@ package frankenphp // #include "frankenphp.h" import "C" import ( + "fmt" "net/http" "runtime" "sync" @@ -84,6 +85,19 @@ func (thread *phpThread) getActiveRequest() *http.Request { return thread.handler.getActiveRequest() } +// small status message for debugging +func (thread *phpThread) debugStatus() string { + threadType := "Thread" + thread.handlerMu.Lock() + if handler, ok := thread.handler.(*workerThread); ok { + threadType = "Worker PHP Thread - " + handler.worker.fileName + } else if _, ok := thread.handler.(*regularThread); ok { + threadType = "Regular PHP Thread" + } + thread.handlerMu.Unlock() + return fmt.Sprintf("Thread %d (%s) %s", thread.threadIndex, thread.state.name(), threadType) +} + // Pin a string that is not null-terminated // PHP's zend_string may contain null-bytes func (thread *phpThread) pinString(s string) *C.char { diff --git a/state.go b/state.go index 3ff1d6064..8c27cb930 100644 --- a/state.go +++ b/state.go @@ -2,7 +2,6 @@ package frankenphp import ( "slices" - "strconv" "sync" ) @@ -27,6 +26,20 @@ const ( stateTransitionComplete ) +var stateNames = map[stateID]string{ + stateReserved: "reserved", + stateBooting: "booting", + stateInactive: "inactive", + stateReady: "ready", + stateShuttingDown: "shutting down", + stateDone: "done", + stateRestarting: "restarting", + stateYielding: "yielding", + stateTransitionRequested: "transition requested", + stateTransitionInProgress: "transition in progress", + stateTransitionComplete: "transition complete", +} + type threadState struct { currentState stateID mu sync.RWMutex @@ -63,8 +76,7 @@ func (ts *threadState) compareAndSwap(compareTo stateID, swapTo stateID) bool { } func (ts *threadState) name() string { - // TODO: return the actual name for logging/metrics - return "state:" + strconv.Itoa(int(ts.get())) + return stateNames[ts.get()] } func (ts *threadState) get() stateID { diff --git a/worker.go b/worker.go index f3e39a9c5..38e04bebb 100644 --- a/worker.go +++ b/worker.go @@ -131,6 +131,7 @@ func RestartWorkers() { worker.threadMutex.RLock() ready.Add(len(worker.threads)) for _, thread := range worker.threads { + // disallow changing handler while restarting thread.handlerMu.Lock() thread.state.set(stateRestarting) close(thread.drainChan) From a43ecbe3df1c1139eafec5c0a2b8041ab0dd08e8 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 8 Dec 2024 23:36:00 +0100 Subject: [PATCH 063/190] Adds test with debug message. --- caddy/admin_test.go | 74 ++++++++++++++----- caddy/watcher_test.go | 4 +- ...th-watcher.php => worker-with-counter.php} | 0 watcher_test.go | 8 +- 4 files changed, 63 insertions(+), 23 deletions(-) rename testdata/{worker-with-watcher.php => worker-with-counter.php} (100%) diff --git a/caddy/admin_test.go b/caddy/admin_test.go index 46177e7b8..7b27408be 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -17,14 +17,14 @@ func TestRestartingWorkerViaAdminApi(t *testing.T) { http_port `+testPort+` frankenphp { - worker ../testdata/worker-with-watcher.php 1 + worker ../testdata/worker-with-counter.php 1 } } localhost:`+testPort+` { route { root ../testdata - rewrite worker-with-watcher.php + rewrite worker-with-counter.php php } } @@ -33,13 +33,13 @@ func TestRestartingWorkerViaAdminApi(t *testing.T) { tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1") tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:2") - assertAdminResponse(tester, "POST", "restart", http.StatusOK, "workers restarted successfully\n") + assertAdminResponse(tester, "POST", "workers/restart", http.StatusOK, "workers restarted successfully\n") tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1") } func TestRemoveThreadsViaAdminApi(t *testing.T) { - absWorkerPath, _ := filepath.Abs("../testdata/worker-with-watcher.php") + absWorkerPath, _ := filepath.Abs("../testdata/worker-with-counter.php") tester := caddytest.NewTester(t) tester.InitServer(` { @@ -48,14 +48,14 @@ func TestRemoveThreadsViaAdminApi(t *testing.T) { http_port `+testPort+` frankenphp { - worker ../testdata/worker-with-watcher.php 4 + worker ../testdata/worker-with-counter.php 4 } } localhost:`+testPort+` { route { root ../testdata - rewrite worker-with-watcher.php + rewrite worker-with-counter.php php } } @@ -66,21 +66,21 @@ func TestRemoveThreadsViaAdminApi(t *testing.T) { // remove a thread expectedMessage := fmt.Sprintf("New thread count: 3 %s\n", absWorkerPath) - assertAdminResponse(tester, "POST", "remove", http.StatusOK, expectedMessage) + assertAdminResponse(tester, "POST", "workers/remove", http.StatusOK, expectedMessage) // remove 2 threads expectedMessage = fmt.Sprintf("New thread count: 1 %s\n", absWorkerPath) - assertAdminResponse(tester, "POST", "remove?count=2", http.StatusOK, expectedMessage) + assertAdminResponse(tester, "POST", "workers/remove?count=2", http.StatusOK, expectedMessage) // get 400 status if removing the last thread - assertAdminResponse(tester, "POST", "remove", http.StatusBadRequest, "") + assertAdminResponse(tester, "POST", "workers/remove", http.StatusBadRequest, "") // make a request to the worker to make sure it's still running tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:2") } func TestAddThreadsViaAdminApi(t *testing.T) { - absWorkerPath, _ := filepath.Abs("../testdata/worker-with-watcher.php") + absWorkerPath, _ := filepath.Abs("../testdata/worker-with-counter.php") tester := caddytest.NewTester(t) tester.InitServer(` { @@ -89,14 +89,14 @@ func TestAddThreadsViaAdminApi(t *testing.T) { http_port `+testPort+` frankenphp { - worker ../testdata/worker-with-watcher.php 1 + worker ../testdata/worker-with-counter.php 1 } } localhost:`+testPort+` { route { root ../testdata - rewrite worker-with-watcher.php + rewrite worker-with-counter.php php } } @@ -106,25 +106,65 @@ func TestAddThreadsViaAdminApi(t *testing.T) { tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1") // get 400 status if the filename is wrong - assertAdminResponse(tester, "POST", "add?file=wrong.php", http.StatusBadRequest, "") + assertAdminResponse(tester, "POST", "workers/add?file=wrong.php", http.StatusBadRequest, "") // add a thread expectedMessage := fmt.Sprintf("New thread count: 2 %s\n", absWorkerPath) - assertAdminResponse(tester, "POST", "add", http.StatusOK, expectedMessage) + assertAdminResponse(tester, "POST", "workers/add", http.StatusOK, expectedMessage) // add 2 threads expectedMessage = fmt.Sprintf("New thread count: 4 %s\n", absWorkerPath) - assertAdminResponse(tester, "POST", "add?count=2", http.StatusOK, expectedMessage) + assertAdminResponse(tester, "POST", "workers/add?count=2", http.StatusOK, expectedMessage) // get 400 status if adding too many threads - assertAdminResponse(tester, "POST", "add?count=100", http.StatusBadRequest, "") + assertAdminResponse(tester, "POST", "workers/add?count=100", http.StatusBadRequest, "") // make a request to the worker to make sure it's still running tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:2") } +func TestShowTheCorrectThreadDebugStatus(t *testing.T) { + absWorker1Path, _ := filepath.Abs("../testdata/worker-with-counter.php") + absWorker2Path, _ := filepath.Abs("../testdata/index.php") + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + + frankenphp { + num_threads 6 + max_threads 12 + worker ../testdata/worker-with-counter.php 2 + worker ../testdata/index.php 2 + } + } + + localhost:`+testPort+` { + route { + root ../testdata + rewrite worker-with-counter.php + php + } + } + `, "caddyfile") + + assertAdminResponse(tester, "POST", "workers/remove?file=index.php", http.StatusOK, "") + + // assert that all threads are in the right state via debug message + assertAdminResponse(tester, "GET", "threads/status", http.StatusOK, `Thread 0 (ready) Regular PHP Thread +Thread 1 (ready) Regular PHP Thread +Thread 2 (ready) Worker PHP Thread - `+absWorker1Path+` +Thread 3 (ready) Worker PHP Thread - `+absWorker1Path+` +Thread 4 (ready) Worker PHP Thread - `+absWorker2Path+` +Thread 5 (inactive) Thread +6 additional threads can be started at runtime +`) +} + func assertAdminResponse(tester *caddytest.Tester, method string, path string, expectedStatus int, expectedBody string) { - adminUrl := "http://localhost:2999/frankenphp/workers/" + adminUrl := "http://localhost:2999/frankenphp/" r, err := http.NewRequest(method, adminUrl+path, nil) if err != nil { panic(err) diff --git a/caddy/watcher_test.go b/caddy/watcher_test.go index aad782c54..63801b870 100644 --- a/caddy/watcher_test.go +++ b/caddy/watcher_test.go @@ -19,7 +19,7 @@ func TestWorkerWithInactiveWatcher(t *testing.T) { frankenphp { worker { - file ../testdata/worker-with-watcher.php + file ../testdata/worker-with-counter.php num 1 watch ./**/*.php } @@ -28,7 +28,7 @@ func TestWorkerWithInactiveWatcher(t *testing.T) { localhost:`+testPort+` { root ../testdata - rewrite worker-with-watcher.php + rewrite worker-with-counter.php php } `, "caddyfile") diff --git a/testdata/worker-with-watcher.php b/testdata/worker-with-counter.php similarity index 100% rename from testdata/worker-with-watcher.php rename to testdata/worker-with-counter.php diff --git a/watcher_test.go b/watcher_test.go index 7f46b4b90..747b6d35b 100644 --- a/watcher_test.go +++ b/watcher_test.go @@ -29,7 +29,7 @@ func TestWorkersShouldReloadOnMatchingPattern(t *testing.T) { runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) { requestBodyHasReset := pollForWorkerReset(t, handler, maxTimesToPollForChanges) assert.True(t, requestBodyHasReset) - }, &testOptions{nbParrallelRequests: 1, nbWorkers: 1, workerScript: "worker-with-watcher.php", watch: watch}) + }, &testOptions{nbParrallelRequests: 1, nbWorkers: 1, workerScript: "worker-with-counter.php", watch: watch}) } func TestWorkersShouldNotReloadOnExcludingPattern(t *testing.T) { @@ -38,7 +38,7 @@ func TestWorkersShouldNotReloadOnExcludingPattern(t *testing.T) { runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) { requestBodyHasReset := pollForWorkerReset(t, handler, minTimesToPollForChanges) assert.False(t, requestBodyHasReset) - }, &testOptions{nbParrallelRequests: 1, nbWorkers: 1, workerScript: "worker-with-watcher.php", watch: watch}) + }, &testOptions{nbParrallelRequests: 1, nbWorkers: 1, workerScript: "worker-with-counter.php", watch: watch}) } func fetchBody(method string, url string, handler func(http.ResponseWriter, *http.Request)) string { @@ -53,14 +53,14 @@ func fetchBody(method string, url string, handler func(http.ResponseWriter, *htt func pollForWorkerReset(t *testing.T, handler func(http.ResponseWriter, *http.Request), limit int) bool { // first we make an initial request to start the request counter - body := fetchBody("GET", "http://example.com/worker-with-watcher.php", handler) + body := fetchBody("GET", "http://example.com/worker-with-counter.php", handler) assert.Equal(t, "requests:1", body) // now we spam file updates and check if the request counter resets for i := 0; i < limit; i++ { updateTestFile("./testdata/files/test.txt", "updated", t) time.Sleep(pollingTime * time.Millisecond) - body := fetchBody("GET", "http://example.com/worker-with-watcher.php", handler) + body := fetchBody("GET", "http://example.com/worker-with-counter.php", handler) if body == "requests:1" { return true } From b117bff45d37f631668fb941573540584935542f Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 9 Dec 2024 00:32:29 +0100 Subject: [PATCH 064/190] Formatting and comments. --- caddy/admin.go | 20 ++++---------------- thread-worker.go | 5 +++-- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/caddy/admin.go b/caddy/admin.go index f0daacd7d..fafa475d4 100644 --- a/caddy/admin.go +++ b/caddy/admin.go @@ -62,10 +62,7 @@ func (admin *FrankenPHPAdmin) showThreadStatus(w http.ResponseWriter, r *http.Re func (admin *FrankenPHPAdmin) addWorkerThreads(w http.ResponseWriter, r *http.Request) error { if r.Method != http.MethodPost { - return caddy.APIError{ - HTTPStatus: http.StatusMethodNotAllowed, - Err: fmt.Errorf("method not allowed"), - } + return caddy.APIError{HTTPStatus: http.StatusMethodNotAllowed, Err: fmt.Errorf("method not allowed")} } workerPattern := r.URL.Query().Get("file") @@ -73,10 +70,7 @@ func (admin *FrankenPHPAdmin) addWorkerThreads(w http.ResponseWriter, r *http.Re for i := 0; i < admin.getCountFromRequest(r); i++ { workerFilename, threadCount, err := frankenphp.AddWorkerThread(workerPattern) if err != nil { - return caddy.APIError{ - HTTPStatus: http.StatusBadRequest, - Err: err, - } + return caddy.APIError{HTTPStatus: http.StatusBadRequest, Err: err} } message = fmt.Sprintf("New thread count: %d %s\n", threadCount, workerFilename) } @@ -87,10 +81,7 @@ func (admin *FrankenPHPAdmin) addWorkerThreads(w http.ResponseWriter, r *http.Re func (admin *FrankenPHPAdmin) removeWorkerThreads(w http.ResponseWriter, r *http.Request) error { if r.Method != http.MethodPost { - return caddy.APIError{ - HTTPStatus: http.StatusMethodNotAllowed, - Err: fmt.Errorf("method not allowed"), - } + return caddy.APIError{HTTPStatus: http.StatusMethodNotAllowed, Err: fmt.Errorf("method not allowed")} } workerPattern := r.URL.Query().Get("file") @@ -98,10 +89,7 @@ func (admin *FrankenPHPAdmin) removeWorkerThreads(w http.ResponseWriter, r *http for i := 0; i < admin.getCountFromRequest(r); i++ { workerFilename, threadCount, err := frankenphp.RemoveWorkerThread(workerPattern) if err != nil { - return caddy.APIError{ - HTTPStatus: http.StatusBadRequest, - Err: err, - } + return caddy.APIError{HTTPStatus: http.StatusBadRequest, Err: err} } message = fmt.Sprintf("New thread count: %d %s\n", threadCount, workerFilename) } diff --git a/thread-worker.go b/thread-worker.go index 2f1f8d8fc..c017bd614 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -155,8 +155,9 @@ func (handler *workerThread) waitForWorkerRequest() bool { c.Write(zap.String("worker", handler.worker.fileName)) } - // execute opcache_reset if the restart was triggered by the watcher - if watcherIsEnabled && handler.state.is(stateRestarting) { + // flush the opcache when restarting due to watcher or admin api + // note: this is done right before frankenphp_handle_request() returns 'false' + if handler.state.is(stateRestarting) { C.frankenphp_reset_opcache() } From ef1bd0d97546531e5584adb1c67bdb92841e4f97 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 9 Dec 2024 18:58:49 +0100 Subject: [PATCH 065/190] Changes Unpin() logic as suggested by @withinboredom --- cgi.go | 2 -- frankenphp.c | 2 +- phpthread.go | 3 +++ thread-regular.go | 1 - thread-worker.go | 5 +++-- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/cgi.go b/cgi.go index e9bb736ad..b41638762 100644 --- a/cgi.go +++ b/cgi.go @@ -227,8 +227,6 @@ func go_frankenphp_release_known_variable_keys(threadIndex C.uintptr_t) { for _, v := range thread.knownVariableKeys { C.frankenphp_release_zend_string(v) } - // release everything that might still be pinned to the thread - thread.Unpin() thread.knownVariableKeys = nil } diff --git a/frankenphp.c b/frankenphp.c index 9a5d029de..c2e4f10d9 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -89,7 +89,7 @@ static void frankenphp_free_request_context() { free(ctx->cookie_data); ctx->cookie_data = NULL; - /* Is freed via thread.Unpin() at the end of each request */ + /* Is freed via thread.Unpin() */ SG(request_info).auth_password = NULL; SG(request_info).auth_user = NULL; SG(request_info).request_method = NULL; diff --git a/phpthread.go b/phpthread.go index f94b498fe..edce7fbe5 100644 --- a/phpthread.go +++ b/phpthread.go @@ -102,10 +102,13 @@ func go_frankenphp_after_script_execution(threadIndex C.uintptr_t, exitStatus C. panic(ScriptExecutionError) } thread.handler.afterScriptExecution(int(exitStatus)) + + // unpin all memory used during script execution thread.Unpin() } //export go_frankenphp_on_thread_shutdown func go_frankenphp_on_thread_shutdown(threadIndex C.uintptr_t) { + phpThreads[threadIndex].Unpin() phpThreads[threadIndex].state.set(stateDone) } diff --git a/thread-regular.go b/thread-regular.go index 6b9dd9569..b08d40682 100644 --- a/thread-regular.go +++ b/thread-regular.go @@ -61,7 +61,6 @@ func (handler *regularThread) waitForRequest() string { if err := updateServerContext(handler.thread, r, true, false); err != nil { rejectRequest(fc.responseWriter, err.Error()) handler.afterRequest(0) - handler.thread.Unpin() // go back to beforeScriptExecution return handler.beforeScriptExecution() } diff --git a/thread-worker.go b/thread-worker.go index 2f1f8d8fc..d96c07b63 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -140,6 +140,9 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { } func (handler *workerThread) waitForWorkerRequest() bool { + // unpin any memory left over from previous requests + handler.thread.Unpin() + if c := logger.Check(zapcore.DebugLevel, "waiting for request"); c != nil { c.Write(zap.String("worker", handler.worker.fileName)) } @@ -180,7 +183,6 @@ func (handler *workerThread) waitForWorkerRequest() bool { rejectRequest(fc.responseWriter, err.Error()) maybeCloseContext(fc) handler.workerRequest = nil - handler.thread.Unpin() return handler.waitForWorkerRequest() } @@ -201,7 +203,6 @@ func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t) { maybeCloseContext(fc) thread.handler.(*workerThread).workerRequest = nil - thread.Unpin() if c := fc.logger.Check(zapcore.DebugLevel, "request handling finished"); c != nil { c.Write(zap.String("worker", fc.scriptFilename), zap.String("url", r.RequestURI)) From 8cd906151cf5657ca2cd02a4104674111b25903d Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 9 Dec 2024 20:55:28 +0100 Subject: [PATCH 066/190] Allows scaling regular threads. --- caddy/admin.go | 65 +++++++++++++++++++++++++------- caddy/admin_test.go | 20 ++++++---- frankenphp.go | 21 ++++------- phpthread.go | 8 ++-- scaling.go | 72 +++++++++++++++++++++++++++++++++++ thread-regular.go | 91 +++++++++++++++++++++++++++++++++++++++------ worker.go | 45 +--------------------- 7 files changed, 228 insertions(+), 94 deletions(-) create mode 100644 scaling.go diff --git a/caddy/admin.go b/caddy/admin.go index fafa475d4..c28eb5bea 100644 --- a/caddy/admin.go +++ b/caddy/admin.go @@ -28,6 +28,14 @@ func (admin FrankenPHPAdmin) Routes() []caddy.AdminRoute { Pattern: "/frankenphp/threads/status", Handler: caddy.AdminHandlerFunc(admin.showThreadStatus), }, + { + Pattern: "/frankenphp/threads/remove", + Handler: caddy.AdminHandlerFunc(admin.removeRegularThreads), + }, + { + Pattern: "/frankenphp/threads/add", + Handler: caddy.AdminHandlerFunc(admin.addRegularThreads), + }, { Pattern: "/frankenphp/workers/add", Handler: caddy.AdminHandlerFunc(admin.addWorkerThreads), @@ -41,28 +49,25 @@ func (admin FrankenPHPAdmin) Routes() []caddy.AdminRoute { func (admin *FrankenPHPAdmin) restartWorkers(w http.ResponseWriter, r *http.Request) error { if r.Method != http.MethodPost { - return caddy.APIError{ - HTTPStatus: http.StatusMethodNotAllowed, - Err: fmt.Errorf("method not allowed"), - } + return admin.error(http.StatusMethodNotAllowed, fmt.Errorf("method not allowed")) } frankenphp.RestartWorkers() caddy.Log().Info("workers restarted from admin api") - admin.respond(w, http.StatusOK, "workers restarted successfully\n") + admin.success(w, "workers restarted successfully\n") return nil } func (admin *FrankenPHPAdmin) showThreadStatus(w http.ResponseWriter, r *http.Request) error { - admin.respond(w, http.StatusOK, frankenphp.ThreadDebugStatus()) + admin.success(w, frankenphp.ThreadDebugStatus()) return nil } func (admin *FrankenPHPAdmin) addWorkerThreads(w http.ResponseWriter, r *http.Request) error { if r.Method != http.MethodPost { - return caddy.APIError{HTTPStatus: http.StatusMethodNotAllowed, Err: fmt.Errorf("method not allowed")} + return admin.error(http.StatusMethodNotAllowed, fmt.Errorf("method not allowed")) } workerPattern := r.URL.Query().Get("file") @@ -70,18 +75,18 @@ func (admin *FrankenPHPAdmin) addWorkerThreads(w http.ResponseWriter, r *http.Re for i := 0; i < admin.getCountFromRequest(r); i++ { workerFilename, threadCount, err := frankenphp.AddWorkerThread(workerPattern) if err != nil { - return caddy.APIError{HTTPStatus: http.StatusBadRequest, Err: err} + return admin.error(http.StatusBadRequest, err) } message = fmt.Sprintf("New thread count: %d %s\n", threadCount, workerFilename) } caddy.Log().Debug(message) - return admin.respond(w, http.StatusOK, message) + return admin.success(w, message) } func (admin *FrankenPHPAdmin) removeWorkerThreads(w http.ResponseWriter, r *http.Request) error { if r.Method != http.MethodPost { - return caddy.APIError{HTTPStatus: http.StatusMethodNotAllowed, Err: fmt.Errorf("method not allowed")} + return admin.error(http.StatusMethodNotAllowed, fmt.Errorf("method not allowed")) } workerPattern := r.URL.Query().Get("file") @@ -89,21 +94,53 @@ func (admin *FrankenPHPAdmin) removeWorkerThreads(w http.ResponseWriter, r *http for i := 0; i < admin.getCountFromRequest(r); i++ { workerFilename, threadCount, err := frankenphp.RemoveWorkerThread(workerPattern) if err != nil { - return caddy.APIError{HTTPStatus: http.StatusBadRequest, Err: err} + return admin.error(http.StatusBadRequest, err) } message = fmt.Sprintf("New thread count: %d %s\n", threadCount, workerFilename) } caddy.Log().Debug(message) - return admin.respond(w, http.StatusOK, message) + return admin.success(w, message) } -func (admin *FrankenPHPAdmin) respond(w http.ResponseWriter, statusCode int, message string) error { - w.WriteHeader(statusCode) +func (admin *FrankenPHPAdmin) addRegularThreads(w http.ResponseWriter, r *http.Request) error { + message := "" + for i := 0; i < admin.getCountFromRequest(r); i++ { + threadCount, err := frankenphp.AddRegularThread() + if err != nil { + return admin.error(http.StatusBadRequest, err) + } + message = fmt.Sprintf("New thread count: %d \n", threadCount) + } + + caddy.Log().Debug(message) + return admin.success(w, message) +} + +func (admin *FrankenPHPAdmin) removeRegularThreads(w http.ResponseWriter, r *http.Request) error { + message := "" + for i := 0; i < admin.getCountFromRequest(r); i++ { + threadCount, err := frankenphp.RemoveRegularThread() + if err != nil { + return admin.error(http.StatusBadRequest, err) + } + message = fmt.Sprintf("New thread count: %d \n", threadCount) + } + + caddy.Log().Debug(message) + return admin.success(w, message) +} + +func (admin *FrankenPHPAdmin) success(w http.ResponseWriter, message string) error { + w.WriteHeader(http.StatusOK) _, err := w.Write([]byte(message)) return err } +func (admin *FrankenPHPAdmin) error(statusCode int, err error) error { + return caddy.APIError{HTTPStatus: statusCode, Err: err} +} + func (admin *FrankenPHPAdmin) getCountFromRequest(r *http.Request) int { value := r.URL.Query().Get("count") if value == "" { diff --git a/caddy/admin_test.go b/caddy/admin_test.go index 7b27408be..98e7df11a 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -8,7 +8,7 @@ import ( "testing" ) -func TestRestartingWorkerViaAdminApi(t *testing.T) { +func TestRestartWorkerViaAdminApi(t *testing.T) { tester := caddytest.NewTester(t) tester.InitServer(` { @@ -38,7 +38,7 @@ func TestRestartingWorkerViaAdminApi(t *testing.T) { tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1") } -func TestRemoveThreadsViaAdminApi(t *testing.T) { +func TestRemoveWorkerThreadsViaAdminApi(t *testing.T) { absWorkerPath, _ := filepath.Abs("../testdata/worker-with-counter.php") tester := caddytest.NewTester(t) tester.InitServer(` @@ -79,7 +79,7 @@ func TestRemoveThreadsViaAdminApi(t *testing.T) { tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:2") } -func TestAddThreadsViaAdminApi(t *testing.T) { +func TestAddWorkerThreadsViaAdminApi(t *testing.T) { absWorkerPath, _ := filepath.Abs("../testdata/worker-with-counter.php") tester := caddytest.NewTester(t) tester.InitServer(` @@ -151,16 +151,22 @@ func TestShowTheCorrectThreadDebugStatus(t *testing.T) { `, "caddyfile") assertAdminResponse(tester, "POST", "workers/remove?file=index.php", http.StatusOK, "") + assertAdminResponse(tester, "POST", "threads/remove", http.StatusOK, "") // assert that all threads are in the right state via debug message - assertAdminResponse(tester, "GET", "threads/status", http.StatusOK, `Thread 0 (ready) Regular PHP Thread -Thread 1 (ready) Regular PHP Thread + assertAdminResponse( + tester, + "GET", + "threads/status", + http.StatusOK, `Thread 0 (ready) Regular PHP Thread +Thread 1 (inactive) Thread 2 (ready) Worker PHP Thread - `+absWorker1Path+` Thread 3 (ready) Worker PHP Thread - `+absWorker1Path+` Thread 4 (ready) Worker PHP Thread - `+absWorker2Path+` -Thread 5 (inactive) Thread +Thread 5 (inactive) 6 additional threads can be started at runtime -`) +`, + ) } func assertAdminResponse(tester *caddytest.Tester, method string, path string, expectedStatus int, expectedBody string) { diff --git a/frankenphp.go b/frankenphp.go index 8e63b979c..638379d7e 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -63,7 +63,7 @@ var ( RequestContextCreationError = errors.New("error during request context creation") ScriptExecutionError = errors.New("error during PHP script execution") - requestChan chan *http.Request + isRunning bool loggerMu sync.RWMutex logger *zap.Logger @@ -283,9 +283,10 @@ func calculateMaxThreads(opt *opt) (int, int, int, error) { // Init starts the PHP runtime and the configured workers. func Init(options ...Option) error { - if requestChan != nil { + if isRunning { return AlreadyStartedError } + isRunning = true // Ignore all SIGPIPE signals to prevent weird issues with systemd: https://github.com/dunglas/frankenphp/issues/1020 // Docker/Moby has a similar hack: https://github.com/moby/moby/blob/d828b032a87606ae34267e349bf7f7ccb1f6495a/cmd/dockerd/docker.go#L87-L90 @@ -337,11 +338,12 @@ func Init(options ...Option) error { logger.Warn(`ZTS is not enabled, only 1 thread will be available, recompile PHP using the "--enable-zts" configuration option or performance will be degraded`) } - requestChan = make(chan *http.Request, opt.numThreads) if err := initPHPThreads(totalThreadCount, maxThreadCount); err != nil { return err } + regularRequestChan = make(chan *http.Request, totalThreadCount-workerThreadCount) + regularThreads = make([]*phpThread, 0, totalThreadCount-workerThreadCount) for i := 0; i < totalThreadCount-workerThreadCount; i++ { thread := getInactivePHPThread() convertToRegularThread(thread) @@ -368,13 +370,13 @@ func Shutdown() { drainWorkers() drainPHPThreads() metrics.Shutdown() - requestChan = nil // Remove the installed app if EmbeddedAppPath != "" { _ = os.RemoveAll(EmbeddedAppPath) } + isRunning = false logger.Debug("FrankenPHP shut down") } @@ -472,15 +474,8 @@ func ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) error return nil } - metrics.StartRequest() - - select { - case <-mainThread.done: - case requestChan <- request: - <-fc.done - } - - metrics.StopRequest() + // If no worker was availabe send the request to non-worker threads + handleRequestWithRegularPHPThreads(request, fc) return nil } diff --git a/phpthread.go b/phpthread.go index 4fc038b8a..ab688d26a 100644 --- a/phpthread.go +++ b/phpthread.go @@ -87,15 +87,15 @@ func (thread *phpThread) getActiveRequest() *http.Request { // small status message for debugging func (thread *phpThread) debugStatus() string { - threadType := "Thread" + threadType := "" thread.handlerMu.Lock() if handler, ok := thread.handler.(*workerThread); ok { - threadType = "Worker PHP Thread - " + handler.worker.fileName + threadType = " Worker PHP Thread - " + handler.worker.fileName } else if _, ok := thread.handler.(*regularThread); ok { - threadType = "Regular PHP Thread" + threadType = " Regular PHP Thread" } thread.handlerMu.Unlock() - return fmt.Sprintf("Thread %d (%s) %s", thread.threadIndex, thread.state.name(), threadType) + return fmt.Sprintf("Thread %d (%s)%s", thread.threadIndex, thread.state.name(), threadType) } // Pin a string that is not null-terminated diff --git a/scaling.go b/scaling.go new file mode 100644 index 000000000..3ca56eedf --- /dev/null +++ b/scaling.go @@ -0,0 +1,72 @@ +package frankenphp + +import ( + "errors" + "fmt" + "strings" +) + +// exposed logic for safely scaling threads + +func AddRegularThread() (int, error) { + thread := getInactivePHPThread() + if thread == nil { + return countRegularThreads(), fmt.Errorf("max amount of threads reached: %d", len(phpThreads)) + } + convertToRegularThread(thread) + return countRegularThreads(), nil +} + +func RemoveRegularThread() (int, error) { + regularThreadMu.RLock() + if len(regularThreads) <= 1 { + regularThreadMu.RUnlock() + return 1, errors.New("cannot remove last thread") + } + thread := regularThreads[len(regularThreads)-1] + regularThreadMu.RUnlock() + convertToInactiveThread(thread) + return countRegularThreads(), nil +} + +func AddWorkerThread(workerFileName string) (string, int, error) { + worker := getWorkerByFilePattern(workerFileName) + if worker == nil { + return "", 0, errors.New("worker not found") + } + thread := getInactivePHPThread() + if thread == nil { + return "", 0, fmt.Errorf("max amount of threads reached: %d", len(phpThreads)) + } + convertToWorkerThread(thread, worker) + return worker.fileName, worker.countThreads(), nil +} + +func RemoveWorkerThread(workerFileName string) (string, int, error) { + worker := getWorkerByFilePattern(workerFileName) + if worker == nil { + return "", 0, errors.New("worker not found") + } + + worker.threadMutex.RLock() + if len(worker.threads) <= 1 { + worker.threadMutex.RUnlock() + return worker.fileName, 0, errors.New("cannot remove last thread") + } + thread := worker.threads[len(worker.threads)-1] + worker.threadMutex.RUnlock() + convertToInactiveThread(thread) + + return worker.fileName, worker.countThreads(), nil +} + +// get the first worker ending in the given pattern +func getWorkerByFilePattern(pattern string) *worker { + for _, worker := range workers { + if pattern == "" || strings.HasSuffix(worker.fileName, pattern) { + return worker + } + } + + return nil +} diff --git a/thread-regular.go b/thread-regular.go index 6b9dd9569..a7a6bd52c 100644 --- a/thread-regular.go +++ b/thread-regular.go @@ -4,6 +4,7 @@ package frankenphp import "C" import ( "net/http" + "sync" ) // representation of a non-worker PHP thread @@ -15,17 +16,25 @@ type regularThread struct { activeRequest *http.Request } +var ( + regularThreads []*phpThread + regularThreadMu = &sync.RWMutex{} + regularRequestChan chan *http.Request +) + func convertToRegularThread(thread *phpThread) { thread.setHandler(®ularThread{ thread: thread, state: thread.state, }) + attachRegularThread(thread) } // return the name of the script or an empty string if no script should be executed func (handler *regularThread) beforeScriptExecution() string { switch handler.state.get() { case stateTransitionRequested: + detachRegularThread(handler.thread) return handler.thread.transitionToNewHandler() case stateTransitionComplete: handler.state.set(stateReady) @@ -49,26 +58,29 @@ func (handler *regularThread) getActiveRequest() *http.Request { } func (handler *regularThread) waitForRequest() string { + var r *http.Request select { case <-handler.thread.drainChan: // go back to beforeScriptExecution return handler.beforeScriptExecution() - case r := <-requestChan: - handler.activeRequest = r - fc := r.Context().Value(contextKey).(*FrankenPHPContext) + case r = <-handler.thread.requestChan: + case r = <-regularRequestChan: + } - if err := updateServerContext(handler.thread, r, true, false); err != nil { - rejectRequest(fc.responseWriter, err.Error()) - handler.afterRequest(0) - handler.thread.Unpin() - // go back to beforeScriptExecution - return handler.beforeScriptExecution() - } + handler.activeRequest = r + fc := r.Context().Value(contextKey).(*FrankenPHPContext) - // set the scriptName that should be executed - return fc.scriptFilename + if err := updateServerContext(handler.thread, r, true, false); err != nil { + rejectRequest(fc.responseWriter, err.Error()) + handler.afterRequest(0) + handler.thread.Unpin() + // go back to beforeScriptExecution + return handler.beforeScriptExecution() } + + // set the scriptName that should be executed + return fc.scriptFilename } func (handler *regularThread) afterRequest(exitStatus int) { @@ -77,3 +89,58 @@ func (handler *regularThread) afterRequest(exitStatus int) { maybeCloseContext(fc) handler.activeRequest = nil } + +func handleRequestWithRegularPHPThreads(r *http.Request, fc *FrankenPHPContext) { + metrics.StartRequest() + regularThreadMu.RLock() + + // dispatch to all threads in order + for _, thread := range regularThreads { + select { + case thread.requestChan <- r: + regularThreadMu.RUnlock() + <-fc.done + metrics.StopRequest() + return + default: + // thread is busy, continue + } + } + regularThreadMu.RUnlock() + + // TODO: there can be possible auto-scaling here + + // if no thread was available, fan out to all threads + select { + case <-mainThread.done: + case regularRequestChan <- r: + <-fc.done + } + metrics.StopRequest() +} + +func attachRegularThread(thread *phpThread) { + regularThreadMu.Lock() + defer regularThreadMu.Unlock() + + regularThreads = append(regularThreads, thread) +} + +func detachRegularThread(thread *phpThread) { + regularThreadMu.Lock() + defer regularThreadMu.Unlock() + + for i, t := range regularThreads { + if t == thread { + regularThreads = append(regularThreads[:i], regularThreads[i+1:]...) + break + } + } +} + +func countRegularThreads() int { + regularThreadMu.RLock() + defer regularThreadMu.RUnlock() + + return len(regularThreads) +} diff --git a/worker.go b/worker.go index 38e04bebb..9449f6cd5 100644 --- a/worker.go +++ b/worker.go @@ -3,11 +3,9 @@ package frankenphp // #include "frankenphp.h" import "C" import ( - "errors" "fmt" "github.com/dunglas/frankenphp/internal/fastabs" "net/http" - "strings" "sync" "time" @@ -83,48 +81,6 @@ func drainWorkers() { watcher.DrainWatcher() } -func AddWorkerThread(workerFileName string) (string, int, error) { - worker := getWorkerByFilePattern(workerFileName) - if worker == nil { - return "", 0, errors.New("worker not found") - } - thread := getInactivePHPThread() - if thread == nil { - return "", 0, fmt.Errorf("max amount of threads reached: %d", len(phpThreads)) - } - convertToWorkerThread(thread, worker) - return worker.fileName, worker.countThreads(), nil -} - -func RemoveWorkerThread(workerFileName string) (string, int, error) { - worker := getWorkerByFilePattern(workerFileName) - if worker == nil { - return "", 0, errors.New("worker not found") - } - - worker.threadMutex.RLock() - if len(worker.threads) <= 1 { - worker.threadMutex.RUnlock() - return worker.fileName, 0, errors.New("cannot remove last thread") - } - thread := worker.threads[len(worker.threads)-1] - worker.threadMutex.RUnlock() - convertToInactiveThread(thread) - - return worker.fileName, worker.countThreads(), nil -} - -// get the first worker ending in the given pattern -func getWorkerByFilePattern(pattern string) *worker { - for _, worker := range workers { - if pattern == "" || strings.HasSuffix(worker.fileName, pattern) { - return worker - } - } - - return nil -} - func RestartWorkers() { ready := sync.WaitGroup{} for _, worker := range workers { @@ -197,6 +153,7 @@ func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) return default: + // thread is busy, continue } } worker.threadMutex.RUnlock() From 9e8d8f03cb9a85bc5fb851449330ecf131ccb001 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 9 Dec 2024 21:28:08 +0100 Subject: [PATCH 067/190] Only allows POST requests. --- caddy/admin.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/caddy/admin.go b/caddy/admin.go index c28eb5bea..c65cff523 100644 --- a/caddy/admin.go +++ b/caddy/admin.go @@ -25,7 +25,7 @@ func (admin FrankenPHPAdmin) Routes() []caddy.AdminRoute { Handler: caddy.AdminHandlerFunc(admin.restartWorkers), }, { - Pattern: "/frankenphp/threads/status", + Pattern: "/frankenphp/threads", Handler: caddy.AdminHandlerFunc(admin.showThreadStatus), }, { @@ -104,6 +104,10 @@ func (admin *FrankenPHPAdmin) removeWorkerThreads(w http.ResponseWriter, r *http } func (admin *FrankenPHPAdmin) addRegularThreads(w http.ResponseWriter, r *http.Request) error { + if r.Method != http.MethodPost { + return admin.error(http.StatusMethodNotAllowed, fmt.Errorf("method not allowed")) + } + message := "" for i := 0; i < admin.getCountFromRequest(r); i++ { threadCount, err := frankenphp.AddRegularThread() @@ -118,6 +122,10 @@ func (admin *FrankenPHPAdmin) addRegularThreads(w http.ResponseWriter, r *http.R } func (admin *FrankenPHPAdmin) removeRegularThreads(w http.ResponseWriter, r *http.Request) error { + if r.Method != http.MethodPost { + return admin.error(http.StatusMethodNotAllowed, fmt.Errorf("method not allowed")) + } + message := "" for i := 0; i < admin.getCountFromRequest(r); i++ { threadCount, err := frankenphp.RemoveRegularThread() From a8a454504e7cf5763df2f0c17caea8ae17dcf7ff Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 10 Dec 2024 21:55:03 +0100 Subject: [PATCH 068/190] Adds suggestions by @dunglas and resolves TODO. --- frankenphp.c | 16 +++------ phpmainthread.go | 2 +- phpmainthread_test.go | 6 ++-- phpthread.go | 9 ++--- state.go | 84 +++++++++++++++++++++++++++++-------------- state_test.go | 14 ++++++-- thread-inactive.go | 1 - thread-regular.go | 4 +-- thread-worker.go | 5 ++- worker.go | 12 ++++--- 10 files changed, 94 insertions(+), 59 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index c2e4f10d9..e0e5095c4 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -832,17 +832,11 @@ static void *php_thread(void *arg) { cfg_get_string("filter.default", &default_filter); should_filter_var = default_filter != NULL; - // perform work until go signals to stop - while (true) { - char *scriptName = go_frankenphp_before_script_execution(thread_index); - - // if go signals to stop, break the loop - if (scriptName == NULL) { - break; - } - - int exit_status = frankenphp_execute_script(scriptName); - go_frankenphp_after_script_execution(thread_index, exit_status); + // loop until Go signals to stop + char *scriptName = NULL; + while ((scriptName = go_frankenphp_before_script_execution(thread_index))) { + go_frankenphp_after_script_execution(thread_index, + frankenphp_execute_script(scriptName)); } go_frankenphp_release_known_variable_keys(thread_index); diff --git a/phpmainthread.go b/phpmainthread.go index d8376883c..5561cbd77 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -62,7 +62,7 @@ func drainPHPThreads() { doneWG.Add(len(phpThreads)) for _, thread := range phpThreads { thread.handlerMu.Lock() - thread.state.set(stateShuttingDown) + _ = thread.state.requestSafeStateChange(stateShuttingDown) close(thread.drainChan) } close(mainThread.done) diff --git a/phpmainthread_test.go b/phpmainthread_test.go index 601218ee2..6d0cf0f60 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -37,7 +37,7 @@ func TestTransitionRegularThreadToWorkerThread(t *testing.T) { assert.IsType(t, ®ularThread{}, phpThreads[0].handler) // transition to worker thread - worker := getDummyWorker("worker-transition-1.php") + worker := getDummyWorker("transition-worker-1.php") convertToWorkerThread(phpThreads[0], worker) assert.IsType(t, &workerThread{}, phpThreads[0].handler) assert.Len(t, worker.threads, 1) @@ -54,8 +54,8 @@ func TestTransitionRegularThreadToWorkerThread(t *testing.T) { func TestTransitionAThreadBetween2DifferentWorkers(t *testing.T) { logger = zap.NewNop() assert.NoError(t, initPHPThreads(1)) - firstWorker := getDummyWorker("worker-transition-1.php") - secondWorker := getDummyWorker("worker-transition-2.php") + firstWorker := getDummyWorker("transition-worker-1.php") + secondWorker := getDummyWorker("transition-worker-2.php") // convert to first worker thread convertToWorkerThread(phpThreads[0], firstWorker) diff --git a/phpthread.go b/phpthread.go index edce7fbe5..eabc58a98 100644 --- a/phpthread.go +++ b/phpthread.go @@ -43,14 +43,15 @@ func newPHPThread(threadIndex int) *phpThread { // change the thread handler safely // must be called from outside of the PHP thread func (thread *phpThread) setHandler(handler threadHandler) { + logger.Debug("setHandler") thread.handlerMu.Lock() defer thread.handlerMu.Unlock() - if thread.state.is(stateShuttingDown) { + if !thread.state.requestSafeStateChange(stateTransitionRequested) { + // no state change allowed == shutdown return } - thread.state.set(stateTransitionRequested) close(thread.drainChan) - thread.state.waitFor(stateTransitionInProgress, stateShuttingDown) + thread.state.waitFor(stateTransitionInProgress) thread.handler = handler thread.drainChan = make(chan struct{}) thread.state.set(stateTransitionComplete) @@ -60,7 +61,7 @@ func (thread *phpThread) setHandler(handler threadHandler) { // is triggered by setHandler and executed on the PHP thread func (thread *phpThread) transitionToNewHandler() string { thread.state.set(stateTransitionInProgress) - thread.state.waitFor(stateTransitionComplete, stateShuttingDown) + thread.state.waitFor(stateTransitionComplete) // execute beforeScriptExecution of the new handler return thread.handler.beforeScriptExecution() } diff --git a/state.go b/state.go index ee9951841..05d9e8e65 100644 --- a/state.go +++ b/state.go @@ -6,16 +6,18 @@ import ( "sync" ) -type stateID int +type stateID uint8 const ( - // livecycle states of a thread + // lifecycle states of a thread stateBooting stateID = iota - stateInactive - stateReady stateShuttingDown stateDone + // these states are safe to transition from at any time + stateInactive + stateReady + // states necessary for restarting workers stateRestarting stateYielding @@ -47,18 +49,22 @@ func newThreadState() *threadState { func (ts *threadState) is(state stateID) bool { ts.mu.RLock() - defer ts.mu.RUnlock() - return ts.currentState == state + ok := ts.currentState == state + ts.mu.RUnlock() + + return ok } func (ts *threadState) compareAndSwap(compareTo stateID, swapTo stateID) bool { ts.mu.Lock() - defer ts.mu.Unlock() - if ts.currentState == compareTo { + ok := ts.currentState == compareTo + if ok { ts.currentState = swapTo - return true + ts.notifySubscribers(swapTo) } - return false + ts.mu.Unlock() + + return ok } func (ts *threadState) name() string { @@ -68,43 +74,69 @@ func (ts *threadState) name() string { func (ts *threadState) get() stateID { ts.mu.RLock() - defer ts.mu.RUnlock() - return ts.currentState + id := ts.currentState + ts.mu.RUnlock() + + return id } -func (h *threadState) set(nextState stateID) { - h.mu.Lock() - defer h.mu.Unlock() - h.currentState = nextState +func (ts *threadState) set(nextState stateID) { + ts.mu.Lock() + ts.currentState = nextState + ts.notifySubscribers(nextState) + ts.mu.Unlock() +} - if len(h.subscribers) == 0 { +func (ts *threadState) notifySubscribers(nextState stateID) { + if len(ts.subscribers) == 0 { return } - newSubscribers := []stateSubscriber{} // notify subscribers to the state change - for _, sub := range h.subscribers { + for _, sub := range ts.subscribers { if !slices.Contains(sub.states, nextState) { newSubscribers = append(newSubscribers, sub) continue } close(sub.ch) } - h.subscribers = newSubscribers + ts.subscribers = newSubscribers } // block until the thread reaches a certain state -func (h *threadState) waitFor(states ...stateID) { - h.mu.Lock() - if slices.Contains(states, h.currentState) { - h.mu.Unlock() +func (ts *threadState) waitFor(states ...stateID) { + ts.mu.Lock() + if slices.Contains(states, ts.currentState) { + ts.mu.Unlock() return } sub := stateSubscriber{ states: states, ch: make(chan struct{}), } - h.subscribers = append(h.subscribers, sub) - h.mu.Unlock() + ts.subscribers = append(ts.subscribers, sub) + ts.mu.Unlock() <-sub.ch } + +// safely request a state change from a different goroutine +func (ts *threadState) requestSafeStateChange(nextState stateID) bool { + ts.mu.Lock() + switch ts.currentState { + // disallow state changes if shutting down + case stateShuttingDown: + ts.mu.Unlock() + return false + // ready and inactive are safe states to transition from + case stateReady, stateInactive: + ts.currentState = nextState + ts.notifySubscribers(nextState) + ts.mu.Unlock() + return true + } + ts.mu.Unlock() + + // wait for the state to change to a safe state + ts.waitFor(stateReady, stateInactive, stateShuttingDown) + return ts.requestSafeStateChange(nextState) +} diff --git a/state_test.go b/state_test.go index 47b68d410..0a9143c2e 100644 --- a/state_test.go +++ b/state_test.go @@ -29,19 +29,27 @@ func TestStateShouldHaveCorrectAmountOfSubscribers(t *testing.T) { go threadState.waitFor(stateInactive, stateShuttingDown) go threadState.waitFor(stateShuttingDown) - time.Sleep(1 * time.Millisecond) assertNumberOfSubscribers(t, threadState, 3) threadState.set(stateInactive) - time.Sleep(1 * time.Millisecond) assertNumberOfSubscribers(t, threadState, 1) threadState.set(stateShuttingDown) - time.Sleep(1 * time.Millisecond) assertNumberOfSubscribers(t, threadState, 0) } func assertNumberOfSubscribers(t *testing.T, threadState *threadState, expected int) { + maxWaits := 10_000 // wait for 1 second max + + for i := 0; i < maxWaits; i++ { + time.Sleep(100 * time.Microsecond) + threadState.mu.RLock() + if len(threadState.subscribers) == expected { + threadState.mu.RUnlock() + break + } + threadState.mu.RUnlock() + } threadState.mu.RLock() assert.Len(t, threadState.subscribers, expected) threadState.mu.RUnlock() diff --git a/thread-inactive.go b/thread-inactive.go index d5cfdece7..7c4810c71 100644 --- a/thread-inactive.go +++ b/thread-inactive.go @@ -25,7 +25,6 @@ func (handler *inactiveThread) beforeScriptExecution() string { case stateTransitionRequested: return thread.transitionToNewHandler() case stateBooting, stateTransitionComplete: - // TODO: there's a tiny race condition here between checking and setting thread.state.set(stateInactive) // wait for external signal to start or shut down thread.state.waitFor(stateTransitionRequested, stateShuttingDown) diff --git a/thread-regular.go b/thread-regular.go index b08d40682..88d72106b 100644 --- a/thread-regular.go +++ b/thread-regular.go @@ -22,7 +22,7 @@ func convertToRegularThread(thread *phpThread) { }) } -// return the name of the script or an empty string if no script should be executed +// beforeScriptExecution returns the name of the script or an empty string on shutdown func (handler *regularThread) beforeScriptExecution() string { switch handler.state.get() { case stateTransitionRequested: @@ -65,7 +65,7 @@ func (handler *regularThread) waitForRequest() string { return handler.beforeScriptExecution() } - // set the scriptName that should be executed + // set the scriptFilename that should be executed return fc.scriptFilename } } diff --git a/thread-worker.go b/thread-worker.go index d96c07b63..09f837d80 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -3,7 +3,6 @@ package frankenphp // #include "frankenphp.h" import "C" import ( - "fmt" "net/http" "path/filepath" "time" @@ -38,7 +37,7 @@ func convertToWorkerThread(thread *phpThread, worker *worker) { worker.attachThread(thread) } -// return the name of the script or an empty string if no script should be executed +// beforeScriptExecution returns the name of the script or an empty string on shutdown func (handler *workerThread) beforeScriptExecution() string { switch handler.state.get() { case stateTransitionRequested: @@ -133,7 +132,7 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { metrics.StopWorker(worker.fileName, StopReasonCrash) if handler.backoff.recordFailure() { if !watcherIsEnabled { - panic(fmt.Errorf("workers %q: too many consecutive failures", worker.fileName)) + logger.Panic("too many consecutive worker failures", zap.String("worker", worker.fileName), zap.Int("failures", handler.backoff.failureCount)) } logger.Warn("many consecutive worker failures", zap.String("worker", worker.fileName), zap.Int("failures", handler.backoff.failureCount)) } diff --git a/worker.go b/worker.go index 49ddcc3be..74d30ac2c 100644 --- a/worker.go +++ b/worker.go @@ -87,8 +87,10 @@ func restartWorkers() { worker.threadMutex.RLock() ready.Add(len(worker.threads)) for _, thread := range worker.threads { - thread.handlerMu.Lock() - thread.state.set(stateRestarting) + if !thread.state.requestSafeStateChange(stateRestarting) { + // no state change allowed = shutdown + continue + } close(thread.drainChan) go func(thread *phpThread) { thread.state.waitFor(stateYielding) @@ -99,9 +101,9 @@ func restartWorkers() { ready.Wait() for _, worker := range workers { for _, thread := range worker.threads { - thread.drainChan = make(chan struct{}) - thread.state.set(stateReady) - thread.handlerMu.Unlock() + if thread.state.compareAndSwap(stateYielding, stateReady) { + thread.drainChan = make(chan struct{}) + } } worker.threadMutex.RUnlock() } From 23a63622356e9e83a441b16133ec9ef72080fab6 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 10 Dec 2024 22:23:53 +0100 Subject: [PATCH 069/190] Makes restarts fully safe. --- state.go | 2 +- worker.go | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/state.go b/state.go index 05d9e8e65..001213282 100644 --- a/state.go +++ b/state.go @@ -124,7 +124,7 @@ func (ts *threadState) requestSafeStateChange(nextState stateID) bool { ts.mu.Lock() switch ts.currentState { // disallow state changes if shutting down - case stateShuttingDown: + case stateShuttingDown, stateDone: ts.mu.Unlock() return false // ready and inactive are safe states to transition from diff --git a/worker.go b/worker.go index 74d30ac2c..1c1fc950f 100644 --- a/worker.go +++ b/worker.go @@ -83,6 +83,7 @@ func drainWorkers() { func restartWorkers() { ready := sync.WaitGroup{} + threadsToRestart := make([]*phpThread, 0) for _, worker := range workers { worker.threadMutex.RLock() ready.Add(len(worker.threads)) @@ -92,20 +93,20 @@ func restartWorkers() { continue } close(thread.drainChan) + threadsToRestart = append(threadsToRestart, thread) go func(thread *phpThread) { thread.state.waitFor(stateYielding) ready.Done() }(thread) } + worker.threadMutex.RUnlock() } + ready.Wait() - for _, worker := range workers { - for _, thread := range worker.threads { - if thread.state.compareAndSwap(stateYielding, stateReady) { - thread.drainChan = make(chan struct{}) - } - } - worker.threadMutex.RUnlock() + + for _, thread := range threadsToRestart { + thread.drainChan = make(chan struct{}) + thread.state.set(stateReady) } } From 18e3e587d83d4c4109423ef5b448df5e14afa78a Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 10 Dec 2024 23:13:39 +0100 Subject: [PATCH 070/190] Will make the initial startup fail even if the watcher is enabled (as is currently the case) --- worker.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/worker.go b/worker.go index 1c1fc950f..803e527f9 100644 --- a/worker.go +++ b/worker.go @@ -29,25 +29,33 @@ var ( func initWorkers(opt []workerOpt) error { workers = make(map[string]*worker, len(opt)) - directoriesToWatch := getDirectoriesToWatch(opt) - watcherIsEnabled = len(directoriesToWatch) > 0 + workersReady := sync.WaitGroup{} for _, o := range opt { worker, err := newWorker(o) worker.threads = make([]*phpThread, 0, o.num) + workersReady.Add(o.num) if err != nil { return err } for i := 0; i < worker.num; i++ { thread := getInactivePHPThread() convertToWorkerThread(thread, worker) + go func() { + thread.state.waitFor(stateReady) + workersReady.Done() + }() } } - if !watcherIsEnabled { + workersReady.Wait() + + directoriesToWatch := getDirectoriesToWatch(opt) + if len(directoriesToWatch) == 0 { return nil } + watcherIsEnabled = true if err := watcher.InitWatcher(directoriesToWatch, restartWorkers, getLogger()); err != nil { return err } From 3672c60fa04ff5ec797b46df72252f464bbcbcc8 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 10 Dec 2024 23:14:03 +0100 Subject: [PATCH 071/190] Also adds compareAndSwap to the test. --- state_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/state_test.go b/state_test.go index 0a9143c2e..29a10c348 100644 --- a/state_test.go +++ b/state_test.go @@ -34,7 +34,7 @@ func TestStateShouldHaveCorrectAmountOfSubscribers(t *testing.T) { threadState.set(stateInactive) assertNumberOfSubscribers(t, threadState, 1) - threadState.set(stateShuttingDown) + assert.True(t, threadState.compareAndSwap(stateInactive, stateShuttingDown)) assertNumberOfSubscribers(t, threadState, 0) } From 38f87b7b7b1239b0b55f8111e249ee84661ea474 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 10 Dec 2024 23:15:48 +0100 Subject: [PATCH 072/190] Adds comment. --- testdata/transition-worker-2.php | 1 + 1 file changed, 1 insertion(+) diff --git a/testdata/transition-worker-2.php b/testdata/transition-worker-2.php index 969c6db20..1fb7c4271 100644 --- a/testdata/transition-worker-2.php +++ b/testdata/transition-worker-2.php @@ -2,6 +2,7 @@ while (frankenphp_handle_request(function () { echo "Hello from worker 2"; + // Simulate work to force potential race conditions (phpmainthread_test.go) usleep(1000); })) { From d97ebfe161a7e6bf21ba5f3005479ed86258370d Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 10 Dec 2024 23:49:32 +0100 Subject: [PATCH 073/190] Prevents panic on initial watcher startup. --- worker.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/worker.go b/worker.go index 803e527f9..bbb44c195 100644 --- a/worker.go +++ b/worker.go @@ -30,6 +30,8 @@ var ( func initWorkers(opt []workerOpt) error { workers = make(map[string]*worker, len(opt)) workersReady := sync.WaitGroup{} + directoriesToWatch := getDirectoriesToWatch(opt) + watcherIsEnabled = len(directoriesToWatch) > 0 for _, o := range opt { worker, err := newWorker(o) @@ -50,12 +52,10 @@ func initWorkers(opt []workerOpt) error { workersReady.Wait() - directoriesToWatch := getDirectoriesToWatch(opt) - if len(directoriesToWatch) == 0 { + if !watcherIsEnabled { return nil } - watcherIsEnabled = true if err := watcher.InitWatcher(directoriesToWatch, restartWorkers, getLogger()); err != nil { return err } From 5f1ec1f078a67d0f73b72e4e6fd3734bf37d55c4 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 11 Dec 2024 22:14:17 +0100 Subject: [PATCH 074/190] Cleans up admin endpoints. --- caddy/admin.go | 111 ++++++++++++++++++------------------------------- scaling.go | 37 ++++++----------- state.go | 2 +- worker.go | 8 ++++ 4 files changed, 63 insertions(+), 95 deletions(-) diff --git a/caddy/admin.go b/caddy/admin.go index c65cff523..6be672c38 100644 --- a/caddy/admin.go +++ b/caddy/admin.go @@ -6,6 +6,7 @@ import ( "github.com/dunglas/frankenphp" "net/http" "strconv" + "strings" ) type FrankenPHPAdmin struct{} @@ -26,23 +27,7 @@ func (admin FrankenPHPAdmin) Routes() []caddy.AdminRoute { }, { Pattern: "/frankenphp/threads", - Handler: caddy.AdminHandlerFunc(admin.showThreadStatus), - }, - { - Pattern: "/frankenphp/threads/remove", - Handler: caddy.AdminHandlerFunc(admin.removeRegularThreads), - }, - { - Pattern: "/frankenphp/threads/add", - Handler: caddy.AdminHandlerFunc(admin.addRegularThreads), - }, - { - Pattern: "/frankenphp/workers/add", - Handler: caddy.AdminHandlerFunc(admin.addWorkerThreads), - }, - { - Pattern: "/frankenphp/workers/remove", - Handler: caddy.AdminHandlerFunc(admin.removeWorkerThreads), + Handler: caddy.AdminHandlerFunc(admin.threads), }, } } @@ -59,83 +44,60 @@ func (admin *FrankenPHPAdmin) restartWorkers(w http.ResponseWriter, r *http.Requ return nil } -func (admin *FrankenPHPAdmin) showThreadStatus(w http.ResponseWriter, r *http.Request) error { - admin.success(w, frankenphp.ThreadDebugStatus()) - - return nil -} - -func (admin *FrankenPHPAdmin) addWorkerThreads(w http.ResponseWriter, r *http.Request) error { - if r.Method != http.MethodPost { - return admin.error(http.StatusMethodNotAllowed, fmt.Errorf("method not allowed")) +func (admin *FrankenPHPAdmin) threads(w http.ResponseWriter, r *http.Request) error { + if r.Method == http.MethodPut { + return admin.changeThreads(w, r, admin.getCountFromRequest(r)) } - - workerPattern := r.URL.Query().Get("file") - message := "" - for i := 0; i < admin.getCountFromRequest(r); i++ { - workerFilename, threadCount, err := frankenphp.AddWorkerThread(workerPattern) - if err != nil { - return admin.error(http.StatusBadRequest, err) - } - message = fmt.Sprintf("New thread count: %d %s\n", threadCount, workerFilename) + if r.Method == http.MethodDelete { + return admin.changeThreads(w, r, -admin.getCountFromRequest(r)) + } + if r.Method == http.MethodGet { + return admin.success(w, frankenphp.ThreadDebugStatus()) } - caddy.Log().Debug(message) - return admin.success(w, message) + return admin.error(http.StatusMethodNotAllowed, fmt.Errorf("method not allowed, try: GET,PUT,DELETE")) } -func (admin *FrankenPHPAdmin) removeWorkerThreads(w http.ResponseWriter, r *http.Request) error { - if r.Method != http.MethodPost { - return admin.error(http.StatusMethodNotAllowed, fmt.Errorf("method not allowed")) +func (admin *FrankenPHPAdmin) changeThreads(w http.ResponseWriter, r *http.Request, count int) error { + if !r.URL.Query().Has("worker") { + return admin.changeRegularThreads(w, count) } + workerFilename := admin.getWorkerByPattern(r.URL.Query().Get("worker")) - workerPattern := r.URL.Query().Get("file") - message := "" - for i := 0; i < admin.getCountFromRequest(r); i++ { - workerFilename, threadCount, err := frankenphp.RemoveWorkerThread(workerPattern) - if err != nil { - return admin.error(http.StatusBadRequest, err) - } - message = fmt.Sprintf("New thread count: %d %s\n", threadCount, workerFilename) - } - - caddy.Log().Debug(message) - return admin.success(w, message) + return admin.changeWorkerThreads(w, count, workerFilename) } -func (admin *FrankenPHPAdmin) addRegularThreads(w http.ResponseWriter, r *http.Request) error { - if r.Method != http.MethodPost { - return admin.error(http.StatusMethodNotAllowed, fmt.Errorf("method not allowed")) +func (admin *FrankenPHPAdmin) changeWorkerThreads(w http.ResponseWriter, num int, workerFilename string) error { + method := frankenphp.AddWorkerThread + if num < 0 { + num = -num + method = frankenphp.RemoveWorkerThread } - message := "" - for i := 0; i < admin.getCountFromRequest(r); i++ { - threadCount, err := frankenphp.AddRegularThread() + for i := 0; i < num; i++ { + threadCount, err := method(workerFilename) if err != nil { return admin.error(http.StatusBadRequest, err) } - message = fmt.Sprintf("New thread count: %d \n", threadCount) + message = fmt.Sprintf("New thread count: %d %s\n", threadCount, workerFilename) } - - caddy.Log().Debug(message) return admin.success(w, message) } -func (admin *FrankenPHPAdmin) removeRegularThreads(w http.ResponseWriter, r *http.Request) error { - if r.Method != http.MethodPost { - return admin.error(http.StatusMethodNotAllowed, fmt.Errorf("method not allowed")) +func (admin *FrankenPHPAdmin) changeRegularThreads(w http.ResponseWriter, num int) error { + method := frankenphp.AddRegularThread + if num < 0 { + num = -num + method = frankenphp.RemoveRegularThread } - message := "" - for i := 0; i < admin.getCountFromRequest(r); i++ { - threadCount, err := frankenphp.RemoveRegularThread() + for i := 0; i < num; i++ { + threadCount, err := method() if err != nil { return admin.error(http.StatusBadRequest, err) } - message = fmt.Sprintf("New thread count: %d \n", threadCount) + message = fmt.Sprintf("New thread count: %d Regular Threads\n", threadCount) } - - caddy.Log().Debug(message) return admin.success(w, message) } @@ -160,3 +122,12 @@ func (admin *FrankenPHPAdmin) getCountFromRequest(r *http.Request) int { } return i } + +func (admin *FrankenPHPAdmin) getWorkerByPattern(pattern string) string { + for _, workerFilename := range frankenphp.WorkerFileNames() { + if strings.HasSuffix(workerFilename, pattern) { + return workerFilename + } + } + return "" +} diff --git a/scaling.go b/scaling.go index 3ca56eedf..c12bc0358 100644 --- a/scaling.go +++ b/scaling.go @@ -3,7 +3,6 @@ package frankenphp import ( "errors" "fmt" - "strings" ) // exposed logic for safely scaling threads @@ -29,44 +28,34 @@ func RemoveRegularThread() (int, error) { return countRegularThreads(), nil } -func AddWorkerThread(workerFileName string) (string, int, error) { - worker := getWorkerByFilePattern(workerFileName) - if worker == nil { - return "", 0, errors.New("worker not found") +func AddWorkerThread(workerFileName string) (int, error) { + worker, ok := workers[workerFileName] + if !ok { + return 0, errors.New("worker not found") } thread := getInactivePHPThread() if thread == nil { - return "", 0, fmt.Errorf("max amount of threads reached: %d", len(phpThreads)) + count := worker.countThreads() + return count, fmt.Errorf("max amount of threads reached: %d", count) } convertToWorkerThread(thread, worker) - return worker.fileName, worker.countThreads(), nil + return worker.countThreads(), nil } -func RemoveWorkerThread(workerFileName string) (string, int, error) { - worker := getWorkerByFilePattern(workerFileName) - if worker == nil { - return "", 0, errors.New("worker not found") +func RemoveWorkerThread(workerFileName string) (int, error) { + worker, ok := workers[workerFileName] + if !ok { + return 0, errors.New("worker not found") } worker.threadMutex.RLock() if len(worker.threads) <= 1 { worker.threadMutex.RUnlock() - return worker.fileName, 0, errors.New("cannot remove last thread") + return 1, errors.New("cannot remove last thread") } thread := worker.threads[len(worker.threads)-1] worker.threadMutex.RUnlock() convertToInactiveThread(thread) - return worker.fileName, worker.countThreads(), nil -} - -// get the first worker ending in the given pattern -func getWorkerByFilePattern(pattern string) *worker { - for _, worker := range workers { - if pattern == "" || strings.HasSuffix(worker.fileName, pattern) { - return worker - } - } - - return nil + return worker.countThreads(), nil } diff --git a/state.go b/state.go index 440e2f020..b31a9fe83 100644 --- a/state.go +++ b/state.go @@ -10,7 +10,7 @@ type stateID uint8 const ( // livecycle states of a thread stateReserved stateID = iota - stateBooting + stateBooting stateShuttingDown stateDone diff --git a/worker.go b/worker.go index 4b6935e21..13c0c743e 100644 --- a/worker.go +++ b/worker.go @@ -119,6 +119,14 @@ func RestartWorkers() { } } +func WorkerFileNames() []string { + workerNames := make([]string, 0, len(workers)) + for fileName, _ := range workers { + workerNames = append(workerNames, fileName) + } + return workerNames +} + func getDirectoriesToWatch(workerOpts []workerOpt) []string { directoriesToWatch := []string{} for _, w := range workerOpts { From 7c61dfac46eacb41702e7ed73fae9d93088bf9a3 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 11 Dec 2024 23:10:40 +0100 Subject: [PATCH 075/190] Fixes admin test. --- caddy/admin_test.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/caddy/admin_test.go b/caddy/admin_test.go index 98e7df11a..e3ef8ebc5 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -66,14 +66,14 @@ func TestRemoveWorkerThreadsViaAdminApi(t *testing.T) { // remove a thread expectedMessage := fmt.Sprintf("New thread count: 3 %s\n", absWorkerPath) - assertAdminResponse(tester, "POST", "workers/remove", http.StatusOK, expectedMessage) + assertAdminResponse(tester, "DELETE", "threads?worker", http.StatusOK, expectedMessage) // remove 2 threads expectedMessage = fmt.Sprintf("New thread count: 1 %s\n", absWorkerPath) - assertAdminResponse(tester, "POST", "workers/remove?count=2", http.StatusOK, expectedMessage) + assertAdminResponse(tester, "DELETE", "threads?worker&count=2", http.StatusOK, expectedMessage) // get 400 status if removing the last thread - assertAdminResponse(tester, "POST", "workers/remove", http.StatusBadRequest, "") + assertAdminResponse(tester, "DELETE", "threads?worker", http.StatusBadRequest, "") // make a request to the worker to make sure it's still running tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:2") @@ -106,18 +106,18 @@ func TestAddWorkerThreadsViaAdminApi(t *testing.T) { tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1") // get 400 status if the filename is wrong - assertAdminResponse(tester, "POST", "workers/add?file=wrong.php", http.StatusBadRequest, "") + assertAdminResponse(tester, "PUT", "threads?worker=wrong.php", http.StatusBadRequest, "") // add a thread expectedMessage := fmt.Sprintf("New thread count: 2 %s\n", absWorkerPath) - assertAdminResponse(tester, "POST", "workers/add", http.StatusOK, expectedMessage) + assertAdminResponse(tester, "PUT", "threads?worker=counter.php", http.StatusOK, expectedMessage) // add 2 threads expectedMessage = fmt.Sprintf("New thread count: 4 %s\n", absWorkerPath) - assertAdminResponse(tester, "POST", "workers/add?count=2", http.StatusOK, expectedMessage) + assertAdminResponse(tester, "PUT", "threads?worker&=counter.php&count=2", http.StatusOK, expectedMessage) // get 400 status if adding too many threads - assertAdminResponse(tester, "POST", "workers/add?count=100", http.StatusBadRequest, "") + assertAdminResponse(tester, "PUT", "threads?worker&=counter.php&count=100", http.StatusBadRequest, "") // make a request to the worker to make sure it's still running tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:2") @@ -150,14 +150,14 @@ func TestShowTheCorrectThreadDebugStatus(t *testing.T) { } `, "caddyfile") - assertAdminResponse(tester, "POST", "workers/remove?file=index.php", http.StatusOK, "") - assertAdminResponse(tester, "POST", "threads/remove", http.StatusOK, "") + assertAdminResponse(tester, "DELETE", "threads?worker=index.php", http.StatusOK, "") + assertAdminResponse(tester, "DELETE", "threads", http.StatusOK, "") // assert that all threads are in the right state via debug message assertAdminResponse( tester, "GET", - "threads/status", + "threads", http.StatusOK, `Thread 0 (ready) Regular PHP Thread Thread 1 (inactive) Thread 2 (ready) Worker PHP Thread - `+absWorker1Path+` From 2af993e04cf46f9fa226ee0ffb561f4a0e01db51 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 11 Dec 2024 23:14:21 +0100 Subject: [PATCH 076/190] Boots a thread in a test. --- caddy/admin_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/caddy/admin_test.go b/caddy/admin_test.go index e3ef8ebc5..39486f74e 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -150,6 +150,7 @@ func TestShowTheCorrectThreadDebugStatus(t *testing.T) { } `, "caddyfile") + assertAdminResponse(tester, "PUT", "threads?worker=counter.php", http.StatusOK, "") assertAdminResponse(tester, "DELETE", "threads?worker=index.php", http.StatusOK, "") assertAdminResponse(tester, "DELETE", "threads", http.StatusOK, "") @@ -164,7 +165,8 @@ Thread 2 (ready) Worker PHP Thread - `+absWorker1Path+` Thread 3 (ready) Worker PHP Thread - `+absWorker1Path+` Thread 4 (ready) Worker PHP Thread - `+absWorker2Path+` Thread 5 (inactive) -6 additional threads can be started at runtime +Thread 6 (ready) Worker PHP Thread - `+absWorker1Path+` +5 additional threads can be started at runtime `, ) } From 547139f15d12e97f877275696a1c17a58324e47f Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 11 Dec 2024 23:20:21 +0100 Subject: [PATCH 077/190] Sets more explicit max_threads. --- caddy/admin_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/caddy/admin_test.go b/caddy/admin_test.go index 39486f74e..1109c417a 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -48,6 +48,8 @@ func TestRemoveWorkerThreadsViaAdminApi(t *testing.T) { http_port `+testPort+` frankenphp { + num_threads 6 + max_threads 6 worker ../testdata/worker-with-counter.php 4 } } @@ -89,6 +91,8 @@ func TestAddWorkerThreadsViaAdminApi(t *testing.T) { http_port `+testPort+` frankenphp { + max_threads 10 + num_threads 3 worker ../testdata/worker-with-counter.php 1 } } From c8bf1ecc5e409f27b418caa9562f33a470293cff Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 12 Dec 2024 21:13:45 +0100 Subject: [PATCH 078/190] Adjusts naming. --- caddy/admin.go | 4 ++-- scaling.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/caddy/admin.go b/caddy/admin.go index 6be672c38..7e254d6e8 100644 --- a/caddy/admin.go +++ b/caddy/admin.go @@ -62,7 +62,7 @@ func (admin *FrankenPHPAdmin) changeThreads(w http.ResponseWriter, r *http.Reque if !r.URL.Query().Has("worker") { return admin.changeRegularThreads(w, count) } - workerFilename := admin.getWorkerByPattern(r.URL.Query().Get("worker")) + workerFilename := admin.getWorkerBySuffix(r.URL.Query().Get("worker")) return admin.changeWorkerThreads(w, count, workerFilename) } @@ -123,7 +123,7 @@ func (admin *FrankenPHPAdmin) getCountFromRequest(r *http.Request) int { return i } -func (admin *FrankenPHPAdmin) getWorkerByPattern(pattern string) string { +func (admin *FrankenPHPAdmin) getWorkerBySuffix(pattern string) string { for _, workerFilename := range frankenphp.WorkerFileNames() { if strings.HasSuffix(workerFilename, pattern) { return workerFilename diff --git a/scaling.go b/scaling.go index c12bc0358..c56045b5b 100644 --- a/scaling.go +++ b/scaling.go @@ -10,7 +10,7 @@ import ( func AddRegularThread() (int, error) { thread := getInactivePHPThread() if thread == nil { - return countRegularThreads(), fmt.Errorf("max amount of threads reached: %d", len(phpThreads)) + return countRegularThreads(), fmt.Errorf("max amount of overall threads reached: %d", len(phpThreads)) } convertToRegularThread(thread) return countRegularThreads(), nil From 7f2b94e42119fb7816ad8cddcf8a1f80dcce37d1 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 12 Dec 2024 21:31:25 +0100 Subject: [PATCH 079/190] Adds docs. --- docs/worker.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/worker.md b/docs/worker.md index ca121ef97..316e00d9c 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -128,6 +128,16 @@ A workaround to using this type of code in worker mode is to restart the worker The previous worker snippet allows configuring a maximum number of request to handle by setting an environment variable named `MAX_REQUESTS`. +### Restart Workers manually + +While it's possible to restart workers [on file changes](config.md#watching-for-file-changes), it's also possible to restart all workers +gracefully via the [Caddy admin API](https://caddyserver.com/docs/api). If the admin is enabled in your +[Caddyfile](config.md#caddyfile-config), you can ping the restart endpoint with a simple POST request like this: + +```console +curl -X POST http://localhost:2019/frankenphp/workers/restart +``` + ### Worker Failures If a worker script crashes with a non-zero exit code, FrankenPHP will restart it with an exponential backoff strategy. From df782541fdac8bcb14b8a911afb3fc90919c5c73 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 12 Dec 2024 22:17:42 +0100 Subject: [PATCH 080/190] Changes logic to actually terminate the thread. --- caddy/admin_test.go | 4 +--- phpmainthread.go | 17 ++--------------- phpthread.go | 27 ++++++++++++++++++++++++--- scaling.go | 4 ++-- state.go | 4 ++-- thread-inactive.go | 6 ++---- thread-regular.go | 1 + thread-worker.go | 1 + 8 files changed, 35 insertions(+), 29 deletions(-) diff --git a/caddy/admin_test.go b/caddy/admin_test.go index 1109c417a..2ab9dcd80 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -164,13 +164,11 @@ func TestShowTheCorrectThreadDebugStatus(t *testing.T) { "GET", "threads", http.StatusOK, `Thread 0 (ready) Regular PHP Thread -Thread 1 (inactive) Thread 2 (ready) Worker PHP Thread - `+absWorker1Path+` Thread 3 (ready) Worker PHP Thread - `+absWorker1Path+` Thread 4 (ready) Worker PHP Thread - `+absWorker2Path+` -Thread 5 (inactive) Thread 6 (ready) Worker PHP Thread - `+absWorker1Path+` -5 additional threads can be started at runtime +7 additional threads can be started at runtime `, ) } diff --git a/phpmainthread.go b/phpmainthread.go index 03de03161..f8251d6b6 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -38,7 +38,6 @@ func initPHPThreads(numThreads int, numMaxThreads int) error { // initialize all threads as inactive for i := 0; i < numMaxThreads; i++ { phpThreads[i] = newPHPThread(i) - convertToInactiveThread(phpThreads[i]) } // start the underlying C threads @@ -73,26 +72,14 @@ func ThreadDebugStatus() string { func drainPHPThreads() { doneWG := sync.WaitGroup{} doneWG.Add(len(phpThreads)) - for _, thread := range phpThreads { - if thread.state.is(stateReserved) { - doneWG.Done() - continue - } - thread.handlerMu.Lock() - _ = thread.state.requestSafeStateChange(stateShuttingDown) - close(thread.drainChan) - } close(mainThread.done) for _, thread := range phpThreads { - if thread.state.is(stateReserved) { - continue - } go func(thread *phpThread) { - thread.state.waitFor(stateDone) - thread.handlerMu.Unlock() + thread.shutdown() doneWG.Done() }(thread) } + doneWG.Wait() mainThread.state.set(stateShuttingDown) mainThread.state.waitFor(stateDone) diff --git a/phpthread.go b/phpthread.go index 8e0b8e15f..798a641e1 100644 --- a/phpthread.go +++ b/phpthread.go @@ -36,7 +36,6 @@ type threadHandler interface { func newPHPThread(threadIndex int) *phpThread { return &phpThread{ threadIndex: threadIndex, - drainChan: make(chan struct{}), requestChan: make(chan *http.Request), handlerMu: &sync.Mutex{}, state: newThreadState(), @@ -50,20 +49,41 @@ func (thread *phpThread) boot() { logger.Error("thread is not in reserved state", zap.Int("threadIndex", thread.threadIndex), zap.Int("state", int(thread.state.get()))) return } + + // boot threads as inactive + thread.handlerMu.Lock() + thread.handler = &inactiveThread{thread: thread} + thread.drainChan = make(chan struct{}) + thread.handlerMu.Unlock() + + // start the actual posix thread - TODO: try this with go threads instead if !C.frankenphp_new_php_thread(C.uintptr_t(thread.threadIndex)) { logger.Panic("unable to create thread", zap.Int("threadIndex", thread.threadIndex)) } thread.state.waitFor(stateInactive) } +// shutdown the underlying PHP thread +func (thread *phpThread) shutdown() { + if !thread.state.requestSafeStateChange(stateShuttingDown) { + // already shutting down or done + return + } + close(thread.drainChan) + thread.state.waitFor(stateDone) + thread.drainChan = make(chan struct{}) + + // threads go back to the reserved state from which they can be booted again + thread.state.set(stateReserved) +} + // change the thread handler safely // must be called from outside of the PHP thread func (thread *phpThread) setHandler(handler threadHandler) { - logger.Debug("setHandler") thread.handlerMu.Lock() defer thread.handlerMu.Unlock() if !thread.state.requestSafeStateChange(stateTransitionRequested) { - // no state change allowed == shutdown + // no state change allowed == shutdown or done return } close(thread.drainChan) @@ -90,6 +110,7 @@ func (thread *phpThread) getActiveRequest() *http.Request { func (thread *phpThread) debugStatus() string { threadType := "" thread.handlerMu.Lock() + // TODO: this can also be put into the handler interface if required elsewhere if handler, ok := thread.handler.(*workerThread); ok { threadType = " Worker PHP Thread - " + handler.worker.fileName } else if _, ok := thread.handler.(*regularThread); ok { diff --git a/scaling.go b/scaling.go index c56045b5b..d211054f7 100644 --- a/scaling.go +++ b/scaling.go @@ -24,7 +24,7 @@ func RemoveRegularThread() (int, error) { } thread := regularThreads[len(regularThreads)-1] regularThreadMu.RUnlock() - convertToInactiveThread(thread) + thread.shutdown() return countRegularThreads(), nil } @@ -55,7 +55,7 @@ func RemoveWorkerThread(workerFileName string) (int, error) { } thread := worker.threads[len(worker.threads)-1] worker.threadMutex.RUnlock() - convertToInactiveThread(thread) + thread.shutdown() return worker.countThreads(), nil } diff --git a/state.go b/state.go index b31a9fe83..cb382b15d 100644 --- a/state.go +++ b/state.go @@ -136,8 +136,8 @@ func (ts *threadState) waitFor(states ...stateID) { func (ts *threadState) requestSafeStateChange(nextState stateID) bool { ts.mu.Lock() switch ts.currentState { - // disallow state changes if shutting down - case stateShuttingDown, stateDone: + // disallow state changes if shutting down or done + case stateShuttingDown, stateDone, stateReserved: ts.mu.Unlock() return false // ready and inactive are safe states to transition from diff --git a/thread-inactive.go b/thread-inactive.go index 7c4810c71..f1e466bc8 100644 --- a/thread-inactive.go +++ b/thread-inactive.go @@ -6,15 +6,13 @@ import ( // representation of a thread with no work assigned to it // implements the threadHandler interface +// each inactive thread weighs around ~350KB +// keeping threads at 'inactive' will consume more memory, but allow a faster transition type inactiveThread struct { thread *phpThread } func convertToInactiveThread(thread *phpThread) { - if thread.handler == nil { - thread.handler = &inactiveThread{thread: thread} - return - } thread.setHandler(&inactiveThread{thread: thread}) } diff --git a/thread-regular.go b/thread-regular.go index 610daece9..6a052c138 100644 --- a/thread-regular.go +++ b/thread-regular.go @@ -42,6 +42,7 @@ func (handler *regularThread) beforeScriptExecution() string { case stateReady: return handler.waitForRequest() case stateShuttingDown: + detachRegularThread(handler.thread) // signal to stop return "" } diff --git a/thread-worker.go b/thread-worker.go index 4c67bf488..0a030f40b 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -51,6 +51,7 @@ func (handler *workerThread) beforeScriptExecution() string { setupWorkerScript(handler, handler.worker) return handler.worker.fileName case stateShuttingDown: + handler.worker.detachThread(handler.thread) // signal to stop return "" } From ec0bc0f479870acf01d5554154c23c578e186e32 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 13 Dec 2024 16:57:15 +0100 Subject: [PATCH 081/190] Removes the test's randomness. --- phpmainthread_test.go | 66 +++++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/phpmainthread_test.go b/phpmainthread_test.go index 467ea2e0b..26dd25fac 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -75,43 +75,39 @@ func TestTransitionAThreadBetween2DifferentWorkers(t *testing.T) { assert.Nil(t, phpThreads) } +// try all possible handler transitions +// takes around 200ms and is supposed to force race conditions func TestTransitionThreadsWhileDoingRequests(t *testing.T) { numThreads := 10 numRequestsPerThread := 100 - isRunning := atomic.Bool{} - isRunning.Store(true) + isDone := atomic.Bool{} wg := sync.WaitGroup{} worker1Path := testDataPath + "/transition-worker-1.php" worker2Path := testDataPath + "/transition-worker-2.php" assert.NoError(t, Init( WithNumThreads(numThreads), - WithWorkers(worker1Path, 1, map[string]string{"ENV1": "foo"}, []string{}), - WithWorkers(worker2Path, 1, map[string]string{"ENV1": "foo"}, []string{}), + WithWorkers(worker1Path, 1, map[string]string{}, []string{}), + WithWorkers(worker2Path, 1, map[string]string{}, []string{}), WithLogger(zap.NewNop()), )) - // randomly transition threads between regular, inactive and 2 worker threads - go func() { - for { - for i := 0; i < numThreads; i++ { - switch rand.IntN(4) { - case 0: - convertToRegularThread(phpThreads[i]) - case 1: - convertToWorkerThread(phpThreads[i], workers[worker1Path]) - case 2: - convertToWorkerThread(phpThreads[i], workers[worker2Path]) - case 3: - convertToInactiveThread(phpThreads[i]) - } - time.Sleep(time.Millisecond) - if !isRunning.Load() { - return + // try all possible permutations of transition, transition every ms + transitions := allPossibleTransitions(worker1Path, worker2Path) + for i := 0; i < numThreads; i++ { + go func(thread *phpThread, start int) { + for { + for j := start; j < len(transitions); j++ { + if isDone.Load() { + return + } + transitions[j](thread) + time.Sleep(time.Millisecond) } + start = 0 } - } - }() + }(phpThreads[i], i) + } // randomly do requests to the 3 endpoints wg.Add(numThreads) @@ -131,8 +127,9 @@ func TestTransitionThreadsWhileDoingRequests(t *testing.T) { }(i) } + // we are finished as soon as all 1000 requests are done wg.Wait() - isRunning.Store(false) + isDone.Store(true) Shutdown() } @@ -159,3 +156,24 @@ func assertRequestBody(t *testing.T, url string, expected string) { body, _ := io.ReadAll(resp.Body) assert.Equal(t, expected, string(body)) } + +// create all permutations of possible transition between 2 handlers +func allPossibleTransitions(worker1Path string, worker2Path string) []func(*phpThread) { + transitions := []func(*phpThread){ + convertToRegularThread, + func(thread *phpThread) { convertToWorkerThread(thread, workers[worker1Path]) }, + func(thread *phpThread) { convertToWorkerThread(thread, workers[worker2Path]) }, + convertToInactiveThread, + } + permutations := []func(*phpThread){} + + for i := 0; i < len(transitions); i++ { + for j := 0; j < len(transitions); j++ { + if i != j { + permutations = append(permutations, transitions[i], transitions[j]) + } + } + } + + return permutations +} From 8f104070a663d1a0ebfc98a959b2fe53eef8fe0b Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 13 Dec 2024 17:05:16 +0100 Subject: [PATCH 082/190] Adds comments. --- caddy/admin_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/caddy/admin_test.go b/caddy/admin_test.go index 2ab9dcd80..3a50b2abc 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -154,11 +154,14 @@ func TestShowTheCorrectThreadDebugStatus(t *testing.T) { } `, "caddyfile") + // should create a 'worker-with-counter.php' thread at index 6 assertAdminResponse(tester, "PUT", "threads?worker=counter.php", http.StatusOK, "") + // should remove the 'index.php' worker thread at index 5 assertAdminResponse(tester, "DELETE", "threads?worker=index.php", http.StatusOK, "") + // should remove a regular thread at index 1 assertAdminResponse(tester, "DELETE", "threads", http.StatusOK, "") - // assert that all threads are in the right state via debug message + // confirm that the threads are in the expected state assertAdminResponse( tester, "GET", From 91c324de09d3348bd70c0d42d8c85e4799921bff Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 13 Dec 2024 20:53:06 +0100 Subject: [PATCH 083/190] Adds comments. --- scaling.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scaling.go b/scaling.go index d211054f7..ab302172d 100644 --- a/scaling.go +++ b/scaling.go @@ -5,8 +5,7 @@ import ( "fmt" ) -// exposed logic for safely scaling threads - +// turn the first inactive/reserved thread into a regular thread func AddRegularThread() (int, error) { thread := getInactivePHPThread() if thread == nil { @@ -16,6 +15,7 @@ func AddRegularThread() (int, error) { return countRegularThreads(), nil } +// remove the last regular thread func RemoveRegularThread() (int, error) { regularThreadMu.RLock() if len(regularThreads) <= 1 { @@ -28,6 +28,7 @@ func RemoveRegularThread() (int, error) { return countRegularThreads(), nil } +// turn the first inactive/reserved thread into a worker thread func AddWorkerThread(workerFileName string) (int, error) { worker, ok := workers[workerFileName] if !ok { @@ -42,6 +43,7 @@ func AddWorkerThread(workerFileName string) (int, error) { return worker.countThreads(), nil } +// remove the last worker thread func RemoveWorkerThread(workerFileName string) (int, error) { worker, ok := workers[workerFileName] if !ok { From ff06bd771ddcdbdaee8647eec40d09c3a0907f69 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 15 Dec 2024 15:22:45 +0100 Subject: [PATCH 084/190] Scaling v1. --- frankenphp.go | 2 ++ phpmainthread.go | 1 + phpthread.go | 5 +-- scaling.go | 90 ++++++++++++++++++++++++++++++++++++++++++++++ thread-inactive.go | 2 ++ thread-worker.go | 3 ++ worker.go | 8 ++++- 7 files changed, 108 insertions(+), 3 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index 638379d7e..78432275a 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -353,6 +353,8 @@ func Init(options ...Option) error { return err } + go initAutoScaling() + if c := logger.Check(zapcore.InfoLevel, "FrankenPHP started 🐘"); c != nil { c.Write(zap.String("php_version", Version().Version), zap.Int("num_threads", totalThreadCount)) } diff --git a/phpmainthread.go b/phpmainthread.go index f8251d6b6..baab9acd3 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -46,6 +46,7 @@ func initPHPThreads(numThreads int, numMaxThreads int) error { for i := 0; i < numThreads; i++ { thread := phpThreads[i] go func() { + thread.isProtected = true thread.boot() ready.Done() }() diff --git a/phpthread.go b/phpthread.go index 798a641e1..9b62ad665 100644 --- a/phpthread.go +++ b/phpthread.go @@ -16,7 +16,6 @@ import ( // identified by the index in the phpThreads slice type phpThread struct { runtime.Pinner - threadIndex int knownVariableKeys map[string]*C.zend_string requestChan chan *http.Request @@ -24,6 +23,8 @@ type phpThread struct { handlerMu *sync.Mutex handler threadHandler state *threadState + waitingSince int64 + isProtected bool } // interface that defines how the callbacks from the C thread should be handled @@ -117,7 +118,7 @@ func (thread *phpThread) debugStatus() string { threadType = " Regular PHP Thread" } thread.handlerMu.Unlock() - return fmt.Sprintf("Thread %d (%s)%s", thread.threadIndex, thread.state.name(), threadType) + return fmt.Sprintf("Thread %d (%s for %dms)%s", thread.threadIndex, thread.state.name(), thread.waitingSince, threadType) } // Pin a string that is not null-terminated diff --git a/scaling.go b/scaling.go index ab302172d..3f5dccbee 100644 --- a/scaling.go +++ b/scaling.go @@ -3,10 +3,36 @@ package frankenphp import ( "errors" "fmt" + "runtime" + "sync" + "sync/atomic" + "time" + + "go.uber.org/zap" ) +var scalingMu = new(sync.RWMutex) +var isAutoScaling = atomic.Bool{} +var cpuCount = runtime.NumCPU() + +func initAutoScaling() { + return + timer := time.NewTimer(5 * time.Second) + for { + timer.Reset(5 * time.Second) + select { + case <-mainThread.done: + return + case <-timer.C: + autoScaleThreads() + } + } +} + // turn the first inactive/reserved thread into a regular thread func AddRegularThread() (int, error) { + scalingMu.Lock() + defer scalingMu.Unlock() thread := getInactivePHPThread() if thread == nil { return countRegularThreads(), fmt.Errorf("max amount of overall threads reached: %d", len(phpThreads)) @@ -17,6 +43,8 @@ func AddRegularThread() (int, error) { // remove the last regular thread func RemoveRegularThread() (int, error) { + scalingMu.Lock() + defer scalingMu.Unlock() regularThreadMu.RLock() if len(regularThreads) <= 1 { regularThreadMu.RUnlock() @@ -30,6 +58,8 @@ func RemoveRegularThread() (int, error) { // turn the first inactive/reserved thread into a worker thread func AddWorkerThread(workerFileName string) (int, error) { + scalingMu.Lock() + defer scalingMu.Unlock() worker, ok := workers[workerFileName] if !ok { return 0, errors.New("worker not found") @@ -45,6 +75,8 @@ func AddWorkerThread(workerFileName string) (int, error) { // remove the last worker thread func RemoveWorkerThread(workerFileName string) (int, error) { + scalingMu.Lock() + defer scalingMu.Unlock() worker, ok := workers[workerFileName] if !ok { return 0, errors.New("worker not found") @@ -61,3 +93,61 @@ func RemoveWorkerThread(workerFileName string) (int, error) { return worker.countThreads(), nil } + +var averageStallPercent float64 = 0.0 +var stallMu = new(sync.Mutex) +var stallTime = 0 + +const minStallTimeMicroseconds = 10_000 + +func requestNewWorkerThread(worker *worker, timeSpentStalling int64, timeSpentTotal int64) { + // ignore requests that have been stalled for an acceptable amount of time + if timeSpentStalling < minStallTimeMicroseconds { + return + } + // percent of time the request spent waiting for a thread + stalledThisRequest := float64(timeSpentStalling) / float64(timeSpentTotal) + + // weigh the change to the average stall-time by the amount of handling threads + numWorkers := float64(worker.countThreads()) + stallMu.Lock() + averageStallPercent = (averageStallPercent*(numWorkers-1.0) + stalledThisRequest) / numWorkers + stallMu.Unlock() + + // if we are only being stalled by a small amount, do not scale + //logger.Info("stalling", zap.Float64("percent", averageStallPercent)) + if averageStallPercent < 0.66 { + return + } + + // prevent multiple auto-scaling attempts + if !isAutoScaling.CompareAndSwap(false, true) { + return + } + + logger.Debug("scaling up worker thread", zap.String("worker", worker.fileName)) + + // it does not matter here if adding a thread is successful or not + _, _ = AddWorkerThread(worker.fileName) + + // wait a bit to prevent spending too much time on scaling + time.Sleep(100 * time.Millisecond) + isAutoScaling.Store(false) +} + +func autoScaleThreads() { + for i := len(phpThreads) - 1; i >= 0; i-- { + thread := phpThreads[i] + if thread.isProtected { + continue + } + if thread.state.is(stateReady) && time.Now().UnixMilli()-thread.waitingSince > 5000 { + convertToInactiveThread(thread) + continue + } + if thread.state.is(stateInactive) && time.Now().UnixMilli()-thread.waitingSince > 5000 { + thread.shutdown() + continue + } + } +} diff --git a/thread-inactive.go b/thread-inactive.go index f1e466bc8..8ca72aa44 100644 --- a/thread-inactive.go +++ b/thread-inactive.go @@ -2,6 +2,7 @@ package frankenphp import ( "net/http" + "time" ) // representation of a thread with no work assigned to it @@ -24,6 +25,7 @@ func (handler *inactiveThread) beforeScriptExecution() string { return thread.transitionToNewHandler() case stateBooting, stateTransitionComplete: thread.state.set(stateInactive) + thread.waitingSince = time.Now().UnixMilli() // wait for external signal to start or shut down thread.state.waitFor(stateTransitionRequested, stateShuttingDown) return handler.beforeScriptExecution() diff --git a/thread-worker.go b/thread-worker.go index 0a030f40b..f88b54c91 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -151,6 +151,8 @@ func (handler *workerThread) waitForWorkerRequest() bool { metrics.ReadyWorker(handler.worker.fileName) } + handler.thread.waitingSince = time.Now().UnixMilli() + var r *http.Request select { case <-handler.thread.drainChan: @@ -170,6 +172,7 @@ func (handler *workerThread) waitForWorkerRequest() bool { } handler.workerRequest = r + handler.thread.waitingSince = 0 if c := logger.Check(zapcore.DebugLevel, "request handling started"); c != nil { c.Write(zap.String("worker", handler.worker.fileName), zap.String("url", r.RequestURI)) diff --git a/worker.go b/worker.go index 13c0c743e..c97c608e3 100644 --- a/worker.go +++ b/worker.go @@ -10,6 +10,7 @@ import ( "time" "github.com/dunglas/frankenphp/internal/watcher" + //"go.uber.org/zap" ) // represents a worker script and can have many threads assigned to it @@ -178,8 +179,13 @@ func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { worker.threadMutex.RUnlock() // if no thread was available, fan the request out to all threads - // TODO: theoretically there could be autoscaling of threads here + stalledAt := time.Now() worker.requestChan <- r + stallTime := time.Since(stalledAt).Microseconds() <-fc.done metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) + + // reaching here means we might not have spawned enough threads + // forward the % of time we spent being stalled to scale.go + requestNewWorkerThread(worker, stallTime, time.Since(stalledAt).Microseconds()) } From 50ba1061cdb4cdcc24fd8979ed34ad1529149443 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 15 Dec 2024 16:15:20 +0100 Subject: [PATCH 085/190] Scaling v2. --- frankenphp.go | 2 +- phpthread.go | 13 ++++--- scaling.go | 90 ++++++++++++++++++++++------------------------ thread-inactive.go | 8 +++-- thread-regular.go | 4 +++ thread-worker.go | 4 +++ worker.go | 13 ++++--- 7 files changed, 73 insertions(+), 61 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index 78432275a..fbf294f5c 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -353,7 +353,7 @@ func Init(options ...Option) error { return err } - go initAutoScaling() + initAutoScaling() if c := logger.Check(zapcore.InfoLevel, "FrankenPHP started 🐘"); c != nil { c.Write(zap.String("php_version", Version().Version), zap.Int("num_threads", totalThreadCount)) diff --git a/phpthread.go b/phpthread.go index 9b62ad665..630361348 100644 --- a/phpthread.go +++ b/phpthread.go @@ -7,6 +7,7 @@ import ( "net/http" "runtime" "sync" + "time" "unsafe" "go.uber.org/zap" @@ -29,6 +30,7 @@ type phpThread struct { // interface that defines how the callbacks from the C thread should be handled type threadHandler interface { + name() string beforeScriptExecution() string afterScriptExecution(exitStatus int) getActiveRequest() *http.Request @@ -109,16 +111,13 @@ func (thread *phpThread) getActiveRequest() *http.Request { // small status message for debugging func (thread *phpThread) debugStatus() string { - threadType := "" + waitingSinceMessage := "" thread.handlerMu.Lock() - // TODO: this can also be put into the handler interface if required elsewhere - if handler, ok := thread.handler.(*workerThread); ok { - threadType = " Worker PHP Thread - " + handler.worker.fileName - } else if _, ok := thread.handler.(*regularThread); ok { - threadType = " Regular PHP Thread" + if thread.waitingSince > 0 { + waitingSinceMessage = fmt.Sprintf(" waiting for %dms", time.Now().UnixMilli()-thread.waitingSince) } thread.handlerMu.Unlock() - return fmt.Sprintf("Thread %d (%s for %dms)%s", thread.threadIndex, thread.state.name(), thread.waitingSince, threadType) + return fmt.Sprintf("Thread %d (%s%s)%s", thread.threadIndex, thread.state.name(), waitingSinceMessage, thread.handler.name()) } // Pin a string that is not null-terminated diff --git a/scaling.go b/scaling.go index 3f5dccbee..7187aaa7b 100644 --- a/scaling.go +++ b/scaling.go @@ -3,7 +3,6 @@ package frankenphp import ( "errors" "fmt" - "runtime" "sync" "sync/atomic" "time" @@ -11,22 +10,36 @@ import ( "go.uber.org/zap" ) +const ( + // only allow scaling threads if they were stalled longer than this time + allowedStallTime = 10 * time.Millisecond + // time to wait after scaling a thread to prevent scaling too fast + scaleBlockTime = 100 * time.Millisecond + // time to wait between checking for idle threads + downScaleCheckTime = 5 * time.Second + // max time a thread can be idle before being stopped or converted to inactive + maxThreadIdleTime = 5 * time.Second + // amount of threads that can be stopped in one downScaleCheckTime iteration + amountOfThreadsStoppedAtOnce = 10 +) + var scalingMu = new(sync.RWMutex) var isAutoScaling = atomic.Bool{} -var cpuCount = runtime.NumCPU() func initAutoScaling() { - return - timer := time.NewTimer(5 * time.Second) - for { - timer.Reset(5 * time.Second) - select { - case <-mainThread.done: - return - case <-timer.C: - autoScaleThreads() + timer := time.NewTimer(downScaleCheckTime) + doneChan := mainThread.done + go func() { + for { + timer.Reset(downScaleCheckTime) + select { + case <-doneChan: + return + case <-timer.C: + stopIdleThreads() + } } - } + }() } // turn the first inactive/reserved thread into a regular thread @@ -94,59 +107,42 @@ func RemoveWorkerThread(workerFileName string) (int, error) { return worker.countThreads(), nil } -var averageStallPercent float64 = 0.0 -var stallMu = new(sync.Mutex) -var stallTime = 0 - -const minStallTimeMicroseconds = 10_000 - -func requestNewWorkerThread(worker *worker, timeSpentStalling int64, timeSpentTotal int64) { +// worker thread autoscaling +func requestNewWorkerThread(worker *worker, timeSpentStalling time.Duration) { // ignore requests that have been stalled for an acceptable amount of time - if timeSpentStalling < minStallTimeMicroseconds { - return - } - // percent of time the request spent waiting for a thread - stalledThisRequest := float64(timeSpentStalling) / float64(timeSpentTotal) - - // weigh the change to the average stall-time by the amount of handling threads - numWorkers := float64(worker.countThreads()) - stallMu.Lock() - averageStallPercent = (averageStallPercent*(numWorkers-1.0) + stalledThisRequest) / numWorkers - stallMu.Unlock() - - // if we are only being stalled by a small amount, do not scale - //logger.Info("stalling", zap.Float64("percent", averageStallPercent)) - if averageStallPercent < 0.66 { - return - } - - // prevent multiple auto-scaling attempts - if !isAutoScaling.CompareAndSwap(false, true) { + if timeSpentStalling < allowedStallTime || !isAutoScaling.CompareAndSwap(false, true) { return } - logger.Debug("scaling up worker thread", zap.String("worker", worker.fileName)) + count, err := AddWorkerThread(worker.fileName) - // it does not matter here if adding a thread is successful or not - _, _ = AddWorkerThread(worker.fileName) + logger.Debug("worker thread autoscaling", zap.String("worker", worker.fileName), zap.Int("count", count), zap.Error(err)) // wait a bit to prevent spending too much time on scaling - time.Sleep(100 * time.Millisecond) + time.Sleep(scaleBlockTime) isAutoScaling.Store(false) } -func autoScaleThreads() { +func stopIdleThreads() { + stoppedThreadCount := 0 for i := len(phpThreads) - 1; i >= 0; i-- { thread := phpThreads[i] - if thread.isProtected { + if stoppedThreadCount > amountOfThreadsStoppedAtOnce || thread.isProtected || thread.waitingSince == 0 { continue } - if thread.state.is(stateReady) && time.Now().UnixMilli()-thread.waitingSince > 5000 { + waitingMilliseconds := time.Now().UnixMilli() - thread.waitingSince + + // convert threads to inactive first + if thread.state.is(stateReady) && waitingMilliseconds > maxThreadIdleTime.Milliseconds() { convertToInactiveThread(thread) + stoppedThreadCount++ continue } - if thread.state.is(stateInactive) && time.Now().UnixMilli()-thread.waitingSince > 5000 { + + // if threads are already inactive, shut them down + if thread.state.is(stateInactive) && waitingMilliseconds > maxThreadIdleTime.Milliseconds() { thread.shutdown() + stoppedThreadCount++ continue } } diff --git a/thread-inactive.go b/thread-inactive.go index 8ca72aa44..c5248d720 100644 --- a/thread-inactive.go +++ b/thread-inactive.go @@ -36,10 +36,14 @@ func (handler *inactiveThread) beforeScriptExecution() string { panic("unexpected state: " + thread.state.name()) } -func (thread *inactiveThread) afterScriptExecution(exitStatus int) { +func (handler *inactiveThread) afterScriptExecution(exitStatus int) { panic("inactive threads should not execute scripts") } -func (thread *inactiveThread) getActiveRequest() *http.Request { +func (handler *inactiveThread) getActiveRequest() *http.Request { panic("inactive threads have no requests") } + +func (handler *inactiveThread) name() string { + return "Inactive PHP Thread" +} diff --git a/thread-regular.go b/thread-regular.go index 6a052c138..3406d998c 100644 --- a/thread-regular.go +++ b/thread-regular.go @@ -58,6 +58,10 @@ func (handler *regularThread) getActiveRequest() *http.Request { return handler.activeRequest } +func (handler *regularThread) name() string { + return "Regular PHP Thread" +} + func (handler *regularThread) waitForRequest() string { var r *http.Request select { diff --git a/thread-worker.go b/thread-worker.go index f88b54c91..e1e8657b0 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -70,6 +70,10 @@ func (handler *workerThread) getActiveRequest() *http.Request { return handler.fakeRequest } +func (handler *workerThread) name() string { + return "Worker PHP Thread - " + handler.worker.fileName +} + func setupWorkerScript(handler *workerThread, worker *worker) { handler.backoff.wait() metrics.StartWorker(worker.fileName) diff --git a/worker.go b/worker.go index c97c608e3..b49bf3b88 100644 --- a/worker.go +++ b/worker.go @@ -92,6 +92,10 @@ func drainWorkers() { } func RestartWorkers() { + // disallow scaling threads while restarting workers + scalingMu.Lock() + defer scalingMu.Unlock() + ready := sync.WaitGroup{} threadsToRestart := make([]*phpThread, 0) for _, worker := range workers { @@ -99,7 +103,8 @@ func RestartWorkers() { ready.Add(len(worker.threads)) for _, thread := range worker.threads { if !thread.state.requestSafeStateChange(stateRestarting) { - // no state change allowed = shutdown + // no state change allowed == thread is shutting down + // we'll proceed to restart all other threads anyways continue } close(thread.drainChan) @@ -181,11 +186,11 @@ func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { // if no thread was available, fan the request out to all threads stalledAt := time.Now() worker.requestChan <- r - stallTime := time.Since(stalledAt).Microseconds() + stallTime := time.Since(stalledAt) <-fc.done metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) // reaching here means we might not have spawned enough threads - // forward the % of time we spent being stalled to scale.go - requestNewWorkerThread(worker, stallTime, time.Since(stalledAt).Microseconds()) + // forward the amount of time the request spent being stalled + requestNewWorkerThread(worker, stallTime) } From bfe3de1cb63fe9f3e46e8fed57a19c8a669492dc Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 15 Dec 2024 22:12:24 +0100 Subject: [PATCH 086/190] Allows regular thread scaling. --- caddy/admin_test.go | 52 +++++++++++++-------- phpthread.go | 2 +- scaling.go | 109 ++++++++++++++++++++++++++++++-------------- thread-regular.go | 7 ++- worker.go | 2 +- 5 files changed, 115 insertions(+), 57 deletions(-) diff --git a/caddy/admin_test.go b/caddy/admin_test.go index 3a50b2abc..742d2d779 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -2,10 +2,13 @@ package caddy_test import ( "fmt" - "github.com/caddyserver/caddy/v2/caddytest" + "io" "net/http" "path/filepath" "testing" + + "github.com/caddyserver/caddy/v2/caddytest" + "github.com/stretchr/testify/assert" ) func TestRestartWorkerViaAdminApi(t *testing.T) { @@ -128,8 +131,6 @@ func TestAddWorkerThreadsViaAdminApi(t *testing.T) { } func TestShowTheCorrectThreadDebugStatus(t *testing.T) { - absWorker1Path, _ := filepath.Abs("../testdata/worker-with-counter.php") - absWorker2Path, _ := filepath.Abs("../testdata/index.php") tester := caddytest.NewTester(t) tester.InitServer(` { @@ -161,19 +162,18 @@ func TestShowTheCorrectThreadDebugStatus(t *testing.T) { // should remove a regular thread at index 1 assertAdminResponse(tester, "DELETE", "threads", http.StatusOK, "") - // confirm that the threads are in the expected state - assertAdminResponse( - tester, - "GET", - "threads", - http.StatusOK, `Thread 0 (ready) Regular PHP Thread -Thread 2 (ready) Worker PHP Thread - `+absWorker1Path+` -Thread 3 (ready) Worker PHP Thread - `+absWorker1Path+` -Thread 4 (ready) Worker PHP Thread - `+absWorker2Path+` -Thread 6 (ready) Worker PHP Thread - `+absWorker1Path+` -7 additional threads can be started at runtime -`, - ) + threadInfo := getAdminResponseBody(tester, "GET", "threads") + + // assert that the correct threads are present in the thread info + assert.Contains(t, threadInfo, "Thread 0") + assert.NotContains(t, threadInfo, "Thread 1") + assert.Contains(t, threadInfo, "Thread 2") + assert.Contains(t, threadInfo, "Thread 3") + assert.Contains(t, threadInfo, "Thread 4") + assert.NotContains(t, threadInfo, "Thread 5") + assert.Contains(t, threadInfo, "Thread 6") + assert.NotContains(t, threadInfo, "Thread 7") + assert.Contains(t, threadInfo, "7 additional threads can be started at runtime") } func assertAdminResponse(tester *caddytest.Tester, method string, path string, expectedStatus int, expectedBody string) { @@ -183,8 +183,24 @@ func assertAdminResponse(tester *caddytest.Tester, method string, path string, e panic(err) } if expectedBody == "" { - tester.AssertResponseCode(r, expectedStatus) + _ = tester.AssertResponseCode(r, expectedStatus) } else { - tester.AssertResponse(r, expectedStatus, expectedBody) + _, _ = tester.AssertResponse(r, expectedStatus, expectedBody) + } +} + +func getAdminResponseBody(tester *caddytest.Tester, method string, path string) string { + adminUrl := "http://localhost:2999/frankenphp/" + r, err := http.NewRequest(method, adminUrl+path, nil) + if err != nil { + panic(err) } + resp := tester.AssertResponseCode(r, http.StatusOK) + defer resp.Body.Close() + bytes, err := io.ReadAll(resp.Body) + if err != nil { + panic(err) + } + + return string(bytes) } diff --git a/phpthread.go b/phpthread.go index 630361348..097b9984c 100644 --- a/phpthread.go +++ b/phpthread.go @@ -117,7 +117,7 @@ func (thread *phpThread) debugStatus() string { waitingSinceMessage = fmt.Sprintf(" waiting for %dms", time.Now().UnixMilli()-thread.waitingSince) } thread.handlerMu.Unlock() - return fmt.Sprintf("Thread %d (%s%s)%s", thread.threadIndex, thread.state.name(), waitingSinceMessage, thread.handler.name()) + return fmt.Sprintf("Thread %d (%s%s) %s", thread.threadIndex, thread.state.name(), waitingSinceMessage, thread.handler.name()) } // Pin a string that is not null-terminated diff --git a/scaling.go b/scaling.go index 7187aaa7b..2fdd6923a 100644 --- a/scaling.go +++ b/scaling.go @@ -11,36 +11,23 @@ import ( ) const ( - // only allow scaling threads if they were stalled longer than this time + // only allow scaling threads if requests were stalled for longer than this time allowedStallTime = 10 * time.Millisecond - // time to wait after scaling a thread to prevent scaling too fast + // time to wait after scaling a thread to prevent spending too many resources on scaling scaleBlockTime = 100 * time.Millisecond - // time to wait between checking for idle threads + // check for and stop idle threads every x seconds downScaleCheckTime = 5 * time.Second - // max time a thread can be idle before being stopped or converted to inactive + // if an autoscaled thread has been waiting for longer than this time, terminate it maxThreadIdleTime = 5 * time.Second - // amount of threads that can be stopped in one downScaleCheckTime iteration - amountOfThreadsStoppedAtOnce = 10 + // amount of threads that can be stopped at once + maxTerminationCount = 10 ) -var scalingMu = new(sync.RWMutex) -var isAutoScaling = atomic.Bool{} - -func initAutoScaling() { - timer := time.NewTimer(downScaleCheckTime) - doneChan := mainThread.done - go func() { - for { - timer.Reset(downScaleCheckTime) - select { - case <-doneChan: - return - case <-timer.C: - stopIdleThreads() - } - } - }() -} +var ( + autoScaledThreads = []*phpThread{} + scalingMu = new(sync.RWMutex) + isAutoScaling = atomic.Bool{} +) // turn the first inactive/reserved thread into a regular thread func AddRegularThread() (int, error) { @@ -107,14 +94,35 @@ func RemoveWorkerThread(workerFileName string) (int, error) { return worker.countThreads(), nil } -// worker thread autoscaling -func requestNewWorkerThread(worker *worker, timeSpentStalling time.Duration) { - // ignore requests that have been stalled for an acceptable amount of time +func initAutoScaling() { + autoScaledThreads = []*phpThread{} + isAutoScaling.Store(false) + timer := time.NewTimer(downScaleCheckTime) + doneChan := mainThread.done + go func() { + for { + timer.Reset(downScaleCheckTime) + select { + case <-doneChan: + return + case <-timer.C: + downScaleThreads() + } + } + }() +} + +// Add worker PHP threads automatically +// only add threads if requests were stalled long enough and no other scaling is in progress +func autoscaleWorkerThreads(worker *worker, timeSpentStalling time.Duration) { if timeSpentStalling < allowedStallTime || !isAutoScaling.CompareAndSwap(false, true) { return } count, err := AddWorkerThread(worker.fileName) + worker.threadMutex.RLock() + autoScaledThreads = append(autoScaledThreads, worker.threads[len(worker.threads)-1]) + worker.threadMutex.RUnlock() logger.Debug("worker thread autoscaling", zap.String("worker", worker.fileName), zap.Int("count", count), zap.Error(err)) @@ -123,24 +131,55 @@ func requestNewWorkerThread(worker *worker, timeSpentStalling time.Duration) { isAutoScaling.Store(false) } -func stopIdleThreads() { +// Add regular PHP threads automatically +// only add threads if requests were stalled long enough and no other scaling is in progress +func autoscaleRegularThreads(timeSpentStalling time.Duration) { + if timeSpentStalling < allowedStallTime || !isAutoScaling.CompareAndSwap(false, true) { + return + } + + count, err := AddRegularThread() + + regularThreadMu.RLock() + autoScaledThreads = append(autoScaledThreads, regularThreads[len(regularThreads)-1]) + regularThreadMu.RUnlock() + + logger.Debug("regular thread autoscaling", zap.Int("count", count), zap.Error(err)) + + // wait a bit to prevent spending too much time on scaling + time.Sleep(scaleBlockTime) + isAutoScaling.Store(false) +} + +func downScaleThreads() { stoppedThreadCount := 0 - for i := len(phpThreads) - 1; i >= 0; i-- { - thread := phpThreads[i] - if stoppedThreadCount > amountOfThreadsStoppedAtOnce || thread.isProtected || thread.waitingSince == 0 { + scalingMu.Lock() + defer scalingMu.Unlock() + for i := len(autoScaledThreads) - 1; i >= 0; i-- { + thread := autoScaledThreads[i] + + // remove the thread if it's reserved + if thread.state.is(stateReserved) { + autoScaledThreads = append(autoScaledThreads[:i], autoScaledThreads[i+1:]...) + continue + } + if stoppedThreadCount > maxTerminationCount || thread.waitingSince == 0 { continue } - waitingMilliseconds := time.Now().UnixMilli() - thread.waitingSince - // convert threads to inactive first - if thread.state.is(stateReady) && waitingMilliseconds > maxThreadIdleTime.Milliseconds() { + // convert threads to inactive if they have been idle for too long + threadIdleTime := time.Now().UnixMilli() - thread.waitingSince + if thread.state.is(stateReady) && threadIdleTime > maxThreadIdleTime.Milliseconds() { + logger.Debug("auto-converting thread to inactive", zap.Int("threadIndex", thread.threadIndex)) convertToInactiveThread(thread) stoppedThreadCount++ + continue } // if threads are already inactive, shut them down - if thread.state.is(stateInactive) && waitingMilliseconds > maxThreadIdleTime.Milliseconds() { + if thread.state.is(stateInactive) && threadIdleTime > maxThreadIdleTime.Milliseconds() { + logger.Debug("auto-stopping thread", zap.Int("threadIndex", thread.threadIndex)) thread.shutdown() stoppedThreadCount++ continue diff --git a/thread-regular.go b/thread-regular.go index 3406d998c..bbfb23f90 100644 --- a/thread-regular.go +++ b/thread-regular.go @@ -5,6 +5,7 @@ import "C" import ( "net/http" "sync" + "time" ) // representation of a non-worker PHP thread @@ -113,15 +114,17 @@ func handleRequestWithRegularPHPThreads(r *http.Request, fc *FrankenPHPContext) } regularThreadMu.RUnlock() - // TODO: there can be possible auto-scaling here - // if no thread was available, fan out to all threads + var stallTime time.Duration + stalledSince := time.Now() select { case <-mainThread.done: case regularRequestChan <- r: + stallTime = time.Since(stalledSince) <-fc.done } metrics.StopRequest() + autoscaleRegularThreads(stallTime) } func attachRegularThread(thread *phpThread) { diff --git a/worker.go b/worker.go index b49bf3b88..3300fc02a 100644 --- a/worker.go +++ b/worker.go @@ -192,5 +192,5 @@ func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { // reaching here means we might not have spawned enough threads // forward the amount of time the request spent being stalled - requestNewWorkerThread(worker, stallTime) + autoscaleWorkerThreads(worker, stallTime) } From e9f62b930c13ef00ce9f2b388db5e8c1fa49d7a7 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 15 Dec 2024 22:41:13 +0100 Subject: [PATCH 087/190] Refactors wait-time. --- phpthread.go | 9 +++------ scaling.go | 9 +++++---- state.go | 24 ++++++++++++++++++++++++ thread-inactive.go | 5 +++-- thread-regular.go | 3 +++ thread-worker.go | 5 ++--- 6 files changed, 40 insertions(+), 15 deletions(-) diff --git a/phpthread.go b/phpthread.go index 097b9984c..d2cb588fd 100644 --- a/phpthread.go +++ b/phpthread.go @@ -7,7 +7,6 @@ import ( "net/http" "runtime" "sync" - "time" "unsafe" "go.uber.org/zap" @@ -24,7 +23,6 @@ type phpThread struct { handlerMu *sync.Mutex handler threadHandler state *threadState - waitingSince int64 isProtected bool } @@ -112,11 +110,10 @@ func (thread *phpThread) getActiveRequest() *http.Request { // small status message for debugging func (thread *phpThread) debugStatus() string { waitingSinceMessage := "" - thread.handlerMu.Lock() - if thread.waitingSince > 0 { - waitingSinceMessage = fmt.Sprintf(" waiting for %dms", time.Now().UnixMilli()-thread.waitingSince) + waitTime := thread.state.waitTime() + if waitTime > 0 { + waitingSinceMessage = fmt.Sprintf(" waiting for %dms", waitTime) } - thread.handlerMu.Unlock() return fmt.Sprintf("Thread %d (%s%s) %s", thread.threadIndex, thread.state.name(), waitingSinceMessage, thread.handler.name()) } diff --git a/scaling.go b/scaling.go index 2fdd6923a..ab5733384 100644 --- a/scaling.go +++ b/scaling.go @@ -163,13 +163,14 @@ func downScaleThreads() { autoScaledThreads = append(autoScaledThreads[:i], autoScaledThreads[i+1:]...) continue } - if stoppedThreadCount > maxTerminationCount || thread.waitingSince == 0 { + + waitTime := thread.state.waitTime() + if stoppedThreadCount > maxTerminationCount || waitTime == 0 { continue } // convert threads to inactive if they have been idle for too long - threadIdleTime := time.Now().UnixMilli() - thread.waitingSince - if thread.state.is(stateReady) && threadIdleTime > maxThreadIdleTime.Milliseconds() { + if thread.state.is(stateReady) && waitTime > maxThreadIdleTime.Milliseconds() { logger.Debug("auto-converting thread to inactive", zap.Int("threadIndex", thread.threadIndex)) convertToInactiveThread(thread) stoppedThreadCount++ @@ -178,7 +179,7 @@ func downScaleThreads() { } // if threads are already inactive, shut them down - if thread.state.is(stateInactive) && threadIdleTime > maxThreadIdleTime.Milliseconds() { + if thread.state.is(stateInactive) && waitTime > maxThreadIdleTime.Milliseconds() { logger.Debug("auto-stopping thread", zap.Int("threadIndex", thread.threadIndex)) thread.shutdown() stoppedThreadCount++ diff --git a/state.go b/state.go index cb382b15d..6af67a363 100644 --- a/state.go +++ b/state.go @@ -3,6 +3,7 @@ package frankenphp import ( "slices" "sync" + "time" ) type stateID uint8 @@ -46,6 +47,7 @@ type threadState struct { currentState stateID mu sync.RWMutex subscribers []stateSubscriber + waitingSince int64 } type stateSubscriber struct { @@ -100,6 +102,28 @@ func (ts *threadState) set(nextState stateID) { ts.mu.Unlock() } +// the thread reached a stable state and is waiting +func (ts *threadState) markAsWaiting(isWaiting bool) { + ts.mu.Lock() + if isWaiting { + ts.waitingSince = time.Now().UnixMilli() + } else { + ts.waitingSince = 0 + } + ts.mu.Unlock() +} + +// the time since the thread is waiting in a stable state (for request/activation) +func (ts *threadState) waitTime() int64 { + ts.mu.RLock() + var waitTime int64 = 0 + if ts.waitingSince != 0 { + waitTime = time.Now().UnixMilli() - ts.waitingSince + } + ts.mu.RUnlock() + return waitTime +} + func (ts *threadState) notifySubscribers(nextState stateID) { if len(ts.subscribers) == 0 { return diff --git a/thread-inactive.go b/thread-inactive.go index c5248d720..cb9afebc4 100644 --- a/thread-inactive.go +++ b/thread-inactive.go @@ -2,7 +2,6 @@ package frankenphp import ( "net/http" - "time" ) // representation of a thread with no work assigned to it @@ -25,9 +24,11 @@ func (handler *inactiveThread) beforeScriptExecution() string { return thread.transitionToNewHandler() case stateBooting, stateTransitionComplete: thread.state.set(stateInactive) - thread.waitingSince = time.Now().UnixMilli() + // wait for external signal to start or shut down + thread.state.markAsWaiting(true) thread.state.waitFor(stateTransitionRequested, stateShuttingDown) + thread.state.markAsWaiting(false) return handler.beforeScriptExecution() case stateShuttingDown: // signal to stop diff --git a/thread-regular.go b/thread-regular.go index bbfb23f90..df07f0896 100644 --- a/thread-regular.go +++ b/thread-regular.go @@ -64,6 +64,8 @@ func (handler *regularThread) name() string { } func (handler *regularThread) waitForRequest() string { + handler.state.markAsWaiting(true) + var r *http.Request select { case <-handler.thread.drainChan: @@ -75,6 +77,7 @@ func (handler *regularThread) waitForRequest() string { } handler.activeRequest = r + handler.state.markAsWaiting(false) fc := r.Context().Value(contextKey).(*FrankenPHPContext) if err := updateServerContext(handler.thread, r, true, false); err != nil { diff --git a/thread-worker.go b/thread-worker.go index e1e8657b0..0d00dd1c5 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -146,6 +146,7 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { func (handler *workerThread) waitForWorkerRequest() bool { // unpin any memory left over from previous requests handler.thread.Unpin() + handler.state.markAsWaiting(true) if c := logger.Check(zapcore.DebugLevel, "waiting for request"); c != nil { c.Write(zap.String("worker", handler.worker.fileName)) @@ -155,8 +156,6 @@ func (handler *workerThread) waitForWorkerRequest() bool { metrics.ReadyWorker(handler.worker.fileName) } - handler.thread.waitingSince = time.Now().UnixMilli() - var r *http.Request select { case <-handler.thread.drainChan: @@ -176,7 +175,7 @@ func (handler *workerThread) waitForWorkerRequest() bool { } handler.workerRequest = r - handler.thread.waitingSince = 0 + handler.state.markAsWaiting(false) if c := logger.Check(zapcore.DebugLevel, "request handling started"); c != nil { c.Write(zap.String("worker", handler.worker.fileName), zap.String("url", r.RequestURI)) From 21949ddbddb69eedb7892cca3f674a9c8bb64781 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 15 Dec 2024 23:03:46 +0100 Subject: [PATCH 088/190] Explicitly requires setting max_threads. --- docs/config.md | 2 +- frankenphp.go | 6 +----- scaling.go | 30 +++++++++++++++++++----------- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/docs/config.md b/docs/config.md index d39d6da35..9c75fdac0 100644 --- a/docs/config.md +++ b/docs/config.md @@ -51,7 +51,7 @@ Optionally, the number of threads to create and [worker scripts](worker.md) to s { frankenphp { 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: 2x the number of num_threads. + max_threads # Limits the number of additional PHP threads that can be started at runtime. Default: num_threads (no scaling). worker { file # Sets the path to the worker script. num # Sets the number of PHP threads to start, defaults to 2x the number of available CPUs. diff --git a/frankenphp.go b/frankenphp.go index fbf294f5c..f709e6264 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -267,10 +267,6 @@ func calculateMaxThreads(opt *opt) (int, int, int, error) { return opt.numThreads, numWorkers, opt.maxThreads, NotEnoughThreads } - // default maxThreads to 2x the number of threads - if opt.maxThreads == 0 { - opt.maxThreads = 2 * opt.numThreads - } if opt.maxThreads < opt.numThreads { opt.maxThreads = opt.numThreads } @@ -353,7 +349,7 @@ func Init(options ...Option) error { return err } - initAutoScaling() + initAutoScaling(totalThreadCount, maxThreadCount) if c := logger.Check(zapcore.InfoLevel, "FrankenPHP started 🐘"); c != nil { c.Write(zap.String("php_version", Version().Version), zap.Int("num_threads", totalThreadCount)) diff --git a/scaling.go b/scaling.go index ab5733384..c8ef7663c 100644 --- a/scaling.go +++ b/scaling.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap" ) +// TODO: make speed of scaling dependant on CPU count? const ( // only allow scaling threads if requests were stalled for longer than this time allowedStallTime = 10 * time.Millisecond @@ -26,7 +27,7 @@ const ( var ( autoScaledThreads = []*phpThread{} scalingMu = new(sync.RWMutex) - isAutoScaling = atomic.Bool{} + blockAutoScaling = atomic.Bool{} ) // turn the first inactive/reserved thread into a regular thread @@ -64,6 +65,8 @@ func AddWorkerThread(workerFileName string) (int, error) { if !ok { return 0, errors.New("worker not found") } + + // TODO: instead of starting new threads, would it make sense to convert idle ones? thread := getInactivePHPThread() if thread == nil { count := worker.countThreads() @@ -94,9 +97,13 @@ func RemoveWorkerThread(workerFileName string) (int, error) { return worker.countThreads(), nil } -func initAutoScaling() { - autoScaledThreads = []*phpThread{} - isAutoScaling.Store(false) +func initAutoScaling(numThreads int, maxThreads int) { + if maxThreads <= numThreads { + blockAutoScaling.Store(true) + return + } + autoScaledThreads = make([]*phpThread, 0, maxThreads-numThreads) + blockAutoScaling.Store(false) timer := time.NewTimer(downScaleCheckTime) doneChan := mainThread.done go func() { @@ -113,9 +120,9 @@ func initAutoScaling() { } // Add worker PHP threads automatically -// only add threads if requests were stalled long enough and no other scaling is in progress +// Only add threads if requests were stalled long enough and no other scaling is in progress func autoscaleWorkerThreads(worker *worker, timeSpentStalling time.Duration) { - if timeSpentStalling < allowedStallTime || !isAutoScaling.CompareAndSwap(false, true) { + if timeSpentStalling < allowedStallTime || !blockAutoScaling.CompareAndSwap(false, true) { return } @@ -128,13 +135,13 @@ func autoscaleWorkerThreads(worker *worker, timeSpentStalling time.Duration) { // wait a bit to prevent spending too much time on scaling time.Sleep(scaleBlockTime) - isAutoScaling.Store(false) + blockAutoScaling.Store(false) } // Add regular PHP threads automatically -// only add threads if requests were stalled long enough and no other scaling is in progress +// Only add threads if requests were stalled long enough and no other scaling is in progress func autoscaleRegularThreads(timeSpentStalling time.Duration) { - if timeSpentStalling < allowedStallTime || !isAutoScaling.CompareAndSwap(false, true) { + if timeSpentStalling < allowedStallTime || !blockAutoScaling.CompareAndSwap(false, true) { return } @@ -148,7 +155,7 @@ func autoscaleRegularThreads(timeSpentStalling time.Duration) { // wait a bit to prevent spending too much time on scaling time.Sleep(scaleBlockTime) - isAutoScaling.Store(false) + blockAutoScaling.Store(false) } func downScaleThreads() { @@ -158,7 +165,7 @@ func downScaleThreads() { for i := len(autoScaledThreads) - 1; i >= 0; i-- { thread := autoScaledThreads[i] - // remove the thread if it's reserved + // the thread might have been stopped otherwise, remove it if thread.state.is(stateReserved) { autoScaledThreads = append(autoScaledThreads[:i], autoScaledThreads[i+1:]...) continue @@ -183,6 +190,7 @@ func downScaleThreads() { logger.Debug("auto-stopping thread", zap.Int("threadIndex", thread.threadIndex)) thread.shutdown() stoppedThreadCount++ + autoScaledThreads = append(autoScaledThreads[:i], autoScaledThreads[i+1:]...) continue } } From 39a7fc9d43cd519c100d44d57767de1088d2eaf6 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 15 Dec 2024 23:15:55 +0100 Subject: [PATCH 089/190] Removes redundant check. --- thread-regular.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/thread-regular.go b/thread-regular.go index df07f0896..ad8e33854 100644 --- a/thread-regular.go +++ b/thread-regular.go @@ -118,14 +118,10 @@ func handleRequestWithRegularPHPThreads(r *http.Request, fc *FrankenPHPContext) regularThreadMu.RUnlock() // if no thread was available, fan out to all threads - var stallTime time.Duration stalledSince := time.Now() - select { - case <-mainThread.done: - case regularRequestChan <- r: - stallTime = time.Since(stalledSince) - <-fc.done - } + regularRequestChan <- r + stallTime := time.Since(stalledSince) + <-fc.done metrics.StopRequest() autoscaleRegularThreads(stallTime) } From 442a558ec50fd777f3b1d6f9c4ff3f5a686bf698 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 15 Dec 2024 23:17:21 +0100 Subject: [PATCH 090/190] Removes unnecessary import. --- thread-regular.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/thread-regular.go b/thread-regular.go index ad8e33854..f21b9bec5 100644 --- a/thread-regular.go +++ b/thread-regular.go @@ -1,7 +1,5 @@ package frankenphp -// #include "frankenphp.h" -import "C" import ( "net/http" "sync" From c213fc9e4f8b341a0c34eccdeb4f6e2e08788a02 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 17 Dec 2024 11:04:29 +0100 Subject: [PATCH 091/190] Records clock time. --- frankenphp.c | 20 +++++++++++++++++++- thread-worker.go | 4 +++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index e0e5095c4..65065b50d 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -82,6 +82,9 @@ __thread bool should_filter_var = 0; __thread frankenphp_server_context *local_ctx = NULL; __thread uintptr_t thread_index; __thread zval *os_environment = NULL; +__thread float ioPercentage = 0.0; +__thread struct timespec cpu_start; +__thread struct timespec req_start; static void frankenphp_free_request_context() { frankenphp_server_context *ctx = SG(server_context); @@ -416,6 +419,9 @@ PHP_FUNCTION(frankenphp_handle_request) { RETURN_FALSE; } + clock_gettime(CLOCK_THREAD_CPUTIME_ID, &cpu_start); + clock_gettime(CLOCK_MONOTONIC, &req_start); + #ifdef ZEND_MAX_EXECUTION_TIMERS /* * Reset default timeout @@ -443,7 +449,19 @@ PHP_FUNCTION(frankenphp_handle_request) { frankenphp_worker_request_shutdown(); ctx->has_active_request = false; - go_frankenphp_finish_worker_request(thread_index); + + // calculate how much time was spent using the CPU + struct timespec cpu_end; + struct timespec req_end; + + clock_gettime(CLOCK_THREAD_CPUTIME_ID, &cpu_end); + clock_gettime(CLOCK_MONOTONIC, &req_end); + + float cpu_diff = (cpu_end.tv_nsec / 1000000000.0 + cpu_end.tv_sec) - (cpu_start.tv_nsec / 1000000000.0 + cpu_start.tv_sec); + float req_diff = (req_end.tv_nsec / 1000000000.0 + req_end.tv_sec) - (req_start.tv_nsec / 1000000000.0 + req_start.tv_sec); + float cpu_percent = cpu_diff / req_diff; + + go_frankenphp_finish_worker_request(thread_index,cpu_percent); RETURN_TRUE; } diff --git a/thread-worker.go b/thread-worker.go index 0d00dd1c5..885466735 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -203,7 +203,7 @@ func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { } //export go_frankenphp_finish_worker_request -func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t) { +func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t, cpuPercent C.float) { thread := phpThreads[threadIndex] r := thread.getActiveRequest() fc := r.Context().Value(contextKey).(*FrankenPHPContext) @@ -214,6 +214,8 @@ func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t) { if c := fc.logger.Check(zapcore.DebugLevel, "request handling finished"); c != nil { c.Write(zap.String("worker", fc.scriptFilename), zap.String("url", r.RequestURI)) } + + //logger.Warn("cpu time", zap.Float64("cpu percent", float64(cpuPercent))) } // when frankenphp_finish_request() is directly called from PHP From 58daaa550e246ea027faa420a1f388ab10b2dcd3 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 17 Dec 2024 15:58:39 +0100 Subject: [PATCH 092/190] Saves CPU metrics of last 100 requests. --- scaling.go | 2 ++ thread-worker.go | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/scaling.go b/scaling.go index c8ef7663c..b2196950e 100644 --- a/scaling.go +++ b/scaling.go @@ -28,6 +28,8 @@ var ( autoScaledThreads = []*phpThread{} scalingMu = new(sync.RWMutex) blockAutoScaling = atomic.Bool{} + allThreadsCpuPercent float64 + cpuMutex sync.Mutex ) // turn the first inactive/reserved thread into a regular thread diff --git a/thread-worker.go b/thread-worker.go index 885466735..3acb1fd20 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -215,7 +215,10 @@ func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t, cpuPercent C.f c.Write(zap.String("worker", fc.scriptFilename), zap.String("url", r.RequestURI)) } - //logger.Warn("cpu time", zap.Float64("cpu percent", float64(cpuPercent))) + cpuMutex.Lock() + allThreadsCpuPercent = (allThreadsCpuPercent * 99 + float64(cpuPercent)) / 100 + logger.Warn("cpu time", zap.Float64("cpu percent", allThreadsCpuPercent)) + cpuMutex.Unlock() } // when frankenphp_finish_request() is directly called from PHP From 14925f6b3e473b156a2a2a6d4cf4dc88efa665fc Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 19 Dec 2024 00:09:24 +0100 Subject: [PATCH 093/190] Integrates CPU tracking. --- frankenphp.c | 12 ++++------ scaling.go | 60 +++++++++++++++++++++++++++++++++++++----------- thread-worker.go | 5 +--- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 65065b50d..e78107a4e 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -83,8 +83,6 @@ __thread frankenphp_server_context *local_ctx = NULL; __thread uintptr_t thread_index; __thread zval *os_environment = NULL; __thread float ioPercentage = 0.0; -__thread struct timespec cpu_start; -__thread struct timespec req_start; static void frankenphp_free_request_context() { frankenphp_server_context *ctx = SG(server_context); @@ -419,6 +417,8 @@ PHP_FUNCTION(frankenphp_handle_request) { RETURN_FALSE; } + // read the CPU timer + struct timespec cpu_start, cpu_end, req_start, req_end; clock_gettime(CLOCK_THREAD_CPUTIME_ID, &cpu_start); clock_gettime(CLOCK_MONOTONIC, &req_start); @@ -450,18 +450,14 @@ PHP_FUNCTION(frankenphp_handle_request) { frankenphp_worker_request_shutdown(); ctx->has_active_request = false; - // calculate how much time was spent using the CPU - struct timespec cpu_end; - struct timespec req_end; - + // calculate how much time was spent using a CPU core clock_gettime(CLOCK_THREAD_CPUTIME_ID, &cpu_end); clock_gettime(CLOCK_MONOTONIC, &req_end); - float cpu_diff = (cpu_end.tv_nsec / 1000000000.0 + cpu_end.tv_sec) - (cpu_start.tv_nsec / 1000000000.0 + cpu_start.tv_sec); float req_diff = (req_end.tv_nsec / 1000000000.0 + req_end.tv_sec) - (req_start.tv_nsec / 1000000000.0 + req_start.tv_sec); float cpu_percent = cpu_diff / req_diff; - go_frankenphp_finish_worker_request(thread_index,cpu_percent); + go_frankenphp_finish_worker_request(thread_index, cpu_percent); RETURN_TRUE; } diff --git a/scaling.go b/scaling.go index b2196950e..cd8ddbbd8 100644 --- a/scaling.go +++ b/scaling.go @@ -3,6 +3,7 @@ package frankenphp import ( "errors" "fmt" + "runtime" "sync" "sync/atomic" "time" @@ -20,16 +21,19 @@ const ( downScaleCheckTime = 5 * time.Second // if an autoscaled thread has been waiting for longer than this time, terminate it maxThreadIdleTime = 5 * time.Second + // if PHP threads are using more than this percentage of CPU, do not scale + maxCpuPotential = 0.85 // amount of threads that can be stopped at once maxTerminationCount = 10 ) var ( - autoScaledThreads = []*phpThread{} - scalingMu = new(sync.RWMutex) - blockAutoScaling = atomic.Bool{} + autoScaledThreads = []*phpThread{} + scalingMu = new(sync.RWMutex) + blockAutoScaling = atomic.Bool{} + cpuCount = runtime.NumCPU() allThreadsCpuPercent float64 - cpuMutex sync.Mutex + cpuMutex sync.Mutex ) // turn the first inactive/reserved thread into a regular thread @@ -122,18 +126,29 @@ func initAutoScaling(numThreads int, maxThreads int) { } // Add worker PHP threads automatically -// Only add threads if requests were stalled long enough and no other scaling is in progress func autoscaleWorkerThreads(worker *worker, timeSpentStalling time.Duration) { + + // first check if time spent waiting for a thread was above the allowed threshold if timeSpentStalling < allowedStallTime || !blockAutoScaling.CompareAndSwap(false, true) { return } - count, err := AddWorkerThread(worker.fileName) - worker.threadMutex.RLock() - autoScaledThreads = append(autoScaledThreads, worker.threads[len(worker.threads)-1]) - worker.threadMutex.RUnlock() + threadCount := worker.countThreads() + if cpuCoresAreBusy(threadCount) { + logger.Debug("not autoscaling", zap.String("worker", worker.fileName), zap.Int("count", threadCount)) + time.Sleep(scaleBlockTime) + blockAutoScaling.Store(false) + return + } - logger.Debug("worker thread autoscaling", zap.String("worker", worker.fileName), zap.Int("count", count), zap.Error(err)) + _, err := AddWorkerThread(worker.fileName) + if err != nil { + logger.Debug("could not add worker thread", zap.String("worker", worker.fileName), zap.Error(err)) + } + + scalingMu.Lock() + autoScaledThreads = append(autoScaledThreads, worker.threads[len(worker.threads)-1]) + scalingMu.Unlock() // wait a bit to prevent spending too much time on scaling time.Sleep(scaleBlockTime) @@ -148,10 +163,9 @@ func autoscaleRegularThreads(timeSpentStalling time.Duration) { } count, err := AddRegularThread() - - regularThreadMu.RLock() + scalingMu.Lock() autoScaledThreads = append(autoScaledThreads, regularThreads[len(regularThreads)-1]) - regularThreadMu.RUnlock() + scalingMu.Unlock() logger.Debug("regular thread autoscaling", zap.Int("count", count), zap.Error(err)) @@ -197,3 +211,23 @@ func downScaleThreads() { } } } + +// threads spend a certain % of time on CPU cores and a certain % waiting for IO +// this function tracks the CPU usage and weighs it against previous requests +func trackCpuUsage(cpuPercent float64) { + cpuMutex.Lock() + allThreadsCpuPercent = (allThreadsCpuPercent*99 + cpuPercent) / 100 + cpuMutex.Unlock() +} + +// threads track how much time they spend on CPU cores +// cpuPotential is the average amount of time threads spend on CPU cores * the number of threads +// example: 10 threads that spend 10% of their time on CPU cores and 90% waiting for IO, would have a potential of 100% +// only scale if the potential is below a threshold +// if the potential is too high, then requests are stalled because of CPU usage, not because of IO +func cpuCoresAreBusy(threadCount int) bool { + cpuMutex.Lock() + cpuPotential := allThreadsCpuPercent * float64(threadCount) / float64(cpuCount) + cpuMutex.Unlock() + return cpuPotential > maxCpuPotential +} diff --git a/thread-worker.go b/thread-worker.go index 3acb1fd20..c7af932d6 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -215,10 +215,7 @@ func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t, cpuPercent C.f c.Write(zap.String("worker", fc.scriptFilename), zap.String("url", r.RequestURI)) } - cpuMutex.Lock() - allThreadsCpuPercent = (allThreadsCpuPercent * 99 + float64(cpuPercent)) / 100 - logger.Warn("cpu time", zap.Float64("cpu percent", allThreadsCpuPercent)) - cpuMutex.Unlock() + trackCpuUsage(float64(cpuPercent)) } // when frankenphp_finish_request() is directly called from PHP From d408bdd2b9265a5fe8f77ac4e1b9100b8e87b95c Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 19 Dec 2024 12:03:21 +0100 Subject: [PATCH 094/190] Replaces clock with probing. --- frankenphp.c | 35 ++++++++++++------- frankenphp.h | 2 ++ scaling.go | 88 ++++++++++++++++++++++++------------------------ thread-worker.go | 4 +-- 4 files changed, 69 insertions(+), 60 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index e78107a4e..b0df120ce 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -417,11 +417,6 @@ PHP_FUNCTION(frankenphp_handle_request) { RETURN_FALSE; } - // read the CPU timer - struct timespec cpu_start, cpu_end, req_start, req_end; - clock_gettime(CLOCK_THREAD_CPUTIME_ID, &cpu_start); - clock_gettime(CLOCK_MONOTONIC, &req_start); - #ifdef ZEND_MAX_EXECUTION_TIMERS /* * Reset default timeout @@ -450,14 +445,7 @@ PHP_FUNCTION(frankenphp_handle_request) { frankenphp_worker_request_shutdown(); ctx->has_active_request = false; - // calculate how much time was spent using a CPU core - clock_gettime(CLOCK_THREAD_CPUTIME_ID, &cpu_end); - clock_gettime(CLOCK_MONOTONIC, &req_end); - float cpu_diff = (cpu_end.tv_nsec / 1000000000.0 + cpu_end.tv_sec) - (cpu_start.tv_nsec / 1000000000.0 + cpu_start.tv_sec); - float req_diff = (req_end.tv_nsec / 1000000000.0 + req_end.tv_sec) - (req_start.tv_nsec / 1000000000.0 + req_start.tv_sec); - float cpu_percent = cpu_diff / req_diff; - - go_frankenphp_finish_worker_request(thread_index, cpu_percent); + go_frankenphp_finish_worker_request(thread_index); RETURN_TRUE; } @@ -1178,3 +1166,24 @@ int frankenphp_reset_opcache(void) { } return 0; } + + /* + * Probe the CPU usage of the entire process fo x milliseconds + * Uses clock_gettime to compare cpu time with real time + * Returns the % of CPUs used by the process in the timeframe + */ +float frankenphp_probe_cpu(int cpu_count, int milliseconds) { + struct timespec sleep_time, cpu_start, cpu_end, probe_start, probe_end; + clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &cpu_start); + clock_gettime(CLOCK_MONOTONIC, &probe_start); + + sleep_time.tv_sec = 0; + sleep_time.tv_nsec = 1000 * 1000 * milliseconds; + nanosleep(&sleep_time, &sleep_time); + + clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &cpu_end); + clock_gettime(CLOCK_MONOTONIC, &probe_end); + float cpu_diff = (cpu_end.tv_nsec / 1000000000.0 + cpu_end.tv_sec) - (cpu_start.tv_nsec / 1000000000.0 + cpu_start.tv_sec); + float req_diff = (probe_end.tv_nsec / 1000000000.0 + probe_end.tv_sec) - (probe_start.tv_nsec / 1000000000.0 + probe_start.tv_sec); + return cpu_diff / req_diff / cpu_count; +} diff --git a/frankenphp.h b/frankenphp.h index 5e498b6c7..e3efcc746 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -69,4 +69,6 @@ zend_string *frankenphp_init_persistent_string(const char *string, size_t len); void frankenphp_release_zend_string(zend_string *z_string); int frankenphp_reset_opcache(void); +float frankenphp_probe_cpu(int cpu_count, int milliseconds); + #endif diff --git a/scaling.go b/scaling.go index cd8ddbbd8..38596f6d6 100644 --- a/scaling.go +++ b/scaling.go @@ -1,5 +1,7 @@ package frankenphp +//#include "frankenphp.h" +import "C" import ( "errors" "fmt" @@ -15,16 +17,16 @@ import ( const ( // only allow scaling threads if requests were stalled for longer than this time allowedStallTime = 10 * time.Millisecond - // time to wait after scaling a thread to prevent spending too many resources on scaling - scaleBlockTime = 100 * time.Millisecond - // check for and stop idle threads every x seconds - downScaleCheckTime = 5 * time.Second - // if an autoscaled thread has been waiting for longer than this time, terminate it - maxThreadIdleTime = 5 * time.Second - // if PHP threads are using more than this percentage of CPU, do not scale - maxCpuPotential = 0.85 - // amount of threads that can be stopped at once + // the amount of time to check for CPU usage before scaling + cpuProbeTime = 100 * time.Millisecond + // if PHP threads are using more than this ratio of the CPU, do not scale + maxCpuUsageForScaling = 0.8 + // check if threads should be stopped every x seconds + downScaleCheckTime = 5 * time.Second + // amount of threads that can be stopped in one iteration of downScaleCheckTime maxTerminationCount = 10 + // if an autoscaled thread has been waiting for longer than this time, terminate it + maxThreadIdleTime = 5 * time.Second ) var ( @@ -32,8 +34,6 @@ var ( scalingMu = new(sync.RWMutex) blockAutoScaling = atomic.Bool{} cpuCount = runtime.NumCPU() - allThreadsCpuPercent float64 - cpuMutex sync.Mutex ) // turn the first inactive/reserved thread into a regular thread @@ -72,7 +72,6 @@ func AddWorkerThread(workerFileName string) (int, error) { return 0, errors.New("worker not found") } - // TODO: instead of starting new threads, would it make sense to convert idle ones? thread := getInactivePHPThread() if thread == nil { count := worker.countThreads() @@ -132,35 +131,38 @@ func autoscaleWorkerThreads(worker *worker, timeSpentStalling time.Duration) { if timeSpentStalling < allowedStallTime || !blockAutoScaling.CompareAndSwap(false, true) { return } + defer blockAutoScaling.Store(false) - threadCount := worker.countThreads() - if cpuCoresAreBusy(threadCount) { - logger.Debug("not autoscaling", zap.String("worker", worker.fileName), zap.Int("count", threadCount)) - time.Sleep(scaleBlockTime) - blockAutoScaling.Store(false) + // TODO: is there an easy way to check if we are reaching memory limits? + + if probeIfCpusAreBusy(cpuProbeTime) { + logger.Debug("cpu is busy, not autoscaling", zap.String("worker", worker.fileName)) return } - _, err := AddWorkerThread(worker.fileName) + count, err := AddWorkerThread(worker.fileName) if err != nil { - logger.Debug("could not add worker thread", zap.String("worker", worker.fileName), zap.Error(err)) + logger.Debug("could not add worker thread", zap.String("worker", worker.fileName), zap.Int("count", count), zap.Error(err)) } scalingMu.Lock() autoScaledThreads = append(autoScaledThreads, worker.threads[len(worker.threads)-1]) scalingMu.Unlock() - - // wait a bit to prevent spending too much time on scaling - time.Sleep(scaleBlockTime) - blockAutoScaling.Store(false) } // Add regular PHP threads automatically -// Only add threads if requests were stalled long enough and no other scaling is in progress func autoscaleRegularThreads(timeSpentStalling time.Duration) { + + // first check if time spent waiting for a thread was above the allowed threshold if timeSpentStalling < allowedStallTime || !blockAutoScaling.CompareAndSwap(false, true) { return } + defer blockAutoScaling.Store(false) + + if probeIfCpusAreBusy(cpuProbeTime) { + logger.Debug("cpu is busy, not autoscaling") + return + } count, err := AddRegularThread() scalingMu.Lock() @@ -168,10 +170,6 @@ func autoscaleRegularThreads(timeSpentStalling time.Duration) { scalingMu.Unlock() logger.Debug("regular thread autoscaling", zap.Int("count", count), zap.Error(err)) - - // wait a bit to prevent spending too much time on scaling - time.Sleep(scaleBlockTime) - blockAutoScaling.Store(false) } func downScaleThreads() { @@ -212,22 +210,24 @@ func downScaleThreads() { } } -// threads spend a certain % of time on CPU cores and a certain % waiting for IO -// this function tracks the CPU usage and weighs it against previous requests -func trackCpuUsage(cpuPercent float64) { - cpuMutex.Lock() - allThreadsCpuPercent = (allThreadsCpuPercent*99 + cpuPercent) / 100 - cpuMutex.Unlock() +func readMemory(){ + return; + var mem runtime.MemStats + runtime.ReadMemStats(&mem) + + fmt.Printf("Total allocated memory: %d bytes\n", mem.TotalAlloc) + fmt.Printf("Number of memory allocations: %d\n", mem.Mallocs) } -// threads track how much time they spend on CPU cores -// cpuPotential is the average amount of time threads spend on CPU cores * the number of threads -// example: 10 threads that spend 10% of their time on CPU cores and 90% waiting for IO, would have a potential of 100% -// only scale if the potential is below a threshold -// if the potential is too high, then requests are stalled because of CPU usage, not because of IO -func cpuCoresAreBusy(threadCount int) bool { - cpuMutex.Lock() - cpuPotential := allThreadsCpuPercent * float64(threadCount) / float64(cpuCount) - cpuMutex.Unlock() - return cpuPotential > maxCpuPotential +// probe the CPU usage of the process +// if CPUs are not busy, most threads are likely waiting for I/O, so we should scale +// if CPUs are already busy we won't gain much by scaling and want to avoid the overhead of doing so +// keep in mind that this will only probe CPU usage by PHP Threads +// time spent by the go runtime or other processes is not considered +func probeIfCpusAreBusy(sleepTime time.Duration) bool { + cpuUsage := float64(C.frankenphp_probe_cpu(C.int(cpuCount), C.int(sleepTime.Milliseconds()))) + + logger.Warn("CPU usage", zap.Float64("usage", cpuUsage)) + return cpuUsage > maxCpuUsageForScaling } + diff --git a/thread-worker.go b/thread-worker.go index c7af932d6..0d00dd1c5 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -203,7 +203,7 @@ func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { } //export go_frankenphp_finish_worker_request -func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t, cpuPercent C.float) { +func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t) { thread := phpThreads[threadIndex] r := thread.getActiveRequest() fc := r.Context().Value(contextKey).(*FrankenPHPContext) @@ -214,8 +214,6 @@ func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t, cpuPercent C.f if c := fc.logger.Check(zapcore.DebugLevel, "request handling finished"); c != nil { c.Write(zap.String("worker", fc.scriptFilename), zap.String("url", r.RequestURI)) } - - trackCpuUsage(float64(cpuPercent)) } // when frankenphp_finish_request() is directly called from PHP From 8fc3293ce2bdfc4439441ef41c52857e6a5ae841 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 19 Dec 2024 12:03:54 +0100 Subject: [PATCH 095/190] fmt. --- frankenphp.c | 16 +++++++++------- scaling.go | 23 +++++++++++------------ 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index b0df120ce..be4ac6f12 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1167,11 +1167,11 @@ int frankenphp_reset_opcache(void) { return 0; } - /* - * Probe the CPU usage of the entire process fo x milliseconds - * Uses clock_gettime to compare cpu time with real time - * Returns the % of CPUs used by the process in the timeframe - */ +/* + * Probe the CPU usage of the entire process fo x milliseconds + * Uses clock_gettime to compare cpu time with real time + * Returns the % of CPUs used by the process in the timeframe + */ float frankenphp_probe_cpu(int cpu_count, int milliseconds) { struct timespec sleep_time, cpu_start, cpu_end, probe_start, probe_end; clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &cpu_start); @@ -1183,7 +1183,9 @@ float frankenphp_probe_cpu(int cpu_count, int milliseconds) { clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &cpu_end); clock_gettime(CLOCK_MONOTONIC, &probe_end); - float cpu_diff = (cpu_end.tv_nsec / 1000000000.0 + cpu_end.tv_sec) - (cpu_start.tv_nsec / 1000000000.0 + cpu_start.tv_sec); - float req_diff = (probe_end.tv_nsec / 1000000000.0 + probe_end.tv_sec) - (probe_start.tv_nsec / 1000000000.0 + probe_start.tv_sec); + float cpu_diff = (cpu_end.tv_nsec / 1000000000.0 + cpu_end.tv_sec) - + (cpu_start.tv_nsec / 1000000000.0 + cpu_start.tv_sec); + float req_diff = (probe_end.tv_nsec / 1000000000.0 + probe_end.tv_sec) - + (probe_start.tv_nsec / 1000000000.0 + probe_start.tv_sec); return cpu_diff / req_diff / cpu_count; } diff --git a/scaling.go b/scaling.go index 38596f6d6..9f2cf30a7 100644 --- a/scaling.go +++ b/scaling.go @@ -22,18 +22,18 @@ const ( // if PHP threads are using more than this ratio of the CPU, do not scale maxCpuUsageForScaling = 0.8 // check if threads should be stopped every x seconds - downScaleCheckTime = 5 * time.Second + downScaleCheckTime = 5 * time.Second // amount of threads that can be stopped in one iteration of downScaleCheckTime maxTerminationCount = 10 // if an autoscaled thread has been waiting for longer than this time, terminate it - maxThreadIdleTime = 5 * time.Second + maxThreadIdleTime = 5 * time.Second ) var ( - autoScaledThreads = []*phpThread{} - scalingMu = new(sync.RWMutex) - blockAutoScaling = atomic.Bool{} - cpuCount = runtime.NumCPU() + autoScaledThreads = []*phpThread{} + scalingMu = new(sync.RWMutex) + blockAutoScaling = atomic.Bool{} + cpuCount = runtime.NumCPU() ) // turn the first inactive/reserved thread into a regular thread @@ -160,9 +160,9 @@ func autoscaleRegularThreads(timeSpentStalling time.Duration) { defer blockAutoScaling.Store(false) if probeIfCpusAreBusy(cpuProbeTime) { - logger.Debug("cpu is busy, not autoscaling") - return - } + logger.Debug("cpu is busy, not autoscaling") + return + } count, err := AddRegularThread() scalingMu.Lock() @@ -210,8 +210,8 @@ func downScaleThreads() { } } -func readMemory(){ - return; +func readMemory() { + return var mem runtime.MemStats runtime.ReadMemStats(&mem) @@ -230,4 +230,3 @@ func probeIfCpusAreBusy(sleepTime time.Duration) bool { logger.Warn("CPU usage", zap.Float64("usage", cpuUsage)) return cpuUsage > maxCpuUsageForScaling } - From 031424784a95d5739a658c6c47df0a4c55872faa Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 19 Dec 2024 14:57:23 +0100 Subject: [PATCH 096/190] Adds autoscale tests. --- caddy/admin_test.go | 110 +++++++++++++++++++++++++++++++++++++ frankenphp.c | 23 -------- frankenphp.go | 1 + frankenphp.h | 2 - scaling.go | 130 ++++++++++++++++++++++++++++---------------- testdata/sleep.php | 23 ++++++++ 6 files changed, 216 insertions(+), 73 deletions(-) create mode 100644 testdata/sleep.php diff --git a/caddy/admin_test.go b/caddy/admin_test.go index 742d2d779..6b9ab131c 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -5,6 +5,8 @@ import ( "io" "net/http" "path/filepath" + "strings" + "sync" "testing" "github.com/caddyserver/caddy/v2/caddytest" @@ -176,6 +178,114 @@ func TestShowTheCorrectThreadDebugStatus(t *testing.T) { assert.Contains(t, threadInfo, "7 additional threads can be started at runtime") } +func TestAutoScaleWorkerThreads(t *testing.T) { + wg := sync.WaitGroup{} + maxTries := 100 + requestsPerTry := 200 + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + + frankenphp { + max_threads 10 + num_threads 2 + worker ../testdata/sleep.php 1 + } + } + + localhost:`+testPort+` { + route { + root ../testdata + rewrite sleep.php + php + } + } + `, "caddyfile") + + // spam an endpoint that simulates IO + endpoint := "http://localhost:" + testPort + "/?sleep=5&work=1000" + autoScaledThread := "Thread 2" + + // first assert that the thread is not already present + threadInfo := getAdminResponseBody(tester, "GET", "threads") + assert.NotContains(t, threadInfo, autoScaledThread) + + // try to spawn the additional threads by spamming the server + for tries := 0; tries < maxTries; tries++ { + wg.Add(requestsPerTry) + for i := 0; i < requestsPerTry; i++ { + go func() { + tester.AssertGetResponse(endpoint, http.StatusOK, "slept for 5 ms and worked for 1000 iterations") + wg.Done() + }() + } + wg.Wait() + threadInfo = getAdminResponseBody(tester, "GET", "threads") + if strings.Contains(threadInfo, autoScaledThread) { + break + } + } + + // assert that the autoscaled thread is present in the threadInfo + assert.Contains(t, threadInfo, autoScaledThread) +} + +func TestAutoScaleRegularThreads(t *testing.T) { + wg := sync.WaitGroup{} + maxTries := 100 + requestsPerTry := 200 + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + + frankenphp { + max_threads 10 + num_threads 1 + } + } + + localhost:`+testPort+` { + route { + root ../testdata + php + } + } + `, "caddyfile") + + // spam an endpoint that simulates IO + endpoint := "http://localhost:" + testPort + "/sleep.php?sleep=5&work=1000" + autoScaledThread := "Thread 1" + + // first assert that the thread is not already present + threadInfo := getAdminResponseBody(tester, "GET", "threads") + assert.NotContains(t, threadInfo, autoScaledThread) + + // try to spawn the additional threads by spamming the server + for tries := 0; tries < maxTries; tries++ { + wg.Add(requestsPerTry) + for i := 0; i < requestsPerTry; i++ { + go func() { + tester.AssertGetResponse(endpoint, http.StatusOK, "slept for 5 ms and worked for 1000 iterations") + wg.Done() + }() + } + wg.Wait() + threadInfo = getAdminResponseBody(tester, "GET", "threads") + if strings.Contains(threadInfo, autoScaledThread) { + break + } + } + + // assert that the autoscaled thread is present in the threadInfo + assert.Contains(t, threadInfo, autoScaledThread) +} + func assertAdminResponse(tester *caddytest.Tester, method string, path string, expectedStatus int, expectedBody string) { adminUrl := "http://localhost:2999/frankenphp/" r, err := http.NewRequest(method, adminUrl+path, nil) diff --git a/frankenphp.c b/frankenphp.c index be4ac6f12..0a499c2fa 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1166,26 +1166,3 @@ int frankenphp_reset_opcache(void) { } return 0; } - -/* - * Probe the CPU usage of the entire process fo x milliseconds - * Uses clock_gettime to compare cpu time with real time - * Returns the % of CPUs used by the process in the timeframe - */ -float frankenphp_probe_cpu(int cpu_count, int milliseconds) { - struct timespec sleep_time, cpu_start, cpu_end, probe_start, probe_end; - clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &cpu_start); - clock_gettime(CLOCK_MONOTONIC, &probe_start); - - sleep_time.tv_sec = 0; - sleep_time.tv_nsec = 1000 * 1000 * milliseconds; - nanosleep(&sleep_time, &sleep_time); - - clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &cpu_end); - clock_gettime(CLOCK_MONOTONIC, &probe_end); - float cpu_diff = (cpu_end.tv_nsec / 1000000000.0 + cpu_end.tv_sec) - - (cpu_start.tv_nsec / 1000000000.0 + cpu_start.tv_sec); - float req_diff = (probe_end.tv_nsec / 1000000000.0 + probe_end.tv_sec) - - (probe_start.tv_nsec / 1000000000.0 + probe_start.tv_sec); - return cpu_diff / req_diff / cpu_count; -} diff --git a/frankenphp.go b/frankenphp.go index f709e6264..11756c5b9 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -367,6 +367,7 @@ func Init(options ...Option) error { func Shutdown() { drainWorkers() drainPHPThreads() + drainAutoScaling() metrics.Shutdown() // Remove the installed app diff --git a/frankenphp.h b/frankenphp.h index e3efcc746..5e498b6c7 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -69,6 +69,4 @@ zend_string *frankenphp_init_persistent_string(const char *string, size_t len); void frankenphp_release_zend_string(zend_string *z_string); int frankenphp_reset_opcache(void); -float frankenphp_probe_cpu(int cpu_count, int milliseconds); - #endif diff --git a/scaling.go b/scaling.go index 9f2cf30a7..ee17477eb 100644 --- a/scaling.go +++ b/scaling.go @@ -4,7 +4,6 @@ package frankenphp import "C" import ( "errors" - "fmt" "runtime" "sync" "sync/atomic" @@ -18,7 +17,7 @@ const ( // only allow scaling threads if requests were stalled for longer than this time allowedStallTime = 10 * time.Millisecond // the amount of time to check for CPU usage before scaling - cpuProbeTime = 100 * time.Millisecond + cpuProbeTime = 50 * time.Millisecond // if PHP threads are using more than this ratio of the CPU, do not scale maxCpuUsageForScaling = 0.8 // check if threads should be stopped every x seconds @@ -40,66 +39,84 @@ var ( func AddRegularThread() (int, error) { scalingMu.Lock() defer scalingMu.Unlock() + _, err := addRegularThread() + return countRegularThreads(), err +} + +func addRegularThread() (*phpThread, error) { thread := getInactivePHPThread() if thread == nil { - return countRegularThreads(), fmt.Errorf("max amount of overall threads reached: %d", len(phpThreads)) + return nil, errors.New("max amount of overall threads reached") } convertToRegularThread(thread) - return countRegularThreads(), nil + return thread, nil } -// remove the last regular thread func RemoveRegularThread() (int, error) { scalingMu.Lock() defer scalingMu.Unlock() + err := removeRegularThread() + return countRegularThreads(), err +} + +// remove the last regular thread +func removeRegularThread() error { regularThreadMu.RLock() if len(regularThreads) <= 1 { regularThreadMu.RUnlock() - return 1, errors.New("cannot remove last thread") + return errors.New("cannot remove last thread") } thread := regularThreads[len(regularThreads)-1] regularThreadMu.RUnlock() thread.shutdown() - return countRegularThreads(), nil + return nil } -// turn the first inactive/reserved thread into a worker thread func AddWorkerThread(workerFileName string) (int, error) { - scalingMu.Lock() - defer scalingMu.Unlock() worker, ok := workers[workerFileName] if !ok { return 0, errors.New("worker not found") } + scalingMu.Lock() + defer scalingMu.Unlock() + _, err := addWorkerThread(worker) + return worker.countThreads(), err +} +// turn the first inactive/reserved thread into a worker thread +func addWorkerThread(worker *worker) (*phpThread, error) { thread := getInactivePHPThread() if thread == nil { - count := worker.countThreads() - return count, fmt.Errorf("max amount of threads reached: %d", count) + return nil, errors.New("max amount of overall threads reached") } convertToWorkerThread(thread, worker) - return worker.countThreads(), nil + return thread, nil } -// remove the last worker thread func RemoveWorkerThread(workerFileName string) (int, error) { - scalingMu.Lock() - defer scalingMu.Unlock() worker, ok := workers[workerFileName] if !ok { return 0, errors.New("worker not found") } + scalingMu.Lock() + defer scalingMu.Unlock() + err := removeWorkerThread(worker) + + return worker.countThreads(), err +} +// remove the last worker thread +func removeWorkerThread(worker *worker) error { worker.threadMutex.RLock() if len(worker.threads) <= 1 { worker.threadMutex.RUnlock() - return 1, errors.New("cannot remove last thread") + return errors.New("cannot remove last thread") } thread := worker.threads[len(worker.threads)-1] worker.threadMutex.RUnlock() thread.shutdown() - return worker.countThreads(), nil + return nil } func initAutoScaling(numThreads int, maxThreads int) { @@ -107,8 +124,8 @@ func initAutoScaling(numThreads int, maxThreads int) { blockAutoScaling.Store(true) return } - autoScaledThreads = make([]*phpThread, 0, maxThreads-numThreads) blockAutoScaling.Store(false) + autoScaledThreads = make([]*phpThread, 0, maxThreads-numThreads) timer := time.NewTimer(downScaleCheckTime) doneChan := mainThread.done go func() { @@ -124,52 +141,60 @@ func initAutoScaling(numThreads int, maxThreads int) { }() } +func drainAutoScaling() { + scalingMu.Lock() + blockAutoScaling.Store(true) + scalingMu.Unlock() +} + // Add worker PHP threads automatically func autoscaleWorkerThreads(worker *worker, timeSpentStalling time.Duration) { - // first check if time spent waiting for a thread was above the allowed threshold if timeSpentStalling < allowedStallTime || !blockAutoScaling.CompareAndSwap(false, true) { return } + scalingMu.Lock() + defer scalingMu.Unlock() defer blockAutoScaling.Store(false) // TODO: is there an easy way to check if we are reaching memory limits? - if probeIfCpusAreBusy(cpuProbeTime) { + if !probeCPUs(cpuProbeTime) { logger.Debug("cpu is busy, not autoscaling", zap.String("worker", worker.fileName)) return } - count, err := AddWorkerThread(worker.fileName) + thread, err := addWorkerThread(worker) if err != nil { - logger.Debug("could not add worker thread", zap.String("worker", worker.fileName), zap.Int("count", count), zap.Error(err)) + logger.Debug("could not add worker thread", zap.String("worker", worker.fileName), zap.Error(err)) + return } - scalingMu.Lock() - autoScaledThreads = append(autoScaledThreads, worker.threads[len(worker.threads)-1]) - scalingMu.Unlock() + autoScaledThreads = append(autoScaledThreads, thread) } // Add regular PHP threads automatically func autoscaleRegularThreads(timeSpentStalling time.Duration) { - // first check if time spent waiting for a thread was above the allowed threshold if timeSpentStalling < allowedStallTime || !blockAutoScaling.CompareAndSwap(false, true) { return } + scalingMu.Lock() + defer scalingMu.Unlock() defer blockAutoScaling.Store(false) - if probeIfCpusAreBusy(cpuProbeTime) { + if !probeCPUs(cpuProbeTime) { logger.Debug("cpu is busy, not autoscaling") return } - count, err := AddRegularThread() - scalingMu.Lock() - autoScaledThreads = append(autoScaledThreads, regularThreads[len(regularThreads)-1]) - scalingMu.Unlock() + thread, err := addRegularThread() + if err != nil { + logger.Debug("could not add regular thread", zap.Error(err)) + return + } - logger.Debug("regular thread autoscaling", zap.Int("count", count), zap.Error(err)) + autoScaledThreads = append(autoScaledThreads, thread) } func downScaleThreads() { @@ -210,23 +235,32 @@ func downScaleThreads() { } } -func readMemory() { - return - var mem runtime.MemStats - runtime.ReadMemStats(&mem) - - fmt.Printf("Total allocated memory: %d bytes\n", mem.TotalAlloc) - fmt.Printf("Number of memory allocations: %d\n", mem.Mallocs) -} - -// probe the CPU usage of the process +// probe the CPU usage of all PHP Threads // if CPUs are not busy, most threads are likely waiting for I/O, so we should scale // if CPUs are already busy we won't gain much by scaling and want to avoid the overhead of doing so -// keep in mind that this will only probe CPU usage by PHP Threads // time spent by the go runtime or other processes is not considered -func probeIfCpusAreBusy(sleepTime time.Duration) bool { - cpuUsage := float64(C.frankenphp_probe_cpu(C.int(cpuCount), C.int(sleepTime.Milliseconds()))) +func probeCPUs(probeTime time.Duration) bool { + var startTime, endTime, cpuTime, cpuEndTime C.struct_timespec + + C.clock_gettime(C.CLOCK_MONOTONIC, &startTime) + C.clock_gettime(C.CLOCK_PROCESS_CPUTIME_ID, &cpuTime) + + timer := time.NewTimer(probeTime) + select { + case <-mainThread.done: + return false + case <-timer.C: + } + + C.clock_gettime(C.CLOCK_MONOTONIC, &endTime) + C.clock_gettime(C.CLOCK_PROCESS_CPUTIME_ID, &cpuEndTime) + + elapsedTime := float64((endTime.tv_sec-startTime.tv_sec)*1e9 + (endTime.tv_nsec - startTime.tv_nsec)) + elapsedCpuTime := float64((cpuEndTime.tv_sec-cpuTime.tv_sec)*1e9 + (cpuEndTime.tv_nsec - cpuTime.tv_nsec)) + cpuUsage := elapsedCpuTime / elapsedTime / float64(cpuCount) + + // TODO: remove unnecessary debug messages + logger.Debug("CPU usage", zap.Float64("cpuUsage", cpuUsage)) - logger.Warn("CPU usage", zap.Float64("usage", cpuUsage)) - return cpuUsage > maxCpuUsageForScaling + return cpuUsage < maxCpuUsageForScaling } diff --git a/testdata/sleep.php b/testdata/sleep.php new file mode 100644 index 000000000..221515d7f --- /dev/null +++ b/testdata/sleep.php @@ -0,0 +1,23 @@ + Date: Thu, 19 Dec 2024 15:09:00 +0100 Subject: [PATCH 097/190] Merges main. --- frankenphp.go | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index 35b4de759..54894b7ef 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -472,27 +472,13 @@ func ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) error fc.startedAt = time.Now() // Detect if a worker is available to handle this request - if !isWorker { - if worker, ok := workers[fc.scriptFilename]; ok { - metrics.StartWorkerRequest(fc.scriptFilename) - worker.handleRequest(request) - <-fc.done - metrics.StopWorkerRequest(fc.scriptFilename, time.Since(fc.startedAt)) - return nil - } else { - metrics.StartRequest() - } - } - - select { - case <-done: - case requestChan <- request: - <-fc.done + if worker, ok := workers[fc.scriptFilename]; ok { + worker.handleRequest(request, fc) + return nil } - if !isWorker { - metrics.StopRequest() - } + // If no worker was availabe send the request to non-worker threads + handleRequestWithRegularPHPThreads(request, fc) return nil } From 3b9f5774a2f4601520d83ec9944f0a76a8224428 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 19 Dec 2024 16:24:17 +0100 Subject: [PATCH 098/190] Fixes alpine (probably) --- frankenphp.c | 1 - scaling.go | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index af907f2a9..e0e5095c4 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -82,7 +82,6 @@ __thread bool should_filter_var = 0; __thread frankenphp_server_context *local_ctx = NULL; __thread uintptr_t thread_index; __thread zval *os_environment = NULL; -__thread float ioPercentage = 0.0; static void frankenphp_free_request_context() { frankenphp_server_context *ctx = SG(server_context); diff --git a/scaling.go b/scaling.go index ee17477eb..26185e714 100644 --- a/scaling.go +++ b/scaling.go @@ -255,8 +255,8 @@ func probeCPUs(probeTime time.Duration) bool { C.clock_gettime(C.CLOCK_MONOTONIC, &endTime) C.clock_gettime(C.CLOCK_PROCESS_CPUTIME_ID, &cpuEndTime) - elapsedTime := float64((endTime.tv_sec-startTime.tv_sec)*1e9 + (endTime.tv_nsec - startTime.tv_nsec)) - elapsedCpuTime := float64((cpuEndTime.tv_sec-cpuTime.tv_sec)*1e9 + (cpuEndTime.tv_nsec - cpuTime.tv_nsec)) + elapsedTime := float64((endTime.tv_sec-startTime.tv_sec)*1e9 + (endTime.tv_nsec - startTime.tv_nsec)*1.0) + elapsedCpuTime := float64((cpuEndTime.tv_sec-cpuTime.tv_sec)*1e9 + (cpuEndTime.tv_nsec - cpuTime.tv_nsec)*1.0) cpuUsage := elapsedCpuTime / elapsedTime / float64(cpuCount) // TODO: remove unnecessary debug messages From 790ce4ed693de66589fe426d93729deab445b08e Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 19 Dec 2024 17:04:08 +0100 Subject: [PATCH 099/190] Fixes alpine (definitely) --- scaling.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scaling.go b/scaling.go index 26185e714..f0596aa95 100644 --- a/scaling.go +++ b/scaling.go @@ -240,10 +240,10 @@ func downScaleThreads() { // if CPUs are already busy we won't gain much by scaling and want to avoid the overhead of doing so // time spent by the go runtime or other processes is not considered func probeCPUs(probeTime time.Duration) bool { - var startTime, endTime, cpuTime, cpuEndTime C.struct_timespec + var start, end, cpuStart, cpuEnd C.struct_timespec - C.clock_gettime(C.CLOCK_MONOTONIC, &startTime) - C.clock_gettime(C.CLOCK_PROCESS_CPUTIME_ID, &cpuTime) + C.clock_gettime(C.CLOCK_MONOTONIC, &start) + C.clock_gettime(C.CLOCK_PROCESS_CPUTIME_ID, &cpuStart) timer := time.NewTimer(probeTime) select { @@ -252,11 +252,11 @@ func probeCPUs(probeTime time.Duration) bool { case <-timer.C: } - C.clock_gettime(C.CLOCK_MONOTONIC, &endTime) - C.clock_gettime(C.CLOCK_PROCESS_CPUTIME_ID, &cpuEndTime) + C.clock_gettime(C.CLOCK_MONOTONIC, &end) + C.clock_gettime(C.CLOCK_PROCESS_CPUTIME_ID, &cpuEnd) - elapsedTime := float64((endTime.tv_sec-startTime.tv_sec)*1e9 + (endTime.tv_nsec - startTime.tv_nsec)*1.0) - elapsedCpuTime := float64((cpuEndTime.tv_sec-cpuTime.tv_sec)*1e9 + (cpuEndTime.tv_nsec - cpuTime.tv_nsec)*1.0) + elapsedTime := float64(end.tv_sec-start.tv_sec)*1e9 + float64(end.tv_nsec - start.tv_nsec) + elapsedCpuTime := float64(cpuEnd.tv_sec-cpuStart.tv_sec)*1e9 + float64(cpuEnd.tv_nsec - cpuStart.tv_nsec) cpuUsage := elapsedCpuTime / elapsedTime / float64(cpuCount) // TODO: remove unnecessary debug messages From 29de62afd2a9e057009ec69c41d9d41be2b18225 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 19 Dec 2024 17:04:33 +0100 Subject: [PATCH 100/190] go fmt --- scaling.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scaling.go b/scaling.go index f0596aa95..4cb3d61d2 100644 --- a/scaling.go +++ b/scaling.go @@ -255,8 +255,8 @@ func probeCPUs(probeTime time.Duration) bool { C.clock_gettime(C.CLOCK_MONOTONIC, &end) C.clock_gettime(C.CLOCK_PROCESS_CPUTIME_ID, &cpuEnd) - elapsedTime := float64(end.tv_sec-start.tv_sec)*1e9 + float64(end.tv_nsec - start.tv_nsec) - elapsedCpuTime := float64(cpuEnd.tv_sec-cpuStart.tv_sec)*1e9 + float64(cpuEnd.tv_nsec - cpuStart.tv_nsec) + elapsedTime := float64(end.tv_sec-start.tv_sec)*1e9 + float64(end.tv_nsec-start.tv_nsec) + elapsedCpuTime := float64(cpuEnd.tv_sec-cpuStart.tv_sec)*1e9 + float64(cpuEnd.tv_nsec-cpuStart.tv_nsec) cpuUsage := elapsedCpuTime / elapsedTime / float64(cpuCount) // TODO: remove unnecessary debug messages From b4474126e5ffa39c343918a448d42d56dd723805 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 19 Dec 2024 23:01:16 +0100 Subject: [PATCH 101/190] Removes unnecessary 'isProtected' --- phpmainthread.go | 1 - phpthread.go | 1 - 2 files changed, 2 deletions(-) diff --git a/phpmainthread.go b/phpmainthread.go index baab9acd3..f8251d6b6 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -46,7 +46,6 @@ func initPHPThreads(numThreads int, numMaxThreads int) error { for i := 0; i < numThreads; i++ { thread := phpThreads[i] go func() { - thread.isProtected = true thread.boot() ready.Done() }() diff --git a/phpthread.go b/phpthread.go index d2cb588fd..f85f89451 100644 --- a/phpthread.go +++ b/phpthread.go @@ -23,7 +23,6 @@ type phpThread struct { handlerMu *sync.Mutex handler threadHandler state *threadState - isProtected bool } // interface that defines how the callbacks from the C thread should be handled From 6fa90d655da482a10fa108a6f34f01522bd69c6b Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 20 Dec 2024 15:09:17 +0100 Subject: [PATCH 102/190] Adds perf tests. --- dev.Dockerfile | 3 +- testdata/k6/computation-heavy-with-io.js | 23 +++++++++++++++ testdata/k6/computation-heavy.js | 23 +++++++++++++++ testdata/k6/computation.js | 23 +++++++++++++++ testdata/k6/database.js | 24 +++++++++++++++ testdata/k6/db-request-fast.js | 23 +++++++++++++++ testdata/k6/db-request-medium.js | 23 +++++++++++++++ testdata/k6/db-request-slow.js | 23 +++++++++++++++ testdata/k6/external-api-fast.js | 23 +++++++++++++++ testdata/k6/external-api-medium.js | 23 +++++++++++++++ testdata/k6/external-api-slow.js | 23 +++++++++++++++ testdata/k6/hanging-server.js | 28 ++++++++++++++++++ testdata/k6/hello-world.js | 23 +++++++++++++++ testdata/k6/k6.Caddyfile | 21 ++++++++++++++ testdata/k6/load-test.sh | 37 ++++++++++++++++++++++++ testdata/k6/load-tests.md | 13 +++++++++ testdata/k6/start-server.sh | 8 +++++ testdata/k6/storage.js | 29 +++++++++++++++++++ testdata/sleep.php | 10 +++++-- 19 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 testdata/k6/computation-heavy-with-io.js create mode 100644 testdata/k6/computation-heavy.js create mode 100644 testdata/k6/computation.js create mode 100644 testdata/k6/database.js create mode 100644 testdata/k6/db-request-fast.js create mode 100644 testdata/k6/db-request-medium.js create mode 100644 testdata/k6/db-request-slow.js create mode 100644 testdata/k6/external-api-fast.js create mode 100644 testdata/k6/external-api-medium.js create mode 100644 testdata/k6/external-api-slow.js create mode 100644 testdata/k6/hanging-server.js create mode 100644 testdata/k6/hello-world.js create mode 100644 testdata/k6/k6.Caddyfile create mode 100644 testdata/k6/load-test.sh create mode 100644 testdata/k6/load-tests.md create mode 100644 testdata/k6/start-server.sh create mode 100644 testdata/k6/storage.js diff --git a/dev.Dockerfile b/dev.Dockerfile index 9493e92d2..e318c97a1 100644 --- a/dev.Dockerfile +++ b/dev.Dockerfile @@ -71,7 +71,8 @@ WORKDIR /usr/local/src/watcher RUN git clone https://github.com/e-dant/watcher . && \ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && \ cmake --build build/ && \ - cmake --install build + cmake --install build && \ + ldconfig WORKDIR /go/src/app COPY . . diff --git a/testdata/k6/computation-heavy-with-io.js b/testdata/k6/computation-heavy-with-io.js new file mode 100644 index 000000000..00a22c681 --- /dev/null +++ b/testdata/k6/computation-heavy-with-io.js @@ -0,0 +1,23 @@ +import http from 'k6/http'; +import { sleep } from 'k6'; + +const ioLatencyMilliseconds = 5; +const workIterations = 500000; +const outputIterations = 50; + +export const options = { + stages: [ + { duration: '20s', target: 10, }, + { duration: '20s', target: 50 }, + { duration: '20s', target: 0 }, + ], + thresholds: { + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(90)<150'], + }, +}; + +export default function () { + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); + //sleep(1); +} \ No newline at end of file diff --git a/testdata/k6/computation-heavy.js b/testdata/k6/computation-heavy.js new file mode 100644 index 000000000..53e8bb110 --- /dev/null +++ b/testdata/k6/computation-heavy.js @@ -0,0 +1,23 @@ +import http from 'k6/http'; +import {sleep} from 'k6'; + +const ioLatencyMilliseconds = 0; +const workIterations = 500000; +const outputIterations = 150; + +export const options = { + stages: [ + {duration: '20s', target: 25,}, + {duration: '20s', target: 50}, + {duration: '20s', target: 0}, + ], + thresholds: { + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(90)<150'], + }, +}; + +export default function () { + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); + //sleep(1); +} \ No newline at end of file diff --git a/testdata/k6/computation.js b/testdata/k6/computation.js new file mode 100644 index 000000000..2a931021f --- /dev/null +++ b/testdata/k6/computation.js @@ -0,0 +1,23 @@ +import http from 'k6/http'; +import { sleep } from 'k6'; + +const ioLatencyMilliseconds = 0; +const workIterations = 50000; +const outputIterations = 50; + +export const options = { + stages: [ + { duration: '20s', target: 40, }, + { duration: '20s', target: 80 }, + { duration: '20s', target: 0 }, + ], + thresholds: { + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(90)<150'], + }, +}; + +export default function () { + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); + //sleep(1); +} \ No newline at end of file diff --git a/testdata/k6/database.js b/testdata/k6/database.js new file mode 100644 index 000000000..19a51ea04 --- /dev/null +++ b/testdata/k6/database.js @@ -0,0 +1,24 @@ +import http from 'k6/http'; +import { sleep } from 'k6'; + +/** + * Modern databases tend to have latencies in the single-digit milliseconds. + */ +export const options = { + stages: [ + { duration: '20s', target: 50, }, // ramp up to concurrency 10 over 20s + { duration: '20s', target: 200 }, // ramp up to concurrency 25 over 20s + { duration: '20s', target: 0 }, // ramp down to 0 over 20s + ], + thresholds: { + http_req_failed: ['rate<0.01'], // http errors should be less than 1% + http_req_duration: ['p(90)<5'], // 90% of requests should be below 150ms + }, +}; + +// simulate different latencies +export default function () { + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=1work=5000&output=10`); + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=5work=5000&output=10`); + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=10work=5000&output=10`); +} \ No newline at end of file diff --git a/testdata/k6/db-request-fast.js b/testdata/k6/db-request-fast.js new file mode 100644 index 000000000..dcef9d4e7 --- /dev/null +++ b/testdata/k6/db-request-fast.js @@ -0,0 +1,23 @@ +import http from 'k6/http'; +import { sleep } from 'k6'; + +const ioLatencyMilliseconds = 1; +const workIterations = 5000; +const outputIterations = 10; + +export const options = { + stages: [ + { duration: '20s', target: 50, }, // ramp up to concurrency 10 over 20s + { duration: '20s', target: 200 }, // ramp up to concurrency 25 over 20s + { duration: '20s', target: 0 }, // ramp down to 0 over 20s + ], + thresholds: { + http_req_failed: ['rate<0.01'], // http errors should be less than 1% + http_req_duration: ['p(90)<5'], // 90% of requests should be below 150ms + }, +}; + +export default function () { + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); + //sleep(1); +} \ No newline at end of file diff --git a/testdata/k6/db-request-medium.js b/testdata/k6/db-request-medium.js new file mode 100644 index 000000000..0f103271c --- /dev/null +++ b/testdata/k6/db-request-medium.js @@ -0,0 +1,23 @@ +import http from 'k6/http'; +import { sleep } from 'k6'; + +const ioLatencyMilliseconds = 5; +const workIterations = 5000; +const outputIterations = 10; + +export const options = { + stages: [ + { duration: '20s', target: 50, }, // ramp up to concurrency 10 over 20s + { duration: '20s', target: 200 }, // ramp up to concurrency 25 over 20s + { duration: '20s', target: 0 }, // ramp down to 0 over 20s + ], + thresholds: { + http_req_failed: ['rate<0.01'], // http errors should be less than 1% + http_req_duration: ['p(90)<10'], // 90% of requests should be below 150ms + }, +}; + +export default function () { + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); + //sleep(1); +} \ No newline at end of file diff --git a/testdata/k6/db-request-slow.js b/testdata/k6/db-request-slow.js new file mode 100644 index 000000000..70d9ab28e --- /dev/null +++ b/testdata/k6/db-request-slow.js @@ -0,0 +1,23 @@ +import http from 'k6/http'; +import { sleep } from 'k6'; + +const ioLatencyMilliseconds = 10; +const workIterations = 5000; +const outputIterations = 10; + +export const options = { + stages: [ + { duration: '20s', target: 50, }, // ramp up to concurrency 10 over 20s + { duration: '20s', target: 200 }, // ramp up to concurrency 25 over 20s + { duration: '20s', target: 0 }, // ramp down to 0 over 20s + ], + thresholds: { + http_req_failed: ['rate<0.01'], // http errors should be less than 1% + http_req_duration: ['p(90)<20'], // 90% of requests should be below 150ms + }, +}; + +export default function () { + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); + //sleep(1); +} \ No newline at end of file diff --git a/testdata/k6/external-api-fast.js b/testdata/k6/external-api-fast.js new file mode 100644 index 000000000..55dfed4af --- /dev/null +++ b/testdata/k6/external-api-fast.js @@ -0,0 +1,23 @@ +import http from 'k6/http'; +import { sleep } from 'k6'; + +const ioLatencyMilliseconds = 40; +const workIterations = 5000; +const outputIterations = 10; + +export const options = { + stages: [ + { duration: '20s', target: 50, }, // ramp up to concurrency 10 over 20s + { duration: '20s', target: 200 }, // ramp up to concurrency 25 over 20s + { duration: '20s', target: 0 }, // ramp down to 0 over 20s + ], + thresholds: { + http_req_failed: ['rate<0.01'], // http errors should be less than 1% + http_req_duration: ['p(90)<150'], // 90% of requests should be below 150ms + }, +}; + +export default function () { + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); + //sleep(1); +} \ No newline at end of file diff --git a/testdata/k6/external-api-medium.js b/testdata/k6/external-api-medium.js new file mode 100644 index 000000000..4fe96cea8 --- /dev/null +++ b/testdata/k6/external-api-medium.js @@ -0,0 +1,23 @@ +import http from 'k6/http'; +import { sleep } from 'k6'; + +const ioLatencyMilliseconds = 150; +const workIterations = 5000; +const outputIterations = 10; + +export const options = { + stages: [ + { duration: '20s', target: 100, }, // ramp up to concurrency 10 over 20s + { duration: '20s', target: 400 }, // ramp up to concurrency 25 over 20s + { duration: '20s', target: 0 }, // ramp down to 0 over 20s + ], + thresholds: { + http_req_failed: ['rate<0.01'], // http errors should be less than 1% + http_req_duration: ['p(90)<200'], // 90% of requests should be below 150ms + }, +}; + +export default function () { + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); + //sleep(1); +} \ No newline at end of file diff --git a/testdata/k6/external-api-slow.js b/testdata/k6/external-api-slow.js new file mode 100644 index 000000000..e3fb019cb --- /dev/null +++ b/testdata/k6/external-api-slow.js @@ -0,0 +1,23 @@ +import http from 'k6/http'; +import { sleep } from 'k6'; + +const ioLatencyMilliseconds = 1000; +const workIterations = 5000; +const outputIterations = 10; + +export const options = { + stages: [ + { duration: '20s', target: 100, }, // ramp up to concurrency 10 over 20s + { duration: '20s', target: 800 }, // ramp up to concurrency 25 over 20s + { duration: '20s', target: 0 }, // ramp down to 0 over 20s + ], + thresholds: { + http_req_failed: ['rate<0.01'], // http errors should be less than 1% + http_req_duration: ['p(90)<1100'], // 90% of requests should be below 150ms + }, +}; + +export default function () { + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); + //sleep(1); +} \ No newline at end of file diff --git a/testdata/k6/hanging-server.js b/testdata/k6/hanging-server.js new file mode 100644 index 000000000..604cebadb --- /dev/null +++ b/testdata/k6/hanging-server.js @@ -0,0 +1,28 @@ +import http from 'k6/http'; +import { sleep } from 'k6'; + +const ioLatencyMilliseconds = 15000; +const workIterations = 100; +const outputIterations = 1; + +export const options = { + stages: [ + { duration: '20s', target: 50, }, // ramp up to concurrency 10 over 20s + { duration: '20s', target: 300 }, // ramp up to concurrency 25 over 20s + { duration: '20s', target: 0 }, // ramp down to 0 over 20s + ], + thresholds: { + http_req_failed: ['rate<0.01'], // http errors should be less than 1% + http_req_duration: ['p(90)<1100'], // 90% of requests should be below 150ms + }, +}; + +export default function () { + // 1 hanging request + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); + + // 5 regular requests + for (let i = 0; i < 5; i++) { + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep`); + } +} \ No newline at end of file diff --git a/testdata/k6/hello-world.js b/testdata/k6/hello-world.js new file mode 100644 index 000000000..66f705430 --- /dev/null +++ b/testdata/k6/hello-world.js @@ -0,0 +1,23 @@ +import http from 'k6/http'; +import { sleep } from 'k6'; + +const ioLatencyMilliseconds = 0; +const workIterations = 0; +const outputIterations = 1; + +export const options = { + stages: [ + { duration: '5s', target: 100, }, + { duration: '20s', target: 200 }, + { duration: '20s', target: 0 }, + ], + thresholds: { + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(90)<3'], + }, +}; + +export default function () { + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); + //sleep(1); +} \ No newline at end of file diff --git a/testdata/k6/k6.Caddyfile b/testdata/k6/k6.Caddyfile new file mode 100644 index 000000000..3870886ac --- /dev/null +++ b/testdata/k6/k6.Caddyfile @@ -0,0 +1,21 @@ +{ + frankenphp { + max_threads {$MAX_THREADS} + num_threads {$NUM_THREADS} + worker { + file /go/src/app/testdata/{$WORKER_FILE:sleep.php} + watch ./**/*.{php,yaml,yml,twig,env} + num {$WORKER_THREADS} + } + } +} + +:80 { + route { + root /go/src/app/testdata + php { + root /go/src/app/testdata + enable_root_symlink false + } + } +} diff --git a/testdata/k6/load-test.sh b/testdata/k6/load-test.sh new file mode 100644 index 000000000..67be9f9a8 --- /dev/null +++ b/testdata/k6/load-test.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# install the dev.Dockerfile, build the app and run k6 tests + +docker build -t frankenphp-dev -f dev.Dockerfile . + +export "CADDY_HOSTNAME=http://host.docker.internal" + +select filename in ./testdata/k6/*.js; do + read -p "How many worker threads? " workerThreads + read -p "How many num threads? " numThreads + read -p "How many max threads? " maxThreads + + docker run --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \ + -p 8123:80 \ + -v $PWD:/go/src/app \ + --name load-test-container \ + -e "MAX_THREADS=$maxThreads" \ + -e "WORKER_THREADS=$workerThreads" \ + -e "NUM_THREADS=$numThreads" \ + -itd \ + frankenphp-dev \ + sh /go/src/app/testdata/k6/start-server.sh + + sleep 5 + + docker run --entrypoint "" -it -v .:/app -w /app \ + --add-host "host.docker.internal:host-gateway" \ + grafana/k6:latest \ + k6 run -e "CADDY_HOSTNAME=$CADDY_HOSTNAME:8123" "./$filename" + + docker exec load-test-container curl "http://localhost:2019/frankenphp/threads" + + docker stop load-test-container + docker rm load-test-container +done + diff --git a/testdata/k6/load-tests.md b/testdata/k6/load-tests.md new file mode 100644 index 000000000..4afc2a62d --- /dev/null +++ b/testdata/k6/load-tests.md @@ -0,0 +1,13 @@ +## Running Load tests + +To run load tests with k6 you need to have docker installed + +Go the root of this repository and run: + +```sh +bash testdata/k6/load-test.sh +``` + +This will build the `frankenphp-dev` docker image and run it under the name 'load-test-container' +in the background. Additionally, it will download grafana/k6 and you'll be able to choose +the load test you want to run. \ No newline at end of file diff --git a/testdata/k6/start-server.sh b/testdata/k6/start-server.sh new file mode 100644 index 000000000..a6eddd706 --- /dev/null +++ b/testdata/k6/start-server.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# run the load test server with the k6.Caddyfile + +cd /go/src/app/caddy/frankenphp \ +&& go build --buildvcs=false \ +&& cd ../../testdata/k6 \ +&& /go/src/app/caddy/frankenphp/frankenphp run -c k6.Caddyfile \ No newline at end of file diff --git a/testdata/k6/storage.js b/testdata/k6/storage.js new file mode 100644 index 000000000..a4c0f482b --- /dev/null +++ b/testdata/k6/storage.js @@ -0,0 +1,29 @@ +import http from 'k6/http'; +import { sleep } from 'k6'; + +/** + * Storages tend to vary more strongly in their latencies than databases. + */ +export const options = { + stages: [ + { duration: '20s', target: 50, }, // ramp up to concurrency 10 over 20s + { duration: '20s', target: 200 }, // ramp up to concurrency 25 over 20s + { duration: '20s', target: 0 }, // ramp down to 0 over 20s + ], + thresholds: { + http_req_failed: ['rate<0.01'], // http errors should be less than 1% + http_req_duration: ['p(90)<5'], // 90% of requests should be below 150ms + }, +}; + +// simulate different latencies +export default function () { + // a read from an SSD is usually faster than 1ms + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=1work=5000&output=100`); + + // a read from a spinning takes around 5ms + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=5work=5000&output=100`); + + // a read from a network storage like S3 can also have latencies of 50ms or more + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=50work=5000&output=100`); +} \ No newline at end of file diff --git a/testdata/sleep.php b/testdata/sleep.php index 221515d7f..dcbd0cf83 100644 --- a/testdata/sleep.php +++ b/testdata/sleep.php @@ -5,6 +5,7 @@ return function () { $sleep = $_GET['sleep'] ?? 0; $work = $_GET['work'] ?? 0; + $output = $_GET['output'] ?? 1; // simulate work // 50_000 iterations are approximately the weight of a simple Laravel request @@ -17,7 +18,12 @@ // HDDs: 5ms - 10ms // modern databases: usually 1ms - 10ms (for simple queries) // external APIs: can take up to multiple seconds - usleep($sleep * 1000); + if ($sleep > 0) { + usleep($sleep * 1000); + } - echo "slept for $sleep ms and worked for $work iterations"; + // simulate output + for ($i = 0; $i < $output; $i++) { + echo "slept for $sleep ms and worked for $work iterations"; + } }; From 3bd7c76b8d613a519e6fcaa5976c43b5b989d4dc Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 22 Dec 2024 00:18:31 +0100 Subject: [PATCH 103/190] Adds request status message to thread debug status. --- phpthread.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/phpthread.go b/phpthread.go index f85f89451..c29b29476 100644 --- a/phpthread.go +++ b/phpthread.go @@ -7,6 +7,7 @@ import ( "net/http" "runtime" "sync" + "time" "unsafe" "go.uber.org/zap" @@ -108,12 +109,15 @@ func (thread *phpThread) getActiveRequest() *http.Request { // small status message for debugging func (thread *phpThread) debugStatus() string { - waitingSinceMessage := "" - waitTime := thread.state.waitTime() - if waitTime > 0 { - waitingSinceMessage = fmt.Sprintf(" waiting for %dms", waitTime) + requestStatusMessage := "" + if waitTime := thread.state.waitTime(); waitTime > 0 { + requestStatusMessage = fmt.Sprintf(", waiting for %dms", waitTime) + } else if r := thread.getActiveRequest(); r != nil { + fc := r.Context().Value(contextKey).(*FrankenPHPContext) + sinceMs := time.Since(fc.startedAt).Milliseconds() + requestStatusMessage = fmt.Sprintf(", handling %s for %dms ", r.URL.Path, sinceMs) } - return fmt.Sprintf("Thread %d (%s%s) %s", thread.threadIndex, thread.state.name(), waitingSinceMessage, thread.handler.name()) + return fmt.Sprintf("Thread %d (%s%s) %s", thread.threadIndex, thread.state.name(), requestStatusMessage, thread.handler.name()) } // Pin a string that is not null-terminated From 45cd915ccd3984804dab2b1f700a450bdae00a32 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 22 Dec 2024 00:19:42 +0100 Subject: [PATCH 104/190] Adjusts performance tests. --- frankenphp.go | 4 +-- scaling.go | 19 ++++-------- testdata/k6/api.js | 29 ++++++++++++++++++ testdata/k6/computation-heavy-with-io.js | 23 -------------- testdata/k6/computation-heavy.js | 23 -------------- testdata/k6/computation.js | 25 +++++++++------- testdata/k6/database.js | 24 +++++++++------ testdata/k6/db-request-fast.js | 23 -------------- testdata/k6/db-request-medium.js | 23 -------------- testdata/k6/db-request-slow.js | 23 -------------- testdata/k6/external-api-fast.js | 23 -------------- testdata/k6/external-api-medium.js | 23 -------------- testdata/k6/external-api-slow.js | 23 -------------- testdata/k6/flamegraph.sh | 16 ++++++++++ testdata/k6/hanging-requests.js | 27 +++++++++++++++++ testdata/k6/hanging-server.js | 28 ----------------- testdata/k6/hello-world.js | 16 ++++------ testdata/k6/k6.Caddyfile | 1 - testdata/k6/load-test.sh | 10 ++++--- testdata/k6/load-tests.md | 14 ++++++--- testdata/k6/start-server.sh | 3 +- testdata/k6/storage.js | 29 ------------------ testdata/k6/timeouts.js | 31 +++++++++++++++++++ testdata/sleep.php | 38 ++++++++++++------------ 24 files changed, 181 insertions(+), 317 deletions(-) create mode 100644 testdata/k6/api.js delete mode 100644 testdata/k6/computation-heavy-with-io.js delete mode 100644 testdata/k6/computation-heavy.js delete mode 100644 testdata/k6/db-request-fast.js delete mode 100644 testdata/k6/db-request-medium.js delete mode 100644 testdata/k6/db-request-slow.js delete mode 100644 testdata/k6/external-api-fast.js delete mode 100644 testdata/k6/external-api-medium.js delete mode 100644 testdata/k6/external-api-slow.js create mode 100644 testdata/k6/flamegraph.sh create mode 100644 testdata/k6/hanging-requests.js delete mode 100644 testdata/k6/hanging-server.js delete mode 100644 testdata/k6/storage.js create mode 100644 testdata/k6/timeouts.js diff --git a/frankenphp.go b/frankenphp.go index 54894b7ef..e240835b5 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -139,7 +139,8 @@ func clientHasClosed(r *http.Request) bool { // NewRequestWithContext creates a new FrankenPHP request context. func NewRequestWithContext(r *http.Request, opts ...RequestOption) (*http.Request, error) { fc := &FrankenPHPContext{ - done: make(chan interface{}), + done: make(chan interface{}), + startedAt: time.Now(), } for _, o := range opts { if err := o(fc); err != nil { @@ -469,7 +470,6 @@ func ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) error } fc.responseWriter = responseWriter - fc.startedAt = time.Now() // Detect if a worker is available to handle this request if worker, ok := workers[fc.scriptFilename]; ok { diff --git a/scaling.go b/scaling.go index 4cb3d61d2..c68062a87 100644 --- a/scaling.go +++ b/scaling.go @@ -17,7 +17,7 @@ const ( // only allow scaling threads if requests were stalled for longer than this time allowedStallTime = 10 * time.Millisecond // the amount of time to check for CPU usage before scaling - cpuProbeTime = 50 * time.Millisecond + cpuProbeTime = 40 * time.Millisecond // if PHP threads are using more than this ratio of the CPU, do not scale maxCpuUsageForScaling = 0.8 // check if threads should be stopped every x seconds @@ -148,11 +148,7 @@ func drainAutoScaling() { } // Add worker PHP threads automatically -func autoscaleWorkerThreads(worker *worker, timeSpentStalling time.Duration) { - // first check if time spent waiting for a thread was above the allowed threshold - if timeSpentStalling < allowedStallTime || !blockAutoScaling.CompareAndSwap(false, true) { - return - } +func autoscaleWorkerThreads(worker *worker) { scalingMu.Lock() defer scalingMu.Unlock() defer blockAutoScaling.Store(false) @@ -166,7 +162,7 @@ func autoscaleWorkerThreads(worker *worker, timeSpentStalling time.Duration) { thread, err := addWorkerThread(worker) if err != nil { - logger.Debug("could not add worker thread", zap.String("worker", worker.fileName), zap.Error(err)) + logger.Info("could not increase the amount of threads handling requests", zap.String("worker", worker.fileName), zap.Error(err)) return } @@ -174,14 +170,9 @@ func autoscaleWorkerThreads(worker *worker, timeSpentStalling time.Duration) { } // Add regular PHP threads automatically -func autoscaleRegularThreads(timeSpentStalling time.Duration) { - // first check if time spent waiting for a thread was above the allowed threshold - if timeSpentStalling < allowedStallTime || !blockAutoScaling.CompareAndSwap(false, true) { - return - } +func autoscaleRegularThreads() { scalingMu.Lock() defer scalingMu.Unlock() - defer blockAutoScaling.Store(false) if !probeCPUs(cpuProbeTime) { logger.Debug("cpu is busy, not autoscaling") @@ -190,7 +181,7 @@ func autoscaleRegularThreads(timeSpentStalling time.Duration) { thread, err := addRegularThread() if err != nil { - logger.Debug("could not add regular thread", zap.Error(err)) + logger.Info("could not increase the amount of threads handling requests", zap.Error(err)) return } diff --git a/testdata/k6/api.js b/testdata/k6/api.js new file mode 100644 index 000000000..a979104c3 --- /dev/null +++ b/testdata/k6/api.js @@ -0,0 +1,29 @@ +import http from 'k6/http'; + +/** + * Many applications communicate with external APIs or microservices. + * Latencies tend to be much higher than with databases in these cases. + * We'll consider 10ms-150ms + */ +export const options = { + stages: [ + { duration: '20s', target: 150, }, + { duration: '20s', target: 400 }, + { duration: '10s', target: 0 }, + ], + thresholds: { + http_req_failed: ['rate<0.01'], + }, +}; + +// simulate different latencies +export default function () { + // 10-150ms latency + const latency = Math.floor(Math.random() * 140) + 10; + // 0-30000 work units + const work = Math.floor(Math.random() * 30000); + // 0-40 output units + const output = Math.floor(Math.random() * 40); + + http.get(http.url`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${latency}&work=${work}&output=${output}`); +} \ No newline at end of file diff --git a/testdata/k6/computation-heavy-with-io.js b/testdata/k6/computation-heavy-with-io.js deleted file mode 100644 index 00a22c681..000000000 --- a/testdata/k6/computation-heavy-with-io.js +++ /dev/null @@ -1,23 +0,0 @@ -import http from 'k6/http'; -import { sleep } from 'k6'; - -const ioLatencyMilliseconds = 5; -const workIterations = 500000; -const outputIterations = 50; - -export const options = { - stages: [ - { duration: '20s', target: 10, }, - { duration: '20s', target: 50 }, - { duration: '20s', target: 0 }, - ], - thresholds: { - http_req_failed: ['rate<0.01'], - http_req_duration: ['p(90)<150'], - }, -}; - -export default function () { - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); - //sleep(1); -} \ No newline at end of file diff --git a/testdata/k6/computation-heavy.js b/testdata/k6/computation-heavy.js deleted file mode 100644 index 53e8bb110..000000000 --- a/testdata/k6/computation-heavy.js +++ /dev/null @@ -1,23 +0,0 @@ -import http from 'k6/http'; -import {sleep} from 'k6'; - -const ioLatencyMilliseconds = 0; -const workIterations = 500000; -const outputIterations = 150; - -export const options = { - stages: [ - {duration: '20s', target: 25,}, - {duration: '20s', target: 50}, - {duration: '20s', target: 0}, - ], - thresholds: { - http_req_failed: ['rate<0.01'], - http_req_duration: ['p(90)<150'], - }, -}; - -export default function () { - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); - //sleep(1); -} \ No newline at end of file diff --git a/testdata/k6/computation.js b/testdata/k6/computation.js index 2a931021f..360043aa1 100644 --- a/testdata/k6/computation.js +++ b/testdata/k6/computation.js @@ -1,23 +1,26 @@ import http from 'k6/http'; -import { sleep } from 'k6'; - -const ioLatencyMilliseconds = 0; -const workIterations = 50000; -const outputIterations = 50; +/** + * Simulate an application that does very little IO, but a lot of computation + */ export const options = { stages: [ - { duration: '20s', target: 40, }, - { duration: '20s', target: 80 }, - { duration: '20s', target: 0 }, + { duration: '20s', target: 80, }, + { duration: '20s', target: 150 }, + { duration: '5s', target: 0 }, ], thresholds: { http_req_failed: ['rate<0.01'], - http_req_duration: ['p(90)<150'], }, }; export default function () { - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); - //sleep(1); + // do 0-1,000,000 work units + const work = Math.floor(Math.random() * 1_000_000); + // output 0-500 units + const output = Math.floor(Math.random() * 500); + // simulate 0-2ms latency + const latency = Math.floor(Math.random() * 3); + + http.get(http.url`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${latency}&work=${work}&output=${output}`); } \ No newline at end of file diff --git a/testdata/k6/database.js b/testdata/k6/database.js index 19a51ea04..214eb8216 100644 --- a/testdata/k6/database.js +++ b/testdata/k6/database.js @@ -1,24 +1,30 @@ import http from 'k6/http'; -import { sleep } from 'k6'; /** * Modern databases tend to have latencies in the single-digit milliseconds. + * We'll simulate 1-10ms latencies and 1-2 queries per request. */ export const options = { stages: [ - { duration: '20s', target: 50, }, // ramp up to concurrency 10 over 20s - { duration: '20s', target: 200 }, // ramp up to concurrency 25 over 20s - { duration: '20s', target: 0 }, // ramp down to 0 over 20s + {duration: '20s', target: 100,}, + {duration: '20s', target: 200}, + {duration: '10s', target: 0}, ], thresholds: { - http_req_failed: ['rate<0.01'], // http errors should be less than 1% - http_req_duration: ['p(90)<5'], // 90% of requests should be below 150ms + http_req_failed: ['rate<0.01'], }, }; // simulate different latencies export default function () { - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=1work=5000&output=10`); - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=5work=5000&output=10`); - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=10work=5000&output=10`); + // 1-10ms latency + const latency = Math.floor(Math.random() * 9) + 1; + // 1-2 queries per request + const iterations = Math.floor(Math.random() * 2) + 1; + // 0-30000 work units + const work = Math.floor(Math.random() * 30000); + // 0-40 output units + const output = Math.floor(Math.random() * 40); + + http.get(http.url`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${latency}&work=${work}&output=${output}&iterations=${iterations}`); } \ No newline at end of file diff --git a/testdata/k6/db-request-fast.js b/testdata/k6/db-request-fast.js deleted file mode 100644 index dcef9d4e7..000000000 --- a/testdata/k6/db-request-fast.js +++ /dev/null @@ -1,23 +0,0 @@ -import http from 'k6/http'; -import { sleep } from 'k6'; - -const ioLatencyMilliseconds = 1; -const workIterations = 5000; -const outputIterations = 10; - -export const options = { - stages: [ - { duration: '20s', target: 50, }, // ramp up to concurrency 10 over 20s - { duration: '20s', target: 200 }, // ramp up to concurrency 25 over 20s - { duration: '20s', target: 0 }, // ramp down to 0 over 20s - ], - thresholds: { - http_req_failed: ['rate<0.01'], // http errors should be less than 1% - http_req_duration: ['p(90)<5'], // 90% of requests should be below 150ms - }, -}; - -export default function () { - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); - //sleep(1); -} \ No newline at end of file diff --git a/testdata/k6/db-request-medium.js b/testdata/k6/db-request-medium.js deleted file mode 100644 index 0f103271c..000000000 --- a/testdata/k6/db-request-medium.js +++ /dev/null @@ -1,23 +0,0 @@ -import http from 'k6/http'; -import { sleep } from 'k6'; - -const ioLatencyMilliseconds = 5; -const workIterations = 5000; -const outputIterations = 10; - -export const options = { - stages: [ - { duration: '20s', target: 50, }, // ramp up to concurrency 10 over 20s - { duration: '20s', target: 200 }, // ramp up to concurrency 25 over 20s - { duration: '20s', target: 0 }, // ramp down to 0 over 20s - ], - thresholds: { - http_req_failed: ['rate<0.01'], // http errors should be less than 1% - http_req_duration: ['p(90)<10'], // 90% of requests should be below 150ms - }, -}; - -export default function () { - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); - //sleep(1); -} \ No newline at end of file diff --git a/testdata/k6/db-request-slow.js b/testdata/k6/db-request-slow.js deleted file mode 100644 index 70d9ab28e..000000000 --- a/testdata/k6/db-request-slow.js +++ /dev/null @@ -1,23 +0,0 @@ -import http from 'k6/http'; -import { sleep } from 'k6'; - -const ioLatencyMilliseconds = 10; -const workIterations = 5000; -const outputIterations = 10; - -export const options = { - stages: [ - { duration: '20s', target: 50, }, // ramp up to concurrency 10 over 20s - { duration: '20s', target: 200 }, // ramp up to concurrency 25 over 20s - { duration: '20s', target: 0 }, // ramp down to 0 over 20s - ], - thresholds: { - http_req_failed: ['rate<0.01'], // http errors should be less than 1% - http_req_duration: ['p(90)<20'], // 90% of requests should be below 150ms - }, -}; - -export default function () { - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); - //sleep(1); -} \ No newline at end of file diff --git a/testdata/k6/external-api-fast.js b/testdata/k6/external-api-fast.js deleted file mode 100644 index 55dfed4af..000000000 --- a/testdata/k6/external-api-fast.js +++ /dev/null @@ -1,23 +0,0 @@ -import http from 'k6/http'; -import { sleep } from 'k6'; - -const ioLatencyMilliseconds = 40; -const workIterations = 5000; -const outputIterations = 10; - -export const options = { - stages: [ - { duration: '20s', target: 50, }, // ramp up to concurrency 10 over 20s - { duration: '20s', target: 200 }, // ramp up to concurrency 25 over 20s - { duration: '20s', target: 0 }, // ramp down to 0 over 20s - ], - thresholds: { - http_req_failed: ['rate<0.01'], // http errors should be less than 1% - http_req_duration: ['p(90)<150'], // 90% of requests should be below 150ms - }, -}; - -export default function () { - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); - //sleep(1); -} \ No newline at end of file diff --git a/testdata/k6/external-api-medium.js b/testdata/k6/external-api-medium.js deleted file mode 100644 index 4fe96cea8..000000000 --- a/testdata/k6/external-api-medium.js +++ /dev/null @@ -1,23 +0,0 @@ -import http from 'k6/http'; -import { sleep } from 'k6'; - -const ioLatencyMilliseconds = 150; -const workIterations = 5000; -const outputIterations = 10; - -export const options = { - stages: [ - { duration: '20s', target: 100, }, // ramp up to concurrency 10 over 20s - { duration: '20s', target: 400 }, // ramp up to concurrency 25 over 20s - { duration: '20s', target: 0 }, // ramp down to 0 over 20s - ], - thresholds: { - http_req_failed: ['rate<0.01'], // http errors should be less than 1% - http_req_duration: ['p(90)<200'], // 90% of requests should be below 150ms - }, -}; - -export default function () { - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); - //sleep(1); -} \ No newline at end of file diff --git a/testdata/k6/external-api-slow.js b/testdata/k6/external-api-slow.js deleted file mode 100644 index e3fb019cb..000000000 --- a/testdata/k6/external-api-slow.js +++ /dev/null @@ -1,23 +0,0 @@ -import http from 'k6/http'; -import { sleep } from 'k6'; - -const ioLatencyMilliseconds = 1000; -const workIterations = 5000; -const outputIterations = 10; - -export const options = { - stages: [ - { duration: '20s', target: 100, }, // ramp up to concurrency 10 over 20s - { duration: '20s', target: 800 }, // ramp up to concurrency 25 over 20s - { duration: '20s', target: 0 }, // ramp down to 0 over 20s - ], - thresholds: { - http_req_failed: ['rate<0.01'], // http errors should be less than 1% - http_req_duration: ['p(90)<1100'], // 90% of requests should be below 150ms - }, -}; - -export default function () { - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); - //sleep(1); -} \ No newline at end of file diff --git a/testdata/k6/flamegraph.sh b/testdata/k6/flamegraph.sh new file mode 100644 index 000000000..72f7d54a5 --- /dev/null +++ b/testdata/k6/flamegraph.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# install brendangregg's FlameGraph +if [ ! -d "/usr/local/src/flamegraph" ]; then + mkdir /usr/local/src/flamegraph && \ + cd /usr/local/src/flamegraph && \ + git clone https://github.com/brendangregg/FlameGraph.git +fi + +# let the test warm up +sleep 10 + +# run a 30 second profile on the Caddy admin port +cd /usr/local/src/flamegraph/FlameGraph && \ +go tool pprof -raw -output=cpu.txt 'http://localhost:2019/debug/pprof/profile?seconds=30' && \ +./stackcollapse-go.pl cpu.txt | ./flamegraph.pl > /go/src/app/testdata/k6/flamegraph.svg \ No newline at end of file diff --git a/testdata/k6/hanging-requests.js b/testdata/k6/hanging-requests.js new file mode 100644 index 000000000..03bdec7ce --- /dev/null +++ b/testdata/k6/hanging-requests.js @@ -0,0 +1,27 @@ +import http from 'k6/http'; + +/** + * It is not uncommon for external services to hang for a long time. + * Make sure the server is resilient in such cases and doesn't hang as well. + */ +export const options = { + stages: [ + { duration: '20s', target: 100, }, + { duration: '20s', target: 500 }, + { duration: '20s', target: 0 }, + ], + thresholds: { + http_req_failed: ['rate<0.01'], + }, +}; + +export default function () { + // 2% chance for a request that hangs for 15s + if (Math.random() < 0.02) { + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=15000&work=10000&output=100`); + return; + } + + // a regular request + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=5&work=10000&output=100`); +} \ No newline at end of file diff --git a/testdata/k6/hanging-server.js b/testdata/k6/hanging-server.js deleted file mode 100644 index 604cebadb..000000000 --- a/testdata/k6/hanging-server.js +++ /dev/null @@ -1,28 +0,0 @@ -import http from 'k6/http'; -import { sleep } from 'k6'; - -const ioLatencyMilliseconds = 15000; -const workIterations = 100; -const outputIterations = 1; - -export const options = { - stages: [ - { duration: '20s', target: 50, }, // ramp up to concurrency 10 over 20s - { duration: '20s', target: 300 }, // ramp up to concurrency 25 over 20s - { duration: '20s', target: 0 }, // ramp down to 0 over 20s - ], - thresholds: { - http_req_failed: ['rate<0.01'], // http errors should be less than 1% - http_req_duration: ['p(90)<1100'], // 90% of requests should be below 150ms - }, -}; - -export default function () { - // 1 hanging request - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); - - // 5 regular requests - for (let i = 0; i < 5; i++) { - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep`); - } -} \ No newline at end of file diff --git a/testdata/k6/hello-world.js b/testdata/k6/hello-world.js index 66f705430..3a45beff9 100644 --- a/testdata/k6/hello-world.js +++ b/testdata/k6/hello-world.js @@ -1,23 +1,19 @@ import http from 'k6/http'; -import { sleep } from 'k6'; - -const ioLatencyMilliseconds = 0; -const workIterations = 0; -const outputIterations = 1; +/** + * 'Hello world' tests the raw server performance. + */ export const options = { stages: [ { duration: '5s', target: 100, }, - { duration: '20s', target: 200 }, - { duration: '20s', target: 0 }, + { duration: '20s', target: 400 }, + { duration: '5s', target: 0 }, ], thresholds: { http_req_failed: ['rate<0.01'], - http_req_duration: ['p(90)<3'], }, }; export default function () { - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${ioLatencyMilliseconds}&work=${workIterations}&output=${outputIterations}`); - //sleep(1); + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php`); } \ No newline at end of file diff --git a/testdata/k6/k6.Caddyfile b/testdata/k6/k6.Caddyfile index 3870886ac..44d5a54b9 100644 --- a/testdata/k6/k6.Caddyfile +++ b/testdata/k6/k6.Caddyfile @@ -4,7 +4,6 @@ num_threads {$NUM_THREADS} worker { file /go/src/app/testdata/{$WORKER_FILE:sleep.php} - watch ./**/*.{php,yaml,yml,twig,env} num {$WORKER_THREADS} } } diff --git a/testdata/k6/load-test.sh b/testdata/k6/load-test.sh index 67be9f9a8..152a49ebb 100644 --- a/testdata/k6/load-test.sh +++ b/testdata/k6/load-test.sh @@ -8,11 +8,11 @@ export "CADDY_HOSTNAME=http://host.docker.internal" select filename in ./testdata/k6/*.js; do read -p "How many worker threads? " workerThreads - read -p "How many num threads? " numThreads + read -p "How many num threads? (must be > worker threads) " numThreads read -p "How many max threads? " maxThreads docker run --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \ - -p 8123:80 \ + -p 8125:80 \ -v $PWD:/go/src/app \ --name load-test-container \ -e "MAX_THREADS=$maxThreads" \ @@ -22,12 +22,14 @@ select filename in ./testdata/k6/*.js; do frankenphp-dev \ sh /go/src/app/testdata/k6/start-server.sh - sleep 5 + docker exec -d load-test-container sh /go/src/app/testdata/k6/flamegraph.sh + + sleep 10 docker run --entrypoint "" -it -v .:/app -w /app \ --add-host "host.docker.internal:host-gateway" \ grafana/k6:latest \ - k6 run -e "CADDY_HOSTNAME=$CADDY_HOSTNAME:8123" "./$filename" + k6 run -e "CADDY_HOSTNAME=$CADDY_HOSTNAME:8125" "./$filename" docker exec load-test-container curl "http://localhost:2019/frankenphp/threads" diff --git a/testdata/k6/load-tests.md b/testdata/k6/load-tests.md index 4afc2a62d..d7acd7e64 100644 --- a/testdata/k6/load-tests.md +++ b/testdata/k6/load-tests.md @@ -1,7 +1,6 @@ ## Running Load tests -To run load tests with k6 you need to have docker installed - +To run load tests with k6 you need to have docker and bash installed. Go the root of this repository and run: ```sh @@ -9,5 +8,12 @@ bash testdata/k6/load-test.sh ``` This will build the `frankenphp-dev` docker image and run it under the name 'load-test-container' -in the background. Additionally, it will download grafana/k6 and you'll be able to choose -the load test you want to run. \ No newline at end of file +in the background. Additionally, it will run the grafana/k6 container and you'll be able to choose +the load test you want to run. A flamegraph.svg will be created in the `testdata/k6` directory. + +If the load test has stopped prematurely, you might have to remove it manually: + +```sh +docker stop load-test-container +docker rm load-test-container +``` diff --git a/testdata/k6/start-server.sh b/testdata/k6/start-server.sh index a6eddd706..40eb4be67 100644 --- a/testdata/k6/start-server.sh +++ b/testdata/k6/start-server.sh @@ -1,7 +1,6 @@ #!/bin/bash -# run the load test server with the k6.Caddyfile - +# build and run FrankenPHP with the k6.Caddyfile cd /go/src/app/caddy/frankenphp \ && go build --buildvcs=false \ && cd ../../testdata/k6 \ diff --git a/testdata/k6/storage.js b/testdata/k6/storage.js deleted file mode 100644 index a4c0f482b..000000000 --- a/testdata/k6/storage.js +++ /dev/null @@ -1,29 +0,0 @@ -import http from 'k6/http'; -import { sleep } from 'k6'; - -/** - * Storages tend to vary more strongly in their latencies than databases. - */ -export const options = { - stages: [ - { duration: '20s', target: 50, }, // ramp up to concurrency 10 over 20s - { duration: '20s', target: 200 }, // ramp up to concurrency 25 over 20s - { duration: '20s', target: 0 }, // ramp down to 0 over 20s - ], - thresholds: { - http_req_failed: ['rate<0.01'], // http errors should be less than 1% - http_req_duration: ['p(90)<5'], // 90% of requests should be below 150ms - }, -}; - -// simulate different latencies -export default function () { - // a read from an SSD is usually faster than 1ms - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=1work=5000&output=100`); - - // a read from a spinning takes around 5ms - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=5work=5000&output=100`); - - // a read from a network storage like S3 can also have latencies of 50ms or more - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=50work=5000&output=100`); -} \ No newline at end of file diff --git a/testdata/k6/timeouts.js b/testdata/k6/timeouts.js new file mode 100644 index 000000000..172212613 --- /dev/null +++ b/testdata/k6/timeouts.js @@ -0,0 +1,31 @@ +import http from 'k6/http'; + +/** + * Databases or external resources can sometimes become unavailable for short periods of time. + * Make sure the server can recover quickly from periods of unavailability. + * This simulation swaps between a hanging and a working server every 10 seconds. + */ +export const options = { + stages: [ + { duration: '20s', target: 100, }, + { duration: '20s', target: 500 }, + { duration: '20s', target: 0 }, + ], + thresholds: { + http_req_failed: ['rate<0.01'], + }, +}; + +export default function () { + const tenSecondInterval = Math.floor(new Date().getSeconds() / 10); + const shouldHang = tenSecondInterval % 2 === 0; + + // every 10 seconds requests lead to a max_execution-timeout + if (shouldHang) { + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=50000`); + return; + } + + // every other 10 seconds the resource is back + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=5&work=30000&output=100`); +} \ No newline at end of file diff --git a/testdata/sleep.php b/testdata/sleep.php index dcbd0cf83..174a28294 100644 --- a/testdata/sleep.php +++ b/testdata/sleep.php @@ -3,27 +3,27 @@ require_once __DIR__ . '/_executor.php'; return function () { - $sleep = $_GET['sleep'] ?? 0; - $work = $_GET['work'] ?? 0; - $output = $_GET['output'] ?? 1; + $sleep = (int)($_GET['sleep'] ?? 0); + $work = (int)($_GET['work'] ?? 0); + $output = (int)($_GET['output'] ?? 1); + $iterations = (int)($_GET['iterations'] ?? 1); - // simulate work - // 50_000 iterations are approximately the weight of a simple Laravel request - for ($i = 0; $i < $work; $i++) { - $a = +$i; - } + for ($i = 0; $i < $iterations; $i++) { + // simulate work + // with 30_000 iterations we're in the range of a simple Laravel request + // (without JIT and with debug symbols enabled) + for ($j = 0; $j < $work; $j++) { + $a = +$j; + } - // simulate IO, some examples: - // SSDs: 0.1ms - 1ms - // HDDs: 5ms - 10ms - // modern databases: usually 1ms - 10ms (for simple queries) - // external APIs: can take up to multiple seconds - if ($sleep > 0) { - usleep($sleep * 1000); - } + // simulate IO, sleep x milliseconds + if ($sleep > 0) { + usleep($sleep * 1000); + } - // simulate output - for ($i = 0; $i < $output; $i++) { - echo "slept for $sleep ms and worked for $work iterations"; + // simulate output + for ($k = 0; $k < $output; $k++) { + echo "slept for $sleep ms and worked for $work iterations"; + } } }; From af404706c7aac64c21ac2cc60d7a2a8327439b12 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 22 Dec 2024 00:20:03 +0100 Subject: [PATCH 105/190] Adds an exponential backoff on request overflow. --- thread-regular.go | 39 ++++++++++++++++++++++++++++++++------- worker.go | 40 +++++++++++++++++++++++++++++++--------- 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/thread-regular.go b/thread-regular.go index f21b9bec5..929abc6ac 100644 --- a/thread-regular.go +++ b/thread-regular.go @@ -115,13 +115,38 @@ func handleRequestWithRegularPHPThreads(r *http.Request, fc *FrankenPHPContext) } regularThreadMu.RUnlock() - // if no thread was available, fan out to all threads - stalledSince := time.Now() - regularRequestChan <- r - stallTime := time.Since(stalledSince) - <-fc.done - metrics.StopRequest() - autoscaleRegularThreads(stallTime) + // if no thread was available, fan the request out to all threads + // if a request has waited for too long, trigger autoscaling + + timeout := allowedStallTime + timer := time.NewTimer(timeout) + + for { + select { + case regularRequestChan <- r: + // a thread was available to handle the request after all + timer.Stop() + <-fc.done + metrics.StopRequest() + return + case <-timer.C: + // reaching here means we might not have spawned enough threads + if blockAutoScaling.CompareAndSwap(false, true) { + go func() { + autoscaleRegularThreads() + blockAutoScaling.Store(false) + }() + } + + // TODO: reject a request that has been waiting for too long (504) + // TODO: limit the amount of stalled requests (maybe) (503) + + // re-trigger autoscaling with an exponential backoff + timeout *= 2 + timer.Reset(timeout) + } + } + } func attachRegularThread(thread *phpThread) { diff --git a/worker.go b/worker.go index 3300fc02a..be679f241 100644 --- a/worker.go +++ b/worker.go @@ -184,13 +184,35 @@ func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { worker.threadMutex.RUnlock() // if no thread was available, fan the request out to all threads - stalledAt := time.Now() - worker.requestChan <- r - stallTime := time.Since(stalledAt) - <-fc.done - metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) - - // reaching here means we might not have spawned enough threads - // forward the amount of time the request spent being stalled - autoscaleWorkerThreads(worker, stallTime) + // if a request has waited for too long, trigger autoscaling + + timeout := allowedStallTime + timer := time.NewTimer(timeout) + + for { + select { + case worker.requestChan <- r: + // a worker was available to handle the request after all + timer.Stop() + <-fc.done + metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) + return + case <-timer.C: + // reaching here means we might not have spawned enough threads + if blockAutoScaling.CompareAndSwap(false, true) { + go func() { + autoscaleWorkerThreads(worker) + blockAutoScaling.Store(false) + }() + } + + // TODO: reject a request that has been waiting for too long (504) + // TODO: limit the amount of stalled requests (maybe) (503) + + // re-trigger autoscaling with an exponential backoff + timeout *= 2 + timer.Reset(timeout) + } + } + } From c7acb255583972bb5674312600a52e87fa716c8c Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 22 Dec 2024 00:36:51 +0100 Subject: [PATCH 106/190] changes dir. --- testdata/{k6 => performance}/api.js | 0 testdata/{k6 => performance}/computation.js | 0 testdata/{k6 => performance}/database.js | 6 +++--- testdata/{k6 => performance}/flamegraph.sh | 2 +- testdata/{k6 => performance}/hanging-requests.js | 0 testdata/{k6 => performance}/hello-world.js | 0 testdata/{k6 => performance}/k6.Caddyfile | 0 testdata/{k6/load-tests.md => performance/perf-test.md} | 6 +++--- testdata/{k6/load-test.sh => performance/perf-test.sh} | 9 +++++---- testdata/{k6 => performance}/start-server.sh | 2 +- testdata/{k6 => performance}/timeouts.js | 0 11 files changed, 13 insertions(+), 12 deletions(-) rename testdata/{k6 => performance}/api.js (100%) rename testdata/{k6 => performance}/computation.js (100%) rename testdata/{k6 => performance}/database.js (86%) rename testdata/{k6 => performance}/flamegraph.sh (94%) rename testdata/{k6 => performance}/hanging-requests.js (100%) rename testdata/{k6 => performance}/hello-world.js (100%) rename testdata/{k6 => performance}/k6.Caddyfile (100%) rename testdata/{k6/load-tests.md => performance/perf-test.md} (83%) rename testdata/{k6/load-test.sh => performance/perf-test.sh} (79%) rename testdata/{k6 => performance}/start-server.sh (84%) rename testdata/{k6 => performance}/timeouts.js (100%) diff --git a/testdata/k6/api.js b/testdata/performance/api.js similarity index 100% rename from testdata/k6/api.js rename to testdata/performance/api.js diff --git a/testdata/k6/computation.js b/testdata/performance/computation.js similarity index 100% rename from testdata/k6/computation.js rename to testdata/performance/computation.js diff --git a/testdata/k6/database.js b/testdata/performance/database.js similarity index 86% rename from testdata/k6/database.js rename to testdata/performance/database.js index 214eb8216..898cdf027 100644 --- a/testdata/k6/database.js +++ b/testdata/performance/database.js @@ -19,10 +19,10 @@ export const options = { export default function () { // 1-10ms latency const latency = Math.floor(Math.random() * 9) + 1; - // 1-2 queries per request + // 1-2 iterations per request const iterations = Math.floor(Math.random() * 2) + 1; - // 0-30000 work units - const work = Math.floor(Math.random() * 30000); + // 0-30000 work units per iteration + const work = Math.floor(Math.random() *30000); // 0-40 output units const output = Math.floor(Math.random() * 40); diff --git a/testdata/k6/flamegraph.sh b/testdata/performance/flamegraph.sh similarity index 94% rename from testdata/k6/flamegraph.sh rename to testdata/performance/flamegraph.sh index 72f7d54a5..51b361f6b 100644 --- a/testdata/k6/flamegraph.sh +++ b/testdata/performance/flamegraph.sh @@ -13,4 +13,4 @@ sleep 10 # run a 30 second profile on the Caddy admin port cd /usr/local/src/flamegraph/FlameGraph && \ go tool pprof -raw -output=cpu.txt 'http://localhost:2019/debug/pprof/profile?seconds=30' && \ -./stackcollapse-go.pl cpu.txt | ./flamegraph.pl > /go/src/app/testdata/k6/flamegraph.svg \ No newline at end of file +./stackcollapse-go.pl cpu.txt | ./flamegraph.pl > /go/src/app/testdata/performance/flamegraph.svg \ No newline at end of file diff --git a/testdata/k6/hanging-requests.js b/testdata/performance/hanging-requests.js similarity index 100% rename from testdata/k6/hanging-requests.js rename to testdata/performance/hanging-requests.js diff --git a/testdata/k6/hello-world.js b/testdata/performance/hello-world.js similarity index 100% rename from testdata/k6/hello-world.js rename to testdata/performance/hello-world.js diff --git a/testdata/k6/k6.Caddyfile b/testdata/performance/k6.Caddyfile similarity index 100% rename from testdata/k6/k6.Caddyfile rename to testdata/performance/k6.Caddyfile diff --git a/testdata/k6/load-tests.md b/testdata/performance/perf-test.md similarity index 83% rename from testdata/k6/load-tests.md rename to testdata/performance/perf-test.md index d7acd7e64..0d47ed05c 100644 --- a/testdata/k6/load-tests.md +++ b/testdata/performance/perf-test.md @@ -4,14 +4,14 @@ To run load tests with k6 you need to have docker and bash installed. Go the root of this repository and run: ```sh -bash testdata/k6/load-test.sh +bash testdata/performance/perf-test.sh ``` This will build the `frankenphp-dev` docker image and run it under the name 'load-test-container' in the background. Additionally, it will run the grafana/k6 container and you'll be able to choose -the load test you want to run. A flamegraph.svg will be created in the `testdata/k6` directory. +the load test you want to run. A flamegraph.svg will be created in the `testdata/performance` directory. -If the load test has stopped prematurely, you might have to remove it manually: +If the load test has stopped prematurely, you might have to remove the container manually: ```sh docker stop load-test-container diff --git a/testdata/k6/load-test.sh b/testdata/performance/perf-test.sh similarity index 79% rename from testdata/k6/load-test.sh rename to testdata/performance/perf-test.sh index 152a49ebb..89f502203 100644 --- a/testdata/k6/load-test.sh +++ b/testdata/performance/perf-test.sh @@ -6,11 +6,12 @@ docker build -t frankenphp-dev -f dev.Dockerfile . export "CADDY_HOSTNAME=http://host.docker.internal" -select filename in ./testdata/k6/*.js; do +select filename in ./testdata/performance/*.js; do read -p "How many worker threads? " workerThreads - read -p "How many num threads? (must be > worker threads) " numThreads read -p "How many max threads? " maxThreads + numThreads=$((workerThreads+1)) + docker run --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \ -p 8125:80 \ -v $PWD:/go/src/app \ @@ -20,9 +21,9 @@ select filename in ./testdata/k6/*.js; do -e "NUM_THREADS=$numThreads" \ -itd \ frankenphp-dev \ - sh /go/src/app/testdata/k6/start-server.sh + sh /go/src/app/testdata/performance/start-server.sh - docker exec -d load-test-container sh /go/src/app/testdata/k6/flamegraph.sh + docker exec -d load-test-container sh /go/src/app/testdata/performance/flamegraph.sh sleep 10 diff --git a/testdata/k6/start-server.sh b/testdata/performance/start-server.sh similarity index 84% rename from testdata/k6/start-server.sh rename to testdata/performance/start-server.sh index 40eb4be67..23aad17c3 100644 --- a/testdata/k6/start-server.sh +++ b/testdata/performance/start-server.sh @@ -3,5 +3,5 @@ # build and run FrankenPHP with the k6.Caddyfile cd /go/src/app/caddy/frankenphp \ && go build --buildvcs=false \ -&& cd ../../testdata/k6 \ +&& cd ../../testdata/performance \ && /go/src/app/caddy/frankenphp/frankenphp run -c k6.Caddyfile \ No newline at end of file diff --git a/testdata/k6/timeouts.js b/testdata/performance/timeouts.js similarity index 100% rename from testdata/k6/timeouts.js rename to testdata/performance/timeouts.js From 8c22cbf1322ddf43749f023133628e5ed1de2770 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 22 Dec 2024 17:25:07 +0100 Subject: [PATCH 107/190] Linting and formatting. --- testdata/performance/api.js | 24 ++++++------- testdata/performance/computation.js | 23 +++++++------ testdata/performance/database.js | 22 ++++++------ testdata/performance/hanging-requests.js | 19 +++++----- testdata/performance/hello-world.js | 17 ++++----- testdata/performance/perf-test.md | 2 +- testdata/performance/perf-test.sh | 44 ++++++++++++------------ testdata/performance/timeouts.js | 23 +++++++------ thread-regular.go | 11 +++--- worker.go | 5 +-- 10 files changed, 97 insertions(+), 93 deletions(-) mode change 100644 => 100755 testdata/performance/perf-test.sh diff --git a/testdata/performance/api.js b/testdata/performance/api.js index a979104c3..642a3d16f 100644 --- a/testdata/performance/api.js +++ b/testdata/performance/api.js @@ -7,23 +7,23 @@ import http from 'k6/http'; */ export const options = { stages: [ - { duration: '20s', target: 150, }, - { duration: '20s', target: 400 }, - { duration: '10s', target: 0 }, + {duration: '20s', target: 150,}, + {duration: '20s', target: 400}, + {duration: '10s', target: 0} ], thresholds: { - http_req_failed: ['rate<0.01'], + http_req_failed: ['rate<0.01'] }, }; -// simulate different latencies +/*global __ENV*/ export default function () { // 10-150ms latency - const latency = Math.floor(Math.random() * 140) + 10; - // 0-30000 work units - const work = Math.floor(Math.random() * 30000); - // 0-40 output units - const output = Math.floor(Math.random() * 40); + const latency = Math.floor(Math.random() * 141) + 10 + // 1-30000 work units + const work = Math.ceil(Math.random() * 30000) + // 1-40 output units + const output = Math.ceil(Math.random() * 40) - http.get(http.url`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${latency}&work=${work}&output=${output}`); -} \ No newline at end of file + http.get(http.url`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${latency}&work=${work}&output=${output}`) +} diff --git a/testdata/performance/computation.js b/testdata/performance/computation.js index 360043aa1..ba380124e 100644 --- a/testdata/performance/computation.js +++ b/testdata/performance/computation.js @@ -5,22 +5,23 @@ import http from 'k6/http'; */ export const options = { stages: [ - { duration: '20s', target: 80, }, - { duration: '20s', target: 150 }, - { duration: '5s', target: 0 }, + {duration: '20s', target: 80,}, + {duration: '20s', target: 150}, + {duration: '5s', target: 0} ], thresholds: { - http_req_failed: ['rate<0.01'], + http_req_failed: ['rate<0.01'] }, }; +/*global __ENV*/ export default function () { - // do 0-1,000,000 work units - const work = Math.floor(Math.random() * 1_000_000); - // output 0-500 units - const output = Math.floor(Math.random() * 500); + // do 1-1,000,000 work units + const work = Math.ceil(Math.random() * 1_000_000) + // output 1-500 units + const output = Math.ceil(Math.random() * 500) // simulate 0-2ms latency - const latency = Math.floor(Math.random() * 3); + const latency = Math.floor(Math.random() * 3) - http.get(http.url`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${latency}&work=${work}&output=${output}`); -} \ No newline at end of file + http.get(http.url`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${latency}&work=${work}&output=${output}`) +} diff --git a/testdata/performance/database.js b/testdata/performance/database.js index 898cdf027..487283caf 100644 --- a/testdata/performance/database.js +++ b/testdata/performance/database.js @@ -8,23 +8,23 @@ export const options = { stages: [ {duration: '20s', target: 100,}, {duration: '20s', target: 200}, - {duration: '10s', target: 0}, + {duration: '10s', target: 0} ], thresholds: { - http_req_failed: ['rate<0.01'], + http_req_failed: ['rate<0.01'] }, }; -// simulate different latencies +/*global __ENV*/ export default function () { // 1-10ms latency - const latency = Math.floor(Math.random() * 9) + 1; + const latency = Math.floor(Math.random() * 10) + 1 // 1-2 iterations per request - const iterations = Math.floor(Math.random() * 2) + 1; - // 0-30000 work units per iteration - const work = Math.floor(Math.random() *30000); - // 0-40 output units - const output = Math.floor(Math.random() * 40); + const iterations = Math.floor(Math.random() * 2) + 1 + // 1-30000 work units per iteration + const work = Math.ceil(Math.random() * 30000) + // 1-40 output units + const output = Math.ceil(Math.random() * 40) - http.get(http.url`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${latency}&work=${work}&output=${output}&iterations=${iterations}`); -} \ No newline at end of file + http.get(http.url`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${latency}&work=${work}&output=${output}&iterations=${iterations}`) +} diff --git a/testdata/performance/hanging-requests.js b/testdata/performance/hanging-requests.js index 03bdec7ce..899ea16d3 100644 --- a/testdata/performance/hanging-requests.js +++ b/testdata/performance/hanging-requests.js @@ -6,22 +6,23 @@ import http from 'k6/http'; */ export const options = { stages: [ - { duration: '20s', target: 100, }, - { duration: '20s', target: 500 }, - { duration: '20s', target: 0 }, + {duration: '20s', target: 100,}, + {duration: '20s', target: 500}, + {duration: '20s', target: 0} ], thresholds: { - http_req_failed: ['rate<0.01'], + http_req_failed: ['rate<0.01'] }, -}; +} +/*global __ENV*/ export default function () { // 2% chance for a request that hangs for 15s if (Math.random() < 0.02) { - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=15000&work=10000&output=100`); - return; + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=15000&work=10000&output=100`) + return } // a regular request - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=5&work=10000&output=100`); -} \ No newline at end of file + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=5&work=10000&output=100`) +} diff --git a/testdata/performance/hello-world.js b/testdata/performance/hello-world.js index 3a45beff9..38a0815a2 100644 --- a/testdata/performance/hello-world.js +++ b/testdata/performance/hello-world.js @@ -5,15 +5,16 @@ import http from 'k6/http'; */ export const options = { stages: [ - { duration: '5s', target: 100, }, - { duration: '20s', target: 400 }, - { duration: '5s', target: 0 }, + {duration: '5s', target: 100}, + {duration: '20s', target: 400}, + {duration: '5s', target: 0} ], thresholds: { - http_req_failed: ['rate<0.01'], - }, -}; + http_req_failed: ['rate<0.01'] + } +} +/*global __ENV*/ export default function () { - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php`); -} \ No newline at end of file + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php`) +} diff --git a/testdata/performance/perf-test.md b/testdata/performance/perf-test.md index 0d47ed05c..19e269e0c 100644 --- a/testdata/performance/perf-test.md +++ b/testdata/performance/perf-test.md @@ -1,4 +1,4 @@ -## Running Load tests +# Running Load tests To run load tests with k6 you need to have docker and bash installed. Go the root of this repository and run: diff --git a/testdata/performance/perf-test.sh b/testdata/performance/perf-test.sh old mode 100644 new mode 100755 index 89f502203..3f8dc3c9d --- a/testdata/performance/perf-test.sh +++ b/testdata/performance/perf-test.sh @@ -7,34 +7,34 @@ docker build -t frankenphp-dev -f dev.Dockerfile . export "CADDY_HOSTNAME=http://host.docker.internal" select filename in ./testdata/performance/*.js; do - read -p "How many worker threads? " workerThreads - read -p "How many max threads? " maxThreads + read -pr "How many worker threads? " workerThreads + read -pr "How many max threads? " maxThreads - numThreads=$((workerThreads+1)) + numThreads=$((workerThreads+1)) - docker run --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \ - -p 8125:80 \ - -v $PWD:/go/src/app \ - --name load-test-container \ - -e "MAX_THREADS=$maxThreads" \ - -e "WORKER_THREADS=$workerThreads" \ - -e "NUM_THREADS=$numThreads" \ - -itd \ - frankenphp-dev \ - sh /go/src/app/testdata/performance/start-server.sh + docker run --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \ + -p 8125:80 \ + -v "$PWD:/go/src/app" \ + --name load-test-container \ + -e "MAX_THREADS=$maxThreads" \ + -e "WORKER_THREADS=$workerThreads" \ + -e "NUM_THREADS=$numThreads" \ + -itd \ + frankenphp-dev \ + sh /go/src/app/testdata/performance/start-server.sh - docker exec -d load-test-container sh /go/src/app/testdata/performance/flamegraph.sh + docker exec -d load-test-container sh /go/src/app/testdata/performance/flamegraph.sh - sleep 10 + sleep 10 - docker run --entrypoint "" -it -v .:/app -w /app \ - --add-host "host.docker.internal:host-gateway" \ - grafana/k6:latest \ - k6 run -e "CADDY_HOSTNAME=$CADDY_HOSTNAME:8125" "./$filename" + docker run --entrypoint "" -it -v .:/app -w /app \ + --add-host "host.docker.internal:host-gateway" \ + grafana/k6:latest \ + k6 run -e "CADDY_HOSTNAME=$CADDY_HOSTNAME:8125" "./$filename" - docker exec load-test-container curl "http://localhost:2019/frankenphp/threads" + docker exec load-test-container curl "http://localhost:2019/frankenphp/threads" - docker stop load-test-container - docker rm load-test-container + docker stop load-test-container + docker rm load-test-container done diff --git a/testdata/performance/timeouts.js b/testdata/performance/timeouts.js index 172212613..74714e325 100644 --- a/testdata/performance/timeouts.js +++ b/testdata/performance/timeouts.js @@ -7,25 +7,26 @@ import http from 'k6/http'; */ export const options = { stages: [ - { duration: '20s', target: 100, }, - { duration: '20s', target: 500 }, - { duration: '20s', target: 0 }, + {duration: '20s', target: 100,}, + {duration: '20s', target: 500}, + {duration: '20s', target: 0} ], thresholds: { - http_req_failed: ['rate<0.01'], + http_req_failed: ['rate<0.01'] }, -}; +} +/*global __ENV*/ export default function () { - const tenSecondInterval = Math.floor(new Date().getSeconds() / 10); - const shouldHang = tenSecondInterval % 2 === 0; + const tenSecondInterval = Math.floor(new Date().getSeconds() / 10) + const shouldHang = tenSecondInterval % 2 === 0 // every 10 seconds requests lead to a max_execution-timeout if (shouldHang) { - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=50000`); - return; + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=50000`) + return } // every other 10 seconds the resource is back - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=5&work=30000&output=100`); -} \ No newline at end of file + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=5&work=30000&output=100`) +} diff --git a/thread-regular.go b/thread-regular.go index 929abc6ac..5477f8d34 100644 --- a/thread-regular.go +++ b/thread-regular.go @@ -151,26 +151,25 @@ func handleRequestWithRegularPHPThreads(r *http.Request, fc *FrankenPHPContext) func attachRegularThread(thread *phpThread) { regularThreadMu.Lock() - defer regularThreadMu.Unlock() - regularThreads = append(regularThreads, thread) + regularThreadMu.Unlock() } func detachRegularThread(thread *phpThread) { regularThreadMu.Lock() - defer regularThreadMu.Unlock() - for i, t := range regularThreads { if t == thread { regularThreads = append(regularThreads[:i], regularThreads[i+1:]...) break } } + regularThreadMu.Unlock() } func countRegularThreads() int { regularThreadMu.RLock() - defer regularThreadMu.RUnlock() + l := len(regularThreads) + regularThreadMu.RUnlock() - return len(regularThreads) + return l } diff --git a/worker.go b/worker.go index be679f241..785817c9a 100644 --- a/worker.go +++ b/worker.go @@ -160,9 +160,10 @@ func (worker *worker) detachThread(thread *phpThread) { func (worker *worker) countThreads() int { worker.threadMutex.RLock() - defer worker.threadMutex.RUnlock() + l := len(worker.threads) + worker.threadMutex.RUnlock() - return len(worker.threads) + return l } func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { From 745b29bcf1adb559bdce12be323a773b965f2faa Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 22 Dec 2024 17:26:23 +0100 Subject: [PATCH 108/190] Linting and formatting. --- testdata/performance/flamegraph.sh | 0 testdata/performance/start-server.sh | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 testdata/performance/flamegraph.sh mode change 100644 => 100755 testdata/performance/start-server.sh diff --git a/testdata/performance/flamegraph.sh b/testdata/performance/flamegraph.sh old mode 100644 new mode 100755 diff --git a/testdata/performance/start-server.sh b/testdata/performance/start-server.sh old mode 100644 new mode 100755 From 68ae2e4d99b1049fb03405dc7245367618f47210 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 22 Dec 2024 18:14:01 +0100 Subject: [PATCH 109/190] Adds explicit scaling tests. --- scaling.go | 2 ++ scaling_test.go | 72 +++++++++++++++++++++++++++++++++++++++++++++++++ state.go | 19 +++++++------ 3 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 scaling_test.go diff --git a/scaling.go b/scaling.go index c68062a87..9da702327 100644 --- a/scaling.go +++ b/scaling.go @@ -49,6 +49,7 @@ func addRegularThread() (*phpThread, error) { return nil, errors.New("max amount of overall threads reached") } convertToRegularThread(thread) + thread.state.waitFor(stateReady, stateShuttingDown, stateReserved) return thread, nil } @@ -90,6 +91,7 @@ func addWorkerThread(worker *worker) (*phpThread, error) { return nil, errors.New("max amount of overall threads reached") } convertToWorkerThread(thread, worker) + thread.state.waitFor(stateReady, stateShuttingDown, stateReserved) return thread, nil } diff --git a/scaling_test.go b/scaling_test.go new file mode 100644 index 000000000..030f84ebc --- /dev/null +++ b/scaling_test.go @@ -0,0 +1,72 @@ +package frankenphp + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +func TestScaleARegularThreadUpAndDown(t *testing.T) { + assert.NoError(t, Init( + WithNumThreads(1), + WithMaxThreads(2), + WithLogger(zap.NewNop()), + )) + + autoScaledThread := phpThreads[1] + + // scale up + autoscaleRegularThreads() + assert.Equal(t, stateReady, autoScaledThread.state.get()) + assert.IsType(t, ®ularThread{}, autoScaledThread.handler) + + // on the first down-scale, the thread will be marked as inactive + setLongWaitTime(autoScaledThread) + downScaleThreads() + assert.IsType(t, &inactiveThread{}, autoScaledThread.handler) + + // on the second down-scale, the thread will be removed + autoScaledThread.state.waitFor(stateInactive) + setLongWaitTime(autoScaledThread) + downScaleThreads() + assert.Equal(t, stateReserved, autoScaledThread.state.get()) + + Shutdown() +} + +func TestScaleAWorkerThreadUpAndDown(t *testing.T) { + workerPath := testDataPath + "/transition-worker-1.php" + assert.NoError(t, Init( + WithNumThreads(2), + WithMaxThreads(3), + WithWorkers(workerPath, 1, map[string]string{}, []string{}), + WithLogger(zap.NewNop()), + )) + + autoScaledThread := phpThreads[2] + + // scale up + autoscaleWorkerThreads(workers[workerPath]) + assert.Equal(t, stateReady, autoScaledThread.state.get()) + + // on the first down-scale, the thread will be marked as inactive + setLongWaitTime(autoScaledThread) + downScaleThreads() + assert.IsType(t, &inactiveThread{}, autoScaledThread.handler) + + // on the second down-scale, the thread will be removed + autoScaledThread.state.waitFor(stateInactive) + setLongWaitTime(autoScaledThread) + downScaleThreads() + assert.Equal(t, stateReserved, autoScaledThread.state.get()) + + Shutdown() +} + +func setLongWaitTime(thread *phpThread) { + thread.state.mu.Lock() + thread.state.waitingSince = time.Now().Add(-time.Hour) + thread.state.mu.Unlock() +} diff --git a/state.go b/state.go index 6af67a363..942760e14 100644 --- a/state.go +++ b/state.go @@ -15,7 +15,7 @@ const ( stateShuttingDown stateDone - // these states are safe to transition from at any time + // these states are 'stable' and safe to transition from at any time stateInactive stateReady @@ -47,7 +47,9 @@ type threadState struct { currentState stateID mu sync.RWMutex subscribers []stateSubscriber - waitingSince int64 + // how long threads have been waiting in stable states + waitingSince time.Time + isWaiting bool } type stateSubscriber struct { @@ -106,19 +108,20 @@ func (ts *threadState) set(nextState stateID) { func (ts *threadState) markAsWaiting(isWaiting bool) { ts.mu.Lock() if isWaiting { - ts.waitingSince = time.Now().UnixMilli() + ts.isWaiting = true + ts.waitingSince = time.Now() } else { - ts.waitingSince = 0 + ts.isWaiting = false } ts.mu.Unlock() } -// the time since the thread is waiting in a stable state (for request/activation) +// the time since the thread is waiting in a stable state in ms func (ts *threadState) waitTime() int64 { ts.mu.RLock() - var waitTime int64 = 0 - if ts.waitingSince != 0 { - waitTime = time.Now().UnixMilli() - ts.waitingSince + waitTime := int64(0) + if ts.isWaiting { + waitTime = time.Now().UnixMilli() - ts.waitingSince.UnixMilli() } ts.mu.RUnlock() return waitTime From 09a5caf80252105508cde11b439602f5d99f0f3b Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 22 Dec 2024 19:16:39 +0100 Subject: [PATCH 110/190] Adjusts perf tests. --- scaling.go | 1 + testdata/performance/api.js | 2 +- testdata/performance/database.js | 2 +- testdata/performance/perf-test.sh | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/scaling.go b/scaling.go index 9da702327..9826daf9f 100644 --- a/scaling.go +++ b/scaling.go @@ -235,6 +235,7 @@ func downScaleThreads() { func probeCPUs(probeTime time.Duration) bool { var start, end, cpuStart, cpuEnd C.struct_timespec + // TODO: make this cross-platform compatible C.clock_gettime(C.CLOCK_MONOTONIC, &start) C.clock_gettime(C.CLOCK_PROCESS_CPUTIME_ID, &cpuStart) diff --git a/testdata/performance/api.js b/testdata/performance/api.js index 642a3d16f..08802c01b 100644 --- a/testdata/performance/api.js +++ b/testdata/performance/api.js @@ -8,7 +8,7 @@ import http from 'k6/http'; export const options = { stages: [ {duration: '20s', target: 150,}, - {duration: '20s', target: 400}, + {duration: '20s', target: 1000}, {duration: '10s', target: 0} ], thresholds: { diff --git a/testdata/performance/database.js b/testdata/performance/database.js index 487283caf..8297f9f5d 100644 --- a/testdata/performance/database.js +++ b/testdata/performance/database.js @@ -7,7 +7,7 @@ import http from 'k6/http'; export const options = { stages: [ {duration: '20s', target: 100,}, - {duration: '20s', target: 200}, + {duration: '30s', target: 200}, {duration: '10s', target: 0} ], thresholds: { diff --git a/testdata/performance/perf-test.sh b/testdata/performance/perf-test.sh index 3f8dc3c9d..8d1123a03 100755 --- a/testdata/performance/perf-test.sh +++ b/testdata/performance/perf-test.sh @@ -7,8 +7,8 @@ docker build -t frankenphp-dev -f dev.Dockerfile . export "CADDY_HOSTNAME=http://host.docker.internal" select filename in ./testdata/performance/*.js; do - read -pr "How many worker threads? " workerThreads - read -pr "How many max threads? " maxThreads + read -r -p "How many worker threads? " workerThreads + read -r -p "How many max threads? " maxThreads numThreads=$((workerThreads+1)) From 3cfcb117347c9cd9fc6f6a54320fc1ed47a64977 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 22 Dec 2024 20:01:48 +0100 Subject: [PATCH 111/190] Uses different worker in removal test. --- caddy/admin_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/caddy/admin_test.go b/caddy/admin_test.go index 6b9ab131c..506b371a8 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -44,7 +44,7 @@ func TestRestartWorkerViaAdminApi(t *testing.T) { } func TestRemoveWorkerThreadsViaAdminApi(t *testing.T) { - absWorkerPath, _ := filepath.Abs("../testdata/worker-with-counter.php") + absWorkerPath, _ := filepath.Abs("../testdata/sleep.php") tester := caddytest.NewTester(t) tester.InitServer(` { @@ -55,21 +55,21 @@ func TestRemoveWorkerThreadsViaAdminApi(t *testing.T) { frankenphp { num_threads 6 max_threads 6 - worker ../testdata/worker-with-counter.php 4 + worker ../testdata/sleep.php 4 } } localhost:`+testPort+` { route { root ../testdata - rewrite worker-with-counter.php + rewrite sleep.php php } } `, "caddyfile") // make a request to the worker to make sure it's running - tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1") + tester.AssertGetResponse("http://localhost:"+testPort, http.StatusOK, "slept for 0 ms and worked for 0 iterations") // remove a thread expectedMessage := fmt.Sprintf("New thread count: 3 %s\n", absWorkerPath) @@ -83,7 +83,7 @@ func TestRemoveWorkerThreadsViaAdminApi(t *testing.T) { assertAdminResponse(tester, "DELETE", "threads?worker", http.StatusBadRequest, "") // make a request to the worker to make sure it's still running - tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:2") + tester.AssertGetResponse("http://localhost:"+testPort, http.StatusOK, "slept for 0 ms and worked for 0 iterations") } func TestAddWorkerThreadsViaAdminApi(t *testing.T) { From cbe45fc41572bacf1355179703d618ae4d21969d Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 22 Dec 2024 20:08:19 +0100 Subject: [PATCH 112/190] More formatting fixes. --- testdata/performance/api.js | 34 ++++++++++----------- testdata/performance/computation.js | 34 ++++++++++----------- testdata/performance/database.js | 38 ++++++++++++------------ testdata/performance/flamegraph.sh | 10 +++---- testdata/performance/hanging-requests.js | 34 ++++++++++----------- testdata/performance/hello-world.js | 22 +++++++------- testdata/performance/perf-test.sh | 24 +++++++-------- testdata/performance/start-server.sh | 8 ++--- testdata/performance/timeouts.js | 38 ++++++++++++------------ 9 files changed, 121 insertions(+), 121 deletions(-) diff --git a/testdata/performance/api.js b/testdata/performance/api.js index 08802c01b..d1070a018 100644 --- a/testdata/performance/api.js +++ b/testdata/performance/api.js @@ -1,4 +1,4 @@ -import http from 'k6/http'; +import http from 'k6/http' /** * Many applications communicate with external APIs or microservices. @@ -6,24 +6,24 @@ import http from 'k6/http'; * We'll consider 10ms-150ms */ export const options = { - stages: [ - {duration: '20s', target: 150,}, - {duration: '20s', target: 1000}, - {duration: '10s', target: 0} - ], - thresholds: { - http_req_failed: ['rate<0.01'] - }, + stages: [ + { duration: '20s', target: 150 }, + { duration: '20s', target: 1000 }, + { duration: '10s', target: 0 } + ], + thresholds: { + http_req_failed: ['rate<0.01'] + } }; -/*global __ENV*/ +/* global __ENV */ export default function () { - // 10-150ms latency - const latency = Math.floor(Math.random() * 141) + 10 - // 1-30000 work units - const work = Math.ceil(Math.random() * 30000) - // 1-40 output units - const output = Math.ceil(Math.random() * 40) + // 10-150ms latency + const latency = Math.floor(Math.random() * 141) + 10 + // 1-30000 work units + const work = Math.ceil(Math.random() * 30000) + // 1-40 output units + const output = Math.ceil(Math.random() * 40) - http.get(http.url`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${latency}&work=${work}&output=${output}`) + http.get(http.url`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${latency}&work=${work}&output=${output}`) } diff --git a/testdata/performance/computation.js b/testdata/performance/computation.js index ba380124e..7067ca993 100644 --- a/testdata/performance/computation.js +++ b/testdata/performance/computation.js @@ -1,27 +1,27 @@ -import http from 'k6/http'; +import http from 'k6/http' /** * Simulate an application that does very little IO, but a lot of computation */ export const options = { - stages: [ - {duration: '20s', target: 80,}, - {duration: '20s', target: 150}, - {duration: '5s', target: 0} - ], - thresholds: { - http_req_failed: ['rate<0.01'] - }, + stages: [ + { duration: '20s', target: 80 }, + { duration: '20s', target: 150 }, + { duration: '5s', target: 0 } + ], + thresholds: { + http_req_failed: ['rate<0.01'] + } }; -/*global __ENV*/ +/* global __ENV */ export default function () { - // do 1-1,000,000 work units - const work = Math.ceil(Math.random() * 1_000_000) - // output 1-500 units - const output = Math.ceil(Math.random() * 500) - // simulate 0-2ms latency - const latency = Math.floor(Math.random() * 3) + // do 1-1,000,000 work units + const work = Math.ceil(Math.random() * 1_000_000) + // output 1-500 units + const output = Math.ceil(Math.random() * 500) + // simulate 0-2ms latency + const latency = Math.floor(Math.random() * 3) - http.get(http.url`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${latency}&work=${work}&output=${output}`) + http.get(http.url`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${latency}&work=${work}&output=${output}`) } diff --git a/testdata/performance/database.js b/testdata/performance/database.js index 8297f9f5d..1968756d0 100644 --- a/testdata/performance/database.js +++ b/testdata/performance/database.js @@ -1,30 +1,30 @@ -import http from 'k6/http'; +import http from 'k6/http' /** * Modern databases tend to have latencies in the single-digit milliseconds. * We'll simulate 1-10ms latencies and 1-2 queries per request. */ export const options = { - stages: [ - {duration: '20s', target: 100,}, - {duration: '30s', target: 200}, - {duration: '10s', target: 0} - ], - thresholds: { - http_req_failed: ['rate<0.01'] - }, + stages: [ + { duration: '20s', target: 100 }, + { duration: '30s', target: 200 }, + { duration: '10s', target: 0 } + ], + thresholds: { + http_req_failed: ['rate<0.01'] + } }; -/*global __ENV*/ +/* global __ENV */ export default function () { - // 1-10ms latency - const latency = Math.floor(Math.random() * 10) + 1 - // 1-2 iterations per request - const iterations = Math.floor(Math.random() * 2) + 1 - // 1-30000 work units per iteration - const work = Math.ceil(Math.random() * 30000) - // 1-40 output units - const output = Math.ceil(Math.random() * 40) + // 1-10ms latency + const latency = Math.floor(Math.random() * 10) + 1 + // 1-2 iterations per request + const iterations = Math.floor(Math.random() * 2) + 1 + // 1-30000 work units per iteration + const work = Math.ceil(Math.random() * 30000) + // 1-40 output units + const output = Math.ceil(Math.random() * 40) - http.get(http.url`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${latency}&work=${work}&output=${output}&iterations=${iterations}`) + http.get(http.url`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=${latency}&work=${work}&output=${output}&iterations=${iterations}`) } diff --git a/testdata/performance/flamegraph.sh b/testdata/performance/flamegraph.sh index 51b361f6b..3f0ce0137 100755 --- a/testdata/performance/flamegraph.sh +++ b/testdata/performance/flamegraph.sh @@ -2,9 +2,9 @@ # install brendangregg's FlameGraph if [ ! -d "/usr/local/src/flamegraph" ]; then - mkdir /usr/local/src/flamegraph && \ - cd /usr/local/src/flamegraph && \ - git clone https://github.com/brendangregg/FlameGraph.git + mkdir /usr/local/src/flamegraph && \ + cd /usr/local/src/flamegraph && \ + git clone https://github.com/brendangregg/FlameGraph.git fi # let the test warm up @@ -12,5 +12,5 @@ sleep 10 # run a 30 second profile on the Caddy admin port cd /usr/local/src/flamegraph/FlameGraph && \ -go tool pprof -raw -output=cpu.txt 'http://localhost:2019/debug/pprof/profile?seconds=30' && \ -./stackcollapse-go.pl cpu.txt | ./flamegraph.pl > /go/src/app/testdata/performance/flamegraph.svg \ No newline at end of file + go tool pprof -raw -output=cpu.txt 'http://localhost:2019/debug/pprof/profile?seconds=30' && \ + ./stackcollapse-go.pl cpu.txt | ./flamegraph.pl > /go/src/app/testdata/performance/flamegraph.svg \ No newline at end of file diff --git a/testdata/performance/hanging-requests.js b/testdata/performance/hanging-requests.js index 899ea16d3..db191fdef 100644 --- a/testdata/performance/hanging-requests.js +++ b/testdata/performance/hanging-requests.js @@ -1,28 +1,28 @@ -import http from 'k6/http'; +import http from 'k6/http' /** * It is not uncommon for external services to hang for a long time. * Make sure the server is resilient in such cases and doesn't hang as well. */ export const options = { - stages: [ - {duration: '20s', target: 100,}, - {duration: '20s', target: 500}, - {duration: '20s', target: 0} - ], - thresholds: { - http_req_failed: ['rate<0.01'] - }, + stages: [ + { duration: '20s', target: 100 }, + { duration: '20s', target: 500 }, + { duration: '20s', target: 0 } + ], + thresholds: { + http_req_failed: ['rate<0.01'] + } } -/*global __ENV*/ +/* global __ENV */ export default function () { - // 2% chance for a request that hangs for 15s - if (Math.random() < 0.02) { - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=15000&work=10000&output=100`) - return - } + // 2% chance for a request that hangs for 15s + if (Math.random() < 0.02) { + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=15000&work=10000&output=100`) + return + } - // a regular request - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=5&work=10000&output=100`) + // a regular request + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=5&work=10000&output=100`) } diff --git a/testdata/performance/hello-world.js b/testdata/performance/hello-world.js index 38a0815a2..f0499fede 100644 --- a/testdata/performance/hello-world.js +++ b/testdata/performance/hello-world.js @@ -1,20 +1,20 @@ -import http from 'k6/http'; +import http from 'k6/http' /** * 'Hello world' tests the raw server performance. */ export const options = { - stages: [ - {duration: '5s', target: 100}, - {duration: '20s', target: 400}, - {duration: '5s', target: 0} - ], - thresholds: { - http_req_failed: ['rate<0.01'] - } + stages: [ + { duration: '5s', target: 100 }, + { duration: '20s', target: 400 }, + { duration: '5s', target: 0 } + ], + thresholds: { + http_req_failed: ['rate<0.01'] + } } -/*global __ENV*/ +/* global __ENV */ export default function () { - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php`) + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php`) } diff --git a/testdata/performance/perf-test.sh b/testdata/performance/perf-test.sh index 8d1123a03..3538177ab 100755 --- a/testdata/performance/perf-test.sh +++ b/testdata/performance/perf-test.sh @@ -13,24 +13,24 @@ select filename in ./testdata/performance/*.js; do numThreads=$((workerThreads+1)) docker run --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \ - -p 8125:80 \ - -v "$PWD:/go/src/app" \ - --name load-test-container \ - -e "MAX_THREADS=$maxThreads" \ - -e "WORKER_THREADS=$workerThreads" \ - -e "NUM_THREADS=$numThreads" \ - -itd \ - frankenphp-dev \ - sh /go/src/app/testdata/performance/start-server.sh + -p 8125:80 \ + -v "$PWD:/go/src/app" \ + --name load-test-container \ + -e "MAX_THREADS=$maxThreads" \ + -e "WORKER_THREADS=$workerThreads" \ + -e "NUM_THREADS=$numThreads" \ + -itd \ + frankenphp-dev \ + sh /go/src/app/testdata/performance/start-server.sh docker exec -d load-test-container sh /go/src/app/testdata/performance/flamegraph.sh sleep 10 docker run --entrypoint "" -it -v .:/app -w /app \ - --add-host "host.docker.internal:host-gateway" \ - grafana/k6:latest \ - k6 run -e "CADDY_HOSTNAME=$CADDY_HOSTNAME:8125" "./$filename" + --add-host "host.docker.internal:host-gateway" \ + grafana/k6:latest \ + k6 run -e "CADDY_HOSTNAME=$CADDY_HOSTNAME:8125" "./$filename" docker exec load-test-container curl "http://localhost:2019/frankenphp/threads" diff --git a/testdata/performance/start-server.sh b/testdata/performance/start-server.sh index 23aad17c3..2a217836d 100755 --- a/testdata/performance/start-server.sh +++ b/testdata/performance/start-server.sh @@ -1,7 +1,7 @@ #!/bin/bash # build and run FrankenPHP with the k6.Caddyfile -cd /go/src/app/caddy/frankenphp \ -&& go build --buildvcs=false \ -&& cd ../../testdata/performance \ -&& /go/src/app/caddy/frankenphp/frankenphp run -c k6.Caddyfile \ No newline at end of file +cd /go/src/app/caddy/frankenphp && \ + go build --buildvcs=false && \ + cd ../../testdata/performance && \ + /go/src/app/caddy/frankenphp/frankenphp run -c k6.Caddyfile \ No newline at end of file diff --git a/testdata/performance/timeouts.js b/testdata/performance/timeouts.js index 74714e325..775c36441 100644 --- a/testdata/performance/timeouts.js +++ b/testdata/performance/timeouts.js @@ -1,4 +1,4 @@ -import http from 'k6/http'; +import http from 'k6/http' /** * Databases or external resources can sometimes become unavailable for short periods of time. @@ -6,27 +6,27 @@ import http from 'k6/http'; * This simulation swaps between a hanging and a working server every 10 seconds. */ export const options = { - stages: [ - {duration: '20s', target: 100,}, - {duration: '20s', target: 500}, - {duration: '20s', target: 0} - ], - thresholds: { - http_req_failed: ['rate<0.01'] - }, + stages: [ + { duration: '20s', target: 100 }, + { duration: '20s', target: 500 }, + { duration: '20s', target: 0 } + ], + thresholds: { + http_req_failed: ['rate<0.01'] + } } -/*global __ENV*/ +/* global __ENV */ export default function () { - const tenSecondInterval = Math.floor(new Date().getSeconds() / 10) - const shouldHang = tenSecondInterval % 2 === 0 + const tenSecondInterval = Math.floor(new Date().getSeconds() / 10) + const shouldHang = tenSecondInterval % 2 === 0 - // every 10 seconds requests lead to a max_execution-timeout - if (shouldHang) { - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=50000`) - return - } + // every 10 seconds requests lead to a max_execution-timeout + if (shouldHang) { + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=50000`) + return + } - // every other 10 seconds the resource is back - http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=5&work=30000&output=100`) + // every other 10 seconds the resource is back + http.get(`${__ENV.CADDY_HOSTNAME}/sleep.php?sleep=5&work=30000&output=100`) } From 1d8e973594785a515bccf8a394c642a072f46092 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 22 Dec 2024 20:19:39 +0100 Subject: [PATCH 113/190] Replaces inline errors and adjusts comments. --- scaling.go | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/scaling.go b/scaling.go index 9826daf9f..f0d36cbca 100644 --- a/scaling.go +++ b/scaling.go @@ -14,17 +14,17 @@ import ( // TODO: make speed of scaling dependant on CPU count? const ( - // only allow scaling threads if requests were stalled for longer than this time + // scale threads if requests stall this amount of time allowedStallTime = 10 * time.Millisecond - // the amount of time to check for CPU usage before scaling + // time to check for CPU usage before scaling a single thread cpuProbeTime = 40 * time.Millisecond - // if PHP threads are using more than this ratio of the CPU, do not scale + // do not scale over this amount of CPU usage maxCpuUsageForScaling = 0.8 - // check if threads should be stopped every x seconds + // downscale idle threads every x seconds downScaleCheckTime = 5 * time.Second - // amount of threads that can be stopped in one iteration of downScaleCheckTime + // max amount of threads stopped in one iteration of downScaleCheckTime maxTerminationCount = 10 - // if an autoscaled thread has been waiting for longer than this time, terminate it + // autoscaled threads waiting for longer than this time are downscaled maxThreadIdleTime = 5 * time.Second ) @@ -33,6 +33,10 @@ var ( scalingMu = new(sync.RWMutex) blockAutoScaling = atomic.Bool{} cpuCount = runtime.NumCPU() + + MaxThreadsReachedError = errors.New("max amount of overall threads reached") + CannotRemoveLastThreadError = errors.New("cannot remove last thread") + WorkerNotFoundError = errors.New("worker not found for given filename") ) // turn the first inactive/reserved thread into a regular thread @@ -46,7 +50,7 @@ func AddRegularThread() (int, error) { func addRegularThread() (*phpThread, error) { thread := getInactivePHPThread() if thread == nil { - return nil, errors.New("max amount of overall threads reached") + return nil, MaxThreadsReachedError } convertToRegularThread(thread) thread.state.waitFor(stateReady, stateShuttingDown, stateReserved) @@ -65,7 +69,7 @@ func removeRegularThread() error { regularThreadMu.RLock() if len(regularThreads) <= 1 { regularThreadMu.RUnlock() - return errors.New("cannot remove last thread") + return CannotRemoveLastThreadError } thread := regularThreads[len(regularThreads)-1] regularThreadMu.RUnlock() @@ -76,7 +80,7 @@ func removeRegularThread() error { func AddWorkerThread(workerFileName string) (int, error) { worker, ok := workers[workerFileName] if !ok { - return 0, errors.New("worker not found") + return 0, WorkerNotFoundError } scalingMu.Lock() defer scalingMu.Unlock() @@ -88,7 +92,7 @@ func AddWorkerThread(workerFileName string) (int, error) { func addWorkerThread(worker *worker) (*phpThread, error) { thread := getInactivePHPThread() if thread == nil { - return nil, errors.New("max amount of overall threads reached") + return nil, MaxThreadsReachedError } convertToWorkerThread(thread, worker) thread.state.waitFor(stateReady, stateShuttingDown, stateReserved) @@ -98,7 +102,7 @@ func addWorkerThread(worker *worker) (*phpThread, error) { func RemoveWorkerThread(workerFileName string) (int, error) { worker, ok := workers[workerFileName] if !ok { - return 0, errors.New("worker not found") + return 0, WorkerNotFoundError } scalingMu.Lock() defer scalingMu.Unlock() @@ -112,7 +116,7 @@ func removeWorkerThread(worker *worker) error { worker.threadMutex.RLock() if len(worker.threads) <= 1 { worker.threadMutex.RUnlock() - return errors.New("cannot remove last thread") + return CannotRemoveLastThreadError } thread := worker.threads[len(worker.threads)-1] worker.threadMutex.RUnlock() @@ -235,7 +239,7 @@ func downScaleThreads() { func probeCPUs(probeTime time.Duration) bool { var start, end, cpuStart, cpuEnd C.struct_timespec - // TODO: make this cross-platform compatible + // TODO: validate cross-platform compatibility C.clock_gettime(C.CLOCK_MONOTONIC, &start) C.clock_gettime(C.CLOCK_PROCESS_CPUTIME_ID, &cpuStart) @@ -253,8 +257,5 @@ func probeCPUs(probeTime time.Duration) bool { elapsedCpuTime := float64(cpuEnd.tv_sec-cpuStart.tv_sec)*1e9 + float64(cpuEnd.tv_nsec-cpuStart.tv_nsec) cpuUsage := elapsedCpuTime / elapsedTime / float64(cpuCount) - // TODO: remove unnecessary debug messages - logger.Debug("CPU usage", zap.Float64("cpuUsage", cpuUsage)) - return cpuUsage < maxCpuUsageForScaling } From bf48b145419c6d5322b19ef70bf38c4fda202d01 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 22 Dec 2024 20:22:00 +0100 Subject: [PATCH 114/190] Formatting. --- state.go | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/state.go b/state.go index 942760e14..fc64aa93d 100644 --- a/state.go +++ b/state.go @@ -104,29 +104,6 @@ func (ts *threadState) set(nextState stateID) { ts.mu.Unlock() } -// the thread reached a stable state and is waiting -func (ts *threadState) markAsWaiting(isWaiting bool) { - ts.mu.Lock() - if isWaiting { - ts.isWaiting = true - ts.waitingSince = time.Now() - } else { - ts.isWaiting = false - } - ts.mu.Unlock() -} - -// the time since the thread is waiting in a stable state in ms -func (ts *threadState) waitTime() int64 { - ts.mu.RLock() - waitTime := int64(0) - if ts.isWaiting { - waitTime = time.Now().UnixMilli() - ts.waitingSince.UnixMilli() - } - ts.mu.RUnlock() - return waitTime -} - func (ts *threadState) notifySubscribers(nextState stateID) { if len(ts.subscribers) == 0 { return @@ -180,3 +157,26 @@ func (ts *threadState) requestSafeStateChange(nextState stateID) bool { ts.waitFor(stateReady, stateInactive, stateShuttingDown) return ts.requestSafeStateChange(nextState) } + +// the thread reached a stable state and is waiting for requests or shutdown +func (ts *threadState) markAsWaiting(isWaiting bool) { + ts.mu.Lock() + if isWaiting { + ts.isWaiting = true + ts.waitingSince = time.Now() + } else { + ts.isWaiting = false + } + ts.mu.Unlock() +} + +// the time since the thread is waiting in a stable state in ms +func (ts *threadState) waitTime() int64 { + ts.mu.RLock() + waitTime := int64(0) + if ts.isWaiting { + waitTime = time.Now().UnixMilli() - ts.waitingSince.UnixMilli() + } + ts.mu.RUnlock() + return waitTime +} From 4f0cc8a95e7fcc707397ca142298c078c7e6372d Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 22 Dec 2024 20:27:47 +0100 Subject: [PATCH 115/190] Formatting. --- testdata/performance/api.js | 2 +- testdata/performance/computation.js | 2 +- testdata/performance/database.js | 2 +- testdata/performance/flamegraph.sh | 8 ++++---- testdata/performance/perf-test.sh | 2 +- testdata/performance/start-server.sh | 6 +++--- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/testdata/performance/api.js b/testdata/performance/api.js index d1070a018..17d57252e 100644 --- a/testdata/performance/api.js +++ b/testdata/performance/api.js @@ -14,7 +14,7 @@ export const options = { thresholds: { http_req_failed: ['rate<0.01'] } -}; +} /* global __ENV */ export default function () { diff --git a/testdata/performance/computation.js b/testdata/performance/computation.js index 7067ca993..36ba3cea6 100644 --- a/testdata/performance/computation.js +++ b/testdata/performance/computation.js @@ -12,7 +12,7 @@ export const options = { thresholds: { http_req_failed: ['rate<0.01'] } -}; +} /* global __ENV */ export default function () { diff --git a/testdata/performance/database.js b/testdata/performance/database.js index 1968756d0..ecef7ad1b 100644 --- a/testdata/performance/database.js +++ b/testdata/performance/database.js @@ -13,7 +13,7 @@ export const options = { thresholds: { http_req_failed: ['rate<0.01'] } -}; +} /* global __ENV */ export default function () { diff --git a/testdata/performance/flamegraph.sh b/testdata/performance/flamegraph.sh index 3f0ce0137..3504886ba 100755 --- a/testdata/performance/flamegraph.sh +++ b/testdata/performance/flamegraph.sh @@ -2,8 +2,8 @@ # install brendangregg's FlameGraph if [ ! -d "/usr/local/src/flamegraph" ]; then - mkdir /usr/local/src/flamegraph && \ - cd /usr/local/src/flamegraph && \ + mkdir /usr/local/src/flamegraph && + cd /usr/local/src/flamegraph && git clone https://github.com/brendangregg/FlameGraph.git fi @@ -11,6 +11,6 @@ fi sleep 10 # run a 30 second profile on the Caddy admin port -cd /usr/local/src/flamegraph/FlameGraph && \ - go tool pprof -raw -output=cpu.txt 'http://localhost:2019/debug/pprof/profile?seconds=30' && \ +cd /usr/local/src/flamegraph/FlameGraph && + go tool pprof -raw -output=cpu.txt 'http://localhost:2019/debug/pprof/profile?seconds=30' && ./stackcollapse-go.pl cpu.txt | ./flamegraph.pl > /go/src/app/testdata/performance/flamegraph.svg \ No newline at end of file diff --git a/testdata/performance/perf-test.sh b/testdata/performance/perf-test.sh index 3538177ab..1dc2d1e4f 100755 --- a/testdata/performance/perf-test.sh +++ b/testdata/performance/perf-test.sh @@ -10,7 +10,7 @@ select filename in ./testdata/performance/*.js; do read -r -p "How many worker threads? " workerThreads read -r -p "How many max threads? " maxThreads - numThreads=$((workerThreads+1)) + numThreads=$((workerThreads + 1)) docker run --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \ -p 8125:80 \ diff --git a/testdata/performance/start-server.sh b/testdata/performance/start-server.sh index 2a217836d..5040c31e9 100755 --- a/testdata/performance/start-server.sh +++ b/testdata/performance/start-server.sh @@ -1,7 +1,7 @@ #!/bin/bash # build and run FrankenPHP with the k6.Caddyfile -cd /go/src/app/caddy/frankenphp && \ - go build --buildvcs=false && \ - cd ../../testdata/performance && \ +cd /go/src/app/caddy/frankenphp && + go build --buildvcs=false && + cd ../../testdata/performance && /go/src/app/caddy/frankenphp/frankenphp run -c k6.Caddyfile \ No newline at end of file From d483baff3205650bff162ffe9c49679c63e4e162 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 22 Dec 2024 20:52:52 +0100 Subject: [PATCH 116/190] Formatting. --- testdata/performance/flamegraph.sh | 2 +- testdata/performance/perf-test.sh | 1 - testdata/performance/start-server.sh | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/testdata/performance/flamegraph.sh b/testdata/performance/flamegraph.sh index 3504886ba..5f0eaa8de 100755 --- a/testdata/performance/flamegraph.sh +++ b/testdata/performance/flamegraph.sh @@ -13,4 +13,4 @@ sleep 10 # run a 30 second profile on the Caddy admin port cd /usr/local/src/flamegraph/FlameGraph && go tool pprof -raw -output=cpu.txt 'http://localhost:2019/debug/pprof/profile?seconds=30' && - ./stackcollapse-go.pl cpu.txt | ./flamegraph.pl > /go/src/app/testdata/performance/flamegraph.svg \ No newline at end of file + ./stackcollapse-go.pl cpu.txt | ./flamegraph.pl >/go/src/app/testdata/performance/flamegraph.svg diff --git a/testdata/performance/perf-test.sh b/testdata/performance/perf-test.sh index 1dc2d1e4f..50740f999 100755 --- a/testdata/performance/perf-test.sh +++ b/testdata/performance/perf-test.sh @@ -37,4 +37,3 @@ select filename in ./testdata/performance/*.js; do docker stop load-test-container docker rm load-test-container done - diff --git a/testdata/performance/start-server.sh b/testdata/performance/start-server.sh index 5040c31e9..998c148cc 100755 --- a/testdata/performance/start-server.sh +++ b/testdata/performance/start-server.sh @@ -4,4 +4,4 @@ cd /go/src/app/caddy/frankenphp && go build --buildvcs=false && cd ../../testdata/performance && - /go/src/app/caddy/frankenphp/frankenphp run -c k6.Caddyfile \ No newline at end of file + /go/src/app/caddy/frankenphp/frankenphp run -c k6.Caddyfile From eef7815d186fa2bc63fbff76a0d983be1d3dd1e9 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 25 Dec 2024 22:30:33 +0100 Subject: [PATCH 117/190] Implements suggestions by @dunglas. --- caddy/admin.go | 13 ++++++------ caddy/admin_test.go | 52 ++++++++++++++++++++------------------------- caddy/caddy.go | 4 ++-- docs/worker.md | 2 +- 4 files changed, 32 insertions(+), 39 deletions(-) diff --git a/caddy/admin.go b/caddy/admin.go index 7e254d6e8..0aaf7a133 100644 --- a/caddy/admin.go +++ b/caddy/admin.go @@ -45,17 +45,16 @@ func (admin *FrankenPHPAdmin) restartWorkers(w http.ResponseWriter, r *http.Requ } func (admin *FrankenPHPAdmin) threads(w http.ResponseWriter, r *http.Request) error { - if r.Method == http.MethodPut { + switch r.Method { + case http.MethodPut: return admin.changeThreads(w, r, admin.getCountFromRequest(r)) - } - if r.Method == http.MethodDelete { + case http.MethodDelete: return admin.changeThreads(w, r, -admin.getCountFromRequest(r)) - } - if r.Method == http.MethodGet { + case http.MethodGet: return admin.success(w, frankenphp.ThreadDebugStatus()) + default: + return admin.error(http.StatusMethodNotAllowed, fmt.Errorf("method not allowed, try: GET,PUT,DELETE")) } - - return admin.error(http.StatusMethodNotAllowed, fmt.Errorf("method not allowed, try: GET,PUT,DELETE")) } func (admin *FrankenPHPAdmin) changeThreads(w http.ResponseWriter, r *http.Request, count int) error { diff --git a/caddy/admin_test.go b/caddy/admin_test.go index 506b371a8..d03111f45 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -38,7 +38,7 @@ func TestRestartWorkerViaAdminApi(t *testing.T) { tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1") tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:2") - assertAdminResponse(tester, "POST", "workers/restart", http.StatusOK, "workers restarted successfully\n") + assertAdminResponse(t, tester, "POST", "workers/restart", http.StatusOK, "workers restarted successfully\n") tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1") } @@ -73,14 +73,14 @@ func TestRemoveWorkerThreadsViaAdminApi(t *testing.T) { // remove a thread expectedMessage := fmt.Sprintf("New thread count: 3 %s\n", absWorkerPath) - assertAdminResponse(tester, "DELETE", "threads?worker", http.StatusOK, expectedMessage) + assertAdminResponse(t, tester, "DELETE", "threads?worker", http.StatusOK, expectedMessage) // remove 2 threads expectedMessage = fmt.Sprintf("New thread count: 1 %s\n", absWorkerPath) - assertAdminResponse(tester, "DELETE", "threads?worker&count=2", http.StatusOK, expectedMessage) + assertAdminResponse(t, tester, "DELETE", "threads?worker&count=2", http.StatusOK, expectedMessage) // get 400 status if removing the last thread - assertAdminResponse(tester, "DELETE", "threads?worker", http.StatusBadRequest, "") + assertAdminResponse(t, tester, "DELETE", "threads?worker", http.StatusBadRequest, "") // make a request to the worker to make sure it's still running tester.AssertGetResponse("http://localhost:"+testPort, http.StatusOK, "slept for 0 ms and worked for 0 iterations") @@ -115,18 +115,18 @@ func TestAddWorkerThreadsViaAdminApi(t *testing.T) { tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1") // get 400 status if the filename is wrong - assertAdminResponse(tester, "PUT", "threads?worker=wrong.php", http.StatusBadRequest, "") + assertAdminResponse(t, tester, "PUT", "threads?worker=wrong.php", http.StatusBadRequest, "") // add a thread expectedMessage := fmt.Sprintf("New thread count: 2 %s\n", absWorkerPath) - assertAdminResponse(tester, "PUT", "threads?worker=counter.php", http.StatusOK, expectedMessage) + assertAdminResponse(t, tester, "PUT", "threads?worker=counter.php", http.StatusOK, expectedMessage) // add 2 threads expectedMessage = fmt.Sprintf("New thread count: 4 %s\n", absWorkerPath) - assertAdminResponse(tester, "PUT", "threads?worker&=counter.php&count=2", http.StatusOK, expectedMessage) + assertAdminResponse(t, tester, "PUT", "threads?worker&=counter.php&count=2", http.StatusOK, expectedMessage) // get 400 status if adding too many threads - assertAdminResponse(tester, "PUT", "threads?worker&=counter.php&count=100", http.StatusBadRequest, "") + assertAdminResponse(t, tester, "PUT", "threads?worker&=counter.php&count=100", http.StatusBadRequest, "") // make a request to the worker to make sure it's still running tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:2") @@ -158,13 +158,13 @@ func TestShowTheCorrectThreadDebugStatus(t *testing.T) { `, "caddyfile") // should create a 'worker-with-counter.php' thread at index 6 - assertAdminResponse(tester, "PUT", "threads?worker=counter.php", http.StatusOK, "") + assertAdminResponse(t, tester, "PUT", "threads?worker=counter.php", http.StatusOK, "") // should remove the 'index.php' worker thread at index 5 - assertAdminResponse(tester, "DELETE", "threads?worker=index.php", http.StatusOK, "") + assertAdminResponse(t, tester, "DELETE", "threads?worker=index.php", http.StatusOK, "") // should remove a regular thread at index 1 - assertAdminResponse(tester, "DELETE", "threads", http.StatusOK, "") + assertAdminResponse(t, tester, "DELETE", "threads", http.StatusOK, "") - threadInfo := getAdminResponseBody(tester, "GET", "threads") + threadInfo := getAdminResponseBody(t, tester, "GET", "threads") // assert that the correct threads are present in the thread info assert.Contains(t, threadInfo, "Thread 0") @@ -210,7 +210,7 @@ func TestAutoScaleWorkerThreads(t *testing.T) { autoScaledThread := "Thread 2" // first assert that the thread is not already present - threadInfo := getAdminResponseBody(tester, "GET", "threads") + threadInfo := getAdminResponseBody(t, tester, "GET", "threads") assert.NotContains(t, threadInfo, autoScaledThread) // try to spawn the additional threads by spamming the server @@ -223,7 +223,7 @@ func TestAutoScaleWorkerThreads(t *testing.T) { }() } wg.Wait() - threadInfo = getAdminResponseBody(tester, "GET", "threads") + threadInfo = getAdminResponseBody(t, tester, "GET", "threads") if strings.Contains(threadInfo, autoScaledThread) { break } @@ -263,7 +263,7 @@ func TestAutoScaleRegularThreads(t *testing.T) { autoScaledThread := "Thread 1" // first assert that the thread is not already present - threadInfo := getAdminResponseBody(tester, "GET", "threads") + threadInfo := getAdminResponseBody(t, tester, "GET", "threads") assert.NotContains(t, threadInfo, autoScaledThread) // try to spawn the additional threads by spamming the server @@ -276,7 +276,7 @@ func TestAutoScaleRegularThreads(t *testing.T) { }() } wg.Wait() - threadInfo = getAdminResponseBody(tester, "GET", "threads") + threadInfo = getAdminResponseBody(t, tester, "GET", "threads") if strings.Contains(threadInfo, autoScaledThread) { break } @@ -286,31 +286,25 @@ func TestAutoScaleRegularThreads(t *testing.T) { assert.Contains(t, threadInfo, autoScaledThread) } -func assertAdminResponse(tester *caddytest.Tester, method string, path string, expectedStatus int, expectedBody string) { +func assertAdminResponse(t *testing.T, tester *caddytest.Tester, method string, path string, expectedStatus int, expectedBody string) { adminUrl := "http://localhost:2999/frankenphp/" r, err := http.NewRequest(method, adminUrl+path, nil) - if err != nil { - panic(err) - } + assert.NoError(t, err) if expectedBody == "" { _ = tester.AssertResponseCode(r, expectedStatus) - } else { - _, _ = tester.AssertResponse(r, expectedStatus, expectedBody) + return } + _, _ = tester.AssertResponse(r, expectedStatus, expectedBody) } -func getAdminResponseBody(tester *caddytest.Tester, method string, path string) string { +func getAdminResponseBody(t *testing.T, tester *caddytest.Tester, method string, path string) string { adminUrl := "http://localhost:2999/frankenphp/" r, err := http.NewRequest(method, adminUrl+path, nil) - if err != nil { - panic(err) - } + assert.NoError(t, err) resp := tester.AssertResponseCode(r, http.StatusOK) defer resp.Body.Close() bytes, err := io.ReadAll(resp.Body) - if err != nil { - panic(err) - } + assert.NoError(t, err) return string(bytes) } diff --git a/caddy/caddy.go b/caddy/caddy.go index f3c7b9b61..08ae25679 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -131,12 +131,12 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { return d.ArgErr() } - v, err := strconv.Atoi(d.Val()) + v, err := strconv.ParseUint(d.Val(), 10, 32) if err != nil { return err } - f.MaxThreads = v + f.MaxThreads = int(v) case "worker": wc := workerConfig{} if d.NextArg() { diff --git a/docs/worker.md b/docs/worker.md index 316e00d9c..5935bfac0 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -128,7 +128,7 @@ A workaround to using this type of code in worker mode is to restart the worker The previous worker snippet allows configuring a maximum number of request to handle by setting an environment variable named `MAX_REQUESTS`. -### Restart Workers manually +### Restart Workers Manually While it's possible to restart workers [on file changes](config.md#watching-for-file-changes), it's also possible to restart all workers gracefully via the [Caddy admin API](https://caddyserver.com/docs/api). If the admin is enabled in your From 601a43a043b21ed0e7447fe87ec722c6b9b2c541 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 26 Dec 2024 23:26:01 +0100 Subject: [PATCH 118/190] Adds note. --- scaling.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scaling.go b/scaling.go index f0d36cbca..452ab3556 100644 --- a/scaling.go +++ b/scaling.go @@ -239,7 +239,8 @@ func downScaleThreads() { func probeCPUs(probeTime time.Duration) bool { var start, end, cpuStart, cpuEnd C.struct_timespec - // TODO: validate cross-platform compatibility + // note: clock_gettime is a POSIX function + // on Windows we'd need to use QueryPerformanceCounter instead C.clock_gettime(C.CLOCK_MONOTONIC, &start) C.clock_gettime(C.CLOCK_PROCESS_CPUTIME_ID, &cpuStart) From 971e1dcf587571fc561ca6e3f0e1fb1df0b22db6 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 26 Dec 2024 23:29:21 +0100 Subject: [PATCH 119/190] Formatting. --- scaling.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/scaling.go b/scaling.go index 452ab3556..92ce8612a 100644 --- a/scaling.go +++ b/scaling.go @@ -14,7 +14,7 @@ import ( // TODO: make speed of scaling dependant on CPU count? const ( - // scale threads if requests stall this amount of time + // scale threads if requestAs stall this amount of time allowedStallTime = 10 * time.Millisecond // time to check for CPU usage before scaling a single thread cpuProbeTime = 40 * time.Millisecond @@ -57,6 +57,7 @@ func addRegularThread() (*phpThread, error) { return thread, nil } +// remove the last regular thread func RemoveRegularThread() (int, error) { scalingMu.Lock() defer scalingMu.Unlock() @@ -64,7 +65,6 @@ func RemoveRegularThread() (int, error) { return countRegularThreads(), err } -// remove the last regular thread func removeRegularThread() error { regularThreadMu.RLock() if len(regularThreads) <= 1 { @@ -77,6 +77,7 @@ func removeRegularThread() error { return nil } +// turn the first inactive/reserved thread into a worker thread func AddWorkerThread(workerFileName string) (int, error) { worker, ok := workers[workerFileName] if !ok { @@ -88,7 +89,6 @@ func AddWorkerThread(workerFileName string) (int, error) { return worker.countThreads(), err } -// turn the first inactive/reserved thread into a worker thread func addWorkerThread(worker *worker) (*phpThread, error) { thread := getInactivePHPThread() if thread == nil { @@ -99,6 +99,7 @@ func addWorkerThread(worker *worker) (*phpThread, error) { return thread, nil } +// remove the last worker thread func RemoveWorkerThread(workerFileName string) (int, error) { worker, ok := workers[workerFileName] if !ok { @@ -111,7 +112,6 @@ func RemoveWorkerThread(workerFileName string) (int, error) { return worker.countThreads(), err } -// remove the last worker thread func removeWorkerThread(worker *worker) error { worker.threadMutex.RLock() if len(worker.threads) <= 1 { @@ -157,7 +157,6 @@ func drainAutoScaling() { func autoscaleWorkerThreads(worker *worker) { scalingMu.Lock() defer scalingMu.Unlock() - defer blockAutoScaling.Store(false) // TODO: is there an easy way to check if we are reaching memory limits? From f4f95762c93051b49759cd78fe86693b9e1d3076 Mon Sep 17 00:00:00 2001 From: Rob Landers Date: Sun, 29 Dec 2024 16:37:29 +0100 Subject: [PATCH 120/190] suggestion: Refactor scaling strategy (#1289) * output the max threads * add metrics to track queue depth * remove per-thread channels * add some guards around scaling if there is nothing in the queue --- frankenphp.go | 2 +- metrics.go | 108 +++++++++++++++++++++++++++++++++++++++++++--- phpthread.go | 2 - scaling.go | 12 +++++- thread-regular.go | 67 +++++++++------------------- thread-worker.go | 1 - worker.go | 68 ++++++++++------------------- 7 files changed, 156 insertions(+), 104 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index e240835b5..8927cf7d2 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -353,7 +353,7 @@ func Init(options ...Option) error { initAutoScaling(totalThreadCount, maxThreadCount) if c := logger.Check(zapcore.InfoLevel, "FrankenPHP started 🐘"); c != nil { - c.Write(zap.String("php_version", Version().Version), zap.Int("num_threads", totalThreadCount)) + c.Write(zap.String("php_version", Version().Version), zap.Int("num_threads", totalThreadCount), zap.Int("max_threads", maxThreadCount)) } if EmbeddedAppPath != "" { if c := logger.Check(zapcore.InfoLevel, "embedded PHP app 📦"); c != nil { diff --git a/metrics.go b/metrics.go index 467e1a5dc..0d87cb9ca 100644 --- a/metrics.go +++ b/metrics.go @@ -4,6 +4,7 @@ import ( "github.com/dunglas/frankenphp/internal/fastabs" "regexp" "sync" + "sync/atomic" "time" "github.com/prometheus/client_golang/prometheus" @@ -40,6 +41,12 @@ type Metrics interface { // StartWorkerRequest collects started worker requests StartWorkerRequest(name string) Shutdown() + QueuedWorkerRequest(name string) + DequeuedWorkerRequest(name string) + GetWorkerQueueDepth(name string) int + QueuedRequest() + DequeuedRequest() + GetQueueDepth() int } type nullMetrics struct{} @@ -74,6 +81,20 @@ func (n nullMetrics) StartWorkerRequest(string) { func (n nullMetrics) Shutdown() { } +func (n nullMetrics) QueuedWorkerRequest(name string) {} + +func (n nullMetrics) DequeuedWorkerRequest(name string) {} + +func (n nullMetrics) GetWorkerQueueDepth(name string) int { + return 0 +} + +func (n nullMetrics) QueuedRequest() {} +func (n nullMetrics) DequeuedRequest() {} +func (n nullMetrics) GetQueueDepth() int { + return 0 +} + type PrometheusMetrics struct { registry prometheus.Registerer totalThreads prometheus.Counter @@ -85,7 +106,13 @@ type PrometheusMetrics struct { workerRestarts map[string]prometheus.Counter workerRequestTime map[string]prometheus.Counter workerRequestCount map[string]prometheus.Counter + workerQueueDepth map[string]prometheus.Gauge + queueDepth prometheus.Gauge mu sync.Mutex + + // todo: use actual metrics? + actualWorkerQueueDepth map[string]*atomic.Int32 + actualQueueDepth atomic.Int32 } func (m *PrometheusMetrics) StartWorker(name string) { @@ -213,6 +240,16 @@ func (m *PrometheusMetrics) TotalWorkers(name string, _ int) { }) m.registry.MustRegister(m.workerRequestCount[identity]) } + + if _, ok := m.workerQueueDepth[identity]; !ok { + m.workerQueueDepth[identity] = prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: "frankenphp", + Subsystem: subsystem, + Name: "worker_queue_depth", + }) + m.registry.MustRegister(m.workerQueueDepth[identity]) + m.actualWorkerQueueDepth[identity] = &atomic.Int32{} + } } func (m *PrometheusMetrics) TotalThreads(num int) { @@ -244,9 +281,48 @@ func (m *PrometheusMetrics) StartWorkerRequest(name string) { m.busyWorkers[name].Inc() } +func (m *PrometheusMetrics) QueuedWorkerRequest(name string) { + if _, ok := m.workerQueueDepth[name]; !ok { + return + } + m.workerQueueDepth[name].Inc() + m.actualWorkerQueueDepth[name].Add(1) +} + +func (m *PrometheusMetrics) DequeuedWorkerRequest(name string) { + if _, ok := m.workerQueueDepth[name]; !ok { + return + } + m.workerQueueDepth[name].Dec() + m.actualWorkerQueueDepth[name].Add(-1) +} + +func (m *PrometheusMetrics) GetWorkerQueueDepth(name string) int { + if _, ok := m.workerQueueDepth[name]; !ok { + return 0 + } + + return int(m.actualWorkerQueueDepth[name].Load()) +} + +func (m *PrometheusMetrics) QueuedRequest() { + m.queueDepth.Inc() + m.actualQueueDepth.Add(1) +} + +func (m *PrometheusMetrics) DequeuedRequest() { + m.queueDepth.Dec() + m.actualQueueDepth.Add(-1) +} + +func (m *PrometheusMetrics) GetQueueDepth() int { + return int(m.actualQueueDepth.Load()) +} + func (m *PrometheusMetrics) Shutdown() { m.registry.Unregister(m.totalThreads) m.registry.Unregister(m.busyThreads) + m.registry.Unregister(m.queueDepth) for _, g := range m.totalWorkers { m.registry.Unregister(g) @@ -276,6 +352,10 @@ func (m *PrometheusMetrics) Shutdown() { m.registry.Unregister(g) } + for _, g := range m.workerQueueDepth { + m.registry.Unregister(g) + } + m.totalThreads = prometheus.NewCounter(prometheus.CounterOpts{ Name: "frankenphp_total_threads", Help: "Total number of PHP threads", @@ -291,9 +371,16 @@ func (m *PrometheusMetrics) Shutdown() { m.workerRestarts = map[string]prometheus.Counter{} m.workerCrashes = map[string]prometheus.Counter{} m.readyWorkers = map[string]prometheus.Gauge{} + m.actualWorkerQueueDepth = map[string]*atomic.Int32{} + m.workerQueueDepth = map[string]prometheus.Gauge{} + m.queueDepth = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "frankenphp_queue_depth", + Help: "Number of regular queued requests", + }) m.registry.MustRegister(m.totalThreads) m.registry.MustRegister(m.busyThreads) + m.registry.MustRegister(m.queueDepth) } func getWorkerNameForMetrics(name string) string { @@ -318,17 +405,24 @@ func NewPrometheusMetrics(registry prometheus.Registerer) *PrometheusMetrics { Name: "frankenphp_busy_threads", Help: "Number of busy PHP threads", }), - totalWorkers: map[string]prometheus.Gauge{}, - busyWorkers: map[string]prometheus.Gauge{}, - workerRequestTime: map[string]prometheus.Counter{}, - workerRequestCount: map[string]prometheus.Counter{}, - workerRestarts: map[string]prometheus.Counter{}, - workerCrashes: map[string]prometheus.Counter{}, - readyWorkers: map[string]prometheus.Gauge{}, + totalWorkers: map[string]prometheus.Gauge{}, + busyWorkers: map[string]prometheus.Gauge{}, + workerRequestTime: map[string]prometheus.Counter{}, + workerRequestCount: map[string]prometheus.Counter{}, + workerRestarts: map[string]prometheus.Counter{}, + workerCrashes: map[string]prometheus.Counter{}, + readyWorkers: map[string]prometheus.Gauge{}, + workerQueueDepth: map[string]prometheus.Gauge{}, + actualWorkerQueueDepth: map[string]*atomic.Int32{}, + queueDepth: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "frankenphp_queue_depth", + Help: "Number of regular queued requests", + }), } m.registry.MustRegister(m.totalThreads) m.registry.MustRegister(m.busyThreads) + m.registry.MustRegister(m.queueDepth) return m } diff --git a/phpthread.go b/phpthread.go index 3dda8aa2b..534a5eb30 100644 --- a/phpthread.go +++ b/phpthread.go @@ -19,7 +19,6 @@ type phpThread struct { runtime.Pinner threadIndex int knownVariableKeys map[string]*C.zend_string - requestChan chan *http.Request drainChan chan struct{} handlerMu *sync.Mutex handler threadHandler @@ -37,7 +36,6 @@ type threadHandler interface { func newPHPThread(threadIndex int) *phpThread { return &phpThread{ threadIndex: threadIndex, - requestChan: make(chan *http.Request), handlerMu: &sync.Mutex{}, state: newThreadState(), } diff --git a/scaling.go b/scaling.go index f0d36cbca..c694289ed 100644 --- a/scaling.go +++ b/scaling.go @@ -157,7 +157,6 @@ func drainAutoScaling() { func autoscaleWorkerThreads(worker *worker) { scalingMu.Lock() defer scalingMu.Unlock() - defer blockAutoScaling.Store(false) // TODO: is there an easy way to check if we are reaching memory limits? @@ -166,6 +165,12 @@ func autoscaleWorkerThreads(worker *worker) { return } + depth := metrics.GetWorkerQueueDepth(worker.fileName) + + if depth <= 0 { + return + } + thread, err := addWorkerThread(worker) if err != nil { logger.Info("could not increase the amount of threads handling requests", zap.String("worker", worker.fileName), zap.Error(err)) @@ -185,6 +190,11 @@ func autoscaleRegularThreads() { return } + depth := metrics.GetQueueDepth() + if depth <= 0 { + return + } + thread, err := addRegularThread() if err != nil { logger.Info("could not increase the amount of threads handling requests", zap.Error(err)) diff --git a/thread-regular.go b/thread-regular.go index 5477f8d34..404bb1b20 100644 --- a/thread-regular.go +++ b/thread-regular.go @@ -3,7 +3,6 @@ package frankenphp import ( "net/http" "sync" - "time" ) // representation of a non-worker PHP thread @@ -69,8 +68,6 @@ func (handler *regularThread) waitForRequest() string { case <-handler.thread.drainChan: // go back to beforeScriptExecution return handler.beforeScriptExecution() - - case r = <-handler.thread.requestChan: case r = <-regularRequestChan: } @@ -99,52 +96,28 @@ func (handler *regularThread) afterRequest(exitStatus int) { func handleRequestWithRegularPHPThreads(r *http.Request, fc *FrankenPHPContext) { metrics.StartRequest() - regularThreadMu.RLock() - - // dispatch to all threads in order - for _, thread := range regularThreads { - select { - case thread.requestChan <- r: - regularThreadMu.RUnlock() - <-fc.done - metrics.StopRequest() - return - default: - // thread is busy, continue - } - } - regularThreadMu.RUnlock() // if no thread was available, fan the request out to all threads - // if a request has waited for too long, trigger autoscaling - - timeout := allowedStallTime - timer := time.NewTimer(timeout) - - for { - select { - case regularRequestChan <- r: - // a thread was available to handle the request after all - timer.Stop() - <-fc.done - metrics.StopRequest() - return - case <-timer.C: - // reaching here means we might not have spawned enough threads - if blockAutoScaling.CompareAndSwap(false, true) { - go func() { - autoscaleRegularThreads() - blockAutoScaling.Store(false) - }() - } - - // TODO: reject a request that has been waiting for too long (504) - // TODO: limit the amount of stalled requests (maybe) (503) - - // re-trigger autoscaling with an exponential backoff - timeout *= 2 - timer.Reset(timeout) - } + + select { + case regularRequestChan <- r: + // a thread was available to handle the request after all + <-fc.done + metrics.StopRequest() + return + default: + // there is no thread available to handle the request + metrics.QueuedRequest() + go autoscaleRegularThreads() + + // block until the request is handled + regularRequestChan <- r + <-fc.done + metrics.StopRequest() + + // success! + metrics.DequeuedRequest() + return } } diff --git a/thread-worker.go b/thread-worker.go index 0d00dd1c5..0ccb64eb3 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -170,7 +170,6 @@ func (handler *workerThread) waitForWorkerRequest() bool { } return false - case r = <-handler.thread.requestChan: case r = <-handler.worker.requestChan: } diff --git a/worker.go b/worker.go index 785817c9a..d27cd551d 100644 --- a/worker.go +++ b/worker.go @@ -166,54 +166,32 @@ func (worker *worker) countThreads() int { return l } +func (worker *worker) makeScalingDecision() { + autoscaleWorkerThreads(worker) +} + func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { metrics.StartWorkerRequest(fc.scriptFilename) - // dispatch requests to all worker threads in order - worker.threadMutex.RLock() - for _, thread := range worker.threads { - select { - case thread.requestChan <- r: - worker.threadMutex.RUnlock() - <-fc.done - metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) - return - default: - // thread is busy, continue - } - } - worker.threadMutex.RUnlock() - // if no thread was available, fan the request out to all threads - // if a request has waited for too long, trigger autoscaling - - timeout := allowedStallTime - timer := time.NewTimer(timeout) - - for { - select { - case worker.requestChan <- r: - // a worker was available to handle the request after all - timer.Stop() - <-fc.done - metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) - return - case <-timer.C: - // reaching here means we might not have spawned enough threads - if blockAutoScaling.CompareAndSwap(false, true) { - go func() { - autoscaleWorkerThreads(worker) - blockAutoScaling.Store(false) - }() - } - - // TODO: reject a request that has been waiting for too long (504) - // TODO: limit the amount of stalled requests (maybe) (503) - - // re-trigger autoscaling with an exponential backoff - timeout *= 2 - timer.Reset(timeout) - } + select { + case worker.requestChan <- r: + // a worker was available to handle the request after all + <-fc.done + metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) + return + default: + // there is no worker to handle the request + metrics.QueuedWorkerRequest(fc.scriptFilename) + go worker.makeScalingDecision() + + // block until we have a worker to handle the request + worker.requestChan <- r + <-fc.done + metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) + + // success! + metrics.DequeuedWorkerRequest(fc.scriptFilename) + return } - } From 5282e3202bd651e76aac29878ee2bb1387b8b38a Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 29 Dec 2024 18:39:23 +0100 Subject: [PATCH 121/190] Resets strategy and fixes tests. --- metrics.go | 49 ++++++------------------------------ metrics_test.go | 1 + phpthread.go | 1 + scaling.go | 33 ++++++++++++++---------- thread-regular.go | 47 ++++++++++++++++++++++------------ worker.go | 64 +++++++++++++++++++++++++++++------------------ 6 files changed, 100 insertions(+), 95 deletions(-) diff --git a/metrics.go b/metrics.go index 0d87cb9ca..946383ae1 100644 --- a/metrics.go +++ b/metrics.go @@ -4,7 +4,6 @@ import ( "github.com/dunglas/frankenphp/internal/fastabs" "regexp" "sync" - "sync/atomic" "time" "github.com/prometheus/client_golang/prometheus" @@ -43,10 +42,8 @@ type Metrics interface { Shutdown() QueuedWorkerRequest(name string) DequeuedWorkerRequest(name string) - GetWorkerQueueDepth(name string) int QueuedRequest() DequeuedRequest() - GetQueueDepth() int } type nullMetrics struct{} @@ -85,15 +82,8 @@ func (n nullMetrics) QueuedWorkerRequest(name string) {} func (n nullMetrics) DequeuedWorkerRequest(name string) {} -func (n nullMetrics) GetWorkerQueueDepth(name string) int { - return 0 -} - func (n nullMetrics) QueuedRequest() {} func (n nullMetrics) DequeuedRequest() {} -func (n nullMetrics) GetQueueDepth() int { - return 0 -} type PrometheusMetrics struct { registry prometheus.Registerer @@ -109,10 +99,6 @@ type PrometheusMetrics struct { workerQueueDepth map[string]prometheus.Gauge queueDepth prometheus.Gauge mu sync.Mutex - - // todo: use actual metrics? - actualWorkerQueueDepth map[string]*atomic.Int32 - actualQueueDepth atomic.Int32 } func (m *PrometheusMetrics) StartWorker(name string) { @@ -248,7 +234,6 @@ func (m *PrometheusMetrics) TotalWorkers(name string, _ int) { Name: "worker_queue_depth", }) m.registry.MustRegister(m.workerQueueDepth[identity]) - m.actualWorkerQueueDepth[identity] = &atomic.Int32{} } } @@ -286,7 +271,6 @@ func (m *PrometheusMetrics) QueuedWorkerRequest(name string) { return } m.workerQueueDepth[name].Inc() - m.actualWorkerQueueDepth[name].Add(1) } func (m *PrometheusMetrics) DequeuedWorkerRequest(name string) { @@ -294,29 +278,14 @@ func (m *PrometheusMetrics) DequeuedWorkerRequest(name string) { return } m.workerQueueDepth[name].Dec() - m.actualWorkerQueueDepth[name].Add(-1) -} - -func (m *PrometheusMetrics) GetWorkerQueueDepth(name string) int { - if _, ok := m.workerQueueDepth[name]; !ok { - return 0 - } - - return int(m.actualWorkerQueueDepth[name].Load()) } func (m *PrometheusMetrics) QueuedRequest() { m.queueDepth.Inc() - m.actualQueueDepth.Add(1) } func (m *PrometheusMetrics) DequeuedRequest() { m.queueDepth.Dec() - m.actualQueueDepth.Add(-1) -} - -func (m *PrometheusMetrics) GetQueueDepth() int { - return int(m.actualQueueDepth.Load()) } func (m *PrometheusMetrics) Shutdown() { @@ -371,7 +340,6 @@ func (m *PrometheusMetrics) Shutdown() { m.workerRestarts = map[string]prometheus.Counter{} m.workerCrashes = map[string]prometheus.Counter{} m.readyWorkers = map[string]prometheus.Gauge{} - m.actualWorkerQueueDepth = map[string]*atomic.Int32{} m.workerQueueDepth = map[string]prometheus.Gauge{} m.queueDepth = prometheus.NewGauge(prometheus.GaugeOpts{ Name: "frankenphp_queue_depth", @@ -405,15 +373,14 @@ func NewPrometheusMetrics(registry prometheus.Registerer) *PrometheusMetrics { Name: "frankenphp_busy_threads", Help: "Number of busy PHP threads", }), - totalWorkers: map[string]prometheus.Gauge{}, - busyWorkers: map[string]prometheus.Gauge{}, - workerRequestTime: map[string]prometheus.Counter{}, - workerRequestCount: map[string]prometheus.Counter{}, - workerRestarts: map[string]prometheus.Counter{}, - workerCrashes: map[string]prometheus.Counter{}, - readyWorkers: map[string]prometheus.Gauge{}, - workerQueueDepth: map[string]prometheus.Gauge{}, - actualWorkerQueueDepth: map[string]*atomic.Int32{}, + totalWorkers: map[string]prometheus.Gauge{}, + busyWorkers: map[string]prometheus.Gauge{}, + workerRequestTime: map[string]prometheus.Counter{}, + workerRequestCount: map[string]prometheus.Counter{}, + workerRestarts: map[string]prometheus.Counter{}, + workerCrashes: map[string]prometheus.Counter{}, + readyWorkers: map[string]prometheus.Gauge{}, + workerQueueDepth: map[string]prometheus.Gauge{}, queueDepth: prometheus.NewGauge(prometheus.GaugeOpts{ Name: "frankenphp_queue_depth", Help: "Number of regular queued requests", diff --git a/metrics_test.go b/metrics_test.go index 052226993..a35e0576c 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -40,6 +40,7 @@ func createPrometheusMetrics() *PrometheusMetrics { workerRequestCount: make(map[string]prometheus.Counter), workerCrashes: make(map[string]prometheus.Counter), workerRestarts: make(map[string]prometheus.Counter), + workerQueueDepth: make(map[string]prometheus.Gauge), readyWorkers: make(map[string]prometheus.Gauge), mu: sync.Mutex{}, } diff --git a/phpthread.go b/phpthread.go index 534a5eb30..f2fc23449 100644 --- a/phpthread.go +++ b/phpthread.go @@ -19,6 +19,7 @@ type phpThread struct { runtime.Pinner threadIndex int knownVariableKeys map[string]*C.zend_string + requestChan chan *http.Request drainChan chan struct{} handlerMu *sync.Mutex handler threadHandler diff --git a/scaling.go b/scaling.go index 81cb4cf62..d54c83e7b 100644 --- a/scaling.go +++ b/scaling.go @@ -14,8 +14,8 @@ import ( // TODO: make speed of scaling dependant on CPU count? const ( - // scale threads if requestAs stall this amount of time - allowedStallTime = 10 * time.Millisecond + // scale threads if requests stall this amount of time + allowedStallTime = 5 * time.Millisecond // time to check for CPU usage before scaling a single thread cpuProbeTime = 40 * time.Millisecond // do not scale over this amount of CPU usage @@ -153,6 +153,15 @@ func drainAutoScaling() { scalingMu.Unlock() } +func requestNewWorkerThread(worker *worker) { + if blockAutoScaling.CompareAndSwap(false, true) { + go func() { + autoscaleWorkerThreads(worker) + blockAutoScaling.Store(false) + }() + } +} + // Add worker PHP threads automatically func autoscaleWorkerThreads(worker *worker) { scalingMu.Lock() @@ -165,12 +174,6 @@ func autoscaleWorkerThreads(worker *worker) { return } - depth := metrics.GetWorkerQueueDepth(worker.fileName) - - if depth <= 0 { - return - } - thread, err := addWorkerThread(worker) if err != nil { logger.Info("could not increase the amount of threads handling requests", zap.String("worker", worker.fileName), zap.Error(err)) @@ -180,6 +183,15 @@ func autoscaleWorkerThreads(worker *worker) { autoScaledThreads = append(autoScaledThreads, thread) } +func requestNewRegularThread() { + if blockAutoScaling.CompareAndSwap(false, true) { + go func() { + autoscaleRegularThreads() + blockAutoScaling.Store(false) + }() + } +} + // Add regular PHP threads automatically func autoscaleRegularThreads() { scalingMu.Lock() @@ -190,11 +202,6 @@ func autoscaleRegularThreads() { return } - depth := metrics.GetQueueDepth() - if depth <= 0 { - return - } - thread, err := addRegularThread() if err != nil { logger.Info("could not increase the amount of threads handling requests", zap.Error(err)) diff --git a/thread-regular.go b/thread-regular.go index 404bb1b20..1e01abfe7 100644 --- a/thread-regular.go +++ b/thread-regular.go @@ -3,6 +3,7 @@ package frankenphp import ( "net/http" "sync" + "time" ) // representation of a non-worker PHP thread @@ -96,30 +97,44 @@ func (handler *regularThread) afterRequest(exitStatus int) { func handleRequestWithRegularPHPThreads(r *http.Request, fc *FrankenPHPContext) { metrics.StartRequest() - - // if no thread was available, fan the request out to all threads - select { case regularRequestChan <- r: - // a thread was available to handle the request after all + // a thread was available to handle the request immediately <-fc.done metrics.StopRequest() return default: - // there is no thread available to handle the request - metrics.QueuedRequest() - go autoscaleRegularThreads() - - // block until the request is handled - regularRequestChan <- r - <-fc.done - metrics.StopRequest() - - // success! - metrics.DequeuedRequest() - return + // no thread was available } + // if no thread was available, fan the request out to all threads + // if a request has waited for too long, trigger autoscaling + + timeout := allowedStallTime + timer := time.NewTimer(timeout) + metrics.QueuedRequest() + + for { + select { + case regularRequestChan <- r: + // a thread was available to handle the request after all + timer.Stop() + metrics.DequeuedRequest() + <-fc.done + metrics.StopRequest() + return + case <-timer.C: + // reaching here means we might not have spawned enough threads + requestNewRegularThread() + + // TODO: reject a request that has been waiting for too long (504) + // TODO: limit the amount of stalled requests (maybe) (503) + + // re-trigger autoscaling with an exponential backoff + timeout *= 2 + timer.Reset(timeout) + } + } } func attachRegularThread(thread *phpThread) { diff --git a/worker.go b/worker.go index d27cd551d..3da5f62d4 100644 --- a/worker.go +++ b/worker.go @@ -10,7 +10,6 @@ import ( "time" "github.com/dunglas/frankenphp/internal/watcher" - //"go.uber.org/zap" ) // represents a worker script and can have many threads assigned to it @@ -166,32 +165,47 @@ func (worker *worker) countThreads() int { return l } -func (worker *worker) makeScalingDecision() { - autoscaleWorkerThreads(worker) -} - func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { metrics.StartWorkerRequest(fc.scriptFilename) - // if no thread was available, fan the request out to all threads - select { - case worker.requestChan <- r: - // a worker was available to handle the request after all - <-fc.done - metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) - return - default: - // there is no worker to handle the request - metrics.QueuedWorkerRequest(fc.scriptFilename) - go worker.makeScalingDecision() - - // block until we have a worker to handle the request - worker.requestChan <- r - <-fc.done - metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) - - // success! - metrics.DequeuedWorkerRequest(fc.scriptFilename) - return + // dispatch requests to all worker threads in order + worker.threadMutex.RLock() + for _, thread := range worker.threads { + select { + case thread.requestChan <- r: + worker.threadMutex.RUnlock() + <-fc.done + metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) + return + default: + // thread is busy, continue + } + } + worker.threadMutex.RUnlock() + + metrics.QueuedWorkerRequest(fc.scriptFilename) + timeout := allowedStallTime + timer := time.NewTimer(timeout) + + for { + select { + case worker.requestChan <- r: + // a worker was available to handle the request after all + timer.Stop() + metrics.DequeuedWorkerRequest(fc.scriptFilename) + <-fc.done + metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) + return + case <-timer.C: + // reaching here means we might not have spawned enough threads + requestNewWorkerThread(worker) + + // TODO: reject a request that has been waiting for too long (504) + // TODO: limit the amount of stalled requests (maybe) (503) + + // re-trigger autoscaling with an exponential backoff + timeout *= 2 + timer.Reset(timeout) + } } } From 83d8c11fefccd59918974a3c79849328b84715fe Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 29 Dec 2024 23:48:00 +0100 Subject: [PATCH 122/190] Adds scaling strategies. --- caddy/admin_test.go | 4 +- caddy/caddy.go | 18 ++++- frankenphp.go | 2 +- options.go | 21 ++++-- phpthread.go | 1 + scaling.go | 156 ++++++++++++++++++++++++++------------------ scaling_test.go | 35 ++++++++-- thread-regular.go | 33 ++-------- thread-worker.go | 1 + worker.go | 28 ++------ 10 files changed, 171 insertions(+), 128 deletions(-) diff --git a/caddy/admin_test.go b/caddy/admin_test.go index d03111f45..619de4375 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -180,7 +180,7 @@ func TestShowTheCorrectThreadDebugStatus(t *testing.T) { func TestAutoScaleWorkerThreads(t *testing.T) { wg := sync.WaitGroup{} - maxTries := 100 + maxTries := 10 requestsPerTry := 200 tester := caddytest.NewTester(t) tester.InitServer(` @@ -235,7 +235,7 @@ func TestAutoScaleWorkerThreads(t *testing.T) { func TestAutoScaleRegularThreads(t *testing.T) { wg := sync.WaitGroup{} - maxTries := 100 + maxTries := 10 requestsPerTry := 200 tester := caddytest.NewTester(t) tester.InitServer(` diff --git a/caddy/caddy.go b/caddy/caddy.go index 08ae25679..c361896c7 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -59,6 +59,8 @@ type FrankenPHPApp struct { NumThreads int `json:"num_threads,omitempty"` // MaxThreads limits how many threads can be started at runtime. Default 2x NumThreads MaxThreads int `json:"max_threads,omitempty"` + // Scaling sets the scaling mode. Default: "on". + Scaling frankenphp.ScalingStrategy `json:"scaling,omitempty"` // Workers configures the worker scripts to start. Workers []workerConfig `json:"workers,omitempty"` } @@ -77,7 +79,7 @@ func (f *FrankenPHPApp) Start() error { opts := []frankenphp.Option{ frankenphp.WithNumThreads(f.NumThreads), - frankenphp.WithMaxThreads(f.MaxThreads), + frankenphp.WithMaxThreads(f.MaxThreads, f.Scaling), frankenphp.WithLogger(logger), frankenphp.WithMetrics(metrics), } @@ -137,6 +139,18 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } f.MaxThreads = int(v) + case "scaling": + if !d.NextArg() { + return d.ArgErr() + } + switch d.Val() { + case "on": + f.Scaling = frankenphp.ScalingStrategyNormal + case "off": + f.Scaling = frankenphp.ScalingStrategyNone + default: + return d.Errf("unknown scaling mode: %s", d.Val()) + } case "worker": wc := workerConfig{} if d.NextArg() { @@ -207,7 +221,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } func parseGlobalOption(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) { - app := &FrankenPHPApp{} + app := &FrankenPHPApp{Scaling: frankenphp.ScalingStrategyNormal} if err := app.UnmarshalCaddyfile(d); err != nil { return nil, err } diff --git a/frankenphp.go b/frankenphp.go index 8927cf7d2..31823798e 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -350,7 +350,7 @@ func Init(options ...Option) error { return err } - initAutoScaling(totalThreadCount, maxThreadCount) + initAutoScaling(totalThreadCount, maxThreadCount, opt.scalingStrategy) if c := logger.Check(zapcore.InfoLevel, "FrankenPHP started 🐘"); c != nil { c.Write(zap.String("php_version", Version().Version), zap.Int("num_threads", totalThreadCount), zap.Int("max_threads", maxThreadCount)) diff --git a/options.go b/options.go index 7795399be..9dd8933d9 100644 --- a/options.go +++ b/options.go @@ -11,11 +11,12 @@ type Option func(h *opt) error // // If you change this, also update the Caddy module and the documentation. type opt struct { - numThreads int - maxThreads int - workers []workerOpt - logger *zap.Logger - metrics Metrics + numThreads int + maxThreads int + scalingStrategy ScalingStrategy + workers []workerOpt + logger *zap.Logger + metrics Metrics } type workerOpt struct { @@ -25,6 +26,13 @@ type workerOpt struct { watch []string } +type ScalingStrategy uint8 + +const ( + ScalingStrategyNormal ScalingStrategy = iota + ScalingStrategyNone +) + // WithNumThreads configures the number of PHP threads to start. func WithNumThreads(numThreads int) Option { return func(o *opt) error { @@ -34,9 +42,10 @@ func WithNumThreads(numThreads int) Option { } } -func WithMaxThreads(maxThreads int) Option { +func WithMaxThreads(maxThreads int, s ScalingStrategy) Option { return func(o *opt) error { o.maxThreads = maxThreads + o.scalingStrategy = s return nil } diff --git a/phpthread.go b/phpthread.go index f2fc23449..3dda8aa2b 100644 --- a/phpthread.go +++ b/phpthread.go @@ -37,6 +37,7 @@ type threadHandler interface { func newPHPThread(threadIndex int) *phpThread { return &phpThread{ threadIndex: threadIndex, + requestChan: make(chan *http.Request), handlerMu: &sync.Mutex{}, state: newThreadState(), } diff --git a/scaling.go b/scaling.go index d54c83e7b..df0eff43b 100644 --- a/scaling.go +++ b/scaling.go @@ -4,6 +4,7 @@ package frankenphp import "C" import ( "errors" + "net/http" "runtime" "sync" "sync/atomic" @@ -14,8 +15,6 @@ import ( // TODO: make speed of scaling dependant on CPU count? const ( - // scale threads if requests stall this amount of time - allowedStallTime = 5 * time.Millisecond // time to check for CPU usage before scaling a single thread cpuProbeTime = 40 * time.Millisecond // do not scale over this amount of CPU usage @@ -28,17 +27,87 @@ const ( maxThreadIdleTime = 5 * time.Second ) +type scalingStrategy interface { + apply(requestChan chan *http.Request, r *http.Request, scaleFunc func()) +} + +type scalingStrategyNormal struct { + minStallTime time.Duration + blockScaling atomic.Bool +} +type scalingStrategyNone struct{} + var ( - autoScaledThreads = []*phpThread{} - scalingMu = new(sync.RWMutex) - blockAutoScaling = atomic.Bool{} - cpuCount = runtime.NumCPU() + activeScalingStrategy scalingStrategy + autoScaledThreads = []*phpThread{} + scalingMu = new(sync.RWMutex) + cpuCount = runtime.NumCPU() MaxThreadsReachedError = errors.New("max amount of overall threads reached") CannotRemoveLastThreadError = errors.New("cannot remove last thread") WorkerNotFoundError = errors.New("worker not found for given filename") ) +// when scaling is disabled, just send the request to the channel +func (s scalingStrategyNone) apply(requestChan chan *http.Request, r *http.Request, scaleFunc func()) { + requestChan <- r +} + +// start a timer that triggers autoscaling +// after triggering autoscaling, double the timer's length +func (s scalingStrategyNormal) apply(requestChan chan *http.Request, r *http.Request, scaleFunc func()) { + timeout := s.minStallTime + timer := time.NewTimer(timeout) + + for { + select { + case requestChan <- r: + timer.Stop() + return + case <-timer.C: + if s.blockScaling.CompareAndSwap(false, true) { + go func() { + scaleFunc() + s.blockScaling.Store(false) + }() + } + timeout *= 2 + timer.Reset(timeout) + } + } +} + +func initAutoScaling(numThreads int, maxThreads int, s ScalingStrategy) { + if maxThreads <= numThreads || s == ScalingStrategyNone { + activeScalingStrategy = scalingStrategyNone{} + return + } + activeScalingStrategy = scalingStrategyNormal{ + minStallTime: 5 * time.Millisecond, + blockScaling: atomic.Bool{}, + } + autoScaledThreads = make([]*phpThread, 0, maxThreads-numThreads) + timer := time.NewTimer(downScaleCheckTime) + doneChan := mainThread.done + go func() { + for { + timer.Reset(downScaleCheckTime) + select { + case <-doneChan: + return + case <-timer.C: + downScaleThreads() + } + } + }() +} + +func drainAutoScaling() { + scalingMu.Lock() + activeScalingStrategy = scalingStrategyNone{} + scalingMu.Unlock() +} + // turn the first inactive/reserved thread into a regular thread func AddRegularThread() (int, error) { scalingMu.Lock() @@ -125,92 +194,45 @@ func removeWorkerThread(worker *worker) error { return nil } -func initAutoScaling(numThreads int, maxThreads int) { - if maxThreads <= numThreads { - blockAutoScaling.Store(true) - return - } - blockAutoScaling.Store(false) - autoScaledThreads = make([]*phpThread, 0, maxThreads-numThreads) - timer := time.NewTimer(downScaleCheckTime) - doneChan := mainThread.done - go func() { - for { - timer.Reset(downScaleCheckTime) - select { - case <-doneChan: - return - case <-timer.C: - downScaleThreads() - } - } - }() -} - -func drainAutoScaling() { - scalingMu.Lock() - blockAutoScaling.Store(true) - scalingMu.Unlock() -} - -func requestNewWorkerThread(worker *worker) { - if blockAutoScaling.CompareAndSwap(false, true) { - go func() { - autoscaleWorkerThreads(worker) - blockAutoScaling.Store(false) - }() - } -} - -// Add worker PHP threads automatically -func autoscaleWorkerThreads(worker *worker) { +// Add a worker PHP threads automatically +func scaleWorkerThreads(worker *worker) { scalingMu.Lock() defer scalingMu.Unlock() // TODO: is there an easy way to check if we are reaching memory limits? - if !probeCPUs(cpuProbeTime) { - logger.Debug("cpu is busy, not autoscaling", zap.String("worker", worker.fileName)) + if !mainThread.state.is(stateReady) || !probeCPUs(cpuProbeTime) { return } thread, err := addWorkerThread(worker) if err != nil { - logger.Info("could not increase the amount of threads handling requests", zap.String("worker", worker.fileName), zap.Error(err)) + logMaxThreadsReachedWarning(zap.String("worker", worker.fileName), zap.Error(err)) return } autoScaledThreads = append(autoScaledThreads, thread) } -func requestNewRegularThread() { - if blockAutoScaling.CompareAndSwap(false, true) { - go func() { - autoscaleRegularThreads() - blockAutoScaling.Store(false) - }() - } -} - -// Add regular PHP threads automatically -func autoscaleRegularThreads() { +// Add a regular PHP thread automatically +func scaleRegularThreads() { scalingMu.Lock() defer scalingMu.Unlock() - if !probeCPUs(cpuProbeTime) { - logger.Debug("cpu is busy, not autoscaling") + if !mainThread.state.is(stateReady) || !probeCPUs(cpuProbeTime) { return } thread, err := addRegularThread() if err != nil { - logger.Info("could not increase the amount of threads handling requests", zap.Error(err)) + logMaxThreadsReachedWarning(zap.Error(err)) return } autoScaledThreads = append(autoScaledThreads, thread) } +// Check all threads and remove those that have been inactive for too long func downScaleThreads() { stoppedThreadCount := 0 scalingMu.Lock() @@ -277,3 +299,13 @@ func probeCPUs(probeTime time.Duration) bool { return cpuUsage < maxCpuUsageForScaling } + +// only log the maximum amount of threads reached warning once per minute +var lastMaxThreadsWarning = time.Time{} + +func logMaxThreadsReachedWarning(zapFields ...zap.Field) { + if lastMaxThreadsWarning.Add(time.Minute).Before(time.Now()) { + logger.Warn("could not increase max_threads, consider raising this limit", zapFields...) + lastMaxThreadsWarning = time.Now() + } +} diff --git a/scaling_test.go b/scaling_test.go index 030f84ebc..2dd4cd856 100644 --- a/scaling_test.go +++ b/scaling_test.go @@ -8,17 +8,44 @@ import ( "go.uber.org/zap" ) +func TestScalingStrategyShouldBeNoneOnLowMaxThreads(t *testing.T) { + doneChan := make(chan struct{}) + initAutoScaling(1, 1, ScalingStrategyNormal) + + assert.IsType(t, scalingStrategyNone{}, activeScalingStrategy) + + close(doneChan) +} + +func TestScalingStrategyShouldBeNormal(t *testing.T) { + doneChan := make(chan struct{}) + initAutoScaling(1, 2, ScalingStrategyNormal) + + assert.IsType(t, scalingStrategyNormal{}, activeScalingStrategy) + + close(doneChan) +} + +func TestScalingStrategyShouldBeNoneWhenExplicitlySetToNone(t *testing.T) { + doneChan := make(chan struct{}) + initAutoScaling(1, 2, ScalingStrategyNone) + + assert.IsType(t, scalingStrategyNone{}, activeScalingStrategy) + + close(doneChan) +} + func TestScaleARegularThreadUpAndDown(t *testing.T) { assert.NoError(t, Init( WithNumThreads(1), - WithMaxThreads(2), + WithMaxThreads(2, ScalingStrategyNormal), WithLogger(zap.NewNop()), )) autoScaledThread := phpThreads[1] // scale up - autoscaleRegularThreads() + scaleRegularThreads() assert.Equal(t, stateReady, autoScaledThread.state.get()) assert.IsType(t, ®ularThread{}, autoScaledThread.handler) @@ -40,7 +67,7 @@ func TestScaleAWorkerThreadUpAndDown(t *testing.T) { workerPath := testDataPath + "/transition-worker-1.php" assert.NoError(t, Init( WithNumThreads(2), - WithMaxThreads(3), + WithMaxThreads(3, ScalingStrategyNormal), WithWorkers(workerPath, 1, map[string]string{}, []string{}), WithLogger(zap.NewNop()), )) @@ -48,7 +75,7 @@ func TestScaleAWorkerThreadUpAndDown(t *testing.T) { autoScaledThread := phpThreads[2] // scale up - autoscaleWorkerThreads(workers[workerPath]) + scaleWorkerThreads(workers[workerPath]) assert.Equal(t, stateReady, autoScaledThread.state.get()) // on the first down-scale, the thread will be marked as inactive diff --git a/thread-regular.go b/thread-regular.go index 1e01abfe7..2e9962330 100644 --- a/thread-regular.go +++ b/thread-regular.go @@ -3,7 +3,6 @@ package frankenphp import ( "net/http" "sync" - "time" ) // representation of a non-worker PHP thread @@ -107,34 +106,12 @@ func handleRequestWithRegularPHPThreads(r *http.Request, fc *FrankenPHPContext) // no thread was available } - // if no thread was available, fan the request out to all threads - // if a request has waited for too long, trigger autoscaling - - timeout := allowedStallTime - timer := time.NewTimer(timeout) + // if no thread was available, mark the request as queued and apply the scaling strategy metrics.QueuedRequest() - - for { - select { - case regularRequestChan <- r: - // a thread was available to handle the request after all - timer.Stop() - metrics.DequeuedRequest() - <-fc.done - metrics.StopRequest() - return - case <-timer.C: - // reaching here means we might not have spawned enough threads - requestNewRegularThread() - - // TODO: reject a request that has been waiting for too long (504) - // TODO: limit the amount of stalled requests (maybe) (503) - - // re-trigger autoscaling with an exponential backoff - timeout *= 2 - timer.Reset(timeout) - } - } + activeScalingStrategy.apply(regularRequestChan, r, scaleRegularThreads) + metrics.DequeuedRequest() + <-fc.done + metrics.StopRequest() } func attachRegularThread(thread *phpThread) { diff --git a/thread-worker.go b/thread-worker.go index 0ccb64eb3..0d00dd1c5 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -170,6 +170,7 @@ func (handler *workerThread) waitForWorkerRequest() bool { } return false + case r = <-handler.thread.requestChan: case r = <-handler.worker.requestChan: } diff --git a/worker.go b/worker.go index 3da5f62d4..c5f6611bb 100644 --- a/worker.go +++ b/worker.go @@ -183,29 +183,11 @@ func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { } worker.threadMutex.RUnlock() + // if no thread was available, mark the request as queued and apply the scaling strategy metrics.QueuedWorkerRequest(fc.scriptFilename) - timeout := allowedStallTime - timer := time.NewTimer(timeout) + activeScalingStrategy.apply(worker.requestChan, r, func() { scaleWorkerThreads(worker) }) + metrics.DequeuedWorkerRequest(fc.scriptFilename) - for { - select { - case worker.requestChan <- r: - // a worker was available to handle the request after all - timer.Stop() - metrics.DequeuedWorkerRequest(fc.scriptFilename) - <-fc.done - metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) - return - case <-timer.C: - // reaching here means we might not have spawned enough threads - requestNewWorkerThread(worker) - - // TODO: reject a request that has been waiting for too long (504) - // TODO: limit the amount of stalled requests (maybe) (503) - - // re-trigger autoscaling with an exponential backoff - timeout *= 2 - timer.Reset(timeout) - } - } + <-fc.done + metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) } From c0946800db8ddc7d9277d25f90a6ebfada8c1b35 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 30 Dec 2024 00:03:57 +0100 Subject: [PATCH 123/190] Fixes test reloading. --- frankenphp.go | 3 ++- scaling.go | 4 ++-- scaling_test.go | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index 31823798e..7aa7f0669 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -371,8 +371,9 @@ func Shutdown() { } drainWorkers() - drainPHPThreads() drainAutoScaling() + drainPHPThreads() + metrics.Shutdown() // Remove the installed app diff --git a/scaling.go b/scaling.go index df0eff43b..621df3093 100644 --- a/scaling.go +++ b/scaling.go @@ -55,7 +55,7 @@ func (s scalingStrategyNone) apply(requestChan chan *http.Request, r *http.Reque // start a timer that triggers autoscaling // after triggering autoscaling, double the timer's length -func (s scalingStrategyNormal) apply(requestChan chan *http.Request, r *http.Request, scaleFunc func()) { +func (s *scalingStrategyNormal) apply(requestChan chan *http.Request, r *http.Request, scaleFunc func()) { timeout := s.minStallTime timer := time.NewTimer(timeout) @@ -82,7 +82,7 @@ func initAutoScaling(numThreads int, maxThreads int, s ScalingStrategy) { activeScalingStrategy = scalingStrategyNone{} return } - activeScalingStrategy = scalingStrategyNormal{ + activeScalingStrategy = &scalingStrategyNormal{ minStallTime: 5 * time.Millisecond, blockScaling: atomic.Bool{}, } diff --git a/scaling_test.go b/scaling_test.go index 2dd4cd856..12ee08535 100644 --- a/scaling_test.go +++ b/scaling_test.go @@ -21,7 +21,7 @@ func TestScalingStrategyShouldBeNormal(t *testing.T) { doneChan := make(chan struct{}) initAutoScaling(1, 2, ScalingStrategyNormal) - assert.IsType(t, scalingStrategyNormal{}, activeScalingStrategy) + assert.IsType(t, &scalingStrategyNormal{}, activeScalingStrategy) close(doneChan) } From 3ba080abd80f91c66ec5cfe2e298a7b7b75e48a2 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 30 Dec 2024 17:34:00 +0100 Subject: [PATCH 124/190] Prevents threads from respawning after shutdown. --- caddy/admin_test.go | 8 ++++---- phpmainthread.go | 15 +++++++++++---- phpthread.go | 4 +++- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/caddy/admin_test.go b/caddy/admin_test.go index 619de4375..541683b7d 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -206,7 +206,7 @@ func TestAutoScaleWorkerThreads(t *testing.T) { `, "caddyfile") // spam an endpoint that simulates IO - endpoint := "http://localhost:" + testPort + "/?sleep=5&work=1000" + endpoint := "http://localhost:" + testPort + "/?sleep=2&work=1000" autoScaledThread := "Thread 2" // first assert that the thread is not already present @@ -218,7 +218,7 @@ func TestAutoScaleWorkerThreads(t *testing.T) { wg.Add(requestsPerTry) for i := 0; i < requestsPerTry; i++ { go func() { - tester.AssertGetResponse(endpoint, http.StatusOK, "slept for 5 ms and worked for 1000 iterations") + tester.AssertGetResponse(endpoint, http.StatusOK, "slept for 2 ms and worked for 1000 iterations") wg.Done() }() } @@ -259,7 +259,7 @@ func TestAutoScaleRegularThreads(t *testing.T) { `, "caddyfile") // spam an endpoint that simulates IO - endpoint := "http://localhost:" + testPort + "/sleep.php?sleep=5&work=1000" + endpoint := "http://localhost:" + testPort + "/sleep.php?sleep=2&work=1000" autoScaledThread := "Thread 1" // first assert that the thread is not already present @@ -271,7 +271,7 @@ func TestAutoScaleRegularThreads(t *testing.T) { wg.Add(requestsPerTry) for i := 0; i < requestsPerTry; i++ { go func() { - tester.AssertGetResponse(endpoint, http.StatusOK, "slept for 5 ms and worked for 1000 iterations") + tester.AssertGetResponse(endpoint, http.StatusOK, "slept for 2 ms and worked for 1000 iterations") wg.Done() }() } diff --git a/phpmainthread.go b/phpmainthread.go index 68b397870..7f1e0efe5 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -74,8 +74,15 @@ func ThreadDebugStatus() string { func drainPHPThreads() { doneWG := sync.WaitGroup{} doneWG.Add(len(phpThreads)) + mainThread.state.set(stateShuttingDown) close(mainThread.done) for _, thread := range phpThreads { + // shut down all reserved threads + if thread.state.compareAndSwap(stateReserved, stateDone) { + doneWG.Done() + continue + } + // shut down all active threads go func(thread *phpThread) { thread.shutdown() doneWG.Done() @@ -83,8 +90,8 @@ func drainPHPThreads() { } doneWG.Wait() - mainThread.state.set(stateShuttingDown) - mainThread.state.waitFor(stateDone) + mainThread.state.set(stateDone) + mainThread.state.waitFor(stateReserved) phpThreads = nil } @@ -121,10 +128,10 @@ func getPHPThreadAtState(state stateID) *phpThread { //export go_frankenphp_main_thread_is_ready func go_frankenphp_main_thread_is_ready() { mainThread.state.set(stateReady) - mainThread.state.waitFor(stateShuttingDown) + mainThread.state.waitFor(stateDone) } //export go_frankenphp_shutdown_main_thread func go_frankenphp_shutdown_main_thread() { - mainThread.state.set(stateDone) + mainThread.state.set(stateReserved) } diff --git a/phpthread.go b/phpthread.go index 3dda8aa2b..96687e87e 100644 --- a/phpthread.go +++ b/phpthread.go @@ -75,7 +75,9 @@ func (thread *phpThread) shutdown() { thread.drainChan = make(chan struct{}) // threads go back to the reserved state from which they can be booted again - thread.state.set(stateReserved) + if mainThread.state.is(stateReady) { + thread.state.set(stateReserved) + } } // change the thread handler safely From 7934b50ab7352d2e0de142208d25e0ba7221f294 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 30 Dec 2024 20:01:51 +0100 Subject: [PATCH 125/190] Adjusts transition tests. --- phpmainthread_test.go | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/phpmainthread_test.go b/phpmainthread_test.go index 26dd25fac..b63780c79 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -157,23 +157,15 @@ func assertRequestBody(t *testing.T, url string, expected string) { assert.Equal(t, expected, string(body)) } -// create all permutations of possible transition between 2 handlers +// create a mix of possible transitions of workers and regular threads func allPossibleTransitions(worker1Path string, worker2Path string) []func(*phpThread) { - transitions := []func(*phpThread){ + return []func(*phpThread){ convertToRegularThread, + func(thread *phpThread) { thread.shutdown() }, + func(thread *phpThread) { thread.boot() }, func(thread *phpThread) { convertToWorkerThread(thread, workers[worker1Path]) }, + convertToInactiveThread, func(thread *phpThread) { convertToWorkerThread(thread, workers[worker2Path]) }, convertToInactiveThread, } - permutations := []func(*phpThread){} - - for i := 0; i < len(transitions); i++ { - for j := 0; j < len(transitions); j++ { - if i != j { - permutations = append(permutations, transitions[i], transitions[j]) - } - } - } - - return permutations } From eb5e76d5d61c8901f2f63503e68b36c3b827762e Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 9 Jan 2025 22:41:41 +0100 Subject: [PATCH 126/190] Simplifies scaling. --- caddy/caddy.go | 18 +----- frankenphp.go | 2 +- options.go | 21 ++----- scaling.go | 144 ++++++++++++++++++++++++---------------------- scaling_test.go | 31 +--------- thread-regular.go | 17 ++++-- worker.go | 18 ++++-- 7 files changed, 111 insertions(+), 140 deletions(-) diff --git a/caddy/caddy.go b/caddy/caddy.go index c361896c7..08ae25679 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -59,8 +59,6 @@ type FrankenPHPApp struct { NumThreads int `json:"num_threads,omitempty"` // MaxThreads limits how many threads can be started at runtime. Default 2x NumThreads MaxThreads int `json:"max_threads,omitempty"` - // Scaling sets the scaling mode. Default: "on". - Scaling frankenphp.ScalingStrategy `json:"scaling,omitempty"` // Workers configures the worker scripts to start. Workers []workerConfig `json:"workers,omitempty"` } @@ -79,7 +77,7 @@ func (f *FrankenPHPApp) Start() error { opts := []frankenphp.Option{ frankenphp.WithNumThreads(f.NumThreads), - frankenphp.WithMaxThreads(f.MaxThreads, f.Scaling), + frankenphp.WithMaxThreads(f.MaxThreads), frankenphp.WithLogger(logger), frankenphp.WithMetrics(metrics), } @@ -139,18 +137,6 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } f.MaxThreads = int(v) - case "scaling": - if !d.NextArg() { - return d.ArgErr() - } - switch d.Val() { - case "on": - f.Scaling = frankenphp.ScalingStrategyNormal - case "off": - f.Scaling = frankenphp.ScalingStrategyNone - default: - return d.Errf("unknown scaling mode: %s", d.Val()) - } case "worker": wc := workerConfig{} if d.NextArg() { @@ -221,7 +207,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } func parseGlobalOption(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) { - app := &FrankenPHPApp{Scaling: frankenphp.ScalingStrategyNormal} + app := &FrankenPHPApp{} if err := app.UnmarshalCaddyfile(d); err != nil { return nil, err } diff --git a/frankenphp.go b/frankenphp.go index 7aa7f0669..963fd0c41 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -350,7 +350,7 @@ func Init(options ...Option) error { return err } - initAutoScaling(totalThreadCount, maxThreadCount, opt.scalingStrategy) + initAutoScaling(totalThreadCount, maxThreadCount) if c := logger.Check(zapcore.InfoLevel, "FrankenPHP started 🐘"); c != nil { c.Write(zap.String("php_version", Version().Version), zap.Int("num_threads", totalThreadCount), zap.Int("max_threads", maxThreadCount)) diff --git a/options.go b/options.go index 9dd8933d9..7795399be 100644 --- a/options.go +++ b/options.go @@ -11,12 +11,11 @@ type Option func(h *opt) error // // If you change this, also update the Caddy module and the documentation. type opt struct { - numThreads int - maxThreads int - scalingStrategy ScalingStrategy - workers []workerOpt - logger *zap.Logger - metrics Metrics + numThreads int + maxThreads int + workers []workerOpt + logger *zap.Logger + metrics Metrics } type workerOpt struct { @@ -26,13 +25,6 @@ type workerOpt struct { watch []string } -type ScalingStrategy uint8 - -const ( - ScalingStrategyNormal ScalingStrategy = iota - ScalingStrategyNone -) - // WithNumThreads configures the number of PHP threads to start. func WithNumThreads(numThreads int) Option { return func(o *opt) error { @@ -42,10 +34,9 @@ func WithNumThreads(numThreads int) Option { } } -func WithMaxThreads(maxThreads int, s ScalingStrategy) Option { +func WithMaxThreads(maxThreads int) Option { return func(o *opt) error { o.maxThreads = maxThreads - o.scalingStrategy = s return nil } diff --git a/scaling.go b/scaling.go index 621df3093..523a14850 100644 --- a/scaling.go +++ b/scaling.go @@ -4,7 +4,6 @@ package frankenphp import "C" import ( "errors" - "net/http" "runtime" "sync" "sync/atomic" @@ -15,10 +14,14 @@ import ( // TODO: make speed of scaling dependant on CPU count? const ( + // requests have to be stalled for at least this amount of time before scaling + minStallTime = 20 * time.Millisecond // time to check for CPU usage before scaling a single thread - cpuProbeTime = 40 * time.Millisecond + cpuProbeTime = 100 * time.Millisecond // do not scale over this amount of CPU usage maxCpuUsageForScaling = 0.8 + // upscale stalled threads every x milliseconds + upscaleCheckTime = 100 * time.Millisecond // downscale idle threads every x seconds downScaleCheckTime = 5 * time.Second // max amount of threads stopped in one iteration of downScaleCheckTime @@ -27,84 +30,31 @@ const ( maxThreadIdleTime = 5 * time.Second ) -type scalingStrategy interface { - apply(requestChan chan *http.Request, r *http.Request, scaleFunc func()) -} - -type scalingStrategyNormal struct { - minStallTime time.Duration - blockScaling atomic.Bool -} -type scalingStrategyNone struct{} - var ( - activeScalingStrategy scalingStrategy - autoScaledThreads = []*phpThread{} - scalingMu = new(sync.RWMutex) - cpuCount = runtime.NumCPU() + autoScaledThreads = []*phpThread{} + scaleChan = make(chan *FrankenPHPContext) + scalingMu = new(sync.RWMutex) + cpuCount = runtime.NumCPU() + disallowScaling = atomic.Bool{} MaxThreadsReachedError = errors.New("max amount of overall threads reached") CannotRemoveLastThreadError = errors.New("cannot remove last thread") WorkerNotFoundError = errors.New("worker not found for given filename") ) -// when scaling is disabled, just send the request to the channel -func (s scalingStrategyNone) apply(requestChan chan *http.Request, r *http.Request, scaleFunc func()) { - requestChan <- r -} - -// start a timer that triggers autoscaling -// after triggering autoscaling, double the timer's length -func (s *scalingStrategyNormal) apply(requestChan chan *http.Request, r *http.Request, scaleFunc func()) { - timeout := s.minStallTime - timer := time.NewTimer(timeout) - - for { - select { - case requestChan <- r: - timer.Stop() - return - case <-timer.C: - if s.blockScaling.CompareAndSwap(false, true) { - go func() { - scaleFunc() - s.blockScaling.Store(false) - }() - } - timeout *= 2 - timer.Reset(timeout) - } - } -} - -func initAutoScaling(numThreads int, maxThreads int, s ScalingStrategy) { - if maxThreads <= numThreads || s == ScalingStrategyNone { - activeScalingStrategy = scalingStrategyNone{} +func initAutoScaling(numThreads int, maxThreads int) { + if maxThreads <= numThreads { return } - activeScalingStrategy = &scalingStrategyNormal{ - minStallTime: 5 * time.Millisecond, - blockScaling: atomic.Bool{}, - } - autoScaledThreads = make([]*phpThread, 0, maxThreads-numThreads) - timer := time.NewTimer(downScaleCheckTime) - doneChan := mainThread.done - go func() { - for { - timer.Reset(downScaleCheckTime) - select { - case <-doneChan: - return - case <-timer.C: - downScaleThreads() - } - } - }() + + maxScaledThreads := maxThreads - numThreads + autoScaledThreads = make([]*phpThread, 0, maxScaledThreads) + go startUpscalingThreads(mainThread.done, maxScaledThreads) + go startDownScalingThreads(mainThread.done) } func drainAutoScaling() { scalingMu.Lock() - activeScalingStrategy = scalingStrategyNone{} scalingMu.Unlock() } @@ -199,8 +149,6 @@ func scaleWorkerThreads(worker *worker) { scalingMu.Lock() defer scalingMu.Unlock() - // TODO: is there an easy way to check if we are reaching memory limits? - if !mainThread.state.is(stateReady) || !probeCPUs(cpuProbeTime) { return } @@ -232,8 +180,51 @@ func scaleRegularThreads() { autoScaledThreads = append(autoScaledThreads, thread) } +func startUpscalingThreads(done chan struct{}, maxScaledThreads int) { + for { + scalingMu.Lock() + scaledThreadCount := len(autoScaledThreads) + scalingMu.Unlock() + if scaledThreadCount >= maxScaledThreads { + time.Sleep(upscaleCheckTime) + continue + } + + select { + case fc := <-scaleChan: + timeSinceStalled := time.Since(fc.startedAt) + if timeSinceStalled < minStallTime { + time.Sleep(upscaleCheckTime) + continue + } + // if the request has been waiting for too long, we need to scale up + worker, ok := workers[fc.scriptFilename] + if !ok { + scaleRegularThreads() + } else { + scaleWorkerThreads(worker) + } + case <-done: + return + } + } +} + +func startDownScalingThreads(done chan struct{}) { + timer := time.NewTimer(downScaleCheckTime) + for { + select { + case <-done: + return + case <-timer.C: + deactivateThreads() + timer.Reset(downScaleCheckTime) + } + } +} + // Check all threads and remove those that have been inactive for too long -func downScaleThreads() { +func deactivateThreads() { stoppedThreadCount := 0 scalingMu.Lock() defer scalingMu.Unlock() @@ -309,3 +300,16 @@ func logMaxThreadsReachedWarning(zapFields ...zap.Field) { lastMaxThreadsWarning = time.Now() } } + +func getProcessAvailableMemory() uint64 { + return uint64(C.sysconf(C._SC_PHYS_PAGES) * C.sysconf(C._SC_PAGE_SIZE)) +} + +func logMemoryUsage() { + memory := getProcessAvailableMemory() / 1024 / 1024 + logger.Warn("Memory", zap.Uint64("memory MB", memory)) + + //C.char *phpThreadMemoryLimit; + //C.cfg_get_string("filter.default", &phpThreadMemoryLimit); + //logger.Warn("phpThreadMemoryLimit", zap.String("phpThreadMemoryLimit", C.GoString(phpThreadMemoryLimit))) +} diff --git a/scaling_test.go b/scaling_test.go index 12ee08535..96005163e 100644 --- a/scaling_test.go +++ b/scaling_test.go @@ -8,37 +8,10 @@ import ( "go.uber.org/zap" ) -func TestScalingStrategyShouldBeNoneOnLowMaxThreads(t *testing.T) { - doneChan := make(chan struct{}) - initAutoScaling(1, 1, ScalingStrategyNormal) - - assert.IsType(t, scalingStrategyNone{}, activeScalingStrategy) - - close(doneChan) -} - -func TestScalingStrategyShouldBeNormal(t *testing.T) { - doneChan := make(chan struct{}) - initAutoScaling(1, 2, ScalingStrategyNormal) - - assert.IsType(t, &scalingStrategyNormal{}, activeScalingStrategy) - - close(doneChan) -} - -func TestScalingStrategyShouldBeNoneWhenExplicitlySetToNone(t *testing.T) { - doneChan := make(chan struct{}) - initAutoScaling(1, 2, ScalingStrategyNone) - - assert.IsType(t, scalingStrategyNone{}, activeScalingStrategy) - - close(doneChan) -} - func TestScaleARegularThreadUpAndDown(t *testing.T) { assert.NoError(t, Init( WithNumThreads(1), - WithMaxThreads(2, ScalingStrategyNormal), + WithMaxThreads(2), WithLogger(zap.NewNop()), )) @@ -67,7 +40,7 @@ func TestScaleAWorkerThreadUpAndDown(t *testing.T) { workerPath := testDataPath + "/transition-worker-1.php" assert.NoError(t, Init( WithNumThreads(2), - WithMaxThreads(3, ScalingStrategyNormal), + WithMaxThreads(3), WithWorkers(workerPath, 1, map[string]string{}, []string{}), WithLogger(zap.NewNop()), )) diff --git a/thread-regular.go b/thread-regular.go index 2e9962330..200125a0e 100644 --- a/thread-regular.go +++ b/thread-regular.go @@ -108,10 +108,19 @@ func handleRequestWithRegularPHPThreads(r *http.Request, fc *FrankenPHPContext) // if no thread was available, mark the request as queued and apply the scaling strategy metrics.QueuedRequest() - activeScalingStrategy.apply(regularRequestChan, r, scaleRegularThreads) - metrics.DequeuedRequest() - <-fc.done - metrics.StopRequest() + for { + select { + case regularRequestChan <- r: + metrics.DequeuedRequest() + <-fc.done + metrics.StopRequest() + return + case <-mainThread.done: + return + case scaleChan <- fc: + // the request has triggered scaling, continue to wait for a thread + } + } } func attachRegularThread(thread *phpThread) { diff --git a/worker.go b/worker.go index c5f6611bb..d7a95f184 100644 --- a/worker.go +++ b/worker.go @@ -185,9 +185,17 @@ func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { // if no thread was available, mark the request as queued and apply the scaling strategy metrics.QueuedWorkerRequest(fc.scriptFilename) - activeScalingStrategy.apply(worker.requestChan, r, func() { scaleWorkerThreads(worker) }) - metrics.DequeuedWorkerRequest(fc.scriptFilename) - - <-fc.done - metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) + for { + select { + case worker.requestChan <- r: + metrics.DequeuedWorkerRequest(fc.scriptFilename) + <-fc.done + metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) + return + case <-mainThread.done: + return + case scaleChan <- fc: + // the request has triggered scaling, continue to wait for a thread + } + } } From 5f70afb716be296786855773e17df3babf42e1cd Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 9 Jan 2025 22:44:02 +0100 Subject: [PATCH 127/190] Shows the original request path in debug status. --- phpthread.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/phpthread.go b/phpthread.go index 96687e87e..26084db7e 100644 --- a/phpthread.go +++ b/phpthread.go @@ -111,15 +111,15 @@ func (thread *phpThread) getActiveRequest() *http.Request { // small status message for debugging func (thread *phpThread) debugStatus() string { - requestStatusMessage := "" + reqState := "" if waitTime := thread.state.waitTime(); waitTime > 0 { - requestStatusMessage = fmt.Sprintf(", waiting for %dms", waitTime) + reqState = fmt.Sprintf(", waiting for %dms", waitTime) } else if r := thread.getActiveRequest(); r != nil { fc := r.Context().Value(contextKey).(*FrankenPHPContext) sinceMs := time.Since(fc.startedAt).Milliseconds() - requestStatusMessage = fmt.Sprintf(", handling %s for %dms ", r.URL.Path, sinceMs) + reqState = fmt.Sprintf(", handling %s for %dms ", fc.originalRequest.URL.Path, sinceMs) } - return fmt.Sprintf("Thread %d (%s%s) %s", thread.threadIndex, thread.state.name(), requestStatusMessage, thread.handler.name()) + return fmt.Sprintf("Thread %d (%s%s) %s", thread.threadIndex, thread.state.name(), reqState, thread.handler.name()) } // Pin a string that is not null-terminated From 9dcc2cdad263c620a52d83dfda58d762ea9292fa Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 10 Jan 2025 11:08:19 +0100 Subject: [PATCH 128/190] Adds automatic thread limit calculation. --- caddy/admin_test.go | 6 ++- caddy/caddy.go | 5 ++ caddy/caddy_test.go | 115 +++----------------------------------------- frankenphp.c | 20 +++++--- frankenphp.go | 6 +-- phpmainthread.go | 74 +++++++++++++++++++++++++--- scaling.go | 44 +++++------------ scaling_test.go | 12 ++--- thread-regular.go | 4 +- worker.go | 2 - 10 files changed, 115 insertions(+), 173 deletions(-) diff --git a/caddy/admin_test.go b/caddy/admin_test.go index 541683b7d..dfc2f7f93 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -233,7 +233,9 @@ func TestAutoScaleWorkerThreads(t *testing.T) { assert.Contains(t, threadInfo, autoScaledThread) } -func TestAutoScaleRegularThreads(t *testing.T) { +// Note: PHP default memory limit is 128MB +// This Test assumes that we have at least 2 times that available +func TestAutoScaleRegularThreadsOnAutomaticThreadLimit(t *testing.T) { wg := sync.WaitGroup{} maxTries := 10 requestsPerTry := 200 @@ -245,7 +247,7 @@ func TestAutoScaleRegularThreads(t *testing.T) { http_port `+testPort+` frankenphp { - max_threads 10 + max_threads auto num_threads 1 } } diff --git a/caddy/caddy.go b/caddy/caddy.go index f46e71291..6e29a162f 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -131,6 +131,11 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { return d.ArgErr() } + if d.Val() == "auto" { + f.MaxThreads = -1 + continue + } + v, err := strconv.ParseUint(d.Val(), 10, 32) if err != nil { return err diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 7c512b018..bba658f91 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -10,7 +10,6 @@ import ( "sync" "testing" - "github.com/dunglas/frankenphp" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/require" @@ -325,7 +324,9 @@ func TestMetrics(t *testing.T) { http_port `+testPort+` https_port 9443 - frankenphp + frankenphp { + num_threads 2 + } } localhost:`+testPort+` { @@ -361,13 +362,11 @@ func TestMetrics(t *testing.T) { t.Fatalf("failed to read metrics: %v", err) } - cpus := fmt.Sprintf("%d", frankenphp.MaxThreads) - // Check metrics expectedMetrics := ` # HELP frankenphp_total_threads Total number of PHP threads # TYPE frankenphp_total_threads counter - frankenphp_total_threads ` + cpus + ` + frankenphp_total_threads 2 # HELP frankenphp_busy_threads Number of busy PHP threads # TYPE frankenphp_busy_threads gauge @@ -388,6 +387,7 @@ func TestWorkerMetrics(t *testing.T) { https_port 9443 frankenphp { + num_threads 3 worker ../testdata/index.php 2 } } @@ -425,13 +425,11 @@ func TestWorkerMetrics(t *testing.T) { t.Fatalf("failed to read metrics: %v", err) } - cpus := fmt.Sprintf("%d", frankenphp.MaxThreads) - // Check metrics expectedMetrics := ` # HELP frankenphp_total_threads Total number of PHP threads # TYPE frankenphp_total_threads counter - frankenphp_total_threads ` + cpus + ` + frankenphp_total_threads 3 # HELP frankenphp_busy_threads Number of busy PHP threads # TYPE frankenphp_busy_threads gauge @@ -477,107 +475,6 @@ func TestWorkerMetrics(t *testing.T) { )) } -func TestAutoWorkerConfig(t *testing.T) { - var wg sync.WaitGroup - tester := caddytest.NewTester(t) - tester.InitServer(` - { - skip_install_trust - admin localhost:2999 - http_port `+testPort+` - https_port 9443 - - frankenphp { - worker ../testdata/index.php - } - } - - localhost:`+testPort+` { - route { - php { - root ../testdata - } - } - } - `, "caddyfile") - - // Make some requests - for i := 0; i < 10; i++ { - wg.Add(1) - go func(i int) { - tester.AssertGetResponse(fmt.Sprintf("http://localhost:"+testPort+"/index.php?i=%d", i), http.StatusOK, fmt.Sprintf("I am by birth a Genevese (%d)", i)) - wg.Done() - }(i) - } - wg.Wait() - - // Fetch metrics - resp, err := http.Get("http://localhost:2999/metrics") - if err != nil { - t.Fatalf("failed to fetch metrics: %v", err) - } - defer resp.Body.Close() - - // Read and parse metrics - metrics := new(bytes.Buffer) - _, err = metrics.ReadFrom(resp.Body) - if err != nil { - t.Fatalf("failed to read metrics: %v", err) - } - - cpus := fmt.Sprintf("%d", frankenphp.MaxThreads) - workers := fmt.Sprintf("%d", frankenphp.MaxThreads-1) - - // Check metrics - expectedMetrics := ` - # HELP frankenphp_total_threads Total number of PHP threads - # TYPE frankenphp_total_threads counter - frankenphp_total_threads ` + cpus + ` - - # HELP frankenphp_busy_threads Number of busy PHP threads - # TYPE frankenphp_busy_threads gauge - frankenphp_busy_threads ` + workers + ` - - # HELP frankenphp_testdata_index_php_busy_workers Number of busy PHP workers for this worker - # TYPE frankenphp_testdata_index_php_busy_workers gauge - frankenphp_testdata_index_php_busy_workers 0 - - # HELP frankenphp_testdata_index_php_total_workers Total number of PHP workers for this worker - # TYPE frankenphp_testdata_index_php_total_workers gauge - frankenphp_testdata_index_php_total_workers ` + workers + ` - - # HELP frankenphp_testdata_index_php_worker_request_count - # TYPE frankenphp_testdata_index_php_worker_request_count counter - frankenphp_testdata_index_php_worker_request_count 10 - - # HELP frankenphp_testdata_index_php_ready_workers Running workers that have successfully called frankenphp_handle_request at least once - # TYPE frankenphp_testdata_index_php_ready_workers gauge - frankenphp_testdata_index_php_ready_workers ` + workers + ` - - # HELP frankenphp_testdata_index_php_worker_crashes Number of PHP worker crashes for this worker - # TYPE frankenphp_testdata_index_php_worker_crashes counter - frankenphp_testdata_index_php_worker_crashes 0 - - # HELP frankenphp_testdata_index_php_worker_restarts Number of PHP worker restarts for this worker - # TYPE frankenphp_testdata_index_php_worker_restarts counter - frankenphp_testdata_index_php_worker_restarts 0 - ` - - require.NoError(t, - testutil.GatherAndCompare( - prometheus.DefaultGatherer, - strings.NewReader(expectedMetrics), - "frankenphp_total_threads", - "frankenphp_busy_threads", - "frankenphp_testdata_index_php_busy_workers", - "frankenphp_testdata_index_php_total_workers", - "frankenphp_testdata_index_php_worker_request_count", - "frankenphp_testdata_index_php_worker_crashes", - "frankenphp_testdata_index_php_worker_restarts", - "frankenphp_testdata_index_php_ready_workers", - )) -} - func TestAllDefinedServerVars(t *testing.T) { documentRoot, _ := filepath.Abs("../testdata/") expectedBodyFile, _ := os.ReadFile("../testdata/server-all-vars-ordered.txt") diff --git a/frankenphp.c b/frankenphp.c index ee20cd45b..86629c4fb 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -78,7 +78,7 @@ typedef struct frankenphp_server_context { bool finished; } frankenphp_server_context; -__thread bool should_filter_var = 0; +bool should_filter_var = 0; __thread frankenphp_server_context *local_ctx = NULL; __thread uintptr_t thread_index; __thread zval *os_environment = NULL; @@ -886,12 +886,6 @@ static void *php_thread(void *arg) { local_ctx = malloc(sizeof(frankenphp_server_context)); - /* check if a default filter is set in php.ini and only filter if - * it is, this is deprecated and will be removed in PHP 9 */ - char *default_filter; - cfg_get_string("filter.default", &default_filter); - should_filter_var = default_filter != NULL; - // loop until Go signals to stop char *scriptName = NULL; while ((scriptName = go_frankenphp_before_script_execution(thread_index))) { @@ -956,7 +950,17 @@ static void *php_main(void *arg) { frankenphp_sapi_module.startup(&frankenphp_sapi_module); - go_frankenphp_main_thread_is_ready(); + /* check if a default filter is set in php.ini and only filter if + * it is, this is deprecated and will be removed in PHP 9 */ + char *default_filter; + cfg_get_string("filter.default", &default_filter); + should_filter_var = default_filter != NULL; + + /* forward the configured memory limit to go for scaling configurations */ + char *php_memory_limit; + cfg_get_string("memory_limit", &php_memory_limit); + + go_frankenphp_main_thread_is_ready(php_memory_limit); /* channel closed, shutdown gracefully */ frankenphp_sapi_module.shutdown(&frankenphp_sapi_module); diff --git a/frankenphp.go b/frankenphp.go index 1368349c0..eee4b298a 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -242,9 +242,6 @@ func Config() PHPConfig { } } -// MaxThreads is internally used during tests. It is written to, but never read and may go away in the future. -var MaxThreads int - func calculateMaxThreads(opt *opt) (int, int, int, error) { maxProcs := runtime.GOMAXPROCS(0) * 2 @@ -270,12 +267,11 @@ func calculateMaxThreads(opt *opt) (int, int, int, error) { return opt.numThreads, numWorkers, opt.maxThreads, NotEnoughThreads } - if opt.maxThreads < opt.numThreads { + if opt.maxThreads < opt.numThreads && opt.maxThreads > 0 { opt.maxThreads = opt.numThreads } metrics.TotalThreads(opt.numThreads) - MaxThreads = opt.numThreads return opt.numThreads, numWorkers, opt.maxThreads, nil } diff --git a/phpmainthread.go b/phpmainthread.go index 7f1e0efe5..55ffcf929 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -4,7 +4,10 @@ package frankenphp import "C" import ( "fmt" + "strconv" "sync" + + "go.uber.org/zap" ) // represents the main PHP thread @@ -13,6 +16,7 @@ type phpMainThread struct { state *threadState done chan struct{} numThreads int + maxThreads int } var ( @@ -28,20 +32,27 @@ func initPHPThreads(numThreads int, numMaxThreads int) error { state: newThreadState(), done: make(chan struct{}), numThreads: numThreads, + maxThreads: numMaxThreads, } - phpThreads = make([]*phpThread, numMaxThreads) - // initialize all threads as inactive + // initialize the first thread // this needs to happen before starting the main thread // since some extensions access environment variables on startup - for i := 0; i < numMaxThreads; i++ { - phpThreads[i] = newPHPThread(i) - } + // the threadIndex on the main thread defaults to 0 -> phpThreads[0].Pin(...) + initialThread := newPHPThread(0) + phpThreads = []*phpThread{initialThread} if err := mainThread.start(); err != nil { return err } + // initialize all other threads + phpThreads = make([]*phpThread, mainThread.maxThreads) + phpThreads[0] = initialThread + for i := 1; i < mainThread.maxThreads; i++ { + phpThreads[i] = newPHPThread(i) + } + // start the underlying C threads ready := sync.WaitGroup{} ready.Add(numThreads) @@ -126,11 +137,62 @@ func getPHPThreadAtState(state stateID) *phpThread { } //export go_frankenphp_main_thread_is_ready -func go_frankenphp_main_thread_is_ready() { +func go_frankenphp_main_thread_is_ready(memory_limit *C.char) { + if mainThread.maxThreads == -1 && memory_limit != nil { + mainThread.setAutomaticThreadLimit(C.GoString(memory_limit)) + } + + if mainThread.maxThreads < mainThread.numThreads { + mainThread.maxThreads = mainThread.numThreads + } + mainThread.state.set(stateReady) mainThread.state.waitFor(stateDone) } +// figure out how many threads can be started based on memory_limit from php.ini +func (mainThread *phpMainThread) setAutomaticThreadLimit(phpMemoryLimit string) { + perThreadMemoryLimit := parsePHPMemoryLimit(phpMemoryLimit) + if perThreadMemoryLimit <= 0 { + return + } + maxAllowedThreads := getProcessAvailableMemory() / perThreadMemoryLimit + mainThread.maxThreads = int(maxAllowedThreads) + logger.Info("Automatic thread limit", zap.String("phpMemoryLimit", phpMemoryLimit), zap.Int("maxThreads", mainThread.maxThreads)) +} + +// Convert the memory limit from php.ini to bytes +// The memory limit in PHP is either post-fixed with an M or G +// Without postfix it's in bytes, -1 means no limit +func parsePHPMemoryLimit(memoryLimit string) uint64 { + multiplier := 1 + lastChar := memoryLimit[len(memoryLimit)-1] + if lastChar == 'M' { + multiplier = 1024 * 1024 + memoryLimit = memoryLimit[:len(memoryLimit)-1] + } else if lastChar == 'G' { + multiplier = 1024 * 1024 * 1024 + memoryLimit = memoryLimit[:len(memoryLimit)-1] + } + + bytes, err := strconv.Atoi(memoryLimit) + if err != nil { + logger.Warn("Could not parse PHP memory limit (assuming unlimited)", zap.String("memoryLimit", memoryLimit), zap.Error(err)) + return 0 + } + if bytes < 0 { + return 0 + } + return uint64(bytes * multiplier) +} + +// Gets all available memory in bytes +// Should be unix compatible - TODO: verify that it is on all important platforms +// On potential Windows support this would need to be done differently +func getProcessAvailableMemory() uint64 { + return uint64(C.sysconf(C._SC_PHYS_PAGES) * C.sysconf(C._SC_PAGE_SIZE)) +} + //export go_frankenphp_shutdown_main_thread func go_frankenphp_shutdown_main_thread() { mainThread.state.set(stateReserved) diff --git a/scaling.go b/scaling.go index 523a14850..a8506613a 100644 --- a/scaling.go +++ b/scaling.go @@ -1,6 +1,7 @@ package frankenphp //#include "frankenphp.h" +//#include import "C" import ( "errors" @@ -145,7 +146,7 @@ func removeWorkerThread(worker *worker) error { } // Add a worker PHP threads automatically -func scaleWorkerThreads(worker *worker) { +func scaleWorkerThread(worker *worker) { scalingMu.Lock() defer scalingMu.Unlock() @@ -155,7 +156,7 @@ func scaleWorkerThreads(worker *worker) { thread, err := addWorkerThread(worker) if err != nil { - logMaxThreadsReachedWarning(zap.String("worker", worker.fileName), zap.Error(err)) + logger.Warn("could not increase max_threads, consider raising this limit", zap.String("worker", worker.fileName), zap.Error(err)) return } @@ -163,7 +164,7 @@ func scaleWorkerThreads(worker *worker) { } // Add a regular PHP thread automatically -func scaleRegularThreads() { +func scaleRegularThread() { scalingMu.Lock() defer scalingMu.Unlock() @@ -173,7 +174,7 @@ func scaleRegularThreads() { thread, err := addRegularThread() if err != nil { - logMaxThreadsReachedWarning(zap.Error(err)) + logger.Warn("could not increase max_threads, consider raising this limit", zap.Error(err)) return } @@ -193,16 +194,18 @@ func startUpscalingThreads(done chan struct{}, maxScaledThreads int) { select { case fc := <-scaleChan: timeSinceStalled := time.Since(fc.startedAt) + + // if the request has not been stalled long enough, wait and repeat if timeSinceStalled < minStallTime { time.Sleep(upscaleCheckTime) continue } - // if the request has been waiting for too long, we need to scale up - worker, ok := workers[fc.scriptFilename] - if !ok { - scaleRegularThreads() + + // if the request has been stalled long enough, scale + if worker, ok := workers[fc.scriptFilename]; ok { + scaleWorkerThread(worker) } else { - scaleWorkerThreads(worker) + scaleRegularThread() } case <-done: return @@ -290,26 +293,3 @@ func probeCPUs(probeTime time.Duration) bool { return cpuUsage < maxCpuUsageForScaling } - -// only log the maximum amount of threads reached warning once per minute -var lastMaxThreadsWarning = time.Time{} - -func logMaxThreadsReachedWarning(zapFields ...zap.Field) { - if lastMaxThreadsWarning.Add(time.Minute).Before(time.Now()) { - logger.Warn("could not increase max_threads, consider raising this limit", zapFields...) - lastMaxThreadsWarning = time.Now() - } -} - -func getProcessAvailableMemory() uint64 { - return uint64(C.sysconf(C._SC_PHYS_PAGES) * C.sysconf(C._SC_PAGE_SIZE)) -} - -func logMemoryUsage() { - memory := getProcessAvailableMemory() / 1024 / 1024 - logger.Warn("Memory", zap.Uint64("memory MB", memory)) - - //C.char *phpThreadMemoryLimit; - //C.cfg_get_string("filter.default", &phpThreadMemoryLimit); - //logger.Warn("phpThreadMemoryLimit", zap.String("phpThreadMemoryLimit", C.GoString(phpThreadMemoryLimit))) -} diff --git a/scaling_test.go b/scaling_test.go index 96005163e..b1658462c 100644 --- a/scaling_test.go +++ b/scaling_test.go @@ -18,19 +18,19 @@ func TestScaleARegularThreadUpAndDown(t *testing.T) { autoScaledThread := phpThreads[1] // scale up - scaleRegularThreads() + scaleRegularThread() assert.Equal(t, stateReady, autoScaledThread.state.get()) assert.IsType(t, ®ularThread{}, autoScaledThread.handler) // on the first down-scale, the thread will be marked as inactive setLongWaitTime(autoScaledThread) - downScaleThreads() + deactivateThreads() assert.IsType(t, &inactiveThread{}, autoScaledThread.handler) // on the second down-scale, the thread will be removed autoScaledThread.state.waitFor(stateInactive) setLongWaitTime(autoScaledThread) - downScaleThreads() + deactivateThreads() assert.Equal(t, stateReserved, autoScaledThread.state.get()) Shutdown() @@ -48,18 +48,18 @@ func TestScaleAWorkerThreadUpAndDown(t *testing.T) { autoScaledThread := phpThreads[2] // scale up - scaleWorkerThreads(workers[workerPath]) + scaleWorkerThread(workers[workerPath]) assert.Equal(t, stateReady, autoScaledThread.state.get()) // on the first down-scale, the thread will be marked as inactive setLongWaitTime(autoScaledThread) - downScaleThreads() + deactivateThreads() assert.IsType(t, &inactiveThread{}, autoScaledThread.handler) // on the second down-scale, the thread will be removed autoScaledThread.state.waitFor(stateInactive) setLongWaitTime(autoScaledThread) - downScaleThreads() + deactivateThreads() assert.Equal(t, stateReserved, autoScaledThread.state.get()) Shutdown() diff --git a/thread-regular.go b/thread-regular.go index 200125a0e..7ab25fd39 100644 --- a/thread-regular.go +++ b/thread-regular.go @@ -106,7 +106,7 @@ func handleRequestWithRegularPHPThreads(r *http.Request, fc *FrankenPHPContext) // no thread was available } - // if no thread was available, mark the request as queued and apply the scaling strategy + // if no thread was available, mark the request as queued and fan it out to all threads metrics.QueuedRequest() for { select { @@ -115,8 +115,6 @@ func handleRequestWithRegularPHPThreads(r *http.Request, fc *FrankenPHPContext) <-fc.done metrics.StopRequest() return - case <-mainThread.done: - return case scaleChan <- fc: // the request has triggered scaling, continue to wait for a thread } diff --git a/worker.go b/worker.go index d7a95f184..5f76e0482 100644 --- a/worker.go +++ b/worker.go @@ -192,8 +192,6 @@ func (worker *worker) handleRequest(r *http.Request, fc *FrankenPHPContext) { <-fc.done metrics.StopWorkerRequest(worker.fileName, time.Since(fc.startedAt)) return - case <-mainThread.done: - return case scaleChan <- fc: // the request has triggered scaling, continue to wait for a thread } From ed90b754ea1c116536b2c716d73563728a8f67d5 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 10 Jan 2025 11:18:26 +0100 Subject: [PATCH 129/190] Properly forwards automatic max threads. --- frankenphp.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frankenphp.go b/frankenphp.go index eee4b298a..4ccf1e42b 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -348,7 +348,7 @@ func Init(options ...Option) error { return err } - initAutoScaling(totalThreadCount, maxThreadCount) + initAutoScaling(mainThread.numThreads, mainThread.maxThreads) if c := logger.Check(zapcore.InfoLevel, "FrankenPHP started 🐘"); c != nil { c.Write(zap.String("php_version", Version().Version), zap.Int("num_threads", totalThreadCount), zap.Int("max_threads", maxThreadCount)) From d047149b3e45db6aa16908855a9b76be637f2744 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 10 Jan 2025 12:25:15 +0100 Subject: [PATCH 130/190] Adds ini overrides. --- phpmainthread.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/phpmainthread.go b/phpmainthread.go index 55ffcf929..42e524fdc 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -17,6 +17,7 @@ type phpMainThread struct { done chan struct{} numThreads int maxThreads int + phpIniOverrides map[string]string } var ( @@ -27,12 +28,13 @@ var ( // start the main PHP thread // start a fixed number of inactive PHP threads // reserve a fixed number of possible PHP threads -func initPHPThreads(numThreads int, numMaxThreads int) error { +func initPHPThreads(numThreads int, numMaxThreads int, phpIniOverrides map[string]string) error { mainThread = &phpMainThread{ state: newThreadState(), done: make(chan struct{}), numThreads: numThreads, maxThreads: numMaxThreads, + phpIniOverrides: phpIniOverrides, } // initialize the first thread @@ -110,10 +112,18 @@ func (mainThread *phpMainThread) start() error { if C.frankenphp_new_main_thread(C.int(mainThread.numThreads)) != 0 { return MainThreadCreationError } + for k,v := range mainThread.phpIniOverrides { + C.frankenphp_set_ini_override(C.CString(k), C.CString(v)) + } + mainThread.state.waitFor(stateReady) return nil } +func (mainThread *phpMainThread) start() error { + +} + func getInactivePHPThread() *phpThread { thread := getPHPThreadAtState(stateInactive) if thread != nil { From 5bdd50c126c2c6f65f45350063b17f1369a67ae9 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 10 Jan 2025 18:36:17 +0100 Subject: [PATCH 131/190] Adds Caddy php ini directive (for testing) --- caddy/admin_test.go | 4 +-- caddy/caddy.go | 15 ++++++++ frankenphp.c | 18 +++++++--- frankenphp.go | 2 +- frankenphp.h | 2 ++ options.go | 19 ++++++++--- phpmainthread.go | 79 ++++++++++++++++--------------------------- phpmainthread_test.go | 6 ++-- phpthread.go | 4 +++ 9 files changed, 84 insertions(+), 65 deletions(-) diff --git a/caddy/admin_test.go b/caddy/admin_test.go index dfc2f7f93..baf2b26c2 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -233,8 +233,7 @@ func TestAutoScaleWorkerThreads(t *testing.T) { assert.Contains(t, threadInfo, autoScaledThread) } -// Note: PHP default memory limit is 128MB -// This Test assumes that we have at least 2 times that available +// Note this test requires at least 2x40MB available memory for the process func TestAutoScaleRegularThreadsOnAutomaticThreadLimit(t *testing.T) { wg := sync.WaitGroup{} maxTries := 10 @@ -249,6 +248,7 @@ func TestAutoScaleRegularThreadsOnAutomaticThreadLimit(t *testing.T) { frankenphp { max_threads auto num_threads 1 + php_ini memory_limit 40M # a reasonable limit for the test } } diff --git a/caddy/caddy.go b/caddy/caddy.go index 6e29a162f..d2db39e0b 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -61,6 +61,8 @@ type FrankenPHPApp struct { MaxThreads int `json:"max_threads,omitempty"` // Workers configures the worker scripts to start. Workers []workerConfig `json:"workers,omitempty"` + // Overwrites the default php ini configuration + PhpIniOverrides map[string]string `json:"php_ini,omitempty"` } // CaddyModule returns the Caddy module information. @@ -80,6 +82,7 @@ func (f *FrankenPHPApp) Start() error { frankenphp.WithMaxThreads(f.MaxThreads), frankenphp.WithLogger(logger), frankenphp.WithMetrics(metrics), + frankenphp.WithPhpIniOverrides(f.PhpIniOverrides), } for _, w := range f.Workers { opts = append(opts, frankenphp.WithWorkers(repl.ReplaceKnown(w.FileName, ""), w.Num, w.Env, w.Watch)) @@ -142,6 +145,18 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } f.MaxThreads = int(v) + case "php_ini": + if !d.NextArg() { + return d.ArgErr() + } + key := d.Val() + if !d.NextArg() { + return d.ArgErr() + } + if f.PhpIniOverrides == nil { + f.PhpIniOverrides = make(map[string]string) + } + f.PhpIniOverrides[key] = d.Val() case "worker": wc := workerConfig{} if d.NextArg() { diff --git a/frankenphp.c b/frankenphp.c index 86629c4fb..2467f8039 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -956,11 +956,7 @@ static void *php_main(void *arg) { cfg_get_string("filter.default", &default_filter); should_filter_var = default_filter != NULL; - /* forward the configured memory limit to go for scaling configurations */ - char *php_memory_limit; - cfg_get_string("memory_limit", &php_memory_limit); - - go_frankenphp_main_thread_is_ready(php_memory_limit); + go_frankenphp_main_thread_is_ready(); /* channel closed, shutdown gracefully */ frankenphp_sapi_module.shutdown(&frankenphp_sapi_module); @@ -1228,3 +1224,15 @@ int frankenphp_reset_opcache(void) { } return 0; } + +void frankenphp_overwrite_ini_configuraton(go_string key, go_string value) { + zend_string *z_key = zend_string_init(key.data, key.len, 0); + zend_string *z_value = zend_string_init(value.data, value.len, 0); + zend_alter_ini_entry(z_key, z_value, PHP_INI_SYSTEM, PHP_INI_STAGE_ACTIVATE); + zend_string_release_ex(z_key, 0); + zend_string_release_ex(z_value, 0); +} + +int frankenphp_get_current_memory_limit() { + return PG(memory_limit); +} diff --git a/frankenphp.go b/frankenphp.go index 4ccf1e42b..244fc5bf6 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -333,7 +333,7 @@ func Init(options ...Option) error { logger.Warn(`ZTS is not enabled, only 1 thread will be available, recompile PHP using the "--enable-zts" configuration option or performance will be degraded`) } - if err := initPHPThreads(totalThreadCount, maxThreadCount); err != nil { + if err := initPHPThreads(totalThreadCount, maxThreadCount, opt.phpIniOverrides); err != nil { return err } diff --git a/frankenphp.h b/frankenphp.h index a6c041fd0..8050f2146 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -71,6 +71,8 @@ void frankenphp_register_variable_safe(char *key, char *var, size_t val_len, zend_string *frankenphp_init_persistent_string(const char *string, size_t len); void frankenphp_release_zend_string(zend_string *z_string); int frankenphp_reset_opcache(void); +void frankenphp_overwrite_ini_configuraton(go_string key, go_string value); +int frankenphp_get_current_memory_limit(); void frankenphp_register_bulk( zval *track_vars_array, ht_key_value_pair remote_addr, diff --git a/options.go b/options.go index 7795399be..3c8e58fd2 100644 --- a/options.go +++ b/options.go @@ -11,11 +11,12 @@ type Option func(h *opt) error // // If you change this, also update the Caddy module and the documentation. type opt struct { - numThreads int - maxThreads int - workers []workerOpt - logger *zap.Logger - metrics Metrics + numThreads int + maxThreads int + workers []workerOpt + logger *zap.Logger + metrics Metrics + phpIniOverrides map[string]string } type workerOpt struct { @@ -67,3 +68,11 @@ func WithLogger(l *zap.Logger) Option { return nil } } + +// WithPhpIniOverrides configures the PHP ini overrides. +func WithPhpIniOverrides(overrides map[string]string) Option { + return func(o *opt) error { + o.phpIniOverrides = overrides + return nil + } +} diff --git a/phpmainthread.go b/phpmainthread.go index 42e524fdc..6822f1541 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -4,7 +4,6 @@ package frankenphp import "C" import ( "fmt" - "strconv" "sync" "go.uber.org/zap" @@ -13,10 +12,10 @@ import ( // represents the main PHP thread // the thread needs to keep running as long as all other threads are running type phpMainThread struct { - state *threadState - done chan struct{} - numThreads int - maxThreads int + state *threadState + done chan struct{} + numThreads int + maxThreads int phpIniOverrides map[string]string } @@ -30,10 +29,10 @@ var ( // reserve a fixed number of possible PHP threads func initPHPThreads(numThreads int, numMaxThreads int, phpIniOverrides map[string]string) error { mainThread = &phpMainThread{ - state: newThreadState(), - done: make(chan struct{}), - numThreads: numThreads, - maxThreads: numMaxThreads, + state: newThreadState(), + done: make(chan struct{}), + numThreads: numThreads, + maxThreads: numMaxThreads, phpIniOverrides: phpIniOverrides, } @@ -112,18 +111,13 @@ func (mainThread *phpMainThread) start() error { if C.frankenphp_new_main_thread(C.int(mainThread.numThreads)) != 0 { return MainThreadCreationError } - for k,v := range mainThread.phpIniOverrides { - C.frankenphp_set_ini_override(C.CString(k), C.CString(v)) - } + + // overwrite php.ini config (if necessary) mainThread.state.waitFor(stateReady) return nil } -func (mainThread *phpMainThread) start() error { - -} - func getInactivePHPThread() *phpThread { thread := getPHPThreadAtState(stateInactive) if thread != nil { @@ -147,11 +141,9 @@ func getPHPThreadAtState(state stateID) *phpThread { } //export go_frankenphp_main_thread_is_ready -func go_frankenphp_main_thread_is_ready(memory_limit *C.char) { - if mainThread.maxThreads == -1 && memory_limit != nil { - mainThread.setAutomaticThreadLimit(C.GoString(memory_limit)) - } - +func go_frankenphp_main_thread_is_ready() { + mainThread.overridePHPIni() + mainThread.setAutomaticMaxThreads() if mainThread.maxThreads < mainThread.numThreads { mainThread.maxThreads = mainThread.numThreads } @@ -160,40 +152,29 @@ func go_frankenphp_main_thread_is_ready(memory_limit *C.char) { mainThread.state.waitFor(stateDone) } +// override php.ini directives with those set in the Caddy config +// this needs to happen on each thread and before script execution +func (mainThread *phpMainThread) overridePHPIni() { + for k, v := range mainThread.phpIniOverrides { + C.frankenphp_overwrite_ini_configuraton( + C.go_string{C.ulong(len(k)), toUnsafeChar(k)}, + C.go_string{C.ulong(len(v)), toUnsafeChar(v)}, + ) + } +} + // figure out how many threads can be started based on memory_limit from php.ini -func (mainThread *phpMainThread) setAutomaticThreadLimit(phpMemoryLimit string) { - perThreadMemoryLimit := parsePHPMemoryLimit(phpMemoryLimit) +func (mainThread *phpMainThread) setAutomaticMaxThreads() { + if mainThread.maxThreads >= 0 { + return + } + perThreadMemoryLimit := uint64(C.frankenphp_get_current_memory_limit()) if perThreadMemoryLimit <= 0 { return } maxAllowedThreads := getProcessAvailableMemory() / perThreadMemoryLimit mainThread.maxThreads = int(maxAllowedThreads) - logger.Info("Automatic thread limit", zap.String("phpMemoryLimit", phpMemoryLimit), zap.Int("maxThreads", mainThread.maxThreads)) -} - -// Convert the memory limit from php.ini to bytes -// The memory limit in PHP is either post-fixed with an M or G -// Without postfix it's in bytes, -1 means no limit -func parsePHPMemoryLimit(memoryLimit string) uint64 { - multiplier := 1 - lastChar := memoryLimit[len(memoryLimit)-1] - if lastChar == 'M' { - multiplier = 1024 * 1024 - memoryLimit = memoryLimit[:len(memoryLimit)-1] - } else if lastChar == 'G' { - multiplier = 1024 * 1024 * 1024 - memoryLimit = memoryLimit[:len(memoryLimit)-1] - } - - bytes, err := strconv.Atoi(memoryLimit) - if err != nil { - logger.Warn("Could not parse PHP memory limit (assuming unlimited)", zap.String("memoryLimit", memoryLimit), zap.Error(err)) - return 0 - } - if bytes < 0 { - return 0 - } - return uint64(bytes * multiplier) + logger.Info("Automatic thread limit", zap.Int("phpMemoryLimit MB", int(perThreadMemoryLimit/1024/1024)), zap.Int("maxThreads", mainThread.maxThreads)) } // Gets all available memory in bytes diff --git a/phpmainthread_test.go b/phpmainthread_test.go index b63780c79..8203d1137 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -18,7 +18,7 @@ var testDataPath, _ = filepath.Abs("./testdata") func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { logger = zap.NewNop() // the logger needs to not be nil - assert.NoError(t, initPHPThreads(1, 1)) // boot 1 thread + assert.NoError(t, initPHPThreads(1, 1, nil)) // boot 1 thread assert.Len(t, phpThreads, 1) assert.Equal(t, 0, phpThreads[0].threadIndex) @@ -30,7 +30,7 @@ func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { func TestTransitionRegularThreadToWorkerThread(t *testing.T) { logger = zap.NewNop() - assert.NoError(t, initPHPThreads(1, 1)) + assert.NoError(t, initPHPThreads(1, 1, nil)) // transition to regular thread convertToRegularThread(phpThreads[0]) @@ -53,7 +53,7 @@ func TestTransitionRegularThreadToWorkerThread(t *testing.T) { func TestTransitionAThreadBetween2DifferentWorkers(t *testing.T) { logger = zap.NewNop() - assert.NoError(t, initPHPThreads(1, 1)) + assert.NoError(t, initPHPThreads(1, 1, nil)) firstWorker := getDummyWorker("transition-worker-1.php") secondWorker := getDummyWorker("transition-worker-2.php") diff --git a/phpthread.go b/phpthread.go index 26084db7e..f4db136bc 100644 --- a/phpthread.go +++ b/phpthread.go @@ -148,6 +148,10 @@ func go_frankenphp_before_script_execution(threadIndex C.uintptr_t) *C.char { if scriptName == "" { return nil } + + // overwrite php.ini config (if necessary) + mainThread.overridePHPIni() + // return the name of the PHP script that should be executed return thread.pinCString(scriptName) } From 936556b41615935d8b38eb7bb300904652299d5f Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 10 Jan 2025 18:41:02 +0100 Subject: [PATCH 132/190] Adds explicit nil check --- phpmainthread.go | 3 +++ phpmainthread_test.go | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/phpmainthread.go b/phpmainthread.go index 6822f1541..a62989887 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -155,6 +155,9 @@ func go_frankenphp_main_thread_is_ready() { // override php.ini directives with those set in the Caddy config // this needs to happen on each thread and before script execution func (mainThread *phpMainThread) overridePHPIni() { + if meinThread.phpIniOverrides == nil { + return + } for k, v := range mainThread.phpIniOverrides { C.frankenphp_overwrite_ini_configuraton( C.go_string{C.ulong(len(k)), toUnsafeChar(k)}, diff --git a/phpmainthread_test.go b/phpmainthread_test.go index 8203d1137..0db0bcc87 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -17,7 +17,7 @@ import ( var testDataPath, _ = filepath.Abs("./testdata") func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { - logger = zap.NewNop() // the logger needs to not be nil + logger = zap.NewNop() // the logger needs to not be nil assert.NoError(t, initPHPThreads(1, 1, nil)) // boot 1 thread assert.Len(t, phpThreads, 1) From 675890341fb3aebf87a1a7c2e246efea325a156d Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 10 Jan 2025 18:57:09 +0100 Subject: [PATCH 133/190] Adds php.ini override test. --- caddy/caddy_test.go | 39 +++++++++++++++++++++++++++++++++++++++ phpmainthread.go | 2 +- testdata/ini.php | 7 +++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 testdata/ini.php diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index bba658f91..447851294 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -513,3 +513,42 @@ func TestAllDefinedServerVars(t *testing.T) { expectedBody, ) } + +func TestPHPIniOverride(t *testing.T) { + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + + frankenphp { + num_threads 2 + worker ../testdata/ini.php 1 + php_ini max_execution_time 100 + php_ini memory_limit 10000000 + } + } + + localhost:`+testPort+` { + route { + root ../testdata + php + } + } + `, "caddyfile") + + // test twice to ensure the ini setting is not lost + for i := 0; i < 2; i++ { + tester.AssertGetResponse( + "http://localhost:"+testPort+"/ini.php?key=max_execution_time", + http.StatusOK, + "max_execution_time:100", + ) + tester.AssertGetResponse( + "http://localhost:"+testPort+"/ini.php?key=memory_limit", + http.StatusOK, + "memory_limit:10000000", + ) + } +} diff --git a/phpmainthread.go b/phpmainthread.go index a62989887..57433fff6 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -155,7 +155,7 @@ func go_frankenphp_main_thread_is_ready() { // override php.ini directives with those set in the Caddy config // this needs to happen on each thread and before script execution func (mainThread *phpMainThread) overridePHPIni() { - if meinThread.phpIniOverrides == nil { + if mainThread.phpIniOverrides == nil { return } for k, v := range mainThread.phpIniOverrides { diff --git a/testdata/ini.php b/testdata/ini.php new file mode 100644 index 000000000..dfbcd365b --- /dev/null +++ b/testdata/ini.php @@ -0,0 +1,7 @@ + Date: Fri, 10 Jan 2025 19:10:33 +0100 Subject: [PATCH 134/190] Fixes linting and conversions. --- frankenphp.c | 14 ++++++-------- phpmainthread.go | 8 +++----- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 2467f8039..507c8aaf5 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1226,13 +1226,11 @@ int frankenphp_reset_opcache(void) { } void frankenphp_overwrite_ini_configuraton(go_string key, go_string value) { - zend_string *z_key = zend_string_init(key.data, key.len, 0); - zend_string *z_value = zend_string_init(value.data, value.len, 0); - zend_alter_ini_entry(z_key, z_value, PHP_INI_SYSTEM, PHP_INI_STAGE_ACTIVATE); - zend_string_release_ex(z_key, 0); - zend_string_release_ex(z_value, 0); + zend_string *z_key = zend_string_init(key.data, key.len, 0); + zend_string *z_value = zend_string_init(value.data, value.len, 0); + zend_alter_ini_entry(z_key, z_value, PHP_INI_SYSTEM, PHP_INI_STAGE_ACTIVATE); + zend_string_release_ex(z_key, 0); + zend_string_release_ex(z_value, 0); } -int frankenphp_get_current_memory_limit() { - return PG(memory_limit); -} +int frankenphp_get_current_memory_limit() { return PG(memory_limit); } diff --git a/phpmainthread.go b/phpmainthread.go index 57433fff6..4e2bc5893 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -112,8 +112,6 @@ func (mainThread *phpMainThread) start() error { return MainThreadCreationError } - // overwrite php.ini config (if necessary) - mainThread.state.waitFor(stateReady) return nil } @@ -160,8 +158,8 @@ func (mainThread *phpMainThread) overridePHPIni() { } for k, v := range mainThread.phpIniOverrides { C.frankenphp_overwrite_ini_configuraton( - C.go_string{C.ulong(len(k)), toUnsafeChar(k)}, - C.go_string{C.ulong(len(v)), toUnsafeChar(v)}, + C.go_string{C.size_t(len(k)), toUnsafeChar(k)}, + C.go_string{C.size_t(len(v)), toUnsafeChar(v)}, ) } } @@ -177,7 +175,7 @@ func (mainThread *phpMainThread) setAutomaticMaxThreads() { } maxAllowedThreads := getProcessAvailableMemory() / perThreadMemoryLimit mainThread.maxThreads = int(maxAllowedThreads) - logger.Info("Automatic thread limit", zap.Int("phpMemoryLimit MB", int(perThreadMemoryLimit/1024/1024)), zap.Int("maxThreads", mainThread.maxThreads)) + logger.Info("Automatic thread limit", zap.Int("phpMemoryLimit(MB)", int(perThreadMemoryLimit/1024/1024)), zap.Int("maxThreads", mainThread.maxThreads)) } // Gets all available memory in bytes From 2761d9cc5cd7b67412f9bf8cfecbc73db6c37a20 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 10 Jan 2025 19:21:14 +0100 Subject: [PATCH 135/190] Fixes fuzzing tests. --- frankenphp.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 507c8aaf5..ebae71ff9 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -740,7 +740,7 @@ frankenphp_register_variable_from_request_info(zend_string *zKey, char *value, frankenphp_register_trusted_var(zKey, value, strlen(value), Z_ARRVAL_P(track_vars_array)); } else if (must_be_present) { - frankenphp_register_trusted_var(zKey, "", 0, Z_ARRVAL_P(track_vars_array)); + frankenphp_register_trusted_var(zKey, NULL, 0, Z_ARRVAL_P(track_vars_array)); } } @@ -749,7 +749,7 @@ void frankenphp_register_variables_from_request_info( zend_string *path_translated, zend_string *query_string, zend_string *auth_user, zend_string *request_method) { frankenphp_register_variable_from_request_info( - content_type, (char *)SG(request_info).content_type, false, + content_type, (char *)SG(request_info).content_type, true, track_vars_array); frankenphp_register_variable_from_request_info( path_translated, (char *)SG(request_info).path_translated, false, From 36d3f54e17772ccca58bc5669e29360bedb3a1a5 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 10 Jan 2025 19:23:01 +0100 Subject: [PATCH 136/190] clang-format --- frankenphp.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frankenphp.c b/frankenphp.c index ebae71ff9..73c3dd981 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -740,7 +740,8 @@ frankenphp_register_variable_from_request_info(zend_string *zKey, char *value, frankenphp_register_trusted_var(zKey, value, strlen(value), Z_ARRVAL_P(track_vars_array)); } else if (must_be_present) { - frankenphp_register_trusted_var(zKey, NULL, 0, Z_ARRVAL_P(track_vars_array)); + frankenphp_register_trusted_var(zKey, NULL, 0, + Z_ARRVAL_P(track_vars_array)); } } From cd5c905b5eff31fb9f8e2b1d91067d1ccc9086e7 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 10 Jan 2025 21:37:02 +0100 Subject: [PATCH 137/190] Checks for original request existence on debug status. --- phpthread.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/phpthread.go b/phpthread.go index f4db136bc..d590ec7c9 100644 --- a/phpthread.go +++ b/phpthread.go @@ -117,7 +117,11 @@ func (thread *phpThread) debugStatus() string { } else if r := thread.getActiveRequest(); r != nil { fc := r.Context().Value(contextKey).(*FrankenPHPContext) sinceMs := time.Since(fc.startedAt).Milliseconds() - reqState = fmt.Sprintf(", handling %s for %dms ", fc.originalRequest.URL.Path, sinceMs) + path := r.URL.Path + if fc.originalRequest != nil { + path = fc.originalRequest.URL.Path + } + reqState = fmt.Sprintf(", handling %s for %dms ", path, sinceMs) } return fmt.Sprintf("Thread %d (%s%s) %s", thread.threadIndex, thread.state.name(), reqState, thread.handler.name()) } From 98f2279538c0a31cbfe2dd9d7ae2e91143add0b1 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 10 Jan 2025 22:48:51 +0100 Subject: [PATCH 138/190] Changes the memory calculation implementation --- internal/memory/memory_bsd.go | 15 +++++++++++++++ internal/memory/memory_darwin.go | 13 +++++++++++++ internal/memory/memory_linux.go | 16 ++++++++++++++++ phpmainthread.go | 19 ++++++++----------- 4 files changed, 52 insertions(+), 11 deletions(-) create mode 100644 internal/memory/memory_bsd.go create mode 100644 internal/memory/memory_darwin.go create mode 100644 internal/memory/memory_linux.go diff --git a/internal/memory/memory_bsd.go b/internal/memory/memory_bsd.go new file mode 100644 index 000000000..9800360d6 --- /dev/null +++ b/internal/memory/memory_bsd.go @@ -0,0 +1,15 @@ +//go:build freebsd || openbsd || dragonfly || netbsd +// +build freebsd openbsd dragonfly netbsd + +package memory + +// cross platform total system memory +// inspired by: https://github.com/pbnjay/memory +func Total() uint64 { + availBytes, err := sysctlUint64("hw.physmem") + if err != nil { + return 0 + } + + return availBytes +} diff --git a/internal/memory/memory_darwin.go b/internal/memory/memory_darwin.go new file mode 100644 index 000000000..8632a0ebe --- /dev/null +++ b/internal/memory/memory_darwin.go @@ -0,0 +1,13 @@ +//go:build darwin +// +build darwin + +package memory + +func Total() uint64 { + availBytes, err := sysctlUint64("hw.memsize") + if err != nil { + return 0 + } + + return availBytes +} diff --git a/internal/memory/memory_linux.go b/internal/memory/memory_linux.go new file mode 100644 index 000000000..459331c7c --- /dev/null +++ b/internal/memory/memory_linux.go @@ -0,0 +1,16 @@ +//go:build linux +// +build linux + +package memory + +import "syscall" + +func Total() uint64 { + sysInfo := &syscall.Sysinfo_t{} + err := syscall.Sysinfo(sysInfo) + if err != nil { + return 0 + } + + return uint64(sysInfo.Totalram) * uint64(sysInfo.Unit) +} diff --git a/phpmainthread.go b/phpmainthread.go index 4e2bc5893..3f8138ae1 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -6,6 +6,7 @@ import ( "fmt" "sync" + "github.com/dunglas/frankenphp/internal/memory" "go.uber.org/zap" ) @@ -164,27 +165,23 @@ func (mainThread *phpMainThread) overridePHPIni() { } } -// figure out how many threads can be started based on memory_limit from php.ini +// max_threads = auto +// Estimate the amount of threads, based on the system's memory limit +// and PHP's per-thread memory_limit (php.ini) func (mainThread *phpMainThread) setAutomaticMaxThreads() { if mainThread.maxThreads >= 0 { return } - perThreadMemoryLimit := uint64(C.frankenphp_get_current_memory_limit()) - if perThreadMemoryLimit <= 0 { + perThreadMemoryLimit := int64(C.frankenphp_get_current_memory_limit()) + totalSysMemory := memory.Total() + if perThreadMemoryLimit <= 0 || totalMemory == 0 { return } - maxAllowedThreads := getProcessAvailableMemory() / perThreadMemoryLimit + maxAllowedThreads := totalSysMemory / uint64(perThreadMemoryLimit) mainThread.maxThreads = int(maxAllowedThreads) logger.Info("Automatic thread limit", zap.Int("phpMemoryLimit(MB)", int(perThreadMemoryLimit/1024/1024)), zap.Int("maxThreads", mainThread.maxThreads)) } -// Gets all available memory in bytes -// Should be unix compatible - TODO: verify that it is on all important platforms -// On potential Windows support this would need to be done differently -func getProcessAvailableMemory() uint64 { - return uint64(C.sysconf(C._SC_PHYS_PAGES) * C.sysconf(C._SC_PAGE_SIZE)) -} - //export go_frankenphp_shutdown_main_thread func go_frankenphp_shutdown_main_thread() { mainThread.state.set(stateReserved) From a16de66ecaa24081779795eb9514fc980230dc52 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 10 Jan 2025 22:51:12 +0100 Subject: [PATCH 139/190] Renames var. --- phpmainthread.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/phpmainthread.go b/phpmainthread.go index 3f8138ae1..c883d74bf 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -173,11 +173,11 @@ func (mainThread *phpMainThread) setAutomaticMaxThreads() { return } perThreadMemoryLimit := int64(C.frankenphp_get_current_memory_limit()) - totalSysMemory := memory.Total() - if perThreadMemoryLimit <= 0 || totalMemory == 0 { + totalOSMemory := memory.Total() + if perThreadMemoryLimit <= 0 || totalOSMemory == 0 { return } - maxAllowedThreads := totalSysMemory / uint64(perThreadMemoryLimit) + maxAllowedThreads := totalOSMemory / uint64(perThreadMemoryLimit) mainThread.maxThreads = int(maxAllowedThreads) logger.Info("Automatic thread limit", zap.Int("phpMemoryLimit(MB)", int(perThreadMemoryLimit/1024/1024)), zap.Int("maxThreads", mainThread.maxThreads)) } From 6a6d040da586928e88d43979529cc3af9e8603d0 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 10 Jan 2025 23:10:51 +0100 Subject: [PATCH 140/190] Reduces the stall-time. --- scaling.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scaling.go b/scaling.go index a8506613a..9045a4da9 100644 --- a/scaling.go +++ b/scaling.go @@ -16,7 +16,7 @@ import ( // TODO: make speed of scaling dependant on CPU count? const ( // requests have to be stalled for at least this amount of time before scaling - minStallTime = 20 * time.Millisecond + minStallTime = 5 * time.Millisecond // time to check for CPU usage before scaling a single thread cpuProbeTime = 100 * time.Millisecond // do not scale over this amount of CPU usage From 83e0c0806c0dcd9de8911d85d9bbbf860d87c18e Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 10 Jan 2025 23:30:06 +0100 Subject: [PATCH 141/190] Adjusts scaling logic and comments. --- scaling.go | 24 +++++++++++++----------- scaling_test.go | 16 ++-------------- worker.go | 4 +++- 3 files changed, 18 insertions(+), 26 deletions(-) diff --git a/scaling.go b/scaling.go index 9045a4da9..41a0ba34d 100644 --- a/scaling.go +++ b/scaling.go @@ -56,10 +56,11 @@ func initAutoScaling(numThreads int, maxThreads int) { func drainAutoScaling() { scalingMu.Lock() + logger.Debug("shutting down autoscalin", zap.Int("num scaled threads", len(autoScaledThreads))) scalingMu.Unlock() } -// turn the first inactive/reserved thread into a regular thread +// AddRegularThread adds one regular PHP thread at runtime if max_threads are not yet reached func AddRegularThread() (int, error) { scalingMu.Lock() defer scalingMu.Unlock() @@ -77,7 +78,7 @@ func addRegularThread() (*phpThread, error) { return thread, nil } -// remove the last regular thread +// RemoveRegularThread removes one regular PHP thread at runtime, won't remove the last thread func RemoveRegularThread() (int, error) { scalingMu.Lock() defer scalingMu.Unlock() @@ -97,7 +98,7 @@ func removeRegularThread() error { return nil } -// turn the first inactive/reserved thread into a worker thread +// AddWorkerThread adds one PHP worker thread at runtime if max_threads are not yet reached func AddWorkerThread(workerFileName string) (int, error) { worker, ok := workers[workerFileName] if !ok { @@ -119,7 +120,7 @@ func addWorkerThread(worker *worker) (*phpThread, error) { return thread, nil } -// remove the last worker thread +// RemoveWorkerThread removes one PHP worker thread at runtime, won't remove the last thread func RemoveWorkerThread(workerFileName string) (int, error) { worker, ok := workers[workerFileName] if !ok { @@ -254,14 +255,15 @@ func deactivateThreads() { continue } + // TODO: reactivate thread-shutdown once there's a way around leaky PECL extensions like #1299 // if threads are already inactive, shut them down - if thread.state.is(stateInactive) && waitTime > maxThreadIdleTime.Milliseconds() { - logger.Debug("auto-stopping thread", zap.Int("threadIndex", thread.threadIndex)) - thread.shutdown() - stoppedThreadCount++ - autoScaledThreads = append(autoScaledThreads[:i], autoScaledThreads[i+1:]...) - continue - } + //if thread.state.is(stateInactive) && waitTime > maxThreadIdleTime.Milliseconds() { + // logger.Debug("auto-stopping thread", zap.Int("threadIndex", thread.threadIndex)) + // thread.shutdown() + // stoppedThreadCount++ + // autoScaledThreads = append(autoScaledThreads[:i], autoScaledThreads[i+1:]...) + // continue + //} } } diff --git a/scaling_test.go b/scaling_test.go index b1658462c..883757c41 100644 --- a/scaling_test.go +++ b/scaling_test.go @@ -22,17 +22,11 @@ func TestScaleARegularThreadUpAndDown(t *testing.T) { assert.Equal(t, stateReady, autoScaledThread.state.get()) assert.IsType(t, ®ularThread{}, autoScaledThread.handler) - // on the first down-scale, the thread will be marked as inactive + // on down-scale, the thread will be marked as inactive setLongWaitTime(autoScaledThread) deactivateThreads() assert.IsType(t, &inactiveThread{}, autoScaledThread.handler) - // on the second down-scale, the thread will be removed - autoScaledThread.state.waitFor(stateInactive) - setLongWaitTime(autoScaledThread) - deactivateThreads() - assert.Equal(t, stateReserved, autoScaledThread.state.get()) - Shutdown() } @@ -51,17 +45,11 @@ func TestScaleAWorkerThreadUpAndDown(t *testing.T) { scaleWorkerThread(workers[workerPath]) assert.Equal(t, stateReady, autoScaledThread.state.get()) - // on the first down-scale, the thread will be marked as inactive + // on down-scale, the thread will be marked as inactive setLongWaitTime(autoScaledThread) deactivateThreads() assert.IsType(t, &inactiveThread{}, autoScaledThread.handler) - // on the second down-scale, the thread will be removed - autoScaledThread.state.waitFor(stateInactive) - setLongWaitTime(autoScaledThread) - deactivateThreads() - assert.Equal(t, stateReserved, autoScaledThread.state.get()) - Shutdown() } diff --git a/worker.go b/worker.go index 5f76e0482..a8ae5f5f2 100644 --- a/worker.go +++ b/worker.go @@ -90,6 +90,7 @@ func drainWorkers() { watcher.DrainWatcher() } +// RestartWorkers attempts to restart all worker threads gracefully func RestartWorkers() { // disallow scaling threads while restarting workers scalingMu.Lock() @@ -124,9 +125,10 @@ func RestartWorkers() { } } +// WorkerFileNames returns the list of worker file names func WorkerFileNames() []string { workerNames := make([]string, 0, len(workers)) - for fileName, _ := range workers { + for fileName := range workers { workerNames = append(workerNames, fileName) } return workerNames From 79447b843f2c6e3924e640fbb95043c70868460a Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 10 Jan 2025 23:45:06 +0100 Subject: [PATCH 142/190] Swaps to unix sys package. --- frankenphp.go | 2 +- internal/memory/memory_bsd.go | 15 --------------- internal/memory/memory_darwin.go | 13 ------------- internal/memory/memory_linux.go | 16 ---------------- internal/memory/memory_unix.go | 13 +++++++++++++ phpmainthread.go | 5 +++-- scaling.go | 2 +- 7 files changed, 18 insertions(+), 48 deletions(-) delete mode 100644 internal/memory/memory_bsd.go delete mode 100644 internal/memory/memory_darwin.go delete mode 100644 internal/memory/memory_linux.go create mode 100644 internal/memory/memory_unix.go diff --git a/frankenphp.go b/frankenphp.go index 244fc5bf6..e8d93d7ce 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -351,7 +351,7 @@ func Init(options ...Option) error { initAutoScaling(mainThread.numThreads, mainThread.maxThreads) if c := logger.Check(zapcore.InfoLevel, "FrankenPHP started 🐘"); c != nil { - c.Write(zap.String("php_version", Version().Version), zap.Int("num_threads", totalThreadCount), zap.Int("max_threads", maxThreadCount)) + c.Write(zap.String("php_version", Version().Version), zap.Int("num_threads", mainThread.numThreads), zap.Int("max_threads", mainThread.maxThreads)) } if EmbeddedAppPath != "" { if c := logger.Check(zapcore.InfoLevel, "embedded PHP app 📦"); c != nil { diff --git a/internal/memory/memory_bsd.go b/internal/memory/memory_bsd.go deleted file mode 100644 index 9800360d6..000000000 --- a/internal/memory/memory_bsd.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build freebsd || openbsd || dragonfly || netbsd -// +build freebsd openbsd dragonfly netbsd - -package memory - -// cross platform total system memory -// inspired by: https://github.com/pbnjay/memory -func Total() uint64 { - availBytes, err := sysctlUint64("hw.physmem") - if err != nil { - return 0 - } - - return availBytes -} diff --git a/internal/memory/memory_darwin.go b/internal/memory/memory_darwin.go deleted file mode 100644 index 8632a0ebe..000000000 --- a/internal/memory/memory_darwin.go +++ /dev/null @@ -1,13 +0,0 @@ -//go:build darwin -// +build darwin - -package memory - -func Total() uint64 { - availBytes, err := sysctlUint64("hw.memsize") - if err != nil { - return 0 - } - - return availBytes -} diff --git a/internal/memory/memory_linux.go b/internal/memory/memory_linux.go deleted file mode 100644 index 459331c7c..000000000 --- a/internal/memory/memory_linux.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build linux -// +build linux - -package memory - -import "syscall" - -func Total() uint64 { - sysInfo := &syscall.Sysinfo_t{} - err := syscall.Sysinfo(sysInfo) - if err != nil { - return 0 - } - - return uint64(sysInfo.Totalram) * uint64(sysInfo.Unit) -} diff --git a/internal/memory/memory_unix.go b/internal/memory/memory_unix.go new file mode 100644 index 000000000..6a1da080f --- /dev/null +++ b/internal/memory/memory_unix.go @@ -0,0 +1,13 @@ +package memory + +import "golang.org/x/sys/unix" + +func TotalSysMemory() uint64 { + sysInfo := &unix.Sysinfo_t{} + err := unix.Sysinfo(sysInfo) + if err != nil { + return 0 + } + + return uint64(sysInfo.Totalram) * uint64(sysInfo.Unit) +} diff --git a/phpmainthread.go b/phpmainthread.go index c883d74bf..604a3fce4 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -70,6 +70,7 @@ func initPHPThreads(numThreads int, numMaxThreads int, phpIniOverrides map[strin return nil } +// ThreadDebugStatus prints the state of all PHP threads - debugging purposes only func ThreadDebugStatus() string { statusMessage := "" reservedThreadCount := 0 @@ -173,13 +174,13 @@ func (mainThread *phpMainThread) setAutomaticMaxThreads() { return } perThreadMemoryLimit := int64(C.frankenphp_get_current_memory_limit()) - totalOSMemory := memory.Total() + totalOSMemory := memory.TotalSysMemory() if perThreadMemoryLimit <= 0 || totalOSMemory == 0 { return } maxAllowedThreads := totalOSMemory / uint64(perThreadMemoryLimit) mainThread.maxThreads = int(maxAllowedThreads) - logger.Info("Automatic thread limit", zap.Int("phpMemoryLimit(MB)", int(perThreadMemoryLimit/1024/1024)), zap.Int("maxThreads", mainThread.maxThreads)) + logger.Info("Automatic thread limit", zap.Int("perThreadMemoryLimitMB", int(perThreadMemoryLimit/1024/1024)), zap.Int("maxThreads", mainThread.maxThreads)) } //export go_frankenphp_shutdown_main_thread diff --git a/scaling.go b/scaling.go index 41a0ba34d..a47d51b3e 100644 --- a/scaling.go +++ b/scaling.go @@ -56,7 +56,7 @@ func initAutoScaling(numThreads int, maxThreads int) { func drainAutoScaling() { scalingMu.Lock() - logger.Debug("shutting down autoscalin", zap.Int("num scaled threads", len(autoScaledThreads))) + logger.Debug("shutting down autoscalin", zap.Int("autoScaledThreads", len(autoScaledThreads))) scalingMu.Unlock() } From 0545e1b310ca2553c8ed2abcbc1b28d867140034 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 11 Jan 2025 13:23:08 +0100 Subject: [PATCH 143/190] Only supports CPU probing on unix and SysMemory on Linux. --- frankenphp.c | 3 +- internal/cpu/cpu_fallback.go | 20 ++++++++ internal/cpu/cpu_unix.go | 41 +++++++++++++++ internal/memory/memory_fallback.go | 12 +++++ .../{memory_unix.go => memory_linux.go} | 9 ++-- phpmainthread.go | 13 ++--- scaling.go | 51 ++++++------------- worker.go | 4 +- 8 files changed, 106 insertions(+), 47 deletions(-) create mode 100644 internal/cpu/cpu_fallback.go create mode 100644 internal/cpu/cpu_unix.go create mode 100644 internal/memory/memory_fallback.go rename internal/memory/{memory_unix.go => memory_linux.go} (54%) diff --git a/frankenphp.c b/frankenphp.c index 73c3dd981..54a5a2228 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1226,10 +1226,11 @@ int frankenphp_reset_opcache(void) { return 0; } +/* Overwrite the php.ini configuration before starting a script */ void frankenphp_overwrite_ini_configuraton(go_string key, go_string value) { zend_string *z_key = zend_string_init(key.data, key.len, 0); zend_string *z_value = zend_string_init(value.data, value.len, 0); - zend_alter_ini_entry(z_key, z_value, PHP_INI_SYSTEM, PHP_INI_STAGE_ACTIVATE); + zend_alter_ini_entry(z_key, z_value, PHP_INI_USER, PHP_INI_STAGE_ACTIVATE); zend_string_release_ex(z_key, 0); zend_string_release_ex(z_value, 0); } diff --git a/internal/cpu/cpu_fallback.go b/internal/cpu/cpu_fallback.go new file mode 100644 index 000000000..a8171e1b3 --- /dev/null +++ b/internal/cpu/cpu_fallback.go @@ -0,0 +1,20 @@ +//go:build !unix + +// -build unix + +package cpu + +import ( + "time" +) + +// The fallback always determines that the CPU limits are not reached +func ProbeCPUs(probeTime time.Duration, maxCPUUsage float64, abort chan struct{}) bool { + timer := time.NewTimer(probeTime) + select { + case <-abort: + return false + case <-timer.C: + return true + } +} diff --git a/internal/cpu/cpu_unix.go b/internal/cpu/cpu_unix.go new file mode 100644 index 000000000..333809dc3 --- /dev/null +++ b/internal/cpu/cpu_unix.go @@ -0,0 +1,41 @@ +//go:build unix +// +build unix + +package cpu + +// #include +import "C" +import ( + "runtime" + "time" +) + +var cpuCount = runtime.NumCPU() + +// probe the CPU usage of the process +// if CPUs are not busy, most threads are likely waiting for I/O, so we should scale +// if CPUs are already busy we won't gain much by scaling and want to avoid the overhead of doing so +func ProbeCPUs(probeTime time.Duration, maxCPUUsage float64, abort chan struct{}) bool { + var start, end, cpuStart, cpuEnd C.struct_timespec + + // note: clock_gettime is a POSIX function + // on Windows we'd need to use QueryPerformanceCounter instead + C.clock_gettime(C.CLOCK_MONOTONIC, &start) + C.clock_gettime(C.CLOCK_PROCESS_CPUTIME_ID, &cpuStart) + + timer := time.NewTimer(probeTime) + select { + case <-abort: + return false + case <-timer.C: + } + + C.clock_gettime(C.CLOCK_MONOTONIC, &end) + C.clock_gettime(C.CLOCK_PROCESS_CPUTIME_ID, &cpuEnd) + + elapsedTime := float64(end.tv_sec-start.tv_sec)*1e9 + float64(end.tv_nsec-start.tv_nsec) + elapsedCpuTime := float64(cpuEnd.tv_sec-cpuStart.tv_sec)*1e9 + float64(cpuEnd.tv_nsec-cpuStart.tv_nsec) + cpuUsage := elapsedCpuTime / elapsedTime / float64(cpuCount) + + return cpuUsage < maxCPUUsage +} diff --git a/internal/memory/memory_fallback.go b/internal/memory/memory_fallback.go new file mode 100644 index 000000000..bc90c802a --- /dev/null +++ b/internal/memory/memory_fallback.go @@ -0,0 +1,12 @@ +//go:build !linux + +// -build linux + +package memory + +import "syscall" + +// Return 0 if the total system memory cannot be determined +func TotalSysMemory() uint64 { + return 0 +} diff --git a/internal/memory/memory_unix.go b/internal/memory/memory_linux.go similarity index 54% rename from internal/memory/memory_unix.go rename to internal/memory/memory_linux.go index 6a1da080f..c648bffec 100644 --- a/internal/memory/memory_unix.go +++ b/internal/memory/memory_linux.go @@ -1,10 +1,13 @@ +//go:build linux +// +build linux + package memory -import "golang.org/x/sys/unix" +import "syscall" func TotalSysMemory() uint64 { - sysInfo := &unix.Sysinfo_t{} - err := unix.Sysinfo(sysInfo) + sysInfo := &syscall.Sysinfo_t{} + err := syscall.Sysinfo(sysInfo) if err != nil { return 0 } diff --git a/phpmainthread.go b/phpmainthread.go index 604a3fce4..cd1d71111 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -167,20 +167,21 @@ func (mainThread *phpMainThread) overridePHPIni() { } // max_threads = auto -// Estimate the amount of threads, based on the system's memory limit -// and PHP's per-thread memory_limit (php.ini) +// Estimate the amount of threads based on php.ini and system memory_limit +// If unable to get the system's memory limit, simply double num_threads func (mainThread *phpMainThread) setAutomaticMaxThreads() { if mainThread.maxThreads >= 0 { return } perThreadMemoryLimit := int64(C.frankenphp_get_current_memory_limit()) - totalOSMemory := memory.TotalSysMemory() - if perThreadMemoryLimit <= 0 || totalOSMemory == 0 { + totalSysMemory := memory.TotalSysMemory() + if perThreadMemoryLimit <= 0 || totalSysMemory == 0 { + mainThread.maxThreads = mainThread.numThreads * 2 return } - maxAllowedThreads := totalOSMemory / uint64(perThreadMemoryLimit) + maxAllowedThreads := totalSysMemory / uint64(perThreadMemoryLimit) mainThread.maxThreads = int(maxAllowedThreads) - logger.Info("Automatic thread limit", zap.Int("perThreadMemoryLimitMB", int(perThreadMemoryLimit/1024/1024)), zap.Int("maxThreads", mainThread.maxThreads)) + logger.Debug("Automatic thread limit", zap.Int("perThreadMemoryLimitMB", int(perThreadMemoryLimit/1024/1024)), zap.Int("maxThreads", mainThread.maxThreads)) } //export go_frankenphp_shutdown_main_thread diff --git a/scaling.go b/scaling.go index a47d51b3e..c41171c70 100644 --- a/scaling.go +++ b/scaling.go @@ -5,11 +5,11 @@ package frankenphp import "C" import ( "errors" - "runtime" "sync" "sync/atomic" "time" + "github.com/dunglas/frankenphp/internal/cpu" "go.uber.org/zap" ) @@ -35,7 +35,6 @@ var ( autoScaledThreads = []*phpThread{} scaleChan = make(chan *FrankenPHPContext) scalingMu = new(sync.RWMutex) - cpuCount = runtime.NumCPU() disallowScaling = atomic.Bool{} MaxThreadsReachedError = errors.New("max amount of overall threads reached") @@ -151,7 +150,12 @@ func scaleWorkerThread(worker *worker) { scalingMu.Lock() defer scalingMu.Unlock() - if !mainThread.state.is(stateReady) || !probeCPUs(cpuProbeTime) { + if !mainThread.state.is(stateReady) { + return + } + + // probe CPU usage before scaling + if !cpu.ProbeCPUs(cpuProbeTime, maxCpuUsageForScaling, mainThread.done) { return } @@ -169,7 +173,12 @@ func scaleRegularThread() { scalingMu.Lock() defer scalingMu.Unlock() - if !mainThread.state.is(stateReady) || !probeCPUs(cpuProbeTime) { + if !mainThread.state.is(stateReady) { + return + } + + // probe CPU usage before scaling + if !cpu.ProbeCPUs(cpuProbeTime, maxCpuUsageForScaling, mainThread.done) { return } @@ -255,8 +264,9 @@ func deactivateThreads() { continue } - // TODO: reactivate thread-shutdown once there's a way around leaky PECL extensions like #1299 - // if threads are already inactive, shut them down + // TODO: Completely stopping threads is more memory efficient + // Some PECL extensions like #1296 will prevent threads from fully stopping (they leak memory) + // Reactivate this if there is a better solution or workaround //if thread.state.is(stateInactive) && waitTime > maxThreadIdleTime.Milliseconds() { // logger.Debug("auto-stopping thread", zap.Int("threadIndex", thread.threadIndex)) // thread.shutdown() @@ -266,32 +276,3 @@ func deactivateThreads() { //} } } - -// probe the CPU usage of all PHP Threads -// if CPUs are not busy, most threads are likely waiting for I/O, so we should scale -// if CPUs are already busy we won't gain much by scaling and want to avoid the overhead of doing so -// time spent by the go runtime or other processes is not considered -func probeCPUs(probeTime time.Duration) bool { - var start, end, cpuStart, cpuEnd C.struct_timespec - - // note: clock_gettime is a POSIX function - // on Windows we'd need to use QueryPerformanceCounter instead - C.clock_gettime(C.CLOCK_MONOTONIC, &start) - C.clock_gettime(C.CLOCK_PROCESS_CPUTIME_ID, &cpuStart) - - timer := time.NewTimer(probeTime) - select { - case <-mainThread.done: - return false - case <-timer.C: - } - - C.clock_gettime(C.CLOCK_MONOTONIC, &end) - C.clock_gettime(C.CLOCK_PROCESS_CPUTIME_ID, &cpuEnd) - - elapsedTime := float64(end.tv_sec-start.tv_sec)*1e9 + float64(end.tv_nsec-start.tv_nsec) - elapsedCpuTime := float64(cpuEnd.tv_sec-cpuStart.tv_sec)*1e9 + float64(cpuEnd.tv_nsec-cpuStart.tv_nsec) - cpuUsage := elapsedCpuTime / elapsedTime / float64(cpuCount) - - return cpuUsage < maxCpuUsageForScaling -} diff --git a/worker.go b/worker.go index a8ae5f5f2..5cf9657f3 100644 --- a/worker.go +++ b/worker.go @@ -90,7 +90,7 @@ func drainWorkers() { watcher.DrainWatcher() } -// RestartWorkers attempts to restart all worker threads gracefully +// RestartWorkers attempts to restart all workers gracefully func RestartWorkers() { // disallow scaling threads while restarting workers scalingMu.Lock() @@ -125,7 +125,7 @@ func RestartWorkers() { } } -// WorkerFileNames returns the list of worker file names +// WorkerFileNames returns the absolute path to all worker files func WorkerFileNames() []string { workerNames := make([]string, 0, len(workers)) for fileName := range workers { From 615662e1ef6ff461b1d2d10ec70853e26065cc3f Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sat, 11 Jan 2025 14:44:09 +0100 Subject: [PATCH 144/190] Adjusts timers and passes the main thread directly to scaling. --- frankenphp.go | 5 +++-- internal/cpu/cpu_fallback.go | 3 +-- internal/cpu/cpu_unix.go | 3 +-- internal/memory/memory_fallback.go | 2 -- phpmainthread.go | 6 +++--- phpmainthread_test.go | 11 +++++++---- scaling.go | 30 ++++++++++++++++++++---------- 7 files changed, 35 insertions(+), 25 deletions(-) diff --git a/frankenphp.go b/frankenphp.go index e8d93d7ce..e30db7b6b 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -333,7 +333,8 @@ func Init(options ...Option) error { logger.Warn(`ZTS is not enabled, only 1 thread will be available, recompile PHP using the "--enable-zts" configuration option or performance will be degraded`) } - if err := initPHPThreads(totalThreadCount, maxThreadCount, opt.phpIniOverrides); err != nil { + mainThread, err := initPHPThreads(totalThreadCount, maxThreadCount, opt.phpIniOverrides) + if err != nil { return err } @@ -348,7 +349,7 @@ func Init(options ...Option) error { return err } - initAutoScaling(mainThread.numThreads, mainThread.maxThreads) + initAutoScaling(mainThread) if c := logger.Check(zapcore.InfoLevel, "FrankenPHP started 🐘"); c != nil { c.Write(zap.String("php_version", Version().Version), zap.Int("num_threads", mainThread.numThreads), zap.Int("max_threads", mainThread.maxThreads)) diff --git a/internal/cpu/cpu_fallback.go b/internal/cpu/cpu_fallback.go index a8171e1b3..a0ec2890e 100644 --- a/internal/cpu/cpu_fallback.go +++ b/internal/cpu/cpu_fallback.go @@ -10,11 +10,10 @@ import ( // The fallback always determines that the CPU limits are not reached func ProbeCPUs(probeTime time.Duration, maxCPUUsage float64, abort chan struct{}) bool { - timer := time.NewTimer(probeTime) select { case <-abort: return false - case <-timer.C: + case <-time.After(probeTime): return true } } diff --git a/internal/cpu/cpu_unix.go b/internal/cpu/cpu_unix.go index 333809dc3..b1c0e2a09 100644 --- a/internal/cpu/cpu_unix.go +++ b/internal/cpu/cpu_unix.go @@ -23,11 +23,10 @@ func ProbeCPUs(probeTime time.Duration, maxCPUUsage float64, abort chan struct{} C.clock_gettime(C.CLOCK_MONOTONIC, &start) C.clock_gettime(C.CLOCK_PROCESS_CPUTIME_ID, &cpuStart) - timer := time.NewTimer(probeTime) select { case <-abort: return false - case <-timer.C: + case <-time.After(probeTime): } C.clock_gettime(C.CLOCK_MONOTONIC, &end) diff --git a/internal/memory/memory_fallback.go b/internal/memory/memory_fallback.go index bc90c802a..cbf42865f 100644 --- a/internal/memory/memory_fallback.go +++ b/internal/memory/memory_fallback.go @@ -4,8 +4,6 @@ package memory -import "syscall" - // Return 0 if the total system memory cannot be determined func TotalSysMemory() uint64 { return 0 diff --git a/phpmainthread.go b/phpmainthread.go index cd1d71111..5b812775c 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -28,7 +28,7 @@ var ( // start the main PHP thread // start a fixed number of inactive PHP threads // reserve a fixed number of possible PHP threads -func initPHPThreads(numThreads int, numMaxThreads int, phpIniOverrides map[string]string) error { +func initPHPThreads(numThreads int, numMaxThreads int, phpIniOverrides map[string]string) (*phpMainThread, error) { mainThread = &phpMainThread{ state: newThreadState(), done: make(chan struct{}), @@ -45,7 +45,7 @@ func initPHPThreads(numThreads int, numMaxThreads int, phpIniOverrides map[strin phpThreads = []*phpThread{initialThread} if err := mainThread.start(); err != nil { - return err + return nil, err } // initialize all other threads @@ -67,7 +67,7 @@ func initPHPThreads(numThreads int, numMaxThreads int, phpIniOverrides map[strin } ready.Wait() - return nil + return mainThread, nil } // ThreadDebugStatus prints the state of all PHP threads - debugging purposes only diff --git a/phpmainthread_test.go b/phpmainthread_test.go index 0db0bcc87..6980da065 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -17,8 +17,9 @@ import ( var testDataPath, _ = filepath.Abs("./testdata") func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { - logger = zap.NewNop() // the logger needs to not be nil - assert.NoError(t, initPHPThreads(1, 1, nil)) // boot 1 thread + logger = zap.NewNop() // the logger needs to not be nil + _, err := initPHPThreads(1, 1, nil) // boot 1 thread + assert.NoError(t, err) assert.Len(t, phpThreads, 1) assert.Equal(t, 0, phpThreads[0].threadIndex) @@ -30,7 +31,8 @@ func TestStartAndStopTheMainThreadWithOneInactiveThread(t *testing.T) { func TestTransitionRegularThreadToWorkerThread(t *testing.T) { logger = zap.NewNop() - assert.NoError(t, initPHPThreads(1, 1, nil)) + _, err := initPHPThreads(1, 1, nil) + assert.NoError(t, err) // transition to regular thread convertToRegularThread(phpThreads[0]) @@ -53,7 +55,8 @@ func TestTransitionRegularThreadToWorkerThread(t *testing.T) { func TestTransitionAThreadBetween2DifferentWorkers(t *testing.T) { logger = zap.NewNop() - assert.NoError(t, initPHPThreads(1, 1, nil)) + _, err := initPHPThreads(1, 1, nil) + assert.NoError(t, err) firstWorker := getDummyWorker("transition-worker-1.php") secondWorker := getDummyWorker("transition-worker-2.php") diff --git a/scaling.go b/scaling.go index c41171c70..8d268c1ec 100644 --- a/scaling.go +++ b/scaling.go @@ -42,13 +42,16 @@ var ( WorkerNotFoundError = errors.New("worker not found for given filename") ) -func initAutoScaling(numThreads int, maxThreads int) { - if maxThreads <= numThreads { +func initAutoScaling(mainThread *phpMainThread) { + if mainThread.maxThreads <= mainThread.numThreads { return } - maxScaledThreads := maxThreads - numThreads + maxScaledThreads := mainThread.maxThreads - mainThread.numThreads + scalingMu.Lock() autoScaledThreads = make([]*phpThread, 0, maxScaledThreads) + scalingMu.Unlock() + go startUpscalingThreads(mainThread.done, maxScaledThreads) go startDownScalingThreads(mainThread.done) } @@ -197,8 +200,13 @@ func startUpscalingThreads(done chan struct{}, maxScaledThreads int) { scaledThreadCount := len(autoScaledThreads) scalingMu.Unlock() if scaledThreadCount >= maxScaledThreads { - time.Sleep(upscaleCheckTime) - continue + // we have reached max_threads, check again later + select { + case <-done: + return + case <-time.After(downScaleCheckTime): + continue + } } select { @@ -207,8 +215,12 @@ func startUpscalingThreads(done chan struct{}, maxScaledThreads int) { // if the request has not been stalled long enough, wait and repeat if timeSinceStalled < minStallTime { - time.Sleep(upscaleCheckTime) - continue + select { + case <-done: + return + case <-time.After(minStallTime - timeSinceStalled): + continue + } } // if the request has been stalled long enough, scale @@ -224,14 +236,12 @@ func startUpscalingThreads(done chan struct{}, maxScaledThreads int) { } func startDownScalingThreads(done chan struct{}) { - timer := time.NewTimer(downScaleCheckTime) for { select { case <-done: return - case <-timer.C: + case <-time.After(downScaleCheckTime): deactivateThreads() - timer.Reset(downScaleCheckTime) } } } From 618345b62c407faba25b6849e7423fd76306ba8a Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 12 Jan 2025 23:49:21 +0100 Subject: [PATCH 145/190] Properly overrides ini settings on startup. --- frankenphp.c | 15 ++++++--------- frankenphp.h | 1 - phpmainthread.go | 40 +++++++++++++++++++--------------------- phpthread.go | 3 --- 4 files changed, 25 insertions(+), 34 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 54a5a2228..b6835de94 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -949,6 +949,12 @@ static void *php_main(void *arg) { #endif #endif + /* overwrite php.ini with settings from the Caddy config */ + char *php_ini_overrides = go_get_php_ini_overrides(); + if (php_ini_overrides != NULL) { + frankenphp_sapi_module.ini_entries = php_ini_overrides; + } + frankenphp_sapi_module.startup(&frankenphp_sapi_module); /* check if a default filter is set in php.ini and only filter if @@ -1226,13 +1232,4 @@ int frankenphp_reset_opcache(void) { return 0; } -/* Overwrite the php.ini configuration before starting a script */ -void frankenphp_overwrite_ini_configuraton(go_string key, go_string value) { - zend_string *z_key = zend_string_init(key.data, key.len, 0); - zend_string *z_value = zend_string_init(value.data, value.len, 0); - zend_alter_ini_entry(z_key, z_value, PHP_INI_USER, PHP_INI_STAGE_ACTIVATE); - zend_string_release_ex(z_key, 0); - zend_string_release_ex(z_value, 0); -} - int frankenphp_get_current_memory_limit() { return PG(memory_limit); } diff --git a/frankenphp.h b/frankenphp.h index 8050f2146..c5b9844e2 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -71,7 +71,6 @@ void frankenphp_register_variable_safe(char *key, char *var, size_t val_len, zend_string *frankenphp_init_persistent_string(const char *string, size_t len); void frankenphp_release_zend_string(zend_string *z_string); int frankenphp_reset_opcache(void); -void frankenphp_overwrite_ini_configuraton(go_string key, go_string value); int frankenphp_get_current_memory_limit(); void frankenphp_register_bulk( diff --git a/phpmainthread.go b/phpmainthread.go index 5b812775c..84c45c7c6 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -17,7 +17,7 @@ type phpMainThread struct { done chan struct{} numThreads int maxThreads int - phpIniOverrides map[string]string + phpIniOverrides *C.char } var ( @@ -30,11 +30,19 @@ var ( // reserve a fixed number of possible PHP threads func initPHPThreads(numThreads int, numMaxThreads int, phpIniOverrides map[string]string) (*phpMainThread, error) { mainThread = &phpMainThread{ - state: newThreadState(), - done: make(chan struct{}), - numThreads: numThreads, - maxThreads: numMaxThreads, - phpIniOverrides: phpIniOverrides, + state: newThreadState(), + done: make(chan struct{}), + numThreads: numThreads, + maxThreads: numMaxThreads, + } + + // convert the php.ini overrides from the Caddy config to a C string + if phpIniOverrides != nil { + overrides := "" + for k, v := range phpIniOverrides { + overrides += fmt.Sprintf("%s=%s\n", k, v) + } + mainThread.phpIniOverrides = C.CString(overrides) } // initialize the first thread @@ -142,7 +150,6 @@ func getPHPThreadAtState(state stateID) *phpThread { //export go_frankenphp_main_thread_is_ready func go_frankenphp_main_thread_is_ready() { - mainThread.overridePHPIni() mainThread.setAutomaticMaxThreads() if mainThread.maxThreads < mainThread.numThreads { mainThread.maxThreads = mainThread.numThreads @@ -152,20 +159,6 @@ func go_frankenphp_main_thread_is_ready() { mainThread.state.waitFor(stateDone) } -// override php.ini directives with those set in the Caddy config -// this needs to happen on each thread and before script execution -func (mainThread *phpMainThread) overridePHPIni() { - if mainThread.phpIniOverrides == nil { - return - } - for k, v := range mainThread.phpIniOverrides { - C.frankenphp_overwrite_ini_configuraton( - C.go_string{C.size_t(len(k)), toUnsafeChar(k)}, - C.go_string{C.size_t(len(v)), toUnsafeChar(v)}, - ) - } -} - // max_threads = auto // Estimate the amount of threads based on php.ini and system memory_limit // If unable to get the system's memory limit, simply double num_threads @@ -188,3 +181,8 @@ func (mainThread *phpMainThread) setAutomaticMaxThreads() { func go_frankenphp_shutdown_main_thread() { mainThread.state.set(stateReserved) } + +//export go_get_php_ini_overrides +func go_get_php_ini_overrides() *C.char { + return mainThread.phpIniOverrides +} diff --git a/phpthread.go b/phpthread.go index d590ec7c9..05ee574f9 100644 --- a/phpthread.go +++ b/phpthread.go @@ -153,9 +153,6 @@ func go_frankenphp_before_script_execution(threadIndex C.uintptr_t) *C.char { return nil } - // overwrite php.ini config (if necessary) - mainThread.overridePHPIni() - // return the name of the PHP script that should be executed return thread.pinCString(scriptName) } From a8b2c7c996946874459a3ee2cc6d6247259ae3b6 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 12 Jan 2025 23:50:11 +0100 Subject: [PATCH 146/190] clang-format --- frankenphp.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frankenphp.c b/frankenphp.c index b6835de94..53072c14c 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -952,7 +952,7 @@ static void *php_main(void *arg) { /* overwrite php.ini with settings from the Caddy config */ char *php_ini_overrides = go_get_php_ini_overrides(); if (php_ini_overrides != NULL) { - frankenphp_sapi_module.ini_entries = php_ini_overrides; + frankenphp_sapi_module.ini_entries = php_ini_overrides; } frankenphp_sapi_module.startup(&frankenphp_sapi_module); From 9e4a9ca1acbd74aed1839046a88b6c50adf60252 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 13 Jan 2025 00:13:33 +0100 Subject: [PATCH 147/190] Adds php_ini error. --- caddy/caddy.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/caddy/caddy.go b/caddy/caddy.go index d2db39e0b..262ee350c 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -27,6 +27,8 @@ import ( const defaultDocumentRoot = "public" +var iniError = errors.New("'php_ini' must be in the format: php_ini \"\" \"\"") + func init() { caddy.RegisterModule(FrankenPHPApp{}) caddy.RegisterModule(FrankenPHPModule{}) @@ -147,16 +149,19 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { f.MaxThreads = int(v) case "php_ini": if !d.NextArg() { - return d.ArgErr() + return iniError } key := d.Val() if !d.NextArg() { - return d.ArgErr() + return iniError } if f.PhpIniOverrides == nil { f.PhpIniOverrides = make(map[string]string) } f.PhpIniOverrides[key] = d.Val() + if d.NextArg() { + return iniError + } case "worker": wc := workerConfig{} if d.NextArg() { From ca20704c3132efea3d73191fa17af8f0ba18ac52 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 13 Jan 2025 00:22:37 +0100 Subject: [PATCH 148/190] Adds ini config. --- docs/config.md | 14 +++++++++++++- phpmainthread.go | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/config.md b/docs/config.md index 983f9951d..3feb7a9e0 100644 --- a/docs/config.md +++ b/docs/config.md @@ -51,7 +51,8 @@ Optionally, the number of threads to create and [worker scripts](worker.md) to s { frankenphp { 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 (no scaling). + max_threads # Limits the number of additional PHP threads that can be started at runtime. Default: num_threads. + php_ini # Overrides a single PHP configuration. Can be specified more than once for multiple overrides. worker { file # Sets the path to the worker script. num # Sets the number of PHP threads to start, defaults to 2x the number of available CPUs. @@ -228,6 +229,17 @@ To load [additional PHP configuration files](https://www.php.net/manual/en/confi the `PHP_INI_SCAN_DIR` environment variable can be used. When set, PHP will load all the file with the `.ini` extension present in the given directories. +You can also overwrite single configurations using the `php_ini` directive in the `Caddyfile`: + +```caddyfile +{ + frankenphp { + php_ini memory_limit 256M + php_ini max_execution_time 15 + } +} +``` + ## Enable the Debug Mode When using the Docker image, set the `CADDY_GLOBAL_OPTIONS` environment variable to `debug` to enable the debug mode: diff --git a/phpmainthread.go b/phpmainthread.go index 84c45c7c6..fb3bb6541 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -37,6 +37,7 @@ func initPHPThreads(numThreads int, numMaxThreads int, phpIniOverrides map[strin } // convert the php.ini overrides from the Caddy config to a C string + // TODO: if needed this would also be possible on a per-thread basis if phpIniOverrides != nil { overrides := "" for k, v := range phpIniOverrides { From 3e20e8f69408d23710b59e6e4cdd4c518e6ebab5 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 15 Jan 2025 21:23:21 +0100 Subject: [PATCH 149/190] Moves ini overrides to #else. --- frankenphp.c | 4 ++-- testdata/Caddyfile | 53 +++++++++++++++++++++++----------------------- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 53072c14c..e2544de7f 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -947,13 +947,13 @@ static void *php_main(void *arg) { memcpy(frankenphp_sapi_module.ini_entries, HARDCODED_INI, sizeof(HARDCODED_INI)); #endif -#endif - +#else /* overwrite php.ini with settings from the Caddy config */ char *php_ini_overrides = go_get_php_ini_overrides(); if (php_ini_overrides != NULL) { frankenphp_sapi_module.ini_entries = php_ini_overrides; } +#endif frankenphp_sapi_module.startup(&frankenphp_sapi_module); diff --git a/testdata/Caddyfile b/testdata/Caddyfile index 03f772dec..69c49e000 100644 --- a/testdata/Caddyfile +++ b/testdata/Caddyfile @@ -1,35 +1,34 @@ { - debug + #debug frankenphp { - #worker ./index.php - } -} - -http:// { - log - route { - root * . - # Add trailing slash for directory requests - @canonicalPath { - file {path}/index.php - not path */ + max_threads 0 + worker { + file /go/src/app/testdata/worker-with-counter.php + #watch ./**/*.{php,yaml,yml,twig,env} + num 7 } - redir @canonicalPath {path}/ 308 - - # If the requested file does not exist, try index files - @indexFiles file { - try_files {path} {path}/index.php index.php - split_path .php - } - rewrite @indexFiles {http.matchers.file.relative} + #worker /go/src/app/testdata/debug.php + php_ini max_execution_time -1 + php_ini max_execution_time 5 + php_ini max_execution_time 6 + php_ini max_execution_time 7 + } + #servers { + #enable_full_duplex + #} - encode zstd br gzip + servers { + #metrics + } +} - # FrankenPHP! - @phpFiles path *.php - php @phpFiles - file_server +:80 { - respond 404 + route { + # everything that is not in /assets is handled by your index or worker PHP file + rewrite worker-with-counter.php + php { + root /go/src/app/testdata # explicitly adding the root here allows for better caching + } } } From 7ce4c09cdba28ef7554c9acb0167a83ce6c30e3b Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 15 Jan 2025 22:48:01 +0100 Subject: [PATCH 150/190] Adds some max_threads docs. --- docs/performance.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/performance.md b/docs/performance.md index c4e401a6f..e2cfbfa51 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -16,6 +16,15 @@ To find the right values, it's best to run load tests simulating real traffic. To configure the number of threads, use the `num_threads` option of the `php_server` and `php` directives. To change the number of workers, use the `num` option of the `worker` section of the `frankenphp` directive. +### `max_threads` + +While it's always better to know exactly what your traffic will look like, real-life applications tend to be more +unpredictable. The `max_threads` configuration is similar to FPM's `pm.max_children` and allows +FrankenPHP to automatically spawn additional threads at runtime up to the specified limit. `max_threads` can help you +figure out how many threads you need to handle your traffic and can make the server more resilient to latency spikes. +If set to `auto`, the limit will be estimated based on the `memory_limit` in your `php.ini`. If not able to do so, +`auto` will instead default to 2x `num_threads`. + ## Worker Mode Enabling [the worker mode](worker.md) dramatically improves performance, From 2b1427b8f859e5c9c0f839ebfcaca58fc671a80b Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 15 Jan 2025 22:48:32 +0100 Subject: [PATCH 151/190] Shows special debug message if in-between worker requests. --- phpthread.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/phpthread.go b/phpthread.go index 05ee574f9..d9c0c381c 100644 --- a/phpthread.go +++ b/phpthread.go @@ -116,12 +116,16 @@ func (thread *phpThread) debugStatus() string { reqState = fmt.Sprintf(", waiting for %dms", waitTime) } else if r := thread.getActiveRequest(); r != nil { fc := r.Context().Value(contextKey).(*FrankenPHPContext) - sinceMs := time.Since(fc.startedAt).Milliseconds() path := r.URL.Path if fc.originalRequest != nil { path = fc.originalRequest.URL.Path } - reqState = fmt.Sprintf(", handling %s for %dms ", path, sinceMs) + if fc.responseWriter == nil { + reqState = fmt.Sprintf(", executing worker script: %s ", path) + } else { + sinceMs := time.Since(fc.startedAt).Milliseconds() + reqState = fmt.Sprintf(", handling %s for %dms ", path, sinceMs) + } } return fmt.Sprintf("Thread %d (%s%s) %s", thread.threadIndex, thread.state.name(), reqState, thread.handler.name()) } From d3df9f99ac48aa7c6b6156f290acf7bb624cb077 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 15 Jan 2025 22:52:37 +0100 Subject: [PATCH 152/190] Refactors ini overrides. --- phpmainthread.go | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/phpmainthread.go b/phpmainthread.go index fb3bb6541..07057c2ca 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -17,7 +17,7 @@ type phpMainThread struct { done chan struct{} numThreads int maxThreads int - phpIniOverrides *C.char + phpIniOverrides map[string]string } var ( @@ -30,20 +30,11 @@ var ( // reserve a fixed number of possible PHP threads func initPHPThreads(numThreads int, numMaxThreads int, phpIniOverrides map[string]string) (*phpMainThread, error) { mainThread = &phpMainThread{ - state: newThreadState(), - done: make(chan struct{}), - numThreads: numThreads, - maxThreads: numMaxThreads, - } - - // convert the php.ini overrides from the Caddy config to a C string - // TODO: if needed this would also be possible on a per-thread basis - if phpIniOverrides != nil { - overrides := "" - for k, v := range phpIniOverrides { - overrides += fmt.Sprintf("%s=%s\n", k, v) - } - mainThread.phpIniOverrides = C.CString(overrides) + state: newThreadState(), + done: make(chan struct{}), + numThreads: numThreads, + maxThreads: numMaxThreads, + phpIniOverrides: phpIniOverrides, } // initialize the first thread @@ -185,5 +176,15 @@ func go_frankenphp_shutdown_main_thread() { //export go_get_php_ini_overrides func go_get_php_ini_overrides() *C.char { - return mainThread.phpIniOverrides + if mainThread.phpIniOverrides == nil { + return nil + } + + // convert the php.ini overrides from the Caddy config to a C string + // TODO: if needed this would also be possible on a per-thread basis + overrides := "" + for k, v := range mainThread.phpIniOverrides { + overrides += fmt.Sprintf("%s=%s\n", k, v) + } + return C.CString(overrides) } From 1df0c9b5c46869411108500b64c51aaf635c7bb4 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 15 Jan 2025 22:54:01 +0100 Subject: [PATCH 153/190] formatting. --- phpthread.go | 1 + 1 file changed, 1 insertion(+) diff --git a/phpthread.go b/phpthread.go index d9c0c381c..51b4e7538 100644 --- a/phpthread.go +++ b/phpthread.go @@ -17,6 +17,7 @@ import ( // identified by the index in the phpThreads slice type phpThread struct { runtime.Pinner + threadIndex int knownVariableKeys map[string]*C.zend_string requestChan chan *http.Request From e51c07d594fa8e5a9e29121f1e8a05abdf491919 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 15 Jan 2025 22:57:28 +0100 Subject: [PATCH 154/190] Changes TODO. --- scaling.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scaling.go b/scaling.go index 8d268c1ec..4b616bd41 100644 --- a/scaling.go +++ b/scaling.go @@ -13,12 +13,12 @@ import ( "go.uber.org/zap" ) -// TODO: make speed of scaling dependant on CPU count? +// TODO: these constants need some real-world trial const ( // requests have to be stalled for at least this amount of time before scaling minStallTime = 5 * time.Millisecond // time to check for CPU usage before scaling a single thread - cpuProbeTime = 100 * time.Millisecond + cpuProbeTime = 120 * time.Millisecond // do not scale over this amount of CPU usage maxCpuUsageForScaling = 0.8 // upscale stalled threads every x milliseconds From d43f6f089e15edcd12f6a6ce866044950dd4cd18 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 15 Jan 2025 23:00:50 +0100 Subject: [PATCH 155/190] trigger From 9c1c8a8c62c3967f14bdc98d35be075626b8cff9 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 15 Jan 2025 23:04:29 +0100 Subject: [PATCH 156/190] linting --- docs/performance.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/performance.md b/docs/performance.md index e2cfbfa51..7585c4ad6 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -16,11 +16,11 @@ To find the right values, it's best to run load tests simulating real traffic. To configure the number of threads, use the `num_threads` option of the `php_server` and `php` directives. To change the number of workers, use the `num` option of the `worker` section of the `frankenphp` directive. -### `max_threads` +### `max_threads` While it's always better to know exactly what your traffic will look like, real-life applications tend to be more unpredictable. The `max_threads` configuration is similar to FPM's `pm.max_children` and allows -FrankenPHP to automatically spawn additional threads at runtime up to the specified limit. `max_threads` can help you +FrankenPHP to automatically spawn additional threads at runtime up to the specified limit. `max_threads` can help you figure out how many threads you need to handle your traffic and can make the server more resilient to latency spikes. If set to `auto`, the limit will be estimated based on the `memory_limit` in your `php.ini`. If not able to do so, `auto` will instead default to 2x `num_threads`. From 825735a8ff39f1bf8ff8457fb5c55c13c583cdd2 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 15 Jan 2025 23:12:37 +0100 Subject: [PATCH 157/190] Resets Caddyfile. --- testdata/Caddyfile | 53 +++++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/testdata/Caddyfile b/testdata/Caddyfile index 69c49e000..03f772dec 100644 --- a/testdata/Caddyfile +++ b/testdata/Caddyfile @@ -1,34 +1,35 @@ { - #debug + debug frankenphp { - max_threads 0 - worker { - file /go/src/app/testdata/worker-with-counter.php - #watch ./**/*.{php,yaml,yml,twig,env} - num 7 - } - #worker /go/src/app/testdata/debug.php - php_ini max_execution_time -1 - php_ini max_execution_time 5 - php_ini max_execution_time 6 - php_ini max_execution_time 7 + #worker ./index.php } - #servers { - #enable_full_duplex - #} - - servers { - #metrics - } } -:80 { - +http:// { + log route { - # everything that is not in /assets is handled by your index or worker PHP file - rewrite worker-with-counter.php - php { - root /go/src/app/testdata # explicitly adding the root here allows for better caching - } + root * . + # Add trailing slash for directory requests + @canonicalPath { + file {path}/index.php + not path */ + } + redir @canonicalPath {path}/ 308 + + # If the requested file does not exist, try index files + @indexFiles file { + try_files {path} {path}/index.php index.php + split_path .php + } + rewrite @indexFiles {http.matchers.file.relative} + + encode zstd br gzip + + # FrankenPHP! + @phpFiles path *.php + php @phpFiles + file_server + + respond 404 } } From 7de097241a8364332180bcf7b31c3f5b0acbbe63 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 16 Jan 2025 09:57:01 +0100 Subject: [PATCH 158/190] phpIniOverrides -> phpIni --- caddy/caddy.go | 10 +++++----- frankenphp.c | 4 ++-- frankenphp.go | 2 +- options.go | 18 +++++++++--------- phpmainthread.go | 32 ++++++++++++++++---------------- 5 files changed, 33 insertions(+), 33 deletions(-) diff --git a/caddy/caddy.go b/caddy/caddy.go index 262ee350c..fb59bb7ed 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -64,7 +64,7 @@ type FrankenPHPApp struct { // Workers configures the worker scripts to start. Workers []workerConfig `json:"workers,omitempty"` // Overwrites the default php ini configuration - PhpIniOverrides map[string]string `json:"php_ini,omitempty"` + PhpIni map[string]string `json:"php_ini,omitempty"` } // CaddyModule returns the Caddy module information. @@ -84,7 +84,7 @@ func (f *FrankenPHPApp) Start() error { frankenphp.WithMaxThreads(f.MaxThreads), frankenphp.WithLogger(logger), frankenphp.WithMetrics(metrics), - frankenphp.WithPhpIniOverrides(f.PhpIniOverrides), + frankenphp.WithPhpIni(f.PhpIni), } for _, w := range f.Workers { opts = append(opts, frankenphp.WithWorkers(repl.ReplaceKnown(w.FileName, ""), w.Num, w.Env, w.Watch)) @@ -155,10 +155,10 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { if !d.NextArg() { return iniError } - if f.PhpIniOverrides == nil { - f.PhpIniOverrides = make(map[string]string) + if f.PhpIni == nil { + f.PhpIni = make(map[string]string) } - f.PhpIniOverrides[key] = d.Val() + f.PhpIni[key] = d.Val() if d.NextArg() { return iniError } diff --git a/frankenphp.c b/frankenphp.c index e2544de7f..bc382a7a3 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -948,8 +948,8 @@ static void *php_main(void *arg) { sizeof(HARDCODED_INI)); #endif #else - /* overwrite php.ini with settings from the Caddy config */ - char *php_ini_overrides = go_get_php_ini_overrides(); + /* overwrite php.ini with custom user settings */ + char *php_ini_overrides = go_get_custom_php_ini(); if (php_ini_overrides != NULL) { frankenphp_sapi_module.ini_entries = php_ini_overrides; } diff --git a/frankenphp.go b/frankenphp.go index e30db7b6b..5f6a66765 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -333,7 +333,7 @@ func Init(options ...Option) error { logger.Warn(`ZTS is not enabled, only 1 thread will be available, recompile PHP using the "--enable-zts" configuration option or performance will be degraded`) } - mainThread, err := initPHPThreads(totalThreadCount, maxThreadCount, opt.phpIniOverrides) + mainThread, err := initPHPThreads(totalThreadCount, maxThreadCount, opt.phpIni) if err != nil { return err } diff --git a/options.go b/options.go index 3c8e58fd2..6d1a5ebd2 100644 --- a/options.go +++ b/options.go @@ -11,12 +11,12 @@ type Option func(h *opt) error // // If you change this, also update the Caddy module and the documentation. type opt struct { - numThreads int - maxThreads int - workers []workerOpt - logger *zap.Logger - metrics Metrics - phpIniOverrides map[string]string + numThreads int + maxThreads int + workers []workerOpt + logger *zap.Logger + metrics Metrics + phpIni map[string]string } type workerOpt struct { @@ -69,10 +69,10 @@ func WithLogger(l *zap.Logger) Option { } } -// WithPhpIniOverrides configures the PHP ini overrides. -func WithPhpIniOverrides(overrides map[string]string) Option { +// WithPhpIni configures user defined PHP ini settings. +func WithPhpIni(overrides map[string]string) Option { return func(o *opt) error { - o.phpIniOverrides = overrides + o.phpIni = overrides return nil } } diff --git a/phpmainthread.go b/phpmainthread.go index 07057c2ca..42e524cc6 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -13,11 +13,11 @@ import ( // represents the main PHP thread // the thread needs to keep running as long as all other threads are running type phpMainThread struct { - state *threadState - done chan struct{} - numThreads int - maxThreads int - phpIniOverrides map[string]string + state *threadState + done chan struct{} + numThreads int + maxThreads int + phpIni map[string]string } var ( @@ -28,13 +28,13 @@ var ( // start the main PHP thread // start a fixed number of inactive PHP threads // reserve a fixed number of possible PHP threads -func initPHPThreads(numThreads int, numMaxThreads int, phpIniOverrides map[string]string) (*phpMainThread, error) { +func initPHPThreads(numThreads int, numMaxThreads int, phpIni map[string]string) (*phpMainThread, error) { mainThread = &phpMainThread{ - state: newThreadState(), - done: make(chan struct{}), - numThreads: numThreads, - maxThreads: numMaxThreads, - phpIniOverrides: phpIniOverrides, + state: newThreadState(), + done: make(chan struct{}), + numThreads: numThreads, + maxThreads: numMaxThreads, + phpIni: phpIni, } // initialize the first thread @@ -174,16 +174,16 @@ func go_frankenphp_shutdown_main_thread() { mainThread.state.set(stateReserved) } -//export go_get_php_ini_overrides -func go_get_php_ini_overrides() *C.char { - if mainThread.phpIniOverrides == nil { +//export go_get_custom_php_ini +func go_get_custom_php_ini() *C.char { + if mainThread.phpIni == nil { return nil } - // convert the php.ini overrides from the Caddy config to a C string + // convert the php.ini from options.go to a C string // TODO: if needed this would also be possible on a per-thread basis overrides := "" - for k, v := range mainThread.phpIniOverrides { + for k, v := range mainThread.phpIni { overrides += fmt.Sprintf("%s=%s\n", k, v) } return C.CString(overrides) From 54b28ac59d10f153e1e1fc2663e88625ae26b025 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 16 Jan 2025 10:08:24 +0100 Subject: [PATCH 159/190] Removes admin endpoints to PUT and DELETE threads. --- caddy/admin.go | 77 +---------------------------- caddy/admin_test.go | 116 +++----------------------------------------- scaling.go | 43 +--------------- worker.go | 9 ---- 4 files changed, 10 insertions(+), 235 deletions(-) diff --git a/caddy/admin.go b/caddy/admin.go index 0aaf7a133..1b2f510bd 100644 --- a/caddy/admin.go +++ b/caddy/admin.go @@ -5,8 +5,6 @@ import ( "github.com/caddyserver/caddy/v2" "github.com/dunglas/frankenphp" "net/http" - "strconv" - "strings" ) type FrankenPHPAdmin struct{} @@ -45,59 +43,7 @@ func (admin *FrankenPHPAdmin) restartWorkers(w http.ResponseWriter, r *http.Requ } func (admin *FrankenPHPAdmin) threads(w http.ResponseWriter, r *http.Request) error { - switch r.Method { - case http.MethodPut: - return admin.changeThreads(w, r, admin.getCountFromRequest(r)) - case http.MethodDelete: - return admin.changeThreads(w, r, -admin.getCountFromRequest(r)) - case http.MethodGet: - return admin.success(w, frankenphp.ThreadDebugStatus()) - default: - return admin.error(http.StatusMethodNotAllowed, fmt.Errorf("method not allowed, try: GET,PUT,DELETE")) - } -} - -func (admin *FrankenPHPAdmin) changeThreads(w http.ResponseWriter, r *http.Request, count int) error { - if !r.URL.Query().Has("worker") { - return admin.changeRegularThreads(w, count) - } - workerFilename := admin.getWorkerBySuffix(r.URL.Query().Get("worker")) - - return admin.changeWorkerThreads(w, count, workerFilename) -} - -func (admin *FrankenPHPAdmin) changeWorkerThreads(w http.ResponseWriter, num int, workerFilename string) error { - method := frankenphp.AddWorkerThread - if num < 0 { - num = -num - method = frankenphp.RemoveWorkerThread - } - message := "" - for i := 0; i < num; i++ { - threadCount, err := method(workerFilename) - if err != nil { - return admin.error(http.StatusBadRequest, err) - } - message = fmt.Sprintf("New thread count: %d %s\n", threadCount, workerFilename) - } - return admin.success(w, message) -} - -func (admin *FrankenPHPAdmin) changeRegularThreads(w http.ResponseWriter, num int) error { - method := frankenphp.AddRegularThread - if num < 0 { - num = -num - method = frankenphp.RemoveRegularThread - } - message := "" - for i := 0; i < num; i++ { - threadCount, err := method() - if err != nil { - return admin.error(http.StatusBadRequest, err) - } - message = fmt.Sprintf("New thread count: %d Regular Threads\n", threadCount) - } - return admin.success(w, message) + return admin.success(w, frankenphp.ThreadDebugStatus()) } func (admin *FrankenPHPAdmin) success(w http.ResponseWriter, message string) error { @@ -109,24 +55,3 @@ func (admin *FrankenPHPAdmin) success(w http.ResponseWriter, message string) err func (admin *FrankenPHPAdmin) error(statusCode int, err error) error { return caddy.APIError{HTTPStatus: statusCode, Err: err} } - -func (admin *FrankenPHPAdmin) getCountFromRequest(r *http.Request) int { - value := r.URL.Query().Get("count") - if value == "" { - return 1 - } - i, err := strconv.Atoi(value) - if err != nil { - return 1 - } - return i -} - -func (admin *FrankenPHPAdmin) getWorkerBySuffix(pattern string) string { - for _, workerFilename := range frankenphp.WorkerFileNames() { - if strings.HasSuffix(workerFilename, pattern) { - return workerFilename - } - } - return "" -} diff --git a/caddy/admin_test.go b/caddy/admin_test.go index baf2b26c2..19644af90 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -1,10 +1,8 @@ package caddy_test import ( - "fmt" "io" "net/http" - "path/filepath" "strings" "sync" "testing" @@ -43,51 +41,7 @@ func TestRestartWorkerViaAdminApi(t *testing.T) { tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1") } -func TestRemoveWorkerThreadsViaAdminApi(t *testing.T) { - absWorkerPath, _ := filepath.Abs("../testdata/sleep.php") - tester := caddytest.NewTester(t) - tester.InitServer(` - { - skip_install_trust - admin localhost:2999 - http_port `+testPort+` - - frankenphp { - num_threads 6 - max_threads 6 - worker ../testdata/sleep.php 4 - } - } - - localhost:`+testPort+` { - route { - root ../testdata - rewrite sleep.php - php - } - } - `, "caddyfile") - - // make a request to the worker to make sure it's running - tester.AssertGetResponse("http://localhost:"+testPort, http.StatusOK, "slept for 0 ms and worked for 0 iterations") - - // remove a thread - expectedMessage := fmt.Sprintf("New thread count: 3 %s\n", absWorkerPath) - assertAdminResponse(t, tester, "DELETE", "threads?worker", http.StatusOK, expectedMessage) - - // remove 2 threads - expectedMessage = fmt.Sprintf("New thread count: 1 %s\n", absWorkerPath) - assertAdminResponse(t, tester, "DELETE", "threads?worker&count=2", http.StatusOK, expectedMessage) - - // get 400 status if removing the last thread - assertAdminResponse(t, tester, "DELETE", "threads?worker", http.StatusBadRequest, "") - - // make a request to the worker to make sure it's still running - tester.AssertGetResponse("http://localhost:"+testPort, http.StatusOK, "slept for 0 ms and worked for 0 iterations") -} - -func TestAddWorkerThreadsViaAdminApi(t *testing.T) { - absWorkerPath, _ := filepath.Abs("../testdata/worker-with-counter.php") +func TestShowTheCorrectThreadDebugStatus(t *testing.T) { tester := caddytest.NewTester(t) tester.InitServer(` { @@ -96,9 +50,10 @@ func TestAddWorkerThreadsViaAdminApi(t *testing.T) { http_port `+testPort+` frankenphp { - max_threads 10 num_threads 3 + max_threads 6 worker ../testdata/worker-with-counter.php 1 + worker ../testdata/index.php 1 } } @@ -111,71 +66,16 @@ func TestAddWorkerThreadsViaAdminApi(t *testing.T) { } `, "caddyfile") - // make a request to the worker to make sure it's running - tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1") - - // get 400 status if the filename is wrong - assertAdminResponse(t, tester, "PUT", "threads?worker=wrong.php", http.StatusBadRequest, "") - - // add a thread - expectedMessage := fmt.Sprintf("New thread count: 2 %s\n", absWorkerPath) - assertAdminResponse(t, tester, "PUT", "threads?worker=counter.php", http.StatusOK, expectedMessage) - - // add 2 threads - expectedMessage = fmt.Sprintf("New thread count: 4 %s\n", absWorkerPath) - assertAdminResponse(t, tester, "PUT", "threads?worker&=counter.php&count=2", http.StatusOK, expectedMessage) - - // get 400 status if adding too many threads - assertAdminResponse(t, tester, "PUT", "threads?worker&=counter.php&count=100", http.StatusBadRequest, "") - - // make a request to the worker to make sure it's still running - tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:2") -} - -func TestShowTheCorrectThreadDebugStatus(t *testing.T) { - tester := caddytest.NewTester(t) - tester.InitServer(` - { - skip_install_trust - admin localhost:2999 - http_port `+testPort+` - - frankenphp { - num_threads 6 - max_threads 12 - worker ../testdata/worker-with-counter.php 2 - worker ../testdata/index.php 2 - } - } - - localhost:`+testPort+` { - route { - root ../testdata - rewrite worker-with-counter.php - php - } - } - `, "caddyfile") - - // should create a 'worker-with-counter.php' thread at index 6 - assertAdminResponse(t, tester, "PUT", "threads?worker=counter.php", http.StatusOK, "") - // should remove the 'index.php' worker thread at index 5 - assertAdminResponse(t, tester, "DELETE", "threads?worker=index.php", http.StatusOK, "") - // should remove a regular thread at index 1 - assertAdminResponse(t, tester, "DELETE", "threads", http.StatusOK, "") - threadInfo := getAdminResponseBody(t, tester, "GET", "threads") // assert that the correct threads are present in the thread info assert.Contains(t, threadInfo, "Thread 0") - assert.NotContains(t, threadInfo, "Thread 1") + assert.Contains(t, threadInfo, "Thread 1") assert.Contains(t, threadInfo, "Thread 2") - assert.Contains(t, threadInfo, "Thread 3") - assert.Contains(t, threadInfo, "Thread 4") - assert.NotContains(t, threadInfo, "Thread 5") - assert.Contains(t, threadInfo, "Thread 6") - assert.NotContains(t, threadInfo, "Thread 7") - assert.Contains(t, threadInfo, "7 additional threads can be started at runtime") + assert.NotContains(t, threadInfo, "Thread 3") + assert.Contains(t, threadInfo, "3 additional threads can be started at runtime") + assert.Contains(t, threadInfo, "worker-with-counter.php") + assert.Contains(t, threadInfo, "index.php") } func TestAutoScaleWorkerThreads(t *testing.T) { diff --git a/scaling.go b/scaling.go index 4b616bd41..ebe6f7d4a 100644 --- a/scaling.go +++ b/scaling.go @@ -58,18 +58,10 @@ func initAutoScaling(mainThread *phpMainThread) { func drainAutoScaling() { scalingMu.Lock() - logger.Debug("shutting down autoscalin", zap.Int("autoScaledThreads", len(autoScaledThreads))) + logger.Debug("shutting down autoscaling", zap.Int("autoScaledThreads", len(autoScaledThreads))) scalingMu.Unlock() } -// AddRegularThread adds one regular PHP thread at runtime if max_threads are not yet reached -func AddRegularThread() (int, error) { - scalingMu.Lock() - defer scalingMu.Unlock() - _, err := addRegularThread() - return countRegularThreads(), err -} - func addRegularThread() (*phpThread, error) { thread := getInactivePHPThread() if thread == nil { @@ -80,14 +72,6 @@ func addRegularThread() (*phpThread, error) { return thread, nil } -// RemoveRegularThread removes one regular PHP thread at runtime, won't remove the last thread -func RemoveRegularThread() (int, error) { - scalingMu.Lock() - defer scalingMu.Unlock() - err := removeRegularThread() - return countRegularThreads(), err -} - func removeRegularThread() error { regularThreadMu.RLock() if len(regularThreads) <= 1 { @@ -100,18 +84,6 @@ func removeRegularThread() error { return nil } -// AddWorkerThread adds one PHP worker thread at runtime if max_threads are not yet reached -func AddWorkerThread(workerFileName string) (int, error) { - worker, ok := workers[workerFileName] - if !ok { - return 0, WorkerNotFoundError - } - scalingMu.Lock() - defer scalingMu.Unlock() - _, err := addWorkerThread(worker) - return worker.countThreads(), err -} - func addWorkerThread(worker *worker) (*phpThread, error) { thread := getInactivePHPThread() if thread == nil { @@ -122,19 +94,6 @@ func addWorkerThread(worker *worker) (*phpThread, error) { return thread, nil } -// RemoveWorkerThread removes one PHP worker thread at runtime, won't remove the last thread -func RemoveWorkerThread(workerFileName string) (int, error) { - worker, ok := workers[workerFileName] - if !ok { - return 0, WorkerNotFoundError - } - scalingMu.Lock() - defer scalingMu.Unlock() - err := removeWorkerThread(worker) - - return worker.countThreads(), err -} - func removeWorkerThread(worker *worker) error { worker.threadMutex.RLock() if len(worker.threads) <= 1 { diff --git a/worker.go b/worker.go index 5cf9657f3..b366e37ea 100644 --- a/worker.go +++ b/worker.go @@ -125,15 +125,6 @@ func RestartWorkers() { } } -// WorkerFileNames returns the absolute path to all worker files -func WorkerFileNames() []string { - workerNames := make([]string, 0, len(workers)) - for fileName := range workers { - workerNames = append(workerNames, fileName) - } - return workerNames -} - func getDirectoriesToWatch(workerOpts []workerOpt) []string { directoriesToWatch := []string{} for _, w := range workerOpts { From 0bba5f69fdafd1808b60ca5e153271c5a6ed8630 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 16 Jan 2025 10:28:28 +0100 Subject: [PATCH 160/190] Puts MaxThreads for tests back in. --- caddy/caddy_test.go | 115 +++++++++++++++++++++++++++++++++++++++++--- frankenphp.go | 4 ++ 2 files changed, 113 insertions(+), 6 deletions(-) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 447851294..d289dc2c0 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -10,6 +10,7 @@ import ( "sync" "testing" + "github.com/dunglas/frankenphp" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/require" @@ -324,9 +325,7 @@ func TestMetrics(t *testing.T) { http_port `+testPort+` https_port 9443 - frankenphp { - num_threads 2 - } + frankenphp } localhost:`+testPort+` { @@ -362,11 +361,13 @@ func TestMetrics(t *testing.T) { t.Fatalf("failed to read metrics: %v", err) } + cpus := fmt.Sprintf("%d", frankenphp.MaxThreads) + // Check metrics expectedMetrics := ` # HELP frankenphp_total_threads Total number of PHP threads # TYPE frankenphp_total_threads counter - frankenphp_total_threads 2 + frankenphp_total_threads ` + cpus + ` # HELP frankenphp_busy_threads Number of busy PHP threads # TYPE frankenphp_busy_threads gauge @@ -387,7 +388,6 @@ func TestWorkerMetrics(t *testing.T) { https_port 9443 frankenphp { - num_threads 3 worker ../testdata/index.php 2 } } @@ -425,11 +425,13 @@ func TestWorkerMetrics(t *testing.T) { t.Fatalf("failed to read metrics: %v", err) } + cpus := fmt.Sprintf("%d", frankenphp.MaxThreads) + // Check metrics expectedMetrics := ` # HELP frankenphp_total_threads Total number of PHP threads # TYPE frankenphp_total_threads counter - frankenphp_total_threads 3 + frankenphp_total_threads ` + cpus + ` # HELP frankenphp_busy_threads Number of busy PHP threads # TYPE frankenphp_busy_threads gauge @@ -475,6 +477,107 @@ func TestWorkerMetrics(t *testing.T) { )) } +func TestAutoWorkerConfig(t *testing.T) { + var wg sync.WaitGroup + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + https_port 9443 + + frankenphp { + worker ../testdata/index.php + } + } + + localhost:`+testPort+` { + route { + php { + root ../testdata + } + } + } + `, "caddyfile") + + // Make some requests + for i := 0; i < 10; i++ { + wg.Add(1) + go func(i int) { + tester.AssertGetResponse(fmt.Sprintf("http://localhost:"+testPort+"/index.php?i=%d", i), http.StatusOK, fmt.Sprintf("I am by birth a Genevese (%d)", i)) + wg.Done() + }(i) + } + wg.Wait() + + // Fetch metrics + resp, err := http.Get("http://localhost:2999/metrics") + if err != nil { + t.Fatalf("failed to fetch metrics: %v", err) + } + defer resp.Body.Close() + + // Read and parse metrics + metrics := new(bytes.Buffer) + _, err = metrics.ReadFrom(resp.Body) + if err != nil { + t.Fatalf("failed to read metrics: %v", err) + } + + cpus := fmt.Sprintf("%d", frankenphp.MaxThreads) + workers := fmt.Sprintf("%d", frankenphp.MaxThreads-1) + + // Check metrics + expectedMetrics := ` + # HELP frankenphp_total_threads Total number of PHP threads + # TYPE frankenphp_total_threads counter + frankenphp_total_threads ` + cpus + ` + + # HELP frankenphp_busy_threads Number of busy PHP threads + # TYPE frankenphp_busy_threads gauge + frankenphp_busy_threads ` + workers + ` + + # HELP frankenphp_testdata_index_php_busy_workers Number of busy PHP workers for this worker + # TYPE frankenphp_testdata_index_php_busy_workers gauge + frankenphp_testdata_index_php_busy_workers 0 + + # HELP frankenphp_testdata_index_php_total_workers Total number of PHP workers for this worker + # TYPE frankenphp_testdata_index_php_total_workers gauge + frankenphp_testdata_index_php_total_workers ` + workers + ` + + # HELP frankenphp_testdata_index_php_worker_request_count + # TYPE frankenphp_testdata_index_php_worker_request_count counter + frankenphp_testdata_index_php_worker_request_count 10 + + # HELP frankenphp_testdata_index_php_ready_workers Running workers that have successfully called frankenphp_handle_request at least once + # TYPE frankenphp_testdata_index_php_ready_workers gauge + frankenphp_testdata_index_php_ready_workers ` + workers + ` + + # HELP frankenphp_testdata_index_php_worker_crashes Number of PHP worker crashes for this worker + # TYPE frankenphp_testdata_index_php_worker_crashes counter + frankenphp_testdata_index_php_worker_crashes 0 + + # HELP frankenphp_testdata_index_php_worker_restarts Number of PHP worker restarts for this worker + # TYPE frankenphp_testdata_index_php_worker_restarts counter + frankenphp_testdata_index_php_worker_restarts 0 + ` + + require.NoError(t, + testutil.GatherAndCompare( + prometheus.DefaultGatherer, + strings.NewReader(expectedMetrics), + "frankenphp_total_threads", + "frankenphp_busy_threads", + "frankenphp_testdata_index_php_busy_workers", + "frankenphp_testdata_index_php_total_workers", + "frankenphp_testdata_index_php_worker_request_count", + "frankenphp_testdata_index_php_worker_crashes", + "frankenphp_testdata_index_php_worker_restarts", + "frankenphp_testdata_index_php_ready_workers", + )) +} + func TestAllDefinedServerVars(t *testing.T) { documentRoot, _ := filepath.Abs("../testdata/") expectedBodyFile, _ := os.ReadFile("../testdata/server-all-vars-ordered.txt") diff --git a/frankenphp.go b/frankenphp.go index 5f6a66765..79b30bb99 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -242,6 +242,9 @@ func Config() PHPConfig { } } +// MaxThreads is internally used during tests. It is written to, but never read and may go away in the future. +var MaxThreads int + func calculateMaxThreads(opt *opt) (int, int, int, error) { maxProcs := runtime.GOMAXPROCS(0) * 2 @@ -272,6 +275,7 @@ func calculateMaxThreads(opt *opt) (int, int, int, error) { } metrics.TotalThreads(opt.numThreads) + MaxThreads = opt.numThreads return opt.numThreads, numWorkers, opt.maxThreads, nil } From bace48ca8a21a3c6044d36bf103708e66321f65a Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 16 Jan 2025 12:16:58 +0100 Subject: [PATCH 161/190] Uses gomaxprocs instead. --- internal/cpu/cpu_unix.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cpu/cpu_unix.go b/internal/cpu/cpu_unix.go index b1c0e2a09..a74bff336 100644 --- a/internal/cpu/cpu_unix.go +++ b/internal/cpu/cpu_unix.go @@ -10,7 +10,7 @@ import ( "time" ) -var cpuCount = runtime.NumCPU() +var cpuCount = runtime.GOMAXPROCS(0) // probe the CPU usage of the process // if CPUs are not busy, most threads are likely waiting for I/O, so we should scale From a43d2c042bc7f20bddd4771f3cd1f52faf9a354c Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 16 Jan 2025 12:24:02 +0100 Subject: [PATCH 162/190] Adjusts docs. --- docs/config.md | 6 +++--- phpmainthread.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/config.md b/docs/config.md index 3feb7a9e0..f4af5fd0f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -51,8 +51,8 @@ Optionally, the number of threads to create and [worker scripts](worker.md) to s { frankenphp { 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. - php_ini # Overrides a single PHP configuration. Can be specified more than once for multiple overrides. + max_threads # Limits the number of additional PHP threads that can be started at runtime. Default: num_threads. Can be set to 'auto'. + 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. num # Sets the number of PHP threads to start, defaults to 2x the number of available CPUs. @@ -229,7 +229,7 @@ To load [additional PHP configuration files](https://www.php.net/manual/en/confi the `PHP_INI_SCAN_DIR` environment variable can be used. When set, PHP will load all the file with the `.ini` extension present in the given directories. -You can also overwrite single configurations using the `php_ini` directive in the `Caddyfile`: +You can also change the PHP configuration using the `php_ini` directive in the `Caddyfile`: ```caddyfile { diff --git a/phpmainthread.go b/phpmainthread.go index 42e524cc6..7a829c4df 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -180,7 +180,7 @@ func go_get_custom_php_ini() *C.char { return nil } - // convert the php.ini from options.go to a C string + // pass the php.ini overrides to PHP before startup // TODO: if needed this would also be possible on a per-thread basis overrides := "" for k, v := range mainThread.phpIni { From 761443535046d07d83da8d858d42112bfc53012a Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 16 Jan 2025 12:35:36 +0100 Subject: [PATCH 163/190] Uses go's monotonic clock instead. --- internal/cpu/cpu_unix.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/internal/cpu/cpu_unix.go b/internal/cpu/cpu_unix.go index a74bff336..ae97efc30 100644 --- a/internal/cpu/cpu_unix.go +++ b/internal/cpu/cpu_unix.go @@ -16,11 +16,11 @@ var cpuCount = runtime.GOMAXPROCS(0) // if CPUs are not busy, most threads are likely waiting for I/O, so we should scale // if CPUs are already busy we won't gain much by scaling and want to avoid the overhead of doing so func ProbeCPUs(probeTime time.Duration, maxCPUUsage float64, abort chan struct{}) bool { - var start, end, cpuStart, cpuEnd C.struct_timespec + var cpuStart, cpuEnd C.struct_timespec // note: clock_gettime is a POSIX function // on Windows we'd need to use QueryPerformanceCounter instead - C.clock_gettime(C.CLOCK_MONOTONIC, &start) + start := time.Now() C.clock_gettime(C.CLOCK_PROCESS_CPUTIME_ID, &cpuStart) select { @@ -29,12 +29,11 @@ func ProbeCPUs(probeTime time.Duration, maxCPUUsage float64, abort chan struct{} case <-time.After(probeTime): } - C.clock_gettime(C.CLOCK_MONOTONIC, &end) C.clock_gettime(C.CLOCK_PROCESS_CPUTIME_ID, &cpuEnd) - - elapsedTime := float64(end.tv_sec-start.tv_sec)*1e9 + float64(end.tv_nsec-start.tv_nsec) + elapsedTime := float64(time.Since(start).Nanoseconds()) elapsedCpuTime := float64(cpuEnd.tv_sec-cpuStart.tv_sec)*1e9 + float64(cpuEnd.tv_nsec-cpuStart.tv_nsec) cpuUsage := elapsedCpuTime / elapsedTime / float64(cpuCount) + println("CPU usage:", int(cpuUsage*100)) return cpuUsage < maxCPUUsage } From 32a3129ab622abc737549fc78269ec6a46004bb9 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 16 Jan 2025 13:10:59 +0100 Subject: [PATCH 164/190] Removes unused func. --- thread-regular.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/thread-regular.go b/thread-regular.go index 7ab25fd39..6a283087c 100644 --- a/thread-regular.go +++ b/thread-regular.go @@ -137,11 +137,3 @@ func detachRegularThread(thread *phpThread) { } regularThreadMu.Unlock() } - -func countRegularThreads() int { - regularThreadMu.RLock() - l := len(regularThreads) - regularThreadMu.RUnlock() - - return l -} From d18901055c9b593ef0947575539f1dc9f6b351a2 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 16 Jan 2025 13:14:23 +0100 Subject: [PATCH 165/190] Removes old notations. --- internal/cpu/cpu_fallback.go | 2 -- internal/cpu/cpu_unix.go | 1 - internal/memory/memory_fallback.go | 2 -- internal/memory/memory_linux.go | 1 - 4 files changed, 6 deletions(-) diff --git a/internal/cpu/cpu_fallback.go b/internal/cpu/cpu_fallback.go index a0ec2890e..d14a993f3 100644 --- a/internal/cpu/cpu_fallback.go +++ b/internal/cpu/cpu_fallback.go @@ -1,7 +1,5 @@ //go:build !unix -// -build unix - package cpu import ( diff --git a/internal/cpu/cpu_unix.go b/internal/cpu/cpu_unix.go index ae97efc30..fa5f287e6 100644 --- a/internal/cpu/cpu_unix.go +++ b/internal/cpu/cpu_unix.go @@ -1,5 +1,4 @@ //go:build unix -// +build unix package cpu diff --git a/internal/memory/memory_fallback.go b/internal/memory/memory_fallback.go index cbf42865f..180bf241c 100644 --- a/internal/memory/memory_fallback.go +++ b/internal/memory/memory_fallback.go @@ -1,7 +1,5 @@ //go:build !linux -// -build linux - package memory // Return 0 if the total system memory cannot be determined diff --git a/internal/memory/memory_linux.go b/internal/memory/memory_linux.go index c648bffec..a0f01a33d 100644 --- a/internal/memory/memory_linux.go +++ b/internal/memory/memory_linux.go @@ -1,5 +1,4 @@ //go:build linux -// +build linux package memory From 32517feccc3e5dd49c7b77b64d9cf0ed2a4eb348 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 16 Jan 2025 14:16:38 +0100 Subject: [PATCH 166/190] Locks the thread handler on debug status. --- phpthread.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/phpthread.go b/phpthread.go index 51b4e7538..d106453bd 100644 --- a/phpthread.go +++ b/phpthread.go @@ -113,6 +113,7 @@ func (thread *phpThread) getActiveRequest() *http.Request { // small status message for debugging func (thread *phpThread) debugStatus() string { reqState := "" + thread.handlerMu.Lock() if waitTime := thread.state.waitTime(); waitTime > 0 { reqState = fmt.Sprintf(", waiting for %dms", waitTime) } else if r := thread.getActiveRequest(); r != nil { @@ -128,6 +129,8 @@ func (thread *phpThread) debugStatus() string { reqState = fmt.Sprintf(", handling %s for %dms ", path, sinceMs) } } + thread.handlerMu.Unlock() + return fmt.Sprintf("Thread %d (%s%s) %s", thread.threadIndex, thread.state.name(), reqState, thread.handler.name()) } From 1d899dbacaafe075ff8dd242d01cb36e280d2860 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 16 Jan 2025 18:10:57 +0100 Subject: [PATCH 167/190] Locks the thread handler on debug status. --- internal/cpu/cpu_unix.go | 1 - phpthread.go | 18 +++++++++++++----- thread-regular.go | 10 ++++++++-- thread-worker.go | 25 +++++++++++++++++-------- 4 files changed, 38 insertions(+), 16 deletions(-) diff --git a/internal/cpu/cpu_unix.go b/internal/cpu/cpu_unix.go index fa5f287e6..c28be89e2 100644 --- a/internal/cpu/cpu_unix.go +++ b/internal/cpu/cpu_unix.go @@ -33,6 +33,5 @@ func ProbeCPUs(probeTime time.Duration, maxCPUUsage float64, abort chan struct{} elapsedCpuTime := float64(cpuEnd.tv_sec-cpuStart.tv_sec)*1e9 + float64(cpuEnd.tv_nsec-cpuStart.tv_nsec) cpuUsage := elapsedCpuTime / elapsedTime / float64(cpuCount) - println("CPU usage:", int(cpuUsage*100)) return cpuUsage < maxCPUUsage } diff --git a/phpthread.go b/phpthread.go index d106453bd..03ed0f0d5 100644 --- a/phpthread.go +++ b/phpthread.go @@ -22,7 +22,8 @@ type phpThread struct { knownVariableKeys map[string]*C.zend_string requestChan chan *http.Request drainChan chan struct{} - handlerMu *sync.Mutex + handlerMu sync.Mutex + requestMu sync.Mutex handler threadHandler state *threadState } @@ -39,7 +40,6 @@ func newPHPThread(threadIndex int) *phpThread { return &phpThread{ threadIndex: threadIndex, requestChan: make(chan *http.Request), - handlerMu: &sync.Mutex{}, state: newThreadState(), } } @@ -110,13 +110,22 @@ func (thread *phpThread) getActiveRequest() *http.Request { return thread.handler.getActiveRequest() } +// get the active request from outside the PHP thread +func (thread *phpThread) getActiveRequestSafely() *http.Request { + thread.handlerMu.Lock() + thread.requestMu.Lock() + r := thread.getActiveRequest() + thread.requestMu.Unlock() + thread.handlerMu.Unlock() + return r +} + // small status message for debugging func (thread *phpThread) debugStatus() string { reqState := "" - thread.handlerMu.Lock() if waitTime := thread.state.waitTime(); waitTime > 0 { reqState = fmt.Sprintf(", waiting for %dms", waitTime) - } else if r := thread.getActiveRequest(); r != nil { + } else if r := thread.getActiveRequestSafely(); r != nil { fc := r.Context().Value(contextKey).(*FrankenPHPContext) path := r.URL.Path if fc.originalRequest != nil { @@ -129,7 +138,6 @@ func (thread *phpThread) debugStatus() string { reqState = fmt.Sprintf(", handling %s for %dms ", path, sinceMs) } } - thread.handlerMu.Unlock() return fmt.Sprintf("Thread %d (%s%s) %s", thread.threadIndex, thread.state.name(), reqState, thread.handler.name()) } diff --git a/thread-regular.go b/thread-regular.go index 6a283087c..ce0778c78 100644 --- a/thread-regular.go +++ b/thread-regular.go @@ -56,6 +56,12 @@ func (handler *regularThread) getActiveRequest() *http.Request { return handler.activeRequest } +func (handler *regularThread) setActiveRequest(r *http.Request) { + handler.thread.requestMu.Lock() + handler.activeRequest = r + handler.thread.requestMu.Unlock() +} + func (handler *regularThread) name() string { return "Regular PHP Thread" } @@ -71,7 +77,7 @@ func (handler *regularThread) waitForRequest() string { case r = <-regularRequestChan: } - handler.activeRequest = r + handler.setActiveRequest(r) handler.state.markAsWaiting(false) fc := r.Context().Value(contextKey).(*FrankenPHPContext) @@ -91,7 +97,7 @@ func (handler *regularThread) afterRequest(exitStatus int) { fc := handler.activeRequest.Context().Value(contextKey).(*FrankenPHPContext) fc.exitStatus = exitStatus maybeCloseContext(fc) - handler.activeRequest = nil + handler.setActiveRequest(nil) } func handleRequestWithRegularPHPThreads(r *http.Request, fc *FrankenPHPContext) { diff --git a/thread-worker.go b/thread-worker.go index 0d00dd1c5..a82a839be 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -97,7 +97,7 @@ func setupWorkerScript(handler *workerThread, worker *worker) { panic(err) } - handler.fakeRequest = r + handler.setFakeRequest(r) if c := logger.Check(zapcore.DebugLevel, "starting"); c != nil { c.Write(zap.String("worker", worker.fileName), zap.Int("thread", handler.thread.threadIndex)) } @@ -109,15 +109,12 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { if handler.workerRequest != nil { fc := handler.workerRequest.Context().Value(contextKey).(*FrankenPHPContext) maybeCloseContext(fc) - handler.workerRequest = nil + handler.setWorkerRequest(nil) } fc := handler.fakeRequest.Context().Value(contextKey).(*FrankenPHPContext) fc.exitStatus = exitStatus - - defer func() { - handler.fakeRequest = nil - }() + handler.setFakeRequest(nil) // on exit status 0 we just run the worker script again worker := handler.worker @@ -174,7 +171,7 @@ func (handler *workerThread) waitForWorkerRequest() bool { case r = <-handler.worker.requestChan: } - handler.workerRequest = r + handler.setWorkerRequest(r) handler.state.markAsWaiting(false) if c := logger.Check(zapcore.DebugLevel, "request handling started"); c != nil { @@ -196,6 +193,18 @@ func (handler *workerThread) waitForWorkerRequest() bool { return true } +func (handler *workerThread) setWorkerRequest(r *http.Request) { + handler.thread.requestMu.Lock() + handler.workerRequest = r + handler.thread.requestMu.Unlock() +} + +func (handler *workerThread) setFakeRequest(r *http.Request) { + handler.thread.requestMu.Lock() + handler.fakeRequest = r + handler.thread.requestMu.Unlock() +} + //export go_frankenphp_worker_handle_request_start func go_frankenphp_worker_handle_request_start(threadIndex C.uintptr_t) C.bool { handler := phpThreads[threadIndex].handler.(*workerThread) @@ -209,7 +218,7 @@ func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t) { fc := r.Context().Value(contextKey).(*FrankenPHPContext) maybeCloseContext(fc) - thread.handler.(*workerThread).workerRequest = nil + thread.handler.(*workerThread).setWorkerRequest(nil) if c := fc.logger.Check(zapcore.DebugLevel, "request handling finished"); c != nil { c.Write(zap.String("worker", fc.scriptFilename), zap.String("url", r.RequestURI)) From e3d38b5e4191816501d25fbeea0aedc9bac78b9d Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 27 Jan 2025 23:14:57 +0100 Subject: [PATCH 168/190] go fmt --- phpmainthread.go | 13 ++++++------- phpthread.go | 14 +++++++------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/phpmainthread.go b/phpmainthread.go index 27dbcab98..850d12e54 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -6,22 +6,21 @@ import ( "fmt" "sync" - "github.com/dunglas/frankenphp/internal/phpheaders" "github.com/dunglas/frankenphp/internal/memory" + "github.com/dunglas/frankenphp/internal/phpheaders" "go.uber.org/zap" ) // represents the main PHP thread // the thread needs to keep running as long as all other threads are running type phpMainThread struct { - state *threadState - done chan struct{} - numThreads int - maxThreads int - phpIni map[string]string + state *threadState + done chan struct{} + numThreads int + maxThreads int + phpIni map[string]string commonHeaders map[string]*C.zend_string knownServerKeys map[string]*C.zend_string - } var ( diff --git a/phpthread.go b/phpthread.go index 5a03dc4c5..dc22a7932 100644 --- a/phpthread.go +++ b/phpthread.go @@ -18,13 +18,13 @@ import ( type phpThread struct { runtime.Pinner - threadIndex int - requestChan chan *http.Request - drainChan chan struct{} - handlerMu sync.Mutex - requestMu sync.Mutex - handler threadHandler - state *threadState + threadIndex int + requestChan chan *http.Request + drainChan chan struct{} + handlerMu sync.Mutex + requestMu sync.Mutex + handler threadHandler + state *threadState } // interface that defines how the callbacks from the C thread should be handled From 755551a9b50e774c2f44115530a7501119439134 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 27 Jan 2025 23:21:02 +0100 Subject: [PATCH 169/190] Makes scale chan nil if scaling is not active. --- scaling.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scaling.go b/scaling.go index ebe6f7d4a..c7da90268 100644 --- a/scaling.go +++ b/scaling.go @@ -32,8 +32,8 @@ const ( ) var ( + scaleChan chan *FrankenPHPContext autoScaledThreads = []*phpThread{} - scaleChan = make(chan *FrankenPHPContext) scalingMu = new(sync.RWMutex) disallowScaling = atomic.Bool{} @@ -44,9 +44,11 @@ var ( func initAutoScaling(mainThread *phpMainThread) { if mainThread.maxThreads <= mainThread.numThreads { + scaleChan = nil return } + scaleChan = make(chan *FrankenPHPContext) maxScaledThreads := mainThread.maxThreads - mainThread.numThreads scalingMu.Lock() autoScaledThreads = make([]*phpThread, 0, maxScaledThreads) From 21353547a695568f1654d7b5c178e610701688de Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 27 Jan 2025 23:30:58 +0100 Subject: [PATCH 170/190] locks before modification. --- scaling.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scaling.go b/scaling.go index c7da90268..1c33fed46 100644 --- a/scaling.go +++ b/scaling.go @@ -48,9 +48,9 @@ func initAutoScaling(mainThread *phpMainThread) { return } + scalingMu.Lock() scaleChan = make(chan *FrankenPHPContext) maxScaledThreads := mainThread.maxThreads - mainThread.numThreads - scalingMu.Lock() autoScaledThreads = make([]*phpThread, 0, maxScaledThreads) scalingMu.Unlock() From 69597ef5befb6cba1372a3777101bb2f4db37484 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Mon, 27 Jan 2025 23:39:27 +0100 Subject: [PATCH 171/190] Fixes race condition on restarts in tests. --- scaling.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scaling.go b/scaling.go index 1c33fed46..d70079265 100644 --- a/scaling.go +++ b/scaling.go @@ -54,7 +54,7 @@ func initAutoScaling(mainThread *phpMainThread) { autoScaledThreads = make([]*phpThread, 0, maxScaledThreads) scalingMu.Unlock() - go startUpscalingThreads(mainThread.done, maxScaledThreads) + go startUpscalingThreads(maxScaledThreads, scaleChan, mainThread.done) go startDownScalingThreads(mainThread.done) } @@ -155,7 +155,7 @@ func scaleRegularThread() { autoScaledThreads = append(autoScaledThreads, thread) } -func startUpscalingThreads(done chan struct{}, maxScaledThreads int) { +func startUpscalingThreads(maxScaledThreads int, scale chan *FrankenPHPContext, done chan struct{}) { for { scalingMu.Lock() scaledThreadCount := len(autoScaledThreads) @@ -171,7 +171,7 @@ func startUpscalingThreads(done chan struct{}, maxScaledThreads int) { } select { - case fc := <-scaleChan: + case fc := <-scale: timeSinceStalled := time.Since(fc.startedAt) // if the request has not been stalled long enough, wait and repeat From 16e8169b1f6d485809f82bca7dbfd62ccb153928 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 2 Feb 2025 00:43:53 +0100 Subject: [PATCH 172/190] Resets the opcache more safely. --- state.go | 2 ++ thread-worker.go | 28 +++++++++++++++++++--------- worker.go | 6 ++++++ 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/state.go b/state.go index fc64aa93d..1f607adb0 100644 --- a/state.go +++ b/state.go @@ -22,6 +22,7 @@ const ( // states necessary for restarting workers stateRestarting stateYielding + stateOpcacheReset // states necessary for transitioning between different handlers stateTransitionRequested @@ -41,6 +42,7 @@ var stateNames = map[stateID]string{ stateTransitionRequested: "transition requested", stateTransitionInProgress: "transition in progress", stateTransitionComplete: "transition complete", + stateOpcacheReset: "opcache reset", } type threadState struct { diff --git a/thread-worker.go b/thread-worker.go index d2ac52a7d..89b45991b 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -45,9 +45,7 @@ func (handler *workerThread) beforeScriptExecution() string { handler.worker.detachThread(handler.thread) return handler.thread.transitionToNewHandler() case stateRestarting: - handler.state.set(stateYielding) - handler.state.waitFor(stateReady, stateShuttingDown) - return handler.beforeScriptExecution() + return handler.restartGracefully() case stateReady, stateTransitionComplete: setupWorkerScript(handler, handler.worker) return handler.worker.fileName @@ -162,12 +160,6 @@ func (handler *workerThread) waitForWorkerRequest() bool { c.Write(zap.String("worker", handler.worker.fileName)) } - // flush the opcache when restarting due to watcher or admin api - // note: this is done right before frankenphp_handle_request() returns 'false' - if handler.state.is(stateRestarting) { - C.frankenphp_reset_opcache() - } - return false case r = <-handler.thread.requestChan: case r = <-handler.worker.requestChan: @@ -209,6 +201,24 @@ func (handler *workerThread) setFakeRequest(r *http.Request) { handler.thread.requestMu.Unlock() } +// When restarting gracefully, all threads wait for each other to finish +// opcache_reset will be called once all threads are yielding +func (handler *workerThread) restartGracefully() string { + handler.state.set(stateYielding) + handler.state.waitFor(stateReady, stateShuttingDown, stateOpcacheReset) + + // one thread will be marked to flush the opcache + // this will avoid a race condition in opcache under high concurrency + if handler.state.is(stateOpcacheReset) { + C.frankenphp_reset_opcache() + logger.Debug("opcache reset", zap.Int("threadIndex", handler.thread.threadIndex)) + handler.state.set(stateYielding) + handler.state.waitFor(stateReady, stateShuttingDown) + } + + return handler.beforeScriptExecution() +} + // go_frankenphp_worker_handle_request_start is called at the start of every php request served. // //export go_frankenphp_worker_handle_request_start diff --git a/worker.go b/worker.go index b366e37ea..da0fbd0a8 100644 --- a/worker.go +++ b/worker.go @@ -119,6 +119,12 @@ func RestartWorkers() { ready.Wait() + // the first thread should reset the opcache + if len(threadsToRestart) > 0 { + threadsToRestart[0].state.set(stateOpcacheReset) + threadsToRestart[0].state.waitFor(stateYielding) + } + for _, thread := range threadsToRestart { thread.drainChan = make(chan struct{}) thread.state.set(stateReady) From c038273f77ce46acb671451026872df40c6a27d2 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 2 Feb 2025 00:50:32 +0100 Subject: [PATCH 173/190] trigger build From 064a368471173834ed5da4783165a2c2ccebe020 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 2 Feb 2025 12:17:05 +0100 Subject: [PATCH 174/190] Starts all threads as inactive. --- phpmainthread.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/phpmainthread.go b/phpmainthread.go index 850d12e54..6da15771f 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -60,8 +60,8 @@ func initPHPThreads(numThreads int, numMaxThreads int, phpIni map[string]string) // start the underlying C threads ready := sync.WaitGroup{} - ready.Add(numThreads) - for i := 0; i < numThreads; i++ { + ready.Add(maxThreads) + for i := 0; i < maxThreads; i++ { thread := phpThreads[i] go func() { thread.boot() From 60b437f3828d2d58c5d8d74192bfddc9a6219d0b Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 2 Feb 2025 17:47:40 +0100 Subject: [PATCH 175/190] Properly removes autoscaled threads. --- scaling.go | 1 + 1 file changed, 1 insertion(+) diff --git a/scaling.go b/scaling.go index d70079265..80b39a788 100644 --- a/scaling.go +++ b/scaling.go @@ -231,6 +231,7 @@ func deactivateThreads() { logger.Debug("auto-converting thread to inactive", zap.Int("threadIndex", thread.threadIndex)) convertToInactiveThread(thread) stoppedThreadCount++ + autoScaledThreads = append(autoScaledThreads[:i], autoScaledThreads[i+1:]...) continue } From 4a5cfd1e544cdd81b1c1b70017a824ea5508128e Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 2 Feb 2025 20:10:10 +0100 Subject: [PATCH 176/190] Fix. --- phpmainthread.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/phpmainthread.go b/phpmainthread.go index 6da15771f..850d12e54 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -60,8 +60,8 @@ func initPHPThreads(numThreads int, numMaxThreads int, phpIni map[string]string) // start the underlying C threads ready := sync.WaitGroup{} - ready.Add(maxThreads) - for i := 0; i < maxThreads; i++ { + ready.Add(numThreads) + for i := 0; i < numThreads; i++ { thread := phpThreads[i] go func() { thread.boot() From 954ac9e77dc961c4d75b81b4b4d719359d8e1a07 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 2 Feb 2025 22:05:56 +0100 Subject: [PATCH 177/190] Marks a single thread to call opcache_reset. --- thread-worker.go | 31 ++++++++++++------------------- worker.go | 22 ++++++++++++++++++---- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/thread-worker.go b/thread-worker.go index 89b45991b..8e14f8572 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -45,7 +45,9 @@ func (handler *workerThread) beforeScriptExecution() string { handler.worker.detachThread(handler.thread) return handler.thread.transitionToNewHandler() case stateRestarting: - return handler.restartGracefully() + handler.state.set(stateYielding) + handler.state.waitFor(stateReady, stateShuttingDown) + return handler.beforeScriptExecution() case stateReady, stateTransitionComplete: setupWorkerScript(handler, handler.worker) return handler.worker.fileName @@ -160,6 +162,15 @@ func (handler *workerThread) waitForWorkerRequest() bool { c.Write(zap.String("worker", handler.worker.fileName)) } + // flush the opcache when restarting due to watcher or admin api + // note: this is done right before frankenphp_handle_request() returns 'false' + if handler.state.is(stateOpcacheReset) { + handler.state.set(stateRestarting) + handler.state.waitFor(stateOpcacheReset) + C.frankenphp_reset_opcache() + handler.state.set(stateReady) + } + return false case r = <-handler.thread.requestChan: case r = <-handler.worker.requestChan: @@ -201,24 +212,6 @@ func (handler *workerThread) setFakeRequest(r *http.Request) { handler.thread.requestMu.Unlock() } -// When restarting gracefully, all threads wait for each other to finish -// opcache_reset will be called once all threads are yielding -func (handler *workerThread) restartGracefully() string { - handler.state.set(stateYielding) - handler.state.waitFor(stateReady, stateShuttingDown, stateOpcacheReset) - - // one thread will be marked to flush the opcache - // this will avoid a race condition in opcache under high concurrency - if handler.state.is(stateOpcacheReset) { - C.frankenphp_reset_opcache() - logger.Debug("opcache reset", zap.Int("threadIndex", handler.thread.threadIndex)) - handler.state.set(stateYielding) - handler.state.waitFor(stateReady, stateShuttingDown) - } - - return handler.beforeScriptExecution() -} - // go_frankenphp_worker_handle_request_start is called at the start of every php request served. // //export go_frankenphp_worker_handle_request_start diff --git a/worker.go b/worker.go index da0fbd0a8..67ba4ca9f 100644 --- a/worker.go +++ b/worker.go @@ -98,6 +98,8 @@ func RestartWorkers() { ready := sync.WaitGroup{} threadsToRestart := make([]*phpThread, 0) + var threadToResetOpcache *phpThread + for _, worker := range workers { worker.threadMutex.RLock() ready.Add(len(worker.threads)) @@ -107,6 +109,16 @@ func RestartWorkers() { // we'll proceed to restart all other threads anyways continue } + + // one thread will reset the opcache + if threadToResetOpcache == nil { + threadToResetOpcache = thread + thread.state.set(stateOpcacheReset) + ready.Done() + close(thread.drainChan) + continue + } + close(thread.drainChan) threadsToRestart = append(threadsToRestart, thread) go func(thread *phpThread) { @@ -119,10 +131,12 @@ func RestartWorkers() { ready.Wait() - // the first thread should reset the opcache - if len(threadsToRestart) > 0 { - threadsToRestart[0].state.set(stateOpcacheReset) - threadsToRestart[0].state.waitFor(stateYielding) + // ping-pong the thread state to make sure opcache reset is handled orderly + if threadToResetOpcache != nil { + threadToResetOpcache.state.waitFor(stateRestarting) + threadToResetOpcache.drainChan = make(chan struct{}) + threadToResetOpcache.state.set(stateOpcacheReset) + threadToResetOpcache.state.waitFor(stateReady) } for _, thread := range threadsToRestart { From 3085a65fedd5265a5f6672eb57d182aa365896ed Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 2 Feb 2025 22:22:35 +0100 Subject: [PATCH 178/190] Marks a single thread to call opcache_reset. --- thread-worker.go | 2 +- worker.go | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/thread-worker.go b/thread-worker.go index 8e14f8572..77a9ade2f 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -168,7 +168,7 @@ func (handler *workerThread) waitForWorkerRequest() bool { handler.state.set(stateRestarting) handler.state.waitFor(stateOpcacheReset) C.frankenphp_reset_opcache() - handler.state.set(stateReady) + handler.state.set(stateRestarting) } return false diff --git a/worker.go b/worker.go index 67ba4ca9f..f9766a3a2 100644 --- a/worker.go +++ b/worker.go @@ -109,6 +109,7 @@ func RestartWorkers() { // we'll proceed to restart all other threads anyways continue } + threadsToRestart = append(threadsToRestart, thread) // one thread will reset the opcache if threadToResetOpcache == nil { @@ -120,7 +121,7 @@ func RestartWorkers() { } close(thread.drainChan) - threadsToRestart = append(threadsToRestart, thread) + go func(thread *phpThread) { thread.state.waitFor(stateYielding) ready.Done() @@ -132,11 +133,12 @@ func RestartWorkers() { ready.Wait() // ping-pong the thread state to make sure opcache reset is handled orderly + // at the end, all threads should be waiting in the 'yielding' state + // opcache can only reset safely if no thread is executing a script if threadToResetOpcache != nil { threadToResetOpcache.state.waitFor(stateRestarting) - threadToResetOpcache.drainChan = make(chan struct{}) threadToResetOpcache.state.set(stateOpcacheReset) - threadToResetOpcache.state.waitFor(stateReady) + threadToResetOpcache.state.waitFor(stateYielding) } for _, thread := range threadsToRestart { From a1f89e3c4d7cb92d03fbc545499cb3a2dd84687a Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Sun, 2 Feb 2025 22:34:46 +0100 Subject: [PATCH 179/190] Resets opcache_reset logic. --- state.go | 2 -- thread-worker.go | 5 +---- worker.go | 24 +----------------------- 3 files changed, 2 insertions(+), 29 deletions(-) diff --git a/state.go b/state.go index 1f607adb0..fc64aa93d 100644 --- a/state.go +++ b/state.go @@ -22,7 +22,6 @@ const ( // states necessary for restarting workers stateRestarting stateYielding - stateOpcacheReset // states necessary for transitioning between different handlers stateTransitionRequested @@ -42,7 +41,6 @@ var stateNames = map[stateID]string{ stateTransitionRequested: "transition requested", stateTransitionInProgress: "transition in progress", stateTransitionComplete: "transition complete", - stateOpcacheReset: "opcache reset", } type threadState struct { diff --git a/thread-worker.go b/thread-worker.go index 77a9ade2f..d2ac52a7d 100644 --- a/thread-worker.go +++ b/thread-worker.go @@ -164,11 +164,8 @@ func (handler *workerThread) waitForWorkerRequest() bool { // flush the opcache when restarting due to watcher or admin api // note: this is done right before frankenphp_handle_request() returns 'false' - if handler.state.is(stateOpcacheReset) { - handler.state.set(stateRestarting) - handler.state.waitFor(stateOpcacheReset) + if handler.state.is(stateRestarting) { C.frankenphp_reset_opcache() - handler.state.set(stateRestarting) } return false diff --git a/worker.go b/worker.go index f9766a3a2..b366e37ea 100644 --- a/worker.go +++ b/worker.go @@ -98,8 +98,6 @@ func RestartWorkers() { ready := sync.WaitGroup{} threadsToRestart := make([]*phpThread, 0) - var threadToResetOpcache *phpThread - for _, worker := range workers { worker.threadMutex.RLock() ready.Add(len(worker.threads)) @@ -109,19 +107,8 @@ func RestartWorkers() { // we'll proceed to restart all other threads anyways continue } - threadsToRestart = append(threadsToRestart, thread) - - // one thread will reset the opcache - if threadToResetOpcache == nil { - threadToResetOpcache = thread - thread.state.set(stateOpcacheReset) - ready.Done() - close(thread.drainChan) - continue - } - close(thread.drainChan) - + threadsToRestart = append(threadsToRestart, thread) go func(thread *phpThread) { thread.state.waitFor(stateYielding) ready.Done() @@ -132,15 +119,6 @@ func RestartWorkers() { ready.Wait() - // ping-pong the thread state to make sure opcache reset is handled orderly - // at the end, all threads should be waiting in the 'yielding' state - // opcache can only reset safely if no thread is executing a script - if threadToResetOpcache != nil { - threadToResetOpcache.state.waitFor(stateRestarting) - threadToResetOpcache.state.set(stateOpcacheReset) - threadToResetOpcache.state.waitFor(stateYielding) - } - for _, thread := range threadsToRestart { thread.drainChan = make(chan struct{}) thread.state.set(stateReady) From 6544a73d47ef85f079b9ae5cdebaf420718e2f94 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Tue, 4 Feb 2025 23:46:13 +0100 Subject: [PATCH 180/190] Downloads specific watcher version. --- .github/actions/watcher/action.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/watcher/action.yaml b/.github/actions/watcher/action.yaml index 2025f65c2..df09bef20 100644 --- a/.github/actions/watcher/action.yaml +++ b/.github/actions/watcher/action.yaml @@ -19,7 +19,7 @@ runs: name: Compile e-dant/watcher run: | mkdir watcher - gh release download --repo e-dant/watcher -A tar.gz -O - | tar -xz -C watcher --strip-components 1 + gh release download 0.13.2 --repo e-dant/watcher -A tar.gz -O - | tar -xz -C watcher --strip-components 1 cd watcher cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build From 46681127631395ef33bf2ac7bbbc22b49ba25375 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 5 Feb 2025 11:19:06 +0100 Subject: [PATCH 181/190] Fixes linting. --- docs/ru/CONTRIBUTING.md | 2 +- docs/ru/README.md | 2 +- docs/ru/docker.md | 3 ++- docs/ru/early-hints.md | 2 +- docs/ru/github-actions.md | 2 +- docs/ru/known-issues.md | 2 +- docs/ru/laravel.md | 4 ++-- docs/ru/metrics.md | 2 +- docs/ru/production.md | 2 +- docs/ru/worker.md | 2 +- 10 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/ru/CONTRIBUTING.md b/docs/ru/CONTRIBUTING.md index 045dc1b04..d3e4e6f59 100644 --- a/docs/ru/CONTRIBUTING.md +++ b/docs/ru/CONTRIBUTING.md @@ -211,4 +211,4 @@ strace -e 'trace=!futex,epoll_ctl,epoll_pwait,tgkill,rt_sigreturn' -p 1 5. Создайте Pull Request с переводом. 6. В [репозитории сайта](https://github.com/dunglas/frankenphp-website/tree/main) скопируйте и переведите файлы в папках `content/`, `data/` и `i18n/`. 7. Переведите значения в созданных YAML-файлах. -8. Откройте Pull Request в репозитории сайта. \ No newline at end of file +8. Откройте Pull Request в репозитории сайта. diff --git a/docs/ru/README.md b/docs/ru/README.md index f66040514..abcfb3eb8 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -83,4 +83,4 @@ frankenphp php-cli /path/to/your/script.php * [WordPress](https://github.com/StephenMiracle/frankenwp) * [Drupal](https://github.com/dunglas/frankenphp-drupal) * [Joomla](https://github.com/alexandreelise/frankenphp-joomla) -* [TYPO3](https://github.com/ochorocho/franken-typo3) \ No newline at end of file +* [TYPO3](https://github.com/ochorocho/franken-typo3) diff --git a/docs/ru/docker.md b/docs/ru/docker.md index c107d6787..dcf0fa46e 100644 --- a/docs/ru/docker.md +++ b/docs/ru/docker.md @@ -77,6 +77,7 @@ FROM dunglas/frankenphp AS runner # Заменяем официальный бинарный файл на пользовательский с добавленными модулями COPY --from=builder /usr/local/bin/frankenphp /usr/local/bin/frankenphp ``` + Образ `builder`, предоставляемый FrankenPHP, содержит скомпилированную версию `libphp`. [Образы builder](https://hub.docker.com/r/dunglas/frankenphp/tags?name=builder) доступны для всех версий FrankenPHP и PHP, как для Debian, так и для Alpine. @@ -197,4 +198,4 @@ Docker-образы обновляются: Сборка запускается автоматически при каждом коммите в основную ветку GitHub-репозитория Теги с префиксом `latest*` указывают на актуальное состояние ветки `main`. -Также доступны теги в формате `sha-`. \ No newline at end of file +Также доступны теги в формате `sha-`. diff --git a/docs/ru/early-hints.md b/docs/ru/early-hints.md index ae12def66..e27c3b194 100644 --- a/docs/ru/early-hints.md +++ b/docs/ru/early-hints.md @@ -18,4 +18,4 @@ echo <<<'HTML' HTML; ``` -Early Hints поддерживается как в обычном, так и в [worker режиме](worker.md). \ No newline at end of file +Early Hints поддерживается как в обычном, так и в [worker режиме](worker.md). diff --git a/docs/ru/github-actions.md b/docs/ru/github-actions.md index 9517381fd..aea7a80ef 100644 --- a/docs/ru/github-actions.md +++ b/docs/ru/github-actions.md @@ -27,4 +27,4 @@ 1. Создайте новый тег в репозитории. 2. GitHub Actions соберёт образ и выполнит тесты. 3. Если сборка пройдёт успешно, образ будет отправлен в реестр с именем тега (например, `v1.2.3` и `v1.2` будут созданы). -4. Также будет обновлён тег `latest`. \ No newline at end of file +4. Также будет обновлён тег `latest`. diff --git a/docs/ru/known-issues.md b/docs/ru/known-issues.md index 6c7834410..af92ba4b7 100644 --- a/docs/ru/known-issues.md +++ b/docs/ru/known-issues.md @@ -137,4 +137,4 @@ error:0A000086:SSL routines::certificate verify failed # Установите переменные окружения для TLS-сертификатов export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt export SSL_CERT_DIR=/etc/ssl/certs -``` \ No newline at end of file +``` diff --git a/docs/ru/laravel.md b/docs/ru/laravel.md index 115d23ae0..b0274fc4e 100644 --- a/docs/ru/laravel.md +++ b/docs/ru/laravel.md @@ -164,7 +164,7 @@ php artisan octane:frankenphp Можно даже упаковать приложения Laravel Octane как автономный бинарный файл! -Для этого [установите Octane правильно](#laravel-octane) и следуйте шагам, описанным в [предыдущем разделе](#laravel-приложения-как-standalone-бинарники). +Для этого [установите Octane правильно](#laravel-octane) и следуйте шагам, описанным в предыдущем разделе. Затем, чтобы запустить FrankenPHP в worker-режиме через Octane, выполните: @@ -173,4 +173,4 @@ PATH="$PWD:$PATH" frankenphp php-cli artisan octane:frankenphp ``` > [!CAUTION] -> Для работы команды автономный бинарник **обязательно** должен быть назван `frankenphp`, так как Octane требует наличия программы с именем `frankenphp` в PATH. \ No newline at end of file +> Для работы команды автономный бинарник **обязательно** должен быть назван `frankenphp`, так как Octane требует наличия программы с именем `frankenphp` в PATH. diff --git a/docs/ru/metrics.md b/docs/ru/metrics.md index 684604ac9..19f1160ac 100644 --- a/docs/ru/metrics.md +++ b/docs/ru/metrics.md @@ -12,4 +12,4 @@ - `frankenphp_total_threads`: Общее количество потоков PHP. - `frankenphp_busy_threads`: Количество потоков PHP, которые в данный момент обрабатывают запрос (работающие worker-скрипты всегда используют поток). -Для метрик worker-скриптов плейсхолдер `[worker]` заменяется на путь к Worker-скрипту, указанному в Caddyfile. \ No newline at end of file +Для метрик worker-скриптов плейсхолдер `[worker]` заменяется на путь к Worker-скрипту, указанному в Caddyfile. diff --git a/docs/ru/production.md b/docs/ru/production.md index be189fc6c..529b4a142 100644 --- a/docs/ru/production.md +++ b/docs/ru/production.md @@ -122,4 +122,4 @@ docker compose up -d --wait ## Деплой на несколько узлов Если вам нужно развернуть приложение на кластер машин, используйте [Docker Swarm](https://docs.docker.com/engine/swarm/stack-deploy/), который совместим с предоставленными файлами Compose. -Для деплоя на Kubernetes ознакомьтесь с [Helm-чартом API Platform](https://api-platform.com/docs/deployment/kubernetes/), который использует FrankenPHP. \ No newline at end of file +Для деплоя на Kubernetes ознакомьтесь с [Helm-чартом API Platform](https://api-platform.com/docs/deployment/kubernetes/), который использует FrankenPHP. diff --git a/docs/ru/worker.md b/docs/ru/worker.md index 40e3df696..e94a540ca 100644 --- a/docs/ru/worker.md +++ b/docs/ru/worker.md @@ -156,4 +156,4 @@ $handler = static function () use ($workerServer) { }; // ... -``` \ No newline at end of file +``` From 1b945f7c68d05b30e9bb7bf89e2c4a7042d93d06 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 5 Feb 2025 11:25:55 +0100 Subject: [PATCH 182/190] Fixes linting. --- docs/ru/config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ru/config.md b/docs/ru/config.md index 943e591e0..d1f948a30 100644 --- a/docs/ru/config.md +++ b/docs/ru/config.md @@ -229,4 +229,4 @@ docker run -v $PWD:/app/public \ -e CADDY_GLOBAL_OPTIONS=debug \ -p 80:80 -p 443:443 -p 443:443/udp \ dunglas/frankenphp -``` \ No newline at end of file +``` From deec7eed63cf0eb18170e1d616cc7b5bde07b549 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 5 Feb 2025 22:58:38 +0100 Subject: [PATCH 183/190] Adds suggestions by @dunglas. --- caddy/admin.go | 1 + caddy/caddy.go | 4 +++ dev.Dockerfile | 2 +- docs/performance.md | 5 +-- internal/cpu/cpu_unix.go | 4 +-- .../cpu/{cpu_fallback.go => cpu_windows.go} | 4 +-- .../{memory_fallback.go => memory_others.go} | 2 +- phpmainthread.go | 36 +++++++++++-------- phpmainthread_test.go | 6 +++- phpthread.go | 8 ++--- scaling.go | 24 ++++++++----- state.go | 5 +-- .../{perf-test.md => performance-testing.md} | 6 ++-- thread-inactive.go => threadinactive.go | 0 thread-regular.go => threadregular.go | 0 thread-worker.go => threadworker.go | 0 16 files changed, 65 insertions(+), 42 deletions(-) rename internal/cpu/{cpu_fallback.go => cpu_windows.go} (70%) rename internal/memory/{memory_fallback.go => memory_others.go} (50%) rename testdata/performance/{perf-test.md => performance-testing.md} (58%) rename thread-inactive.go => threadinactive.go (100%) rename thread-regular.go => threadregular.go (100%) rename thread-worker.go => threadworker.go (100%) diff --git a/caddy/admin.go b/caddy/admin.go index 1b2f510bd..03c22e813 100644 --- a/caddy/admin.go +++ b/caddy/admin.go @@ -17,6 +17,7 @@ func (FrankenPHPAdmin) CaddyModule() caddy.ModuleInfo { } } +// EXPERIMENTAL: These routes are not yet stable and may change in the future. func (admin FrankenPHPAdmin) Routes() []caddy.AdminRoute { return []caddy.AdminRoute{ { diff --git a/caddy/caddy.go b/caddy/caddy.go index fb59bb7ed..6d7893b74 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -228,6 +228,10 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } } + if f.MaxThreads > 0 && f.NumThreads > 0 && f.MaxThreads < f.NumThreads { + return errors.New("'max_threads' must be greater than or equal to 'num_threads'") + } + return nil } diff --git a/dev.Dockerfile b/dev.Dockerfile index e318c97a1..974ea7b1f 100644 --- a/dev.Dockerfile +++ b/dev.Dockerfile @@ -72,7 +72,7 @@ RUN git clone https://github.com/e-dant/watcher . && \ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && \ cmake --build build/ && \ cmake --install build && \ - ldconfig + ldconfig WORKDIR /go/src/app COPY . . diff --git a/docs/performance.md b/docs/performance.md index 94b21c042..31d894e90 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -19,11 +19,12 @@ To change the number of workers, use the `num` option of the `worker` section of ### `max_threads` While it's always better to know exactly what your traffic will look like, real-life applications tend to be more -unpredictable. The `max_threads` configuration is similar to FPM's `pm.max_children` and allows -FrankenPHP to automatically spawn additional threads at runtime up to the specified limit. `max_threads` can help you +unpredictable. The `max_threads` allows FrankenPHP to automatically spawn additional threads at runtime up to the specified limit. +`max_threads` can help you figure out how many threads you need to handle your traffic and can make the server more resilient to latency spikes. If set to `auto`, the limit will be estimated based on the `memory_limit` in your `php.ini`. If not able to do so, `auto` will instead default to 2x `num_threads`. +`max_threads is similar to [PHP FPM's `pm.max_children`](https://www.php.net/manual/en/install.fpm.configuration.php#pm.max-children). ## Worker Mode diff --git a/internal/cpu/cpu_unix.go b/internal/cpu/cpu_unix.go index c28be89e2..4d1821522 100644 --- a/internal/cpu/cpu_unix.go +++ b/internal/cpu/cpu_unix.go @@ -1,5 +1,3 @@ -//go:build unix - package cpu // #include @@ -11,7 +9,7 @@ import ( var cpuCount = runtime.GOMAXPROCS(0) -// probe the CPU usage of the process +// ProbeCPUs probes the CPU usage of the process // if CPUs are not busy, most threads are likely waiting for I/O, so we should scale // if CPUs are already busy we won't gain much by scaling and want to avoid the overhead of doing so func ProbeCPUs(probeTime time.Duration, maxCPUUsage float64, abort chan struct{}) bool { diff --git a/internal/cpu/cpu_fallback.go b/internal/cpu/cpu_windows.go similarity index 70% rename from internal/cpu/cpu_fallback.go rename to internal/cpu/cpu_windows.go index d14a993f3..d09d55241 100644 --- a/internal/cpu/cpu_fallback.go +++ b/internal/cpu/cpu_windows.go @@ -1,12 +1,10 @@ -//go:build !unix - package cpu import ( "time" ) -// The fallback always determines that the CPU limits are not reached +// ProbeCPUs fallback that always determines that the CPU limits are not reached func ProbeCPUs(probeTime time.Duration, maxCPUUsage float64, abort chan struct{}) bool { select { case <-abort: diff --git a/internal/memory/memory_fallback.go b/internal/memory/memory_others.go similarity index 50% rename from internal/memory/memory_fallback.go rename to internal/memory/memory_others.go index 180bf241c..a06af7b1a 100644 --- a/internal/memory/memory_fallback.go +++ b/internal/memory/memory_others.go @@ -2,7 +2,7 @@ package memory -// Return 0 if the total system memory cannot be determined +// TotalSysMemory returns 0 if the total system memory cannot be determined func TotalSysMemory() uint64 { return 0 } diff --git a/phpmainthread.go b/phpmainthread.go index 850d12e54..cc9ae378c 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -9,6 +9,7 @@ import ( "github.com/dunglas/frankenphp/internal/memory" "github.com/dunglas/frankenphp/internal/phpheaders" "go.uber.org/zap" + "go.uber.org/zap/zapcore" ) // represents the main PHP thread @@ -28,9 +29,9 @@ var ( mainThread *phpMainThread ) -// start the main PHP thread -// start a fixed number of inactive PHP threads -// reserve a fixed number of possible PHP threads +// initPHPThreads starts the main PHP thread, +// a fixed number of inactive PHP threads +// and reserves a fixed number of possible PHP threads func initPHPThreads(numThreads int, numMaxThreads int, phpIni map[string]string) (*phpMainThread, error) { mainThread = &phpMainThread{ state: newThreadState(), @@ -73,7 +74,7 @@ func initPHPThreads(numThreads int, numMaxThreads int, phpIni map[string]string) return mainThread, nil } -// ThreadDebugStatus prints the state of all PHP threads - debugging purposes only +// EXPERIMENTAL: ThreadDebugStatus prints the state of all PHP threads - debugging purposes only func ThreadDebugStatus() string { statusMessage := "" reservedThreadCount := 0 @@ -135,16 +136,20 @@ func (mainThread *phpMainThread) start() error { } func getInactivePHPThread() *phpThread { - thread := getPHPThreadAtState(stateInactive) - if thread != nil { - return thread + for _, thread := range phpThreads { + if thread.state.is(stateInactive) { + return thread + } } - thread = getPHPThreadAtState(stateReserved) - if thread == nil { - return nil + + for _, thread := range phpThreads { + if thread.state.compareAndSwap(stateReserved, stateBootRequested) { + thread.boot() + return thread + } } - thread.boot() - return thread + + return nil } func getPHPThreadAtState(state stateID) *phpThread { @@ -168,7 +173,7 @@ func go_frankenphp_main_thread_is_ready() { } // max_threads = auto -// Estimate the amount of threads based on php.ini and system memory_limit +// setAutomaticMaxThreads estimates the amount of threads based on php.ini and system memory_limit // If unable to get the system's memory limit, simply double num_threads func (mainThread *phpMainThread) setAutomaticMaxThreads() { if mainThread.maxThreads >= 0 { @@ -182,7 +187,10 @@ func (mainThread *phpMainThread) setAutomaticMaxThreads() { } maxAllowedThreads := totalSysMemory / uint64(perThreadMemoryLimit) mainThread.maxThreads = int(maxAllowedThreads) - logger.Debug("Automatic thread limit", zap.Int("perThreadMemoryLimitMB", int(perThreadMemoryLimit/1024/1024)), zap.Int("maxThreads", mainThread.maxThreads)) + + if c := logger.Check(zapcore.DebugLevel, "Automatic thread limit"); c != nil { + c.Write(zap.Int("perThreadMemoryLimitMB", int(perThreadMemoryLimit/1024/1024)), zap.Int("maxThreads", mainThread.maxThreads)) + } } //export go_frankenphp_shutdown_main_thread diff --git a/phpmainthread_test.go b/phpmainthread_test.go index 3248dff5b..2a1705c03 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -182,7 +182,11 @@ func allPossibleTransitions(worker1Path string, worker2Path string) []func(*phpT return []func(*phpThread){ convertToRegularThread, func(thread *phpThread) { thread.shutdown() }, - func(thread *phpThread) { thread.boot() }, + func(thread *phpThread) { + if thread.state.is(stateReserved) { + thread.boot() + } + }, func(thread *phpThread) { convertToWorkerThread(thread, workers[worker1Path]) }, convertToInactiveThread, func(thread *phpThread) { convertToWorkerThread(thread, workers[worker2Path]) }, diff --git a/phpthread.go b/phpthread.go index dc22a7932..853df09e4 100644 --- a/phpthread.go +++ b/phpthread.go @@ -43,11 +43,11 @@ func newPHPThread(threadIndex int) *phpThread { } } -// boot the underlying PHP thread +// boot starts the underlying PHP thread func (thread *phpThread) boot() { // thread must be in reserved state to boot - if !thread.state.compareAndSwap(stateReserved, stateBooting) { - logger.Error("thread is not in reserved state", zap.Int("threadIndex", thread.threadIndex), zap.Int("state", int(thread.state.get()))) + if !thread.state.compareAndSwap(stateReserved, stateBooting) && !thread.state.compareAndSwap(stateBootRequested, stateBooting) { + logger.Panic("thread is not in reserved state: " + thread.state.name()) return } @@ -119,7 +119,7 @@ func (thread *phpThread) getActiveRequestSafely() *http.Request { return r } -// small status message for debugging +// debugStatus creates a small status message for debugging purposes func (thread *phpThread) debugStatus() string { reqState := "" if waitTime := thread.state.waitTime(); waitTime > 0 { diff --git a/scaling.go b/scaling.go index 80b39a788..747ff253e 100644 --- a/scaling.go +++ b/scaling.go @@ -11,9 +11,9 @@ import ( "github.com/dunglas/frankenphp/internal/cpu" "go.uber.org/zap" + "go.uber.org/zap/zapcore" ) -// TODO: these constants need some real-world trial const ( // requests have to be stalled for at least this amount of time before scaling minStallTime = 5 * time.Millisecond @@ -109,7 +109,7 @@ func removeWorkerThread(worker *worker) error { return nil } -// Add a worker PHP threads automatically +// scaleWorkerThread adds a worker PHP thread automatically func scaleWorkerThread(worker *worker) { scalingMu.Lock() defer scalingMu.Unlock() @@ -125,14 +125,16 @@ func scaleWorkerThread(worker *worker) { thread, err := addWorkerThread(worker) if err != nil { - logger.Warn("could not increase max_threads, consider raising this limit", zap.String("worker", worker.fileName), zap.Error(err)) + if c := logger.Check(zapcore.WarnLevel, "could not increase max_threads, consider raising this limit"); c != nil { + c.Write(zap.String("worker", worker.fileName), zap.Error(err)) + } return } autoScaledThreads = append(autoScaledThreads, thread) } -// Add a regular PHP thread automatically +// scaleRegularThread adds a regular PHP thread automatically func scaleRegularThread() { scalingMu.Lock() defer scalingMu.Unlock() @@ -148,7 +150,9 @@ func scaleRegularThread() { thread, err := addRegularThread() if err != nil { - logger.Warn("could not increase max_threads, consider raising this limit", zap.Error(err)) + if c := logger.Check(zapcore.WarnLevel, "could not increase max_threads, consider raising this limit"); c != nil { + c.Write(zap.Error(err)) + } return } @@ -207,7 +211,7 @@ func startDownScalingThreads(done chan struct{}) { } } -// Check all threads and remove those that have been inactive for too long +// deactivateThreads checks all threads and removes those that have been inactive for too long func deactivateThreads() { stoppedThreadCount := 0 scalingMu.Lock() @@ -228,7 +232,9 @@ func deactivateThreads() { // convert threads to inactive if they have been idle for too long if thread.state.is(stateReady) && waitTime > maxThreadIdleTime.Milliseconds() { - logger.Debug("auto-converting thread to inactive", zap.Int("threadIndex", thread.threadIndex)) + if c := logger.Check(zapcore.DebugLevel, "auto-converting thread to inactive"); c != nil { + c.Write(zap.Int("threadIndex", thread.threadIndex)) + } convertToInactiveThread(thread) stoppedThreadCount++ autoScaledThreads = append(autoScaledThreads[:i], autoScaledThreads[i+1:]...) @@ -240,7 +246,9 @@ func deactivateThreads() { // Some PECL extensions like #1296 will prevent threads from fully stopping (they leak memory) // Reactivate this if there is a better solution or workaround //if thread.state.is(stateInactive) && waitTime > maxThreadIdleTime.Milliseconds() { - // logger.Debug("auto-stopping thread", zap.Int("threadIndex", thread.threadIndex)) + // if c := logger.Check(zapcore.DebugLevel, "auto-stopping thread"); c != nil { + // c.Write(zap.Int("threadIndex", thread.threadIndex)) + // } // thread.shutdown() // stoppedThreadCount++ // autoScaledThreads = append(autoScaledThreads[:i], autoScaledThreads[i+1:]...) diff --git a/state.go b/state.go index fc64aa93d..37061c241 100644 --- a/state.go +++ b/state.go @@ -12,6 +12,7 @@ const ( // livecycle states of a thread stateReserved stateID = iota stateBooting + stateBootRequested stateShuttingDown stateDone @@ -158,7 +159,7 @@ func (ts *threadState) requestSafeStateChange(nextState stateID) bool { return ts.requestSafeStateChange(nextState) } -// the thread reached a stable state and is waiting for requests or shutdown +// markAsWaiting hints that the thread reached a stable state and is waiting for requests or shutdown func (ts *threadState) markAsWaiting(isWaiting bool) { ts.mu.Lock() if isWaiting { @@ -170,7 +171,7 @@ func (ts *threadState) markAsWaiting(isWaiting bool) { ts.mu.Unlock() } -// the time since the thread is waiting in a stable state in ms +// waitTime returns the time since the thread is waiting in a stable state in ms func (ts *threadState) waitTime() int64 { ts.mu.RLock() waitTime := int64(0) diff --git a/testdata/performance/perf-test.md b/testdata/performance/performance-testing.md similarity index 58% rename from testdata/performance/perf-test.md rename to testdata/performance/performance-testing.md index 19e269e0c..e13d4ee58 100644 --- a/testdata/performance/perf-test.md +++ b/testdata/performance/performance-testing.md @@ -1,6 +1,6 @@ # Running Load tests -To run load tests with k6 you need to have docker and bash installed. +To run load tests with k6 you need to have Docker and Bash installed. Go the root of this repository and run: ```sh @@ -8,8 +8,8 @@ bash testdata/performance/perf-test.sh ``` This will build the `frankenphp-dev` docker image and run it under the name 'load-test-container' -in the background. Additionally, it will run the grafana/k6 container and you'll be able to choose -the load test you want to run. A flamegraph.svg will be created in the `testdata/performance` directory. +in the background. Additionally, it will run the `grafana/k6` container and you'll be able to choose +the load test you want to run. A `flamegraph.svg` will be created in the `testdata/performance` directory. If the load test has stopped prematurely, you might have to remove the container manually: diff --git a/thread-inactive.go b/threadinactive.go similarity index 100% rename from thread-inactive.go rename to threadinactive.go diff --git a/thread-regular.go b/threadregular.go similarity index 100% rename from thread-regular.go rename to threadregular.go diff --git a/thread-worker.go b/threadworker.go similarity index 100% rename from thread-worker.go rename to threadworker.go From a9b1f44e40e013eb9f733311422af06d7aacfac4 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 5 Feb 2025 23:05:14 +0100 Subject: [PATCH 184/190] Removes linux tag. --- internal/memory/memory_linux.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/memory/memory_linux.go b/internal/memory/memory_linux.go index a0f01a33d..fc95d199d 100644 --- a/internal/memory/memory_linux.go +++ b/internal/memory/memory_linux.go @@ -1,5 +1,3 @@ -//go:build linux - package memory import "syscall" From a8ea845adeabb763dba20151067d6ac5a541ced7 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Wed, 5 Feb 2025 23:33:58 +0100 Subject: [PATCH 185/190] Fixes linting. --- docs/performance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/performance.md b/docs/performance.md index 31d894e90..6e6d55e6a 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -24,7 +24,7 @@ unpredictable. The `max_threads` allows FrankenPHP to automatically spawn additi figure out how many threads you need to handle your traffic and can make the server more resilient to latency spikes. If set to `auto`, the limit will be estimated based on the `memory_limit` in your `php.ini`. If not able to do so, `auto` will instead default to 2x `num_threads`. -`max_threads is similar to [PHP FPM's `pm.max_children`](https://www.php.net/manual/en/install.fpm.configuration.php#pm.max-children). +`max_threads is similar to PHP FPM's [pm.max_children](https://www.php.net/manual/en/install.fpm.configuration.php#pm.max-children). ## Worker Mode From 4e03be1d0686e453557daddf1991b58844518d1d Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Thu, 6 Feb 2025 00:36:47 +0100 Subject: [PATCH 186/190] Turns the debug state into json. --- caddy/admin.go | 9 ++++- caddy/admin_test.go | 57 ++++++++++++++------------- debugstate.go | 67 ++++++++++++++++++++++++++++++++ phpmainthread.go | 15 ------- phpthread.go | 25 ------------ testdata/worker-with-counter.php | 1 + threadinactive.go | 2 +- threadworker.go | 3 +- 8 files changed, 109 insertions(+), 70 deletions(-) create mode 100644 debugstate.go diff --git a/caddy/admin.go b/caddy/admin.go index 03c22e813..2be765b01 100644 --- a/caddy/admin.go +++ b/caddy/admin.go @@ -1,6 +1,7 @@ package caddy import ( + "encoding/json" "fmt" "github.com/caddyserver/caddy/v2" "github.com/dunglas/frankenphp" @@ -44,7 +45,13 @@ func (admin *FrankenPHPAdmin) restartWorkers(w http.ResponseWriter, r *http.Requ } func (admin *FrankenPHPAdmin) threads(w http.ResponseWriter, r *http.Request) error { - return admin.success(w, frankenphp.ThreadDebugStatus()) + debugState := frankenphp.DebugState() + prettyJson, err := json.MarshalIndent(debugState, "", " ") + if err != nil { + return admin.error(http.StatusInternalServerError, err) + } + + return admin.success(w, string(prettyJson)) } func (admin *FrankenPHPAdmin) success(w http.ResponseWriter, message string) error { diff --git a/caddy/admin_test.go b/caddy/admin_test.go index 19644af90..4a970ffda 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -1,13 +1,14 @@ package caddy_test import ( + "encoding/json" "io" "net/http" - "strings" "sync" "testing" "github.com/caddyserver/caddy/v2/caddytest" + "github.com/dunglas/frankenphp" "github.com/stretchr/testify/assert" ) @@ -66,16 +67,14 @@ func TestShowTheCorrectThreadDebugStatus(t *testing.T) { } `, "caddyfile") - threadInfo := getAdminResponseBody(t, tester, "GET", "threads") + debugState := getDebugState(t, tester) // assert that the correct threads are present in the thread info - assert.Contains(t, threadInfo, "Thread 0") - assert.Contains(t, threadInfo, "Thread 1") - assert.Contains(t, threadInfo, "Thread 2") - assert.NotContains(t, threadInfo, "Thread 3") - assert.Contains(t, threadInfo, "3 additional threads can be started at runtime") - assert.Contains(t, threadInfo, "worker-with-counter.php") - assert.Contains(t, threadInfo, "index.php") + assert.Equal(t, debugState.ThreadDebugStates[0].State, "ready") + assert.Contains(t, debugState.ThreadDebugStates[1].Name, "worker-with-counter.php") + assert.Contains(t, debugState.ThreadDebugStates[2].Name, "index.php") + assert.Equal(t, debugState.ReservedThreadCount, 3) + assert.Len(t, debugState.ThreadDebugStates, 3) } func TestAutoScaleWorkerThreads(t *testing.T) { @@ -107,11 +106,7 @@ func TestAutoScaleWorkerThreads(t *testing.T) { // spam an endpoint that simulates IO endpoint := "http://localhost:" + testPort + "/?sleep=2&work=1000" - autoScaledThread := "Thread 2" - - // first assert that the thread is not already present - threadInfo := getAdminResponseBody(t, tester, "GET", "threads") - assert.NotContains(t, threadInfo, autoScaledThread) + amountOfThreads := len(getDebugState(t, tester).ThreadDebugStates) // try to spawn the additional threads by spamming the server for tries := 0; tries < maxTries; tries++ { @@ -123,14 +118,15 @@ func TestAutoScaleWorkerThreads(t *testing.T) { }() } wg.Wait() - threadInfo = getAdminResponseBody(t, tester, "GET", "threads") - if strings.Contains(threadInfo, autoScaledThread) { + + amountOfThreads = len(getDebugState(t, tester).ThreadDebugStates) + if amountOfThreads > 2 { break } } - // assert that the autoscaled thread is present in the threadInfo - assert.Contains(t, threadInfo, autoScaledThread) + // assert that there are now more threads than before + assert.NotEqual(t, amountOfThreads, 2) } // Note this test requires at least 2x40MB available memory for the process @@ -162,11 +158,7 @@ func TestAutoScaleRegularThreadsOnAutomaticThreadLimit(t *testing.T) { // spam an endpoint that simulates IO endpoint := "http://localhost:" + testPort + "/sleep.php?sleep=2&work=1000" - autoScaledThread := "Thread 1" - - // first assert that the thread is not already present - threadInfo := getAdminResponseBody(t, tester, "GET", "threads") - assert.NotContains(t, threadInfo, autoScaledThread) + amountOfThreads := len(getDebugState(t, tester).ThreadDebugStates) // try to spawn the additional threads by spamming the server for tries := 0; tries < maxTries; tries++ { @@ -178,14 +170,15 @@ func TestAutoScaleRegularThreadsOnAutomaticThreadLimit(t *testing.T) { }() } wg.Wait() - threadInfo = getAdminResponseBody(t, tester, "GET", "threads") - if strings.Contains(threadInfo, autoScaledThread) { + + amountOfThreads = len(getDebugState(t, tester).ThreadDebugStates) + if amountOfThreads > 1 { break } } - // assert that the autoscaled thread is present in the threadInfo - assert.Contains(t, threadInfo, autoScaledThread) + // assert that there are now more threads present + assert.NotEqual(t, amountOfThreads, 1) } func assertAdminResponse(t *testing.T, tester *caddytest.Tester, method string, path string, expectedStatus int, expectedBody string) { @@ -210,3 +203,13 @@ func getAdminResponseBody(t *testing.T, tester *caddytest.Tester, method string, return string(bytes) } + +func getDebugState(t *testing.T, tester *caddytest.Tester) frankenphp.FrankenPHPDebugState { + threadStates := getAdminResponseBody(t, tester, "GET", "threads") + + var debugStates frankenphp.FrankenPHPDebugState + err := json.Unmarshal([]byte(threadStates), &debugStates) + assert.NoError(t, err) + + return debugStates +} diff --git a/debugstate.go b/debugstate.go new file mode 100644 index 000000000..c6d412b93 --- /dev/null +++ b/debugstate.go @@ -0,0 +1,67 @@ +package frankenphp + +import ( + "net/http" + "time" +) + +type ThreadDebugState struct { + Index int + Name string + State string + IsHandlingRequest bool + Path string + InRequestSinceMilliseconds int64 + WaitingSinceMilliseconds int64 +} + +type FrankenPHPDebugState struct { + ThreadDebugStates []ThreadDebugState + ReservedThreadCount int +} + +// EXPERIMENTAL: DebugState prints the state of all PHP threads - debugging purposes only +func DebugState() FrankenPHPDebugState { + fullState := FrankenPHPDebugState{ + ThreadDebugStates: make([]ThreadDebugState, 0, len(phpThreads)), + ReservedThreadCount: 0, + } + for _, thread := range phpThreads { + if thread.state.is(stateReserved) { + fullState.ReservedThreadCount++ + continue + } + fullState.ThreadDebugStates = append(fullState.ThreadDebugStates, threadDebugState(thread)) + } + + return fullState +} + +// threadDebugState creates a small jsonable status message for debugging purposes +func threadDebugState(thread *phpThread) ThreadDebugState { + debugState := ThreadDebugState{ + Index: thread.threadIndex, + Name: thread.handler.name(), + State: thread.state.name(), + WaitingSinceMilliseconds: thread.state.waitTime(), + } + + var r *http.Request + if r = thread.getActiveRequestSafely(); r == nil { + return debugState + } + + fc := r.Context().Value(contextKey).(*FrankenPHPContext) + if fc.originalRequest != nil { + debugState.Path = fc.originalRequest.URL.Path + } else { + debugState.Path = r.URL.Path + } + + if fc.responseWriter != nil { + debugState.IsHandlingRequest = true + debugState.InRequestSinceMilliseconds = time.Since(fc.startedAt).Milliseconds() + } + + return debugState +} diff --git a/phpmainthread.go b/phpmainthread.go index cc9ae378c..dfb753be9 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -74,21 +74,6 @@ func initPHPThreads(numThreads int, numMaxThreads int, phpIni map[string]string) return mainThread, nil } -// EXPERIMENTAL: ThreadDebugStatus prints the state of all PHP threads - debugging purposes only -func ThreadDebugStatus() string { - statusMessage := "" - reservedThreadCount := 0 - for _, thread := range phpThreads { - if thread.state.is(stateReserved) { - reservedThreadCount++ - continue - } - statusMessage += thread.debugStatus() + "\n" - } - statusMessage += fmt.Sprintf("%d additional threads can be started at runtime\n", reservedThreadCount) - return statusMessage -} - func drainPHPThreads() { doneWG := sync.WaitGroup{} doneWG.Add(len(phpThreads)) diff --git a/phpthread.go b/phpthread.go index 853df09e4..d66b43053 100644 --- a/phpthread.go +++ b/phpthread.go @@ -3,11 +3,9 @@ package frankenphp // #include "frankenphp.h" import "C" import ( - "fmt" "net/http" "runtime" "sync" - "time" "unsafe" "go.uber.org/zap" @@ -17,7 +15,6 @@ import ( // identified by the index in the phpThreads slice type phpThread struct { runtime.Pinner - threadIndex int requestChan chan *http.Request drainChan chan struct{} @@ -119,28 +116,6 @@ func (thread *phpThread) getActiveRequestSafely() *http.Request { return r } -// debugStatus creates a small status message for debugging purposes -func (thread *phpThread) debugStatus() string { - reqState := "" - if waitTime := thread.state.waitTime(); waitTime > 0 { - reqState = fmt.Sprintf(", waiting for %dms", waitTime) - } else if r := thread.getActiveRequestSafely(); r != nil { - fc := r.Context().Value(contextKey).(*FrankenPHPContext) - path := r.URL.Path - if fc.originalRequest != nil { - path = fc.originalRequest.URL.Path - } - if fc.responseWriter == nil { - reqState = fmt.Sprintf(", executing worker script: %s ", path) - } else { - sinceMs := time.Since(fc.startedAt).Milliseconds() - reqState = fmt.Sprintf(", handling %s for %dms ", path, sinceMs) - } - } - - return fmt.Sprintf("Thread %d (%s%s) %s", thread.threadIndex, thread.state.name(), reqState, thread.handler.name()) -} - // Pin a string that is not null-terminated // PHP's zend_string may contain null-bytes func (thread *phpThread) pinString(s string) *C.char { diff --git a/testdata/worker-with-counter.php b/testdata/worker-with-counter.php index 248cf469f..e96eafed7 100644 --- a/testdata/worker-with-counter.php +++ b/testdata/worker-with-counter.php @@ -4,6 +4,7 @@ $printNumberOfRequests = function () use (&$numberOfRequests) { $numberOfRequests++; echo "requests:$numberOfRequests"; + usleep(10 * 1000); }; while (frankenphp_handle_request($printNumberOfRequests)) { diff --git a/threadinactive.go b/threadinactive.go index cb9afebc4..c0404a8d4 100644 --- a/threadinactive.go +++ b/threadinactive.go @@ -42,7 +42,7 @@ func (handler *inactiveThread) afterScriptExecution(exitStatus int) { } func (handler *inactiveThread) getActiveRequest() *http.Request { - panic("inactive threads have no requests") + return nil } func (handler *inactiveThread) name() string { diff --git a/threadworker.go b/threadworker.go index d2ac52a7d..acd1481a7 100644 --- a/threadworker.go +++ b/threadworker.go @@ -145,7 +145,6 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { func (handler *workerThread) waitForWorkerRequest() bool { // unpin any memory left over from previous requests handler.thread.Unpin() - handler.state.markAsWaiting(true) if c := logger.Check(zapcore.DebugLevel, "waiting for request"); c != nil { c.Write(zap.String("worker", handler.worker.fileName)) @@ -155,6 +154,8 @@ func (handler *workerThread) waitForWorkerRequest() bool { metrics.ReadyWorker(handler.worker.fileName) } + handler.state.markAsWaiting(true) + var r *http.Request select { case <-handler.thread.drainChan: From 8325a4af00d9eb32d97ca183081e5fc59ec70354 Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 7 Feb 2025 19:06:14 +0100 Subject: [PATCH 187/190] Adds php.ini block notation. --- caddy/caddy.go | 43 ++++++++++++++++++++++++++++++++----------- caddy/caddy_test.go | 45 +++++++++++++++++++++++++++++++++++++-------- docs/config.md | 8 +++++++- 3 files changed, 76 insertions(+), 20 deletions(-) diff --git a/caddy/caddy.go b/caddy/caddy.go index 6d7893b74..c72db244e 100644 --- a/caddy/caddy.go +++ b/caddy/caddy.go @@ -148,20 +148,41 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { f.MaxThreads = int(v) case "php_ini": - if !d.NextArg() { - return iniError - } - key := d.Val() - if !d.NextArg() { - return iniError + parseIniLine := func(d *caddyfile.Dispenser) error { + key := d.Val() + if !d.NextArg() { + return iniError + } + if f.PhpIni == nil { + f.PhpIni = make(map[string]string) + } + f.PhpIni[key] = d.Val() + if d.NextArg() { + return iniError + } + + return nil } - if f.PhpIni == nil { - f.PhpIni = make(map[string]string) + + isBlock := false + for d.NextBlock(1) { + isBlock = true + err := parseIniLine(d) + if err != nil { + return err + } } - f.PhpIni[key] = d.Val() - if d.NextArg() { - return iniError + + if !isBlock { + if !d.NextArg() { + return iniError + } + err := parseIniLine(d) + if err != nil { + return err + } } + case "worker": wc := workerConfig{} if d.NextArg() { diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 34a1d62d1..369fb7924 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -617,7 +617,7 @@ func TestAllDefinedServerVars(t *testing.T) { ) } -func TestPHPIniOverride(t *testing.T) { +func TestPHPIniConfiguration(t *testing.T) { tester := caddytest.NewTester(t) tester.InitServer(` { @@ -641,17 +641,46 @@ func TestPHPIniOverride(t *testing.T) { } `, "caddyfile") + testSingleIniConfiguration(tester, "max_execution_time", "100") + testSingleIniConfiguration(tester, "memory_limit", "10000000") +} + +func TestPHPIniBlockConfiguration(t *testing.T) { + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + + frankenphp { + num_threads 1 + php_ini { + opcache.enable 1 + opcache.jit tracing + } + } + } + + localhost:`+testPort+` { + route { + root ../testdata + php + } + } + `, "caddyfile") + + testSingleIniConfiguration(tester, "opcache.enable", "1") + testSingleIniConfiguration(tester, "opcache.jit", "tracing") +} + +func testSingleIniConfiguration(tester *caddytest.Tester, key string, value string) { // test twice to ensure the ini setting is not lost for i := 0; i < 2; i++ { tester.AssertGetResponse( - "http://localhost:"+testPort+"/ini.php?key=max_execution_time", - http.StatusOK, - "max_execution_time:100", - ) - tester.AssertGetResponse( - "http://localhost:"+testPort+"/ini.php?key=memory_limit", + "http://localhost:"+testPort+"/ini.php?key="+key, http.StatusOK, - "memory_limit:10000000", + key+":"+value, ) } } diff --git a/docs/config.md b/docs/config.md index 1eaa7c2f9..720f102c6 100644 --- a/docs/config.md +++ b/docs/config.md @@ -235,7 +235,13 @@ You can also change the PHP configuration using the `php_ini` directive in the ` { frankenphp { php_ini memory_limit 256M - php_ini max_execution_time 15 + + # or + + php_ini { + memory_limit 256M + max_execution_time 15 + } } } ``` From 9801f24ff36fd4b4ce00472ec89b641df7b2b4eb Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 7 Feb 2025 19:08:55 +0100 Subject: [PATCH 188/190] Adds loglevel check. --- scaling.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scaling.go b/scaling.go index 747ff253e..84429ba55 100644 --- a/scaling.go +++ b/scaling.go @@ -60,7 +60,9 @@ func initAutoScaling(mainThread *phpMainThread) { func drainAutoScaling() { scalingMu.Lock() - logger.Debug("shutting down autoscaling", zap.Int("autoScaledThreads", len(autoScaledThreads))) + if c := logger.Check(zapcore.DebugLevel, "shutting down autoscaling"); c != nil { + c.Write(zap.Int("autoScaledThreads", len(autoScaledThreads))) + } scalingMu.Unlock() } From 7adc465923595974e2539ab0a41f18b3653255cd Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 7 Feb 2025 19:14:38 +0100 Subject: [PATCH 189/190] Removes sleep. --- testdata/worker-with-counter.php | 1 - 1 file changed, 1 deletion(-) diff --git a/testdata/worker-with-counter.php b/testdata/worker-with-counter.php index e96eafed7..248cf469f 100644 --- a/testdata/worker-with-counter.php +++ b/testdata/worker-with-counter.php @@ -4,7 +4,6 @@ $printNumberOfRequests = function () use (&$numberOfRequests) { $numberOfRequests++; echo "requests:$numberOfRequests"; - usleep(10 * 1000); }; while (frankenphp_handle_request($printNumberOfRequests)) { From 9560e9f6250c51a02e6eea9cf8549badf256ab3e Mon Sep 17 00:00:00 2001 From: Alliballibaba Date: Fri, 7 Feb 2025 23:21:21 +0100 Subject: [PATCH 190/190] Adjusts test to work without opcache. --- caddy/caddy_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index 369fb7924..b9b65e9e6 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -656,8 +656,8 @@ func TestPHPIniBlockConfiguration(t *testing.T) { frankenphp { num_threads 1 php_ini { - opcache.enable 1 - opcache.jit tracing + max_execution_time 15 + memory_limit 20000000 } } } @@ -670,8 +670,8 @@ func TestPHPIniBlockConfiguration(t *testing.T) { } `, "caddyfile") - testSingleIniConfiguration(tester, "opcache.enable", "1") - testSingleIniConfiguration(tester, "opcache.jit", "tracing") + testSingleIniConfiguration(tester, "max_execution_time", "15") + testSingleIniConfiguration(tester, "memory_limit", "20000000") } func testSingleIniConfiguration(tester *caddytest.Tester, key string, value string) {