From 02331038b025479793f84e23650e3084418321f6 Mon Sep 17 00:00:00 2001 From: modestas_sienauskas Date: Wed, 22 Apr 2026 11:37:19 +0300 Subject: [PATCH 1/4] feat: multi-host fallback support in RedisSentinel Adds a 'hosts' array option to RedisSentinel::__construct so the client transparently falls back to the next Sentinel endpoint on network failure, eliminating the need for userland workarounds in HA deployments. $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], ]]); Semantics: - Sticky connection: the first reachable host is used until it fails. - One command-level retry per call; a bounded linear scan inside sentinel_try_next_host iterates remaining hosts on a failed attempt. - Skipped hosts are not revisited for the instance lifetime. - Network error detection inspects RedisSock state (status, stream), not exception message strings. - Zero BC risk: when 'hosts' is absent, behavior is identical to today. Host list is stored on RedisSock as three new fields; sentinel_host_entry is forward-declared in common.h with the full struct in sentinel_library.h so redis_object / Redis / RedisCluster layouts are untouched. All 11 RedisSentinel methods wrap their REDIS_PROCESS_KW_CMD call in a new SENTINEL_METHOD macro that implements the retry. No-op on single-host. Refs #2819 --- common.h | 9 +++ redis_sentinel.c | 48 ++++++++++---- sentinel_library.c | 156 +++++++++++++++++++++++++++++++++++++++++++++ sentinel_library.h | 34 ++++++++++ 4 files changed, 234 insertions(+), 13 deletions(-) diff --git a/common.h b/common.h index 27e79d600d..baca57850c 100644 --- a/common.h +++ b/common.h @@ -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; @@ -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; diff --git a/redis_sentinel.c b/redis_sentinel.c index fdaa191f45..7c17e1b3b4 100644 --- a/redis_sentinel.c +++ b/redis_sentinel.c @@ -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 @@ -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)); } diff --git a/sentinel_library.c b/sentinel_library.c index bed1aca385..c229cd2bbd 100644 --- a/sentinel_library.c +++ b/sentinel_library.c @@ -1,13 +1,37 @@ #include "sentinel_library.h" +#include + +extern zend_class_entry *redis_exception_ce; + +/* Upper bound on hosts list length. Real HA deployments have 3-7 Sentinels; + * this is purely a DoS guard against runaway allocation. */ +#define SENTINEL_MAX_HOSTS 1024 static zend_object_handlers redis_sentinel_object_handlers; +void +sentinel_free_hosts(RedisSock *sock) +{ + size_t i; + if (sock == NULL || sock->sentinel_hosts == NULL) return; + for (i = 0; i < sock->sentinel_hosts_count; i++) { + if (sock->sentinel_hosts[i].host) { + zend_string_release(sock->sentinel_hosts[i].host); + } + } + efree(sock->sentinel_hosts); + sock->sentinel_hosts = NULL; + sock->sentinel_hosts_count = 0; + sock->sentinel_current_host_idx = 0; +} + static void free_redis_sentinel_object(zend_object *object) { redis_sentinel_object *obj = PHPREDIS_GET_OBJECT(redis_sentinel_object, object); if (obj->sock) { + sentinel_free_hosts(obj->sock); redis_sock_disconnect(obj->sock, 0, 1); redis_free_socket(obj->sock); } @@ -30,6 +54,138 @@ create_sentinel_object(zend_class_entry *ce) return &obj->std; } +static int +parse_one_host_entry(zval *entry, size_t i, sentinel_host_entry *out) +{ + zval *host_zv, *port_zv; + + if (Z_TYPE_P(entry) != IS_ARRAY) { + zend_throw_exception_ex(redis_exception_ce, 0, + "RedisSentinel: 'hosts' entry at index %zu must be an array", i); + return FAILURE; + } + + host_zv = zend_hash_str_find(Z_ARRVAL_P(entry), ZEND_STRL("host")); + if (host_zv == NULL) { + zend_throw_exception_ex(redis_exception_ce, 0, + "RedisSentinel: 'hosts' entry at index %zu missing required 'host' key", i); + return FAILURE; + } + if (Z_TYPE_P(host_zv) != IS_STRING) { + zend_throw_exception_ex(redis_exception_ce, 0, + "RedisSentinel: 'hosts' entry at index %zu: 'host' must be string", i); + return FAILURE; + } + + port_zv = zend_hash_str_find(Z_ARRVAL_P(entry), ZEND_STRL("port")); + if (port_zv == NULL) { + out->port = 26379; + } else if (Z_TYPE_P(port_zv) == IS_LONG) { + out->port = (int) Z_LVAL_P(port_zv); + } else { + zend_throw_exception_ex(redis_exception_ce, 0, + "RedisSentinel: 'hosts' entry at index %zu: 'port' must be int", i); + return FAILURE; + } + + out->host = zend_string_copy(Z_STR_P(host_zv)); + return SUCCESS; +} + +int +sentinel_parse_hosts_option(RedisSock *sock, zval *hosts_zv) +{ + HashTable *ht; + zval *entry; + size_t n, i = 0; + + if (Z_TYPE_P(hosts_zv) != IS_ARRAY) { + REDIS_THROW_EXCEPTION("RedisSentinel: 'hosts' must be an array", 0); + return FAILURE; + } + + ht = Z_ARRVAL_P(hosts_zv); + n = zend_hash_num_elements(ht); + if (n == 0) { + REDIS_THROW_EXCEPTION("RedisSentinel: 'hosts' must not be empty", 0); + return FAILURE; + } + if (n > SENTINEL_MAX_HOSTS) { + zend_throw_exception_ex(redis_exception_ce, 0, + "RedisSentinel: 'hosts' has %zu entries; max is %d", n, SENTINEL_MAX_HOSTS); + return FAILURE; + } + + sock->sentinel_hosts = ecalloc(n, sizeof(sentinel_host_entry)); + sock->sentinel_hosts_count = n; + sock->sentinel_current_host_idx = 0; + + ZEND_HASH_FOREACH_VAL(ht, entry) { + if (parse_one_host_entry(entry, i, &sock->sentinel_hosts[i]) != SUCCESS) { + sentinel_free_hosts(sock); + return FAILURE; + } + i++; + } ZEND_HASH_FOREACH_END(); + + return SUCCESS; +} + +zend_bool +sentinel_was_network_error(RedisSock *sock) +{ + if (sock == NULL) return 1; + if (sock->stream == NULL) return 1; + if (sock->status == REDIS_SOCK_STATUS_FAILED) return 1; + if (sock->status == REDIS_SOCK_STATUS_DISCONNECTED) return 1; + return 0; +} + +zend_bool +sentinel_has_more_hosts(RedisSock *sock) +{ + if (sock == NULL || sock->sentinel_hosts == NULL) return 0; + return (sock->sentinel_current_host_idx + 1) < sock->sentinel_hosts_count; +} + +int +sentinel_try_next_host(RedisSock *sock) +{ + size_t i; + + if (sock == NULL || sock->sentinel_hosts == NULL) return FAILURE; + + if (sock->stream) { + redis_sock_disconnect(sock, 0, 1); + } + + for (i = sock->sentinel_current_host_idx + 1; i < sock->sentinel_hosts_count; i++) { + /* Clear any exception set by a prior iteration's failed connect so it + * doesn't contaminate the next redis_sock_server_open attempt or leak + * into the caller if this iteration succeeds. */ + if (EG(exception)) zend_clear_exception(); + + if (sock->host) zend_string_release(sock->host); + sock->host = zend_string_copy(sock->sentinel_hosts[i].host); + sock->port = sock->sentinel_hosts[i].port; + + if (redis_sock_server_open(sock) == SUCCESS) { + sock->sentinel_current_host_idx = i; + if (EG(exception)) zend_clear_exception(); + return SUCCESS; + } + } + + { + sentinel_host_entry *last = &sock->sentinel_hosts[sock->sentinel_hosts_count - 1]; + if (EG(exception)) zend_clear_exception(); + zend_throw_exception_ex(redis_exception_ce, 0, + "Failed to connect to any of %zu Sentinel hosts (last attempted: %s:%d)", + sock->sentinel_hosts_count, ZSTR_VAL(last->host), last->port); + } + return FAILURE; +} + PHP_REDIS_API int sentinel_mbulk_reply_zipped_assoc(INTERNAL_FUNCTION_PARAMETERS, RedisSock *redis_sock, zval *z_tab, void *ctx) { diff --git a/sentinel_library.h b/sentinel_library.h index 88d9a564e7..b282b6c351 100644 --- a/sentinel_library.h +++ b/sentinel_library.h @@ -6,8 +6,42 @@ typedef redis_object redis_sentinel_object; +/* Multi-host fallback entry. One per user-provided Sentinel endpoint; + * the full list hangs off RedisSock->sentinel_hosts (issue #2819). */ +struct sentinel_host_entry { + zend_string *host; + int port; +}; + zend_object *create_sentinel_object(zend_class_entry *ce); PHP_REDIS_API int sentinel_mbulk_reply_zipped_assoc(INTERNAL_FUNCTION_PARAMETERS, RedisSock *redis_sock, zval *z_tab, void *ctx); +int sentinel_parse_hosts_option(RedisSock *sock, zval *hosts_zv); +int sentinel_try_next_host(RedisSock *sock); +zend_bool sentinel_was_network_error(RedisSock *sock); +zend_bool sentinel_has_more_hosts(RedisSock *sock); +void sentinel_free_hosts(RedisSock *sock); + +/* Resolve the RedisSentinel from PHP_METHOD context and wrap the command call + * with retry-once-on-network-error. Command-level retry is bounded to 1; the + * scan inside sentinel_try_next_host iterates the remaining host list. + * No-op on single-host usage — sentinel_has_more_hosts short-circuits. */ +#define SENTINEL_METHOD(cmd_call) \ + do { \ + redis_sentinel_object *__obj = \ + PHPREDIS_ZVAL_GET_OBJECT(redis_sentinel_object, getThis()); \ + RedisSock *__sock = __obj->sock; \ + cmd_call; \ + if (EG(exception) && \ + sentinel_was_network_error(__sock) && \ + sentinel_has_more_hosts(__sock)) { \ + OBJ_RELEASE(EG(exception)); \ + EG(exception) = NULL; \ + if (sentinel_try_next_host(__sock) == SUCCESS) { \ + cmd_call; \ + } \ + } \ + } while (0) + #endif /* REDIS_SENTINEL_LIBRARY_H */ From d05e493df1f5476127269c921d9a459b11a1cfe2 Mon Sep 17 00:00:00 2001 From: modestas_sienauskas Date: Wed, 22 Apr 2026 11:39:33 +0300 Subject: [PATCH 2/4] docs: document RedisSentinel 'hosts' option - Stub phpdoc on __construct describes the 'hosts' option alongside the existing single-host parameters. - sentinel.md gains a 'Multi-host support' section covering API, semantics (sticky, bounded retry, no rehydration), and error handling (RedisException on validation and exhaustion). - Regenerated arginfo reflects only the new stub hash; method signatures are unchanged. Refs #2819 --- redis_sentinel.stub.php | 15 +++++++++++++++ redis_sentinel_arginfo.h | 8 +++----- redis_sentinel_legacy_arginfo.h | 8 +++----- sentinel.md | 31 +++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 10 deletions(-) diff --git a/redis_sentinel.stub.php b/redis_sentinel.stub.php index 32551e1dd5..1f4959fe89 100644 --- a/redis_sentinel.stub.php +++ b/redis_sentinel.stub.php @@ -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) - 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 */ diff --git a/redis_sentinel_arginfo.h b/redis_sentinel_arginfo.h index c813d2397b..4b69a91267 100644 --- a/redis_sentinel_arginfo.h +++ b/redis_sentinel_arginfo.h @@ -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") @@ -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); @@ -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) @@ -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; } diff --git a/redis_sentinel_legacy_arginfo.h b/redis_sentinel_legacy_arginfo.h index f6a641ef91..7ca8866c07 100644 --- a/redis_sentinel_legacy_arginfo.h +++ b/redis_sentinel_legacy_arginfo.h @@ -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) @@ -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); @@ -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) @@ -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; } diff --git a/sentinel.md b/sentinel.md index eac3d9e0cf..065d5ddbe6 100644 --- a/sentinel.md +++ b/sentinel.md @@ -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) @@ -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 From ddea683d9b2217ea97619704fd05c321a164af11 Mon Sep 17 00:00:00 2001 From: modestas_sienauskas Date: Wed, 22 Apr 2026 11:41:31 +0300 Subject: [PATCH 3/4] tests: integration tests for RedisSentinel multi-host Adds 17 integration tests (tests/RedisSentinelMultiHostTest.php) covering: - Construction with 'hosts' array - Connect-time fallback through dead hosts - Exhaustion throwing RedisException with host count in message - Validation errors (empty, missing 'host' key, wrong types, oversized) - DoS guard (>1024 hosts rejected) - Default port (26379) when omitted - Single-host BC path unchanged - Auth propagation (skipped unless SENTINEL_AUTH_PASS is set) - Sticky behavior verified via call-time comparison - Bounded retry elapsed-time check Tests mark themselves skipped when the local Sentinel env isn't reachable, so existing local test runs are unaffected. The env itself (tests/sentinel-multihost/) is a minimal docker-compose cluster with 1 master + 2 replicas + 3 Sentinels on ports 26379/80/81. Refs #2819 --- tests/RedisSentinelMultiHostTest.php | 280 ++++++++++++++++++++ tests/TestRedis.php | 4 +- tests/sentinel-multihost/README.md | 41 +++ tests/sentinel-multihost/docker-compose.yml | 60 +++++ tests/sentinel-multihost/sentinel.conf | 7 + 5 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 tests/RedisSentinelMultiHostTest.php create mode 100644 tests/sentinel-multihost/README.md create mode 100644 tests/sentinel-multihost/docker-compose.yml create mode 100644 tests/sentinel-multihost/sentinel.conf diff --git a/tests/RedisSentinelMultiHostTest.php b/tests/RedisSentinelMultiHostTest.php new file mode 100644 index 0000000000..07edf814c6 --- /dev/null +++ b/tests/RedisSentinelMultiHostTest.php @@ -0,0 +1,280 @@ +markTestSkipped( + 'Sentinel env not available. Start via: ' + . 'docker compose -f tests/sentinel-multihost/docker-compose.yml up -d' + ); + } + } + + protected function hostsList(): array + { + return array_map( + fn($p) => ['host' => '127.0.0.1', 'port' => $p], + self::sentinelPorts() + ); + } + + protected function deadPort(): array + { + /* ECONNREFUSED deterministically — no service binds port 1 */ + return ['host' => '127.0.0.1', 'port' => 1]; + } + + public function testConstructAcceptsHostsArray() + { + $s = new RedisSentinel(['hosts' => $this->hostsList()]); + $this->assertIsObject($s, RedisSentinel::class); + } + + public function testConnectsToFirstAvailableHost() + { + $s = new RedisSentinel(['hosts' => $this->hostsList()]); + $this->assertTrue($s->ping()); + } + + public function testFallsBackWhenFirstHostDown() + { + $s = new RedisSentinel(['hosts' => [ + $this->deadPort(), + ['host' => '127.0.0.1', 'port' => 26379], + ]]); + $addr = $s->getMasterAddrByName(self::NAME); + $this->assertIsArray($addr, 2); + } + + public function testFallsBackThroughMultipleFailures() + { + $s = new RedisSentinel(['hosts' => [ + $this->deadPort(), + $this->deadPort(), + ['host' => '127.0.0.1', 'port' => 26379], + ]]); + $this->assertTrue($s->ping()); + } + + public function testThrowsWhenAllHostsDown() + { + $s = new RedisSentinel(['hosts' => [ + $this->deadPort(), + $this->deadPort(), + $this->deadPort(), + ]]); + $threw = false; + try { + $s->ping(); + } catch (RedisException $e) { + $threw = true; + $this->assertStringContains('Sentinel', $e->getMessage()); + $this->assertStringContains('hosts', $e->getMessage()); + } + $this->assertTrue($threw); + } + + public function testRejectsEmptyHostsArray() + { + $threw = false; + try { + new RedisSentinel(['hosts' => []]); + } catch (RedisException $e) { + $threw = true; + $this->assertStringContains('empty', strtolower($e->getMessage())); + } + $this->assertTrue($threw); + } + + public function testRejectsHostsEntryMissingHostKey() + { + $threw = false; + try { + new RedisSentinel(['hosts' => [['port' => 26379]]]); + } catch (RedisException $e) { + $threw = true; + $this->assertStringContains("'host'", $e->getMessage()); + $this->assertStringContains('0', $e->getMessage()); + } + $this->assertTrue($threw); + } + + public function testRejectsNonStringHost() + { + $threw = false; + try { + new RedisSentinel(['hosts' => [['host' => 123, 'port' => 26379]]]); + } catch (RedisException $e) { + $threw = true; + $this->assertStringContains("'host'", $e->getMessage()); + } + $this->assertTrue($threw); + } + + public function testRejectsNonIntPort() + { + $threw = false; + try { + new RedisSentinel(['hosts' => [ + ['host' => '127.0.0.1', 'port' => 'abc'], + ]]); + } catch (RedisException $e) { + $threw = true; + $this->assertStringContains("'port'", $e->getMessage()); + } + $this->assertTrue($threw); + } + + public function testRejectsNullHostsOption() + { + /* hosts => null should be treated as invalid, not silently ignored — + * avoids a foot-gun where a misconfigured config yields default + * single-host behavior. */ + $threw = false; + try { + new RedisSentinel(['hosts' => null]); + } catch (RedisException $e) { + $threw = true; + } + $this->assertTrue($threw); + } + + public function testRejectsHostsAsString() + { + $threw = false; + try { + new RedisSentinel(['hosts' => '127.0.0.1:26379']); + } catch (RedisException $e) { + $threw = true; + $this->assertStringContains('array', strtolower($e->getMessage())); + } + $this->assertTrue($threw); + } + + public function testRejectsNonArrayHostsEntry() + { + $threw = false; + try { + new RedisSentinel(['hosts' => ['127.0.0.1:26379']]); + } catch (RedisException $e) { + $threw = true; + $this->assertStringContains('array', strtolower($e->getMessage())); + } + $this->assertTrue($threw); + } + + public function testRejectsHostsArrayTooLarge() + { + /* DoS guard: 1024-entry limit prevents runaway allocation when user + * accidentally passes an unbounded list. */ + $threw = false; + try { + new RedisSentinel([ + 'hosts' => array_fill(0, 2048, ['host' => '127.0.0.1']), + ]); + } catch (RedisException $e) { + $threw = true; + $this->assertStringContains('max', strtolower($e->getMessage())); + } + $this->assertTrue($threw); + } + + public function testDefaultPortWhenOmitted() + { + /* hosts[0] omits port → defaults to 26379 */ + $s = new RedisSentinel(['hosts' => [ + ['host' => '127.0.0.1'], + ]]); + $this->assertTrue($s->ping()); + } + + public function testSingleHostPathUnchanged() + { + /* BC check: no 'hosts' option, classic single-host usage */ + $s = new RedisSentinel(['host' => '127.0.0.1', 'port' => 26379]); + $this->assertTrue($s->ping()); + } + + public function testPassesAuthToAllHosts() + { + /* Skipped unless AUTH-protected Sentinel env provided */ + if (!getenv('SENTINEL_AUTH_PASS')) { + $this->markTestSkipped('Requires SENTINEL_AUTH_PASS env var'); + } + $s = new RedisSentinel([ + 'hosts' => $this->hostsList(), + 'auth' => getenv('SENTINEL_AUTH_PASS'), + ]); + $this->assertTrue($s->ping()); + } + + public function testStickyAfterFailover() + { + /* hosts[0] dead → hosts[1] alive. First call pays the ECONNREFUSED + + * fallback cost; second call must go directly to hosts[1] with no + * retry cycle. Timing is the externally observable proxy for stickiness: + * a non-sticky impl would re-hit hosts[0] on the second call. */ + $s = new RedisSentinel(['hosts' => [ + $this->deadPort(), + ['host' => '127.0.0.1', 'port' => 26379], + ]]); + + $t0 = microtime(true); + $addr1 = $s->getMasterAddrByName(self::NAME); + $t1 = microtime(true); + $addr2 = $s->getMasterAddrByName(self::NAME); + $t2 = microtime(true); + + $this->assertEquals($addr1, $addr2); + + /* First call: connect-refused on hosts[0] + fallback + command on hosts[1]. + * Second call: single command on hosts[1]. The second must not exceed + * the first by more than noise — a re-visited hosts[0] would double it. */ + $first = $t1 - $t0; + $second = $t2 - $t1; + $this->assertLT($first * 2 + 0.05, $second); + } + + public function testBoundedRetries() + { + /* All hosts unreachable — must throw after trying each once, not loop. + * ECONNREFUSED on port 1 returns an RST immediately; even 5 attempts + * should complete in tens of milliseconds. 2s is diagnostic: any + * regression introducing a per-host sleep or secondary loop is caught. */ + $t0 = microtime(true); + $threw = false; + try { + $s = new RedisSentinel([ + 'hosts' => array_fill(0, 5, $this->deadPort()), + 'connectTimeout' => 0.1, + ]); + $s->ping(); + } catch (RedisException $e) { + $threw = true; + $elapsed = microtime(true) - $t0; + $this->assertLT(2.0, $elapsed); + } + $this->assertTrue($threw); + } +} diff --git a/tests/TestRedis.php b/tests/TestRedis.php index 303d05b951..ab9c6d28ef 100644 --- a/tests/TestRedis.php +++ b/tests/TestRedis.php @@ -5,6 +5,7 @@ require_once __DIR__ . "/RedisArrayTest.php"; require_once __DIR__ . "/RedisClusterTest.php"; require_once __DIR__ . "/RedisSentinelTest.php"; +require_once __DIR__ . "/RedisSentinelMultiHostTest.php"; function getClassArray($classes) { $result = []; @@ -28,7 +29,8 @@ function getTestClass($class) { 'redis' => 'Redis_Test', 'redisarray' => 'Redis_Array_Test', 'rediscluster' => 'Redis_Cluster_Test', - 'redissentinel' => 'Redis_Sentinel_Test' + 'redissentinel' => 'Redis_Sentinel_Test', + 'redissentinelmultihost' => 'Redis_Sentinel_Multi_Host_Test' ]; /* Return early if the class is one of our built-in ones */ diff --git a/tests/sentinel-multihost/README.md b/tests/sentinel-multihost/README.md new file mode 100644 index 0000000000..3df2afcb3f --- /dev/null +++ b/tests/sentinel-multihost/README.md @@ -0,0 +1,41 @@ +# Sentinel multi-host integration test env + +Minimal Redis + Sentinel cluster for integration-testing +`RedisSentinel` multi-host fallback (issue #2819). + +## Start + + docker compose -f tests/sentinel-multihost/docker-compose.yml up -d + sleep 3 # let Sentinels agree on master + +## Endpoints + + Redis master: 127.0.0.1:16379 + Sentinel 1: 127.0.0.1:26379 + Sentinel 2: 127.0.0.1:26380 + Sentinel 3: 127.0.0.1:26381 + +## Smoke test + + php -r ' + $s = new RedisSentinel([ + "hosts" => [ + ["host" => "127.0.0.1", "port" => 26379], + ["host" => "127.0.0.1", "port" => 26380], + ["host" => "127.0.0.1", "port" => 26381], + ], + ]); + var_dump($s->getMasterAddrByName("mymaster")); + ' + +Expected: `["redis-master", "6379"]` (or the current master IP/port). + +## Simulate Sentinel failure + + docker compose -f tests/sentinel-multihost/docker-compose.yml stop sentinel-1 + +Re-run the smoke test — should still return master info (via fallback to sentinel-2). + +## Teardown + + docker compose -f tests/sentinel-multihost/docker-compose.yml down diff --git a/tests/sentinel-multihost/docker-compose.yml b/tests/sentinel-multihost/docker-compose.yml new file mode 100644 index 0000000000..e5052b1945 --- /dev/null +++ b/tests/sentinel-multihost/docker-compose.yml @@ -0,0 +1,60 @@ +services: + redis-master: + image: redis:7-alpine + container_name: sm-redis-master + ports: ["16379:6379"] + networks: [sm-net] + + redis-replica-1: + image: redis:7-alpine + container_name: sm-redis-replica-1 + command: redis-server --replicaof redis-master 6379 + depends_on: [redis-master] + networks: [sm-net] + + redis-replica-2: + image: redis:7-alpine + container_name: sm-redis-replica-2 + command: redis-server --replicaof redis-master 6379 + depends_on: [redis-master] + networks: [sm-net] + + sentinel-1: + image: redis:7-alpine + container_name: sm-sentinel-1 + command: > + sh -c "cp /conf/sentinel.conf /tmp/s1.conf && + redis-sentinel /tmp/s1.conf --port 26379" + ports: ["26379:26379"] + volumes: + - ./sentinel.conf:/conf/sentinel.conf:ro + depends_on: [redis-master, redis-replica-1, redis-replica-2] + networks: [sm-net] + + sentinel-2: + image: redis:7-alpine + container_name: sm-sentinel-2 + command: > + sh -c "cp /conf/sentinel.conf /tmp/s2.conf && + redis-sentinel /tmp/s2.conf --port 26379" + ports: ["26380:26379"] + volumes: + - ./sentinel.conf:/conf/sentinel.conf:ro + depends_on: [redis-master, redis-replica-1, redis-replica-2] + networks: [sm-net] + + sentinel-3: + image: redis:7-alpine + container_name: sm-sentinel-3 + command: > + sh -c "cp /conf/sentinel.conf /tmp/s3.conf && + redis-sentinel /tmp/s3.conf --port 26379" + ports: ["26381:26379"] + volumes: + - ./sentinel.conf:/conf/sentinel.conf:ro + depends_on: [redis-master, redis-replica-1, redis-replica-2] + networks: [sm-net] + +networks: + sm-net: + driver: bridge diff --git a/tests/sentinel-multihost/sentinel.conf b/tests/sentinel-multihost/sentinel.conf new file mode 100644 index 0000000000..b47f18aa79 --- /dev/null +++ b/tests/sentinel-multihost/sentinel.conf @@ -0,0 +1,7 @@ +port 26379 +sentinel resolve-hostnames yes +sentinel announce-hostnames yes +sentinel monitor mymaster redis-master 6379 2 +sentinel down-after-milliseconds mymaster 5000 +sentinel failover-timeout mymaster 10000 +sentinel parallel-syncs mymaster 1 From dce2046aff3dbac1e2f43eb9ec34391795e06da9 Mon Sep 17 00:00:00 2001 From: modestas_sienauskas Date: Wed, 22 Apr 2026 11:42:12 +0300 Subject: [PATCH 4/4] ci: add sentinel-multihost job Runs the RedisSentinelMultiHostTest integration tests across PHP 8.1-8.4 against the docker-compose cluster in tests/sentinel-multihost/. - docker compose up, wait for Sentinels via 'nc -z' - phpize + configure --enable-redis + make - php tests/TestRedis.php --class redissentinelmultihost - Dumps docker logs on failure for triage - Tears down the cluster in all outcomes Refs #2819 --- .github/workflows/ci.yml | 52 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3288d59d63..fa77eb41d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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