Skip to content

Commit 8eca63c

Browse files
ddevsrclaude
andcommitted
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 env-gated live tests for the cache handlers. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 1c093bd commit 8eca63c

16 files changed

Lines changed: 617 additions & 11 deletions

File tree

app/Config/Cache.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,14 +113,25 @@ class Cache extends BaseConfig
113113
* Your Redis server can be specified below, if you are using
114114
* the Redis or Predis drivers.
115115
*
116+
* To connect through Redis Sentinel, populate the `sentinel` key with the
117+
* master service name and the list of Sentinel nodes. When `sentinel` is
118+
* non-empty, `host`/`port` are ignored by the Redis handler (phpredis),
119+
* and the Predis handler replaces its single-node connection with the
120+
* Sentinel nodes.
121+
*
116122
* @var array{
117123
* host?: string,
118124
* password?: string|null,
119125
* port?: int,
120126
* timeout?: int,
121127
* async?: bool,
122128
* persistent?: bool,
123-
* database?: int
129+
* database?: int,
130+
* sentinel?: array{
131+
* service?: string,
132+
* nodes?: list<array{host: string, port?: int, scheme?: string}>,
133+
* timeout?: float
134+
* }
124135
* }
125136
*/
126137
public array $redis = [
@@ -131,6 +142,7 @@ class Cache extends BaseConfig
131142
'async' => false, // specific to Predis and ignored by the native Redis extension
132143
'persistent' => false,
133144
'database' => 0,
145+
'sentinel' => [],
134146
];
135147

136148
/**

app/Config/Session.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,40 @@ class Session extends BaseConfig
6060
*/
6161
public string $savePath = WRITEPATH . 'session';
6262

63+
/**
64+
* --------------------------------------------------------------------------
65+
* Redis Sentinel Settings
66+
* --------------------------------------------------------------------------
67+
*
68+
* Used by the RedisHandler session driver to connect through Redis
69+
* Sentinel instead of a single fixed host. When `nodes` is non-empty,
70+
* the handler queries the Sentinel nodes for the current master of the
71+
* named `service` and connects to it, and `$savePath` is ignored.
72+
*
73+
* Requires the `redis` PHP extension (phpredis >= 5.3 recommended; older
74+
* versions work via the SENTINEL command).
75+
*
76+
* @var array{
77+
* service?: string,
78+
* nodes?: list<array{host: string, port?: int}>,
79+
* timeout?: float,
80+
* persistent?: bool,
81+
* password?: string|null,
82+
* database?: int
83+
* }
84+
*/
85+
public array $sentinel = [
86+
// 'service' => 'mymaster',
87+
// 'nodes' => [
88+
// ['host' => '127.0.0.1', 'port' => 26379],
89+
// ['host' => 'sentinel2', 'port' => 26379],
90+
// ],
91+
// 'timeout' => 0.5,
92+
// 'persistent' => false,
93+
// 'password' => null,
94+
// 'database' => 0,
95+
];
96+
6397
/**
6498
* --------------------------------------------------------------------------
6599
* Session Match IP

system/Cache/Handlers/PredisHandler.php

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ class PredisHandler extends BaseHandler implements LockStoreProviderInterface
4141
* port: int,
4242
* async: bool,
4343
* persistent: bool,
44-
* timeout: int
44+
* timeout: int,
45+
* sentinel?: array{
46+
* service?: string,
47+
* nodes?: list<array{scheme?: string, host: string, port?: int}>
48+
* }
4549
* }
4650
*/
4751
protected $config = [
@@ -52,6 +56,7 @@ class PredisHandler extends BaseHandler implements LockStoreProviderInterface
5256
'async' => false,
5357
'persistent' => false,
5458
'timeout' => 0,
59+
'sentinel' => [],
5560
];
5661

