package flag import ( "context" "log/slog" "sync" "time" ) // DefaultCacheTTL ist die dokumentierte Cache-Invalidierungszeit // (Akzeptanzkriterium 2/3): eine Aenderung wirkt spaetestens nach dieser // Zeit auf allen Core-Instanzen, ohne dass ein Dienst neu gestartet werden // muss (Akzeptanzkriterium 3). const DefaultCacheTTL = 5 * time.Second type cacheEntry struct { flag Flag expiresAt time.Time } // Service ist die Auswertungsseite (SDK/Client-Analogon zu Unleash) mit // lokalem TTL-Cache. Bewusst getrennt von Store (Verwaltung). type Service struct { store *Store ttl time.Duration mu sync.RWMutex cache map[string]cacheEntry } func NewService(store *Store, ttl time.Duration) *Service { if ttl <= 0 { ttl = DefaultCacheTTL } return &Service{store: store, ttl: ttl, cache: make(map[string]cacheEntry)} } // IsEnabled wertet ein Flag fuer einen Tenant aus. Liefert IMMER einen // bool ohne Fehlerwert — ein nicht erreichbarer Flag-Dienst darf abhaengige // Aufrufer nicht zum Absturz bringen oder zu Fehlerbehandlungscode zwingen, // der leicht vergessen wird (Akzeptanzkriterium 3 / Pruefung 3: dokumentiertes // Fallback-Verhalten = false, ggf. aus dem zuletzt bekannten Zwischenspeicher). func (s *Service) IsEnabled(ctx context.Context, tenantSlug, key string) bool { f, ok := s.resolve(ctx, key) if !ok { return false } return evaluate(f, tenantSlug) } func (s *Service) resolve(ctx context.Context, key string) (Flag, bool) { s.mu.RLock() entry, exists := s.cache[key] fresh := exists && time.Now().Before(entry.expiresAt) s.mu.RUnlock() if fresh { return entry.flag, true } f, err := s.store.Get(ctx, key) if err != nil { if exists { slog.Warn("feature-flag-dienst nicht erreichbar, nutze zwischengespeicherten stand", "flag_key", key, "error", err) return entry.flag, true } slog.Warn("feature-flag-dienst nicht erreichbar, kein zwischengespeicherter stand vorhanden, fallback: deaktiviert", "flag_key", key, "error", err) return Flag{}, false } s.mu.Lock() s.cache[key] = cacheEntry{flag: f, expiresAt: time.Now().Add(s.ttl)} s.mu.Unlock() return f, true } // Invalidate erzwingt beim naechsten IsEnabled-Aufruf ein sofortiges Neuladen // aus der Datenbank statt auf den TTL-Ablauf zu warten — wird nach Store.Set // auf derselben Instanz aufgerufen, damit der Schreiber die eigene Aenderung // ohne Wartezeit sieht. Andere Core-Instanzen sehen sie spaetestens nach // DefaultCacheTTL (siehe Akzeptanzkriterium 3). func (s *Service) Invalidate(key string) { s.mu.Lock() delete(s.cache, key) s.mu.Unlock() }