Files
gitea/services/pubsub/redis.go
T
silverwind 7308c5fb97 enhance(websocket): improve client synchronization (#38847)
Keep notification counts and stopwatches in sync when tabs open or
reconnect, closing the gap behind [this
flake](https://github.com/go-gitea/gitea/actions/runs/31312525323/job/93242429394).

1. Send current notification counts and stopwatch state on each
WebSocket connection, replacing the separate state fetches tabs made on
every connect.
2. Tabs joining an already open connection fetch current state once,
since its snapshot went out before they attached.
3. Send events queued during the handshake before the snapshot, so older
events can't roll it back.
4. Wait for the Redis SUBSCRIBE acknowledgement, so the snapshot read
after subscribing can't miss an event.
2026-10-29 05:18:17 +00:00

197 lines
5.4 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pubsub
import (
"context"
"sync"
"time"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/nosql"
"gitea.dev/modules/util"
"github.com/redis/go-redis/v9"
)
const (
redisPingTimeout = 3 * time.Second
redisPingRetries = 10
redisPingRetryDelay = time.Second
redisPublishTimeout = 2 * time.Second
redisSubscribeTimeout = 2 * time.Second
)
// RedisBroker fans out across processes via Redis pub/sub. Each topic is
// backed by a single Redis SUBSCRIBE shared between local subscribers; the
// last local Unsubscribe tears the Redis subscription down.
type RedisBroker struct {
client redis.UniversalClient
mu sync.RWMutex
topics map[string]*redisTopic
}
type redisTopic struct {
ps *redis.PubSub
subs []*redisSub
cancel context.CancelFunc
}
// redisSub pairs a delivery channel with the once that guards its close, so
// either cancel() or readLoop's error-exit path can safely close it.
type redisSub struct {
ch chan []byte
once sync.Once
}
func (s *redisSub) close() { s.once.Do(func() { close(s.ch) }) }
var _ Broker = (*RedisBroker)(nil)
func redisChannelForTopic(s string) string {
return "gitea-ws-topic:" + s
}
func NewRedisBroker(connStr string) (*RedisBroker, error) {
client := nosql.GetManager().GetRedisClient(connStr)
// context.Background not graceful.ShutdownContext: shutdown ctx may not be initialized at boot.
// Retry to ride out docker-compose start-order races (matches modules/queue).
var err error
for range redisPingRetries {
pingCtx, cancel := context.WithTimeout(context.Background(), redisPingTimeout)
err = client.Ping(pingCtx).Err()
cancel()
if err == nil {
break
}
log.Warn("pubsub redis: not ready, retrying in 1s: %v", err)
time.Sleep(redisPingRetryDelay)
}
if err != nil {
return nil, err
}
return &RedisBroker{
client: client,
topics: make(map[string]*redisTopic),
}, nil
}
func (b *RedisBroker) Subscribe(topic string) (<-chan []byte, func()) {
sub := &redisSub{ch: make(chan []byte, subChanBuffer)}
// Fast path: topic already has a Redis subscription, just attach locally.
b.mu.Lock()
if state, exists := b.topics[topic]; exists {
state.subs = append(state.subs, sub)
b.mu.Unlock()
return sub.ch, b.makeCancel(topic, sub)
}
b.mu.Unlock()
// Slow path: create the Redis subscription outside the broker mutex so
// other Subscribe/cancel calls aren't blocked on the network round-trip.
// graceful.ShutdownContext so the reader loop dies cleanly on Gitea
// shutdown even if every local subscriber has already cancelled.
ctx, cancelCtx := context.WithCancel(graceful.GetManager().ShutdownContext())
ps := b.client.Subscribe(ctx, redisChannelForTopic(topic))
if _, err := ps.ReceiveTimeout(ctx, redisSubscribeTimeout); err != nil { // the ack, so state read afterwards misses no publish
log.Error("pubsub redis: subscribe to %q: %v", topic, err)
cancelCtx()
_ = ps.Close()
sub.close()
return sub.ch, func() {}
}
b.mu.Lock()
if existing, exists := b.topics[topic]; exists {
// Another goroutine won the create race; merge into theirs and discard ours.
existing.subs = append(existing.subs, sub)
b.mu.Unlock()
cancelCtx()
_ = ps.Close()
return sub.ch, b.makeCancel(topic, sub)
}
b.topics[topic] = &redisTopic{ps: ps, cancel: cancelCtx, subs: []*redisSub{sub}}
b.mu.Unlock()
go b.readLoop(ctx, topic, ps)
return sub.ch, b.makeCancel(topic, sub)
}
func (b *RedisBroker) makeCancel(topic string, sub *redisSub) func() {
return func() {
b.mu.Lock()
state, ok := b.topics[topic]
if !ok {
b.mu.Unlock()
sub.close()
return
}
state.subs = util.SliceRemoveAll(state.subs, sub)
if len(state.subs) == 0 {
state.cancel()
_ = state.ps.Close()
delete(b.topics, topic)
}
b.mu.Unlock()
sub.close()
}
}
func (b *RedisBroker) readLoop(ctx context.Context, topic string, ps *redis.PubSub) {
for {
msg, err := ps.ReceiveMessage(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
// Transport blip: tear the topic down so a fresh Subscribe rebuilds it.
// Closing each subscriber's channel wakes the WebSocket handler, which
// will reconnect and re-Subscribe.
b.mu.Lock()
if cur, ok := b.topics[topic]; ok && cur.ps == ps {
for _, s := range cur.subs {
s.close()
}
cur.cancel()
_ = cur.ps.Close()
delete(b.topics, topic)
}
b.mu.Unlock()
log.Trace("pubsub redis: receive on %q: %v", topic, err)
return
}
payload := []byte(msg.Payload)
b.mu.RLock()
state, ok := b.topics[topic]
if !ok {
b.mu.RUnlock()
return
}
for _, s := range state.subs {
select {
case s.ch <- payload:
default:
log.Trace("pubsub redis: dropping message on topic %q — subscriber channel full", topic)
}
}
b.mu.RUnlock()
}
}
func (b *RedisBroker) Publish(topic string, msg []byte) {
ctx, cancel := context.WithTimeout(graceful.GetManager().HammerContext(), redisPublishTimeout)
defer cancel()
if err := b.client.Publish(ctx, redisChannelForTopic(topic), msg).Err(); err != nil {
log.Error("pubsub redis: publish to %q: %v", topic, err)
}
}
// HasTopicSubscribers conservatively returns true: cross-process subscriber
// discovery via PUBSUB NUMSUB is per-node and would silently miss subscribers
// in cluster mode. Publishers do the upstream lookup unconditionally.
func (b *RedisBroker) HasTopicSubscribers(topic string) bool {
return true
}