5762
/**
@@ -76,7 +81,36 @@ public function __construct(Cache $config)
7681
public function initialize(): void
7782
{
7883
try {
79-
$this->redis = new Client($this->config, ['prefix' => $this->prefix]);
84+
// Predis has native Sentinel support: pass the Sentinel nodes plus a
85+
// replication/service option and it discovers and follows the master.
86+
if (($this->config['sentinel']['nodes'] ?? []) !== []) {
87+
$nodes = array_map(
88+
static fn (array $node): array => [
89+
'scheme' => $node['scheme'] ?? 'tcp',
90+
'host' => $node['host'],
91+
'port' => $node['port'] ?? 26379,
92+
],
93+
$this->config['sentinel']['nodes'],
94+
);
95+
$options = [
96+
'prefix' => $this->prefix,
97+
'replication' => 'sentinel',
98+
'service' => $this->config['sentinel']['service'],
99+
];
100+
101+
// `parameters` are applied to the connections resolved by Sentinel.
102+
if (isset($this->config['password'])) {
103+
$options['parameters']['password'] = $this->config['password'];
104+
}
105+
if (isset($this->config['database'])) {
106+
$options['parameters']['database'] = $this->config['database'];
107+
}
108+
109+
$this->redis = new Client($nodes, $options);
110+
} else {
111+
$this->redis = new Client($this->config, ['prefix' => $this->prefix]);
112+
}
113+
80114
$this->lockStore = null;
81115
$this->redis->time();
82116
} catch (Exception $e) {

system/Cache/Handlers/RedisHandler.php

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use Config\Cache;
2222
use Redis;
2323
use RedisException;
24+
use RuntimeException;
2425

2526
/**
2627
* Redis cache handler
@@ -39,6 +40,11 @@ class RedisHandler extends BaseHandler implements LockStoreProviderInterface
3940
* timeout: int,
4041
* persistent: bool,
4142
* database: int,
43+
* sentinel?: array{
44+
* service?: string,
45+
* nodes?: list<array{host: string, port?: int}>,
46+
* timeout?: float
47+
* }
4248
* }
4349
*/
4450
protected $config = [
@@ -48,6 +54,7 @@ class RedisHandler extends BaseHandler implements LockStoreProviderInterface
4854
'timeout' => 0,
4955
'persistent' => false,
5056
'database' => 0,
57+
'sentinel' => [],
5158
];
5259

5360
/**
@@ -79,9 +86,23 @@ public function initialize(): void
7986
try {
8087
$funcConnection = isset($config['persistent']) && $config['persistent'] ? 'pconnect' : 'connect';
8188

89+
// When a Sentinel cluster is configured, discover the current master
90+
// address before connecting; otherwise fall back to the single host.
91+
if (($config['sentinel']['nodes'] ?? []) !== []) {
92+
[$host, $port] = RedisSentinel::discoverMaster(
93+
$config['sentinel']['nodes'],
94+
$config['sentinel']['service'],
95+
(float) ($config['sentinel']['timeout'] ?? 0),
96+
);
97+
} else {
98+
$host = $config['host'];
99+
// Unix domain sockets are passed as the host with a port of 0.
100+
$port = $config['host'][0] === '/' ? 0 : $config['port'];
101+
}
102+
82103
// Note:: If Redis is your primary cache choice, and it is "offline", every page load will end up been delayed by the timeout duration.
83104
// 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.
84-
if (! $this->redis->{$funcConnection}($config['host'], ($config['host'][0] === '/' ? 0 : $config['port']), $config['timeout'])) {
105+
if (! $this->redis->{$funcConnection}($host, $port, $config['timeout'])) {
85106
// Note:: I'm unsure if log_message() is necessary, however I'm not 100% comfortable removing it.
86107
log_message('error', 'Cache: Redis connection failed. Check your configuration.');
87108

@@ -101,6 +122,8 @@ public function initialize(): void
101122
}
102123
} catch (RedisException $e) {
103124
throw new CriticalError('Cache: RedisException occurred with message (' . $e->getMessage() . ').', $e->getCode(), $e);
125+
} catch (RuntimeException $e) {
126+
throw new CriticalError('Cache: ' . $e->getMessage(), $e->getCode(), $e);
104127
}
105128
}
106129

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Cache\Handlers;
15+
16+
use Redis;
17+
use RedisException;
18+
use RuntimeException;
19+
20+
/**
21+
* Discovers the current Redis master from a list of Sentinel nodes.
22+
*
23+
* phpredis has no built-in Sentinel failover handling, so the Redis cache
24+
* and session handlers use this utility to resolve the master address before
25+
* connecting. It prefers the `RedisSentinel` class (phpredis >= 5.3) and
26+
* falls back to a plain `SENTINEL get-master-addr-by-name` command for older
27+
* versions.
28+
*/
29+
class RedisSentinel
30+
{
31+
/**
32+
* Default Sentinel port.
33+
*/
34+
private const DEFAULT_SENTINEL_PORT = 26379;
35+
36+
/**
37+
* Queries the given Sentinel nodes for the address of the named master.
38+
*
39+
* Each node is tried in order; the first one that answers wins. When no
40+
* node can return the master address a RuntimeException is thrown so the
41+
* caller can surface a clear error.
42+
*
43+
* @param list<array{host: string, port?: int}> $nodes Sentinel nodes to query.
44+
* @param string $service Sentinel master name, e.g. "mymaster".
45+
* @param float $timeout Connection timeout (seconds) per node.
46+
*
47+
* @return array{0: string, 1: int} The master host and port.
48+
*
49+
* @throws RuntimeException When no Sentinel node can discover the master.
50+
*/
51+
public static function discoverMaster(array $nodes, string $service, float $timeout = 0.0): array
52+
{
53+
if ($nodes === []) {
54+
throw new RuntimeException('No Redis Sentinel nodes configured.');
55+
}
56+
57+
foreach ($nodes as $node) {
58+
$host = $node['host'] ?? '';
59+
$port = $node['port'] ?? self::DEFAULT_SENTINEL_PORT;
60+
61+
if ($host === '') {
62+
continue;
63+
}
64+
65+
$address = self::queryNode($host, (int) $port, $service, $timeout);
66+
67+
if ($address !== null) {
68+
return $address;
69+
}
70+
}
71+
72+
throw new RuntimeException(sprintf('Redis Sentinel unable to discover master "%s".', $service));
73+
}
74+
75+
/**
76+
* Queries a single Sentinel node for the master address.
77+
*
78+
* @return array{0: string, 1: int}|null
79+
*/
80+
private static function queryNode(string $host, int $port, string $service, float $timeout): ?array
81+
{
82+
// Prefer the dedicated RedisSentinel class (phpredis >= 5.3).
83+
if (class_exists(\RedisSentinel::class)) {
84+
try {
85+
$sentinel = new \RedisSentinel($host, $port, $timeout);
86+
$result = $sentinel->getMasterAddrByName($service);
87+
88+
if ($result === false) {
89+
return null;
90+
}
91+
92+
return self::normalise($result);
93+
} catch (RedisException) {
94+
// Node unreachable or command failed; try the next one.
95+
return null;
96+
}
97+
}
98+
99+
// Fall back to the SENTINEL command on a plain Redis connection.
100+
try {
101+
$redis = new Redis();
102+
$redis->connect($host, $port, $timeout);
103+
104+
$result = $redis->rawcommand('SENTINEL', 'get-master-addr-by-name', $service);
105+
106+
try {
107+
$redis->close();
108+
} catch (RedisException) {
109+
// Connection already dead, that's fine.
110+
}
111+
112+
if ($result === false) {
113+
return null;
114+
}
115+
116+
return self::normalise($result);
117+
} catch (RedisException) {
118+
return null;
119+
}
120+
}
121+
122+
/**
123+
* Normalises the varied reply shapes into a [host, port] pair.
124+
*
125+
* phpredis returns either a flat `['host', 'port']` list (rawCommand and
126+
* most RedisSentinel builds) or an associative `[['ip' => .., 'port' => ..]]`
127+
* shape on some builds. Both are coerced to `array{0:string, 1:int}`.
128+
*
129+
* @param array<array-key, mixed> $result
130+
*
131+
* @return array{0: string, 1: int}|null
132+
*/
133+
private static function normalise(array $result): ?array
134+
{
135+
// Some RedisSentinel builds wrap the entry in an outer array.
136+
$entry = array_is_list($result) && isset($result[0]) && is_array($result[0])
137+
? $result[0]
138+
: $result;
139+
140+
if (isset($entry['ip'], $entry['port'])) {
141+
return [(string) $entry['ip'], (int) $entry['port']];
142+
}
143+
144+
if (isset($entry[0], $entry[1]) && is_string($entry[0]) && (is_string($entry[1]) || is_int($entry[1]))) {
145+
return [(string) $entry[0], (int) $entry[1]];
146+
}
147+
148+
return null;
149+
}
150+
}

