From a47b7de4d5bc6250b028897337604d3db384b91d Mon Sep 17 00:00:00 2001 From: michalsn Date: Sat, 10 Jan 2026 09:47:37 +0100 Subject: [PATCH 01/18] wip --- app/Config/WorkerMode.php | 86 +++++++++++ system/Boot.php | 33 ++++ system/Cache/CacheFactory.php | 3 + system/Cache/Handlers/MemcachedHandler.php | 43 ++++-- system/Cache/Handlers/PredisHandler.php | 27 ++++ system/Cache/Handlers/RedisHandler.php | 37 +++-- system/Cache/ReconnectCacheDecorator.php | 141 ++++++++++++++++++ system/CodeIgniter.php | 25 ++++ system/Commands/Worker/Views/Caddyfile.tpl | 81 ++++++++++ .../Worker/Views/frankenphp-worker.php.tpl | 132 ++++++++++++++++ system/Commands/Worker/WorkerInstall.php | 137 +++++++++++++++++ system/Commands/Worker/WorkerUninstall.php | 107 +++++++++++++ system/Config/BaseService.php | 38 +++++ system/Database/BaseConnection.php | 31 ++++ system/Database/Config.php | 41 +++++ system/Database/Postgre/Connection.php | 11 ++ system/Debug/Timer.php | 9 ++ system/Debug/Toolbar.php | 13 ++ system/Debug/Toolbar/Collectors/Database.php | 9 ++ system/Events/Events.php | 17 +++ system/Log/Logger.php | 11 ++ 21 files changed, 1010 insertions(+), 22 deletions(-) create mode 100644 app/Config/WorkerMode.php create mode 100644 system/Cache/ReconnectCacheDecorator.php create mode 100644 system/Commands/Worker/Views/Caddyfile.tpl create mode 100644 system/Commands/Worker/Views/frankenphp-worker.php.tpl create mode 100644 system/Commands/Worker/WorkerInstall.php create mode 100644 system/Commands/Worker/WorkerUninstall.php diff --git a/app/Config/WorkerMode.php b/app/Config/WorkerMode.php new file mode 100644 index 000000000000..f298249d64e8 --- /dev/null +++ b/app/Config/WorkerMode.php @@ -0,0 +1,86 @@ + + */ + public array $persistentServices = [ + 'autoloader', + 'locator', + 'exceptions', + 'logger', + 'timer', + 'commands', + 'codeigniter', + 'superglobals', + ]; + + /** + * Database connection strategy + * + * Determines how database connections are handled between requests: + * + * - 'keep-alive' (recommended): + * Keeps connections alive across requests for best performance + * - 'disconnect': + * Closes all connections after each request + * + * @var 'keep-alive'|'disconnect' + */ + public string $databaseStrategy = 'keep-alive'; + + /** + * Cache handler strategy + * + * Determines how cache handlers are managed between requests: + * + * - 'keep-alive' (recommended): + * Keeps cache handler alive across requests for best performance + * - 'disconnect': + * Closes the handler after each request + * + * @var 'keep-alive'|'disconnect' + */ + public string $cacheStrategy = 'keep-alive'; + + /** + * Reset Factories + * + * Whether to reset Factories (Models, etc.) between requests. + * Set to false if you want to cache model instances across requests. + */ + public bool $resetFactories = true; + + /** + * Garbage Collection + * + * Whether to force garbage collection after each request. + * Helps prevent memory leaks at a small performance cost. + */ + public bool $garbageCollection = true; +} diff --git a/system/Boot.php b/system/Boot.php index 76f9fee8966d..f84506e4e8a0 100644 --- a/system/Boot.php +++ b/system/Boot.php @@ -76,6 +76,39 @@ public static function bootWeb(Paths $paths): int return EXIT_SUCCESS; } + /** + * Bootstrap for FrankenPHP worker mode. + * + * This method performs one-time initialization for worker mode, + * loading everything except the CodeIgniter instance, which should + * be created fresh for each request. + * + * @used-by `public/frankenphp-worker.php` + */ + public static function bootWorker(Paths $paths): CodeIgniter + { + static::definePathConstants($paths); + if (! defined('APP_NAMESPACE')) { + static::loadConstants(); + } + static::checkMissingExtensions(); + + static::loadDotEnv($paths); + static::defineEnvironment(); + static::loadEnvironmentBootstrap($paths); + + static::loadCommonFunctions(); + static::loadAutoloader(); + static::setExceptionHandler(); + static::initializeKint(); + + // Note: We skip config caching in worker mode + // as it may cause issues with state between requests + static::autoloadHelpers(); + + return Boot::initializeCodeIgniter(); + } + /** * Used by command line scripts other than * * `spark` diff --git a/system/Cache/CacheFactory.php b/system/Cache/CacheFactory.php index e84254142125..83e182d13279 100644 --- a/system/Cache/CacheFactory.php +++ b/system/Cache/CacheFactory.php @@ -86,6 +86,9 @@ public static function getHandler(Cache $config, ?string $handler = null, ?strin $adapter = self::getHandler($config, $backup, 'dummy'); } + // Wrap with reconnect decorator for automatic reconnection on failure + $adapter = new ReconnectCacheDecorator($adapter); + return $adapter; } } diff --git a/system/Cache/Handlers/MemcachedHandler.php b/system/Cache/Handlers/MemcachedHandler.php index 14bc8e80a144..0d3c7faf86fc 100644 --- a/system/Cache/Handlers/MemcachedHandler.php +++ b/system/Cache/Handlers/MemcachedHandler.php @@ -57,18 +57,6 @@ public function __construct(Cache $config) $this->config = array_merge($this->config, $config->memcached); } - /** - * Closes the connection to Memcache(d) if present. - */ - public function __destruct() - { - if ($this->memcached instanceof Memcached) { - $this->memcached->quit(); - } elseif ($this->memcached instanceof Memcache) { - $this->memcached->close(); - } - } - public function initialize(): void { try { @@ -230,4 +218,35 @@ public function isSupported(): bool { return extension_loaded('memcached') || extension_loaded('memcache'); } + + /** + * Reconnect to Memcached server + * + * Used by ReconnectCacheDecorator to restore connection after failure. + */ + public function reconnect(): bool + { + // Close existing connection to avoid resource leak + if (isset($this->memcached)) { + try { + if ($this->memcached instanceof Memcached) { + $this->memcached->quit(); + } elseif ($this->memcached instanceof Memcache) { + $this->memcached->close(); + } + } catch (Exception $e) { + // Connection already dead, that's fine + } + } + + try { + $this->initialize(); + + return true; + } catch (CriticalError $e) { + log_message('error', 'Cache: Memcached reconnection failed: ' . $e->getMessage()); + + return false; + } + } } diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index 763848b232fc..a43c86827c8a 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -204,4 +204,31 @@ public function isSupported(): bool { return class_exists(Client::class); } + + /** + * Reconnect to Redis server via Predis + * + * Used by ReconnectCacheDecorator to restore connection after failure. + */ + public function reconnect(): bool + { + // Disconnect existing connection to avoid resource leak + if (isset($this->redis)) { + try { + $this->redis->disconnect(); + } catch (Exception $e) { + // Connection already dead, that's fine + } + } + + try { + $this->initialize(); + + return true; + } catch (CriticalError $e) { + log_message('error', 'Cache: Predis reconnection failed: ' . $e->getMessage()); + + return false; + } + } } diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index 7dacf94ad6bb..e27c6fc154d6 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -64,16 +64,6 @@ public function __construct(Cache $config) $this->config = array_merge($this->config, $config->redis); } - /** - * Closes the connection to Redis if present. - */ - public function __destruct() - { - if (isset($this->redis)) { - $this->redis->close(); - } - } - public function initialize(): void { $config = $this->config; @@ -229,4 +219,31 @@ public function isSupported(): bool { return extension_loaded('redis'); } + + /** + * Reconnect to Redis server + * + * Used by ReconnectCacheDecorator to restore connection after failure. + */ + public function reconnect(): bool + { + // Close existing connection to avoid resource leak + if (isset($this->redis)) { + try { + $this->redis->close(); + } catch (RedisException $e) { + // Connection already dead, that's fine + } + } + + try { + $this->initialize(); + + return true; + } catch (CriticalError $e) { + log_message('error', 'Cache: Redis reconnection failed: ' . $e->getMessage()); + + return false; + } + } } diff --git a/system/Cache/ReconnectCacheDecorator.php b/system/Cache/ReconnectCacheDecorator.php new file mode 100644 index 000000000000..9177cdc56b41 --- /dev/null +++ b/system/Cache/ReconnectCacheDecorator.php @@ -0,0 +1,141 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache; + +use Exception; + +/** + * Reconnect Cache Decorator + * + * Wraps cache handlers to provide automatic reconnection on failure. + * Used in worker mode to prevent connection failures from reaching users. + */ +class ReconnectCacheDecorator implements CacheInterface +{ + /** + * Methods that should attempt reconnection on failure + * + * @var list + */ + private array $retryableMethods = [ + 'get', + 'save', + 'delete', + 'deleteMatching', + 'increment', + 'decrement', + 'clean', + 'getCacheInfo', + 'getMetaData', + ]; + + public function __construct( + private CacheInterface $handler + ) {} + + /** + * Get the underlying handler for direct access + */ + public function getHandler(): CacheInterface + { + return $this->handler; + } + + public function initialize(): void + { + $this->handler->initialize(); + } + + public function get(string $key): mixed + { + return $this->execute('get', [$key]); + } + + public function save(string $key, mixed $value, int $ttl = 60): bool + { + return $this->execute('save', [$key, $value, $ttl]); + } + + public function delete(string $key): bool + { + return $this->execute('delete', [$key]); + } + + public function deleteMatching(string $pattern): int + { + return $this->execute('deleteMatching', [$pattern]); + } + + public function increment(string $key, int $offset = 1): bool|int + { + return $this->execute('increment', [$key, $offset]); + } + + public function decrement(string $key, int $offset = 1): bool|int + { + return $this->execute('decrement', [$key, $offset]); + } + + public function clean(): bool + { + return $this->execute('clean', []); + } + + public function getCacheInfo(): array|false|object|null + { + return $this->execute('getCacheInfo', []); + } + + public function getMetaData(string $key): ?array + { + return $this->execute('getMetaData', [$key]); + } + + public function isSupported(): bool + { + return $this->handler->isSupported(); + } + + /** + * Execute cache operation with automatic reconnection on failure + * + * @param string $method Method name + * @param array $arguments Method arguments + * + * @return mixed + * @throws Exception + */ + private function execute(string $method, array $arguments): mixed + { + if (! in_array($method, $this->retryableMethods, true)) { + return $this->handler->{$method}(...$arguments); + } + + try { + return $this->handler->{$method}(...$arguments); + } catch (Exception $e) { + // If handler doesn't support reconnection, rethrow immediately + if (! method_exists($this->handler, 'reconnect')) { + throw $e; + } + + if (! $this->handler->reconnect()) { + throw $e; + } + + // Retry once - if it fails again, let the exception propagate + return $this->handler->{$method}(...$arguments); + } + } +} diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index 847020504726..b1deea7ddd61 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -192,6 +192,31 @@ public function initialize() date_default_timezone_set($this->config->appTimezone ?? 'UTC'); } + /** + * Reset request-specific state for worker mode. + * Clears all request/response data to prepare for the next request. + * + * @return void + */ + public function resetForWorkerMode(): void + { + $this->request = null; + $this->response = null; + $this->router = null; + $this->controller = null; + $this->method = null; + $this->output = null; + + // Reset timing + $this->startTime = null; + $this->totalTime = 0; + + // Clean any leftover output buffers (safety measure for errors) + while (ob_get_level() > 0) { + ob_end_clean(); + } + } + /** * Initializes Kint * diff --git a/system/Commands/Worker/Views/Caddyfile.tpl b/system/Commands/Worker/Views/Caddyfile.tpl new file mode 100644 index 000000000000..02aa1e7b853e --- /dev/null +++ b/system/Commands/Worker/Views/Caddyfile.tpl @@ -0,0 +1,81 @@ +# CodeIgniter 4 - FrankenPHP Worker Mode Configuration +# +# This Caddyfile configures FrankenPHP to run CodeIgniter in worker mode. +# Adjust settings based on your server resources and application needs. +# +# Start with: frankenphp run + +{ + # FrankenPHP worker configuration + frankenphp { + # Worker configuration + worker { + # Path to the worker file + file public/frankenphp-worker.php + + # Number of workers (default: 2x CPU cores) + # Adjust based on your server capacity + num 2 + } + } + + # Disable admin API (recommended for production) + admin off +} + +# HTTP server configuration +:8080 { + # Document root + root * public + + # Enable compression + encode zstd br gzip + + # Security headers + header { + # Prevent clickjacking + X-Frame-Options "SAMEORIGIN" + # Prevent MIME type sniffing + X-Content-Type-Options "nosniff" + # Enable XSS protection + X-XSS-Protection "1; mode=block" + # Remove server header + -Server + } + + # Route all PHP requests through the worker + php_server { + # Use frankenphp-worker.php as the entry point + index frankenphp-worker.php + # Route all requests through the worker + try_files {path} frankenphp-worker.php + } + + # Serve static files + file_server +} + +# HTTPS configuration (uncomment and configure for production) +# :443 { +# # TLS certificate paths +# tls /path/to/cert.pem /path/to/key.pem +# +# root * public +# encode zstd br gzip +# +# header { +# X-Frame-Options "SAMEORIGIN" +# X-Content-Type-Options "nosniff" +# X-XSS-Protection "1; mode=block" +# -Server +# # Enable HSTS for HTTPS +# Strict-Transport-Security "max-age=31536000; includeSubDomains" +# } +# +# php_server { +# index frankenphp-worker.php +# try_files {path} frankenphp-worker.php +# } +# +# file_server +# } diff --git a/system/Commands/Worker/Views/frankenphp-worker.php.tpl b/system/Commands/Worker/Views/frankenphp-worker.php.tpl new file mode 100644 index 000000000000..fdd34071c710 --- /dev/null +++ b/system/Commands/Worker/Views/frankenphp-worker.php.tpl @@ -0,0 +1,132 @@ +systemDirectory . '/Boot.php'; + +// One-time boot - loads autoloader, environment, helpers, etc. +$app = Boot::bootWorker($paths); + +// Prevent worker termination on client disconnect +ignore_user_abort(true); + +/** @var WorkerMode $workerConfig */ +$workerConfig = config('WorkerMode'); + +/* + *--------------------------------------------------------------- + * REQUEST HANDLER + *--------------------------------------------------------------- + */ + +$handler = function () use ($app, $workerConfig) { + // Reset request-specific state + $app->resetForWorkerMode(); + + // Update superglobals with fresh request data + service('superglobals') + ->setServerArray($_SERVER) + ->setGetArray($_GET) + ->setPostArray($_POST) + ->setCookieArray($_COOKIE) + ->setFilesArray($_FILES) + ->setRequestArray($_REQUEST); + + // Handle the request + $app->run(); + + if ($workerConfig->garbageCollection) { + // Force garbage collection + gc_collect_cycles(); + } +}; + +/* + *--------------------------------------------------------------- + * WORKER REQUEST LOOP + *--------------------------------------------------------------- + */ + +while (frankenphp_handle_request($handler)) { + // Close session + if (Services::has('session')) { + Services::session()->close(); + } + + // Reset database connections based on strategy (keep-alive or disconnect) + DatabaseConfig::resetForWorkerMode($workerConfig); + + // Reset model/entity instances (if enabled) + if ($workerConfig->resetFactories) { + Factories::reset(); + } + + // Reset services except persistent ones + Services::resetForWorkerMode($workerConfig); + + // Reset performance + Events::resetForWorkerMode(); + + // Reset persistent services that accumulate state + if (Services::has('timer')) { + Services::timer()->reset(); + } + + if (Services::has('logger')) { + Services::logger()->reset(); + } + + // Reset debug toolbar collectors (development only) + if (CI_DEBUG && Services::has('toolbar')) { + Services::toolbar()->reset(); + } +} diff --git a/system/Commands/Worker/WorkerInstall.php b/system/Commands/Worker/WorkerInstall.php new file mode 100644 index 000000000000..8610713115ff --- /dev/null +++ b/system/Commands/Worker/WorkerInstall.php @@ -0,0 +1,137 @@ + 'Overwrite existing files', + ]; + + /** + * Template file mappings (template => destination path) + * + * @var array + */ + private array $templates = [ + 'frankenphp-worker.php.tpl' => 'public/frankenphp-worker.php', + 'Caddyfile.tpl' => 'Caddyfile', + ]; + + public function run(array $params) + { + $force = array_key_exists('force', $params) || CLI::getOption('force'); + + CLI::write('Setting up FrankenPHP Worker Mode', 'yellow'); + CLI::newLine(); + + helper('filesystem'); + + $created = []; + + // Process each template + foreach ($this->templates as $template => $destination) { + $source = SYSTEMPATH . 'Commands/Worker/Views/' . $template; + $target = ROOTPATH . $destination; + + $isFile = is_file($target); + + // Skip if file exists and not forcing overwrite + if (! $force && $isFile) { + continue; + } + + // Read template content + $content = file_get_contents($source); + if ($content === false) { + CLI::error( + "Failed to read template: {$template}", + 'light_gray', + 'red' + ); + CLI::newLine(); + + return EXIT_ERROR; + } + + // Write file to destination + if (! write_file($target, $content)) { + CLI::error( + 'Failed to create file: ' . clean_path($target), + 'light_gray', + 'red' + ); + CLI::newLine(); + + return EXIT_ERROR; + } + + if ($force && $isFile) { + CLI::write(' File overwritten: ' . clean_path($target), 'yellow'); + } else { + CLI::write(' File created: ' . clean_path($target), 'green'); + } + + $created[] = $destination; + } + + // No files were created + if ($created === []) { + CLI::newLine(); + CLI::write('Worker mode files already exist.', 'yellow'); + CLI::write('Use --force to overwrite existing files.', 'yellow'); + CLI::newLine(); + + return EXIT_ERROR; + } + + // Success message + CLI::newLine(); + CLI::write('Worker mode files created successfully!', 'green'); + CLI::newLine(); + + $this->showNextSteps(); + + return EXIT_SUCCESS; + } + + /** + * Display next steps to the user + */ + protected function showNextSteps(): void + { + CLI::write('Next Steps:', 'yellow'); + CLI::newLine(); + + CLI::write('1. Review configuration files:', 'white'); + CLI::write(' Caddyfile - Adjust port, worker count, max_requests', 'green'); + CLI::write(' public/frankenphp-worker.php - Customize state management', 'green'); + CLI::newLine(); + + CLI::write('2. Start FrankenPHP:', 'white'); + CLI::write(' frankenphp run', 'green'); + CLI::newLine(); + + CLI::write('3. Test your application:', 'white'); + CLI::write(' curl http://localhost:8080/', 'green'); + CLI::newLine(); + + CLI::write('Documentation:', 'yellow'); + CLI::write(' • https://frankenphp.dev/docs/worker/', 'blue'); + CLI::newLine(); + } +} diff --git a/system/Commands/Worker/WorkerUninstall.php b/system/Commands/Worker/WorkerUninstall.php new file mode 100644 index 000000000000..bd33d4ba36dd --- /dev/null +++ b/system/Commands/Worker/WorkerUninstall.php @@ -0,0 +1,107 @@ + 'Skip confirmation prompt', + ]; + + /** + * Files to remove (must match Install command) + * + * @var list + */ + private array $files = [ + 'public/frankenphp-worker.php', + 'Caddyfile', + ]; + + public function run(array $params) + { + $force = array_key_exists('force', $params) || CLI::getOption('force'); + + CLI::write('Uninstalling FrankenPHP Worker Mode', 'yellow'); + CLI::newLine(); + + // Find existing files + $existing = []; + foreach ($this->files as $file) { + $path = ROOTPATH . $file; + if (is_file($path)) { + $existing[] = $file; + } + } + + // No files to remove + if (empty($existing)) { + CLI::write('No worker mode files found to remove.', 'yellow'); + CLI::newLine(); + + return EXIT_SUCCESS; + } + + // Show files that will be removed + CLI::write('The following files will be removed:', 'yellow'); + foreach ($existing as $file) { + CLI::write(' - ' . $file, 'white'); + } + CLI::newLine(); + + // Confirm deletion unless --force is used + if (! $force) { + $confirm = CLI::prompt('Are you sure you want to remove these files?', ['y', 'n']); + CLI::newLine(); + + if ($confirm !== 'y') { + CLI::write('Uninstall cancelled.', 'yellow'); + CLI::newLine(); + + return EXIT_ERROR; + } + } + + $removed = []; + + // Remove each file + foreach ($existing as $file) { + $path = ROOTPATH . $file; + + if (! @unlink($path)) { + CLI::error('Failed to remove file: ' . clean_path($path), 'light_gray', 'red'); + continue; + } + + CLI::write(' File removed: ' . clean_path($path), 'green'); + $removed[] = $file; + } + + // Summary + CLI::newLine(); + if ($removed === []) { + CLI::error('No files were removed.'); + CLI::newLine(); + + return EXIT_ERROR; + } + + CLI::write('Worker mode files removed successfully!', 'green'); + CLI::newLine(); + + return EXIT_SUCCESS; + } +} diff --git a/system/Config/BaseService.php b/system/Config/BaseService.php index 0fb4c3c7c3ad..3e834f195556 100644 --- a/system/Config/BaseService.php +++ b/system/Config/BaseService.php @@ -80,6 +80,7 @@ use Config\Toolbar as ConfigToolbar; use Config\Validation as ConfigValidation; use Config\View as ConfigView; +use Config\WorkerMode; /** * Services Configuration file. @@ -361,6 +362,43 @@ public static function reset(bool $initAutoloader = true) } } + /** + * Resets all services except those in the persistent list. + * Used for worker mode to preserve expensive-to-initialize services. + * + * @param WorkerMode $config + * @return void + */ + public static function resetForWorkerMode(WorkerMode $config): void + { + // Reset mocks (testing only, safe to clear) + static::$mocks = []; + + // Reset factories + static::$factories = []; + + // Process each service instance + $persistentInstances = []; + + foreach (static::$instances as $serviceName => $service) { + if ($serviceName === 'cache') { + // Only persist cache in keep-alive mode + if ($config->cacheStrategy === 'keep-alive') { + $persistentInstances[$serviceName] = $service; + } + + continue; + } + + // Persist services in the persistent list + if (in_array($serviceName, $config->persistentServices, true)) { + $persistentInstances[$serviceName] = $service; + } + } + + static::$instances = $persistentInstances; + } + /** * Resets any mock and shared instances for a single service. * diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index f09175d59b46..7b35b49f7b17 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -493,6 +493,37 @@ public function close() */ abstract protected function _close(); + /** + * Check if the connection is still alive. + * + * @return bool + */ + public function ping(): bool + { + if (! $this->connID) { + return false; + } + + return $this->_ping(); + } + + /** + * Driver-specific ping implementation. + * + * @return bool + */ + protected function _ping(): bool + { + try { + $result = $this->simpleQuery('SELECT 1'); + + return $result !== false; + + } catch (DatabaseException) { + return false; + } + } + /** * Create a persistent database connection. * diff --git a/system/Database/Config.php b/system/Database/Config.php index fcf700ee3bcb..9d3402cb73b3 100644 --- a/system/Database/Config.php +++ b/system/Database/Config.php @@ -16,6 +16,7 @@ use CodeIgniter\Config\BaseConfig; use CodeIgniter\Exceptions\InvalidArgumentException; use Config\Database as DbConfig; +use Config\WorkerMode; /** * @see \CodeIgniter\Database\ConfigTest @@ -152,4 +153,44 @@ protected static function ensureFactory() static::$factory = new Database(); } + + /** + * Reset database connections for worker mode. + * Behavior controlled by WorkerMode config. + * + * This method handles connection state management between requests + * in long-running worker processes. + * + * @param WorkerMode $config + * @return void + */ + public static function resetForWorkerMode(WorkerMode $config): void + { + foreach (static::$instances as $group => $connection) { + switch ($config->databaseStrategy) { + case 'disconnect': + $connection->close(); + break; + + case 'keep-alive': + while ($connection->transDepth > 0) { + $connection->transRollback(); + } + $connection->transDepth = 0; + + if (! $connection->ping()) { + log_message('info', "Database connection dead, reconnecting: {$group}"); + $connection->reconnect(); + } + break; + } + } + + // If using the disconnect strategy, clear the instances array + if ($config->databaseStrategy === 'disconnect') { + static::$instances = []; + static::$factory = null; + } + } + } diff --git a/system/Database/Postgre/Connection.php b/system/Database/Postgre/Connection.php index e60c8b1583d9..20f88c275c28 100644 --- a/system/Database/Postgre/Connection.php +++ b/system/Database/Postgre/Connection.php @@ -166,6 +166,17 @@ protected function _close() pg_close($this->connID); } + /** + * Ping the database connection. + * + * @return bool + */ + protected function _ping(): bool + { + return pg_ping($this->connID); + } + + /** * Select a specific database table to use. */ diff --git a/system/Debug/Timer.php b/system/Debug/Timer.php index 98b1299a89f7..19026d73380f 100644 --- a/system/Debug/Timer.php +++ b/system/Debug/Timer.php @@ -148,4 +148,13 @@ public function record(string $name, callable $callable) return $returnValue; } + + /** + * Reset all timers for worker mode. + * Clears all timer data between requests. + */ + public function reset(): void + { + $this->timers = []; + } } diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index 257e19e1f602..c35003129764 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -606,4 +606,17 @@ private function shouldDisableToolbar(IncomingRequest $request): bool return false; } + + /** + * Reset all collectors for worker mode. + * Calls reset() on collectors that support it. + */ + public function reset(): void + { + foreach ($this->collectors as $collector) { + if (method_exists($collector, 'reset')) { + $collector->reset(); + } + } + } } diff --git a/system/Debug/Toolbar/Collectors/Database.php b/system/Debug/Toolbar/Collectors/Database.php index be57931b30f9..fdf961d2f974 100644 --- a/system/Debug/Toolbar/Collectors/Database.php +++ b/system/Debug/Toolbar/Collectors/Database.php @@ -254,4 +254,13 @@ private function getConnections(): void { $this->connections = \Config\Database::getConnections(); } + + /** + * Reset collector state for worker mode. + * Clears collected queries between requests. + */ + public function reset(): void + { + static::$queries = []; + } } diff --git a/system/Events/Events.php b/system/Events/Events.php index 4c255f02df05..dfd2f286f13b 100644 --- a/system/Events/Events.php +++ b/system/Events/Events.php @@ -284,4 +284,21 @@ public static function getPerformanceLogs() { return static::$performanceLog; } + + /** + * Reset events for worker mode. + * + * Uses WorkerMode config to determine behavior. + * Clears performance logs and re-initializes event listeners from config files. + * + * Note: This clears dynamically registered listeners. If your application + * registers listeners at runtime, they will need to be re-registered for each request. + * + * @return void + */ + public static function resetForWorkerMode(): void + { + static::$performanceLog = []; + } + } diff --git a/system/Log/Logger.php b/system/Log/Logger.php index 8308ddaf94f7..405997d9e0a7 100644 --- a/system/Log/Logger.php +++ b/system/Log/Logger.php @@ -376,4 +376,15 @@ public function determineFile(): array return ['unknown', 'unknown']; } + + /** + * Reset log cache for worker mode. + * Clears cached log entries between requests. + */ + public function reset(): void + { + if ($this->cacheLogs) { + $this->logCache = []; + } + } } From afe79f7dea4ee2f0e2ff23c1736ab384c60cf80e Mon Sep 17 00:00:00 2001 From: michalsn Date: Sun, 11 Jan 2026 14:22:01 +0100 Subject: [PATCH 02/18] wip --- system/Database/Config.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/system/Database/Config.php b/system/Database/Config.php index 9d3402cb73b3..86383f25566b 100644 --- a/system/Database/Config.php +++ b/system/Database/Config.php @@ -173,10 +173,20 @@ public static function resetForWorkerMode(WorkerMode $config): void break; case 'keep-alive': - while ($connection->transDepth > 0) { - $connection->transRollback(); + $maxAttempts = 10; // Prevent infinite loop + $attempts = 0; + + while ($connection->transDepth > 0 && $attempts < $maxAttempts) { + if (! $connection->transRollback()) { + log_message('error', "Database transaction rollback failed for group: {$group}"); + break; + } + $attempts++; + } + + if ($connection->transDepth > 0) { + log_message('warning', "Database connection has uncommitted transactions after cleanup: {$group}"); } - $connection->transDepth = 0; if (! $connection->ping()) { log_message('info', "Database connection dead, reconnecting: {$group}"); From 21d8397a4aad1b5fd0c12c43e1a8e7ded101a2fa Mon Sep 17 00:00:00 2001 From: michalsn Date: Mon, 12 Jan 2026 14:35:35 +0100 Subject: [PATCH 03/18] wip --- system/Config/BaseService.php | 12 + user_guide_src/source/concepts/index.rst | 1 + .../source/concepts/worker_mode.rst | 398 ++++++++++++++++++ 3 files changed, 411 insertions(+) create mode 100644 user_guide_src/source/concepts/worker_mode.rst diff --git a/system/Config/BaseService.php b/system/Config/BaseService.php index 3e834f195556..a0a5ebbe1b8a 100644 --- a/system/Config/BaseService.php +++ b/system/Config/BaseService.php @@ -203,6 +203,18 @@ public static function get(string $key): ?object return static::$instances[$key] ?? static::__callStatic($key, []); } + /** + * Checks if a service instance has been created. + * + * @param string $key Identifier of the entry to check. + * + * @return bool True if the service instance exists, false otherwise. + */ + public static function has(string $key): bool + { + return isset(static::$instances[$key]); + } + /** * Sets an entry. * diff --git a/user_guide_src/source/concepts/index.rst b/user_guide_src/source/concepts/index.rst index 8ee5c0538979..aee2e0e26044 100644 --- a/user_guide_src/source/concepts/index.rst +++ b/user_guide_src/source/concepts/index.rst @@ -14,4 +14,5 @@ The following pages describe the architectural concepts behind CodeIgniter4: factories http security + worker_mode goals diff --git a/user_guide_src/source/concepts/worker_mode.rst b/user_guide_src/source/concepts/worker_mode.rst new file mode 100644 index 000000000000..6e07ba6a0576 --- /dev/null +++ b/user_guide_src/source/concepts/worker_mode.rst @@ -0,0 +1,398 @@ +########### +Worker Mode +########### + +.. contents:: + :local: + :depth: 1 + +.. versionadded:: 4.7.0 + +.. important:: Worker Mode is currently **experimental**. The only officially supported + worker implementation is **FrankenPHP**, which is backed by the PHP Foundation. + +************ +Introduction +************ + +What is Worker Mode? +==================== + +Worker Mode is a performance optimization feature that allows CodeIgniter to handle multiple +HTTP requests within the same PHP process, instead of starting a new process for each request +like traditional PHP-FPM does. + +Traditional PHP creates a new process for each HTTP request, bootstraps the framework from scratch, +loads all classes fresh, and terminates the process after sending the response. Worker Mode takes +a different approach: a single PHP process handles multiple requests, bootstrapping the framework +only once at startup, keeping classes and resources in memory, and persisting indefinitely. + +This approach can provide significant performance improvements, especially for applications with +substantial bootstrap overhead or database operations. + +Requirements +============ + +Worker Mode requires PHP 8.2 or higher, FrankenPHP (the only officially supported worker +implementation), and CodeIgniter 4.7.0 or higher. FrankenPHP is backed by the PHP Foundation +and provides robust worker mode capabilities. + +How It Works +============ + +Worker Mode operates through four key mechanisms: + +**One-time bootstrap** + Loading the autoloader, environment configuration, helpers, and services once when the worker starts + +**Request loop** + Handling incoming requests in a continuous loop without terminating the process + +**State management** + Resetting request-specific state between requests while keeping shared resources in memory + +**Connection persistence** + Optionally keeping database and cache connections alive across requests + +*************** +Getting Started +*************** + +Installation +============ + +1. Install FrankenPHP following the `official documentation `_ + +2. Install the Worker Mode template files using the spark command: + +.. code-block:: bash + + php spark worker:install + +This command creates two files: **Caddyfile** (FrankenPHP configuration file with worker mode enabled) +and **public/frankenphp-worker.php** (worker entry point that handles requests). + +3. Configure your worker settings in **app/Config/WorkerMode.php** if needed. The defaults are + recommended for most applications. + +Running the Worker +================== + +Start FrankenPHP using the generated Caddyfile: + +.. code-block:: bash + + frankenphp run + +The server will start with worker mode enabled, handling requests through the worker entry point. + +Uninstalling +============ + +To remove the Worker Mode template files: + +.. code-block:: bash + + php spark worker:uninstall + +This removes the **Caddyfile** and **public/frankenphp-worker.php** files. + +********************** +Performance Benchmarks +********************** + +The performance gains from Worker Mode depend heavily on your application's characteristics: + +**Simple endpoints** + Applications returning minimal JSON responses show similar performance to traditional mode, + as there's little bootstrap overhead to eliminate. + +**Database operations** + Endpoints performing database queries typically see 2-3x improvements from connection reuse + and reduced initialization overhead. + +**Worker count considerations** + Total throughput scales with worker count. Two workers handling concurrent requests will have + lower throughput than ten workers, even though per-request speed is faster. Match worker count + to your typical concurrency patterns. + +**First request latency** + The initial request to each worker is always slower due to connection establishment. Subsequent + requests benefit from cached connections. + +************* +Configuration +************* + +All worker mode configuration is managed through the ``app/Config/WorkerMode.php`` file. + +Persistent Services +=================== + +.. php:property:: $persistentServices + + :type: array + :default: ``['autoloader', 'locator', 'exceptions', 'logger', 'timer', 'commands', 'codeigniter', 'superglobals']`` + +Services that should persist across requests and not be reset. Services not in this list will +be reset for each request to prevent state leakage. + +The recommended persistent services each serve specific purposes: + +``autoloader`` + PSR-4 autoloading configuration + +``locator`` + File locator for finding framework files + +``exceptions`` + Exception handler + +``logger`` + Logger instance + +``timer`` + Performance timer + +``commands`` + CLI commands registry + +``codeigniter`` + Main application instance + +``superglobals`` + Superglobals wrapper + +.. code-block:: php + + public array $persistentServices = [ + 'autoloader', + 'locator', + 'exceptions', + 'logger', + 'timer', + 'commands', + 'codeigniter', + 'superglobals', + ]; + +Database Connection Strategy +============================= + +.. php:property:: $databaseStrategy + + :type: string + :default: ``'keep-alive'`` + +Determines how database connections are handled between requests. + +**keep-alive** (recommended) + Keeps connections alive across requests for best performance. The connection is automatically + pinged and reconnected if needed. Any uncommitted transactions are rolled back between requests. + This strategy provides the best performance for database-heavy applications. + +**disconnect** + Closes all connections after each request. Use this for low-traffic applications or when you + need a fresh connection state for each request. This strategy eliminates connection persistence + but incurs reconnection overhead. + +.. code-block:: php + + public string $databaseStrategy = 'keep-alive'; + +.. note:: Do not enable the ``pConnect`` option in **app/Config/Database.php** when using Worker Mode. + The worker's keep-alive strategy provides better connection management than PHP's persistent + connections. + +Cache Handler Strategy +======================= + +.. php:property:: $cacheStrategy + + :type: string + :default: ``'keep-alive'`` + +Determines how cache handlers are managed between requests. + +**keep-alive** (recommended) + Keeps cache handlers alive across requests for best performance. This works well with + cache backends like Redis or Memcached. + +**disconnect** + Closes the cache handler after each request. Consider this strategy if using file-based + caching to avoid file locking issues between workers. + +.. code-block:: php + + public string $cacheStrategy = 'keep-alive'; + +Reset Factories +=============== + +.. php:property:: $resetFactories + + :type: bool + :default: ``true`` + +Whether to reset Factories (Models, Config instances, etc.) between requests. + +**true** (recommended) + Ensures fresh model instances for each request, preventing state leakage between requests. + This is the safest option and recommended for most applications. + +**false** + Caches model instances across requests. This is an advanced option that requires careful + consideration of state management. Only use this if you fully understand the implications + and have verified your models don't maintain request-specific state. + +.. code-block:: php + + public bool $resetFactories = true; + +.. warning:: Setting this to ``false`` can cause state leakage between requests if your models + maintain internal state. Only disable this if you fully understand the implications. + +Garbage Collection +================== + +.. php:property:: $garbageCollection + + :type: bool + :default: ``true`` + +Whether to force garbage collection after each request. + +**true** (recommended) + Helps prevent memory leaks at a small performance cost. The garbage collector runs after + each request completes, freeing unreferenced objects. + +**false** + Relies on PHP's automatic garbage collection. This may provide slightly better performance + but could allow memory to accumulate between requests. + +.. code-block:: php + + public bool $garbageCollection = true; + +********************* +Performance Tuning +********************* + +Optimize Configuration +====================== + +The ``app/Config/Optimize.php`` configuration options (Config Caching and File Locator Caching) +should **NOT** be used with Worker Mode. + +These optimizations were designed for traditional PHP where each request starts fresh. In Worker +Mode, the persistent process already provides these benefits naturally, and enabling them can +cause issues with stale data and state management. + +.. warning:: Do not enable ``$configCacheEnabled`` or ``$locatorCacheEnabled`` in + **app/Config/Optimize.php** when using Worker Mode. The worker's persistent process already + provides superior optimization, and these options can cause stale data and state management + issues. + +OPcache Settings +================ + +Worker Mode benefits significantly from OPcache. Ensure it's enabled and properly configured: + +.. code-block:: ini + + opcache.enable=1 + opcache.memory_consumption=256 + opcache.max_accelerated_files=20000 + opcache.validate_timestamps=0 ; In production only + +Worker Count Tuning +=================== + +The number of workers significantly impacts performance and resource usage. Consider these factors +when tuning worker count: + +**CPU cores** + Start with a worker count equal to your CPU core count, or 2x cores for I/O-heavy workloads. + +**Expected concurrency** + Match worker count to your typical concurrent request patterns. If you regularly handle + 50 concurrent requests, configure at least 50 workers to avoid queuing. + +**Memory constraints** + Each worker holds the full application state in memory. Monitor total memory usage and + adjust worker count accordingly. + +**Database connections** + With keep-alive strategy, each worker maintains its own database connection. Ensure your + database can handle worker-count connections. + +************************ +Important Considerations +************************ + +State Management +================ + +Since the PHP process persists across requests, careful attention to state management is essential. +Avoid storing request-specific data in static properties or global variables, as these will leak +between requests. Be cautious with singleton patterns that might retain state. Resources like file +handles, database cursors, and similar must be properly cleaned up after each request. + +Registrars +========== + +Config registrars (``Config/Registrar.php`` files) work correctly in Worker Mode through a +two-phase approach. Registrar files are discovered once when the worker starts, and registrar +logic is applied every time a Config class is instantiated. Each request gets a fresh Config +instance with registrars applied. + +Memory Management +================= + +Monitor memory usage in production by checking worker process memory with ``ps aux | grep frankenphp``. +If memory grows continuously, verify that garbage collection is enabled (``$garbageCollection = true``), +resources are properly closed or freed after use, and large objects are unset when no longer needed. + +Debugging +========= + +Worker Mode can make debugging more challenging since the process persists across requests. During +development, consider using the ``disconnect`` strategies for cleaner state between requests. Remember +to restart the worker after code changes, as workers don't auto-reload. Check logs in **writable/logs/** +for connection-related issues. + +Session and Cache Considerations +================================= + +Sessions work normally in Worker Mode, with the worker automatically closing sessions between requests +to prevent file locking issues. + +For caching, file-based cache handlers may experience file locking contention between workers. Consider +using memory-based caching systems like Redis or Memcached in production for better performance and +concurrency handling. + +************** +Best Practices +************** + +Configuration Recommendations +============================= + +Use the recommended default ``WorkerMode`` configuration for most applications. The defaults are +optimized for a balance of performance, safety, and resource usage. Only modify settings after +profiling and understanding the implications. + +Monitoring and Maintenance +========================== + +Set up monitoring for worker process memory usage to detect leaks early. Implement graceful worker +restarts for deployments to ensure new code is loaded. Monitor database connection counts to ensure +the keep-alive strategy isn't exhausting your database connection pool. + +Development Practices +===================== + +Test your application thoroughly under Worker Mode before deploying to production. Write stateless +code that doesn't rely on static properties or global variables. Always close file handles and free +resources explicitly rather than relying on garbage collection. Consider using dependency injection +to make state management more explicit. From 5fb59eb66b6a063a2ca49a9ce985c41af9129262 Mon Sep 17 00:00:00 2001 From: michalsn Date: Mon, 12 Jan 2026 19:58:54 +0100 Subject: [PATCH 04/18] wip --- system/Cache/CacheFactory.php | 3 - system/Cache/Handlers/BaseHandler.php | 22 +++ system/Cache/Handlers/MemcachedHandler.php | 45 ++++-- system/Cache/Handlers/PredisHandler.php | 25 +++- system/Cache/Handlers/RedisHandler.php | 21 ++- system/Cache/ReconnectCacheDecorator.php | 141 ------------------ system/Config/BaseService.php | 7 +- .../Cache/Handlers/MemcachedHandlerTest.php | 15 ++ .../Cache/Handlers/PredisHandlerTest.php | 15 ++ .../Cache/Handlers/RedisHandlerTest.php | 15 ++ 10 files changed, 138 insertions(+), 171 deletions(-) delete mode 100644 system/Cache/ReconnectCacheDecorator.php diff --git a/system/Cache/CacheFactory.php b/system/Cache/CacheFactory.php index 83e182d13279..e84254142125 100644 --- a/system/Cache/CacheFactory.php +++ b/system/Cache/CacheFactory.php @@ -86,9 +86,6 @@ public static function getHandler(Cache $config, ?string $handler = null, ?strin $adapter = self::getHandler($config, $backup, 'dummy'); } - // Wrap with reconnect decorator for automatic reconnection on failure - $adapter = new ReconnectCacheDecorator($adapter); - return $adapter; } } diff --git a/system/Cache/Handlers/BaseHandler.php b/system/Cache/Handlers/BaseHandler.php index 4f7022d3c249..7cf048c6a92b 100644 --- a/system/Cache/Handlers/BaseHandler.php +++ b/system/Cache/Handlers/BaseHandler.php @@ -87,4 +87,26 @@ public function remember(string $key, int $ttl, Closure $callback): mixed return $value; } + + /** + * Check if connection is alive. + * + * Default implementation for handlers that don't require connection management. + * Handlers with persistent connections (Redis, Predis, Memcached) should override this. + */ + public function ping(): bool + { + return true; + } + + /** + * Reconnect to the cache server. + * + * Default implementation for handlers that don't require connection management. + * Handlers with persistent connections (Redis, Predis, Memcached) should override this. + */ + public function reconnect(): bool + { + return true; + } } diff --git a/system/Cache/Handlers/MemcachedHandler.php b/system/Cache/Handlers/MemcachedHandler.php index 0d3c7faf86fc..99c9c21ade04 100644 --- a/system/Cache/Handlers/MemcachedHandler.php +++ b/system/Cache/Handlers/MemcachedHandler.php @@ -219,23 +219,40 @@ public function isSupported(): bool return extension_loaded('memcached') || extension_loaded('memcache'); } - /** - * Reconnect to Memcached server - * - * Used by ReconnectCacheDecorator to restore connection after failure. - */ + public function ping(): bool + { + if (! isset($this->memcached)) { + return false; + } + + $version = $this->memcached->getVersion(); + + if ($this->memcached instanceof Memcached) { + // Memcached extension returns array with server:port => version + if (! is_array($version)) { + return false; + } + + $serverKey = $this->config['host'] . ':' . $this->config['port']; + + return isset($version[$serverKey]) && $version[$serverKey] !== false; + } + + if ($this->memcached instanceof Memcache) { + // Memcache extension returns string version + return is_string($version) && $version !== false && $version !== ''; + } + + return false; + } + public function reconnect(): bool { - // Close existing connection to avoid resource leak if (isset($this->memcached)) { - try { - if ($this->memcached instanceof Memcached) { - $this->memcached->quit(); - } elseif ($this->memcached instanceof Memcache) { - $this->memcached->close(); - } - } catch (Exception $e) { - // Connection already dead, that's fine + if ($this->memcached instanceof Memcached) { + $this->memcached->quit(); + } elseif ($this->memcached instanceof Memcache) { + $this->memcached->close(); } } diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index a43c86827c8a..677200b8bcb3 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -205,14 +205,27 @@ public function isSupported(): bool return class_exists(Client::class); } - /** - * Reconnect to Redis server via Predis - * - * Used by ReconnectCacheDecorator to restore connection after failure. - */ + public function ping(): bool + { + if (! isset($this->redis)) { + return false; + } + + try { + $result = $this->redis->ping(); + + if (is_object($result)) { + return $result->getPayload() === 'PONG'; + } + + return $result === 'PONG'; + } catch (Exception $e) { + return false; + } + } + public function reconnect(): bool { - // Disconnect existing connection to avoid resource leak if (isset($this->redis)) { try { $this->redis->disconnect(); diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index e27c6fc154d6..452bfc1f577b 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -220,14 +220,23 @@ public function isSupported(): bool return extension_loaded('redis'); } - /** - * Reconnect to Redis server - * - * Used by ReconnectCacheDecorator to restore connection after failure. - */ + public function ping(): bool + { + if (! isset($this->redis)) { + return false; + } + + try { + $result = $this->redis->ping(); + + return $result === true || $result === '+PONG' || $result === 'PONG'; + } catch (RedisException $e) { + return false; + } + } + public function reconnect(): bool { - // Close existing connection to avoid resource leak if (isset($this->redis)) { try { $this->redis->close(); diff --git a/system/Cache/ReconnectCacheDecorator.php b/system/Cache/ReconnectCacheDecorator.php deleted file mode 100644 index 9177cdc56b41..000000000000 --- a/system/Cache/ReconnectCacheDecorator.php +++ /dev/null @@ -1,141 +0,0 @@ - - * - * For the full copyright and license information, please view - * the LICENSE file that was distributed with this source code. - */ - -namespace CodeIgniter\Cache; - -use Exception; - -/** - * Reconnect Cache Decorator - * - * Wraps cache handlers to provide automatic reconnection on failure. - * Used in worker mode to prevent connection failures from reaching users. - */ -class ReconnectCacheDecorator implements CacheInterface -{ - /** - * Methods that should attempt reconnection on failure - * - * @var list - */ - private array $retryableMethods = [ - 'get', - 'save', - 'delete', - 'deleteMatching', - 'increment', - 'decrement', - 'clean', - 'getCacheInfo', - 'getMetaData', - ]; - - public function __construct( - private CacheInterface $handler - ) {} - - /** - * Get the underlying handler for direct access - */ - public function getHandler(): CacheInterface - { - return $this->handler; - } - - public function initialize(): void - { - $this->handler->initialize(); - } - - public function get(string $key): mixed - { - return $this->execute('get', [$key]); - } - - public function save(string $key, mixed $value, int $ttl = 60): bool - { - return $this->execute('save', [$key, $value, $ttl]); - } - - public function delete(string $key): bool - { - return $this->execute('delete', [$key]); - } - - public function deleteMatching(string $pattern): int - { - return $this->execute('deleteMatching', [$pattern]); - } - - public function increment(string $key, int $offset = 1): bool|int - { - return $this->execute('increment', [$key, $offset]); - } - - public function decrement(string $key, int $offset = 1): bool|int - { - return $this->execute('decrement', [$key, $offset]); - } - - public function clean(): bool - { - return $this->execute('clean', []); - } - - public function getCacheInfo(): array|false|object|null - { - return $this->execute('getCacheInfo', []); - } - - public function getMetaData(string $key): ?array - { - return $this->execute('getMetaData', [$key]); - } - - public function isSupported(): bool - { - return $this->handler->isSupported(); - } - - /** - * Execute cache operation with automatic reconnection on failure - * - * @param string $method Method name - * @param array $arguments Method arguments - * - * @return mixed - * @throws Exception - */ - private function execute(string $method, array $arguments): mixed - { - if (! in_array($method, $this->retryableMethods, true)) { - return $this->handler->{$method}(...$arguments); - } - - try { - return $this->handler->{$method}(...$arguments); - } catch (Exception $e) { - // If handler doesn't support reconnection, rethrow immediately - if (! method_exists($this->handler, 'reconnect')) { - throw $e; - } - - if (! $this->handler->reconnect()) { - throw $e; - } - - // Retry once - if it fails again, let the exception propagate - return $this->handler->{$method}(...$arguments); - } - } -} diff --git a/system/Config/BaseService.php b/system/Config/BaseService.php index a0a5ebbe1b8a..856346828cb6 100644 --- a/system/Config/BaseService.php +++ b/system/Config/BaseService.php @@ -394,8 +394,13 @@ public static function resetForWorkerMode(WorkerMode $config): void foreach (static::$instances as $serviceName => $service) { if ($serviceName === 'cache') { - // Only persist cache in keep-alive mode if ($config->cacheStrategy === 'keep-alive') { + // Ping the connection and reconnect if needed + if (! $service->ping()) { + $service->reconnect(); + } + + // Persist cache instance in keep-alive mode $persistentInstances[$serviceName] = $service; } diff --git a/tests/system/Cache/Handlers/MemcachedHandlerTest.php b/tests/system/Cache/Handlers/MemcachedHandlerTest.php index 06ae6112a432..e6bd5dd147ec 100644 --- a/tests/system/Cache/Handlers/MemcachedHandlerTest.php +++ b/tests/system/Cache/Handlers/MemcachedHandlerTest.php @@ -188,4 +188,19 @@ public function testGetMetaDataMiss(): void { $this->assertNull($this->handler->getMetaData(self::$dummy)); } + + public function testPing(): void + { + $this->assertTrue($this->handler->ping()); + } + + public function testReconnect(): void + { + $this->handler->save(self::$key1, 'value'); + $this->assertSame('value', $this->handler->get(self::$key1)); + + $this->assertTrue($this->handler->reconnect()); + + $this->assertSame('value', $this->handler->get(self::$key1)); + } } diff --git a/tests/system/Cache/Handlers/PredisHandlerTest.php b/tests/system/Cache/Handlers/PredisHandlerTest.php index 96857fc6a9f2..6aa93b73cd06 100644 --- a/tests/system/Cache/Handlers/PredisHandlerTest.php +++ b/tests/system/Cache/Handlers/PredisHandlerTest.php @@ -184,4 +184,19 @@ public function testIsSupported(): void { $this->assertTrue($this->handler->isSupported()); } + + public function testPing(): void + { + $this->assertTrue($this->handler->ping()); + } + + public function testReconnect(): void + { + $this->handler->save(self::$key1, 'value'); + $this->assertSame('value', $this->handler->get(self::$key1)); + + $this->assertTrue($this->handler->reconnect()); + + $this->assertSame('value', $this->handler->get(self::$key1)); + } } diff --git a/tests/system/Cache/Handlers/RedisHandlerTest.php b/tests/system/Cache/Handlers/RedisHandlerTest.php index bbdb3afc7cd1..d42123c6dd82 100644 --- a/tests/system/Cache/Handlers/RedisHandlerTest.php +++ b/tests/system/Cache/Handlers/RedisHandlerTest.php @@ -233,4 +233,19 @@ public function testIsSupported(): void { $this->assertTrue($this->handler->isSupported()); } + + public function testPing(): void + { + $this->assertTrue($this->handler->ping()); + } + + public function testReconnect(): void + { + $this->handler->save(self::$key1, 'value'); + $this->assertSame('value', $this->handler->get(self::$key1)); + + $this->assertTrue($this->handler->reconnect()); + + $this->assertSame('value', $this->handler->get(self::$key1)); + } } From a106259898e0e1eb4a51522e0e9cc2e4950b2d09 Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 13 Jan 2026 08:24:28 +0100 Subject: [PATCH 05/18] wip --- .../system/Cache/Handlers/FileHandlerTest.php | 10 +++ tests/system/CodeIgniterTest.php | 23 +++++++ tests/system/Config/ServicesTest.php | 61 +++++++++++++++++++ tests/system/Database/ConfigTest.php | 33 ++++++++++ tests/system/Events/EventsTest.php | 14 +++++ 5 files changed, 141 insertions(+) diff --git a/tests/system/Cache/Handlers/FileHandlerTest.php b/tests/system/Cache/Handlers/FileHandlerTest.php index 6a709d60caf8..16c6042be124 100644 --- a/tests/system/Cache/Handlers/FileHandlerTest.php +++ b/tests/system/Cache/Handlers/FileHandlerTest.php @@ -388,6 +388,16 @@ public function testGetItemWithCorruptedData(): void $this->assertNull($this->handler->get(self::$key1)); } + + public function testPing(): void + { + $this->assertTrue($this->handler->ping()); + } + + public function testReconnect(): void + { + $this->assertTrue($this->handler->reconnect()); + } } /** diff --git a/tests/system/CodeIgniterTest.php b/tests/system/CodeIgniterTest.php index bab5830e6685..e4bc8a9e2cd5 100644 --- a/tests/system/CodeIgniterTest.php +++ b/tests/system/CodeIgniterTest.php @@ -1270,4 +1270,27 @@ public function testRouteAttributesDisabledInConfig(): void // But the controller method should still execute $this->assertStringContainsString('Filtered', (string) $output); } + + public function testResetForWorkerMode(): void + { + $config = new App(); + $codeigniter = new MockCodeIgniter($config); + + $this->setPrivateProperty($codeigniter, 'request', service('request')); + $this->setPrivateProperty($codeigniter, 'response', service('response')); + $this->setPrivateProperty($codeigniter, 'output', 'test output'); + + $this->assertNotNull($this->getPrivateProperty($codeigniter, 'request')); + $this->assertNotNull($this->getPrivateProperty($codeigniter, 'response')); + $this->assertNotNull($this->getPrivateProperty($codeigniter, 'output')); + + $codeigniter->resetForWorkerMode(); + + $this->assertNull($this->getPrivateProperty($codeigniter, 'request')); + $this->assertNull($this->getPrivateProperty($codeigniter, 'response')); + $this->assertNull($this->getPrivateProperty($codeigniter, 'router')); + $this->assertNull($this->getPrivateProperty($codeigniter, 'controller')); + $this->assertNull($this->getPrivateProperty($codeigniter, 'method')); + $this->assertNull($this->getPrivateProperty($codeigniter, 'output')); + } } diff --git a/tests/system/Config/ServicesTest.php b/tests/system/Config/ServicesTest.php index 37b50890c400..9f1ccb816757 100644 --- a/tests/system/Config/ServicesTest.php +++ b/tests/system/Config/ServicesTest.php @@ -51,6 +51,7 @@ use Config\Exceptions; use Config\Security as SecurityConfig; use Config\Session as ConfigSession; +use Config\WorkerMode; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -500,4 +501,64 @@ public function testServiceInstance(): void $this->assertInstanceOf(\Config\Services::class, new \Config\Services()); rename(COMPOSER_PATH . '.backup', COMPOSER_PATH); } + + public function testHas(): void + { + $this->assertFalse(Services::has('timer')); + + Services::timer(); + + $this->assertTrue(Services::has('timer')); + } + + public function testHasReturnsFalseForNonExistentService(): void + { + $this->assertFalse(Services::has('nonexistent')); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testResetForWorkerModeWithKeepAliveStrategy(): void + { + $config = new WorkerMode(); + $config->cacheStrategy = 'keep-alive'; + $config->persistentServices = ['timer']; + + Services::cache(); + Services::timer(); + Services::typography(); + + $this->assertTrue(Services::has('cache')); + $this->assertTrue(Services::has('timer')); + $this->assertTrue(Services::has('typography')); + + Services::resetForWorkerMode($config); + + $this->assertTrue(Services::has('cache')); + $this->assertTrue(Services::has('timer')); + $this->assertFalse(Services::has('typography')); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testResetForWorkerModeWithReconnectStrategy(): void + { + $config = new WorkerMode(); + $config->cacheStrategy = 'reconnect'; + $config->persistentServices = ['timer']; + + Services::cache(); + Services::timer(); + Services::typography(); + + $this->assertTrue(Services::has('cache')); + $this->assertTrue(Services::has('timer')); + $this->assertTrue(Services::has('typography')); + + Services::resetForWorkerMode($config); + + $this->assertFalse(Services::has('cache')); + $this->assertTrue(Services::has('timer')); + $this->assertFalse(Services::has('typography')); + } } diff --git a/tests/system/Database/ConfigTest.php b/tests/system/Database/ConfigTest.php index 1d2482e0ad87..ec85e34534db 100644 --- a/tests/system/Database/ConfigTest.php +++ b/tests/system/Database/ConfigTest.php @@ -16,6 +16,7 @@ use CodeIgniter\Database\Postgre\Connection as PostgreConnection; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\ReflectionHelper; +use Config\WorkerMode; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -239,4 +240,36 @@ public static function provideConvertDSN(): iterable ], ]; } + + #[Group('DatabaseLive')] + public function testResetForWorkerModeWithDisconnectStrategy(): void + { + $config = new WorkerMode(); + $config->databaseStrategy = 'disconnect'; + + $conn = Config::connect(); + $this->assertInstanceOf(BaseConnection::class, $conn); + + Config::resetForWorkerMode($config); + + $this->assertFalse($this->getPrivateProperty($conn, 'connID')); + } + + #[Group('DatabaseLive')] + public function testResetForWorkerModeWithKeepAliveStrategy(): void + { + $config = new WorkerMode(); + $config->databaseStrategy = 'keep-alive'; + + $conn = Config::connect(); + $this->assertInstanceOf(BaseConnection::class, $conn); + + $conn->transStart(); + $this->assertGreaterThan(0, $conn->transDepth); + + Config::resetForWorkerMode($config); + + $this->assertSame(0, $conn->transDepth); + $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); + } } diff --git a/tests/system/Events/EventsTest.php b/tests/system/Events/EventsTest.php index dc22ec3cd92b..9ed1d88006f4 100644 --- a/tests/system/Events/EventsTest.php +++ b/tests/system/Events/EventsTest.php @@ -332,4 +332,18 @@ private function getEditableObject(): stdClass return clone $user; } + + public function testResetForWorkerMode(): void + { + Events::on('test', static fn () => null); + Events::trigger('test'); + + $performanceLog = Events::getPerformanceLogs(); + $this->assertNotEmpty($performanceLog); + + Events::resetForWorkerMode(); + + $performanceLog = Events::getPerformanceLogs(); + $this->assertEmpty($performanceLog); + } } From cff3785da043ad8beb501dc7ad0dd238bbd80d11 Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 13 Jan 2026 08:36:20 +0100 Subject: [PATCH 06/18] wip --- app/Config/WorkerMode.php | 4 ++-- system/CodeIgniter.php | 2 -- system/Commands/Worker/WorkerInstall.php | 18 +++++++++++++----- system/Commands/Worker/WorkerUninstall.php | 17 ++++++++++++++--- system/Config/BaseService.php | 3 --- system/Database/BaseConnection.php | 5 ----- system/Database/Config.php | 4 ---- system/Database/Postgre/Connection.php | 3 --- system/Events/Events.php | 3 --- tests/system/Config/ServicesTest.php | 8 ++++---- tests/system/Database/ConfigTest.php | 4 ++-- 11 files changed, 35 insertions(+), 36 deletions(-) diff --git a/app/Config/WorkerMode.php b/app/Config/WorkerMode.php index f298249d64e8..cf25871df6f9 100644 --- a/app/Config/WorkerMode.php +++ b/app/Config/WorkerMode.php @@ -50,7 +50,7 @@ class WorkerMode * - 'disconnect': * Closes all connections after each request * - * @var 'keep-alive'|'disconnect' + * @var 'disconnect'|'keep-alive' */ public string $databaseStrategy = 'keep-alive'; @@ -64,7 +64,7 @@ class WorkerMode * - 'disconnect': * Closes the handler after each request * - * @var 'keep-alive'|'disconnect' + * @var 'disconnect'|'keep-alive' */ public string $cacheStrategy = 'keep-alive'; diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index b1deea7ddd61..bdbb326ae775 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -195,8 +195,6 @@ public function initialize() /** * Reset request-specific state for worker mode. * Clears all request/response data to prepare for the next request. - * - * @return void */ public function resetForWorkerMode(): void { diff --git a/system/Commands/Worker/WorkerInstall.php b/system/Commands/Worker/WorkerInstall.php index 8610713115ff..432b8ed73d6e 100644 --- a/system/Commands/Worker/WorkerInstall.php +++ b/system/Commands/Worker/WorkerInstall.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Commands\Worker; use CodeIgniter\CLI\BaseCommand; @@ -16,9 +25,8 @@ class WorkerInstall extends BaseCommand protected $group = 'Worker Mode'; protected $name = 'worker:install'; protected $description = 'Install FrankenPHP worker mode by creating necessary configuration files'; - - protected $usage = 'worker:install [options]'; - protected $options = [ + protected $usage = 'worker:install [options]'; + protected $options = [ '--force' => 'Overwrite existing files', ]; @@ -61,7 +69,7 @@ public function run(array $params) CLI::error( "Failed to read template: {$template}", 'light_gray', - 'red' + 'red', ); CLI::newLine(); @@ -73,7 +81,7 @@ public function run(array $params) CLI::error( 'Failed to create file: ' . clean_path($target), 'light_gray', - 'red' + 'red', ); CLI::newLine(); diff --git a/system/Commands/Worker/WorkerUninstall.php b/system/Commands/Worker/WorkerUninstall.php index bd33d4ba36dd..5680657f09a5 100644 --- a/system/Commands/Worker/WorkerUninstall.php +++ b/system/Commands/Worker/WorkerUninstall.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\Commands\Worker; use CodeIgniter\CLI\BaseCommand; @@ -15,9 +24,8 @@ class WorkerUninstall extends BaseCommand protected $group = 'Worker Mode'; protected $name = 'worker:uninstall'; protected $description = 'Remove FrankenPHP worker mode configuration files'; - - protected $usage = 'worker:uninstall [options]'; - protected $options = [ + protected $usage = 'worker:uninstall [options]'; + protected $options = [ '--force' => 'Skip confirmation prompt', ]; @@ -40,6 +48,7 @@ public function run(array $params) // Find existing files $existing = []; + foreach ($this->files as $file) { $path = ROOTPATH . $file; if (is_file($path)) { @@ -57,6 +66,7 @@ public function run(array $params) // Show files that will be removed CLI::write('The following files will be removed:', 'yellow'); + foreach ($existing as $file) { CLI::write(' - ' . $file, 'white'); } @@ -83,6 +93,7 @@ public function run(array $params) if (! @unlink($path)) { CLI::error('Failed to remove file: ' . clean_path($path), 'light_gray', 'red'); + continue; } diff --git a/system/Config/BaseService.php b/system/Config/BaseService.php index 856346828cb6..aada9722c290 100644 --- a/system/Config/BaseService.php +++ b/system/Config/BaseService.php @@ -377,9 +377,6 @@ public static function reset(bool $initAutoloader = true) /** * Resets all services except those in the persistent list. * Used for worker mode to preserve expensive-to-initialize services. - * - * @param WorkerMode $config - * @return void */ public static function resetForWorkerMode(WorkerMode $config): void { diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index 7b35b49f7b17..2707c4d49bfe 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -495,8 +495,6 @@ abstract protected function _close(); /** * Check if the connection is still alive. - * - * @return bool */ public function ping(): bool { @@ -509,8 +507,6 @@ public function ping(): bool /** * Driver-specific ping implementation. - * - * @return bool */ protected function _ping(): bool { @@ -518,7 +514,6 @@ protected function _ping(): bool $result = $this->simpleQuery('SELECT 1'); return $result !== false; - } catch (DatabaseException) { return false; } diff --git a/system/Database/Config.php b/system/Database/Config.php index 86383f25566b..ac5789717fed 100644 --- a/system/Database/Config.php +++ b/system/Database/Config.php @@ -160,9 +160,6 @@ protected static function ensureFactory() * * This method handles connection state management between requests * in long-running worker processes. - * - * @param WorkerMode $config - * @return void */ public static function resetForWorkerMode(WorkerMode $config): void { @@ -202,5 +199,4 @@ public static function resetForWorkerMode(WorkerMode $config): void static::$factory = null; } } - } diff --git a/system/Database/Postgre/Connection.php b/system/Database/Postgre/Connection.php index 20f88c275c28..4dc9127ac311 100644 --- a/system/Database/Postgre/Connection.php +++ b/system/Database/Postgre/Connection.php @@ -168,15 +168,12 @@ protected function _close() /** * Ping the database connection. - * - * @return bool */ protected function _ping(): bool { return pg_ping($this->connID); } - /** * Select a specific database table to use. */ diff --git a/system/Events/Events.php b/system/Events/Events.php index dfd2f286f13b..2a8029ae3dd7 100644 --- a/system/Events/Events.php +++ b/system/Events/Events.php @@ -293,12 +293,9 @@ public static function getPerformanceLogs() * * Note: This clears dynamically registered listeners. If your application * registers listeners at runtime, they will need to be re-registered for each request. - * - * @return void */ public static function resetForWorkerMode(): void { static::$performanceLog = []; } - } diff --git a/tests/system/Config/ServicesTest.php b/tests/system/Config/ServicesTest.php index 9f1ccb816757..3fed374675e1 100644 --- a/tests/system/Config/ServicesTest.php +++ b/tests/system/Config/ServicesTest.php @@ -520,8 +520,8 @@ public function testHasReturnsFalseForNonExistentService(): void #[RunInSeparateProcess] public function testResetForWorkerModeWithKeepAliveStrategy(): void { - $config = new WorkerMode(); - $config->cacheStrategy = 'keep-alive'; + $config = new WorkerMode(); + $config->cacheStrategy = 'keep-alive'; $config->persistentServices = ['timer']; Services::cache(); @@ -543,8 +543,8 @@ public function testResetForWorkerModeWithKeepAliveStrategy(): void #[RunInSeparateProcess] public function testResetForWorkerModeWithReconnectStrategy(): void { - $config = new WorkerMode(); - $config->cacheStrategy = 'reconnect'; + $config = new WorkerMode(); + $config->cacheStrategy = 'reconnect'; $config->persistentServices = ['timer']; Services::cache(); diff --git a/tests/system/Database/ConfigTest.php b/tests/system/Database/ConfigTest.php index ec85e34534db..05b35501a9a7 100644 --- a/tests/system/Database/ConfigTest.php +++ b/tests/system/Database/ConfigTest.php @@ -244,7 +244,7 @@ public static function provideConvertDSN(): iterable #[Group('DatabaseLive')] public function testResetForWorkerModeWithDisconnectStrategy(): void { - $config = new WorkerMode(); + $config = new WorkerMode(); $config->databaseStrategy = 'disconnect'; $conn = Config::connect(); @@ -258,7 +258,7 @@ public function testResetForWorkerModeWithDisconnectStrategy(): void #[Group('DatabaseLive')] public function testResetForWorkerModeWithKeepAliveStrategy(): void { - $config = new WorkerMode(); + $config = new WorkerMode(); $config->databaseStrategy = 'keep-alive'; $conn = Config::connect(); From 245d03f84007a140ac40b8d398dc3c7715d0c6c7 Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 13 Jan 2026 18:01:36 +0100 Subject: [PATCH 07/18] wip --- system/Cache/Handlers/MemcachedHandler.php | 16 ++----- system/Cache/Handlers/PredisHandler.php | 16 ++----- system/Cache/Handlers/RedisHandler.php | 6 +-- system/CodeIgniter.php | 8 ++-- system/Commands/Worker/Views/Caddyfile.tpl | 39 +--------------- .../Worker/Views/frankenphp-worker.php.tpl | 3 ++ system/Commands/Worker/WorkerInstall.php | 3 +- system/Commands/Worker/WorkerUninstall.php | 5 +- system/Config/BaseService.php | 27 +++++++++-- system/Database/Config.php | 28 ++++++++--- tests/system/Config/ServicesTest.php | 46 ++++++++++++++++++- tests/system/Database/ConfigTest.php | 29 ++++++++++++ tests/system/Events/EventsTest.php | 2 +- 13 files changed, 145 insertions(+), 83 deletions(-) diff --git a/system/Cache/Handlers/MemcachedHandler.php b/system/Cache/Handlers/MemcachedHandler.php index 99c9c21ade04..10e2ecae0a96 100644 --- a/system/Cache/Handlers/MemcachedHandler.php +++ b/system/Cache/Handlers/MemcachedHandler.php @@ -221,10 +221,6 @@ public function isSupported(): bool public function ping(): bool { - if (! isset($this->memcached)) { - return false; - } - $version = $this->memcached->getVersion(); if ($this->memcached instanceof Memcached) { @@ -240,7 +236,7 @@ public function ping(): bool if ($this->memcached instanceof Memcache) { // Memcache extension returns string version - return is_string($version) && $version !== false && $version !== ''; + return is_string($version) && $version !== ''; } return false; @@ -248,12 +244,10 @@ public function ping(): bool public function reconnect(): bool { - if (isset($this->memcached)) { - if ($this->memcached instanceof Memcached) { - $this->memcached->quit(); - } elseif ($this->memcached instanceof Memcache) { - $this->memcached->close(); - } + if ($this->memcached instanceof Memcached) { + $this->memcached->quit(); + } elseif ($this->memcached instanceof Memcache) { + $this->memcached->close(); } try { diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index 677200b8bcb3..fff4bbe79596 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -207,10 +207,6 @@ public function isSupported(): bool public function ping(): bool { - if (! isset($this->redis)) { - return false; - } - try { $result = $this->redis->ping(); @@ -219,19 +215,17 @@ public function ping(): bool } return $result === 'PONG'; - } catch (Exception $e) { + } catch (Exception) { return false; } } public function reconnect(): bool { - if (isset($this->redis)) { - try { - $this->redis->disconnect(); - } catch (Exception $e) { - // Connection already dead, that's fine - } + try { + $this->redis->disconnect(); + } catch (Exception $e) { + // Connection already dead, that's fine } try { diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index 452bfc1f577b..0660ea196658 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -229,8 +229,8 @@ public function ping(): bool try { $result = $this->redis->ping(); - return $result === true || $result === '+PONG' || $result === 'PONG'; - } catch (RedisException $e) { + return in_array($result, [true, '+PONG', 'PONG'], true); + } catch (RedisException) { return false; } } @@ -240,7 +240,7 @@ public function reconnect(): bool if (isset($this->redis)) { try { $this->redis->close(); - } catch (RedisException $e) { + } catch (RedisException) { // Connection already dead, that's fine } } diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index bdbb326ae775..50c785640ba7 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -95,14 +95,14 @@ class CodeIgniter /** * Current response. * - * @var ResponseInterface + * @var ResponseInterface|null */ protected $response; /** * Router to use. * - * @var Router + * @var Router|null */ protected $router; @@ -116,14 +116,14 @@ class CodeIgniter /** * Controller method to invoke. * - * @var string + * @var string|null */ protected $method; /** * Output handler to use. * - * @var string + * @var string|null */ protected $output; diff --git a/system/Commands/Worker/Views/Caddyfile.tpl b/system/Commands/Worker/Views/Caddyfile.tpl index 02aa1e7b853e..89e741870b9c 100644 --- a/system/Commands/Worker/Views/Caddyfile.tpl +++ b/system/Commands/Worker/Views/Caddyfile.tpl @@ -15,7 +15,7 @@ # Number of workers (default: 2x CPU cores) # Adjust based on your server capacity - num 2 + num 16 } } @@ -31,18 +31,6 @@ # Enable compression encode zstd br gzip - # Security headers - header { - # Prevent clickjacking - X-Frame-Options "SAMEORIGIN" - # Prevent MIME type sniffing - X-Content-Type-Options "nosniff" - # Enable XSS protection - X-XSS-Protection "1; mode=block" - # Remove server header - -Server - } - # Route all PHP requests through the worker php_server { # Use frankenphp-worker.php as the entry point @@ -54,28 +42,3 @@ # Serve static files file_server } - -# HTTPS configuration (uncomment and configure for production) -# :443 { -# # TLS certificate paths -# tls /path/to/cert.pem /path/to/key.pem -# -# root * public -# encode zstd br gzip -# -# header { -# X-Frame-Options "SAMEORIGIN" -# X-Content-Type-Options "nosniff" -# X-XSS-Protection "1; mode=block" -# -Server -# # Enable HSTS for HTTPS -# Strict-Transport-Security "max-age=31536000; includeSubDomains" -# } -# -# php_server { -# index frankenphp-worker.php -# try_files {path} frankenphp-worker.php -# } -# -# file_server -# } diff --git a/system/Commands/Worker/Views/frankenphp-worker.php.tpl b/system/Commands/Worker/Views/frankenphp-worker.php.tpl index fdd34071c710..2d9af7a171c2 100644 --- a/system/Commands/Worker/Views/frankenphp-worker.php.tpl +++ b/system/Commands/Worker/Views/frankenphp-worker.php.tpl @@ -69,6 +69,9 @@ $workerConfig = config('WorkerMode'); */ $handler = function () use ($app, $workerConfig) { + // Validate database connections + DatabaseConfig::validateForWorkerMode($workerConfig); + // Reset request-specific state $app->resetForWorkerMode(); diff --git a/system/Commands/Worker/WorkerInstall.php b/system/Commands/Worker/WorkerInstall.php index 432b8ed73d6e..0a3cb560c2fe 100644 --- a/system/Commands/Worker/WorkerInstall.php +++ b/system/Commands/Worker/WorkerInstall.php @@ -1,5 +1,7 @@ cacheStrategy !== 'keep-alive' || ! isset(static::$instances['cache'])) { + return; + } + + $cache = static::$instances['cache']; + + if (! $cache->ping()) { + $cache->reconnect(); + } + } + /** * Resets all services except those in the persistent list. * Used for worker mode to preserve expensive-to-initialize services. + * + * Called at the END of each request to clean up state. */ public static function resetForWorkerMode(WorkerMode $config): void { @@ -392,11 +414,6 @@ public static function resetForWorkerMode(WorkerMode $config): void foreach (static::$instances as $serviceName => $service) { if ($serviceName === 'cache') { if ($config->cacheStrategy === 'keep-alive') { - // Ping the connection and reconnect if needed - if (! $service->ping()) { - $service->reconnect(); - } - // Persist cache instance in keep-alive mode $persistentInstances[$serviceName] = $service; } diff --git a/system/Database/Config.php b/system/Database/Config.php index ac5789717fed..e006b545c716 100644 --- a/system/Database/Config.php +++ b/system/Database/Config.php @@ -154,12 +154,33 @@ protected static function ensureFactory() static::$factory = new Database(); } + /** + * Validate database connections for worker mode at the start of a request. + * Checks if connections are alive and reconnects if needed. + * + * This should be called at the beginning of each request in worker mode, + * before the application runs. + */ + public static function validateForWorkerMode(WorkerMode $config): void + { + if ($config->databaseStrategy !== 'keep-alive') { + return; + } + + foreach (static::$instances as $connection) { + if (! $connection->ping()) { + $connection->reconnect(); + } + } + } + /** * Reset database connections for worker mode. - * Behavior controlled by WorkerMode config. * * This method handles connection state management between requests * in long-running worker processes. + * + * Called at the END of each request to clean up state. */ public static function resetForWorkerMode(WorkerMode $config): void { @@ -184,11 +205,6 @@ public static function resetForWorkerMode(WorkerMode $config): void if ($connection->transDepth > 0) { log_message('warning', "Database connection has uncommitted transactions after cleanup: {$group}"); } - - if (! $connection->ping()) { - log_message('info', "Database connection dead, reconnecting: {$group}"); - $connection->reconnect(); - } break; } } diff --git a/tests/system/Config/ServicesTest.php b/tests/system/Config/ServicesTest.php index 3fed374675e1..dad155166244 100644 --- a/tests/system/Config/ServicesTest.php +++ b/tests/system/Config/ServicesTest.php @@ -544,7 +544,7 @@ public function testResetForWorkerModeWithKeepAliveStrategy(): void public function testResetForWorkerModeWithReconnectStrategy(): void { $config = new WorkerMode(); - $config->cacheStrategy = 'reconnect'; + $config->cacheStrategy = 'disconnect'; $config->persistentServices = ['timer']; Services::cache(); @@ -561,4 +561,48 @@ public function testResetForWorkerModeWithReconnectStrategy(): void $this->assertTrue(Services::has('timer')); $this->assertFalse(Services::has('typography')); } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testValidateForWorkerModeWithKeepAliveStrategy(): void + { + $config = new WorkerMode(); + $config->cacheStrategy = 'keep-alive'; + + Services::cache(); + $this->assertTrue(Services::has('cache')); + + Services::validateForWorkerMode($config); + + $this->assertTrue(Services::has('cache')); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testValidateForWorkerModeWithDisconnectStrategy(): void + { + $config = new WorkerMode(); + $config->cacheStrategy = 'disconnect'; + + Services::cache(); + $this->assertTrue(Services::has('cache')); + + Services::validateForWorkerMode($config); + + $this->assertTrue(Services::has('cache')); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testValidateForWorkerModeWithNoCacheService(): void + { + $config = new WorkerMode(); + $config->cacheStrategy = 'keep-alive'; + + $this->assertFalse(Services::has('cache')); + + Services::validateForWorkerMode($config); + + $this->assertFalse(Services::has('cache')); + } } diff --git a/tests/system/Database/ConfigTest.php b/tests/system/Database/ConfigTest.php index 05b35501a9a7..a3e651e05928 100644 --- a/tests/system/Database/ConfigTest.php +++ b/tests/system/Database/ConfigTest.php @@ -272,4 +272,33 @@ public function testResetForWorkerModeWithKeepAliveStrategy(): void $this->assertSame(0, $conn->transDepth); $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); } + + #[Group('DatabaseLive')] + public function testValidateForWorkerModeWithKeepAliveStrategy(): void + { + $config = new WorkerMode(); + $config->databaseStrategy = 'keep-alive'; + + $conn = Config::connect(); + $this->assertInstanceOf(BaseConnection::class, $conn); + $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); + + Config::validateForWorkerMode($config); + + $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); + } + + #[Group('DatabaseLive')] + public function testValidateForWorkerModeWithDisconnectStrategy(): void + { + $config = new WorkerMode(); + $config->databaseStrategy = 'disconnect'; + + $conn = Config::connect(); + $this->assertInstanceOf(BaseConnection::class, $conn); + + Config::validateForWorkerMode($config); + + $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); + } } diff --git a/tests/system/Events/EventsTest.php b/tests/system/Events/EventsTest.php index 9ed1d88006f4..783a24f877f9 100644 --- a/tests/system/Events/EventsTest.php +++ b/tests/system/Events/EventsTest.php @@ -335,7 +335,7 @@ private function getEditableObject(): stdClass public function testResetForWorkerMode(): void { - Events::on('test', static fn () => null); + Events::on('test', static fn (): null => null); Events::trigger('test'); $performanceLog = Events::getPerformanceLogs(); From 5387ff81b293b900b4adb44ad2ad7c5f4b1a847e Mon Sep 17 00:00:00 2001 From: michalsn Date: Wed, 14 Jan 2026 19:32:39 +0100 Subject: [PATCH 08/18] wip --- app/Config/WorkerMode.php | 31 +------ system/Cache/Handlers/RedisHandler.php | 2 +- system/Commands/Worker/Views/Caddyfile.tpl | 2 - .../Worker/Views/frankenphp-worker.php.tpl | 13 +-- system/Commands/Worker/WorkerInstall.php | 1 + system/Commands/Worker/WorkerUninstall.php | 1 + system/Config/BaseService.php | 13 +-- system/Database/Config.php | 46 +++------- system/Events/Events.php | 10 +-- system/Session/Handlers/MemcachedHandler.php | 28 ++++-- system/Session/Handlers/RedisHandler.php | 28 ++++-- system/Session/PersistsConnection.php | 87 +++++++++++++++++++ tests/system/Config/ServicesTest.php | 55 ++---------- tests/system/Database/ConfigTest.php | 42 +-------- .../Handlers/Database/RedisHandlerTest.php | 65 ++++++++++++++ 15 files changed, 236 insertions(+), 188 deletions(-) create mode 100644 system/Session/PersistsConnection.php diff --git a/app/Config/WorkerMode.php b/app/Config/WorkerMode.php index cf25871df6f9..92285f117ac5 100644 --- a/app/Config/WorkerMode.php +++ b/app/Config/WorkerMode.php @@ -26,6 +26,7 @@ class WorkerMode * - `commands`: CLI commands registry * - `codeigniter`: Main application instance * - `superglobals`: Superglobals wrapper + * - `routes`: Router configuration * * @var list */ @@ -38,36 +39,10 @@ class WorkerMode 'commands', 'codeigniter', 'superglobals', + 'routes', + 'cache', ]; - /** - * Database connection strategy - * - * Determines how database connections are handled between requests: - * - * - 'keep-alive' (recommended): - * Keeps connections alive across requests for best performance - * - 'disconnect': - * Closes all connections after each request - * - * @var 'disconnect'|'keep-alive' - */ - public string $databaseStrategy = 'keep-alive'; - - /** - * Cache handler strategy - * - * Determines how cache handlers are managed between requests: - * - * - 'keep-alive' (recommended): - * Keeps cache handler alive across requests for best performance - * - 'disconnect': - * Closes the handler after each request - * - * @var 'disconnect'|'keep-alive' - */ - public string $cacheStrategy = 'keep-alive'; - /** * Reset Factories * diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index 0660ea196658..98cf33651687 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -229,7 +229,7 @@ public function ping(): bool try { $result = $this->redis->ping(); - return in_array($result, [true, '+PONG', 'PONG'], true); + return in_array($result, [true, '+PONG'], true); } catch (RedisException) { return false; } diff --git a/system/Commands/Worker/Views/Caddyfile.tpl b/system/Commands/Worker/Views/Caddyfile.tpl index 89e741870b9c..7984d582de49 100644 --- a/system/Commands/Worker/Views/Caddyfile.tpl +++ b/system/Commands/Worker/Views/Caddyfile.tpl @@ -33,8 +33,6 @@ # Route all PHP requests through the worker php_server { - # Use frankenphp-worker.php as the entry point - index frankenphp-worker.php # Route all requests through the worker try_files {path} frankenphp-worker.php } diff --git a/system/Commands/Worker/Views/frankenphp-worker.php.tpl b/system/Commands/Worker/Views/frankenphp-worker.php.tpl index 2d9af7a171c2..eab7f73ecff7 100644 --- a/system/Commands/Worker/Views/frankenphp-worker.php.tpl +++ b/system/Commands/Worker/Views/frankenphp-worker.php.tpl @@ -69,8 +69,11 @@ $workerConfig = config('WorkerMode'); */ $handler = function () use ($app, $workerConfig) { - // Validate database connections - DatabaseConfig::validateForWorkerMode($workerConfig); + // Validate database connections before handling request + DatabaseConfig::validateForWorkerMode(); + + // Validate cache connection before handling request + Services::validateForWorkerMode(); // Reset request-specific state $app->resetForWorkerMode(); @@ -105,8 +108,8 @@ while (frankenphp_handle_request($handler)) { Services::session()->close(); } - // Reset database connections based on strategy (keep-alive or disconnect) - DatabaseConfig::resetForWorkerMode($workerConfig); + // Cleanup connections with uncommitted transactions + DatabaseConfig::cleanupForWorkerMode(); // Reset model/entity instances (if enabled) if ($workerConfig->resetFactories) { @@ -117,7 +120,7 @@ while (frankenphp_handle_request($handler)) { Services::resetForWorkerMode($workerConfig); // Reset performance - Events::resetForWorkerMode(); + Events::cleanupForWorkerMode(); // Reset persistent services that accumulate state if (Services::has('timer')) { diff --git a/system/Commands/Worker/WorkerInstall.php b/system/Commands/Worker/WorkerInstall.php index 0a3cb560c2fe..59c04a8b3e8d 100644 --- a/system/Commands/Worker/WorkerInstall.php +++ b/system/Commands/Worker/WorkerInstall.php @@ -10,6 +10,7 @@ * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. */ + namespace CodeIgniter\Commands\Worker; use CodeIgniter\CLI\BaseCommand; diff --git a/system/Commands/Worker/WorkerUninstall.php b/system/Commands/Worker/WorkerUninstall.php index c8eb76e15a65..0d083f2e5b49 100644 --- a/system/Commands/Worker/WorkerUninstall.php +++ b/system/Commands/Worker/WorkerUninstall.php @@ -10,6 +10,7 @@ * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. */ + namespace CodeIgniter\Commands\Worker; use CodeIgniter\CLI\BaseCommand; diff --git a/system/Config/BaseService.php b/system/Config/BaseService.php index 85e289d6221a..1cd4aaa4ba80 100644 --- a/system/Config/BaseService.php +++ b/system/Config/BaseService.php @@ -381,9 +381,9 @@ public static function reset(bool $initAutoloader = true) * This should be called at the beginning of each request in worker mode, * before the application runs. */ - public static function validateForWorkerMode(WorkerMode $config): void + public static function validateForWorkerMode(): void { - if ($config->cacheStrategy !== 'keep-alive' || ! isset(static::$instances['cache'])) { + if (! isset(static::$instances['cache'])) { return; } @@ -412,15 +412,6 @@ public static function resetForWorkerMode(WorkerMode $config): void $persistentInstances = []; foreach (static::$instances as $serviceName => $service) { - if ($serviceName === 'cache') { - if ($config->cacheStrategy === 'keep-alive') { - // Persist cache instance in keep-alive mode - $persistentInstances[$serviceName] = $service; - } - - continue; - } - // Persist services in the persistent list if (in_array($serviceName, $config->persistentServices, true)) { $persistentInstances[$serviceName] = $service; diff --git a/system/Database/Config.php b/system/Database/Config.php index e006b545c716..f871db3e9408 100644 --- a/system/Database/Config.php +++ b/system/Database/Config.php @@ -161,9 +161,9 @@ protected static function ensureFactory() * This should be called at the beginning of each request in worker mode, * before the application runs. */ - public static function validateForWorkerMode(WorkerMode $config): void + public static function validateForWorkerMode(): void { - if ($config->databaseStrategy !== 'keep-alive') { + if (static::$instances === []) { return; } @@ -175,44 +175,24 @@ public static function validateForWorkerMode(WorkerMode $config): void } /** - * Reset database connections for worker mode. + * Cleanup database connections for worker mode. * - * This method handles connection state management between requests - * in long-running worker processes. + * If any uncommitted transactions are found, the connection + * is closed and reconnected to ensure a clean state for the next request. + * + * Uncommitted transactions at this point indicate a bug in the + * application code (transactions should be completed before request ends). * * Called at the END of each request to clean up state. */ - public static function resetForWorkerMode(WorkerMode $config): void + public static function cleanupForWorkerMode(): void { foreach (static::$instances as $group => $connection) { - switch ($config->databaseStrategy) { - case 'disconnect': - $connection->close(); - break; - - case 'keep-alive': - $maxAttempts = 10; // Prevent infinite loop - $attempts = 0; - - while ($connection->transDepth > 0 && $attempts < $maxAttempts) { - if (! $connection->transRollback()) { - log_message('error', "Database transaction rollback failed for group: {$group}"); - break; - } - $attempts++; - } - - if ($connection->transDepth > 0) { - log_message('warning', "Database connection has uncommitted transactions after cleanup: {$group}"); - } - break; + if ($connection->transDepth > 0) { + log_message('error', "Uncommitted transaction detected in database group '{$group}'. Transactions must be completed before request ends. Reconnecting to ensure clean state."); + $connection->close(); + $connection->initialize(); } } - - // If using the disconnect strategy, clear the instances array - if ($config->databaseStrategy === 'disconnect') { - static::$instances = []; - static::$factory = null; - } } } diff --git a/system/Events/Events.php b/system/Events/Events.php index 2a8029ae3dd7..3688cefdb039 100644 --- a/system/Events/Events.php +++ b/system/Events/Events.php @@ -286,15 +286,11 @@ public static function getPerformanceLogs() } /** - * Reset events for worker mode. + * Cleanup performance log for worker mode. * - * Uses WorkerMode config to determine behavior. - * Clears performance logs and re-initializes event listeners from config files. - * - * Note: This clears dynamically registered listeners. If your application - * registers listeners at runtime, they will need to be re-registered for each request. + * Called at the END of each request to clean up state. */ - public static function resetForWorkerMode(): void + public static function cleanupForWorkerMode(): void { static::$performanceLog = []; } diff --git a/system/Session/Handlers/MemcachedHandler.php b/system/Session/Handlers/MemcachedHandler.php index 90d02f6f394b..f6f5950d5c5e 100644 --- a/system/Session/Handlers/MemcachedHandler.php +++ b/system/Session/Handlers/MemcachedHandler.php @@ -15,6 +15,7 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Session\Exceptions\SessionException; +use CodeIgniter\Session\PersistsConnection; use Config\Session as SessionConfig; use Memcached; use ReturnTypeWillChange; @@ -24,6 +25,8 @@ */ class MemcachedHandler extends BaseHandler { + use PersistsConnection; + /** * Memcached instance. * @@ -83,6 +86,23 @@ public function __construct(SessionConfig $config, string $ipAddress) */ public function open($path, $name): bool { + if ($this->hasPersistentConnection()) { + $memcached = $this->getPersistentConnection(); + $version = $memcached->getVersion(); + + if (is_array($version)) { + foreach ($version as $serverVersion) { + if ($serverVersion !== false) { + $this->memcached = $memcached; + + return true; + } + } + } + + $this->setPersistentConnection(null); + } + $this->memcached = new Memcached(); $this->memcached->setOption(Memcached::OPT_BINARY_PROTOCOL, true); // required for touch() usage @@ -131,6 +151,8 @@ public function open($path, $name): bool return false; } + $this->setPersistentConnection($this->memcached); + return true; } @@ -210,12 +232,6 @@ public function close(): bool $this->memcached->delete($this->lockKey); } - if (! $this->memcached->quit()) { - return false; - } - - $this->memcached = null; - return true; } diff --git a/system/Session/Handlers/RedisHandler.php b/system/Session/Handlers/RedisHandler.php index 213272bb87c7..cad7300d51b9 100644 --- a/system/Session/Handlers/RedisHandler.php +++ b/system/Session/Handlers/RedisHandler.php @@ -15,6 +15,7 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Session\Exceptions\SessionException; +use CodeIgniter\Session\PersistsConnection; use Config\Session as SessionConfig; use Redis; use RedisException; @@ -25,6 +26,8 @@ */ class RedisHandler extends BaseHandler { + use PersistsConnection; + private const DEFAULT_PORT = 6379; private const DEFAULT_PROTOCOL = 'tcp'; @@ -176,6 +179,22 @@ public function open($path, $name): bool return false; } + if ($this->hasPersistentConnection()) { + $redis = $this->getPersistentConnection(); + + try { + $pingReply = $redis->ping(); + + if (in_array($pingReply, [true, '+PONG'], true)) { + $this->redis = $redis; + + return true; + } + } catch (RedisException) { + $this->setPersistentConnection(null); + } + } + $redis = new Redis(); $funcConnection = isset($this->savePath['persistent']) && $this->savePath['persistent'] === true @@ -191,6 +210,7 @@ public function open($path, $name): bool 'Session: Unable to select Redis database with index ' . $this->savePath['database'], ); } else { + $this->setPersistentConnection($redis); $this->redis = $redis; return true; @@ -285,21 +305,15 @@ public function close(): bool try { $pingReply = $this->redis->ping(); - if (($pingReply === true) || ($pingReply === '+PONG')) { + if (in_array($pingReply, [true, '+PONG'], true)) { if (isset($this->lockKey) && ! $this->releaseLock()) { return false; } - - if (! $this->redis->close()) { - return false; - } } } catch (RedisException $e) { $this->logger->error('Session: Got RedisException on close(): ' . $e->getMessage()); } - $this->redis = null; - return true; } diff --git a/system/Session/PersistsConnection.php b/system/Session/PersistsConnection.php new file mode 100644 index 000000000000..ec6f7fee5136 --- /dev/null +++ b/system/Session/PersistsConnection.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Session; + +/** + * Trait for session handlers that need persistent connections. + */ +trait PersistsConnection +{ + /** + * Connection pool keyed by connection identifier. + * Allows multiple configurations to each have their own connection. + * + * @var array + */ + protected static $connectionPool = []; + + /** + * Get connection identifier based on configuration. + * This returns a unique hash for each distinct connection configuration. + */ + protected function getConnectionIdentifier(): string + { + return hash('xxh128', serialize([ + 'class' => static::class, + 'savePath' => $this->savePath, + 'keyPrefix' => $this->keyPrefix, + ])); + } + + /** + * Check if a persistent connection exists for this configuration. + */ + protected function hasPersistentConnection(): bool + { + $identifier = $this->getConnectionIdentifier(); + + return isset(self::$connectionPool[$identifier]); + } + + /** + * Get the persistent connection for this configuration. + * + * @return object|null + */ + protected function getPersistentConnection(): ?object + { + $identifier = $this->getConnectionIdentifier(); + + return self::$connectionPool[$identifier] ?? null; + } + + /** + * Store a connection for persistence. + * + * @param object|null $connection The connection to persist (null to clear). + */ + protected function setPersistentConnection(?object $connection): void + { + $identifier = $this->getConnectionIdentifier(); + + if ($connection === null) { + unset(self::$connectionPool[$identifier]); + } else { + self::$connectionPool[$identifier] = $connection; + } + } + + /** + * Reset all persistent connections (useful for testing). + */ + public static function resetPersistentConnections(): void + { + self::$connectionPool = []; + } +} diff --git a/tests/system/Config/ServicesTest.php b/tests/system/Config/ServicesTest.php index dad155166244..eb1fc9ded140 100644 --- a/tests/system/Config/ServicesTest.php +++ b/tests/system/Config/ServicesTest.php @@ -518,10 +518,9 @@ public function testHasReturnsFalseForNonExistentService(): void #[PreserveGlobalState(false)] #[RunInSeparateProcess] - public function testResetForWorkerModeWithKeepAliveStrategy(): void + public function testResetForWorkerMode(): void { $config = new WorkerMode(); - $config->cacheStrategy = 'keep-alive'; $config->persistentServices = ['timer']; Services::cache(); @@ -541,67 +540,23 @@ public function testResetForWorkerModeWithKeepAliveStrategy(): void #[PreserveGlobalState(false)] #[RunInSeparateProcess] - public function testResetForWorkerModeWithReconnectStrategy(): void + public function testValidateForWorkerMode(): void { - $config = new WorkerMode(); - $config->cacheStrategy = 'disconnect'; - $config->persistentServices = ['timer']; - - Services::cache(); - Services::timer(); - Services::typography(); - - $this->assertTrue(Services::has('cache')); - $this->assertTrue(Services::has('timer')); - $this->assertTrue(Services::has('typography')); - - Services::resetForWorkerMode($config); - - $this->assertFalse(Services::has('cache')); - $this->assertTrue(Services::has('timer')); - $this->assertFalse(Services::has('typography')); - } - - #[PreserveGlobalState(false)] - #[RunInSeparateProcess] - public function testValidateForWorkerModeWithKeepAliveStrategy(): void - { - $config = new WorkerMode(); - $config->cacheStrategy = 'keep-alive'; - Services::cache(); $this->assertTrue(Services::has('cache')); - Services::validateForWorkerMode($config); + Services::validateForWorkerMode(); $this->assertTrue(Services::has('cache')); } #[PreserveGlobalState(false)] #[RunInSeparateProcess] - public function testValidateForWorkerModeWithDisconnectStrategy(): void + public function testValidateForWorkerModeWithNoCache(): void { - $config = new WorkerMode(); - $config->cacheStrategy = 'disconnect'; - - Services::cache(); - $this->assertTrue(Services::has('cache')); - - Services::validateForWorkerMode($config); - - $this->assertTrue(Services::has('cache')); - } - - #[PreserveGlobalState(false)] - #[RunInSeparateProcess] - public function testValidateForWorkerModeWithNoCacheService(): void - { - $config = new WorkerMode(); - $config->cacheStrategy = 'keep-alive'; - $this->assertFalse(Services::has('cache')); - Services::validateForWorkerMode($config); + Services::validateForWorkerMode(); $this->assertFalse(Services::has('cache')); } diff --git a/tests/system/Database/ConfigTest.php b/tests/system/Database/ConfigTest.php index a3e651e05928..f9aeb3b25b73 100644 --- a/tests/system/Database/ConfigTest.php +++ b/tests/system/Database/ConfigTest.php @@ -242,62 +242,28 @@ public static function provideConvertDSN(): iterable } #[Group('DatabaseLive')] - public function testResetForWorkerModeWithDisconnectStrategy(): void + public function testResetForWorkerMode(): void { - $config = new WorkerMode(); - $config->databaseStrategy = 'disconnect'; - - $conn = Config::connect(); - $this->assertInstanceOf(BaseConnection::class, $conn); - - Config::resetForWorkerMode($config); - - $this->assertFalse($this->getPrivateProperty($conn, 'connID')); - } - - #[Group('DatabaseLive')] - public function testResetForWorkerModeWithKeepAliveStrategy(): void - { - $config = new WorkerMode(); - $config->databaseStrategy = 'keep-alive'; - $conn = Config::connect(); $this->assertInstanceOf(BaseConnection::class, $conn); $conn->transStart(); $this->assertGreaterThan(0, $conn->transDepth); - Config::resetForWorkerMode($config); + Config::resetForWorkerMode(); $this->assertSame(0, $conn->transDepth); $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); } #[Group('DatabaseLive')] - public function testValidateForWorkerModeWithKeepAliveStrategy(): void + public function testValidateForWorkerMode(): void { - $config = new WorkerMode(); - $config->databaseStrategy = 'keep-alive'; - $conn = Config::connect(); $this->assertInstanceOf(BaseConnection::class, $conn); $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); - Config::validateForWorkerMode($config); - - $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); - } - - #[Group('DatabaseLive')] - public function testValidateForWorkerModeWithDisconnectStrategy(): void - { - $config = new WorkerMode(); - $config->databaseStrategy = 'disconnect'; - - $conn = Config::connect(); - $this->assertInstanceOf(BaseConnection::class, $conn); - - Config::validateForWorkerMode($config); + Config::validateForWorkerMode(); $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); } diff --git a/tests/system/Session/Handlers/Database/RedisHandlerTest.php b/tests/system/Session/Handlers/Database/RedisHandlerTest.php index 9244385a908e..2e7821446fe3 100644 --- a/tests/system/Session/Handlers/Database/RedisHandlerTest.php +++ b/tests/system/Session/Handlers/Database/RedisHandlerTest.php @@ -19,6 +19,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use Redis; /** * @internal @@ -56,6 +57,13 @@ protected function getInstance($options = []): RedisHandler return new RedisHandler($sessionConfig, $this->userIpAddress); } + protected function tearDown(): void + { + parent::tearDown(); + + RedisHandler::resetPersistentConnections(); + } + public function testOpen(): void { $handler = $this->getInstance(); @@ -294,4 +302,61 @@ public static function provideSetSavePath(): iterable ], ]; } + + public function testConnectionReuse(): void + { + $handler1 = $this->getInstance(); + $handler1->open($this->sessionSavePath, $this->sessionName); + + $connection1 = $this->getPrivateProperty($handler1, 'redis'); + $this->assertInstanceOf(Redis::class, $connection1); + + $handler2 = $this->getInstance(); + $handler2->open($this->sessionSavePath, $this->sessionName); + + $connection2 = $this->getPrivateProperty($handler2, 'redis'); + + $this->assertSame($connection1, $connection2); + + $handler1->close(); + $handler2->close(); + } + + public function testDifferentConfigurationsGetDifferentConnections(): void + { + $handler1 = $this->getInstance(); + $handler1->open($this->sessionSavePath, $this->sessionName); + + $connection1 = $this->getPrivateProperty($handler1, 'redis'); + + $handler2 = $this->getInstance(['cookieName' => 'different_session']); + $handler2->open($this->sessionSavePath, 'different_session'); + + $connection2 = $this->getPrivateProperty($handler2, 'redis'); + + $this->assertNotSame($connection1, $connection2); + + $handler1->close(); + $handler2->close(); + } + + public function testResetPersistentConnections(): void + { + $handler1 = $this->getInstance(); + $handler1->open($this->sessionSavePath, $this->sessionName); + + $connection1 = $this->getPrivateProperty($handler1, 'redis'); + + RedisHandler::resetPersistentConnections(); + + $handler2 = $this->getInstance(); + $handler2->open($this->sessionSavePath, $this->sessionName); + + $connection2 = $this->getPrivateProperty($handler2, 'redis'); + + $this->assertNotSame($connection1, $connection2); + + $handler1->close(); + $handler2->close(); + } } From 6a719c6e5a1a452775a124ea320156ddcbf42c32 Mon Sep 17 00:00:00 2001 From: michalsn Date: Thu, 15 Jan 2026 14:20:37 +0100 Subject: [PATCH 09/18] wip --- app/Config/WorkerMode.php | 9 +- .../Worker/Views/frankenphp-worker.php.tpl | 8 +- system/Commands/Worker/WorkerInstall.php | 11 +- system/Database/Config.php | 1 - system/Session/Handlers/RedisHandler.php | 6 +- system/Session/PersistsConnection.php | 2 - tests/system/Commands/WorkerCommandsTest.php | 183 ++++++++ tests/system/Database/ConfigTest.php | 3 +- tests/system/Database/Live/PingTest.php | 69 +++ tests/system/Events/EventsTest.php | 2 +- user_guide_src/source/concepts/index.rst | 1 - .../source/concepts/worker_mode.rst | 398 ------------------ user_guide_src/source/installation/index.rst | 1 + .../source/installation/running.rst | 30 ++ .../source/installation/worker_mode.rst | 309 ++++++++++++++ 15 files changed, 603 insertions(+), 430 deletions(-) create mode 100644 tests/system/Commands/WorkerCommandsTest.php create mode 100644 tests/system/Database/Live/PingTest.php delete mode 100644 user_guide_src/source/concepts/worker_mode.rst create mode 100644 user_guide_src/source/installation/worker_mode.rst diff --git a/app/Config/WorkerMode.php b/app/Config/WorkerMode.php index 92285f117ac5..5a51015277ec 100644 --- a/app/Config/WorkerMode.php +++ b/app/Config/WorkerMode.php @@ -27,6 +27,7 @@ class WorkerMode * - `codeigniter`: Main application instance * - `superglobals`: Superglobals wrapper * - `routes`: Router configuration + * - `cache`: Cache instance * * @var list */ @@ -43,14 +44,6 @@ class WorkerMode 'cache', ]; - /** - * Reset Factories - * - * Whether to reset Factories (Models, etc.) between requests. - * Set to false if you want to cache model instances across requests. - */ - public bool $resetFactories = true; - /** * Garbage Collection * diff --git a/system/Commands/Worker/Views/frankenphp-worker.php.tpl b/system/Commands/Worker/Views/frankenphp-worker.php.tpl index eab7f73ecff7..ff6a2cfa8090 100644 --- a/system/Commands/Worker/Views/frankenphp-worker.php.tpl +++ b/system/Commands/Worker/Views/frankenphp-worker.php.tpl @@ -68,7 +68,7 @@ $workerConfig = config('WorkerMode'); *--------------------------------------------------------------- */ -$handler = function () use ($app, $workerConfig) { +$handler = static function () use ($app, $workerConfig) { // Validate database connections before handling request DatabaseConfig::validateForWorkerMode(); @@ -111,10 +111,8 @@ while (frankenphp_handle_request($handler)) { // Cleanup connections with uncommitted transactions DatabaseConfig::cleanupForWorkerMode(); - // Reset model/entity instances (if enabled) - if ($workerConfig->resetFactories) { - Factories::reset(); - } + // Reset factories + Factories::reset(); // Reset services except persistent ones Services::resetForWorkerMode($workerConfig); diff --git a/system/Commands/Worker/WorkerInstall.php b/system/Commands/Worker/WorkerInstall.php index 59c04a8b3e8d..dd177ac2c6a7 100644 --- a/system/Commands/Worker/WorkerInstall.php +++ b/system/Commands/Worker/WorkerInstall.php @@ -127,21 +127,16 @@ protected function showNextSteps(): void CLI::write('Next Steps:', 'yellow'); CLI::newLine(); - CLI::write('1. Review configuration files:', 'white'); - CLI::write(' Caddyfile - Adjust port, worker count, max_requests', 'green'); - CLI::write(' public/frankenphp-worker.php - Customize state management', 'green'); - CLI::newLine(); - - CLI::write('2. Start FrankenPHP:', 'white'); + CLI::write('1. Start FrankenPHP:', 'white'); CLI::write(' frankenphp run', 'green'); CLI::newLine(); - CLI::write('3. Test your application:', 'white'); + CLI::write('2. Test your application:', 'white'); CLI::write(' curl http://localhost:8080/', 'green'); CLI::newLine(); CLI::write('Documentation:', 'yellow'); - CLI::write(' • https://frankenphp.dev/docs/worker/', 'blue'); + CLI::write(' https://frankenphp.dev/docs/worker/', 'blue'); CLI::newLine(); } } diff --git a/system/Database/Config.php b/system/Database/Config.php index f871db3e9408..41e1617eac36 100644 --- a/system/Database/Config.php +++ b/system/Database/Config.php @@ -16,7 +16,6 @@ use CodeIgniter\Config\BaseConfig; use CodeIgniter\Exceptions\InvalidArgumentException; use Config\Database as DbConfig; -use Config\WorkerMode; /** * @see \CodeIgniter\Database\ConfigTest diff --git a/system/Session/Handlers/RedisHandler.php b/system/Session/Handlers/RedisHandler.php index cad7300d51b9..6fd9545d9bf8 100644 --- a/system/Session/Handlers/RedisHandler.php +++ b/system/Session/Handlers/RedisHandler.php @@ -305,10 +305,8 @@ public function close(): bool try { $pingReply = $this->redis->ping(); - if (in_array($pingReply, [true, '+PONG'], true)) { - if (isset($this->lockKey) && ! $this->releaseLock()) { - return false; - } + if (in_array($pingReply, [true, '+PONG'], true) && (isset($this->lockKey) && ! $this->releaseLock())) { + return false; } } catch (RedisException $e) { $this->logger->error('Session: Got RedisException on close(): ' . $e->getMessage()); diff --git a/system/Session/PersistsConnection.php b/system/Session/PersistsConnection.php index ec6f7fee5136..716a949f15cd 100644 --- a/system/Session/PersistsConnection.php +++ b/system/Session/PersistsConnection.php @@ -51,8 +51,6 @@ protected function hasPersistentConnection(): bool /** * Get the persistent connection for this configuration. - * - * @return object|null */ protected function getPersistentConnection(): ?object { diff --git a/tests/system/Commands/WorkerCommandsTest.php b/tests/system/Commands/WorkerCommandsTest.php new file mode 100644 index 000000000000..03bd2023f9d9 --- /dev/null +++ b/tests/system/Commands/WorkerCommandsTest.php @@ -0,0 +1,183 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class WorkerCommandsTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + /** + * @var list + */ + private array $filesToCleanup = [ + 'public/frankenphp-worker.php', + 'Caddyfile', + ]; + + protected function tearDown(): void + { + parent::tearDown(); + + $this->cleanupFiles(); + } + + private function cleanupFiles(): void + { + foreach ($this->filesToCleanup as $file) { + $path = ROOTPATH . $file; + if (is_file($path)) { + @unlink($path); + } + } + } + + public function testWorkerInstallCreatesFiles(): void + { + command('worker:install'); + + $this->assertFileExists(ROOTPATH . 'public/frankenphp-worker.php'); + $this->assertFileExists(ROOTPATH . 'Caddyfile'); + + $output = $this->getStreamFilterBuffer(); + $this->assertStringContainsString('Worker mode files created successfully!', $output); + $this->assertStringContainsString('File created:', $output); + } + + public function testWorkerInstallSkipsExistingFilesWithoutForce(): void + { + command('worker:install'); + $this->resetStreamFilterBuffer(); + + command('worker:install'); + + $output = $this->getStreamFilterBuffer(); + $this->assertStringContainsString('Worker mode files already exist', $output); + $this->assertStringContainsString('Use --force to overwrite', $output); + } + + public function testWorkerInstallOverwritesWithForce(): void + { + command('worker:install'); + + $workerFile = ROOTPATH . 'public/frankenphp-worker.php'; + file_put_contents($workerFile, 'resetStreamFilterBuffer(); + + command('worker:install --force'); + + $output = $this->getStreamFilterBuffer(); + $this->assertStringContainsString('File overwritten:', $output); + + $content = file_get_contents($workerFile); + $this->assertStringNotContainsString('// Modified content', (string) $content); + $this->assertStringContainsString('FrankenPHP Worker', (string) $content); + } + + public function testWorkerInstallShowsNextSteps(): void + { + command('worker:install'); + + $output = $this->getStreamFilterBuffer(); + $this->assertStringContainsString('Next Steps:', $output); + $this->assertStringContainsString('frankenphp run', $output); + $this->assertStringContainsString('https://frankenphp.dev/docs/worker/', $output); + } + + public function testWorkerUninstallRemovesFiles(): void + { + command('worker:install'); + $this->resetStreamFilterBuffer(); + + command('worker:uninstall --force'); + + $this->assertFileDoesNotExist(ROOTPATH . 'public/frankenphp-worker.php'); + $this->assertFileDoesNotExist(ROOTPATH . 'Caddyfile'); + + $output = $this->getStreamFilterBuffer(); + $this->assertStringContainsString('Worker mode files removed successfully!', $output); + $this->assertStringContainsString('File removed:', $output); + } + + public function testWorkerUninstallWithNoFilesToRemove(): void + { + $this->cleanupFiles(); + + command('worker:uninstall --force'); + + $output = $this->getStreamFilterBuffer(); + $this->assertStringContainsString('No worker mode files found to remove', $output); + } + + public function testWorkerUninstallListsFilesToRemove(): void + { + command('worker:install'); + $this->resetStreamFilterBuffer(); + + command('worker:uninstall --force'); + + $output = $this->getStreamFilterBuffer(); + $this->assertStringContainsString('The following files will be removed:', $output); + $this->assertStringContainsString('public/frankenphp-worker.php', $output); + $this->assertStringContainsString('Caddyfile', $output); + } + + public function testWorkerInstallAndUninstallCycle(): void + { + command('worker:install'); + $this->assertFileExists(ROOTPATH . 'public/frankenphp-worker.php'); + $this->assertFileExists(ROOTPATH . 'Caddyfile'); + + command('worker:uninstall --force'); + $this->assertFileDoesNotExist(ROOTPATH . 'public/frankenphp-worker.php'); + $this->assertFileDoesNotExist(ROOTPATH . 'Caddyfile'); + } + + public function testWorkerInstallCreatesValidPHPFile(): void + { + command('worker:install'); + + $workerFile = ROOTPATH . 'public/frankenphp-worker.php'; + $this->assertFileExists($workerFile); + + $content = file_get_contents($workerFile); + $this->assertStringStartsWith('assertStringContainsString('frankenphp_handle_request', (string) $content); + $this->assertStringContainsString('DatabaseConfig::validateForWorkerMode', (string) $content); + $this->assertStringContainsString('DatabaseConfig::cleanupForWorkerMode', (string) $content); + } + + public function testWorkerInstallCreatesValidCaddyfile(): void + { + command('worker:install'); + + $caddyfile = ROOTPATH . 'Caddyfile'; + $this->assertFileExists($caddyfile); + + $content = file_get_contents($caddyfile); + + $this->assertStringContainsString('frankenphp', (string) $content); + $this->assertStringContainsString('worker', (string) $content); + $this->assertStringContainsString('public/frankenphp-worker.php', (string) $content); + } +} diff --git a/tests/system/Database/ConfigTest.php b/tests/system/Database/ConfigTest.php index f9aeb3b25b73..a02c19604833 100644 --- a/tests/system/Database/ConfigTest.php +++ b/tests/system/Database/ConfigTest.php @@ -16,7 +16,6 @@ use CodeIgniter\Database\Postgre\Connection as PostgreConnection; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\ReflectionHelper; -use Config\WorkerMode; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -250,7 +249,7 @@ public function testResetForWorkerMode(): void $conn->transStart(); $this->assertGreaterThan(0, $conn->transDepth); - Config::resetForWorkerMode(); + Config::cleanupForWorkerMode(); $this->assertSame(0, $conn->transDepth); $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); diff --git a/tests/system/Database/Live/PingTest.php b/tests/system/Database/Live/PingTest.php new file mode 100644 index 000000000000..8e10c9d85b2a --- /dev/null +++ b/tests/system/Database/Live/PingTest.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Live; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class PingTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected $migrate = false; + + public function testPingReturnsTrueWhenConnected(): void + { + $this->db->initialize(); + + $result = $this->db->ping(); + + $this->assertTrue($result); + } + + public function testPingReturnsFalseWhenNotConnected(): void + { + $this->db->close(); + + $this->setPrivateProperty($this->db, 'connID', false); + + $result = $this->db->ping(); + + $this->assertFalse($result); + } + + public function testPingAfterReconnect(): void + { + $this->db->close(); + $this->db->reconnect(); + + $result = $this->db->ping(); + + $this->assertTrue($result); + } + + public function testPingCanBeUsedToCheckConnectionBeforeQuery(): void + { + if ($this->db->ping()) { + $result = $this->db->query('SELECT 1'); + $this->assertNotFalse($result); + } else { + $this->fail('Connection should be alive'); + } + } +} diff --git a/tests/system/Events/EventsTest.php b/tests/system/Events/EventsTest.php index 783a24f877f9..5ec4ae679d3b 100644 --- a/tests/system/Events/EventsTest.php +++ b/tests/system/Events/EventsTest.php @@ -341,7 +341,7 @@ public function testResetForWorkerMode(): void $performanceLog = Events::getPerformanceLogs(); $this->assertNotEmpty($performanceLog); - Events::resetForWorkerMode(); + Events::cleanupForWorkerMode(); $performanceLog = Events::getPerformanceLogs(); $this->assertEmpty($performanceLog); diff --git a/user_guide_src/source/concepts/index.rst b/user_guide_src/source/concepts/index.rst index aee2e0e26044..8ee5c0538979 100644 --- a/user_guide_src/source/concepts/index.rst +++ b/user_guide_src/source/concepts/index.rst @@ -14,5 +14,4 @@ The following pages describe the architectural concepts behind CodeIgniter4: factories http security - worker_mode goals diff --git a/user_guide_src/source/concepts/worker_mode.rst b/user_guide_src/source/concepts/worker_mode.rst deleted file mode 100644 index 6e07ba6a0576..000000000000 --- a/user_guide_src/source/concepts/worker_mode.rst +++ /dev/null @@ -1,398 +0,0 @@ -########### -Worker Mode -########### - -.. contents:: - :local: - :depth: 1 - -.. versionadded:: 4.7.0 - -.. important:: Worker Mode is currently **experimental**. The only officially supported - worker implementation is **FrankenPHP**, which is backed by the PHP Foundation. - -************ -Introduction -************ - -What is Worker Mode? -==================== - -Worker Mode is a performance optimization feature that allows CodeIgniter to handle multiple -HTTP requests within the same PHP process, instead of starting a new process for each request -like traditional PHP-FPM does. - -Traditional PHP creates a new process for each HTTP request, bootstraps the framework from scratch, -loads all classes fresh, and terminates the process after sending the response. Worker Mode takes -a different approach: a single PHP process handles multiple requests, bootstrapping the framework -only once at startup, keeping classes and resources in memory, and persisting indefinitely. - -This approach can provide significant performance improvements, especially for applications with -substantial bootstrap overhead or database operations. - -Requirements -============ - -Worker Mode requires PHP 8.2 or higher, FrankenPHP (the only officially supported worker -implementation), and CodeIgniter 4.7.0 or higher. FrankenPHP is backed by the PHP Foundation -and provides robust worker mode capabilities. - -How It Works -============ - -Worker Mode operates through four key mechanisms: - -**One-time bootstrap** - Loading the autoloader, environment configuration, helpers, and services once when the worker starts - -**Request loop** - Handling incoming requests in a continuous loop without terminating the process - -**State management** - Resetting request-specific state between requests while keeping shared resources in memory - -**Connection persistence** - Optionally keeping database and cache connections alive across requests - -*************** -Getting Started -*************** - -Installation -============ - -1. Install FrankenPHP following the `official documentation `_ - -2. Install the Worker Mode template files using the spark command: - -.. code-block:: bash - - php spark worker:install - -This command creates two files: **Caddyfile** (FrankenPHP configuration file with worker mode enabled) -and **public/frankenphp-worker.php** (worker entry point that handles requests). - -3. Configure your worker settings in **app/Config/WorkerMode.php** if needed. The defaults are - recommended for most applications. - -Running the Worker -================== - -Start FrankenPHP using the generated Caddyfile: - -.. code-block:: bash - - frankenphp run - -The server will start with worker mode enabled, handling requests through the worker entry point. - -Uninstalling -============ - -To remove the Worker Mode template files: - -.. code-block:: bash - - php spark worker:uninstall - -This removes the **Caddyfile** and **public/frankenphp-worker.php** files. - -********************** -Performance Benchmarks -********************** - -The performance gains from Worker Mode depend heavily on your application's characteristics: - -**Simple endpoints** - Applications returning minimal JSON responses show similar performance to traditional mode, - as there's little bootstrap overhead to eliminate. - -**Database operations** - Endpoints performing database queries typically see 2-3x improvements from connection reuse - and reduced initialization overhead. - -**Worker count considerations** - Total throughput scales with worker count. Two workers handling concurrent requests will have - lower throughput than ten workers, even though per-request speed is faster. Match worker count - to your typical concurrency patterns. - -**First request latency** - The initial request to each worker is always slower due to connection establishment. Subsequent - requests benefit from cached connections. - -************* -Configuration -************* - -All worker mode configuration is managed through the ``app/Config/WorkerMode.php`` file. - -Persistent Services -=================== - -.. php:property:: $persistentServices - - :type: array - :default: ``['autoloader', 'locator', 'exceptions', 'logger', 'timer', 'commands', 'codeigniter', 'superglobals']`` - -Services that should persist across requests and not be reset. Services not in this list will -be reset for each request to prevent state leakage. - -The recommended persistent services each serve specific purposes: - -``autoloader`` - PSR-4 autoloading configuration - -``locator`` - File locator for finding framework files - -``exceptions`` - Exception handler - -``logger`` - Logger instance - -``timer`` - Performance timer - -``commands`` - CLI commands registry - -``codeigniter`` - Main application instance - -``superglobals`` - Superglobals wrapper - -.. code-block:: php - - public array $persistentServices = [ - 'autoloader', - 'locator', - 'exceptions', - 'logger', - 'timer', - 'commands', - 'codeigniter', - 'superglobals', - ]; - -Database Connection Strategy -============================= - -.. php:property:: $databaseStrategy - - :type: string - :default: ``'keep-alive'`` - -Determines how database connections are handled between requests. - -**keep-alive** (recommended) - Keeps connections alive across requests for best performance. The connection is automatically - pinged and reconnected if needed. Any uncommitted transactions are rolled back between requests. - This strategy provides the best performance for database-heavy applications. - -**disconnect** - Closes all connections after each request. Use this for low-traffic applications or when you - need a fresh connection state for each request. This strategy eliminates connection persistence - but incurs reconnection overhead. - -.. code-block:: php - - public string $databaseStrategy = 'keep-alive'; - -.. note:: Do not enable the ``pConnect`` option in **app/Config/Database.php** when using Worker Mode. - The worker's keep-alive strategy provides better connection management than PHP's persistent - connections. - -Cache Handler Strategy -======================= - -.. php:property:: $cacheStrategy - - :type: string - :default: ``'keep-alive'`` - -Determines how cache handlers are managed between requests. - -**keep-alive** (recommended) - Keeps cache handlers alive across requests for best performance. This works well with - cache backends like Redis or Memcached. - -**disconnect** - Closes the cache handler after each request. Consider this strategy if using file-based - caching to avoid file locking issues between workers. - -.. code-block:: php - - public string $cacheStrategy = 'keep-alive'; - -Reset Factories -=============== - -.. php:property:: $resetFactories - - :type: bool - :default: ``true`` - -Whether to reset Factories (Models, Config instances, etc.) between requests. - -**true** (recommended) - Ensures fresh model instances for each request, preventing state leakage between requests. - This is the safest option and recommended for most applications. - -**false** - Caches model instances across requests. This is an advanced option that requires careful - consideration of state management. Only use this if you fully understand the implications - and have verified your models don't maintain request-specific state. - -.. code-block:: php - - public bool $resetFactories = true; - -.. warning:: Setting this to ``false`` can cause state leakage between requests if your models - maintain internal state. Only disable this if you fully understand the implications. - -Garbage Collection -================== - -.. php:property:: $garbageCollection - - :type: bool - :default: ``true`` - -Whether to force garbage collection after each request. - -**true** (recommended) - Helps prevent memory leaks at a small performance cost. The garbage collector runs after - each request completes, freeing unreferenced objects. - -**false** - Relies on PHP's automatic garbage collection. This may provide slightly better performance - but could allow memory to accumulate between requests. - -.. code-block:: php - - public bool $garbageCollection = true; - -********************* -Performance Tuning -********************* - -Optimize Configuration -====================== - -The ``app/Config/Optimize.php`` configuration options (Config Caching and File Locator Caching) -should **NOT** be used with Worker Mode. - -These optimizations were designed for traditional PHP where each request starts fresh. In Worker -Mode, the persistent process already provides these benefits naturally, and enabling them can -cause issues with stale data and state management. - -.. warning:: Do not enable ``$configCacheEnabled`` or ``$locatorCacheEnabled`` in - **app/Config/Optimize.php** when using Worker Mode. The worker's persistent process already - provides superior optimization, and these options can cause stale data and state management - issues. - -OPcache Settings -================ - -Worker Mode benefits significantly from OPcache. Ensure it's enabled and properly configured: - -.. code-block:: ini - - opcache.enable=1 - opcache.memory_consumption=256 - opcache.max_accelerated_files=20000 - opcache.validate_timestamps=0 ; In production only - -Worker Count Tuning -=================== - -The number of workers significantly impacts performance and resource usage. Consider these factors -when tuning worker count: - -**CPU cores** - Start with a worker count equal to your CPU core count, or 2x cores for I/O-heavy workloads. - -**Expected concurrency** - Match worker count to your typical concurrent request patterns. If you regularly handle - 50 concurrent requests, configure at least 50 workers to avoid queuing. - -**Memory constraints** - Each worker holds the full application state in memory. Monitor total memory usage and - adjust worker count accordingly. - -**Database connections** - With keep-alive strategy, each worker maintains its own database connection. Ensure your - database can handle worker-count connections. - -************************ -Important Considerations -************************ - -State Management -================ - -Since the PHP process persists across requests, careful attention to state management is essential. -Avoid storing request-specific data in static properties or global variables, as these will leak -between requests. Be cautious with singleton patterns that might retain state. Resources like file -handles, database cursors, and similar must be properly cleaned up after each request. - -Registrars -========== - -Config registrars (``Config/Registrar.php`` files) work correctly in Worker Mode through a -two-phase approach. Registrar files are discovered once when the worker starts, and registrar -logic is applied every time a Config class is instantiated. Each request gets a fresh Config -instance with registrars applied. - -Memory Management -================= - -Monitor memory usage in production by checking worker process memory with ``ps aux | grep frankenphp``. -If memory grows continuously, verify that garbage collection is enabled (``$garbageCollection = true``), -resources are properly closed or freed after use, and large objects are unset when no longer needed. - -Debugging -========= - -Worker Mode can make debugging more challenging since the process persists across requests. During -development, consider using the ``disconnect`` strategies for cleaner state between requests. Remember -to restart the worker after code changes, as workers don't auto-reload. Check logs in **writable/logs/** -for connection-related issues. - -Session and Cache Considerations -================================= - -Sessions work normally in Worker Mode, with the worker automatically closing sessions between requests -to prevent file locking issues. - -For caching, file-based cache handlers may experience file locking contention between workers. Consider -using memory-based caching systems like Redis or Memcached in production for better performance and -concurrency handling. - -************** -Best Practices -************** - -Configuration Recommendations -============================= - -Use the recommended default ``WorkerMode`` configuration for most applications. The defaults are -optimized for a balance of performance, safety, and resource usage. Only modify settings after -profiling and understanding the implications. - -Monitoring and Maintenance -========================== - -Set up monitoring for worker process memory usage to detect leaks early. Implement graceful worker -restarts for deployments to ensure new code is loaded. Monitor database connection counts to ensure -the keep-alive strategy isn't exhausting your database connection pool. - -Development Practices -===================== - -Test your application thoroughly under Worker Mode before deploying to production. Write stateless -code that doesn't rely on static properties or global variables. Always close file handles and free -resources explicitly rather than relying on garbage collection. Consider using dependency injection -to make state management more explicit. diff --git a/user_guide_src/source/installation/index.rst b/user_guide_src/source/installation/index.rst index f81b61a589d4..ae1e432f6b89 100644 --- a/user_guide_src/source/installation/index.rst +++ b/user_guide_src/source/installation/index.rst @@ -28,6 +28,7 @@ repository. installing_composer installing_manual running + worker_mode troubleshooting deployment ../changelogs/index diff --git a/user_guide_src/source/installation/running.rst b/user_guide_src/source/installation/running.rst index 0fbc6fdc9c11..550aa5bcb1bd 100644 --- a/user_guide_src/source/installation/running.rst +++ b/user_guide_src/source/installation/running.rst @@ -158,6 +158,36 @@ The local development server can be customized with three command line options: php spark serve --php /usr/bin/php7.6.5.4 +*************************** +Worker Mode with FrankenPHP +*************************** + +.. versionadded:: 4.7.0 + +FrankenPHP is a modern PHP application server that supports Worker Mode, allowing +your application to handle multiple requests within the same PHP process for +improved performance. + +Quick Start +=========== + +1. Install FrankenPHP using `static binaries `_ + +2. Generate worker mode files: + +.. code-block:: console + + php spark worker:install + +3. Start the server: + +.. code-block:: console + + frankenphp run + +For detailed configuration, performance tuning, and important considerations +about state management, see :doc:`worker_mode`. + ******************* Hosting with Apache ******************* diff --git a/user_guide_src/source/installation/worker_mode.rst b/user_guide_src/source/installation/worker_mode.rst new file mode 100644 index 000000000000..c57f334163ab --- /dev/null +++ b/user_guide_src/source/installation/worker_mode.rst @@ -0,0 +1,309 @@ +########### +Worker Mode +########### + +.. contents:: + :local: + :depth: 2 + +.. versionadded:: 4.7.0 + +.. important:: Worker Mode is currently **experimental**. The only officially supported + worker implementation is **FrankenPHP**, which is backed by the PHP Foundation. + +************ +Introduction +************ + +What is Worker Mode? +==================== + +Worker Mode is a performance optimization feature that allows CodeIgniter to handle multiple +HTTP requests within the same PHP process, instead of starting a new process for each request +like traditional PHP-FPM does. + +Traditional PHP vs Worker Mode +------------------------------ + +**Traditional PHP (PHP-FPM)** + +In traditional PHP, each HTTP request goes through this cycle: + +1. Web server receives request and spawns a new PHP process +2. PHP loads and parses all required files +3. Framework bootstraps: autoloader, configuration, services, routes +4. Database connections are established +5. Request is processed and response is sent +6. All connections are closed +7. PHP process terminates, freeing all memory + +This "shared nothing" architecture is simple and safe, but inefficient for high-traffic +applications because steps 2-4 repeat for every single request. + +**Worker Mode** + +In Worker Mode, the lifecycle changes dramatically: + +1. Worker process starts and performs one-time initialization: + + - Loads and parses all required files (cached in OPcache) + - Bootstraps framework: autoloader, configuration, services, routes + - Establishes database and cache connections + +2. For each incoming request: + + - Reuses existing connections and cached resources + - Resets only request-specific state (superglobals, request/response objects) + - Processes request and sends response + - Cleans up request-specific data + +3. Worker continues running, ready for the next request + +This approach eliminates redundant initialization work and connection overhead, +resulting in **2-3x performance improvements** for typical database-driven applications. + +How CodeIgniter Manages State +============================= + +The key challenge in Worker Mode is preventing state from leaking between requests. +CodeIgniter handles this through several mechanisms: + +**Services Reset** + 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. + +**Superglobals Isolation** + Request data (``$_GET``, ``$_POST``, ``$_SERVER``, etc.) is properly isolated + per request through the Superglobals service. + +**Connection Persistence** + Database and cache connections are validated at request start and reused if healthy. + Unhealthy connections are automatically re-established. + +*************** +Getting Started +*************** + +Installation +============ + +1. Install FrankenPHP following the `official documentation `_ + + You can use a static binary or Docker. Here we will assume we use a static binary, + which can be `downloaded directly `_. + +2. Install the Worker Mode template files using the spark command: + + .. code-block:: console + + php spark worker:install + + This command creates two files: + + - **Caddyfile** - FrankenPHP configuration file with worker mode enabled + - **public/frankenphp-worker.php** - Worker entry point that handles the request loop + +3. Configure your worker settings in **app/Config/WorkerMode.php** if needed. + The defaults are recommended for most applications. + +Running the Worker +================== + +Start FrankenPHP using the generated Caddyfile: + +.. code-block:: console + + frankenphp run + +The server will start with worker mode enabled, handling requests through the worker entry point. + +To run in the background (daemon mode): + +.. code-block:: console + + frankenphp start + +Uninstalling +============ + +To remove the Worker Mode template files: + +.. code-block:: console + + php spark worker:uninstall + +This removes the **Caddyfile** and **public/frankenphp-worker.php** files. + +********************** +Performance Benchmarks +********************** + +Worker Mode typically provides **2-3x performance improvements** for database-driven +applications. The actual gains depend on your application's characteristics: + +===================== ===================================================================== +Scenario Expected Improvement +===================== ===================================================================== +**Simple endpoints** Applications returning minimal JSON responses show modest + improvements (10-30%), as there's little bootstrap overhead + to eliminate. +**Database queries** Endpoints performing database queries typically see **2-3x** + improvements from connection reuse and reduced initialization + overhead. +**Complex bootstrap** Applications with many services, routes, or configuration see + the largest gains, as this overhead is eliminated entirely. +===================== ===================================================================== + +Worker Count Considerations +=========================== + +Total throughput scales with worker count. Match worker count to your typical concurrency +patterns and available CPU cores. Too few workers limit concurrency; too many waste memory. + +First Request Latency +===================== + +The initial request to each worker is slower due to bootstrap and connection establishment. +Subsequent requests benefit from cached resources and persistent connections. + +************* +Configuration +************* + +All worker mode configuration is managed through the **app/Config/WorkerMode.php** file. + +Configuration Options +===================== + +======================= ======= ========================================================================== +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', 'logger', 'timer', + 'commands', 'codeigniter', 'superglobals', 'routes', 'cache']`` +**$garbageCollection** bool Whether to force garbage collection after each request. + ``true`` (default, recommended): Prevents memory leaks. + ``false``: Relies on PHP's automatic garbage collection. +======================= ======= ========================================================================== + +Persistent Services +=================== + +The ``$persistentServices`` array controls which services survive between requests. +The default configuration includes: + +===================== ========================================================================== +Service Purpose +===================== ========================================================================== +``autoloader`` PSR-4 autoloading configuration. Safe to persist as class maps don't change. +``locator`` File locator for finding framework files. Caches file paths for performance. +``exceptions`` Exception handler. Stateless, safe to reuse. +``logger`` Logger instance. Maintains file handles for efficient logging. +``timer`` Performance timer. Reset internally per request. +``commands`` CLI commands registry. Only used during worker startup. +``codeigniter`` Main application instance. Orchestrates the request/response cycle. +``superglobals`` Superglobals wrapper. Properly isolated per request internally. +``routes`` Router configuration. Route definitions don't change between requests. +``cache`` Cache service. Maintains connections to cache backends (Redis, Memcached). +===================== ========================================================================== + +.. 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. + +********************** +Optimize Configuration +********************** + +The ``app/Config/Optimize.php`` configuration options (Config Caching and File Locator Caching) +should **NOT** be used with Worker Mode. + +These optimizations were designed for traditional PHP where each request starts fresh. In Worker +Mode, the persistent process already provides these benefits naturally, and enabling them can +cause issues with stale data. + +.. warning:: Do not enable ``$configCacheEnabled`` or ``$locatorCacheEnabled`` in + **app/Config/Optimize.php** when using Worker Mode. + +OPcache Settings +================ + +Worker Mode benefits significantly from OPcache. Ensure it's enabled and properly configured: + +.. code-block:: ini + + opcache.enable=1 + opcache.memory_consumption=256 + opcache.max_accelerated_files=20000 + opcache.validate_timestamps=0 ; In production only + +************************ +Important Considerations +************************ + +State Management +================ + +Since the PHP process persists across requests, careful attention to state management is essential: + +- **Avoid static properties** for request-specific data, as these persist between requests +- **Be cautious with singletons** that might retain request state +- **Clean up resources** like file handles and database cursors after each request +- **Don't store user data** in class properties that persist across requests + +Registrars +========== + +Config registrars (``Config/Registrar.php`` files) work correctly in Worker Mode. +Registrar files are discovered once when the worker starts, and registrar logic is applied +every time a Config class is instantiated. Each request gets a fresh Config instance with +registrars applied. + +Memory Management +================= + +Monitor memory usage: + +.. code-block:: console + + ps aux | grep frankenphp + +If memory grows continuously: + +- Verify garbage collection is enabled (``$garbageCollection = true``) +- Ensure resources are properly closed after use +- Unset large objects when no longer needed +- Check for circular references that prevent garbage collection + +Debugging +========= + +Worker Mode can make debugging more challenging since the process persists across requests: + +- **Restart workers after code changes** - workers don't auto-reload modified files +- **Check logs** in **writable/logs/** for connection and state-related issues +- **Use logging liberally** to trace request flow across the persistent process + +Session and Cache Handlers +========================== + +**File-based handlers** may experience file locking contention between workers. +For production environments, consider using: + +- **Redis** or **Memcached** for sessions and cache +- These provide better concurrency and automatic connection persistence + +Database Connections +==================== + +Database connections are automatically managed: + +- Connections persist across requests for performance +- Connections are validated at the start of each request +- Failed connections are automatically re-established +- Uncommitted transactions trigger a warning and reconnection to ensure clean state From d970cfde1615146d750df7d568e55f96811f5074 Mon Sep 17 00:00:00 2001 From: michalsn Date: Thu, 15 Jan 2026 21:31:04 +0100 Subject: [PATCH 10/18] wip --- app/Config/WorkerMode.php | 4 ---- .../Worker/Views/frankenphp-worker.php.tpl | 23 ++++++------------- system/Debug/Timer.php | 9 -------- system/Events/Events.php | 3 ++- system/Log/Logger.php | 11 --------- user_guide_src/source/changelogs/v4.7.0.rst | 18 +++++++++++++-- .../source/installation/worker_mode.rst | 6 ++--- 7 files changed, 27 insertions(+), 47 deletions(-) diff --git a/app/Config/WorkerMode.php b/app/Config/WorkerMode.php index 5a51015277ec..029545a039ed 100644 --- a/app/Config/WorkerMode.php +++ b/app/Config/WorkerMode.php @@ -21,8 +21,6 @@ class WorkerMode * - `autoloader`: PSR-4 autoloading configuration * - `locator`: File locator * - `exceptions`: Exception handler - * - `logger`: Logger instance - * - `timer`: Performance timer * - `commands`: CLI commands registry * - `codeigniter`: Main application instance * - `superglobals`: Superglobals wrapper @@ -35,8 +33,6 @@ class WorkerMode 'autoloader', 'locator', 'exceptions', - 'logger', - 'timer', 'commands', 'codeigniter', 'superglobals', diff --git a/system/Commands/Worker/Views/frankenphp-worker.php.tpl b/system/Commands/Worker/Views/frankenphp-worker.php.tpl index ff6a2cfa8090..d42b15585e60 100644 --- a/system/Commands/Worker/Views/frankenphp-worker.php.tpl +++ b/system/Commands/Worker/Views/frankenphp-worker.php.tpl @@ -87,8 +87,11 @@ $handler = static function () use ($app, $workerConfig) { ->setFilesArray($_FILES) ->setRequestArray($_REQUEST); - // Handle the request - $app->run(); + try { + $app->run(); + } catch (Throwable $e) { + Services::exceptions()->exceptionHandler($e); + } if ($workerConfig->garbageCollection) { // Force garbage collection @@ -117,20 +120,8 @@ while (frankenphp_handle_request($handler)) { // Reset services except persistent ones Services::resetForWorkerMode($workerConfig); - // Reset performance - Events::cleanupForWorkerMode(); - - // Reset persistent services that accumulate state - if (Services::has('timer')) { - Services::timer()->reset(); - } - - if (Services::has('logger')) { - Services::logger()->reset(); - } - - // Reset debug toolbar collectors (development only) - if (CI_DEBUG && Services::has('toolbar')) { + if (CI_DEBUG) { + Events::cleanupForWorkerMode(); Services::toolbar()->reset(); } } diff --git a/system/Debug/Timer.php b/system/Debug/Timer.php index 19026d73380f..98b1299a89f7 100644 --- a/system/Debug/Timer.php +++ b/system/Debug/Timer.php @@ -148,13 +148,4 @@ public function record(string $name, callable $callable) return $returnValue; } - - /** - * Reset all timers for worker mode. - * Clears all timer data between requests. - */ - public function reset(): void - { - $this->timers = []; - } } diff --git a/system/Events/Events.php b/system/Events/Events.php index 3688cefdb039..979f531591fe 100644 --- a/system/Events/Events.php +++ b/system/Events/Events.php @@ -286,12 +286,13 @@ public static function getPerformanceLogs() } /** - * Cleanup performance log for worker mode. + * Cleanup performance log and request-specific listeners for worker mode. * * Called at the END of each request to clean up state. */ public static function cleanupForWorkerMode(): void { static::$performanceLog = []; + static::removeAllListeners('DBQuery'); } } diff --git a/system/Log/Logger.php b/system/Log/Logger.php index 405997d9e0a7..8308ddaf94f7 100644 --- a/system/Log/Logger.php +++ b/system/Log/Logger.php @@ -376,15 +376,4 @@ public function determineFile(): array return ['unknown', 'unknown']; } - - /** - * Reset log cache for worker mode. - * Clears cached log entries between requests. - */ - public function reset(): void - { - if ($this->cacheLogs) { - $this->logCache = []; - } - } } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index c1fe14c0fcb6..6b04637ba7a1 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -15,6 +15,7 @@ Highlights ********** - Update minimal PHP requirement to ``8.2``. +- Added experimental **FrankenPHP Worker Mode** support for improved performance. ******** BREAKING @@ -270,21 +271,27 @@ Libraries - **Image:** The ``ImageMagickHandler`` has been rewritten to rely solely on the PHP ``imagick`` extension. - **Image:** Added ``ImageMagickHandler::clearMetadata()`` method to remove image metadata for privacy protection. - **ResponseTrait:** Added ``paginate``` method to simplify paginated API responses. See :ref:`ResponseTrait::paginate() ` for details. -- **Session:** Added ``persistent`` config item to redis handler. +- **Session:** Added ``persistent`` config item to ``RedisHandler``. +- **Session:** Added connection persistence for Redis and Memcached session handlers via ``PersistsConnection`` trait, improving performance in worker mode by reusing connections across requests. - **Time:** added methods ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()`` - **Time:** Added ``Time::isPast()`` and ``Time::isFuture()`` convenience methods. See :ref:`isPast ` and :ref:`isFuture ` for details. -- **View:** Added the ability to override namespaced views (e.g., from modules/packages) by placing a matching file structure within the **app/Views/overrides** directory. See :ref:`Overriding Namespaced Views ` for details. - **Toolbar:** Fixed an issue where the Debug Toolbar was incorrectly injected into responses generated by third-party libraries (e.g., Dompdf) that use native PHP headers instead of the framework's Response object. +- **View:** Added the ability to override namespaced views (e.g., from modules/packages) by placing a matching file structure within the **app/Views/overrides** directory. See :ref:`Overriding Namespaced Views ` for details. +- **Worker Mode:** Added experimental FrankenPHP Worker Mode support, allowing CodeIgniter to handle multiple HTTP requests within the same PHP process for 2-3x performance improvements. See :doc:`Worker Mode ` for details. Commands ======== +- Added ``php spark worker:install`` command to generate FrankenPHP worker mode files (Caddyfile and worker entry point). +- Added ``php spark worker:uninstall`` command to remove worker mode files. + Testing ======= Database ======== +- **BaseConnection:** Added ``ping()`` method to check if the database connection is still alive. Postgre uses native ``pg_ping()``, other drivers use ``SELECT 1``. - **Exception Logging:** All DB drivers now log database exceptions uniformly. Previously, each driver had its own log format. Query Builder @@ -326,10 +333,17 @@ Message Changes Changes ******* +- **Boot:** Added ``Boot::bootWorker()`` method for one-time worker initialization. +- **CodeIgniter:** Added ``CodeIgniter::resetForWorkerMode()`` method to reset request-specific state between worker requests. +- **Config:** Added ``app/Config/WorkerMode.php`` for worker mode configuration (persistent services, garbage collection). - **Cookie:** The ``CookieInterface::EXPIRES_FORMAT`` has been changed to ``D, d M Y H:i:s T`` to follow the recommended format in RFC 7231. +- **DatabaseConfig:** Added ``Config::validateForWorkerMode()`` and ``Config::cleanupForWorkerMode()`` methods for database connection management in worker mode. +- **Events:** Added ``Events::cleanupForWorkerMode()`` method. - **Format:** Added support for configuring ``json_encode()`` maximum depth via ``Config\Format::$jsonEncodeDepth``. - **Paths:** Added support for changing the location of the ``.env`` file via the ``Paths::$envDirectory`` property. +- **Services:** Added ``Services::resetForWorkerMode()`` and ``Services::validateForWorkerMode()`` methods. - **Toolbar:** Added ``$disableOnHeaders`` property to **app/Config/Toolbar.php**. +- **Toolbar:** Added ``Toolbar::reset()`` method to reset debug toolbar collectors. ************ Deprecations diff --git a/user_guide_src/source/installation/worker_mode.rst b/user_guide_src/source/installation/worker_mode.rst index c57f334163ab..d90062c9742b 100644 --- a/user_guide_src/source/installation/worker_mode.rst +++ b/user_guide_src/source/installation/worker_mode.rst @@ -91,9 +91,9 @@ Getting Started Installation ============ -1. Install FrankenPHP following the `official documentation `_ +1. Install FrankenPHP following the `official documentation `_ - You can use a static binary or Docker. Here we will assume we use a static binary, + You can use a static binary or Docker. Here, we will assume we use a static binary, which can be `downloaded directly `_. 2. Install the Worker Mode template files using the spark command: @@ -203,8 +203,6 @@ Service Purpose ``autoloader`` PSR-4 autoloading configuration. Safe to persist as class maps don't change. ``locator`` File locator for finding framework files. Caches file paths for performance. ``exceptions`` Exception handler. Stateless, safe to reuse. -``logger`` Logger instance. Maintains file handles for efficient logging. -``timer`` Performance timer. Reset internally per request. ``commands`` CLI commands registry. Only used during worker startup. ``codeigniter`` Main application instance. Orchestrates the request/response cycle. ``superglobals`` Superglobals wrapper. Properly isolated per request internally. From 71760f6f3331c84975bf0cf7c576cf60169bc397 Mon Sep 17 00:00:00 2001 From: michalsn Date: Thu, 15 Jan 2026 22:33:08 +0100 Subject: [PATCH 11/18] wip --- system/CodeIgniter.php | 5 -- system/Database/Config.php | 14 +++-- tests/system/CommonSingleServiceTest.php | 3 + tests/system/Database/ConfigTest.php | 27 -------- tests/system/Database/Live/WorkerModeTest.php | 61 +++++++++++++++++++ .../source/installation/worker_mode.rst | 30 ++++----- 6 files changed, 88 insertions(+), 52 deletions(-) create mode 100644 tests/system/Database/Live/WorkerModeTest.php diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index 50c785640ba7..4050f4c139d5 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -208,11 +208,6 @@ public function resetForWorkerMode(): void // Reset timing $this->startTime = null; $this->totalTime = 0; - - // Clean any leftover output buffers (safety measure for errors) - while (ob_get_level() > 0) { - ob_end_clean(); - } } /** diff --git a/system/Database/Config.php b/system/Database/Config.php index 41e1617eac36..56b856580bda 100644 --- a/system/Database/Config.php +++ b/system/Database/Config.php @@ -176,8 +176,8 @@ public static function validateForWorkerMode(): void /** * Cleanup database connections for worker mode. * - * If any uncommitted transactions are found, the connection - * is closed and reconnected to ensure a clean state for the next request. + * Rolls back any uncommitted transactions and resets transaction status + * to ensure a clean state for the next request. * * Uncommitted transactions at this point indicate a bug in the * application code (transactions should be completed before request ends). @@ -188,10 +188,14 @@ public static function cleanupForWorkerMode(): void { foreach (static::$instances as $group => $connection) { if ($connection->transDepth > 0) { - log_message('error', "Uncommitted transaction detected in database group '{$group}'. Transactions must be completed before request ends. Reconnecting to ensure clean state."); - $connection->close(); - $connection->initialize(); + log_message('error', "Uncommitted transaction detected in database group '{$group}'. Transactions must be completed before request ends."); + + while ($connection->transDepth > 0) { + $connection->transRollback(); + } } + + $connection->resetTransStatus(); } } } diff --git a/tests/system/CommonSingleServiceTest.php b/tests/system/CommonSingleServiceTest.php index 014ac12a27fc..f96ee6673053 100644 --- a/tests/system/CommonSingleServiceTest.php +++ b/tests/system/CommonSingleServiceTest.php @@ -91,9 +91,12 @@ public static function provideServiceNames(): iterable 'reset', 'resetSingle', 'resetServicesCache', + 'resetForWorkerMode', 'injectMock', + 'has', 'encrypter', // Encrypter needs a starter key 'session', // Headers already sent + 'validateForWorkerMode', ]; if ($services === []) { diff --git a/tests/system/Database/ConfigTest.php b/tests/system/Database/ConfigTest.php index a02c19604833..1d2482e0ad87 100644 --- a/tests/system/Database/ConfigTest.php +++ b/tests/system/Database/ConfigTest.php @@ -239,31 +239,4 @@ public static function provideConvertDSN(): iterable ], ]; } - - #[Group('DatabaseLive')] - public function testResetForWorkerMode(): void - { - $conn = Config::connect(); - $this->assertInstanceOf(BaseConnection::class, $conn); - - $conn->transStart(); - $this->assertGreaterThan(0, $conn->transDepth); - - Config::cleanupForWorkerMode(); - - $this->assertSame(0, $conn->transDepth); - $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); - } - - #[Group('DatabaseLive')] - public function testValidateForWorkerMode(): void - { - $conn = Config::connect(); - $this->assertInstanceOf(BaseConnection::class, $conn); - $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); - - Config::validateForWorkerMode(); - - $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); - } } diff --git a/tests/system/Database/Live/WorkerModeTest.php b/tests/system/Database/Live/WorkerModeTest.php new file mode 100644 index 000000000000..d1ef35b9f024 --- /dev/null +++ b/tests/system/Database/Live/WorkerModeTest.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Live; + +use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\Config; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('DatabaseLive')] +final class WorkerModeTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected function tearDown(): void + { + parent::tearDown(); + + $this->setPrivateProperty(Config::class, 'instances', []); + } + + public function testCleanupForWorkerMode(): void + { + $conn = Config::connect(); + $this->assertInstanceOf(BaseConnection::class, $conn); + + $conn->transStart(); + $this->assertGreaterThan(0, $conn->transDepth); + + Config::cleanupForWorkerMode(); + + $this->assertSame(0, $conn->transDepth); + $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); + } + + public function testValidateForWorkerMode(): void + { + $conn = Config::connect(); + $this->assertInstanceOf(BaseConnection::class, $conn); + $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); + + Config::validateForWorkerMode(); + + $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); + } +} diff --git a/user_guide_src/source/installation/worker_mode.rst b/user_guide_src/source/installation/worker_mode.rst index d90062c9742b..a7f070df416d 100644 --- a/user_guide_src/source/installation/worker_mode.rst +++ b/user_guide_src/source/installation/worker_mode.rst @@ -184,8 +184,8 @@ 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', 'logger', 'timer', - 'commands', 'codeigniter', 'superglobals', 'routes', 'cache']`` + Default: ``['autoloader', 'locator', 'exceptions', 'commands', + 'codeigniter', 'superglobals', 'routes', 'cache']`` **$garbageCollection** bool Whether to force garbage collection after each request. ``true`` (default, recommended): Prevents memory leaks. ``false``: Relies on PHP's automatic garbage collection. @@ -197,18 +197,18 @@ Persistent Services The ``$persistentServices`` array controls which services survive between requests. The default configuration includes: -===================== ========================================================================== -Service Purpose -===================== ========================================================================== -``autoloader`` PSR-4 autoloading configuration. Safe to persist as class maps don't change. -``locator`` File locator for finding framework files. Caches file paths for performance. -``exceptions`` Exception handler. Stateless, safe to reuse. -``commands`` CLI commands registry. Only used during worker startup. -``codeigniter`` Main application instance. Orchestrates the request/response cycle. -``superglobals`` Superglobals wrapper. Properly isolated per request internally. -``routes`` Router configuration. Route definitions don't change between requests. -``cache`` Cache service. Maintains connections to cache backends (Redis, Memcached). -===================== ========================================================================== +================ ========================================================================== +Service Purpose +================ ========================================================================== +``autoloader`` PSR-4 autoloading configuration. Safe to persist as class maps don't change. +``locator`` File locator for finding framework files. Caches file paths for performance. +``exceptions`` Exception handler. Stateless, safe to reuse. +``commands`` CLI commands registry. Only used during worker startup. +``codeigniter`` Main application instance. Orchestrates the request/response cycle. +``superglobals`` Superglobals wrapper. Properly isolated per request internally. +``routes`` Router configuration. Route definitions don't change between requests. +``cache`` Cache service. Maintains connections to cache backends (Redis, Memcached). +================ ========================================================================== .. warning:: Adding services to ``$persistentServices`` without understanding their state management can cause data leakage between requests. Only persist services @@ -304,4 +304,4 @@ Database connections are automatically managed: - Connections persist across requests for performance - Connections are validated at the start of each request - Failed connections are automatically re-established -- Uncommitted transactions trigger a warning and reconnection to ensure clean state +- Uncommitted transactions are automatically rolled back with a warning logged From c97febd1f512efb788a6c424cfa06e60984db2bd Mon Sep 17 00:00:00 2001 From: michalsn Date: Thu, 15 Jan 2026 22:54:39 +0100 Subject: [PATCH 12/18] wip --- tests/system/Config/ServicesTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/system/Config/ServicesTest.php b/tests/system/Config/ServicesTest.php index eb1fc9ded140..dcc420a0214f 100644 --- a/tests/system/Config/ServicesTest.php +++ b/tests/system/Config/ServicesTest.php @@ -533,7 +533,7 @@ public function testResetForWorkerMode(): void Services::resetForWorkerMode($config); - $this->assertTrue(Services::has('cache')); + $this->assertFalse(Services::has('cache')); $this->assertTrue(Services::has('timer')); $this->assertFalse(Services::has('typography')); } @@ -554,6 +554,7 @@ public function testValidateForWorkerMode(): void #[RunInSeparateProcess] public function testValidateForWorkerModeWithNoCache(): void { + Services::resetSingle('cache'); $this->assertFalse(Services::has('cache')); Services::validateForWorkerMode(); From 52af1bab28a0476353a8bf5e2f1b5c1f80a9b2a5 Mon Sep 17 00:00:00 2001 From: michalsn Date: Fri, 16 Jan 2026 07:32:10 +0100 Subject: [PATCH 13/18] wip --- system/Database/Config.php | 4 +--- system/Database/MySQLi/Connection.php | 6 ++++-- system/Database/OCI8/Connection.php | 18 ++++++++++++++++++ system/Database/Postgre/Connection.php | 2 +- system/Database/SQLSRV/Connection.php | 6 ++++-- system/Database/SQLite3/Connection.php | 6 ++++-- user_guide_src/source/changelogs/v4.7.0.rst | 3 ++- user_guide_src/source/database/connecting.rst | 10 +++++----- 8 files changed, 39 insertions(+), 16 deletions(-) diff --git a/system/Database/Config.php b/system/Database/Config.php index 56b856580bda..ab22ab531793 100644 --- a/system/Database/Config.php +++ b/system/Database/Config.php @@ -167,9 +167,7 @@ public static function validateForWorkerMode(): void } foreach (static::$instances as $connection) { - if (! $connection->ping()) { - $connection->reconnect(); - } + $connection->reconnect(); } } diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index 43303ed5441c..21d298819723 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -257,8 +257,10 @@ public function connect(bool $persistent = false) */ public function reconnect() { - $this->close(); - $this->initialize(); + if ($this->ping() === false) { + $this->close(); + $this->initialize(); + } } /** diff --git a/system/Database/OCI8/Connection.php b/system/Database/OCI8/Connection.php index 69c3a0d8eee2..7c5d03c6418d 100644 --- a/system/Database/OCI8/Connection.php +++ b/system/Database/OCI8/Connection.php @@ -158,6 +158,10 @@ public function connect(bool $persistent = false) */ public function reconnect() { + if ($this->ping() === false) { + $this->close(); + $this->initialize(); + } } /** @@ -176,6 +180,20 @@ protected function _close() oci_close($this->connID); } + /** + * Ping the database connection. + */ + protected function _ping(): bool + { + try { + $result = $this->simpleQuery('SELECT 1 FROM DUAL'); + + return $result !== false; + } catch (DatabaseException) { + return false; + } + } + /** * Select a specific database table to use. */ diff --git a/system/Database/Postgre/Connection.php b/system/Database/Postgre/Connection.php index 4dc9127ac311..71841601c25b 100644 --- a/system/Database/Postgre/Connection.php +++ b/system/Database/Postgre/Connection.php @@ -150,7 +150,7 @@ private function convertDSN() */ public function reconnect() { - if ($this->connID === false || pg_ping($this->connID) === false) { + if ($this->ping() === false) { $this->close(); $this->initialize(); } diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php index 7e6d0810a3ad..3e28636541c9 100644 --- a/system/Database/SQLSRV/Connection.php +++ b/system/Database/SQLSRV/Connection.php @@ -174,8 +174,10 @@ public function getAllErrorMessages(): string */ public function reconnect() { - $this->close(); - $this->initialize(); + if ($this->ping() === false) { + $this->close(); + $this->initialize(); + } } /** diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index c61bb6c42b49..6c2bc34caf89 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -127,8 +127,10 @@ public function connect(bool $persistent = false) */ public function reconnect() { - $this->close(); - $this->initialize(); + if ($this->ping() === false) { + $this->close(); + $this->initialize(); + } } /** diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 6b04637ba7a1..5669d25ae56d 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -291,7 +291,8 @@ Testing Database ======== -- **BaseConnection:** Added ``ping()`` method to check if the database connection is still alive. Postgre uses native ``pg_ping()``, other drivers use ``SELECT 1``. +- **BaseConnection:** Added ``ping()`` method to check if the database connection is still alive. OCI8 uses ``SELECT 1 FROM DUAL``, other drivers use ``SELECT 1``. +- **BaseConnection:** The ``reconnect()`` method now uses ``ping()`` to check if the connection is alive before reconnecting. Previously, some drivers would always close and reconnect unconditionally, and OCI8's ``reconnect()`` did nothing. - **Exception Logging:** All DB drivers now log database exceptions uniformly. Previously, each driver had its own log format. Query Builder diff --git a/user_guide_src/source/database/connecting.rst b/user_guide_src/source/database/connecting.rst index d30fe155295b..65588dca4187 100644 --- a/user_guide_src/source/database/connecting.rst +++ b/user_guide_src/source/database/connecting.rst @@ -94,12 +94,12 @@ Reconnecting / Keeping the Connection Alive If the database server's idle timeout is exceeded while you're doing some heavy PHP lifting (processing an image, for instance), you should -consider pinging the server by using the ``reconnect()`` method before -sending further queries, which can gracefully keep the connection alive -or re-establish it. +consider calling the ``reconnect()`` method before sending further queries, +which can gracefully keep the connection alive or re-establish it. -.. important:: If you are using MySQLi database driver, the ``reconnect()`` method - does not ping the server but it closes the connection then connects again. +The ``reconnect()`` method pings the server to check if the connection is still +alive. If the connection has been lost, it will close and re-establish the +connection. .. literalinclude:: connecting/007.php :lines: 2- From d30bb21cbb63f4fa0956af69827e986076783d1a Mon Sep 17 00:00:00 2001 From: michalsn Date: Fri, 16 Jan 2026 07:50:10 +0100 Subject: [PATCH 14/18] wip --- system/Database/BaseConnection.php | 14 ++++++++++++++ system/Database/MySQLi/Connection.php | 14 -------------- system/Database/OCI8/Connection.php | 14 -------------- system/Database/Postgre/Connection.php | 14 -------------- system/Database/SQLSRV/Connection.php | 14 -------------- system/Database/SQLite3/Connection.php | 14 -------------- tests/system/Database/Live/PingTest.php | 3 ++- 7 files changed, 16 insertions(+), 71 deletions(-) diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index 2707c4d49bfe..af3d13f3571c 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -486,6 +486,20 @@ public function close() } } + /** + * Keep or establish the connection if no queries have been sent for + * a length of time exceeding the server's idle timeout. + * + * @return void + */ + public function reconnect() + { + if ($this->ping() === false) { + $this->close(); + $this->initialize(); + } + } + /** * Platform dependent way method for closing the connection. * diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index 21d298819723..5c11f6eda53a 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -249,20 +249,6 @@ public function connect(bool $persistent = false) return false; } - /** - * Keep or establish the connection if no queries have been sent for - * a length of time exceeding the server's idle timeout. - * - * @return void - */ - public function reconnect() - { - if ($this->ping() === false) { - $this->close(); - $this->initialize(); - } - } - /** * Close the database connection. * diff --git a/system/Database/OCI8/Connection.php b/system/Database/OCI8/Connection.php index 7c5d03c6418d..dc884588a251 100644 --- a/system/Database/OCI8/Connection.php +++ b/system/Database/OCI8/Connection.php @@ -150,20 +150,6 @@ public function connect(bool $persistent = false) : $func($this->username, $this->password, $this->DSN, $this->charset); } - /** - * Keep or establish the connection if no queries have been sent for - * a length of time exceeding the server's idle timeout. - * - * @return void - */ - public function reconnect() - { - if ($this->ping() === false) { - $this->close(); - $this->initialize(); - } - } - /** * Close the database connection. * diff --git a/system/Database/Postgre/Connection.php b/system/Database/Postgre/Connection.php index 71841601c25b..295616ab035d 100644 --- a/system/Database/Postgre/Connection.php +++ b/system/Database/Postgre/Connection.php @@ -142,20 +142,6 @@ private function convertDSN() $this->DSN = $output; } - /** - * Keep or establish the connection if no queries have been sent for - * a length of time exceeding the server's idle timeout. - * - * @return void - */ - public function reconnect() - { - if ($this->ping() === false) { - $this->close(); - $this->initialize(); - } - } - /** * Close the database connection. * diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php index 3e28636541c9..76dd80bdb09f 100644 --- a/system/Database/SQLSRV/Connection.php +++ b/system/Database/SQLSRV/Connection.php @@ -166,20 +166,6 @@ public function getAllErrorMessages(): string return implode("\n", $errors); } - /** - * Keep or establish the connection if no queries have been sent for - * a length of time exceeding the server's idle timeout. - * - * @return void - */ - public function reconnect() - { - if ($this->ping() === false) { - $this->close(); - $this->initialize(); - } - } - /** * Close the database connection. * diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index 6c2bc34caf89..3865669a9e2d 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -119,20 +119,6 @@ public function connect(bool $persistent = false) } } - /** - * Keep or establish the connection if no queries have been sent for - * a length of time exceeding the server's idle timeout. - * - * @return void - */ - public function reconnect() - { - if ($this->ping() === false) { - $this->close(); - $this->initialize(); - } - } - /** * Close the database connection. * diff --git a/tests/system/Database/Live/PingTest.php b/tests/system/Database/Live/PingTest.php index 8e10c9d85b2a..0982742b4e89 100644 --- a/tests/system/Database/Live/PingTest.php +++ b/tests/system/Database/Live/PingTest.php @@ -60,7 +60,8 @@ public function testPingAfterReconnect(): void public function testPingCanBeUsedToCheckConnectionBeforeQuery(): void { if ($this->db->ping()) { - $result = $this->db->query('SELECT 1'); + $sql = $this->db->DBDriver === 'OCI8' ? 'SELECT 1 FROM DUAL' : 'SELECT 1'; + $result = $this->db->query($sql); $this->assertNotFalse($result); } else { $this->fail('Connection should be alive'); From 3513359db8c493a496ebbf389480ba10347d5a2f Mon Sep 17 00:00:00 2001 From: michalsn Date: Fri, 16 Jan 2026 07:56:32 +0100 Subject: [PATCH 15/18] wip --- tests/system/Database/Live/PingTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/system/Database/Live/PingTest.php b/tests/system/Database/Live/PingTest.php index 0982742b4e89..4271f4f57a2e 100644 --- a/tests/system/Database/Live/PingTest.php +++ b/tests/system/Database/Live/PingTest.php @@ -60,7 +60,7 @@ public function testPingAfterReconnect(): void public function testPingCanBeUsedToCheckConnectionBeforeQuery(): void { if ($this->db->ping()) { - $sql = $this->db->DBDriver === 'OCI8' ? 'SELECT 1 FROM DUAL' : 'SELECT 1'; + $sql = $this->db->DBDriver === 'OCI8' ? 'SELECT 1 FROM DUAL' : 'SELECT 1'; $result = $this->db->query($sql); $this->assertNotFalse($result); } else { From 5af8efd19ed5789d42d671ba166e18a844092ea1 Mon Sep 17 00:00:00 2001 From: michalsn Date: Fri, 16 Jan 2026 22:15:55 +0100 Subject: [PATCH 16/18] wip --- system/Commands/Worker/Views/Caddyfile.tpl | 2 +- system/Commands/Worker/WorkerInstall.php | 4 ---- tests/system/Commands/WorkerCommandsTest.php | 2 +- user_guide_src/source/changelogs/v4.7.0.rst | 1 - 4 files changed, 2 insertions(+), 7 deletions(-) diff --git a/system/Commands/Worker/Views/Caddyfile.tpl b/system/Commands/Worker/Views/Caddyfile.tpl index 7984d582de49..08ab621ac7f8 100644 --- a/system/Commands/Worker/Views/Caddyfile.tpl +++ b/system/Commands/Worker/Views/Caddyfile.tpl @@ -15,7 +15,7 @@ # Number of workers (default: 2x CPU cores) # Adjust based on your server capacity - num 16 + # num 16 } } diff --git a/system/Commands/Worker/WorkerInstall.php b/system/Commands/Worker/WorkerInstall.php index dd177ac2c6a7..99afdc9f69eb 100644 --- a/system/Commands/Worker/WorkerInstall.php +++ b/system/Commands/Worker/WorkerInstall.php @@ -134,9 +134,5 @@ protected function showNextSteps(): void CLI::write('2. Test your application:', 'white'); CLI::write(' curl http://localhost:8080/', 'green'); CLI::newLine(); - - CLI::write('Documentation:', 'yellow'); - CLI::write(' https://frankenphp.dev/docs/worker/', 'blue'); - CLI::newLine(); } } diff --git a/tests/system/Commands/WorkerCommandsTest.php b/tests/system/Commands/WorkerCommandsTest.php index 03bd2023f9d9..28aea6b77a94 100644 --- a/tests/system/Commands/WorkerCommandsTest.php +++ b/tests/system/Commands/WorkerCommandsTest.php @@ -100,7 +100,7 @@ public function testWorkerInstallShowsNextSteps(): void $output = $this->getStreamFilterBuffer(); $this->assertStringContainsString('Next Steps:', $output); $this->assertStringContainsString('frankenphp run', $output); - $this->assertStringContainsString('https://frankenphp.dev/docs/worker/', $output); + $this->assertStringContainsString('http://localhost:8080/', $output); } public function testWorkerUninstallRemovesFiles(): void diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 5669d25ae56d..9ae8afac510f 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -277,7 +277,6 @@ Libraries - **Time:** Added ``Time::isPast()`` and ``Time::isFuture()`` convenience methods. See :ref:`isPast ` and :ref:`isFuture ` for details. - **Toolbar:** Fixed an issue where the Debug Toolbar was incorrectly injected into responses generated by third-party libraries (e.g., Dompdf) that use native PHP headers instead of the framework's Response object. - **View:** Added the ability to override namespaced views (e.g., from modules/packages) by placing a matching file structure within the **app/Views/overrides** directory. See :ref:`Overriding Namespaced Views ` for details. -- **Worker Mode:** Added experimental FrankenPHP Worker Mode support, allowing CodeIgniter to handle multiple HTTP requests within the same PHP process for 2-3x performance improvements. See :doc:`Worker Mode ` for details. Commands ======== From aef426e573495bb636d4cf3fdd938106c9dd4121 Mon Sep 17 00:00:00 2001 From: michalsn Date: Mon, 19 Jan 2026 15:39:32 +0100 Subject: [PATCH 17/18] apply code suggestions Co-authored-by: John Paul E. Balandan, CPA --- app/Config/WorkerMode.php | 4 ++-- system/Cache/Handlers/PredisHandler.php | 2 +- .../Worker/Views/frankenphp-worker.php.tpl | 10 ++++---- system/Config/BaseService.php | 4 ++-- system/Database/Config.php | 5 ++-- tests/system/Commands/WorkerCommandsTest.php | 2 +- tests/system/CommonSingleServiceTest.php | 2 +- tests/system/Config/ServicesTest.php | 8 +++---- tests/system/Database/Live/WorkerModeTest.php | 4 ++-- user_guide_src/source/changelogs/v4.7.0.rst | 4 ++-- .../source/installation/worker_mode.rst | 24 +++++++++---------- 11 files changed, 34 insertions(+), 35 deletions(-) diff --git a/app/Config/WorkerMode.php b/app/Config/WorkerMode.php index 029545a039ed..1c005f63a67a 100644 --- a/app/Config/WorkerMode.php +++ b/app/Config/WorkerMode.php @@ -41,10 +41,10 @@ class WorkerMode ]; /** - * Garbage Collection + * Force Garbage Collection * * Whether to force garbage collection after each request. * Helps prevent memory leaks at a small performance cost. */ - public bool $garbageCollection = true; + public bool $forceGarbageCollection = true; } diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index fff4bbe79596..8919c7a1db2b 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -224,7 +224,7 @@ public function reconnect(): bool { try { $this->redis->disconnect(); - } catch (Exception $e) { + } catch (Exception) { // Connection already dead, that's fine } diff --git a/system/Commands/Worker/Views/frankenphp-worker.php.tpl b/system/Commands/Worker/Views/frankenphp-worker.php.tpl index d42b15585e60..229a3bcdd6b4 100644 --- a/system/Commands/Worker/Views/frankenphp-worker.php.tpl +++ b/system/Commands/Worker/Views/frankenphp-worker.php.tpl @@ -69,11 +69,11 @@ $workerConfig = config('WorkerMode'); */ $handler = static function () use ($app, $workerConfig) { - // Validate database connections before handling request - DatabaseConfig::validateForWorkerMode(); + // Reconnect database connections before handling request + DatabaseConfig::reconnectForWorkerMode(); - // Validate cache connection before handling request - Services::validateForWorkerMode(); + // Reconnect cache connection before handling request + Services::reconnectCacheForWorkerMode(); // Reset request-specific state $app->resetForWorkerMode(); @@ -93,7 +93,7 @@ $handler = static function () use ($app, $workerConfig) { Services::exceptions()->exceptionHandler($e); } - if ($workerConfig->garbageCollection) { + if ($workerConfig->forceGarbageCollection) { // Force garbage collection gc_collect_cycles(); } diff --git a/system/Config/BaseService.php b/system/Config/BaseService.php index 1cd4aaa4ba80..483da962d5ad 100644 --- a/system/Config/BaseService.php +++ b/system/Config/BaseService.php @@ -375,13 +375,13 @@ public static function reset(bool $initAutoloader = true) } /** - * Validate cache connections for worker mode at the start of a request. + * Reconnect cache connection for worker mode at the start of a request. * Checks if cache connection is alive and reconnects if needed. * * This should be called at the beginning of each request in worker mode, * before the application runs. */ - public static function validateForWorkerMode(): void + public static function reconnectCacheForWorkerMode(): void { if (! isset(static::$instances['cache'])) { return; diff --git a/system/Database/Config.php b/system/Database/Config.php index ab22ab531793..42f991b3f30b 100644 --- a/system/Database/Config.php +++ b/system/Database/Config.php @@ -154,13 +154,12 @@ protected static function ensureFactory() } /** - * Validate database connections for worker mode at the start of a request. - * Checks if connections are alive and reconnects if needed. + * Reconnect database connections for worker mode at the start of a request. * * This should be called at the beginning of each request in worker mode, * before the application runs. */ - public static function validateForWorkerMode(): void + public static function reconnectForWorkerMode(): void { if (static::$instances === []) { return; diff --git a/tests/system/Commands/WorkerCommandsTest.php b/tests/system/Commands/WorkerCommandsTest.php index 28aea6b77a94..be0880f9157d 100644 --- a/tests/system/Commands/WorkerCommandsTest.php +++ b/tests/system/Commands/WorkerCommandsTest.php @@ -163,7 +163,7 @@ public function testWorkerInstallCreatesValidPHPFile(): void $this->assertStringStartsWith('assertStringContainsString('frankenphp_handle_request', (string) $content); - $this->assertStringContainsString('DatabaseConfig::validateForWorkerMode', (string) $content); + $this->assertStringContainsString('DatabaseConfig::reconnectForWorkerMode', (string) $content); $this->assertStringContainsString('DatabaseConfig::cleanupForWorkerMode', (string) $content); } diff --git a/tests/system/CommonSingleServiceTest.php b/tests/system/CommonSingleServiceTest.php index f96ee6673053..e423d1feaee0 100644 --- a/tests/system/CommonSingleServiceTest.php +++ b/tests/system/CommonSingleServiceTest.php @@ -96,7 +96,7 @@ public static function provideServiceNames(): iterable 'has', 'encrypter', // Encrypter needs a starter key 'session', // Headers already sent - 'validateForWorkerMode', + 'reconnectCacheForWorkerMode', ]; if ($services === []) { diff --git a/tests/system/Config/ServicesTest.php b/tests/system/Config/ServicesTest.php index dcc420a0214f..7eef9818a727 100644 --- a/tests/system/Config/ServicesTest.php +++ b/tests/system/Config/ServicesTest.php @@ -540,24 +540,24 @@ public function testResetForWorkerMode(): void #[PreserveGlobalState(false)] #[RunInSeparateProcess] - public function testValidateForWorkerMode(): void + public function testReconnectCacheForWorkerMode(): void { Services::cache(); $this->assertTrue(Services::has('cache')); - Services::validateForWorkerMode(); + Services::reconnectCacheForWorkerMode(); $this->assertTrue(Services::has('cache')); } #[PreserveGlobalState(false)] #[RunInSeparateProcess] - public function testValidateForWorkerModeWithNoCache(): void + public function testReconnectCacheForWorkerModeWithNoCache(): void { Services::resetSingle('cache'); $this->assertFalse(Services::has('cache')); - Services::validateForWorkerMode(); + Services::reconnectCacheForWorkerMode(); $this->assertFalse(Services::has('cache')); } diff --git a/tests/system/Database/Live/WorkerModeTest.php b/tests/system/Database/Live/WorkerModeTest.php index d1ef35b9f024..a8c77d756da7 100644 --- a/tests/system/Database/Live/WorkerModeTest.php +++ b/tests/system/Database/Live/WorkerModeTest.php @@ -48,13 +48,13 @@ public function testCleanupForWorkerMode(): void $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); } - public function testValidateForWorkerMode(): void + public function testReconnectForWorkerMode(): void { $conn = Config::connect(); $this->assertInstanceOf(BaseConnection::class, $conn); $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); - Config::validateForWorkerMode(); + Config::reconnectForWorkerMode(); $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 9ae8afac510f..7acffa3fb013 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -337,11 +337,11 @@ Changes - **CodeIgniter:** Added ``CodeIgniter::resetForWorkerMode()`` method to reset request-specific state between worker requests. - **Config:** Added ``app/Config/WorkerMode.php`` for worker mode configuration (persistent services, garbage collection). - **Cookie:** The ``CookieInterface::EXPIRES_FORMAT`` has been changed to ``D, d M Y H:i:s T`` to follow the recommended format in RFC 7231. -- **DatabaseConfig:** Added ``Config::validateForWorkerMode()`` and ``Config::cleanupForWorkerMode()`` methods for database connection management in worker mode. +- **DatabaseConfig:** Added ``Config::reconnectForWorkerMode()`` and ``Config::cleanupForWorkerMode()`` methods for database connection management in worker mode. - **Events:** Added ``Events::cleanupForWorkerMode()`` method. - **Format:** Added support for configuring ``json_encode()`` maximum depth via ``Config\Format::$jsonEncodeDepth``. - **Paths:** Added support for changing the location of the ``.env`` file via the ``Paths::$envDirectory`` property. -- **Services:** Added ``Services::resetForWorkerMode()`` and ``Services::validateForWorkerMode()`` methods. +- **Services:** Added ``Services::resetForWorkerMode()`` and ``Services::reconnectCacheForWorkerMode()`` methods. - **Toolbar:** Added ``$disableOnHeaders`` property to **app/Config/Toolbar.php**. - **Toolbar:** Added ``Toolbar::reset()`` method to reset debug toolbar collectors. diff --git a/user_guide_src/source/installation/worker_mode.rst b/user_guide_src/source/installation/worker_mode.rst index a7f070df416d..4dea3d1aad32 100644 --- a/user_guide_src/source/installation/worker_mode.rst +++ b/user_guide_src/source/installation/worker_mode.rst @@ -179,17 +179,17 @@ All worker mode configuration is managed through the **app/Config/WorkerMode.php Configuration Options ===================== -======================= ======= ========================================================================== -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']`` -**$garbageCollection** bool Whether to force garbage collection after each request. - ``true`` (default, recommended): Prevents memory leaks. - ``false``: Relies on PHP's automatic garbage collection. -======================= ======= ========================================================================== +=========================== ======= ====================================================================== +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']`` +**$forceGarbageCollection** bool Whether to force garbage collection after each request. + ``true`` (default, recommended): Prevents memory leaks. + ``false``: Relies on PHP's automatic garbage collection. +=========================== ======= ====================================================================== Persistent Services =================== @@ -273,7 +273,7 @@ Monitor memory usage: If memory grows continuously: -- Verify garbage collection is enabled (``$garbageCollection = true``) +- Verify garbage collection is enabled (``$forceGarbageCollection = true``) - Ensure resources are properly closed after use - Unset large objects when no longer needed - Check for circular references that prevent garbage collection From f5399d9db8f7d4d47c1ec20470ad3e9a251a3e29 Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 20 Jan 2026 08:01:14 +0100 Subject: [PATCH 18/18] mention spark optimize restrictions for worker mode --- app/Config/Optimize.php | 2 ++ user_guide_src/source/concepts/autoloader.rst | 2 ++ user_guide_src/source/concepts/factories.rst | 26 +------------------ .../source/installation/deployment.rst | 2 ++ 4 files changed, 7 insertions(+), 25 deletions(-) diff --git a/app/Config/Optimize.php b/app/Config/Optimize.php index 481e645f57b2..8b93eb1c9ffb 100644 --- a/app/Config/Optimize.php +++ b/app/Config/Optimize.php @@ -7,6 +7,8 @@ * * NOTE: This class does not extend BaseConfig for performance reasons. * So you cannot replace the property values with Environment Variables. + * + * WARNING: Do not use these options when running the app in the Worker Mode. */ class Optimize { diff --git a/user_guide_src/source/concepts/autoloader.rst b/user_guide_src/source/concepts/autoloader.rst index 6391148323c8..519b07342d76 100644 --- a/user_guide_src/source/concepts/autoloader.rst +++ b/user_guide_src/source/concepts/autoloader.rst @@ -212,3 +212,5 @@ Or you can enable it with the ``spark optimize`` command. .. note:: This property cannot be overridden by :ref:`environment variables `. + +.. warning:: Do not use this option when running the app in the :doc:`Worker Mode `. diff --git a/user_guide_src/source/concepts/factories.rst b/user_guide_src/source/concepts/factories.rst index aaf7df459c6d..f715fbd3ad0c 100644 --- a/user_guide_src/source/concepts/factories.rst +++ b/user_guide_src/source/concepts/factories.rst @@ -340,28 +340,4 @@ Or you can enable this with the ``spark optimize`` command. This property cannot be overridden by :ref:`environment variables `. -.. note:: - In v4.4.x, uncomment the following code in **public/index.php**:: - - --- a/public/index.php - +++ b/public/index.php - @@ -49,8 +49,8 @@ if (! defined('ENVIRONMENT')) { - } - - // Load Config Cache - -// $factoriesCache = new \CodeIgniter\Cache\FactoriesCache(); - -// $factoriesCache->load('config'); - +$factoriesCache = new \CodeIgniter\Cache\FactoriesCache(); - +$factoriesCache->load('config'); - // ^^^ Uncomment these lines if you want to use Config Caching. - - /* - @@ -79,7 +79,7 @@ $app->setContext($context); - $app->run(); - - // Save Config Cache - -// $factoriesCache->save('config'); - +$factoriesCache->save('config'); - // ^^^ Uncomment this line if you want to use Config Caching. - - // Exits the application, setting the exit code for CLI-based applications +.. warning:: Do not use this option when running the app in the :doc:`Worker Mode `. diff --git a/user_guide_src/source/installation/deployment.rst b/user_guide_src/source/installation/deployment.rst index 0eb206ee9d04..e6dc702b60a4 100644 --- a/user_guide_src/source/installation/deployment.rst +++ b/user_guide_src/source/installation/deployment.rst @@ -28,6 +28,8 @@ The ``spark optimize`` command performs the following optimizations: - Enabling `Config Caching`_ - Enabling `FileLocator Caching`_ +.. warning:: Do not use ``spark optimize`` when running the app in the :doc:`worker_mode`. + Composer Optimization =====================