veylant/internal/routing/cache.go
2026-02-23 13:35:04 +01:00

121 lines
3.0 KiB
Go

package routing
import (
"context"
"sync"
"time"
"go.uber.org/zap"
)
// RuleCache is an in-memory, per-tenant cache of routing rules backed by a RuleStore.
// It performs lazy loading on first access and refreshes all loaded tenants every TTL.
// Mutations (Create/Update/Delete via admin API) should call Invalidate() for immediate
// consistency without waiting for the next refresh cycle.
type RuleCache struct {
mu sync.RWMutex
entries map[string]cacheEntry // tenantID → cached rules
store RuleStore
ttl time.Duration
stopCh chan struct{}
logger *zap.Logger
}
type cacheEntry struct {
rules []RoutingRule
loadedAt time.Time
}
// NewRuleCache creates a RuleCache wrapping store with the given TTL.
func NewRuleCache(store RuleStore, ttl time.Duration, logger *zap.Logger) *RuleCache {
if ttl <= 0 {
ttl = 30 * time.Second
}
return &RuleCache{
entries: make(map[string]cacheEntry),
store: store,
ttl: ttl,
stopCh: make(chan struct{}),
logger: logger,
}
}
// Start launches the background refresh goroutine.
// Call Stop() to shut it down gracefully.
func (c *RuleCache) Start() {
go c.refreshLoop()
}
// Stop signals the background goroutine to stop.
func (c *RuleCache) Stop() {
close(c.stopCh)
}
// Get returns active rules for tenantID, loading from the store on cache miss
// or when the entry is older than the TTL.
func (c *RuleCache) Get(ctx context.Context, tenantID string) ([]RoutingRule, error) {
c.mu.RLock()
entry, ok := c.entries[tenantID]
c.mu.RUnlock()
if ok && time.Since(entry.loadedAt) < c.ttl {
return entry.rules, nil
}
return c.load(ctx, tenantID)
}
// Invalidate removes the cached rules for tenantID, forcing a reload on next Get().
// Call this after any Create/Update/Delete operation on routing rules.
func (c *RuleCache) Invalidate(tenantID string) {
c.mu.Lock()
delete(c.entries, tenantID)
c.mu.Unlock()
}
// load fetches rules from the store and updates the cache entry.
func (c *RuleCache) load(ctx context.Context, tenantID string) ([]RoutingRule, error) {
rules, err := c.store.ListActive(ctx, tenantID)
if err != nil {
c.logger.Error("rule cache: failed to load rules", zap.String("tenant_id", tenantID), zap.Error(err))
return nil, err
}
c.mu.Lock()
c.entries[tenantID] = cacheEntry{rules: rules, loadedAt: time.Now()}
c.mu.Unlock()
c.logger.Debug("rule cache: loaded rules", zap.String("tenant_id", tenantID), zap.Int("count", len(rules)))
return rules, nil
}
// refreshLoop periodically reloads all cached tenants.
func (c *RuleCache) refreshLoop() {
ticker := time.NewTicker(c.ttl)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.refreshAll()
case <-c.stopCh:
return
}
}
}
func (c *RuleCache) refreshAll() {
c.mu.RLock()
tenants := make([]string, 0, len(c.entries))
for tid := range c.entries {
tenants = append(tenants, tid)
}
c.mu.RUnlock()
for _, tid := range tenants {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_, _ = c.load(ctx, tid) //nolint:errcheck — logged inside load()
cancel()
}
}