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.
142 lines
3.9 KiB
Go
142 lines
3.9 KiB
Go
// 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.0–1.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
|
||
}
|