From 05aeff854cc24ab6075b11e7124ace6ad098beac Mon Sep 17 00:00:00 2001 From: Bogdan Date: Wed, 4 Nov 2026 21:20:32 +0100 Subject: [PATCH] perf(Config): retain stateless config instances in Worker Mode - add Factories::resetExcept() to selectively clear factory components - add Factories::resetForWorkerMode() preserving persistent factories - add $persistentFactories property to Config\WorkerMode (default: ['config']) - update frankenphp-worker.php.tpl to call Factories::resetForWorkerMode($workerConfig) - update Worker Mode documentation, upgrading guide, and changelog - add unit tests covering selective reset, worker mode persistence, and state isolation --- app/Config/WorkerMode.php | 16 ++++ docs/changelogs/v4.7.7.md | 2 + docs/installation/upgrade_477.md | 13 ++- docs/installation/worker_mode.md | 15 +++- .../Worker/Views/frankenphp-worker.php.tpl | 4 +- system/Config/Factories.php | 50 +++++++++++ .../Commands/Worker/WorkerCommandsTest.php | 1 + tests/system/Config/FactoriesTest.php | 87 +++++++++++++++++++ 8 files changed, 183 insertions(+), 5 deletions(-) diff --git a/app/Config/WorkerMode.php b/app/Config/WorkerMode.php index fd8b49434332..25089d0ac3e9 100644 --- a/app/Config/WorkerMode.php +++ b/app/Config/WorkerMode.php @@ -40,6 +40,22 @@ class WorkerMode 'cache', ]; + /** + * Persistent Factory Components + * + * List of factory component names that should persist across requests. + * Configuration instances ('config') are stateless and safe to retain, + * avoiding repeated file location, parsing, and instantiation. + * + * Components not in this list (such as 'models' or 'cells') will be reset + * between requests to prevent state leakage. + * + * @var list + */ + public array $persistentFactories = [ + 'config', + ]; + /** * Reset Event Listeners * diff --git a/docs/changelogs/v4.7.7.md b/docs/changelogs/v4.7.7.md index a2195cc39a8d..28bd5fdc126c 100644 --- a/docs/changelogs/v4.7.7.md +++ b/docs/changelogs/v4.7.7.md @@ -10,6 +10,8 @@ Release Date: Unreleased ## Changes +- **Worker Mode:** Added `Factories::resetForWorkerMode()` and `Factories::resetExcept()` to retain stateless factory components (by default `'config'`) across requests in FrankenPHP Worker Mode, avoiding repeated class location, parsing, and instantiation. Added `$persistentFactories` property to `Config\WorkerMode`. + ## Deprecations ## Bugs Fixed diff --git a/docs/installation/upgrade_477.md b/docs/installation/upgrade_477.md index 082aa62c50e8..ca286cff0dfe 100644 --- a/docs/installation/upgrade_477.md +++ b/docs/installation/upgrade_477.md @@ -8,6 +8,14 @@ Please refer to the upgrade instructions corresponding to your installation meth ## Mandatory File Changes +### Worker Mode + +If you are using Worker Mode, you should update **public/frankenphp-worker.php** after upgrading to benefit from stateless config retention between requests. The easiest way is to re-run the install command: + +```console +php spark worker:install --force +``` + ## Breaking Changes ## Breaking Enhancements @@ -25,10 +33,11 @@ The following files received significant changes (including deprecations or visu #### Config -- @TODO +- **app/Config/WorkerMode.php** + - `Config\WorkerMode::$persistentFactories` has been added, with a default value set to `['config']`. See [Worker Mode](worker_mode.md#persistent-factories) for details. ### All Changes This is a list of all files in the **project space** that received changes; many will be simple comments or formatting that have no effect on the runtime: -- @TODO +- **app/Config/WorkerMode.php** diff --git a/docs/installation/worker_mode.md b/docs/installation/worker_mode.md index 58bad275ae99..d446cc8dc576 100644 --- a/docs/installation/worker_mode.md +++ b/docs/installation/worker_mode.md @@ -52,7 +52,7 @@ The key challenge in Worker Mode is preventing state from leaking between reques Most services are destroyed and recreated for each request. Only services listed in `$persistentServices` survive between requests. **Factories Reset** -All Factories (Models, Config instances) are reset between requests, ensuring fresh instances without stale data. +Factories are reset between requests to ensure fresh instances without stale data (such as Models and Cells), while stateless components listed in `$persistentFactories` (by default `config`) survive between requests to avoid repeated file location, parsing, and instantiation. **Superglobals Isolation** Request data (`$_GET`, `$_POST`, `$_SERVER`, etc.) is properly isolated per request through the Superglobals service. @@ -137,6 +137,7 @@ All worker mode configuration is managed through the **app/Config/WorkerMode.php | Option | Type | Description | |----|----|----| | **$persistentServices** | array | Services that persist across requests and are not reset. Services not in this list are destroyed after each request to prevent state leakage. Default: `['autoloader', 'locator', 'exceptions', 'commands', 'codeigniter', 'superglobals', 'routes', 'cache']` | +| **$persistentFactories** | array | Factory components that persist across requests and are not reset. Default: `['config']` | | **$resetEventListeners** | array | Event names whose listeners are removed between requests. Use this when you register event listeners inside other event callbacks rather than at the top level of **Config/Events.php**, which would cause them to accumulate across requests. Default: `[]` | | **$forceGarbageCollection** | bool | Whether to force garbage collection after each request. `true` (default, recommended): Prevents memory leaks. `false`: Relies on PHP's automatic garbage collection. | @@ -158,6 +159,18 @@ The `$persistentServices` array controls which services survive between requests !!! warning "Warning" Adding services to `$persistentServices` without understanding their state management can cause data leakage between requests. Only persist services that are truly stateless or manage their own request isolation. +### Persistent Factories + +!!! success "Available from version 4.7.7" + +The `$persistentFactories` array controls which factory components survive between requests. + +By default, `'config'` is retained across requests: + +- Configuration objects (subclasses of `BaseConfig`) represent immutable application and environment settings. +- Retaining them eliminates repetitive file location searches, class existence checks, environment parsing, and instantiation on every request. +- Factory components not listed (such as `'models'` and `'cells'`) are reset between requests to prevent state leakage. + ### Reset Event Listeners !!! success "Available from version 4.7.1" diff --git a/system/Commands/Worker/Views/frankenphp-worker.php.tpl b/system/Commands/Worker/Views/frankenphp-worker.php.tpl index 801bf796f343..1474f2fd1d95 100644 --- a/system/Commands/Worker/Views/frankenphp-worker.php.tpl +++ b/system/Commands/Worker/Views/frankenphp-worker.php.tpl @@ -118,8 +118,8 @@ while (frankenphp_handle_request($handler)) { // Cleanup connections with uncommitted transactions DatabaseConfig::cleanupForWorkerMode(); - // Reset factories - Factories::reset(); + // Reset factories except persistent ones + Factories::resetForWorkerMode($workerConfig); // Reset services except persistent ones Services::resetForWorkerMode($workerConfig); diff --git a/system/Config/Factories.php b/system/Config/Factories.php index 5831862be8f6..bbcc7a2cb9b9 100644 --- a/system/Config/Factories.php +++ b/system/Config/Factories.php @@ -17,6 +17,7 @@ use CodeIgniter\Database\ConnectionInterface; use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Model; +use Config\WorkerMode; /** * Factories for creating instances. @@ -469,6 +470,55 @@ public static function reset(?string $component = null) self::$updated = []; } + /** + * Resets the static arrays for all components except the specified ones. + * + * @param list $components Component names to keep + */ + public static function resetExcept(array $components): void + { + $components = array_map(strtolower(...), $components); + + foreach (array_keys(self::$options) as $component) { + if (! in_array($component, $components, true)) { + unset(self::$options[$component]); + } + } + + foreach (array_keys(self::$aliases) as $component) { + if (! in_array($component, $components, true)) { + unset(self::$aliases[$component]); + } + } + + foreach (array_keys(self::$instances) as $component) { + if (! in_array($component, $components, true)) { + unset(self::$instances[$component]); + } + } + + foreach (array_keys(self::$updated) as $component) { + if (! in_array($component, $components, true)) { + unset(self::$updated[$component]); + } + } + } + + /** + * Resets factories for Worker Mode. + * + * Preserves only persistent factory components defined in WorkerMode config. + * + * Called at the END of each request to clean up state. + */ + public static function resetForWorkerMode(?WorkerMode $config = null): void + { + $config ??= self::config(WorkerMode::class); + $persistentFactories = $config instanceof WorkerMode ? $config->persistentFactories : ['config']; + + self::resetExcept($persistentFactories); + } + /** * Helper method for injecting mock instances * diff --git a/tests/system/Commands/Worker/WorkerCommandsTest.php b/tests/system/Commands/Worker/WorkerCommandsTest.php index ff570356fdcf..7e9fe6bc6e41 100644 --- a/tests/system/Commands/Worker/WorkerCommandsTest.php +++ b/tests/system/Commands/Worker/WorkerCommandsTest.php @@ -165,6 +165,7 @@ public function testWorkerInstallCreatesValidPHPFile(): void $this->assertStringContainsString('frankenphp_handle_request', (string) $content); $this->assertStringContainsString('DatabaseConfig::reconnectForWorkerMode', (string) $content); $this->assertStringContainsString('DatabaseConfig::cleanupForWorkerMode', (string) $content); + $this->assertStringContainsString('Factories::resetForWorkerMode', (string) $content); } public function testWorkerInstallCreatesValidCaddyfile(): void diff --git a/tests/system/Config/FactoriesTest.php b/tests/system/Config/FactoriesTest.php index abaef3997a88..0711367853ed 100644 --- a/tests/system/Config/FactoriesTest.php +++ b/tests/system/Config/FactoriesTest.php @@ -17,6 +17,7 @@ use CodeIgniter\Test\CIUnitTestCase; use Config\App; use Config\Database; +use Config\WorkerMode; use PHPUnit\Framework\Attributes\Depends; use PHPUnit\Framework\Attributes\Group; use ReflectionClass; @@ -526,4 +527,90 @@ public function testGetNonexistentClass(): void $this->assertNull($config); } + + public function testResetExcept(): void + { + $config = Factories::config('App'); + $model = Factories::models(UserModel::class); + + $this->assertSame($config, Factories::get('config', 'App')); + $this->assertSame($model, Factories::get('models', UserModel::class)); + + Factories::resetExcept(['config']); + + $this->assertSame($config, Factories::get('config', 'App')); + $this->assertNotSame($model, Factories::models(UserModel::class)); + } + + public function testResetExceptCaseInsensitive(): void + { + $config = Factories::config('App'); + $model = Factories::models(UserModel::class); + + Factories::resetExcept(['CONFIG']); + + $this->assertSame($config, Factories::get('config', 'App')); + $this->assertNotSame($model, Factories::models(UserModel::class)); + } + + public function testResetExceptEmptyArrayResetsAll(): void + { + $config = Factories::config('App'); + $model = Factories::models(UserModel::class); + + Factories::resetExcept([]); + + $this->assertNotSame($config, Factories::config('App')); + $this->assertNotSame($model, Factories::models(UserModel::class)); + } + + public function testResetForWorkerModeWithConfig(): void + { + $workerConfig = new WorkerMode(); + $workerConfig->persistentFactories = ['config']; + + $config = Factories::config('App'); + $model = Factories::models(UserModel::class); + + Factories::resetForWorkerMode($workerConfig); + + $this->assertSame($config, Factories::get('config', 'App')); + $this->assertNotSame($model, Factories::models(UserModel::class)); + } + + public function testResetForWorkerModeDefault(): void + { + $config = Factories::config('App'); + $model = Factories::models(UserModel::class); + + Factories::resetForWorkerMode(); + + $this->assertSame($config, Factories::get('config', 'App')); + $this->assertNotSame($model, Factories::models(UserModel::class)); + } + + public function testStateIsolationSequentialCycles(): void + { + $workerConfig = new WorkerMode(); + + // Cycle 1 + $config1 = Factories::config('App'); + $model1 = Factories::models(UserModel::class); + $model1->allowCallbacks(false); + $this->assertFalse($this->getPrivateProperty($model1, 'tempAllowCallbacks')); + + // End of Cycle 1 cleanup + Factories::resetForWorkerMode($workerConfig); + + // Cycle 2 + $config2 = Factories::config('App'); + $model2 = Factories::models(UserModel::class); + + // Config is retained across cycles + $this->assertSame($config1, $config2); + + // Model is fresh and not polluted by cycle 1 state + $this->assertNotSame($model1, $model2); + $this->assertTrue($this->getPrivateProperty($model2, 'tempAllowCallbacks')); + } }