feat(mail): QA-07 Last- & Leistungstest für IMAP/POP3/SMTP

Neues Paket mail/internal/loadtest: parallele Sessions ausführen,
Latenz-Perzentile (p50/p95/max) und Durchsatz messen, Ressourcen-
Stichprobe (Heap über runtime.MemStats, kumulierte CPU-Zeit über
/proc/self/stat). k6/vegeta sind auf diesem Rechner nicht installierbar
(Projektregel) — echter Ersatz ohne externe Abhängigkeit: reale
nebenläufige TCP-Sessions über die Standardbibliothek gegen die echten,
in dieser Sitzung gebauten Protokollserver, kein Mock.

Je ein TestLoadtest_<Protokoll>ParallelSessionsMeetTargets in imap/,
pop3/, smtp/: 200 parallele Worker, 2000 vollständige realistische
Sessions je Protokoll (POP3 USER/PASS/STAT/RETR/QUIT, IMAP
LOGIN/SELECT/FETCH/LOGOUT, SMTP EHLO/MAIL/RCPT/DATA/QUIT). Zielwerte
für p95-Latenz und Mindestdurchsatz vor dem entscheidenden Testlauf im
Code festgelegt, auf Basis einer separaten Vorab-Messung mit
Sicherheitsabstand.

Reale Messwerte auf 192.168.1.131: POP3 6652 Sessions/s (p95 42,9ms),
IMAP 5354 Sessions/s (p95 54,9ms), SMTP 6328 Sessions/s (p95 44,0ms) —
alle Zielwerte deutlich unterboten/überboten, 0 Fehler über 6000
Sessions insgesamt, Heap-Wachstum je Protokoll im niedrigen
einstelligen MiB-Bereich (kein Ressourcenleck).

go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~30 Pakete)
regressionsfrei getestet.
This commit is contained in:
sysops
2026-09-01 10:29:05 +02:00
parent 12c9037121
commit 060b73566b
5 changed files with 652 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
// Package loadtest implementiert die für QA-07 gemeinsam genutzte
// Last- und Leistungsmessung: parallele Sessions ausführen, Latenzen
// aufzeichnen, Durchsatz und Ressourcenverbrauch bestimmen. k6/vegeta
// sind auf diesem Rechner nicht installierbar (Projektregel: keine
// zusätzlichen Toolchains/Dienste installieren) — echter Ersatz ohne
// externe Abhängigkeit: reale nebenläufige TCP-Sessions über die
// Go-Standardbibliothek, gegen die echten, in dieser Sitzung gebauten
// Protokollserver.
package loadtest
import (
"context"
"os"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Result fasst Latenz- und Durchsatzwerte eines Lasttest-Laufs
// zusammen (Akzeptanzkriterium 2).
type Result struct {
Protocol string
Concurrency int
Sessions int
Errors int
Duration time.Duration
Latencies []time.Duration // aufsteigend sortiert
}
// Throughput liefert Sessions pro Sekunde über die Gesamtlaufzeit.
func (r Result) Throughput() float64 {
if r.Duration <= 0 {
return 0
}
return float64(r.Sessions) / r.Duration.Seconds()
}
// Percentile liefert die Latenz am p-ten Perzentil (0.01.0).
func (r Result) Percentile(p float64) time.Duration {
if len(r.Latencies) == 0 {
return 0
}
idx := int(p * float64(len(r.Latencies)-1))
if idx < 0 {
idx = 0
}
if idx >= len(r.Latencies) {
idx = len(r.Latencies) - 1
}
return r.Latencies[idx]
}
// Max liefert die höchste gemessene Latenz.
func (r Result) Max() time.Duration {
if len(r.Latencies) == 0 {
return 0
}
return r.Latencies[len(r.Latencies)-1]
}
// Run führt totalSessions Sessions über maximal concurrency parallele
// Worker aus (Akzeptanzkriterium 1: realistische Anzahl paralleler
// Sessions) und misst je Session die Latenz von sessionFunc.
func Run(ctx context.Context, protocol string, concurrency, totalSessions int, sessionFunc func(ctx context.Context) error) Result {
var mu sync.Mutex
latencies := make([]time.Duration, 0, totalSessions)
errCount := 0
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < totalSessions; i++ {
sem <- struct{}{}
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-sem }()
t0 := time.Now()
err := sessionFunc(ctx)
elapsed := time.Since(t0)
mu.Lock()
latencies = append(latencies, elapsed)
if err != nil {
errCount++
}
mu.Unlock()
}()
}
wg.Wait()
duration := time.Since(start)
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
return Result{
Protocol: protocol,
Concurrency: concurrency,
Sessions: totalSessions,
Errors: errCount,
Duration: duration,
Latencies: latencies,
}
}
// ResourceSample ist eine Momentaufnahme des Ressourcenverbrauchs
// DIESES Prozesses (Akzeptanzkriterium/Pflichtprüfung 3): Heap über die
// Go-Runtime, kumulierte CPU-Zeit über /proc/self/stat (Linux, kein
// externes Werkzeug nötig).
type ResourceSample struct {
HeapAllocBytes uint64
CPUTimeSeconds float64
}
func SampleResources() ResourceSample {
var m runtime.MemStats
runtime.ReadMemStats(&m)
return ResourceSample{HeapAllocBytes: m.HeapAlloc, CPUTimeSeconds: readSelfCPUTimeSeconds()}
}
// readSelfCPUTimeSeconds liefert die kumulierte User+System-CPU-Zeit
// dieses Prozesses in Sekunden. Liefert 0, wenn /proc/self/stat nicht
// lesbar ist (z. B. auf Nicht-Linux-Systemen) — Aufrufer behandeln das
// als "keine CPU-Messung verfügbar", kein harter Fehler.
func readSelfCPUTimeSeconds() float64 {
data, err := os.ReadFile("/proc/self/stat")
if err != nil {
return 0
}
fields := strings.Fields(string(data))
if len(fields) < 15 {
return 0
}
utime, err1 := strconv.ParseFloat(fields[13], 64)
stime, err2 := strconv.ParseFloat(fields[14], 64)
if err1 != nil || err2 != nil {
return 0
}
const clockTicksPerSecond = 100 // Linux-Standard-USER_HZ
return (utime + stime) / clockTicksPerSecond
}