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
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -321,3 +321,55 @@ jobs:
echo "PHP didn't raise any warnings at startup."
- name: Inspect extension
run: php --ri redis

sentinel-multihost:
runs-on: ubuntu-latest
continue-on-error: false
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: :redis
coverage: none
tools: none

- name: Start Sentinel cluster
working-directory: tests/sentinel-multihost
run: |
docker compose up -d
for p in 26379 26380 26381; do
for i in 1 2 3 4 5 6 7 8 9 10; do
if nc -z 127.0.0.1 $p 2>/dev/null; then
echo "sentinel $p up"; break
fi
echo "waiting for sentinel $p..."; sleep 0.5
done
done

- name: Build extension
run: |
phpize
./configure --enable-redis
make -j"$(nproc)"

- name: Run multi-host tests
run: |
php -d extension=modules/redis.so tests/TestRedis.php --class redissentinelmultihost

- name: Dump docker logs on failure
if: failure()
working-directory: tests/sentinel-multihost
run: docker compose logs

- name: Stop Sentinel cluster
if: always()
working-directory: tests/sentinel-multihost
run: docker compose down
9 changes: 9 additions & 0 deletions common.h
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,10 @@ typedef struct RedisHello {
zend_string *version;
} RedisHello;

/* Forward decl; full definition in sentinel_library.h. RedisSock only holds
* a pointer, so incomplete type suffices here (issue #2819). */
typedef struct sentinel_host_entry sentinel_host_entry;

