internal/cfgservice: Store (Schreiben/Historie) + Service (Lesen mit Vorrangregel + TTL-Cache, Default 5s, analog internal/flag). Genannt "cfgservice" statt "config", da internal/config bereits die Bootstrap- Konfiguration des Core-Prozesses selbst belegt. Store.Set schreibt aktuellen Stand (config_values) und Historieneintrag (config_value_history) atomar in einer Transaktion — eine Aenderung ohne Versionshistorie ist strukturell ausgeschlossen (Akzeptanzkriterium 2). Version wird pro (key, scope) monoton hochgezaehlt. Service.Resolve wendet die Vorrangregel an: Tenant-spezifischer Override (scope = Tenant-Slug) hat Vorrang vor globalem Default (scope = 'global'), faellt sauber zurueck wenn kein Override existiert (Akzeptanzkriterium 1). Invalidate erzwingt sofortiges Neuladen fuer den Schreiber, andere Instanzen sehen Aenderungen spaetestens nach der TTL. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Vorrangregel automatisiert getestet — TestService_TenantOverrideTakesPrecedenceOverGlobal: Tenant mit Override bekommt Tenant-Wert, Tenant ohne Override bekommt Global-Default. PASS. 2. Cache-Invalidierung nach Aenderung innerhalb dokumentierter Zeit gemessen — TestService_CacheInvalidationTiming: wirksam nach 154ms bei TTL=150ms (innerhalb Ziel+Toleranz), vorher nachweislich noch alter Stand. PASS. 3. Versionierungshistorie ueber mehrere Aenderungen nachvollzogen — TestStore_HistoryTracksAllChanges: 3 aufeinanderfolgende Aenderungen, Historie liefert alle 3 in korrekter Reihenfolge mit korrekten Versionsnummern. PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
130 lines
4.0 KiB
Go
130 lines
4.0 KiB
Go
// Package cfgservice implementiert Core CFG-01: den zentralen Dienst fuer
|
|
// globale und tenant-spezifische Konfigurationswerte mit Versionierung und
|
|
// Cache-Invalidierung. Andere Module lesen Konfiguration AUSSCHLIESSLICH
|
|
// ueber dieses Paket (Akzeptanzkriterium 3), niemals ueber eigene Tabellen.
|
|
package cfgservice
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// GlobalScope ist der reservierte Scope-Wert fuer globale Defaults — jeder
|
|
// andere Scope-Wert ist ein Tenant-Slug (Akzeptanzkriterium 1).
|
|
const GlobalScope = "global"
|
|
|
|
var ErrNotFound = errors.New("cfgservice: kein wert fuer diesen key gefunden")
|
|
|
|
type Value struct {
|
|
Key string
|
|
Scope string
|
|
Value string
|
|
Version int
|
|
}
|
|
|
|
type HistoryEntry struct {
|
|
Key string
|
|
Scope string
|
|
Value string
|
|
Version int
|
|
}
|
|
|
|
// Store ist die Schreib-/Verwaltungsseite. Set schreibt IMMER sowohl den
|
|
// aktuellen Stand (config_values) als auch einen Historieneintrag
|
|
// (config_value_history) in derselben Transaktion — eine Aenderung ohne
|
|
// Versionshistorie ist strukturell ausgeschlossen (Akzeptanzkriterium 2).
|
|
type Store struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewStore(pool *pgxpool.Pool) *Store {
|
|
return &Store{pool: pool}
|
|
}
|
|
|
|
// Set schreibt einen neuen Wert fuer (key, scope) und erhoeht die Version um 1
|
|
// (Version 1 bei erstmaligem Setzen).
|
|
func (s *Store) Set(ctx context.Context, key, scope, value string) (Value, error) {
|
|
if scope == "" {
|
|
return Value{}, errors.New("cfgservice: scope darf nicht leer sein")
|
|
}
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return Value{}, fmt.Errorf("transaktion starten: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
var currentVersion int
|
|
err = tx.QueryRow(ctx, `SELECT version FROM config_values WHERE key = $1 AND scope = $2`, key, scope).Scan(¤tVersion)
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return Value{}, fmt.Errorf("aktuelle version lesen: %w", err)
|
|
}
|
|
newVersion := currentVersion + 1
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO config_values (key, scope, value, version, updated_at)
|
|
VALUES ($1, $2, $3, $4, now())
|
|
ON CONFLICT (key, scope) DO UPDATE SET value = $3, version = $4, updated_at = now()
|
|
`, key, scope, value, newVersion); err != nil {
|
|
return Value{}, fmt.Errorf("wert speichern: %w", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO config_value_history (key, scope, value, version, changed_at)
|
|
VALUES ($1, $2, $3, $4, now())
|
|
`, key, scope, value, newVersion); err != nil {
|
|
return Value{}, fmt.Errorf("historie schreiben: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return Value{}, fmt.Errorf("transaktion committen: %w", err)
|
|
}
|
|
|
|
return Value{Key: key, Scope: scope, Value: value, Version: newVersion}, nil
|
|
}
|
|
|
|
// Get liefert den Wert fuer GENAU EINEN Scope (kein Vorrang-Fallback) — die
|
|
// Vorrangregel (Tenant vor Global) lebt bewusst in Service.Resolve, damit
|
|
// Store rein CRUD bleibt.
|
|
func (s *Store) Get(ctx context.Context, key, scope string) (Value, error) {
|
|
var v Value
|
|
v.Key, v.Scope = key, scope
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT value, version FROM config_values WHERE key = $1 AND scope = $2
|
|
`, key, scope).Scan(&v.Value, &v.Version)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Value{}, ErrNotFound
|
|
}
|
|
return Value{}, fmt.Errorf("wert lesen: %w", err)
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// History liefert die vollstaendige Versionshistorie eines (key, scope) in
|
|
// aufsteigender Reihenfolge (Akzeptanzkriterium 2 / Pruefung 3).
|
|
func (s *Store) History(ctx context.Context, key, scope string) ([]HistoryEntry, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT key, scope, value, version FROM config_value_history
|
|
WHERE key = $1 AND scope = $2 ORDER BY version
|
|
`, key, scope)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("historie abfragen: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []HistoryEntry
|
|
for rows.Next() {
|
|
var h HistoryEntry
|
|
if err := rows.Scan(&h.Key, &h.Scope, &h.Value, &h.Version); err != nil {
|
|
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
|
|
}
|
|
out = append(out, h)
|
|
}
|
|
return out, rows.Err()
|
|
}
|