QA-08: last-leistungstest (jwt-verifikation, connection-pooling, rate-limiting unter mehrmodul-last)
This commit is contained in:
@@ -0,0 +1,257 @@
|
|||||||
|
// Package loadtest implementiert Core QA-08: Last- und Leistungstests fuer
|
||||||
|
// die drei Mechanismen, die unter realistischer Mehrmodul-Last (sechs
|
||||||
|
// gleichzeitig zugreifende Fachmodule: DMS/Mail/Archive/Workflow/AI/Connect)
|
||||||
|
// am ehesten unter Druck geraten — JWT-Verifikationspfad (API-05),
|
||||||
|
// Tenant-Connection-Pooling (TEN-06) und Rate-Limiting (API-03). Jeder Test
|
||||||
|
// dokumentiert einen Zielwert UND das tatsaechlich gemessene Ergebnis
|
||||||
|
// (Ticket-Vorgabe: "Pruefen heisst messen. Behauptungen ohne Messprotokoll
|
||||||
|
// gelten nicht als erledigt.").
|
||||||
|
package loadtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/moduletrust"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/ratelimit"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
const simulatedModuleCount = 6 // DMS, Mail, Archive, Workflow, AI, Connect
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 1: definierte Ziel-Latenz fuer
|
||||||
|
// JWT-Verifikation unter Last aus sechs gleichzeitig zugreifenden Modulen.
|
||||||
|
//
|
||||||
|
// Zielwert: p95 < 10ms je Verify()-Aufruf. Begruendung des Zielwerts:
|
||||||
|
// Verify() ist eine rein lokale Operation gegen einen bereits im Speicher
|
||||||
|
// zwischengespeicherten Schluesselsatz (StaleCache, siehe API-05) — es
|
||||||
|
// findet kein Netzwerk-Roundtrip zu Core statt, daher ist ein niedriger
|
||||||
|
// Millisekunden-Zielwert realistisch, nicht willkuerlich hoch angesetzt.
|
||||||
|
func TestLoad_JWTVerificationLatencyUnderSixModuleLoad(t *testing.T) {
|
||||||
|
km, err := moduletrust.NewKeyManager()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("keymanager: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := km.Rotate(); err != nil {
|
||||||
|
t.Fatalf("rotate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
verifier := moduletrust.NewVerifier(time.Minute, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
||||||
|
return km.PublicKeySet(), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
const requestsPerModule = 200
|
||||||
|
totalRequests := simulatedModuleCount * requestsPerModule
|
||||||
|
|
||||||
|
tokens := make([]string, totalRequests)
|
||||||
|
for i := 0; i < totalRequests; i++ {
|
||||||
|
tok, err := km.Issue("user-1", fmt.Sprintf("tenant-%d", i%50), 5*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
tokens[i] = tok
|
||||||
|
}
|
||||||
|
|
||||||
|
latencies := make([]time.Duration, totalRequests)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for module := 0; module < simulatedModuleCount; module++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(moduleIdx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < requestsPerModule; i++ {
|
||||||
|
idx := moduleIdx*requestsPerModule + i
|
||||||
|
start := time.Now()
|
||||||
|
if _, err := verifier.Verify(context.Background(), tokens[idx]); err != nil {
|
||||||
|
t.Errorf("verify: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
latencies[idx] = time.Since(start)
|
||||||
|
}
|
||||||
|
}(module)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
|
||||||
|
p95 := latencies[int(float64(len(latencies))*0.95)]
|
||||||
|
max := latencies[len(latencies)-1]
|
||||||
|
|
||||||
|
t.Logf("JWT-Verifikation unter Last: %d module x %d anfragen = %d gesamt. p95=%s, max=%s, ziel=p95<10ms",
|
||||||
|
simulatedModuleCount, requestsPerModule, totalRequests, p95, max)
|
||||||
|
|
||||||
|
if p95 >= 10*time.Millisecond {
|
||||||
|
t.Fatalf("p95-latenz = %s, ziel war unter 10ms", p95)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 2: Connection-Pooling bleibt unter der
|
||||||
|
// Postgres-Verbindungsobergrenze bei simulierter Vielzahl an Mandanten.
|
||||||
|
//
|
||||||
|
// Zielwert: bei 200 simulierten Mandanten, auf die sechs Module gleichzeitig
|
||||||
|
// zugreifen, bleiben NIE mehr als maxOpen=20 Pools gleichzeitig offen (LRU-
|
||||||
|
// Verdraengung greift) — weit unter einer typischen Postgres
|
||||||
|
// max_connections-Grenze (Default 100).
|
||||||
|
func TestLoad_ConnectionPoolingStaysUnderLimitWithManySimulatedTenants(t *testing.T) {
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
|
||||||
|
db_name TEXT NOT NULL UNIQUE, db_dsn TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := tenant.NewRegistry(pool)
|
||||||
|
const simulatedTenants = 200
|
||||||
|
const maxOpen = 20
|
||||||
|
|
||||||
|
prefix := fmt.Sprintf("qa08_%d", time.Now().UnixNano())
|
||||||
|
slugs := make([]string, simulatedTenants)
|
||||||
|
for i := 0; i < simulatedTenants; i++ {
|
||||||
|
slug := fmt.Sprintf("%s_%d", prefix, i)
|
||||||
|
slugs[i] = slug
|
||||||
|
// DSN muss nur SYNTAKTISCH gueltig sein — pgxpool.New verbindet
|
||||||
|
// erst lazy bei tatsaechlicher Nutzung, fuer diesen Lasttest zaehlt
|
||||||
|
// ausschliesslich die Zahl offener *Pool-Objekte*, nicht ob die
|
||||||
|
// referenzierte Datenbank real existiert.
|
||||||
|
dsn := fmt.Sprintf("postgresql://nexarch_test:unused@localhost:5432/%s?sslmode=disable", slug)
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ($1, $1, $1, $2)
|
||||||
|
`, slug, dsn); err != nil {
|
||||||
|
t.Fatalf("tenant %s anlegen: %v", slug, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(context.Background(), `DELETE FROM tenants WHERE slug LIKE $1`, prefix+"%")
|
||||||
|
})
|
||||||
|
|
||||||
|
router := tenant.NewRouter(registry, maxOpen)
|
||||||
|
defer router.Close()
|
||||||
|
|
||||||
|
var maxObservedOpen int64
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for module := 0; module < simulatedModuleCount; module++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(moduleIdx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < simulatedTenants; i++ {
|
||||||
|
slug := slugs[(i+moduleIdx*37)%simulatedTenants] // module-uebergreifend gemischter zugriff
|
||||||
|
if _, err := router.Resolve(context.Background(), slug); err != nil {
|
||||||
|
t.Errorf("resolve %s: %v", slug, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if current := int64(router.OpenCount()); current > atomic.LoadInt64(&maxObservedOpen) {
|
||||||
|
atomic.StoreInt64(&maxObservedOpen, current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(module)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
t.Logf("connection-pooling unter last: %d simulierte mandanten, %d module, max. gleichzeitig beobachtete offene pools = %d (ziel: <= %d)",
|
||||||
|
simulatedTenants, simulatedModuleCount, maxObservedOpen, maxOpen)
|
||||||
|
|
||||||
|
if maxObservedOpen > int64(maxOpen) {
|
||||||
|
t.Fatalf("max. beobachtete offene pools = %d, ziel war <= %d", maxObservedOpen, maxOpen)
|
||||||
|
}
|
||||||
|
if router.OpenCount() > maxOpen {
|
||||||
|
t.Fatalf("finale offene pools = %d, ziel war <= %d", router.OpenCount(), maxOpen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 3: Rate-Limiting funktioniert korrekt
|
||||||
|
// unter Mehrinstanz-Last (mehrere "Core-Instanzen" mit geteiltem Postgres-
|
||||||
|
// Zustand) aus sechs gleichzeitig zugreifenden Modulen, nicht nur im
|
||||||
|
// Einzelinstanz-Test (der bereits in API-03 abgedeckt ist).
|
||||||
|
//
|
||||||
|
// Zielwert: bei konfiguriertem Limit=500 und insgesamt 1200 Anfragen ueber
|
||||||
|
// DREI simulierte Core-Instanzen (separate Pools/Store-Objekte) hinweg
|
||||||
|
// werden EXAKT 500 Anfragen erlaubt — kein Overcounting durch fehlende
|
||||||
|
// Koordination zwischen den Instanzen, kein Undercounting durch verlorene
|
||||||
|
// Updates.
|
||||||
|
func TestLoad_RateLimitingCorrectAcrossMultipleInstancesUnderSixModuleLoad(t *testing.T) {
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const instanceCount = 3
|
||||||
|
pools := make([]*pgxpool.Pool, instanceCount)
|
||||||
|
stores := make([]*ratelimit.Store, instanceCount)
|
||||||
|
for i := 0; i < instanceCount; i++ {
|
||||||
|
p, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool %d: %v", i, err)
|
||||||
|
}
|
||||||
|
defer p.Close()
|
||||||
|
if i == 0 {
|
||||||
|
if _, err := p.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limit_configs (
|
||||||
|
key TEXT PRIMARY KEY, limit_value INT NOT NULL, window_seconds INT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limit_counters (
|
||||||
|
key TEXT NOT NULL, window_start TIMESTAMPTZ NOT NULL, count INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (key, window_start)
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pools[i] = p
|
||||||
|
stores[i] = ratelimit.NewStore(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := fmt.Sprintf("qa08-key-%d", time.Now().UnixNano())
|
||||||
|
const limit = 500
|
||||||
|
if err := stores[0].SetLimit(ctx, key, limit, time.Minute); err != nil {
|
||||||
|
t.Fatalf("setlimit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRequests = 1200 // simulierte last aus sechs modulen ueber drei instanzen, deutlich ueber dem limit von 500
|
||||||
|
var allowedCount int64
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < totalRequests; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
instance := stores[i%instanceCount] // request "kommt" reihum von einer der drei core-instanzen
|
||||||
|
go func(s *ratelimit.Store) {
|
||||||
|
defer wg.Done()
|
||||||
|
res, err := s.Allow(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("allow: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.Allowed {
|
||||||
|
atomic.AddInt64(&allowedCount, 1)
|
||||||
|
}
|
||||||
|
}(instance)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
t.Logf("rate-limiting unter mehrinstanz-last: %d anfragen ueber %d instanzen, limit=%d, tatsaechlich erlaubt=%d",
|
||||||
|
totalRequests, instanceCount, limit, allowedCount)
|
||||||
|
|
||||||
|
if allowedCount != limit {
|
||||||
|
t.Fatalf("erlaubte anfragen ueber alle instanzen = %d, ziel war exakt %d (geteilter, korrekter zustand)", allowedCount, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user