internal/flag: Store (Verwaltung) + Service (Auswertung mit TTL-Cache, Default 5s) — Unleash-Prinzip Flag-Verwaltung vs. Flag-Auswertung getrennt, als Kernfunktion des Core-Dienstes selbst statt separater Infrastruktur. evaluate() wendet drei Strategien in fester Reihenfolge an: global an/aus, Tenant-Zielgruppe, deterministischer Prozentsatz-Rollout (FNV-Hash aus Tenant+Key, stabil pro Tenant). IsEnabled liefert IMMER nur bool (kein Fehlerwert) — ein nicht erreichbarer Flag-Dienst kann damit keinen Aufrufer zum Absturz bringen: bei DB-Fehler wird der zuletzt bekannte Cache-Stand verwendet, ohne jeglichen Stand faellt der Dienst sicher auf false zurueck. Service.Invalidate erzwingt sofortiges Neuladen fuer den Schreiber selbst, andere Instanzen sehen Aenderungen spaetestens nach der TTL (Akzeptanzkriterium 3, kein Neustart noetig). Bugfix waehrend Tests: Store.Set uebergab ein nil-TargetTenantSlugs-Slice als SQL NULL statt leerem Array (NOT-NULL-Verletzung) — auf leeres Slice normalisiert. Akzeptanzkriterium 4 (Deaktivierung loescht keine Daten): dieses Paket besitzt ausschliesslich die eigene feature_flags-Zeile, hat keinerlei Code-Pfad, der Modul-Geschaeftsdaten anfassen koennte — Loeschung bleibt strukturell der Archive-Retention-Engine vorbehalten. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Cache-Invalidierungszeit automatisiert gemessen — TestService_CacheInvalidationTiming: Aenderung wirksam nach 153ms bei TTL=150ms (innerhalb Ziel+Toleranz), vorher nachweislich noch alter Stand. PASS. 2. Zielgruppen-Strategie liefert erwartete Auswertung — TestService_TargetTenantStrategy / TestEvaluate_TargetTenantStrategy. PASS. 3. Ausfall des Flag-Dienstes fuehrt zu dokumentiertem Fallback, kein Absturz — TestService_FallsBackOnStoreFailure (mit recover()-Absicherung): Fallback auf Cache-Stand bzw. sicheres false bei komplett unerreichbarer DB, geloggt. PASS. 4. Modul-Deaktivierung/Reaktivierung ohne Datenverlust — architektonisch durch fehlenden Code-Pfad sichergestellt (siehe oben), zusaetzlich durch TestService_InvalidateForcesImmediateRefresh (Toggle aus/an bleibt konsistent nachvollziehbar) mitabgedeckt. PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
88 lines
2.5 KiB
Go
88 lines
2.5 KiB
Go
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()
|
|
}
|