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/app/Config/WorkerMode.php b/app/Config/WorkerMode.php new file mode 100644 index 000000000000..1c005f63a67a --- /dev/null +++ b/app/Config/WorkerMode.php @@ -0,0 +1,50 @@ + + */ + public array $persistentServices = [ + 'autoloader', + 'locator', + 'exceptions', + 'commands', + 'codeigniter', + 'superglobals', + 'routes', + 'cache', + ]; + + /** + * Force Garbage Collection + * + * Whether to force garbage collection after each request. + * Helps prevent memory leaks at a small performance cost. + */ + public bool $forceGarbageCollection = 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/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 14bc8e80a144..10e2ecae0a96 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,46 @@ public function isSupported(): bool { return extension_loaded('memcached') || extension_loaded('memcache'); } + + public function ping(): bool + { + $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 !== ''; + } + + return false; + } + + public function reconnect(): bool + { + if ($this->memcached instanceof Memcached) { + $this->memcached->quit(); + } elseif ($this->memcached instanceof Memcache) { + $this->memcached->close(); + } + + 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..8919c7a1db2b 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -204,4 +204,38 @@ public function isSupported(): bool { return class_exists(Client::class); } + + public function ping(): bool + { + try { + $result = $this->redis->ping(); + + if (is_object($result)) { + return $result->getPayload() === 'PONG'; + } + + return $result === 'PONG'; + } catch (Exception) { + return false; + } + } + + public function reconnect(): bool + { + try { + $this->redis->disconnect(); + } catch (Exception) { + // 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..98cf33651687 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,40 @@ public function isSupported(): bool { return extension_loaded('redis'); } + + public function ping(): bool + { + if (! isset($this->redis)) { + return false; + } + + try { + $result = $this->redis->ping(); + + return in_array($result, [true, '+PONG'], true); + } catch (RedisException) { + return false; + } + } + + public function reconnect(): bool + { + if (isset($this->redis)) { + try { + $this->redis->close(); + } catch (RedisException) { + // 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/CodeIgniter.php b/system/CodeIgniter.php index 847020504726..4050f4c139d5 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; @@ -192,6 +192,24 @@ 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. + */ + 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; + } + /** * Initializes Kint * diff --git a/system/Commands/Worker/Views/Caddyfile.tpl b/system/Commands/Worker/Views/Caddyfile.tpl new file mode 100644 index 000000000000..08ab621ac7f8 --- /dev/null +++ b/system/Commands/Worker/Views/Caddyfile.tpl @@ -0,0 +1,42 @@ +# 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 16 + } + } + + # Disable admin API (recommended for production) + admin off +} + +# HTTP server configuration +:8080 { + # Document root + root * public + + # Enable compression + encode zstd br gzip + + # Route all PHP requests through the worker + php_server { + # Route all requests through the worker + try_files {path} frankenphp-worker.php + } + + # Serve static files + 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..229a3bcdd6b4 --- /dev/null +++ b/system/Commands/Worker/Views/frankenphp-worker.php.tpl @@ -0,0 +1,127 @@ +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 = static function () use ($app, $workerConfig) { + // Reconnect database connections before handling request + DatabaseConfig::reconnectForWorkerMode(); + + // Reconnect cache connection before handling request + Services::reconnectCacheForWorkerMode(); + + // 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); + + try { + $app->run(); + } catch (Throwable $e) { + Services::exceptions()->exceptionHandler($e); + } + + if ($workerConfig->forceGarbageCollection) { + // Force garbage collection + gc_collect_cycles(); + } +}; + +/* + *--------------------------------------------------------------- + * WORKER REQUEST LOOP + *--------------------------------------------------------------- + */ + +while (frankenphp_handle_request($handler)) { + // Close session + if (Services::has('session')) { + Services::session()->close(); + } + + // Cleanup connections with uncommitted transactions + DatabaseConfig::cleanupForWorkerMode(); + + // Reset factories + Factories::reset(); + + // Reset services except persistent ones + Services::resetForWorkerMode($workerConfig); + + if (CI_DEBUG) { + Events::cleanupForWorkerMode(); + Services::toolbar()->reset(); + } +} diff --git a/system/Commands/Worker/WorkerInstall.php b/system/Commands/Worker/WorkerInstall.php new file mode 100644 index 000000000000..99afdc9f69eb --- /dev/null +++ b/system/Commands/Worker/WorkerInstall.php @@ -0,0 +1,138 @@ + + * + * 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; +use CodeIgniter\CLI\CLI; + +/** + * Install Worker Mode for FrankenPHP. + * + * This command sets up the necessary files to run CodeIgniter 4 + * in FrankenPHP worker mode for improved performance. + */ +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 = [ + '--force' => '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. Start FrankenPHP:', 'white'); + CLI::write(' frankenphp run', 'green'); + CLI::newLine(); + + CLI::write('2. Test your application:', 'white'); + CLI::write(' curl http://localhost:8080/', 'green'); + CLI::newLine(); + } +} diff --git a/system/Commands/Worker/WorkerUninstall.php b/system/Commands/Worker/WorkerUninstall.php new file mode 100644 index 000000000000..0d083f2e5b49 --- /dev/null +++ b/system/Commands/Worker/WorkerUninstall.php @@ -0,0 +1,120 @@ + + * + * 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; +use CodeIgniter\CLI\CLI; + +/** + * Uninstall Worker Mode for FrankenPHP. + * + * This command removes the files created by the worker:install command. + */ +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 = [ + '--force' => '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 ($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..483da962d5ad 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. @@ -202,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. * @@ -361,6 +374,53 @@ public static function reset(bool $initAutoloader = true) } } + /** + * 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 reconnectCacheForWorkerMode(): void + { + if (! 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 + { + // Reset mocks (testing only, safe to clear) + static::$mocks = []; + + // Reset factories + static::$factories = []; + + // Process each service instance + $persistentInstances = []; + + foreach (static::$instances as $serviceName => $service) { + // 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..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. * @@ -493,6 +507,32 @@ public function close() */ abstract protected function _close(); + /** + * Check if the connection is still alive. + */ + public function ping(): bool + { + if (! $this->connID) { + return false; + } + + return $this->_ping(); + } + + /** + * Driver-specific ping implementation. + */ + 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..42f991b3f30b 100644 --- a/system/Database/Config.php +++ b/system/Database/Config.php @@ -152,4 +152,47 @@ protected static function ensureFactory() static::$factory = new Database(); } + + /** + * 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 reconnectForWorkerMode(): void + { + if (static::$instances === []) { + return; + } + + foreach (static::$instances as $connection) { + $connection->reconnect(); + } + } + + /** + * Cleanup database connections for worker mode. + * + * 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). + * + * Called at the END of each request to clean up state. + */ + 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."); + + while ($connection->transDepth > 0) { + $connection->transRollback(); + } + } + + $connection->resetTransStatus(); + } + } } diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index 43303ed5441c..5c11f6eda53a 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -249,18 +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() - { - $this->close(); - $this->initialize(); - } - /** * Close the database connection. * diff --git a/system/Database/OCI8/Connection.php b/system/Database/OCI8/Connection.php index 69c3a0d8eee2..dc884588a251 100644 --- a/system/Database/OCI8/Connection.php +++ b/system/Database/OCI8/Connection.php @@ -150,16 +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() - { - } - /** * Close the database connection. * @@ -176,6 +166,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 e60c8b1583d9..295616ab035d 100644 --- a/system/Database/Postgre/Connection.php +++ b/system/Database/Postgre/Connection.php @@ -143,27 +143,21 @@ private function convertDSN() } /** - * Keep or establish the connection if no queries have been sent for - * a length of time exceeding the server's idle timeout. + * Close the database connection. * * @return void */ - public function reconnect() + protected function _close() { - if ($this->connID === false || pg_ping($this->connID) === false) { - $this->close(); - $this->initialize(); - } + pg_close($this->connID); } /** - * Close the database connection. - * - * @return void + * Ping the database connection. */ - protected function _close() + protected function _ping(): bool { - pg_close($this->connID); + return pg_ping($this->connID); } /** diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php index 7e6d0810a3ad..76dd80bdb09f 100644 --- a/system/Database/SQLSRV/Connection.php +++ b/system/Database/SQLSRV/Connection.php @@ -166,18 +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() - { - $this->close(); - $this->initialize(); - } - /** * Close the database connection. * diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index c61bb6c42b49..3865669a9e2d 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -119,18 +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() - { - $this->close(); - $this->initialize(); - } - /** * Close the database connection. * 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..979f531591fe 100644 --- a/system/Events/Events.php +++ b/system/Events/Events.php @@ -284,4 +284,15 @@ public static function getPerformanceLogs() { return static::$performanceLog; } + + /** + * 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/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..6fd9545d9bf8 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,13 @@ public function close(): bool try { $pingReply = $this->redis->ping(); - if (($pingReply === true) || ($pingReply === '+PONG')) { - if (isset($this->lockKey) && ! $this->releaseLock()) { - return false; - } - - if (! $this->redis->close()) { - 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()); } - $this->redis = null; - return true; } diff --git a/system/Session/PersistsConnection.php b/system/Session/PersistsConnection.php new file mode 100644 index 000000000000..716a949f15cd --- /dev/null +++ b/system/Session/PersistsConnection.php @@ -0,0 +1,85 @@ + + * + * 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. + */ + 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/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/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)); + } } 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/Commands/WorkerCommandsTest.php b/tests/system/Commands/WorkerCommandsTest.php new file mode 100644 index 000000000000..be0880f9157d --- /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('http://localhost:8080/', $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::reconnectForWorkerMode', (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/CommonSingleServiceTest.php b/tests/system/CommonSingleServiceTest.php index 014ac12a27fc..e423d1feaee0 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 + 'reconnectCacheForWorkerMode', ]; if ($services === []) { diff --git a/tests/system/Config/ServicesTest.php b/tests/system/Config/ServicesTest.php index 37b50890c400..7eef9818a727 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 testResetForWorkerMode(): void + { + $config = new WorkerMode(); + $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 testReconnectCacheForWorkerMode(): void + { + Services::cache(); + $this->assertTrue(Services::has('cache')); + + Services::reconnectCacheForWorkerMode(); + + $this->assertTrue(Services::has('cache')); + } + + #[PreserveGlobalState(false)] + #[RunInSeparateProcess] + public function testReconnectCacheForWorkerModeWithNoCache(): void + { + Services::resetSingle('cache'); + $this->assertFalse(Services::has('cache')); + + Services::reconnectCacheForWorkerMode(); + + $this->assertFalse(Services::has('cache')); + } } diff --git a/tests/system/Database/Live/PingTest.php b/tests/system/Database/Live/PingTest.php new file mode 100644 index 000000000000..4271f4f57a2e --- /dev/null +++ b/tests/system/Database/Live/PingTest.php @@ -0,0 +1,70 @@ + + * + * 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()) { + $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'); + } + } +} diff --git a/tests/system/Database/Live/WorkerModeTest.php b/tests/system/Database/Live/WorkerModeTest.php new file mode 100644 index 000000000000..a8c77d756da7 --- /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 testReconnectForWorkerMode(): void + { + $conn = Config::connect(); + $this->assertInstanceOf(BaseConnection::class, $conn); + $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); + + Config::reconnectForWorkerMode(); + + $this->assertNotFalse($this->getPrivateProperty($conn, 'connID')); + } +} diff --git a/tests/system/Events/EventsTest.php b/tests/system/Events/EventsTest.php index dc22ec3cd92b..5ec4ae679d3b 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 => null); + Events::trigger('test'); + + $performanceLog = Events::getPerformanceLogs(); + $this->assertNotEmpty($performanceLog); + + Events::cleanupForWorkerMode(); + + $performanceLog = Events::getPerformanceLogs(); + $this->assertEmpty($performanceLog); + } } 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(); + } } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index c1fe14c0fcb6..7acffa3fb013 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. 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. 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 @@ -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::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::reconnectCacheForWorkerMode()`` 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/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/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- 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 ===================== 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..4dea3d1aad32 --- /dev/null +++ b/user_guide_src/source/installation/worker_mode.rst @@ -0,0 +1,307 @@ +########### +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', '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 +=================== + +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). +================ ========================================================================== + +.. 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 (``$forceGarbageCollection = 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 are automatically rolled back with a warning logged