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.6 KiB
Go
88 lines
2.6 KiB
Go
// Package flag implementiert Core LIC-02: einen Feature-Flag-Dienst mit
|
|
// Strategien (global an/aus, Prozentsatz, Tenant-Zielgruppe) als Kernfunktion
|
|
// des Core-Dienstes selbst — keine zusaetzliche Infrastruktur (Unleash-Server
|
|
// + eigene DB), siehe "bewusst vermeiden" im LIC-02-Ticket.
|
|
package flag
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"hash/fnv"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
var ErrNotFound = errors.New("flag: nicht gefunden")
|
|
|
|
// Flag ist die zentrale Definition — Auswertung (Evaluate) ist bewusst davon
|
|
// getrennt (Unleash-Prinzip: Flag-Verwaltung vs. Flag-Auswertung).
|
|
type Flag struct {
|
|
Key string
|
|
Enabled bool
|
|
RolloutPercentage int
|
|
TargetTenantSlugs []string
|
|
}
|
|
|
|
// Store ist die Verwaltungsseite (Admin): Flags definieren/lesen.
|
|
type Store struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewStore(pool *pgxpool.Pool) *Store {
|
|
return &Store{pool: pool}
|
|
}
|
|
|
|
func (s *Store) Set(ctx context.Context, f Flag) error {
|
|
if f.TargetTenantSlugs == nil {
|
|
f.TargetTenantSlugs = []string{} // pgx uebertraegt ein nil-Slice sonst als SQL NULL statt leerem Array.
|
|
}
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO feature_flags (key, enabled, rollout_percentage, target_tenant_slugs, updated_at)
|
|
VALUES ($1, $2, $3, $4, now())
|
|
ON CONFLICT (key) DO UPDATE SET
|
|
enabled = $2, rollout_percentage = $3, target_tenant_slugs = $4, updated_at = now()
|
|
`, f.Key, f.Enabled, f.RolloutPercentage, f.TargetTenantSlugs)
|
|
if err != nil {
|
|
return fmt.Errorf("flag speichern: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) Get(ctx context.Context, key string) (Flag, error) {
|
|
var f Flag
|
|
row := s.pool.QueryRow(ctx, `
|
|
SELECT key, enabled, rollout_percentage, target_tenant_slugs
|
|
FROM feature_flags WHERE key = $1
|
|
`, key)
|
|
if err := row.Scan(&f.Key, &f.Enabled, &f.RolloutPercentage, &f.TargetTenantSlugs); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Flag{}, ErrNotFound
|
|
}
|
|
return Flag{}, fmt.Errorf("flag lesen: %w", err)
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// evaluate wendet die Strategien in fester Reihenfolge an: globaler
|
|
// An/Aus-Schalter zuerst, dann Tenant-Zielgruppe, dann Prozentsatz-Rollout.
|
|
// Ein unbekannter/nicht getroffener Fall ergibt false — Fail-Safe-Default,
|
|
// kein Feature wird versehentlich aktiv.
|
|
func evaluate(f Flag, tenantSlug string) bool {
|
|
if f.Enabled {
|
|
return true
|
|
}
|
|
for _, target := range f.TargetTenantSlugs {
|
|
if target == tenantSlug {
|
|
return true
|
|
}
|
|
}
|
|
if f.RolloutPercentage > 0 {
|
|
h := fnv.New32a()
|
|
_, _ = h.Write([]byte(f.Key + "|" + tenantSlug))
|
|
return int(h.Sum32()%100) < f.RolloutPercentage
|
|
}
|
|
return false
|
|
}
|