255 lines
7.9 KiB
Go
255 lines
7.9 KiB
Go
package cache
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/config"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Channel constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const (
|
|
// ChannelCatalog is the pub/sub channel for catalog events (scan_complete,
|
|
// metadata_updated).
|
|
ChannelCatalog = "silo:catalog"
|
|
|
|
// ChannelAdmin is the pub/sub channel for admin events (user_disabled,
|
|
// user_deleted, settings_changed).
|
|
ChannelAdmin = "silo:admin"
|
|
|
|
// ChannelPlayback is the pub/sub channel reserved for future playback
|
|
// events.
|
|
ChannelPlayback = "silo:playback"
|
|
|
|
// ChannelLogs is the pub/sub channel for persisted operational/audit log
|
|
// entries that should be fanned out to admin WebSocket subscribers.
|
|
ChannelLogs = "silo:logs"
|
|
|
|
// ChannelEvents is the pub/sub channel for passive websocket events.
|
|
ChannelEvents = "silo:events"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Event type constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const (
|
|
EventScanComplete = "scan_complete"
|
|
EventMetadataUpdated = "metadata_updated"
|
|
EventAdminStatsInvalidated = "admin_stats_invalidated"
|
|
EventPlaybackSessionsChanged = "playback_sessions_changed"
|
|
EventUserDisabled = "user_disabled"
|
|
EventUserDeleted = "user_deleted"
|
|
EventSettingsChanged = "settings_changed"
|
|
EventNodePoolChanged = "node_pool_changed"
|
|
EventOperationalLogAppended = "operational_log_appended"
|
|
EventAuditLogAppended = "audit_log_appended"
|
|
EventEventsNotification = "events_notification"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Event
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Event represents a cache invalidation event transmitted over pub/sub.
|
|
type Event struct {
|
|
Type string `json:"type"` // e.g. "user_disabled", "scan_complete"
|
|
Payload string `json:"payload"` // e.g. user ID, library ID
|
|
}
|
|
|
|
// EventHandler is a callback invoked when an event is received on a
|
|
// subscribed channel.
|
|
type EventHandler func(event Event)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// EventBus interface
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// EventBus is the interface for publishing and subscribing to cache
|
|
// invalidation events. When Redis is not configured the factory returns
|
|
// a no-op implementation so callers never need nil checks.
|
|
type EventBus interface {
|
|
// Publish sends an event on the given channel.
|
|
Publish(ctx context.Context, channel string, event Event) error
|
|
|
|
// Subscribe registers a handler for events on the given channel.
|
|
// The handler is called in a dedicated goroutine.
|
|
Subscribe(ctx context.Context, channel string, handler EventHandler) error
|
|
|
|
// Close tears down the bus and stops all subscriber goroutines.
|
|
// It is safe to call Close multiple times.
|
|
Close() error
|
|
}
|
|
|
|
// NewEventBus returns an EventBus. When redisURL is empty a no-op
|
|
// implementation is returned that silently succeeds on every call and
|
|
// has zero external dependencies.
|
|
func NewEventBus(redisURL string) EventBus {
|
|
if redisURL == "" {
|
|
return &NoopEventBus{}
|
|
}
|
|
return newRedisEventBus(redisURL)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// NoopEventBus
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// NoopEventBus is a silent no-op implementation of EventBus used when
|
|
// Redis is not configured. All methods return nil without doing anything.
|
|
type NoopEventBus struct{}
|
|
|
|
// Publish is a no-op that always returns nil.
|
|
func (n *NoopEventBus) Publish(_ context.Context, _ string, _ Event) error { return nil }
|
|
|
|
// Subscribe is a no-op that always returns nil. The handler is never called.
|
|
func (n *NoopEventBus) Subscribe(_ context.Context, _ string, _ EventHandler) error { return nil }
|
|
|
|
// Close is a no-op that always returns nil. Safe to call multiple times.
|
|
func (n *NoopEventBus) Close() error { return nil }
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RedisEventBus
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// subscription tracks a single Redis pub/sub subscription so it can be
|
|
// torn down on Close.
|
|
type subscription struct {
|
|
pubsub *redis.PubSub
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
// RedisEventBus implements EventBus using Redis pub/sub. Events are
|
|
// JSON-serialized before being published.
|
|
type RedisEventBus struct {
|
|
client *redis.Client
|
|
|
|
mu sync.Mutex
|
|
subs []subscription
|
|
once sync.Once // ensures Close is idempotent
|
|
done chan struct{}
|
|
}
|
|
|
|
// newRedisEventBus creates a RedisEventBus connected to the given Redis URL.
|
|
func newRedisEventBus(redisURL string) *RedisEventBus {
|
|
opts, err := redis.ParseURL(redisURL)
|
|
if err != nil {
|
|
// If the URL cannot be parsed, treat it as a simple address.
|
|
opts = &redis.Options{Addr: redisURL}
|
|
}
|
|
return &RedisEventBus{
|
|
client: redis.NewClient(opts),
|
|
done: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Publish serializes the event as JSON and publishes it on the given
|
|
// Redis channel.
|
|
func (r *RedisEventBus) Publish(ctx context.Context, channel string, event Event) error {
|
|
data, err := json.Marshal(event)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return r.client.Publish(ctx, channel, data).Err()
|
|
}
|
|
|
|
// Subscribe creates a Redis pub/sub subscription on the given channel and
|
|
// spawns a goroutine that delivers incoming messages to the handler.
|
|
func (r *RedisEventBus) Subscribe(ctx context.Context, channel string, handler EventHandler) error {
|
|
subCtx, cancel := context.WithCancel(ctx)
|
|
pubsub := r.client.Subscribe(subCtx, channel)
|
|
|
|
// Wait for the subscription to be confirmed.
|
|
if _, err := pubsub.Receive(subCtx); err != nil {
|
|
cancel()
|
|
return err
|
|
}
|
|
|
|
r.mu.Lock()
|
|
r.subs = append(r.subs, subscription{pubsub: pubsub, cancel: cancel})
|
|
r.mu.Unlock()
|
|
|
|
go r.listen(pubsub, handler)
|
|
return nil
|
|
}
|
|
|
|
// listen reads messages from the pub/sub channel and invokes the handler.
|
|
func (r *RedisEventBus) listen(pubsub *redis.PubSub, handler EventHandler) {
|
|
ch := pubsub.Channel()
|
|
for {
|
|
select {
|
|
case msg, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
var evt Event
|
|
if err := json.Unmarshal([]byte(msg.Payload), &evt); err != nil {
|
|
// Skip malformed messages.
|
|
continue
|
|
}
|
|
handler(evt)
|
|
case <-r.done:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close stops all subscriber goroutines and closes the Redis client.
|
|
// It is safe to call Close multiple times; only the first call has any
|
|
// effect.
|
|
func (r *RedisEventBus) Close() error {
|
|
var firstErr error
|
|
r.once.Do(func() {
|
|
close(r.done)
|
|
|
|
r.mu.Lock()
|
|
subs := r.subs
|
|
r.subs = nil
|
|
r.mu.Unlock()
|
|
|
|
for _, s := range subs {
|
|
if err := s.pubsub.Close(); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
s.cancel()
|
|
}
|
|
|
|
if err := r.client.Close(); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
})
|
|
return firstErr
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Redis client factory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// NewRedisClient creates a Redis client based on the config.
|
|
// Returns nil and no error if no Redis URL or Sentinel config is provided.
|
|
// Returns an error if configuration is present but invalid.
|
|
func NewRedisClient(cfg config.RedisConfig) (*redis.Client, error) {
|
|
if cfg.SentinelMaster != "" && len(cfg.SentinelAddresses) > 0 {
|
|
return redis.NewFailoverClient(&redis.FailoverOptions{
|
|
MasterName: cfg.SentinelMaster,
|
|
SentinelAddrs: cfg.SentinelAddresses,
|
|
SentinelPassword: cfg.SentinelPassword,
|
|
DB: 0,
|
|
}), nil
|
|
}
|
|
if cfg.URL == "" {
|
|
return nil, nil
|
|
}
|
|
opt, err := redis.ParseURL(cfg.URL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid redis URL: %w", err)
|
|
}
|
|
return redis.NewClient(opt), nil
|
|
}
|