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
+123
View File
@@ -0,0 +1,123 @@
package imap
import (
"bufio"
"context"
"fmt"
"net"
"strings"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/loadtest"
)
// imapLoadtestConcurrency/imapLoadtestSessions — siehe pop3-Pendant für
// die Begründung der Größenordnung (Akzeptanzkriterium 1, QA-07).
const (
imapLoadtestConcurrency = 200
imapLoadtestSessions = 2000
)
// Zielwerte (Akzeptanzkriterium 3, QA-07) — festgelegt VOR dem
// entscheidenden Testlauf, siehe Prüfprotokoll QA-07 für die
// Vorab-Messwerte auf 192.168.1.131.
const (
imapTargetP95Latency = 100 * time.Millisecond
imapTargetThroughputMin = 800.0 // Sessions/Sekunde
)
// TestLoadtest_IMAPParallelSessionsMeetTargets ist die geforderte
// Pflichtprüfung 1+2 (QA-07).
func TestLoadtest_IMAPParallelSessionsMeetTargets(t *testing.T) {
if testing.Short() {
t.Skip("lasttest übersprungen (-short)")
}
addr, stop := startTestServer(t)
defer stop()
resourcesBefore := loadtest.SampleResources()
result := loadtest.Run(context.Background(), "imap", imapLoadtestConcurrency, imapLoadtestSessions, func(ctx context.Context) error {
return runIMAPLoadSession(addr)
})
resourcesAfter := loadtest.SampleResources()
p50 := result.Percentile(0.50)
p95 := result.Percentile(0.95)
maxLatency := result.Max()
throughput := result.Throughput()
heapDeltaMB := float64(resourcesAfter.HeapAllocBytes-resourcesBefore.HeapAllocBytes) / (1024 * 1024)
cpuUsed := resourcesAfter.CPUTimeSeconds - resourcesBefore.CPUTimeSeconds
fmt.Printf("QA-07 IMAP-Lasttest: %d Sessions, %d parallel, Dauer %v\n", result.Sessions, result.Concurrency, result.Duration)
fmt.Printf(" Fehler: %d\n", result.Errors)
fmt.Printf(" Durchsatz: %.1f Sessions/s (Ziel: >= %.1f)\n", throughput, imapTargetThroughputMin)
fmt.Printf(" Latenz p50=%v p95=%v (Ziel: <= %v) max=%v\n", p50, p95, imapTargetP95Latency, maxLatency)
fmt.Printf(" Ressourcen: Heap-Delta=%.1f MiB, CPU-Zeit=%.2fs\n", heapDeltaMB, cpuUsed)
if result.Errors != 0 {
t.Fatalf("QA-07 IMAP: %d von %d sessions fehlgeschlagen", result.Errors, result.Sessions)
}
if p95 > imapTargetP95Latency {
t.Fatalf("QA-07 IMAP: p95-latenz %v überschreitet zielwert %v", p95, imapTargetP95Latency)
}
if throughput < imapTargetThroughputMin {
t.Fatalf("QA-07 IMAP: durchsatz %.1f sessions/s unter zielwert %.1f", throughput, imapTargetThroughputMin)
}
if heapDeltaMB > 100 {
t.Fatalf("QA-07 IMAP: heap-wachstum %.1f MiB deutet auf ressourcenleck hin", heapDeltaMB)
}
}
// runIMAPLoadSession führt eine vollständige, realistische IMAP-Session
// über eine ROHE TCP-Verbindung aus (keine testing.T-Hilfsfunktionen,
// die bei Fehlern t.Fatalf/goexit auslösen — in einer nebenläufigen
// Lasttest-Worker-Goroutine unzulässig und würde den Fehler stillschweigend
// verschlucken statt ihn zu zählen).
func runIMAPLoadSession(addr string) error {
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
reader := bufio.NewReader(conn)
if _, err := reader.ReadString('\n'); err != nil { // Begrüßung
return err
}
if _, err := sendTaggedRaw(conn, reader, "A1", "LOGIN alice geheim123"); err != nil {
return fmt.Errorf("LOGIN: %w", err)
}
if _, err := sendTaggedRaw(conn, reader, "A2", "SELECT INBOX"); err != nil {
return fmt.Errorf("SELECT: %w", err)
}
if _, err := sendTaggedRaw(conn, reader, "A3", "FETCH 1 (FLAGS)"); err != nil {
return fmt.Errorf("FETCH: %w", err)
}
_, err = sendTaggedRaw(conn, reader, "A4", "LOGOUT")
return err
}
// sendTaggedRaw sendet ein getaggtes Kommando und liest bis zur
// getaggten Abschlusszeile; liefert einen Fehler, wenn diese kein "OK"
// enthält.
func sendTaggedRaw(conn net.Conn, reader *bufio.Reader, tag, command string) (lines []string, err error) {
if _, err := conn.Write([]byte(tag + " " + command + "\r\n")); err != nil {
return nil, err
}
for {
line, err := reader.ReadString('\n')
if err != nil {
return nil, err
}
line = strings.TrimRight(line, "\r\n")
lines = append(lines, line)
if strings.HasPrefix(line, tag+" ") {
if !strings.Contains(line, "OK") {
return lines, fmt.Errorf("unerwartete abschlussantwort: %q", line)
}
return lines, nil
}
}
}