Files
gochat/backend/internal/pubsub/redis_pubsub.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

165 lines
4.6 KiB
Go

package pubsub
import (
"context"
"encoding/json"
"fmt"
"sync"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
redisstream "github.com/ThreeDotsLabs/watermill-redisstream/pkg/redisstream"
"github.com/redis/go-redis/v9"
applogger "github.com/gochat/gochat/pkg/logger"
)
// RedisPubSub implements the PubSub interface using Redis Streams via watermill-redisstream.
// It provides production-grade message delivery with consumer groups for reliable fan-out.
type RedisPubSub struct {
publisher *redisstream.Publisher
subscriber *redisstream.Subscriber
client redis.UniversalClient
mu sync.Mutex
subscriptions map[string]context.CancelFunc // track active subscriptions for cleanup
}
// NewRedisPubSub creates a new Redis-backed PubSub instance.
// Falls back to InMemoryPubSub if the Redis client is nil or connection fails.
func NewRedisPubSub(client redis.UniversalClient) (*RedisPubSub, error) {
if client == nil {
return nil, fmt.Errorf("redis client is required for RedisPubSub")
}
logger := watermill.NopLogger{}
publisher, err := redisstream.NewPublisher(redisstream.PublisherConfig{
Client: client,
}, logger)
if err != nil {
return nil, fmt.Errorf("failed to create redis stream publisher: %w", err)
}
subscriber, err := redisstream.NewSubscriber(redisstream.SubscriberConfig{
Client: client,
ConsumerGroup: "gochat-pubsub",
}, logger)
if err != nil {
return nil, fmt.Errorf("failed to create redis stream subscriber: %w", err)
}
return &RedisPubSub{
publisher: publisher,
subscriber: subscriber,
client: client,
subscriptions: make(map[string]context.CancelFunc),
}, nil
}
// Publish sends an event to all subscribers of the given topic via Redis Streams.
func (ps *RedisPubSub) Publish(ctx context.Context, topic string, event Event) error {
payload, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("failed to marshal event: %w", err)
}
msg := message.NewMessage(watermill.NewUUID(), payload)
// Propagate context metadata into message metadata
msg.Metadata.Set("event_type", event.Type)
msg.Metadata.Set("account_id", fmt.Sprintf("%d", event.AccountID))
if err := ps.publisher.Publish(topic, msg); err != nil {
return fmt.Errorf("failed to publish event to redis stream: %w", err)
}
applogger.L().Debugf("Published event %s to topic %s via Redis", event.Type, topic)
return nil
}
// Subscribe registers a handler for events on the given topic via Redis Streams consumer groups.
func (ps *RedisPubSub) Subscribe(ctx context.Context, topic string, handler EventHandler) error {
messages, err := ps.subscriber.Subscribe(ctx, topic)
if err != nil {
return fmt.Errorf("failed to subscribe to redis stream topic %s: %w", topic, err)
}
// Create a cancellable context so we can stop this specific subscription
subCtx, cancel := context.WithCancel(ctx)
ps.mu.Lock()
ps.subscriptions[topic] = cancel
ps.mu.Unlock()
go func() {
for {
select {
case <-subCtx.Done():
applogger.L().Debugf("Subscription to topic %s stopped", topic)
return
case msg, ok := <-messages:
if !ok {
applogger.L().Debugf("Message channel for topic %s closed", topic)
ps.mu.Lock()
delete(ps.subscriptions, topic)
ps.mu.Unlock()
return
}
var event Event
if err := json.Unmarshal(msg.Payload, &event); err != nil {
applogger.L().Errorf("Failed to unmarshal event from redis stream: %v", err)
msg.Nack()
continue
}
handler(event)
msg.Ack()
}
}
}()
applogger.L().Infof("Subscribed to topic %s via Redis Streams (consumer group: gochat-pubsub)", topic)
return nil
}
// Unsubscribe removes a subscription from the given topic.
func (ps *RedisPubSub) Unsubscribe(ctx context.Context, topic string) error {
ps.mu.Lock()
cancel, ok := ps.subscriptions[topic]
if ok {
cancel()
delete(ps.subscriptions, topic)
}
ps.mu.Unlock()
if !ok {
applogger.L().Debugf("No active subscription found for topic %s", topic)
return nil
}
applogger.L().Infof("Unsubscribed from topic %s", topic)
return nil
}
// Close shuts down the Redis PubSub publisher and subscriber gracefully.
func (ps *RedisPubSub) Close() error {
ps.mu.Lock()
for topic, cancel := range ps.subscriptions {
cancel()
delete(ps.subscriptions, topic)
}
ps.mu.Unlock()
if err := ps.subscriber.Close(); err != nil {
applogger.L().Errorf("Redis PubSub subscriber close error: %v", err)
}
if err := ps.publisher.Close(); err != nil {
applogger.L().Errorf("Redis PubSub publisher close error: %v", err)
return fmt.Errorf("redis publisher close error: %w", err)
}
applogger.L().Info("Redis PubSub closed")
return nil
}