package cache import ( "encoding/json" "fmt" "os" "path/filepath" "sync" "time" "git.warky.dev/wdevs/whatshooked/pkg/events" "git.warky.dev/wdevs/whatshooked/pkg/logging" ) // CachedEvent represents an event stored in cache type CachedEvent struct { ID string `json:"id"` Event events.Event `json:"event"` Timestamp time.Time `json:"timestamp"` Reason string `json:"reason"` Attempts int `json:"attempts"` LastAttempt *time.Time `json:"last_attempt,omitempty"` } // MessageCache manages cached events when no webhooks are available type MessageCache struct { events map[string]*CachedEvent mu sync.RWMutex dataPath string enabled bool maxAge time.Duration // Maximum age before events are purged maxEvents int // Maximum number of events to keep } // Config holds cache configuration type Config struct { Enabled bool `json:"enabled"` DataPath string `json:"data_path"` MaxAge time.Duration `json:"max_age"` // Default: 7 days MaxEvents int `json:"max_events"` // Default: 10000 } // NewMessageCache creates a new message cache func NewMessageCache(cfg Config) (*MessageCache, error) { if !cfg.Enabled { return &MessageCache{ enabled: false, }, nil } if cfg.DataPath == "" { cfg.DataPath = "./data/cache" } if cfg.MaxAge == 0 { cfg.MaxAge = 7 * 24 * time.Hour // 7 days } if cfg.MaxEvents == 0 { cfg.MaxEvents = 10000 } // Create cache directory if err := os.MkdirAll(cfg.DataPath, 0755); err != nil { return nil, fmt.Errorf("failed to create cache directory: %w", err) } cache := &MessageCache{ events: make(map[string]*CachedEvent), dataPath: cfg.DataPath, enabled: true, maxAge: cfg.MaxAge, maxEvents: cfg.MaxEvents, } // Load existing cached events if err := cache.loadFromDisk(); err != nil { logging.Warn("Failed to load cached events from disk", "error", err) } // Start cleanup goroutine go cache.cleanupLoop() logging.Info("Message cache initialized", "enabled", cfg.Enabled, "data_path", cfg.DataPath, "max_age", cfg.MaxAge, "max_events", cfg.MaxEvents) return cache, nil } // Store adds an event to the cache func (c *MessageCache) Store(event events.Event, reason string) error { if !c.enabled { return nil } c.mu.Lock() defer c.mu.Unlock() // Check if we're at capacity if len(c.events) >= c.maxEvents { // Remove oldest event c.removeOldest() } // Generate unique ID id := fmt.Sprintf("%d-%s", time.Now().UnixNano(), event.Type) cached := &CachedEvent{ ID: id, Event: event, Timestamp: time.Now(), Reason: reason, Attempts: 0, } c.events[id] = cached // Save to disk asynchronously go c.saveToDisk(cached) logging.Debug("Event cached", "event_id", id, "event_type", event.Type, "reason", reason, "cache_size", len(c.events)) return nil } // Get retrieves a cached event by ID func (c *MessageCache) Get(id string) (*CachedEvent, bool) { if !c.enabled { return nil, false } c.mu.RLock() defer c.mu.RUnlock() event, exists := c.events[id] return event, exists } // List returns all cached events func (c *MessageCache) List() []*CachedEvent { if !c.enabled { return nil } c.mu.RLock() defer c.mu.RUnlock() result := make([]*CachedEvent, 0, len(c.events)) for _, event := range c.events { result = append(result, event) } return result } // ListByEventType returns cached events filtered by event type func (c *MessageCache) ListByEventType(eventType events.EventType) []*CachedEvent { if !c.enabled { return nil } c.mu.RLock() defer c.mu.RUnlock() result := make([]*CachedEvent, 0) for _, cached := range c.events { if cached.Event.Type == eventType { result = append(result, cached) } } return result } // Remove deletes an event from the cache func (c *MessageCache) Remove(id string) error { if !c.enabled { return nil } c.mu.Lock() defer c.mu.Unlock() if _, exists := c.events[id]; !exists { return fmt.Errorf("cached event not found: %s", id) } delete(c.events, id) // Remove from disk go c.removeFromDisk(id) logging.Debug("Event removed from cache", "event_id", id) return nil } // IncrementAttempts increments the delivery attempt counter func (c *MessageCache) IncrementAttempts(id string) error { if !c.enabled { return nil } c.mu.Lock() defer c.mu.Unlock() cached, exists := c.events[id] if !exists { return fmt.Errorf("cached event not found: %s", id) } now := time.Now() cached.Attempts++ cached.LastAttempt = &now // Update on disk go c.saveToDisk(cached) return nil } // Clear removes all cached events func (c *MessageCache) Clear() error { if !c.enabled { return nil } c.mu.Lock() defer c.mu.Unlock() c.events = make(map[string]*CachedEvent) // Clear disk cache go c.clearDisk() logging.Info("Message cache cleared") return nil } // Count returns the number of cached events func (c *MessageCache) Count() int { if !c.enabled { return 0 } c.mu.RLock() defer c.mu.RUnlock() return len(c.events) } // IsEnabled returns whether the cache is enabled func (c *MessageCache) IsEnabled() bool { return c.enabled } // removeOldest removes the oldest event from the cache func (c *MessageCache) removeOldest() { var oldestID string var oldestTime time.Time for id, cached := range c.events { if oldestID == "" || cached.Timestamp.Before(oldestTime) { oldestID = id oldestTime = cached.Timestamp } } if oldestID != "" { delete(c.events, oldestID) go c.removeFromDisk(oldestID) logging.Debug("Removed oldest cached event due to capacity", "event_id", oldestID) } } // cleanupLoop periodically removes expired events func (c *MessageCache) cleanupLoop() { ticker := time.NewTicker(1 * time.Hour) defer ticker.Stop() for range ticker.C { c.cleanup() } } // cleanup removes expired events func (c *MessageCache) cleanup() { if !c.enabled { return } c.mu.Lock() defer c.mu.Unlock() now := time.Now() expiredIDs := make([]string, 0) for id, cached := range c.events { if now.Sub(cached.Timestamp) > c.maxAge { expiredIDs = append(expiredIDs, id) } } for _, id := range expiredIDs { delete(c.events, id) go c.removeFromDisk(id) } if len(expiredIDs) > 0 { logging.Info("Cleaned up expired cached events", "count", len(expiredIDs)) } } // saveToDisk saves a cached event to disk func (c *MessageCache) saveToDisk(cached *CachedEvent) { filePath := filepath.Join(c.dataPath, fmt.Sprintf("%s.json", cached.ID)) data, err := json.MarshalIndent(cached, "", " ") if err != nil { logging.Error("Failed to marshal cached event", "event_id", cached.ID, "error", err) return } if err := os.WriteFile(filePath, data, 0644); err != nil { logging.Error("Failed to save cached event to disk", "event_id", cached.ID, "error", err) } } // loadFromDisk loads all cached events from disk func (c *MessageCache) loadFromDisk() error { files, err := filepath.Glob(filepath.Join(c.dataPath, "*.json")) if err != nil { return fmt.Errorf("failed to list cache files: %w", err) } loaded := 0 for _, file := range files { data, err := os.ReadFile(file) if err != nil { logging.Warn("Failed to read cache file", "file", file, "error", err) continue } var cached CachedEvent if err := json.Unmarshal(data, &cached); err != nil { logging.Warn("Failed to unmarshal cache file", "file", file, "error", err) continue } // Skip expired events if time.Since(cached.Timestamp) > c.maxAge { os.Remove(file) continue } c.events[cached.ID] = &cached loaded++ } if loaded > 0 { logging.Info("Loaded cached events from disk", "count", loaded) } return nil } // removeFromDisk removes a cached event file from disk func (c *MessageCache) removeFromDisk(id string) { filePath := filepath.Join(c.dataPath, fmt.Sprintf("%s.json", id)) if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { logging.Error("Failed to remove cached event from disk", "event_id", id, "error", err) } } // clearDisk removes all cache files from disk func (c *MessageCache) clearDisk() { files, err := filepath.Glob(filepath.Join(c.dataPath, "*.json")) if err != nil { logging.Error("Failed to list cache files for clearing", "error", err) return } for _, file := range files { if err := os.Remove(file); err != nil { logging.Error("Failed to remove cache file", "file", file, "error", err) } } }