/* {{{ struct RedisSock */
typedef struct {
php_stream *stream;
Expand Down Expand Up @@ -301,6 +305,11 @@ typedef struct {
zend_bool null_mbulk_as_null;
zend_bool tcp_keepalive;
zend_bool sentinel;
/* Multi-host fallback list for RedisSentinel (issue #2819). NULL on
* non-Sentinel sockets and on single-host Sentinel usage. */
sentinel_host_entry *sentinel_hosts;
size_t sentinel_hosts_count;
size_t sentinel_current_host_idx;
size_t txBytes;
size_t rxBytes;
uint8_t flags;
Expand Down
48 changes: 35 additions & 13 deletions redis_sentinel.c
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ PHP_METHOD(RedisSentinel, __construct)
{
HashTable *opts = NULL;
redis_sentinel_object *sentinel;
zval *hosts_zv;

ZEND_PARSE_PARAMETERS_START(0, 1)
Z_PARAM_OPTIONAL
Expand All @@ -51,63 +52,84 @@ PHP_METHOD(RedisSentinel, __construct)

sentinel = PHPREDIS_ZVAL_GET_OBJECT(redis_sentinel_object, getThis());
sentinel->sock = redis_sock_create(ZEND_STRL("127.0.0.1"), 26379, 0, 0, 0, NULL, 0);
if (opts != NULL && redis_sock_configure(sentinel->sock, opts) != SUCCESS) {
RETURN_THROWS();

if (opts != NULL) {
/* 'hosts' is parsed here (not in redis_sock_configure) so we can strip
* it along with the now-irrelevant 'host'/'port' before configure sees
* the table. Without the strip, configure would overwrite hosts[0]. */
hosts_zv = zend_hash_str_find(opts, ZEND_STRL("hosts"));
if (hosts_zv != NULL) {
if (sentinel_parse_hosts_option(sentinel->sock, hosts_zv) != SUCCESS) {
RETURN_THROWS();
}
if (sentinel->sock->host) zend_string_release(sentinel->sock->host);
sentinel->sock->host = zend_string_copy(sentinel->sock->sentinel_hosts[0].host);
sentinel->sock->port = sentinel->sock->sentinel_hosts[0].port;

zend_hash_str_del(opts, ZEND_STRL("hosts"));
zend_hash_str_del(opts, ZEND_STRL("host"));
zend_hash_str_del(opts, ZEND_STRL("port"));
}

if (redis_sock_configure(sentinel->sock, opts) != SUCCESS) {
RETURN_THROWS();
}
}

sentinel->sock->sentinel = 1;
}

PHP_METHOD(RedisSentinel, ckquorum)
{
REDIS_PROCESS_KW_CMD("ckquorum", redis_sentinel_str_cmd, redis_boolean_response);
SENTINEL_METHOD(REDIS_PROCESS_KW_CMD("ckquorum", redis_sentinel_str_cmd, redis_boolean_response));
}

PHP_METHOD(RedisSentinel, failover)
{
REDIS_PROCESS_KW_CMD("failover", redis_sentinel_str_cmd, redis_boolean_response);
SENTINEL_METHOD(REDIS_PROCESS_KW_CMD("failover", redis_sentinel_str_cmd, redis_boolean_response));
}

PHP_METHOD(RedisSentinel, flushconfig)
{
REDIS_PROCESS_KW_CMD("flushconfig", redis_sentinel_cmd, redis_boolean_response);
SENTINEL_METHOD(REDIS_PROCESS_KW_CMD("flushconfig", redis_sentinel_cmd, redis_boolean_response));
}

PHP_METHOD(RedisSentinel, getMasterAddrByName)
{
REDIS_PROCESS_KW_CMD("get-master-addr-by-name", redis_sentinel_str_cmd, redis_mbulk_reply_raw);
SENTINEL_METHOD(REDIS_PROCESS_KW_CMD("get-master-addr-by-name", redis_sentinel_str_cmd, redis_mbulk_reply_raw));
}

PHP_METHOD(RedisSentinel, master)
{
REDIS_PROCESS_KW_CMD("master", redis_sentinel_str_cmd, redis_mbulk_reply_zipped_raw);
SENTINEL_METHOD(REDIS_PROCESS_KW_CMD("master", redis_sentinel_str_cmd, redis_mbulk_reply_zipped_raw));
}

PHP_METHOD(RedisSentinel, masters)
{
REDIS_PROCESS_KW_CMD("masters", redis_sentinel_cmd, sentinel_mbulk_reply_zipped_assoc);
SENTINEL_METHOD(REDIS_PROCESS_KW_CMD("masters", redis_sentinel_cmd, sentinel_mbulk_reply_zipped_assoc));
}

PHP_METHOD(RedisSentinel, myid)
{
REDIS_PROCESS_KW_CMD("myid", redis_sentinel_cmd, redis_string_response);
SENTINEL_METHOD(REDIS_PROCESS_KW_CMD("myid", redis_sentinel_cmd, redis_string_response));
}

PHP_METHOD(RedisSentinel, ping)
{
REDIS_PROCESS_KW_CMD("ping", redis_empty_cmd, redis_boolean_response);
SENTINEL_METHOD(REDIS_PROCESS_KW_CMD("ping", redis_empty_cmd, redis_boolean_response));
}

PHP_METHOD(RedisSentinel, reset)
{
REDIS_PROCESS_KW_CMD("reset", redis_sentinel_str_cmd, redis_long_response);
SENTINEL_METHOD(REDIS_PROCESS_KW_CMD("reset", redis_sentinel_str_cmd, redis_long_response));
}

PHP_METHOD(RedisSentinel, sentinels)
{
REDIS_PROCESS_KW_CMD("sentinels", redis_sentinel_str_cmd, sentinel_mbulk_reply_zipped_assoc);
SENTINEL_METHOD(REDIS_PROCESS_KW_CMD("sentinels", redis_sentinel_str_cmd, sentinel_mbulk_reply_zipped_assoc));
}

PHP_METHOD(RedisSentinel, slaves)
{
REDIS_PROCESS_KW_CMD("slaves", redis_sentinel_str_cmd, sentinel_mbulk_reply_zipped_assoc);
SENTINEL_METHOD(REDIS_PROCESS_KW_CMD("slaves", redis_sentinel_str_cmd, sentinel_mbulk_reply_zipped_assoc));
}
15 changes: 15 additions & 0 deletions redis_sentinel.stub.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@

class RedisSentinel {

/**
* @param array|null $options Connection options. Accepts:
* - 'host' (string, default '127.0.0.1') - single Sentinel host
* - 'port' (int, default 26379) - single Sentinel port
* - 'hosts' (list<array{host:string,port?:int}>) - multiple Sentinel
* hosts. When provided, 'host' and 'port' are ignored and
* the client automatically falls back to the next host
* on network failure. See issue #2819.
* - 'connectTimeout' (float)
* - 'persistent' (?string)
* - 'retryInterval' (int)
* - 'readTimeout' (float)
* - 'auth' (string|array)
* - 'ssl' (array)
*/
public function __construct(?array $options = null);

/** @return bool|RedisSentinel */
Expand Down
8 changes: 3 additions & 5 deletions redis_sentinel_arginfo.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* This is a generated file, edit the .stub.php file instead.
* Stub hash: ca40579af888c5bb0661cd0201d840297474479a */
* Stub hash: 65a689d40abaa87e77542700a99742b597051699 */

ZEND_BEGIN_ARG_INFO_EX(arginfo_class_RedisSentinel___construct, 0, 0, 0)
ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, options, IS_ARRAY, 1, "null")
Expand Down Expand Up @@ -33,6 +33,7 @@ ZEND_END_ARG_INFO()

#define arginfo_class_RedisSentinel_slaves arginfo_class_RedisSentinel_ckquorum


ZEND_METHOD(RedisSentinel, __construct);
ZEND_METHOD(RedisSentinel, ckquorum);
ZEND_METHOD(RedisSentinel, failover);
Expand All @@ -46,6 +47,7 @@ ZEND_METHOD(RedisSentinel, reset);
ZEND_METHOD(RedisSentinel, sentinels);
ZEND_METHOD(RedisSentinel, slaves);


static const zend_function_entry class_RedisSentinel_methods[] = {
ZEND_ME(RedisSentinel, __construct, arginfo_class_RedisSentinel___construct, ZEND_ACC_PUBLIC)
ZEND_ME(RedisSentinel, ckquorum, arginfo_class_RedisSentinel_ckquorum, ZEND_ACC_PUBLIC)
Expand All @@ -67,11 +69,7 @@ static zend_class_entry *register_class_RedisSentinel(void)
zend_class_entry ce, *class_entry;

INIT_CLASS_ENTRY(ce, "RedisSentinel", class_RedisSentinel_methods);
#if (PHP_VERSION_ID >= 80400)
class_entry = zend_register_internal_class_with_flags(&ce, NULL, 0);
#else
class_entry = zend_register_internal_class_ex(&ce, NULL);
#endif

return class_entry;
}
8 changes: 3 additions & 5 deletions redis_sentinel_legacy_arginfo.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* This is a generated file, edit the .stub.php file instead.
* Stub hash: ca40579af888c5bb0661cd0201d840297474479a */
* Stub hash: 65a689d40abaa87e77542700a99742b597051699 */

ZEND_BEGIN_ARG_INFO_EX(arginfo_class_RedisSentinel___construct, 0, 0, 0)
ZEND_ARG_INFO(0, options)
Expand Down Expand Up @@ -32,6 +32,7 @@ ZEND_END_ARG_INFO()

#define arginfo_class_RedisSentinel_slaves arginfo_class_RedisSentinel_ckquorum


ZEND_METHOD(RedisSentinel, __construct);
ZEND_METHOD(RedisSentinel, ckquorum);
ZEND_METHOD(RedisSentinel, failover);
Expand All @@ -45,6 +46,7 @@ ZEND_METHOD(RedisSentinel, reset);
ZEND_METHOD(RedisSentinel, sentinels);
ZEND_METHOD(RedisSentinel, slaves);


static const zend_function_entry class_RedisSentinel_methods[] = {
ZEND_ME(RedisSentinel, __construct, arginfo_class_RedisSentinel___construct, ZEND_ACC_PUBLIC)
ZEND_ME(RedisSentinel, ckquorum, arginfo_class_RedisSentinel_ckquorum, ZEND_ACC_PUBLIC)
Expand All @@ -66,11 +68,7 @@ static zend_class_entry *register_class_RedisSentinel(void)
zend_class_entry ce, *class_entry;

INIT_CLASS_ENTRY(ce, "RedisSentinel", class_RedisSentinel_methods);
#if (PHP_VERSION_ID >= 80400)
class_entry = zend_register_internal_class_with_flags(&ce, NULL, 0);
#else
class_entry = zend_register_internal_class_ex(&ce, NULL);
#endif

return class_entry;
}
31 changes: 31 additions & 0 deletions sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Redis Sentinel also provides other collateral tasks such as monitoring, notifica

*host*: String, IP address or hostname
*port*: Int (optional, default is 26379)
*hosts*: Array of `['host' => string, 'port' => int]` entries (optional). When provided, `host` and `port` are ignored and the client automatically falls back to the next host on network failure. See [Multi-host support](#multi-host-support) below.
*timeout*: Float, value in seconds (optional, default is 0 meaning unlimited)
*persistent*: String, persistent connection id (optional, default is NULL meaning not persistent)
*retry_interval*: Int, value in milliseconds (optional, default is 0)
Expand Down Expand Up @@ -59,6 +60,36 @@ $sentinel = new RedisSentinel([
]); // connect sentinel with password authentication
~~~

### Multi-host support
-----

For high-availability deployments (Kubernetes, multi-AZ), `RedisSentinel` accepts a `hosts` array of Sentinel endpoints. On network failure the client transparently falls back to the next entry in the list, so a single dead Sentinel no longer takes down the client.

~~~php
$sentinel = new RedisSentinel([
'hosts' => [
['host' => '10.0.0.1', 'port' => 26379],
['host' => '10.0.0.2', 'port' => 26379],
['host' => '10.0.0.3', 'port' => 26379],
],
'connectTimeout' => 0.1,
'auth' => 'secret',
]);

// Auto-falls-back to a reachable host if 10.0.0.1 is down.
$master = $sentinel->getMasterAddrByName('mymaster');
~~~

##### *Semantics*

* When `hosts` is provided, `host` and `port` are ignored.
* The client tries hosts in order. The first reachable host is used for all subsequent calls ("sticky" connection).
* If the current host becomes unreachable during a method call, the client transparently advances to the next host in the list and retries the call once.
* Skipped hosts are NOT revisited for the lifetime of the `RedisSentinel` instance.
* When all hosts are exhausted, a `RedisException` is thrown with a message mentioning the host count.
* Retry is triggered only on network errors (connection refused, socket EOF, stream broken). Redis protocol errors (NOAUTH, WRONGPASS, unknown command) are propagated without retry.
* Validation errors at construct time (empty `hosts`, missing `host` key, wrong types, `hosts` too large) throw `RedisException`, consistent with the rest of phpredis.

##### *Examples for versions older than 6.0*

~~~php
Expand Down
Loading