206 lines
5.2 KiB
Go
206 lines
5.2 KiB
Go
package events
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
const (
|
|
// MetaEventsStream is the Redis stream name for metadata events
|
|
MetaEventsStream = "meta:events"
|
|
)
|
|
|
|
// MetaPublisher subscribes to Redis keyspace notifications
|
|
// and publishes metadata change events to the meta:events stream
|
|
type MetaPublisher struct {
|
|
client *redis.Client
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
running bool
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewMetaPublisher creates a new MetaPublisher
|
|
func NewMetaPublisher(client *redis.Client) *MetaPublisher {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
return &MetaPublisher{
|
|
client: client,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
}
|
|
|
|
// Start begins subscribing to keyspace notifications
|
|
func (p *MetaPublisher) Start() error {
|
|
p.mu.Lock()
|
|
if p.running {
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
p.running = true
|
|
p.mu.Unlock()
|
|
|
|
// Clear the stream on startup - consumers will re-bootstrap from Redis keys
|
|
// This ensures the stream only contains events from the current session
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := p.client.Del(ctx, MetaEventsStream).Err(); err != nil {
|
|
log.Printf("[MetaPublisher] Warning: failed to clear stream on startup: %v", err)
|
|
} else {
|
|
log.Printf("[MetaPublisher] Cleared %s stream for fresh start", MetaEventsStream)
|
|
}
|
|
|
|
p.wg.Add(1)
|
|
go p.subscribeLoop()
|
|
|
|
log.Println("[MetaPublisher] Started - listening for keyspace notifications")
|
|
return nil
|
|
}
|
|
|
|
// Stop stops the publisher
|
|
func (p *MetaPublisher) Stop() error {
|
|
p.mu.Lock()
|
|
if !p.running {
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
p.running = false
|
|
p.mu.Unlock()
|
|
|
|
p.cancel()
|
|
p.wg.Wait()
|
|
|
|
log.Println("[MetaPublisher] Stopped")
|
|
return nil
|
|
}
|
|
|
|
// IsRunning returns true if the publisher is running
|
|
func (p *MetaPublisher) IsRunning() bool {
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
return p.running
|
|
}
|
|
|
|
// subscribeLoop subscribes to keyspace notifications and publishes events
|
|
func (p *MetaPublisher) subscribeLoop() {
|
|
defer p.wg.Done()
|
|
|
|
// Subscribe to all file:* keyspace events
|
|
// Pattern: __keyspace@0__:file:*
|
|
pattern := "__keyspace@0__:file:*"
|
|
pubsub := p.client.PSubscribe(p.ctx, pattern)
|
|
defer pubsub.Close()
|
|
|
|
log.Printf("[MetaPublisher] Subscribed to pattern: %s", pattern)
|
|
|
|
ch := pubsub.Channel()
|
|
for {
|
|
select {
|
|
case <-p.ctx.Done():
|
|
return
|
|
case msg := <-ch:
|
|
if msg == nil {
|
|
continue
|
|
}
|
|
// msg.Channel: __keyspace@0__:file:midhash256:abc123/tmdb
|
|
// msg.Payload: set, del, expire, etc.
|
|
p.publishEvent(msg.Channel, msg.Payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
// publishEvent publishes a keyspace event to the meta:events stream
|
|
func (p *MetaPublisher) publishEvent(channel, operation string) {
|
|
// Extract key from channel: __keyspace@0__:file:xxx/field -> file:xxx/field
|
|
key := strings.TrimPrefix(channel, "__keyspace@0__:")
|
|
|
|
// Skip index key and non-file keys
|
|
if strings.Contains(key, "__index__") || !strings.HasPrefix(key, "file:") {
|
|
return
|
|
}
|
|
|
|
// Build event fields
|
|
fields := map[string]interface{}{
|
|
"type": operation, // "set", "del", "expire"
|
|
"key": key, // "file:midhash256:abc123/tmdb"
|
|
"ts": fmt.Sprintf("%d", time.Now().UnixMilli()),
|
|
}
|
|
|
|
// Add to stream (no MaxLen - stream is cleared on meta-core restart)
|
|
_, err := p.client.XAdd(p.ctx, &redis.XAddArgs{
|
|
Stream: MetaEventsStream,
|
|
Values: fields,
|
|
}).Result()
|
|
|
|
if err != nil {
|
|
log.Printf("[MetaPublisher] Error publishing event: %v", err)
|
|
}
|
|
}
|
|
|
|
// publishEventDirect publishes an event directly (not from keyspace notification)
|
|
func (p *MetaPublisher) publishEventDirect(operation, key string) {
|
|
fields := map[string]interface{}{
|
|
"type": operation,
|
|
"key": key,
|
|
"ts": fmt.Sprintf("%d", time.Now().UnixMilli()),
|
|
}
|
|
|
|
_, err := p.client.XAdd(p.ctx, &redis.XAddArgs{
|
|
Stream: MetaEventsStream,
|
|
Values: fields,
|
|
}).Result()
|
|
|
|
if err != nil {
|
|
log.Printf("[MetaPublisher] Error publishing direct event: %v", err)
|
|
}
|
|
}
|
|
|
|
// RepublishFunc is a function type for getting metadata that can be republished
|
|
type RepublishFunc func() (hashIDs []string, getMetadata func(hashID string) (map[string]string, error), err error)
|
|
|
|
// RepublishAllMetadata iterates over all existing file metadata and publishes events
|
|
// This is called on startup and can be triggered via API
|
|
func (p *MetaPublisher) RepublishAllMetadata(republishFunc RepublishFunc) (int, error) {
|
|
p.mu.RLock()
|
|
running := p.running
|
|
p.mu.RUnlock()
|
|
|
|
if !running {
|
|
return 0, fmt.Errorf("publisher not running")
|
|
}
|
|
|
|
// Get all hash IDs and metadata getter
|
|
hashIDs, getMetadata, err := republishFunc()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to get hash IDs: %w", err)
|
|
}
|
|
|
|
log.Printf("[MetaPublisher] Republishing events for %d files...", len(hashIDs))
|
|
|
|
publishedCount := 0
|
|
for _, hashID := range hashIDs {
|
|
// Get all properties for this file
|
|
metadata, err := getMetadata(hashID)
|
|
if err != nil || metadata == nil {
|
|
continue
|
|
}
|
|
|
|
// Publish a set event for each property
|
|
for property := range metadata {
|
|
key := fmt.Sprintf("file:%s/%s", hashID, property)
|
|
p.publishEventDirect("set", key)
|
|
publishedCount++
|
|
}
|
|
}
|
|
|
|
log.Printf("[MetaPublisher] Republished %d property events", publishedCount)
|
|
return publishedCount, nil
|
|
}
|