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() } }