From 8eea32770165689d52630ed236b642a46f89f51d Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean Date: Sun, 2 Aug 2026 23:17:29 +0700 Subject: [PATCH 1/4] feat: add Redis Sentinel support to cache and session Redis handlers Add configurable Redis Sentinel support to the Cache RedisHandler and PredisHandler, and to the Session RedisHandler, so the handlers discover the current master from a list of Sentinel nodes instead of a single fixed host. - Add RedisSentinel::discoverMaster() utility shared by both phpredis handlers. It prefers the RedisSentinel class (phpredis >= 5.3) and falls back to the SENTINEL get-master-addr-by-name command for older versions. - Cache RedisHandler: when a sentinel block is configured, discover the master address before connecting; wrap discovery failures in CriticalError. - Cache PredisHandler: hand the Sentinel nodes plus a replication=sentinel and service option to Predis, which discovers and follows the master natively. - Session RedisHandler: new Config\Session::$sentinel property. When populated it takes precedence over $savePath; open() discovers the master and logs + returns false when no Sentinel can answer. - Document the new configuration in the caching and sessions user guide, with sample snippets. - Add unit tests for the discovery utility and sentinel-shaped savePath, plus live tests that assume a local Sentinel on 127.0.0.1:26379 (mymaster), gated on the redis extension like the other live Redis tests. Co-Authored-By: Claude --- app/Config/Cache.php | 14 +- app/Config/Session.php | 34 ++++ system/Cache/Handlers/PredisHandler.php | 38 ++++- system/Cache/Handlers/RedisHandler.php | 25 ++- system/Cache/Handlers/RedisSentinel.php | 150 ++++++++++++++++++ system/Language/en/Session.php | 11 +- .../Session/Exceptions/SessionException.php | 8 + system/Session/Handlers/RedisHandler.php | 61 ++++++- .../Cache/Handlers/PredisHandlerTest.php | 24 +++ .../Cache/Handlers/RedisHandlerTest.php | 25 +++ .../Cache/Handlers/RedisSentinelTest.php | 69 ++++++++ .../Handlers/Database/RedisHandlerTest.php | 36 ++++- user_guide_src/source/libraries/caching.rst | 25 +++ .../source/libraries/caching/016.php | 34 ++++ user_guide_src/source/libraries/sessions.rst | 21 +++ .../source/libraries/sessions/046.php | 30 ++++ 16 files changed, 594 insertions(+), 11 deletions(-) create mode 100644 system/Cache/Handlers/RedisSentinel.php create mode 100644 tests/system/Cache/Handlers/RedisSentinelTest.php create mode 100644 user_guide_src/source/libraries/caching/016.php create mode 100644 user_guide_src/source/libraries/sessions/046.php diff --git a/app/Config/Cache.php b/app/Config/Cache.php index 38ac5419d84c..e67e51906cd7 100644 --- a/app/Config/Cache.php +++ b/app/Config/Cache.php @@ -113,6 +113,12 @@ class Cache extends BaseConfig * Your Redis server can be specified below, if you are using * the Redis or Predis drivers. * + * To connect through Redis Sentinel, populate the `sentinel` key with the + * master service name and the list of Sentinel nodes. When `sentinel` is + * non-empty, `host`/`port` are ignored by the Redis handler (phpredis), + * and the Predis handler replaces its single-node connection with the + * Sentinel nodes. + * * @var array{ * host?: string, * password?: string|null, @@ -120,7 +126,12 @@ class Cache extends BaseConfig * timeout?: int, * async?: bool, * persistent?: bool, - * database?: int + * database?: int, + * sentinel?: array{ + * service?: string, + * nodes?: list, + * timeout?: float + * } * } */ public array $redis = [ @@ -131,6 +142,7 @@ class Cache extends BaseConfig 'async' => false, // specific to Predis and ignored by the native Redis extension 'persistent' => false, 'database' => 0, + 'sentinel' => [], ]; /** diff --git a/app/Config/Session.php b/app/Config/Session.php index 24912865f271..0d4e202e7946 100644 --- a/app/Config/Session.php +++ b/app/Config/Session.php @@ -60,6 +60,40 @@ class Session extends BaseConfig */ public string $savePath = WRITEPATH . 'session'; + /** + * -------------------------------------------------------------------------- + * Redis Sentinel Settings + * -------------------------------------------------------------------------- + * + * Used by the RedisHandler session driver to connect through Redis + * Sentinel instead of a single fixed host. When `nodes` is non-empty, + * the handler queries the Sentinel nodes for the current master of the + * named `service` and connects to it, and `$savePath` is ignored. + * + * Requires the `redis` PHP extension (phpredis >= 5.3 recommended; older + * versions work via the SENTINEL command). + * + * @var array{ + * service?: string, + * nodes?: list, + * timeout?: float, + * persistent?: bool, + * password?: string|null, + * database?: int + * } + */ + public array $sentinel = [ + // 'service' => 'mymaster', + // 'nodes' => [ + // ['host' => '127.0.0.1', 'port' => 26379], + // ['host' => 'sentinel2', 'port' => 26379], + // ], + // 'timeout' => 0.5, + // 'persistent' => false, + // 'password' => null, + // 'database' => 0, + ]; + /** * -------------------------------------------------------------------------- * Session Match IP diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index 02ea87313d60..22255a219b5b 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -41,7 +41,11 @@ class PredisHandler extends BaseHandler implements LockStoreProviderInterface * port: int, * async: bool, * persistent: bool, - * timeout: int + * timeout: int, + * sentinel?: array{ + * service?: string, + * nodes?: list + * } * } */ protected $config = [ @@ -52,6 +56,7 @@ class PredisHandler extends BaseHandler implements LockStoreProviderInterface 'async' => false, 'persistent' => false, 'timeout' => 0, + 'sentinel' => [], ]; /** @@ -76,7 +81,36 @@ public function __construct(Cache $config) public function initialize(): void { try { - $this->redis = new Client($this->config, ['prefix' => $this->prefix]); + // Predis has native Sentinel support: pass the Sentinel nodes plus a + // replication/service option and it discovers and follows the master. + if (($this->config['sentinel']['nodes'] ?? []) !== []) { + $nodes = array_map( + static fn (array $node): array => [ + 'scheme' => $node['scheme'] ?? 'tcp', + 'host' => $node['host'], + 'port' => $node['port'] ?? 26379, + ], + $this->config['sentinel']['nodes'], + ); + $options = [ + 'prefix' => $this->prefix, + 'replication' => 'sentinel', + 'service' => $this->config['sentinel']['service'], + ]; + + // `parameters` are applied to the connections resolved by Sentinel. + if (isset($this->config['password'])) { + $options['parameters']['password'] = $this->config['password']; + } + if (isset($this->config['database'])) { + $options['parameters']['database'] = $this->config['database']; + } + + $this->redis = new Client($nodes, $options); + } else { + $this->redis = new Client($this->config, ['prefix' => $this->prefix]); + } + $this->lockStore = null; $this->redis->time(); } catch (Exception $e) { diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index 8a4549f2ee68..0b7ccc85bc91 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -21,6 +21,7 @@ use Config\Cache; use Redis; use RedisException; +use RuntimeException; /** * Redis cache handler @@ -39,6 +40,11 @@ class RedisHandler extends BaseHandler implements LockStoreProviderInterface * timeout: int, * persistent: bool, * database: int, + * sentinel?: array{ + * service?: string, + * nodes?: list, + * timeout?: float + * } * } */ protected $config = [ @@ -48,6 +54,7 @@ class RedisHandler extends BaseHandler implements LockStoreProviderInterface 'timeout' => 0, 'persistent' => false, 'database' => 0, + 'sentinel' => [], ]; /** @@ -79,9 +86,23 @@ public function initialize(): void try { $funcConnection = isset($config['persistent']) && $config['persistent'] ? 'pconnect' : 'connect'; + // When a Sentinel cluster is configured, discover the current master + // address before connecting; otherwise fall back to the single host. + if (($config['sentinel']['nodes'] ?? []) !== []) { + [$host, $port] = RedisSentinel::discoverMaster( + $config['sentinel']['nodes'], + $config['sentinel']['service'], + (float) ($config['sentinel']['timeout'] ?? 0), + ); + } else { + $host = $config['host']; + // Unix domain sockets are passed as the host with a port of 0. + $port = $config['host'][0] === '/' ? 0 : $config['port']; + } + // Note:: If Redis is your primary cache choice, and it is "offline", every page load will end up been delayed by the timeout duration. // I feel like some sort of temporary flag should be set, to indicate that we think Redis is "offline", allowing us to bypass the timeout for a set period of time. - if (! $this->redis->{$funcConnection}($config['host'], ($config['host'][0] === '/' ? 0 : $config['port']), $config['timeout'])) { + if (! $this->redis->{$funcConnection}($host, $port, $config['timeout'])) { // Note:: I'm unsure if log_message() is necessary, however I'm not 100% comfortable removing it. log_message('error', 'Cache: Redis connection failed. Check your configuration.'); @@ -101,6 +122,8 @@ public function initialize(): void } } catch (RedisException $e) { throw new CriticalError('Cache: RedisException occurred with message (' . $e->getMessage() . ').', $e->getCode(), $e); + } catch (RuntimeException $e) { + throw new CriticalError('Cache: ' . $e->getMessage(), $e->getCode(), $e); } } diff --git a/system/Cache/Handlers/RedisSentinel.php b/system/Cache/Handlers/RedisSentinel.php new file mode 100644 index 000000000000..b6654fd70154 --- /dev/null +++ b/system/Cache/Handlers/RedisSentinel.php @@ -0,0 +1,150 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\Handlers; + +use Redis; +use RedisException; +use RuntimeException; + +/** + * Discovers the current Redis master from a list of Sentinel nodes. + * + * phpredis has no built-in Sentinel failover handling, so the Redis cache + * and session handlers use this utility to resolve the master address before + * connecting. It prefers the `RedisSentinel` class (phpredis >= 5.3) and + * falls back to a plain `SENTINEL get-master-addr-by-name` command for older + * versions. + */ +class RedisSentinel +{ + /** + * Default Sentinel port. + */ + private const DEFAULT_SENTINEL_PORT = 26379; + + /** + * Queries the given Sentinel nodes for the address of the named master. + * + * Each node is tried in order; the first one that answers wins. When no + * node can return the master address a RuntimeException is thrown so the + * caller can surface a clear error. + * + * @param list $nodes Sentinel nodes to query. + * @param string $service Sentinel master name, e.g. "mymaster". + * @param float $timeout Connection timeout (seconds) per node. + * + * @return array{0: string, 1: int} The master host and port. + * + * @throws RuntimeException When no Sentinel node can discover the master. + */ + public static function discoverMaster(array $nodes, string $service, float $timeout = 0.0): array + { + if ($nodes === []) { + throw new RuntimeException('No Redis Sentinel nodes configured.'); + } + + foreach ($nodes as $node) { + $host = $node['host'] ?? ''; + $port = $node['port'] ?? self::DEFAULT_SENTINEL_PORT; + + if ($host === '') { + continue; + } + + $address = self::queryNode($host, (int) $port, $service, $timeout); + + if ($address !== null) { + return $address; + } + } + + throw new RuntimeException(sprintf('Redis Sentinel unable to discover master "%s".', $service)); + } + + /** + * Queries a single Sentinel node for the master address. + * + * @return array{0: string, 1: int}|null + */ + private static function queryNode(string $host, int $port, string $service, float $timeout): ?array + { + // Prefer the dedicated RedisSentinel class (phpredis >= 5.3). + if (class_exists(\RedisSentinel::class)) { + try { + $sentinel = new \RedisSentinel($host, $port, $timeout); + $result = $sentinel->getMasterAddrByName($service); + + if ($result === false) { + return null; + } + + return self::normalise($result); + } catch (RedisException) { + // Node unreachable or command failed; try the next one. + return null; + } + } + + // Fall back to the SENTINEL command on a plain Redis connection. + try { + $redis = new Redis(); + $redis->connect($host, $port, $timeout); + + $result = $redis->rawcommand('SENTINEL', 'get-master-addr-by-name', $service); + + try { + $redis->close(); + } catch (RedisException) { + // Connection already dead, that's fine. + } + + if ($result === false) { + return null; + } + + return self::normalise($result); + } catch (RedisException) { + return null; + } + } + + /** + * Normalises the varied reply shapes into a [host, port] pair. + * + * phpredis returns either a flat `['host', 'port']` list (rawCommand and + * most RedisSentinel builds) or an associative `[['ip' => .., 'port' => ..]]` + * shape on some builds. Both are coerced to `array{0:string, 1:int}`. + * + * @param array $result + * + * @return array{0: string, 1: int}|null + */ + private static function normalise(array $result): ?array + { + // Some RedisSentinel builds wrap the entry in an outer array. + $entry = array_is_list($result) && isset($result[0]) && is_array($result[0]) + ? $result[0] + : $result; + + if (isset($entry['ip'], $entry['port'])) { + return [(string) $entry['ip'], (int) $entry['port']]; + } + + if (isset($entry[0], $entry[1]) && is_string($entry[0]) && (is_string($entry[1]) || is_int($entry[1]))) { + return [(string) $entry[0], (int) $entry[1]]; + } + + return null; + } +} diff --git a/system/Language/en/Session.php b/system/Language/en/Session.php index d067410462c0..a13987c5023a 100644 --- a/system/Language/en/Session.php +++ b/system/Language/en/Session.php @@ -13,9 +13,10 @@ // Session language settings return [ - 'missingDatabaseTable' => 'Session: "savePath" must have the table name for the Database Session Handler to work.', - 'invalidSavePath' => 'Session: Configured save path "{0}" is not a directory, does not exist or cannot be created.', - 'writeProtectedSavePath' => 'Session: Configured save path "{0}" is not writable by the PHP process.', - 'emptySavePath' => 'Session: No save path configured.', - 'invalidSavePathFormat' => 'Session: Invalid Redis save path format: "{0}"', + 'missingDatabaseTable' => 'Session: "savePath" must have the table name for the Database Session Handler to work.', + 'invalidSavePath' => 'Session: Configured save path "{0}" is not a directory, does not exist or cannot be created.', + 'writeProtectedSavePath' => 'Session: Configured save path "{0}" is not writable by the PHP process.', + 'emptySavePath' => 'Session: No save path configured.', + 'invalidSavePathFormat' => 'Session: Invalid Redis save path format: "{0}"', + 'sentinelDiscoveryFailed' => 'Session: Redis Sentinel unable to discover master "{0}".', ]; diff --git a/system/Session/Exceptions/SessionException.php b/system/Session/Exceptions/SessionException.php index 29c9a74ab3c1..8332cc6b8949 100644 --- a/system/Session/Exceptions/SessionException.php +++ b/system/Session/Exceptions/SessionException.php @@ -56,4 +56,12 @@ public static function forInvalidSavePathFormat(string $path) { return new static(lang('Session.invalidSavePathFormat', [$path])); } + + /** + * @return static + */ + public static function forSentinelDiscoveryFailed(string $service) + { + return new static(lang('Session.sentinelDiscoveryFailed', [$service])); + } } diff --git a/system/Session/Handlers/RedisHandler.php b/system/Session/Handlers/RedisHandler.php index 3e5a4a2bbe7e..ad71da5c0905 100644 --- a/system/Session/Handlers/RedisHandler.php +++ b/system/Session/Handlers/RedisHandler.php @@ -13,12 +13,14 @@ namespace CodeIgniter\Session\Handlers; +use CodeIgniter\Cache\Handlers\RedisSentinel; use CodeIgniter\I18n\Time; use CodeIgniter\Session\Exceptions\SessionException; use CodeIgniter\Session\PersistsConnection; use Config\Session as SessionConfig; use Redis; use RedisException; +use RuntimeException; /** * Session handler using Redis for persistence. @@ -30,6 +32,20 @@ class RedisHandler extends BaseHandler private const DEFAULT_PORT = 6379; private const DEFAULT_PROTOCOL = 'tcp'; + /** + * Sentinel configuration, when set takes precedence over $savePath. + * + * @var array{ + * service?: string, + * nodes?: list, + * timeout?: float, + * persistent?: bool, + * password?: string|null, + * database?: int + * } + */ + protected array $sentinel = []; + /** * phpRedis instance. * @@ -92,6 +108,9 @@ public function __construct(SessionConfig $config, string $ipAddress) // Add session cookie name for multiple session cookies. $this->keyPrefix .= $config->cookieName . ':'; + // Store Sentinel configuration; when populated it overrides $savePath. + $this->sentinel = $config->sentinel; + $this->setSavePath(); if ($this->matchIP === true) { @@ -104,6 +123,25 @@ public function __construct(SessionConfig $config, string $ipAddress) protected function setSavePath(): void { + // When a Sentinel cluster is configured, build a Sentinel-shaped save + // path and skip the single-host savePath parsing. This also means an + // empty $savePath is valid as long as $sentinel is populated. + if (($this->sentinel['nodes'] ?? []) !== []) { + $this->savePath = [ + 'sentinel' => true, + 'service' => $this->sentinel['service'] ?? '', + 'nodes' => $this->sentinel['nodes'], + 'password' => $this->sentinel['password'] ?? null, + 'database' => $this->sentinel['database'] ?? 0, + 'timeout' => (float) ($this->sentinel['timeout'] ?? 0.0), + 'persistent' => isset($this->sentinel['persistent']) + ? filter_var($this->sentinel['persistent'], FILTER_VALIDATE_BOOL) + : null, + ]; + + return; + } + if ($this->savePath === '') { throw SessionException::forEmptySavepath(); } @@ -194,13 +232,34 @@ public function open($path, $name): bool } } + // When using Sentinel, discover the current master address first. + if (($this->savePath['sentinel'] ?? false) === true) { + try { + [$host, $port] = RedisSentinel::discoverMaster( + $this->savePath['nodes'], + $this->savePath['service'], + $this->savePath['timeout'], + ); + } catch (RuntimeException $e) { + $this->logger->error( + 'Session: Redis Sentinel unable to discover master "' + . $this->savePath['service'] . '": ' . $e->getMessage(), + ); + + return false; + } + } else { + $host = $this->savePath['host']; + $port = $this->savePath['port']; + } + $redis = new Redis(); $funcConnection = isset($this->savePath['persistent']) && $this->savePath['persistent'] === true ? 'pconnect' : 'connect'; - if ($redis->{$funcConnection}($this->savePath['host'], $this->savePath['port'], $this->savePath['timeout']) === false) { + if ($redis->{$funcConnection}($host, $port, $this->savePath['timeout']) === false) { $this->logger->error('Session: Unable to connect to Redis with the configured settings.'); } elseif (isset($this->savePath['password']) && ! $redis->auth($this->savePath['password'])) { $this->logger->error('Session: Unable to authenticate to Redis instance.'); diff --git a/tests/system/Cache/Handlers/PredisHandlerTest.php b/tests/system/Cache/Handlers/PredisHandlerTest.php index 91949f53238f..0b9e263fb90c 100644 --- a/tests/system/Cache/Handlers/PredisHandlerTest.php +++ b/tests/system/Cache/Handlers/PredisHandlerTest.php @@ -196,4 +196,28 @@ public function testReconnect(): void $this->assertSame('value', $this->handler->get(self::$key1)); } + + /** + * Live test that runs when predis/predis is installed (see the class-level + * Group('CacheLive') attribute). It assumes a local Redis Sentinel is + * reachable at 127.0.0.1:26379 monitoring the "mymaster" service, mirroring + * how the other live Redis tests assume a server on 127.0.0.1:6379. + */ + public function testInitializeWithSentinel(): void + { + $config = new Cache(); + $config->redis = [ + 'sentinel' => [ + 'service' => 'mymaster', + 'nodes' => [ + ['host' => '127.0.0.1', 'port' => 26379], + ], + ], + ]; + + $handler = CacheFactory::getHandler($config, 'predis'); + $handler->save(self::$key1, 'sentinel-value'); + $this->assertSame('sentinel-value', $handler->get(self::$key1)); + $handler->clean(); + } } diff --git a/tests/system/Cache/Handlers/RedisHandlerTest.php b/tests/system/Cache/Handlers/RedisHandlerTest.php index 1f85ecc28349..6bd2c7e00ae9 100644 --- a/tests/system/Cache/Handlers/RedisHandlerTest.php +++ b/tests/system/Cache/Handlers/RedisHandlerTest.php @@ -238,4 +238,29 @@ public function testReconnect(): void $this->assertSame('value', $this->handler->get(self::$key1)); } + + /** + * Live test that runs when the redis extension is loaded (see the class-level + * RequiresPhpExtension attribute). It assumes a local Redis Sentinel is + * reachable at 127.0.0.1:26379 monitoring the "mymaster" service, mirroring + * how the other live Redis tests assume a server on 127.0.0.1:6379. + */ + public function testInitializeWithSentinel(): void + { + $config = new Cache(); + $config->redis = [ + 'sentinel' => [ + 'service' => 'mymaster', + 'nodes' => [ + ['host' => '127.0.0.1', 'port' => 26379], + ], + 'timeout' => 0.5, + ], + ]; + + $handler = CacheFactory::getHandler($config, 'redis'); + $handler->save(self::$key1, 'sentinel-value'); + $this->assertSame('sentinel-value', $handler->get(self::$key1)); + $handler->clean(); + } } diff --git a/tests/system/Cache/Handlers/RedisSentinelTest.php b/tests/system/Cache/Handlers/RedisSentinelTest.php new file mode 100644 index 000000000000..cbd92138978f --- /dev/null +++ b/tests/system/Cache/Handlers/RedisSentinelTest.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\Cache\Handlers; + +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +/** + * @internal + */ +#[Group('CacheLive')] +final class RedisSentinelTest extends TestCase +{ + public function testDiscoverMasterThrowsWhenNoNodes(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No Redis Sentinel nodes configured.'); + + RedisSentinel::discoverMaster([], 'mymaster'); + } + + #[RequiresPhpExtension('redis')] + public function testDiscoverMasterThrowsWhenAllNodesUnreachable(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Redis Sentinel unable to discover master "mymaster".'); + + // A port that nothing listens on and a short timeout to keep it fast. + RedisSentinel::discoverMaster( + [['host' => '127.0.0.1', 'port' => 1]], + 'mymaster', + 0.2, + ); + } + + /** + * Live test that runs when the redis extension is loaded (see the method-level + * RequiresPhpExtension attribute). It assumes a local Redis Sentinel is + * reachable at 127.0.0.1:26379 monitoring the "mymaster" service, mirroring + * how the other live Redis tests assume a server on 127.0.0.1:6379. + */ + #[RequiresPhpExtension('redis')] + public function testDiscoverMasterLive(): void + { + $address = RedisSentinel::discoverMaster( + [['host' => '127.0.0.1', 'port' => 26379]], + 'mymaster', + 0.5, + ); + + $this->assertCount(2, $address); + $this->assertIsString($address[0]); + $this->assertIsInt($address[1]); + $this->assertGreaterThan(0, $address[1]); + } +} diff --git a/tests/system/Session/Handlers/Database/RedisHandlerTest.php b/tests/system/Session/Handlers/Database/RedisHandlerTest.php index 637132669aec..26d186f86bc2 100644 --- a/tests/system/Session/Handlers/Database/RedisHandlerTest.php +++ b/tests/system/Session/Handlers/Database/RedisHandlerTest.php @@ -36,7 +36,7 @@ final class RedisHandlerTest extends CIUnitTestCase private string $userIpAddress = '127.0.0.1'; /** - * @param array $options Replace values for `Config\Session`. + * @param array $options Replace values for `Config\Session`. */ protected function getInstance($options = []): RedisHandler { @@ -308,6 +308,40 @@ public static function provideSetSavePath(): iterable ]; } + /** + * When `$sentinel` is populated, `setSavePath()` builds a Sentinel-shaped + * array and `$savePath` is ignored. This is a pure unit test: no Sentinel + * server is contacted because `setSavePath()` runs in the constructor. + */ + public function testSetSavePathWithSentinel(): void + { + $sentinel = [ + 'service' => 'mymaster', + 'nodes' => [ + ['host' => '127.0.0.1', 'port' => 26379], + ['host' => 'sentinel2', 'port' => 26379], + ], + 'timeout' => 0.5, + 'persistent' => true, + 'password' => 'secret', + 'database' => 1, + ]; + $option = ['sentinel' => $sentinel, 'savePath' => '']; + $handler = $this->getInstance($option); + + $savePath = $this->getPrivateProperty($handler, 'savePath'); + + $this->assertSame([ + 'sentinel' => true, + 'service' => 'mymaster', + 'nodes' => $sentinel['nodes'], + 'password' => 'secret', + 'database' => 1, + 'timeout' => 0.5, + 'persistent' => true, + ], $savePath); + } + public function testConnectionReuse(): void { $handler1 = $this->getInstance(); diff --git a/user_guide_src/source/libraries/caching.rst b/user_guide_src/source/libraries/caching.rst index 6a4184ea10c6..022d9dbc93a4 100644 --- a/user_guide_src/source/libraries/caching.rst +++ b/user_guide_src/source/libraries/caching.rst @@ -342,6 +342,31 @@ Config options to connect to redis server stored in the cache configuration file For more information on Redis, please see `https://redis.io `_. +Redis Sentinel +-------------- + +Both the **Redis** and **Predis** handlers support connecting through +`Redis Sentinel `_. +When the ``sentinel`` key is populated, the handler discovers the current master +of the named ``service`` from the listed Sentinel ``nodes`` and connects to it, +so your cache keeps working after a failover without a hardcoded master address. + +For the **Redis** handler (phpredis), ``host``/``port`` are ignored when +``sentinel`` is set; the handler queries each Sentinel node for the master. +Sentinel support requires the ``redis`` PHP extension (phpredis >= 5.3 is +recommended, which exposes a dedicated ``RedisSentinel`` class; older versions +work via the ``SENTINEL`` command). + +For the **Predis** handler, Sentinel is handled natively: Predis is given the +Sentinel nodes plus a ``replication => sentinel`` / ``service`` option and +discovers and follows the master itself. + +.. literalinclude:: caching/016.php + +.. note:: Discovery happens once when the handler initialises. If the master + fails over while the connection is open, call ``Cache::reconnect()`` (Redis + handler) or let the Predis client retry to re-discover the new master. + Predis Caching ============== diff --git a/user_guide_src/source/libraries/caching/016.php b/user_guide_src/source/libraries/caching/016.php new file mode 100644 index 000000000000..5e0e52efd534 --- /dev/null +++ b/user_guide_src/source/libraries/caching/016.php @@ -0,0 +1,34 @@ + '127.0.0.1', + 'password' => null, + 'port' => 6379, + 'async' => false, // specific to Predis and ignored by the native Redis extension + 'persistent' => false, + 'timeout' => 0, + 'database' => 0, + // Connect through Redis Sentinel. When populated, `host`/`port` are + // ignored by the Redis handler (phpredis) and the Predis handler uses + // the Sentinel nodes to discover and follow the master. + 'sentinel' => [ + 'service' => 'mymaster', + 'nodes' => [ + ['host' => '127.0.0.1', 'port' => 26379], + ['host' => 'sentinel2', 'port' => 26379], + ['host' => 'sentinel3', 'port' => 26379], + ], + 'timeout' => 0.5, + ], + ]; + + // ... +} diff --git a/user_guide_src/source/libraries/sessions.rst b/user_guide_src/source/libraries/sessions.rst index 45b3c56e112c..52a3cfef4623 100644 --- a/user_guide_src/source/libraries/sessions.rst +++ b/user_guide_src/source/libraries/sessions.rst @@ -707,6 +707,27 @@ Starting with v4.5.0, you can use Redis ACL (username and password):: (``$lockRetryInterval``) and the number of retries (``$lockMaxRetries``) are configurable. +Redis Sentinel +-------------- + +The RedisHandler can connect through +`Redis Sentinel `_ +instead of a single fixed host. Set the ``$sentinel`` property with the master +``service`` name and the list of Sentinel ``nodes``; the handler queries the +Sentinel nodes for the current master and connects to it, so sessions survive a +failover without a hardcoded master address. When ``$sentinel`` is populated, +``$savePath`` is ignored. + +This requires the ``redis`` PHP extension (phpredis >= 5.3 is recommended, +which exposes a dedicated ``RedisSentinel`` class; older versions work via the +``SENTINEL`` command). + +.. literalinclude:: sessions/046.php + +.. note:: Discovery happens once when the session is opened. If the master + fails over while the session is open, re-opening the session re-discovers + the new master. + .. _sessions-memcachedhandler-driver: MemcachedHandler Driver diff --git a/user_guide_src/source/libraries/sessions/046.php b/user_guide_src/source/libraries/sessions/046.php new file mode 100644 index 000000000000..132a84bd6f7f --- /dev/null +++ b/user_guide_src/source/libraries/sessions/046.php @@ -0,0 +1,30 @@ + 'mymaster', + 'nodes' => [ + ['host' => '127.0.0.1', 'port' => 26379], + ['host' => 'sentinel2', 'port' => 26379], + ['host' => 'sentinel3', 'port' => 26379], + ], + 'timeout' => 0.5, + 'persistent' => false, + // 'password' => null, + // 'database' => 0, + ]; + + // ... +} From 1ffeda15f64540cacd92f3d7897426890e31dd91 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean Date: Sun, 2 Aug 2026 23:52:54 +0700 Subject: [PATCH 2/4] chore: run cs-fix --- app/Config/Cache.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Config/Cache.php b/app/Config/Cache.php index e67e51906cd7..3bfcdf3cae33 100644 --- a/app/Config/Cache.php +++ b/app/Config/Cache.php @@ -142,7 +142,7 @@ class Cache extends BaseConfig 'async' => false, // specific to Predis and ignored by the native Redis extension 'persistent' => false, 'database' => 0, - 'sentinel' => [], + 'sentinel' => [], ]; /** From 31a746af0a4f88a9a6390fb5b3c80fda31aa3f0c Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean Date: Mon, 3 Aug 2026 00:18:22 +0700 Subject: [PATCH 3/4] fix: make Redis Sentinel discovery work across phpredis and Predis The previous implementation broke against newer clients: - phpredis >= 6.0 changed the RedisSentinel constructor to a single-array signature, so the positional call threw ArgumentCountError. Drop the RedisSentinel class branch entirely and always use the SENTINEL get-master-addr-by-name command via rawCommand, which works on every phpredis version. - Predis' native sentinel replication routes read commands to replicas when any are known, so a cache write/read round-trip against a sentinel setup with a replica returned null. Replace it with the same discovery model as the phpredis handler: query the sentinel nodes for the master address, then connect a plain single-node client to that master. Co-Authored-By: Claude --- system/Cache/Handlers/PredisHandler.php | 76 +++++++++++++++++-------- system/Cache/Handlers/RedisSentinel.php | 46 ++++----------- 2 files changed, 62 insertions(+), 60 deletions(-) diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index 22255a219b5b..8ddd7f38cc1c 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -22,7 +22,9 @@ use Exception; use Predis\Client; use Predis\Collection\Iterator\Keyspace; +use Predis\Command\RawCommand; use Predis\Response\Status; +use RuntimeException; /** * Predis cache handler @@ -81,43 +83,69 @@ public function __construct(Cache $config) public function initialize(): void { try { - // Predis has native Sentinel support: pass the Sentinel nodes plus a - // replication/service option and it discovers and follows the master. + // When a Sentinel cluster is configured, discover the current master + // address first and connect to it directly (Predis has no built-in + // Sentinel handling on this client). Otherwise connect to the single + // configured host. if (($this->config['sentinel']['nodes'] ?? []) !== []) { - $nodes = array_map( - static fn (array $node): array => [ - 'scheme' => $node['scheme'] ?? 'tcp', - 'host' => $node['host'], - 'port' => $node['port'] ?? 26379, - ], - $this->config['sentinel']['nodes'], - ); - $options = [ - 'prefix' => $this->prefix, - 'replication' => 'sentinel', - 'service' => $this->config['sentinel']['service'], - ]; - - // `parameters` are applied to the connections resolved by Sentinel. - if (isset($this->config['password'])) { - $options['parameters']['password'] = $this->config['password']; - } - if (isset($this->config['database'])) { - $options['parameters']['database'] = $this->config['database']; - } + [$host, $port] = $this->discoverMasterFromSentinel(); - $this->redis = new Client($nodes, $options); + $config = $this->config; + $config['host'] = $host; + $config['port'] = $port; + + $this->redis = new Client($config, ['prefix' => $this->prefix]); } else { $this->redis = new Client($this->config, ['prefix' => $this->prefix]); } $this->lockStore = null; $this->redis->time(); + } catch (RuntimeException $e) { + throw new CriticalError('Cache: ' . $e->getMessage(), $e->getCode(), $e); } catch (Exception $e) { throw new CriticalError('Cache: Predis connection refused (' . $e->getMessage() . ').', $e->getCode(), $e); } } + /** + * Queries the configured Sentinel nodes for the current master address. + * + * Each node is tried in order; the first one that answers wins. + * + * @return array{0: string, 1: int} The master host and port. + * + * @throws RuntimeException When no Sentinel node can discover the master. + */ + private function discoverMasterFromSentinel(): array + { + $service = $this->config['sentinel']['service']; + + foreach ($this->config['sentinel']['nodes'] as $node) { + try { + $sentinel = new Client([ + 'scheme' => $node['scheme'] ?? 'tcp', + 'host' => $node['host'], + 'port' => $node['port'] ?? 26379, + 'timeout' => (float) ($this->config['sentinel']['timeout'] ?? 0), + ]); + + $result = $sentinel->executeCommand( + RawCommand::create('SENTINEL', 'get-master-addr-by-name', $service), + ); + $sentinel->disconnect(); + + if (is_array($result) && isset($result[0], $result[1]) && is_string($result[0])) { + return [(string) $result[0], (int) $result[1]]; + } + } catch (Exception) { + // Node unreachable or command failed; try the next one. + } + } + + throw new RuntimeException(sprintf('Redis Sentinel unable to discover master "%s".', $service)); + } + public function get(string $key): mixed { $key = static::validateKey($key); diff --git a/system/Cache/Handlers/RedisSentinel.php b/system/Cache/Handlers/RedisSentinel.php index b6654fd70154..edcb09fbd67f 100644 --- a/system/Cache/Handlers/RedisSentinel.php +++ b/system/Cache/Handlers/RedisSentinel.php @@ -22,9 +22,10 @@ * * phpredis has no built-in Sentinel failover handling, so the Redis cache * and session handlers use this utility to resolve the master address before - * connecting. It prefers the `RedisSentinel` class (phpredis >= 5.3) and - * falls back to a plain `SENTINEL get-master-addr-by-name` command for older - * versions. + * connecting. It sends a plain `SENTINEL get-master-addr-by-name` command to + * each node, which works on every phpredis version without relying on the + * `RedisSentinel` class (whose constructor differs between phpredis 5.x and + * 6.x). */ class RedisSentinel { @@ -79,24 +80,6 @@ public static function discoverMaster(array $nodes, string $service, float $time */ private static function queryNode(string $host, int $port, string $service, float $timeout): ?array { - // Prefer the dedicated RedisSentinel class (phpredis >= 5.3). - if (class_exists(\RedisSentinel::class)) { - try { - $sentinel = new \RedisSentinel($host, $port, $timeout); - $result = $sentinel->getMasterAddrByName($service); - - if ($result === false) { - return null; - } - - return self::normalise($result); - } catch (RedisException) { - // Node unreachable or command failed; try the next one. - return null; - } - } - - // Fall back to the SENTINEL command on a plain Redis connection. try { $redis = new Redis(); $redis->connect($host, $port, $timeout); @@ -115,16 +98,16 @@ private static function queryNode(string $host, int $port, string $service, floa return self::normalise($result); } catch (RedisException) { + // Node unreachable or command failed; try the next one. return null; } } /** - * Normalises the varied reply shapes into a [host, port] pair. + * Normalises the flat `['host', 'port']` reply into a typed pair. * - * phpredis returns either a flat `['host', 'port']` list (rawCommand and - * most RedisSentinel builds) or an associative `[['ip' => .., 'port' => ..]]` - * shape on some builds. Both are coerced to `array{0:string, 1:int}`. + * `SENTINEL get-master-addr-by-name` returns a two-element list, e.g. + * `['127.0.0.1', '6379']`. * * @param array $result * @@ -132,17 +115,8 @@ private static function queryNode(string $host, int $port, string $service, floa */ private static function normalise(array $result): ?array { - // Some RedisSentinel builds wrap the entry in an outer array. - $entry = array_is_list($result) && isset($result[0]) && is_array($result[0]) - ? $result[0] - : $result; - - if (isset($entry['ip'], $entry['port'])) { - return [(string) $entry['ip'], (int) $entry['port']]; - } - - if (isset($entry[0], $entry[1]) && is_string($entry[0]) && (is_string($entry[1]) || is_int($entry[1]))) { - return [(string) $entry[0], (int) $entry[1]]; + if (isset($result[0], $result[1]) && is_string($result[0]) && (is_string($result[1]) || is_int($result[1]))) { + return [(string) $result[0], (int) $result[1]]; } return null; From fed5b60648392f5855d954a676ef923e21466742 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean Date: Mon, 3 Aug 2026 00:36:41 +0700 Subject: [PATCH 4/4] test: skip live Sentinel tests when no Sentinel is reachable CI only provides a plain Redis server on 127.0.0.1:6379, not a Redis Sentinel. The live Sentinel tests therefore failed in CI: master discovery threw, CacheFactory::getHandler fell back to the DummyHandler, whose save() always returns true but stores nothing, so get() returned null. Probe for a Sentinel on 127.0.0.1:26379 and mark the test skipped when none is reachable. Locally (or in CI with a Sentinel service added) the tests run as before. Co-Authored-By: Claude --- .../system/Cache/Handlers/PredisHandlerTest.php | 10 +++++++++- .../system/Cache/Handlers/RedisHandlerTest.php | 10 +++++++++- .../system/Cache/Handlers/RedisSentinelTest.php | 17 +++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/tests/system/Cache/Handlers/PredisHandlerTest.php b/tests/system/Cache/Handlers/PredisHandlerTest.php index 0b9e263fb90c..63b5d9ba4b22 100644 --- a/tests/system/Cache/Handlers/PredisHandlerTest.php +++ b/tests/system/Cache/Handlers/PredisHandlerTest.php @@ -201,10 +201,18 @@ public function testReconnect(): void * Live test that runs when predis/predis is installed (see the class-level * Group('CacheLive') attribute). It assumes a local Redis Sentinel is * reachable at 127.0.0.1:26379 monitoring the "mymaster" service, mirroring - * how the other live Redis tests assume a server on 127.0.0.1:6379. + * how the other live Redis tests assume a server on 127.0.0.1:6379. Skipped + * when no Sentinel is running (CI only provides a plain Redis server). */ public function testInitializeWithSentinel(): void { + $socket = @stream_socket_client('tcp://127.0.0.1:26379', $errno, $errstr, 1.0); + + if ($socket === false) { + $this->markTestSkipped('Redis Sentinel not reachable at 127.0.0.1:26379.'); + } + + fclose($socket); $config = new Cache(); $config->redis = [ 'sentinel' => [ diff --git a/tests/system/Cache/Handlers/RedisHandlerTest.php b/tests/system/Cache/Handlers/RedisHandlerTest.php index 6bd2c7e00ae9..f0824a03334c 100644 --- a/tests/system/Cache/Handlers/RedisHandlerTest.php +++ b/tests/system/Cache/Handlers/RedisHandlerTest.php @@ -243,10 +243,18 @@ public function testReconnect(): void * Live test that runs when the redis extension is loaded (see the class-level * RequiresPhpExtension attribute). It assumes a local Redis Sentinel is * reachable at 127.0.0.1:26379 monitoring the "mymaster" service, mirroring - * how the other live Redis tests assume a server on 127.0.0.1:6379. + * how the other live Redis tests assume a server on 127.0.0.1:6379. Skipped + * when no Sentinel is running (CI only provides a plain Redis server). */ public function testInitializeWithSentinel(): void { + $socket = @stream_socket_client('tcp://127.0.0.1:26379', $errno, $errstr, 1.0); + + if ($socket === false) { + $this->markTestSkipped('Redis Sentinel not reachable at 127.0.0.1:26379.'); + } + + fclose($socket); $config = new Cache(); $config->redis = [ 'sentinel' => [ diff --git a/tests/system/Cache/Handlers/RedisSentinelTest.php b/tests/system/Cache/Handlers/RedisSentinelTest.php index cbd92138978f..c077fa845616 100644 --- a/tests/system/Cache/Handlers/RedisSentinelTest.php +++ b/tests/system/Cache/Handlers/RedisSentinelTest.php @@ -32,6 +32,22 @@ public function testDiscoverMasterThrowsWhenNoNodes(): void RedisSentinel::discoverMaster([], 'mymaster'); } + /** + * Skips the test when no Redis Sentinel is reachable at 127.0.0.1:26379. + * This keeps CI (which only provides a plain Redis server) green while the + * live test still runs locally when a Sentinel is available. + */ + private function skipUnlessSentinel(): void + { + $socket = @stream_socket_client('tcp://127.0.0.1:26379', $errno, $errstr, 1.0); + + if ($socket === false) { + $this->markTestSkipped('Redis Sentinel not reachable at 127.0.0.1:26379.'); + } + + fclose($socket); + } + #[RequiresPhpExtension('redis')] public function testDiscoverMasterThrowsWhenAllNodesUnreachable(): void { @@ -55,6 +71,7 @@ public function testDiscoverMasterThrowsWhenAllNodesUnreachable(): void #[RequiresPhpExtension('redis')] public function testDiscoverMasterLive(): void { + $this->skipUnlessSentinel(); $address = RedisSentinel::discoverMaster( [['host' => '127.0.0.1', 'port' => 26379]], 'mymaster',