system/Language/en/Session.php

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313

1414
// Session language settings
1515
return [
16-
'missingDatabaseTable' => 'Session: "savePath" must have the table name for the Database Session Handler to work.',
17-
'invalidSavePath' => 'Session: Configured save path "{0}" is not a directory, does not exist or cannot be created.',
18-
'writeProtectedSavePath' => 'Session: Configured save path "{0}" is not writable by the PHP process.',
19-
'emptySavePath' => 'Session: No save path configured.',
20-
'invalidSavePathFormat' => 'Session: Invalid Redis save path format: "{0}"',
16+
'missingDatabaseTable' => 'Session: "savePath" must have the table name for the Database Session Handler to work.',
17+
'invalidSavePath' => 'Session: Configured save path "{0}" is not a directory, does not exist or cannot be created.',
18+
'writeProtectedSavePath' => 'Session: Configured save path "{0}" is not writable by the PHP process.',
19+
'emptySavePath' => 'Session: No save path configured.',
20+
'invalidSavePathFormat' => 'Session: Invalid Redis save path format: "{0}"',
21+
'sentinelDiscoveryFailed' => 'Session: Redis Sentinel unable to discover master "{0}".',
2122
];

system/Session/Exceptions/SessionException.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,12 @@ public static function forInvalidSavePathFormat(string $path)
5656
{
5757
return new static(lang('Session.invalidSavePathFormat', [$path]));
5858
}
59+
60+
/**
61+
* @return static
62+
*/
63+
public static function forSentinelDiscoveryFailed(string $service)
64+
{
65+
return new static(lang('Session.sentinelDiscoveryFailed', [$service]));
66+
}
5967
}

0 commit comments

Comments
 (0)