* Implement MessageCache to store events when no webhooks are available. * Add configuration options for enabling cache, setting data path, max age, and max events. * Create API endpoints for managing cached events, including listing, replaying, and deleting. * Integrate caching into the hooks manager to store events when no active webhooks are found. * Enhance logging for better traceability of cached events and operations.
395 lines
8.3 KiB
Go
395 lines
8.3 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|