feat: enhance chat service with Redis pub/sub for multi-instance message handling, add instance ID for deduplication

This commit is contained in:
zwitschi committed 2026-08-06 20:44:29 +02:00
1 parent d991d6b6c7
commit 7244c1d658
4 files changed
+90 -19

No files matched your search

+67 -1
View File
@@ -41,8 +41,17 @@ var (
// Rate limit config (env-overridable)
rateLimitMax = 10
rateLimitWindow = 10 * time.Second
// Instance ID for multi-instance pub/sub deduplication
instanceID = fmt.Sprintf("chat-%s", generateShortID())
)
func generateShortID() string {
b := make([]byte, 6)
rand.Read(b)
return hex.EncodeToString(b)
}
type clientInfo struct {
Username string
Room string
@@ -2161,7 +2170,7 @@ func handleMetrics(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "# HELP linearvoid_chat_build_info Static build info for chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_build_info gauge")
fmt.Fprintln(w, "linearvoid_chat_build_info{service=\"chat\",database_driver=\""+databaseDriverName+"\"} 1")
fmt.Fprintln(w, "linearvoid_chat_build_info{service=\"chat\",database_driver=\""+databaseDriverName+"\",instance_id=\""+instanceID+"\"} 1")
}
// broadcast sends a message to all connected clients in a room and saves to DB
@@ -2172,12 +2181,23 @@ func broadcast(msg Message) {
// Persist to configured SQL backend.
db.Create(&msg)
// Local fan-out
for client := range clients {
if err := client.WriteJSON(msg); err != nil {
client.Close()
delete(clients, client)
delete(clientMeta, client)
}
}
// Redis pub/sub for multi-instance fan-out
if redisClient != nil && msg.Room != "" {
payload, _ := json.Marshal(map[string]any{
"instance": instanceID,
"msg": msg,
})
redisClient.Publish(context.Background(), fmt.Sprintf("room:%s:messages", msg.Room), payload)
}
}
// handleConnection handles WebSocket connections for chat (public rooms, private DMs, moderation)
@@ -2333,6 +2353,47 @@ func pushChatHistory(conn *websocket.Conn, room string, limit int) {
// dmsSent tracks total private messages for Prometheus metrics (set from handleMetrics).
var dmsSent int64
// subscribeRoomMessages listens for Redis pub/sub messages and fans out locally.
func subscribeRoomMessages() {
if redisClient == nil {
return
}
pubsub := redisClient.PSubscribe(context.Background(), "room:*:messages")
defer pubsub.Close()
log.Printf("Redis pub/sub subscriber started (instance %s)", instanceID)
for {
msg, err := pubsub.ReceiveMessage(context.Background())
if err != nil {
log.Printf("Redis pub/sub error: %v — retrying in 5s", err)
time.Sleep(5 * time.Second)
pubsub = redisClient.PSubscribe(context.Background(), "room:*:messages")
continue
}
var envelope struct {
Instance string `json:"instance"`
Msg Message `json:"msg"`
}
if err := json.Unmarshal([]byte(msg.Payload), &envelope); err != nil {
continue
}
// Skip messages originating from this instance
if envelope.Instance == instanceID {
continue
}
// Fan out to local clients
mutex.Lock()
for client := range clients {
client.WriteJSON(envelope.Msg)
}
mutex.Unlock()
}
}
// handlePrivateDM routes private messages to the specific user pair
func handlePrivateDM(msg Message) {
msg.Type = "private"
@@ -2640,6 +2701,11 @@ func main() {
initRedis()
initDB()
// Subscribe to Redis pub/sub for multi-instance message fan-out
go subscribeRoomMessages()
log.Printf("Instance ID: %s", instanceID)
http.HandleFunc("/ws", handleConnection)
http.HandleFunc("/api/admin/login", handleAdminLogin)
http.HandleFunc("/api/admin/logout", handleAdminLogout)