Describe the bug
In Redis Sentinel mode, when a Redis primary dies and the pub/sub reconnect's first dial hits the dead primary's address (connection refused), the pub/sub retry goroutine stops retrying permanently. From that moment the affected Centrifugo pod silently delivers nothing for that shard's channels: client WebSocket connections stay open, publishes keep returning 200 and advancing history offsets, /health stays green — but no subscriber on that pod receives another publication until the process is restarted.
This looks like the same failure class as #766 (v4: node kept stale Redis state after Sentinel failover, health check green, manual restart required), which was closed after an upgrade without a root cause. We reproduce it on the latest v6.9.1 and can supply the evidence that issue lacked: the exact log signature, Redis-side PUBSUB CHANNELS state, and the code path.
Versions
- Centrifugo v6.9.1 (official Docker image, unmodified), 3 replicas on GKE
- Redis 8.8.0 via Bitnami
redis chart 27.0.15, Sentinel mode (1 master + 2 replicas + 3 sentinels, quorum 2), two independent shards
- Engine config (nothing else non-default besides namespaces/delta):
"engine": {
"type": "redis",
"redis": {
"address": [
"redis+sentinel://redis-shard0:26379?sentinel_master_name=mymaster",
"redis+sentinel://redis-shard1:26379?sentinel_master_name=mymaster"
]
}
}
(Startup logs engine_mode: sharded(2):sentinel-x2. Also reproduced logic-wise with a single shard being the affected one — the wedge is per shard, per pod.)
Steps to Reproduce
- Centrifugo v6.9.1, Redis engine via
redis+sentinel:// DSN, at least one client subscribed (so the shard has channels).
- Drive continuous publish load (we used 200-command
/api/batch requests at ~15–20 req/s ≈ 3–4K publishes/s).
- Kill the Redis primary pod (
kubectl delete pod <primary>). The pod IP dies instantly; Sentinel promotion takes ~10s — that gap is the race window.
- Pods whose reconnect dial lands in that window (
connection refused from the stale primary address) wedge permanently. Pods whose dial happens to succeed (e.g. the StatefulSet re-created the pod name fast enough) recover normally — same cluster, same config, same drill.
Reproduced multiple times. A quiet failover (no publish traffic at reconnect time, dial succeeds) recovers correctly every time — which is presumably why this survives in the wild mostly unnoticed.
Log evidence
Stuck pod (debug logging enabled — the absence of lines after 18:10:25 is the point; observed silent for 150+ seconds until manual restart):
{"level":"error","error":"EOF","pub_sub_shard":0,"shard":"redis-shard1:26379","time":"2026-07-19T18:10:24Z","message":"pub/sub connection error"}
{"level":"error","error":"EOF","shard":"redis-shard1:26379","time":"2026-07-19T18:10:24Z","message":"control pub/sub connection error"}
{"level":"error","error":"dial tcp 10.124.0.56:6379: connect: connection refused","pub_sub_shard":0,"shard":"redis-shard1:26379","time":"2026-07-19T18:10:25Z","message":"pub/sub subscribe error"}
{"level":"error","error":"dial tcp 10.124.0.56:6379: connect: connection refused","shard":"redis-shard1:26379","time":"2026-07-19T18:10:25Z","message":"control pub/sub subscribe error"}
10.124.0.56 is the killed primary's pod IP. Note what's missing after the subscribe error: no deferred "stopping Redis PUB/SUB" (it's a defer in the same function), and no further "running Redis PUB/SUB" — ever.
Healthy recovery on the same cluster (quiet failover, for contrast — this is what every failover should look like):
{"level":"error","error":"EOF","pub_sub_shard":0,"shard":"redis-shard1:26379","message":"pub/sub connection error"}
{"level":"debug","num_processors":4,"pub_sub_shard":0,"shard":"redis-shard1:26379","message":"stopping Redis PUB/SUB"}
{"level":"debug","num_processors":4,"pub_sub_shard":0,"shard":"redis-shard1:26379","message":"running Redis PUB/SUB"}
{"level":"debug","elapsed":"394.368µs","num_channels":2,"pub_sub_shard":0,"shard":"redis-shard1:26379","message":"resubscribed to channels"}
Redis-side confirmation of the outage: with ~100 channels per shard actively subscribed, redis-cli PUBSUB CHANNELS 'centrifugo.client.*' on the shard master showed 1 channel instead of ~100 after the incident (the 1 came from a freshly connecting client, whose own SUBSCRIBE works — masking the outage for new clients while existing ones starve).
Expected behavior
runForever() in broker_redis.go is designed to re-run runPubSub() every 250ms indefinitely, so a refused dial should just be retried until Sentinel finishes promotion (a few seconds). That is exactly what happens in Redis Cluster mode on the same cluster: under identical dead-IP dial conditions (dial tcp ...: connect: connection refused / i/o timeout on reconnect), cluster mode logged clean stopping → running → resubscribed to channels cycles within 1–2s on every one of four master kills we drilled. Only the Sentinel path wedges.
Where it appears to be stuck
Reading runPubSubLoop() (redis_pubsub_shared.go), the error path after a failed shard-channel subscribe is: log "pub/sub subscribe error" → return → deferred conn.Close() / cancel() (rueidis DedicatedClient obtained from client.Dedicate()) → deferred "stopping Redis PUB/SUB" debug log. We observe the error log but never the deferred debug log and never another runForever iteration — so the goroutine appears to hang inside the dedicated-connection teardown after a dial that never succeeded, specifically when the client is a Sentinel-managed rueidis client whose master is gone. (Possibly adjacent to the rueidis blocking classes referenced in the OnMessage hook comment, redis/rueidis#596.)
Happy to provide more logs, run patched builds against our reproduction, or test candidate fixes — the drill reproduces this in a couple of minutes on demand.
Describe the bug
In Redis Sentinel mode, when a Redis primary dies and the pub/sub reconnect's first dial hits the dead primary's address (
connection refused), the pub/sub retry goroutine stops retrying permanently. From that moment the affected Centrifugo pod silently delivers nothing for that shard's channels: client WebSocket connections stay open, publishes keep returning 200 and advancing history offsets,/healthstays green — but no subscriber on that pod receives another publication until the process is restarted.This looks like the same failure class as #766 (v4: node kept stale Redis state after Sentinel failover, health check green, manual restart required), which was closed after an upgrade without a root cause. We reproduce it on the latest v6.9.1 and can supply the evidence that issue lacked: the exact log signature, Redis-side
PUBSUB CHANNELSstate, and the code path.Versions
redischart 27.0.15, Sentinel mode (1 master + 2 replicas + 3 sentinels, quorum 2), two independent shards(Startup logs
engine_mode: sharded(2):sentinel-x2. Also reproduced logic-wise with a single shard being the affected one — the wedge is per shard, per pod.)Steps to Reproduce
redis+sentinel://DSN, at least one client subscribed (so the shard has channels)./api/batchrequests at ~15–20 req/s ≈ 3–4K publishes/s).kubectl delete pod <primary>). The pod IP dies instantly; Sentinel promotion takes ~10s — that gap is the race window.connection refusedfrom the stale primary address) wedge permanently. Pods whose dial happens to succeed (e.g. the StatefulSet re-created the pod name fast enough) recover normally — same cluster, same config, same drill.Reproduced multiple times. A quiet failover (no publish traffic at reconnect time, dial succeeds) recovers correctly every time — which is presumably why this survives in the wild mostly unnoticed.
Log evidence
Stuck pod (debug logging enabled — the absence of lines after 18:10:25 is the point; observed silent for 150+ seconds until manual restart):
10.124.0.56is the killed primary's pod IP. Note what's missing after thesubscribe error: no deferred"stopping Redis PUB/SUB"(it's adeferin the same function), and no further"running Redis PUB/SUB"— ever.Healthy recovery on the same cluster (quiet failover, for contrast — this is what every failover should look like):
Redis-side confirmation of the outage: with ~100 channels per shard actively subscribed,
redis-cli PUBSUB CHANNELS 'centrifugo.client.*'on the shard master showed 1 channel instead of ~100 after the incident (the 1 came from a freshly connecting client, whose own SUBSCRIBE works — masking the outage for new clients while existing ones starve).Expected behavior
runForever()inbroker_redis.gois designed to re-runrunPubSub()every 250ms indefinitely, so a refused dial should just be retried until Sentinel finishes promotion (a few seconds). That is exactly what happens in Redis Cluster mode on the same cluster: under identical dead-IP dial conditions (dial tcp ...: connect: connection refused/i/o timeouton reconnect), cluster mode logged cleanstopping → running → resubscribed to channelscycles within 1–2s on every one of four master kills we drilled. Only the Sentinel path wedges.Where it appears to be stuck
Reading
runPubSubLoop()(redis_pubsub_shared.go), the error path after a failed shard-channel subscribe is: log"pub/sub subscribe error"→return→ deferredconn.Close()/cancel()(rueidisDedicatedClientobtained fromclient.Dedicate()) → deferred"stopping Redis PUB/SUB"debug log. We observe the error log but never the deferred debug log and never anotherrunForeveriteration — so the goroutine appears to hang inside the dedicated-connection teardown after a dial that never succeeded, specifically when the client is a Sentinel-managed rueidis client whose master is gone. (Possibly adjacent to the rueidis blocking classes referenced in theOnMessagehook comment, redis/rueidis#596.)Happy to provide more logs, run patched builds against our reproduction, or test candidate fixes — the drill reproduces this in a couple of minutes on demand.