-
-
Notifications
You must be signed in to change notification settings - Fork 121
Expand file tree
/
Copy pathredis_pubsub_shared.go
More file actions
493 lines (460 loc) · 17.2 KB
/
Copy pathredis_pubsub_shared.go
File metadata and controls
493 lines (460 loc) · 17.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
package centrifuge
import (
"context"
"errors"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/centrifugal/centrifuge/internal/convert"
"github.com/redis/rueidis"
)
const (
pubSubProcessorBufferSize = 4096
// defaultPubSubProbeInterval is the default idle interval after which a
// PUB/SUB connection gets a liveness probe.
defaultPubSubProbeInterval = 30 * time.Second
// minPubSubProbeInterval is the floor for a configured probe interval.
// Probing below this makes no sense: the interval is also the deadline
// for the probe round trip, and shorter values turn ordinary Redis
// latency into connection restarts.
minPubSubProbeInterval = 100 * time.Millisecond
)
// normalizePubSubProbeInterval normalizes a configured probe interval: zero
// means default, negative disables probing, positive is clamped to the floor.
func normalizePubSubProbeInterval(configured time.Duration) time.Duration {
if configured == 0 {
return defaultPubSubProbeInterval
}
if configured > 0 && configured < minPubSubProbeInterval {
return minPubSubProbeInterval
}
return configured
}
// pubSubProbeMessage is the payload of PUB/SUB liveness probes. Probes are
// recognized by the channel they arrive on (the shard service channel), not
// by payload — the payload only helps when inspecting traffic by hand.
const pubSubProbeMessage = "probe"
// publishPubSubProbe publishes a liveness probe to the shard service channel
// through the regular publish path. The publish always goes through the main
// shard client (the master), even when the PUB/SUB loop subscribes on a
// replica — in that mode a delivered probe additionally verifies the
// replication link.
//
// Returns true when Redis accepted the PUBLISH. Only a probe that was really
// sent can prove anything about the receive path — see the probe branch of
// runPubSubLoop. The timeout callers pass is the probe interval itself: an
// attempt that takes longer than that is superseded by the next one anyway.
func publishPubSubProbe(shard *RedisShard, node *Node, psm redisPubSubMetrics, name, shardChannel string, useShardedPubSub bool, timeout time.Duration, logFields map[string]any) bool {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var cmd rueidis.Completed
if useShardedPubSub {
cmd = shard.client.B().Spublish().Channel(shardChannel).Message(pubSubProbeMessage).Build()
} else {
cmd = shard.client.B().Publish().Channel(shardChannel).Message(pubSubProbeMessage).Build()
}
if err := shard.client.Do(ctx, cmd).Error(); err != nil {
psm.incErrors(name, "probe_publish")
node.logger.log(newErrorLogEntry(err, "error publishing PUB/SUB probe", logFields))
return false
}
return true
}
// pubSubProbeState is the part of the liveness probing state that must
// survive a loop restart. It is created once per PUB/SUB loop identity by the
// broker starting the loop, and is only ever touched by that single loop
// goroutine (runForever runs one loop instance at a time), so it needs no
// synchronization.
type pubSubProbeState struct {
// consecutiveRestarts counts loop restarts caused by failed liveness
// probes since the last time the connection delivered something. Reset
// as soon as any message arrives.
consecutiveRestarts int
}
// pubSubProbeRestartBackoff spaces out repeated probe-triggered restarts.
//
// Restarting only helps if the new connection lands on a node that actually
// delivers. When it does not — the underlying client keeps resolving to the
// same wedged node — unbounded restarts would resubscribe every channel of
// this node every couple of intervals, which is expensive precisely when
// Redis is least able to take it. The first restart is immediate (the common
// case, where reconnecting fixes it), each further one doubles the wait, and
// the wait is capped at ten intervals.
func pubSubProbeRestartBackoff(interval time.Duration, consecutiveRestarts int) time.Duration {
if consecutiveRestarts <= 1 {
return 0
}
maxBackoff := 10 * interval
backoff := interval
for i := 2; i < consecutiveRestarts; i++ {
backoff *= 2
if backoff >= maxBackoff {
return maxBackoff
}
}
return backoff
}
// pubSubCallbacks carries the type-varying behavior as function pointers.
// Both RedisBroker and RedisMapBroker provide their own callbacks.
type pubSubCallbacks struct {
// handleMessage processes a message received from PUB/SUB.
handleMessage func(isCluster bool, handler BrokerEventHandler, ch string, data []byte) error
// shardChannelID returns the shard channel ID for a given cluster shard index and pub/sub shard index.
shardChannelID func(clusterIdx, psIdx int, useShardedPubSub bool) string
// messageChannelID returns the pub/sub channel name for a given user channel.
messageChannelID func(ch string) string
// shardForChannel returns the RedisShard for a given channel (for filtering during resubscribe).
shardForChannel func(ch string) *RedisShard
// extraResubscribeChannels returns broker-level channel subscriptions that
// must survive PUB/SUB reconnects but are not tracked in the Hub (shared
// poll key channels). May be nil.
extraResubscribeChannels func() []string
}
func getPubSubStartLogFields(s *RedisShard, logFields map[string]any) map[string]any {
startLogFields := make(map[string]any, len(logFields))
for k, v := range logFields {
startLogFields[k] = v
}
if s.isCluster {
startLogFields["cluster"] = true
}
return startLogFields
}
func logResubscribed(node *Node, numChannels int, elapsed time.Duration, logFields map[string]any) {
combinedLogFields := make(map[string]any, len(logFields)+2)
for k, v := range logFields {
combinedLogFields[k] = v
}
combinedLogFields["elapsed"] = elapsed.String()
combinedLogFields["num_channels"] = numChannels
node.logger.log(newLogEntry(LogLevelDebug, "resubscribed to channels", combinedLogFields))
}
// runPubSubLoop is the unified PUB/SUB loop used by both RedisBroker and RedisMapBroker.
// It handles connection setup, message processing, resubscription, and error handling.
func runPubSubLoop(
shard *RedisShard,
subClientsMu *sync.Mutex,
subClients [][]rueidis.DedicatedClient,
cb pubSubCallbacks,
node *Node,
name string,
psm redisPubSubMetrics,
subscribeOnReplica bool,
probeInterval time.Duration,
probeState *pubSubProbeState,
numProcessors, numResubscribeShards, numSubscribeShards, numPartitions int,
logFields map[string]any,
eventHandler BrokerEventHandler,
clusterShardIndex, psShardIndex int,
useShardedPubSub bool,
startOnce func(error),
) {
shardChannel := cb.shardChannelID(clusterShardIndex, psShardIndex, useShardedPubSub)
if node.logEnabled(LogLevelDebug) {
debugLogValues := map[string]any{
"num_processors": numProcessors,
}
if useShardedPubSub {
debugLogValues["cluster_shard_index"] = clusterShardIndex
}
pubSubStartLogFields := getPubSubStartLogFields(shard, logFields)
combinedLogFields := make(map[string]any, len(pubSubStartLogFields)+len(debugLogValues))
for k, v := range pubSubStartLogFields {
combinedLogFields[k] = v
}
for k, v := range debugLogValues {
combinedLogFields[k] = v
}
node.logger.log(newLogEntry(LogLevelDebug, "running Redis PUB/SUB", combinedLogFields))
defer func() {
node.logger.log(newLogEntry(LogLevelDebug, "stopping Redis PUB/SUB", combinedLogFields))
}()
}
done := make(chan struct{})
var doneOnce sync.Once
closeDoneOnce := func() {
doneOnce.Do(func() {
close(done)
})
}
defer closeDoneOnce()
// Run PUB/SUB message processors to spread received message processing work over worker goroutines.
processors := make(map[int]chan rueidis.PubSubMessage)
for i := 0; i < numProcessors; i++ {
processingCh := make(chan rueidis.PubSubMessage, pubSubProcessorBufferSize)
processors[i] = processingCh
go func(ch chan rueidis.PubSubMessage) {
for {
select {
case <-done:
return
case msg := <-ch:
err := cb.handleMessage(shard.isCluster, eventHandler, msg.Channel, convert.StringToBytes(msg.Message))
if err != nil {
psm.incErrors(name, "handle_client_message")
node.logger.log(newErrorLogEntry(err, "error handling client message", logFields))
continue
}
}
}
}(processingCh)
}
// Buffer monitoring goroutine.
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
for i := 0; i < numProcessors; i++ {
psm.buffered.WithLabelValues(name, "client", strconv.Itoa(i)).Set(float64(len(processors[i])))
}
}
}
}()
client := shard.client
if subscribeOnReplica {
client = shard.replicaClient
}
conn, cancel := client.Dedicate()
defer cancel()
defer conn.Close()
// receivedCount counts messages delivered by this connection. It feeds
// the liveness probing below: a connection that received anything since
// the previous check is alive and is not probed. A counter increment is
// the only per-message cost of probing.
var receivedCount atomic.Uint64
wait := conn.SetPubSubHooks(rueidis.PubSubHooks{
OnMessage: func(msg rueidis.PubSubMessage) {
receivedCount.Add(1)
if msg.Channel == shardChannel {
// The shard channel is a service channel: nothing publishes
// real traffic to it, only liveness probes arrive here. The
// probe did its job by updating receivedCount — don't pass it
// to message processors.
return
}
select {
case processors[index(msg.Channel, numProcessors)] <- msg:
case <-done:
default:
// Buffer is full, drop the message. It's expected that PUB/SUB layer
// only provides at most once delivery guarantee.
// Blocking here will block Redis connection read loop which is not a
// good thing and can lead to slower command processing and potentially
// to deadlocks (see https://github.com/redis/rueidis/issues/596).
psm.dropped.WithLabelValues(name, "client").Inc()
}
},
OnSubscription: func(ps rueidis.PubSubSubscription) {
if !useShardedPubSub {
return
}
if ps.Kind == "sunsubscribe" && ps.Channel == shardChannel {
// Helps to handle slot migration.
node.logger.log(newLogEntry(LogLevelInfo, "pub/sub restart due to slot migration", logFields))
closeDoneOnce()
}
},
})
var err error
if useShardedPubSub {
err = conn.Do(context.Background(), conn.B().Ssubscribe().Channel(shardChannel).Build()).Error()
} else {
err = conn.Do(context.Background(), conn.B().Subscribe().Channel(shardChannel).Build()).Error()
}
if err != nil {
startOnce(err)
psm.incErrors(name, "subscribe_shard_channel")
node.logger.log(newErrorLogEntry(err, "pub/sub subscribe error", logFields))
return
}
channels := node.Hub().Channels()
if cb.extraResubscribeChannels != nil {
// Broker-level subscriptions not tracked in the Hub (shared poll key
// channels) go through the same per-shard/partition filters below.
channels = append(channels, cb.extraResubscribeChannels()...)
}
var wg sync.WaitGroup
started := time.Now()
for i := 0; i < numResubscribeShards; i++ {
wg.Add(1)
go func(subscriberIndex int) {
defer wg.Done()
estimatedCap := len(channels) / numResubscribeShards / numSubscribeShards
if useShardedPubSub {
estimatedCap /= numPartitions
}
chIDs := make([]string, 0, estimatedCap)
for _, ch := range channels {
if cb.shardForChannel(ch) != shard {
continue
}
if useShardedPubSub && consistentIndex(ch, numPartitions) != clusterShardIndex {
continue
}
if index(ch, numSubscribeShards) != psShardIndex {
continue
}
if index(ch, numResubscribeShards) != subscriberIndex {
continue
}
chIDs = append(chIDs, cb.messageChannelID(ch))
}
subscribeBatch := func(batch []string) error {
if useShardedPubSub {
return conn.Do(context.Background(), conn.B().Ssubscribe().Channel(batch...).Build()).Error()
}
return conn.Do(context.Background(), conn.B().Subscribe().Channel(batch...).Build()).Error()
}
batch := make([]string, 0, redisSubscribeBatchLimit)
for i, ch := range chIDs {
if len(batch) > 0 && i%redisSubscribeBatchLimit == 0 {
err := subscribeBatch(batch)
if err != nil {
psm.incErrors(name, "subscribe_channel")
node.logger.log(newErrorLogEntry(err, "error subscribing", logFields))
closeDoneOnce()
return
}
batch = batch[:0]
}
batch = append(batch, ch)
}
if len(batch) > 0 {
err := subscribeBatch(batch)
if err != nil {
psm.incErrors(name, "subscribe_channel")
node.logger.log(newErrorLogEntry(err, "error subscribing", logFields))
closeDoneOnce()
return
}
}
}(i)
}
go func() {
wg.Wait()
if len(channels) > 0 && node.logEnabled(LogLevelDebug) {
logResubscribed(node, len(channels), time.Since(started), logFields)
}
select {
case <-done:
startOnce(errors.New("error resubscribing"))
default:
subClientsMu.Lock()
subClients[clusterShardIndex][psShardIndex] = conn
subClientsMu.Unlock()
defer func() {
// Compare-and-swap: only nil the slot if it still holds OUR
// conn. A subsequent run of this same loop (after topology
// rebuild closed our `done`) may have already written its own
// fresh conn into this slot before our defer fires. Without
// the equality check, our nil write would clobber a live
// connection.
subClientsMu.Lock()
if subClients[clusterShardIndex][psShardIndex] == conn {
subClients[clusterShardIndex][psShardIndex] = nil
}
subClientsMu.Unlock()
}()
startOnce(nil)
}
<-done
}()
// The loop below parks until the connection errors, the loop is asked to
// stop, or the shard closes. A healthy-looking connection is not enough
// to park on forever: after a Redis failover the connection may end up
// attached to a node that answers keepalive pings and accepts commands
// but never receives the published traffic — a demoted master, a node
// outside the replication chain, or an unrelated Redis on a reused IP
// (see centrifugal/centrifugo#1189). No connection error ever fires in
// that state, so subscribers starve silently until process restart.
//
// The probe ticker breaks that: when nothing has been received for a
// full interval, publish a small probe to the shard service channel
// through the regular publish path and expect it back on this
// connection. If a probe was really sent and a whole further interval
// passed without receiving ANYTHING (not even the probe), the connection
// is considered stale and the loop restarts, re-resolving the topology.
// Under regular traffic the probe never fires, so the steady-state cost
// is one atomic load per interval.
var probeTickerCh <-chan time.Time
if probeInterval > 0 {
probeTicker := time.NewTicker(probeInterval)
defer probeTicker.Stop()
probeTickerCh = probeTicker.C
}
var seenCount uint64
// probeSent is non-nil while a probe attempt is outstanding and becomes
// true once Redis has accepted that PUBLISH. Publishing happens off this
// goroutine, so the loop learns the outcome through the flag.
var probeSent *atomic.Bool
for {
select {
case err = <-wait:
startOnce(err)
if err != nil {
psm.incErrors(name, "connection")
node.logger.log(newErrorLogEntry(err, "pub/sub connection error", logFields))
}
return
case <-done:
return
case <-shard.closeCh:
return
case <-probeTickerCh:
cur := receivedCount.Load()
if cur != seenCount {
// The connection delivered something during the last
// interval (possibly a previous probe) — alive.
seenCount = cur
probeSent = nil
probeState.consecutiveRestarts = 0
continue
}
if probeSent != nil && probeSent.Load() {
// A probe was published a full interval ago and nothing at
// all has been received since — the connection is attached
// to a node that does not deliver published traffic.
psm.incErrors(name, "probe_timeout")
probeState.consecutiveRestarts++
backoff := pubSubProbeRestartBackoff(probeInterval, probeState.consecutiveRestarts)
restartLogFields := make(map[string]any, len(logFields)+2)
for k, v := range logFields {
restartLogFields[k] = v
}
restartLogFields["consecutive_probe_restarts"] = probeState.consecutiveRestarts
restartLogFields["restart_delay"] = backoff.String()
node.logger.log(newLogEntry(LogLevelWarn, "no PUB/SUB message received since liveness probe was sent, restarting PUB/SUB connection", restartLogFields))
if backoff > 0 {
// Keep the connection in place while waiting: it is stale,
// not broken, so subscriptions issued meanwhile still
// succeed and are restored by the resubscribe on restart.
select {
case <-time.After(backoff):
case <-done:
case <-shard.closeCh:
}
}
return
}
// Either no probe was outstanding, or the outstanding one never
// reached Redis — the publish failed or is still in flight. A
// probe that was not sent says nothing about the receive path:
// it is the publish path that is broken (a failing over master,
// a READONLY pin), and restarting the PUB/SUB connection would
// not fix that while resubscribing every channel of this node.
// Start a fresh attempt instead.
sent := &atomic.Bool{}
probeSent = sent
go func() {
if publishPubSubProbe(shard, node, psm, name, shardChannel, useShardedPubSub, probeInterval, logFields) {
sent.Store(true)
}
}()
}
}
}