diff --git a/app/Config/Cache.php b/app/Config/Cache.php index 38ac5419d84c..3bfcdf3cae33 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..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 @@ -41,7 +43,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 +58,7 @@ class PredisHandler extends BaseHandler implements LockStoreProviderInterface 'async' => false, 'persistent' => false, 'timeout' => 0, + 'sentinel' => [], ]; /** @@ -76,14 +83,69 @@ public function __construct(Cache $config) public function initialize(): void { try { - $this->redis = new Client($this->config, ['prefix' => $this->prefix]); + // 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'] ?? []) !== []) { + [$host, $port] = $this->discoverMasterFromSentinel(); + + $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/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..edcb09fbd67f --- /dev/null +++ b/system/Cache/Handlers/RedisSentinel.php @@ -0,0 +1,124 @@ + + * + * 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 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 +{ + /** + * 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 + { + 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) { + // Node unreachable or command failed; try the next one. + return null; + } + } + + /** + * Normalises the flat `['host', 'port']` reply into a typed pair. + * + * `SENTINEL get-master-addr-by-name` returns a two-element list, e.g. + * `['127.0.0.1', '6379']`. + * + * @param array $result + * + * @return array{0: string, 1: int}|null + */ + private static function normalise(array $result): ?array + { + 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; + } +} 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..63b5d9ba4b22 100644 --- a/tests/system/Cache/Handlers/PredisHandlerTest.php +++ b/tests/system/Cache/Handlers/PredisHandlerTest.php @@ -196,4 +196,36 @@ 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. 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' => [ + '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..f0824a03334c 100644 --- a/tests/system/Cache/Handlers/RedisHandlerTest.php +++ b/tests/system/Cache/Handlers/RedisHandlerTest.php @@ -238,4 +238,37 @@ 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. 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' => [ + '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..c077fa845616 --- /dev/null +++ b/tests/system/Cache/Handlers/RedisSentinelTest.php @@ -0,0 +1,86 @@ + + * + * 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'); + } + + /** + * 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 + { + $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 + { + $this->skipUnlessSentinel(); + $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, + ]; + + // ... +}