Files
nexarch/internal/loadtest/loadtest_test.go
T

258 lines
8.9 KiB
Go

// 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)
}
}