Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion app/Config/Cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,25 @@ 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,
* port?: int,
* timeout?: int,
* async?: bool,
* persistent?: bool,
* database?: int
* database?: int,
* sentinel?: array{
* service?: string,
* nodes?: list<array{host: string, port?: int, scheme?: string}>,
* timeout?: float
* }
* }
*/
public array $redis = [
Expand All @@ -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' => [],
];

/**
Expand Down
34 changes: 34 additions & 0 deletions app/Config/Session.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<array{host: string, port?: int}>,
* 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
Expand Down
66 changes: 64 additions & 2 deletions system/Cache/Handlers/PredisHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<array{scheme?: string, host: string, port?: int}>
* }
* }
*/
protected $config = [
Expand All @@ -52,6 +58,7 @@ class PredisHandler extends BaseHandler implements LockStoreProviderInterface
'async' => false,
'persistent' => false,
'timeout' => 0,
'sentinel' => [],
];

/**
Expand All @@ -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);
Expand Down
25 changes: 24 additions & 1 deletion system/Cache/Handlers/RedisHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use Config\Cache;
use Redis;
use RedisException;
use RuntimeException;

/**
* Redis cache handler
Expand All @@ -39,6 +40,11 @@ class RedisHandler extends BaseHandler implements LockStoreProviderInterface
* timeout: int,
* persistent: bool,
* database: int,
* sentinel?: array{
* service?: string,
* nodes?: list<array{host: string, port?: int}>,
* timeout?: float
* }
* }
*/
protected $config = [
Expand All @@ -48,6 +54,7 @@ class RedisHandler extends BaseHandler implements LockStoreProviderInterface
'timeout' => 0,
'persistent' => false,
'database' => 0,
'sentinel' => [],
];

/**
Expand Down Expand Up @@ -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.');

Expand All @@ -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);
}
}

Expand Down
124 changes: 124 additions & 0 deletions system/Cache/Handlers/RedisSentinel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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<array{host: string, port?: int}> $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<array-key, mixed> $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;
}
}
11 changes: 6 additions & 5 deletions system/Language/en/Session.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}".',
];
8 changes: 8 additions & 0 deletions system/Session/Exceptions/SessionException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]));
}
}
Loading
Loading