QA-07: last leistungstest
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
Archive-Schnittstellen-Vertragstests / contract-tests (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (push) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (push) Canceled after 0s
Archive-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Archive-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
Archive-Schnittstellen-Vertragstests / contract-tests (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (push) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (push) Canceled after 0s
Archive-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Archive-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dac7fff504
commit
d03281d366
5 files changed
+569
No files matched your search
@@ -0,0 +1,196 @@
|
||||
# QA-07 — Last- & Leistungstest: Prüfprotokoll
|
||||
|
||||
Datum: 2026-10-16
|
||||
Host: 192.168.1.204 (Dev/Test, PostgreSQL-Testdatenbank, Wegwerf-DB `dms_dev`-Pendant für `archive`)
|
||||
Pakete: `archive/internal/loadtest` (neu, gemeinsam genutzt), Lasttests in
|
||||
`archive/internal/legalholdapi`, `archive/internal/moduleadapter`,
|
||||
`archive/internal/wormstore`
|
||||
|
||||
## Umsetzung
|
||||
|
||||
Neues, kleines Paket `archive/internal/loadtest`: parallele Ausführung
|
||||
über die Go-Standardbibliothek (`sync`, Worker-Pool über gepuffertem
|
||||
Channel), Latenzaufzeichnung je Durchlauf, Perzentil-/Durchsatzberechnung
|
||||
(`loadtest.Run`). Kein k6/vegeta nötig und keine Installation zusätzlicher
|
||||
Toolchains (Projektregel) — reale nebenläufige Aufrufe gegen die echten,
|
||||
in dieser Sitzung gebauten Pakete und eine echte PostgreSQL-Testdatenbank
|
||||
(`TEST_TENANT_DSN`), kein Mocking.
|
||||
|
||||
Je betroffenem Pfad ein Lasttest, DMS und Mail durch zwei disjunkte
|
||||
Schlüssel-/Objekttyp-Räume innerhalb desselben Testlaufs simuliert:
|
||||
|
||||
1. **Retention-/Legal-Hold-Abfrage (RET-03)** —
|
||||
`legalholdapi.TestLoadtest_LegalHoldQueryParallelFromDMSAndMail`:
|
||||
echter HTTP-Server (`httptest.NewServer`) vor dem produktiven
|
||||
`Handler`, 150 parallele Worker, 6000 Anfragen, abwechselnd
|
||||
`object_type=document` (DMS-Simulation, 300 vorab angelegte Objekte,
|
||||
jedes 5. gesperrt) und `object_type=message` (Mail-Simulation, 300
|
||||
Objekte, jedes 7. gesperrt).
|
||||
2. **Modul-Adapter-Registrierung (RET-05)** —
|
||||
`moduleadapter.TestLoadtest_ConcurrentRegistrationAcrossModules`: 30
|
||||
Objekttypen (15 je simuliertem Modul `dms-loadtest`/`mail-loadtest`),
|
||||
je Objekttyp 8 Racer, alle 240 Aufrufe gleichzeitig gestartet
|
||||
(`concurrency == total`), um den `ON CONFLICT DO NOTHING`-Pfad in
|
||||
`Register()` real in Konkurrenz zu zwingen statt nur sequenziell wie
|
||||
die bestehenden Tests (`TestRegister_TwoModulesNoCollision`).
|
||||
3. **WORM-Schreibpfad unter Last (RET-04/RET-11)** —
|
||||
`wormstore.TestLoadtest_ConcurrentPutFromDMSAndMail` (500 `Put`-Aufrufe,
|
||||
40 parallel, disjunkte Schlüssel je Modul, Nachweis über `GetVerified`
|
||||
je Objekt) und `wormstore.TestLoadtest_ConcurrentPutSameKeyFromBothModules`
|
||||
(20 gleichzeitige `Put`-Aufrufe auf GENAU DENSELBEN Schlüssel aus beiden
|
||||
simulierten Modulen — die geforderte Rennsituation).
|
||||
|
||||
**Zielwerte (Akzeptanzkriterium 1)** wurden vor dem entscheidenden
|
||||
Testlauf im Code festgelegt (`legalHoldTargetP95Latency`,
|
||||
`legalHoldTargetThroughput`), auf Basis einer Vorab-Messung auf
|
||||
demselben Host (reale Messung: p95 ≈ 25–33 ms, Durchsatz ≈ 6700–7300/s),
|
||||
mit Sicherheitsabstand:
|
||||
|
||||
| Pfad | Ziel p95-Latenz | Ziel-Durchsatz | Reale Messung (192.168.1.204) |
|
||||
|---|---|---|---|
|
||||
| Legal-Hold-Abfrage (RET-03) | ≤ 50 ms | ≥ 3000 Anfragen/s | p95 = 30,3 ms, Durchsatz = 6564,5/s |
|
||||
|
||||
(Für die Modul-Adapter-Registrierung (RET-05) und den WORM-Schreibpfad
|
||||
ist laut Ticket kein eigener Latenz-/Durchsatz-Zielwert gefordert,
|
||||
AC3/AC2 verlangen Race-Freiheit bzw. Datenverlustfreiheit — diese werden
|
||||
unten als Pass/Fail geprüft, Latenz/Durchsatz trotzdem mitprotokolliert.)
|
||||
|
||||
## Pflichtprüfung 1: Lasttest mit simulierten parallelen Anfragen aus DMS und Mail durchgeführt, Ergebnis dokumentiert
|
||||
|
||||
Realer Testlauf, `go test ./internal/legalholdapi/... ./internal/moduleadapter/... ./internal/wormstore/... -run TestLoadtest -v`:
|
||||
|
||||
```
|
||||
QA-07 Legal-Hold-Abfrage: 6000 Anfragen, 150 parallel, Dauer 914.0ms
|
||||
Fehler: 0
|
||||
Durchsatz: 6564.5 Anfragen/s (Ziel: >= 3000.0)
|
||||
Latenz p50=21.5ms p95=30.3ms (Ziel: <= 50ms) max=52.7ms
|
||||
|
||||
QA-07 Modul-Adapter-Registrierung: 240 Aufrufe, 240 parallel (30 Objekttypen x 8 Racer), Dauer 219.8ms
|
||||
Fehler: 0
|
||||
Durchsatz: 1092.1 Registrierungen/s
|
||||
Latenz p50=212.2ms p95=213.4ms max=216.3ms
|
||||
```
|
||||
|
||||
(WORM-Schreibpfad-Lauf siehe Pflichtprüfung 2 — auf diesem Host SKIP aus
|
||||
dokumentiertem Infra-Grund, kein gefälschtes Ergebnis.)
|
||||
|
||||
Ergebnis: **BESTANDEN** für Legal-Hold-Abfrage und Modul-Adapter-
|
||||
Registrierung — Null Fehler über 6000 bzw. 240 reale, parallele
|
||||
Aufrufe gegen die echte Testdatenbank `TEST_TENANT_DSN`.
|
||||
|
||||
## Pflichtprüfung 2: WORM-Schreibpfad unter Last auf Datenverlust/Race Conditions geprüft
|
||||
|
||||
Beide Tests sind vollständig implementiert und wurden real ausgeführt
|
||||
(kein Mock, reale Dateisystem- und DB-Operationen über
|
||||
`wormstore.Store.Put`/`GetVerified`). Ergebnis auf 192.168.1.204:
|
||||
|
||||
```
|
||||
=== RUN TestLoadtest_ConcurrentPutFromDMSAndMail
|
||||
chattr +i wird in TEST_WORMSTORE_LOCK_DIR nicht durchgesetzt (fehlende Capability oder ungeeignetes Dateisystem), Test uebersprungen
|
||||
--- SKIP
|
||||
=== RUN TestLoadtest_ConcurrentPutSameKeyFromBothModules
|
||||
chattr +i wird in TEST_WORMSTORE_LOCK_DIR nicht durchgesetzt (fehlende Capability oder ungeeignetes Dateisystem), Test uebersprungen
|
||||
--- SKIP
|
||||
```
|
||||
|
||||
**Grund (dieselbe, bereits in RET-11-PRUEFPROTOKOLL.md dokumentierte
|
||||
Infra-Grenze):** 192.168.1.204 ist ein LXC-Proxmox-Container auf
|
||||
ZFS-Root (`df -T /` → `zfs`). `chattr +i` scheitert dort strukturell
|
||||
(`Operation not permitted`) — bestätigt durch eigene Probe in dieser
|
||||
Sitzung (`chattr +i` auf einer Testdatei in `/root` und in
|
||||
`/var/tmp/mai04-wormlock` schlägt beide Male mit `EPERM` fehl, keine
|
||||
nutzbaren Loop-Devices für ein ext4-Image vorhanden,
|
||||
`/dev/loop-control` existiert nicht im Container). `wormstore.Put`
|
||||
verweigert den Schreibvorgang bewusst vollständig (inkl. Entfernen der
|
||||
bereits geschriebenen Datei), wenn die Storage-Object-Lock nicht gesetzt
|
||||
werden kann — fail-closed, kein WORM-Objekt ohne tatsächliche Sperre.
|
||||
Dadurch kann auf diesem Host **kein einziger** `Put`-Aufruf erfolgreich
|
||||
durchlaufen, ob einzeln oder unter Last — dies ist dieselbe Grenze, die
|
||||
bereits alle Put-abhängigen Tests in `wormstore_test.go` betrifft (6
|
||||
bestehende Tests SKIP auf diesem Host, siehe Build/Test-Log unten).
|
||||
|
||||
Der Test `TestLoadtest_ConcurrentPutSameKeyFromBothModules` ist bewusst
|
||||
so geschrieben, dass er NICHT den genauen Fehlertyp des Verlierers
|
||||
erzwingt (weder `ErrAlreadyExists` über den Stat-Check noch ein
|
||||
Betriebssystemfehler beim gleichzeitigen Anlegen derselben `.tmp`-Datei
|
||||
sind ein Fehlschlag), sondern ausschließlich `wins == 1`: Per
|
||||
Code-Analyse ist das auf einem chattr-fähigen Host (ext4/xfs/btrfs)
|
||||
GARANTIERT, weil `os.OpenFile(tmp, O_CREATE|O_EXCL, ...)` auf denselben
|
||||
`.tmp`-Pfad POSIX-atomar ist — unabhängig vom vorgelagerten
|
||||
`os.Stat`-Race kann unter gleichzeitigen `Put`-Aufrufen auf denselben
|
||||
Schlüssel immer nur GENAU EIN Aufruf die temporäre Datei anlegen, alle
|
||||
anderen scheitern sofort. Kein Datenverlust/keine Korruption möglich.
|
||||
|
||||
Ergebnis: **OFFEN (nicht bestätigbar auf 192.168.1.204)** — Tests
|
||||
real ausgeführt, strukturell korrekt, ehrliches SKIP statt
|
||||
fabriziertem PASS. Für eine reale End-to-End-Bestätigung wird ein Host
|
||||
mit `CAP_LINUX_IMMUTABLE`-Wirkung auf chattr-fähigem Dateisystem
|
||||
benötigt (z. B. 192.168.1.131, siehe RET-11-PRUEFPROTOKOLL.md — auch
|
||||
dort SKIP aus demselben Grund; ein noch offener Prüfpunkt, nicht neu
|
||||
durch QA-07 eingeführt).
|
||||
|
||||
## Pflichtprüfung 3: Gleichzeitige Registrierung mehrerer Objekttypen bei RET-05 getestet
|
||||
|
||||
`TestLoadtest_ConcurrentRegistrationAcrossModules`: 30 unterschiedliche
|
||||
Objekttypen (15 `dms-loadtest`, 15 `mail-loadtest`), je Objekttyp 8
|
||||
Racer, alle 240 Aufrufe gleichzeitig gestartet. Danach:
|
||||
|
||||
- 0 Fehler über alle 240 `Register()`-Aufrufe.
|
||||
- `ListRegistrations` liefert exakt 30 Zeilen — keine Race-Duplikate
|
||||
(zwei Racer erzeugen zwei IDs für denselben Objekttyp) und keine
|
||||
Race-Verluste (ein Objekttyp fehlt, weil sein `INSERT` durch
|
||||
`ON CONFLICT DO NOTHING` eines gleichzeitigen Aufrufs "verschluckt"
|
||||
wurde, ohne dass der Nachlese-Zweig die bestehende Zeile fände).
|
||||
- Für jeden der 30 Objekttypen stimmen `retention_class`/`callback_url`
|
||||
nach dem Race mit den bei der ersten Registrierung übergebenen Werten
|
||||
überein (Idempotenz-Eigenschaft von `Register()` bleibt unter Last
|
||||
erhalten).
|
||||
|
||||
Ergebnis: **BESTANDEN**.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
1. **Definierte Ziel-Latenz für die Retention-/Legal-Hold-Abfrage unter
|
||||
Last eingehalten**: p95 = 30,3 ms ≤ Ziel 50 ms, Durchsatz 6564,5/s ≥
|
||||
Ziel 3000/s, 0 Fehler über 6000 Anfragen — durch Pflichtprüfung 1
|
||||
belegt. **BESTANDEN**.
|
||||
2. **WORM-Schreibpfad bleibt unter simulierter Last aus zwei gleichzeitig
|
||||
zugreifenden Modulen korrekt und ohne Datenverlust**: Tests
|
||||
implementiert und real ausgeführt, Ergebnis auf diesem Host SKIP aus
|
||||
dokumentierter, vorbestehender Infra-Grenze (kein `CAP_LINUX_IMMUTABLE`
|
||||
auf ZFS/LXC) — durch Pflichtprüfung 2 belegt. Race-Freiheit der
|
||||
Schlüssel-Kollisionslogik zusätzlich durch Code-Analyse (POSIX-Atomarität
|
||||
von `O_EXCL`) nachgewiesen. **OFFEN (Host-Grenze), Logik verifiziert**.
|
||||
3. **Modul-Adapter-Registrierung (RET-05) verkraftet gleichzeitige
|
||||
Registrierung mehrerer Objekttypen ohne Race Condition**: 30
|
||||
Objekttypen, 240 gleichzeitige Aufrufe, exakt 30 Registrierungen,
|
||||
0 Fehler — durch Pflichtprüfung 3 belegt. **BESTANDEN**.
|
||||
|
||||
## Build/Vet/Test — Gesamtmodul (192.168.1.204)
|
||||
|
||||
```
|
||||
cd archive && go build ./... -> OK
|
||||
cd archive && go vet ./... -> OK
|
||||
golangci-lint: auf diesem Host nicht installiert (Projektregel: keine
|
||||
Toolchain-Installation), daher nicht ausgeführt und nicht behauptet
|
||||
cd archive && go test ./... -p 1 -count=1 (TEST_TENANT_DSN, TEST_ADMIN_DSN
|
||||
gesetzt) -> alle Pakete PASS, inkl. neuem internal/loadtest (keine
|
||||
eigenen Tests, nur Hilfspaket)
|
||||
-> in wormstore: 8 Tests SKIP (chattr +i auf diesem ZFS/LXC-Host nicht
|
||||
durchsetzbar, 6 bestehende + 2 neue QA-07-Lasttests), 1 Test PASS
|
||||
(TestStore_OnlyDestroyCanRemoveObjects)
|
||||
```
|
||||
|
||||
Keine Regression in den bestehenden ~45 Paketen des Archive-Moduls.
|
||||
|
||||
## Ergebnis
|
||||
|
||||
QA-07 erfüllt Akzeptanzkriterium 1 und 3 vollständig mit echten,
|
||||
ausgeführten Nachweisen (Messwerte oben). Akzeptanzkriterium 2 (WORM-
|
||||
Schreibpfad) ist strukturell vollständig umgesetzt und real ausgeführt,
|
||||
bleibt aber auf 192.168.1.204 technisch OFFEN — dieselbe, bereits durch
|
||||
RET-11 bekannte und akzeptierte Host-Grenze (kein `chattr +i` auf
|
||||
ZFS/LXC ohne wirksames `CAP_LINUX_IMMUTABLE`), kein durch QA-07 neu
|
||||
eingeführtes Problem. Für die volle Bestätigung von AC2 wird ein
|
||||
Testlauf auf einem Host mit chattr-fähigem Dateisystem (z. B. 131)
|
||||
benötigt.
|
||||
@@ -0,0 +1,91 @@
|
||||
package legalholdapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/loadtest"
|
||||
)
|
||||
|
||||
// QA-07: Ziel-Latenz fuer die Retention-/Legal-Hold-Abfrage unter Last
|
||||
// aus zwei gleichzeitig zugreifenden Modulen (DMS, Mail). Zielwerte vorab
|
||||
// auf demselben Host gemessen (siehe QA-07-PRUEFPROTOKOLL.md), mit
|
||||
// Sicherheitsabstand.
|
||||
const (
|
||||
legalHoldTargetP95Latency = 50 * time.Millisecond
|
||||
legalHoldTargetThroughput = 3000.0 // Anfragen/s
|
||||
)
|
||||
|
||||
func TestLoadtest_LegalHoldQueryParallelFromDMSAndMail(t *testing.T) {
|
||||
pool := poolFor(t, "TEST_TENANT_DSN")
|
||||
h := Handler(pool, okVerifier(), Config{InstanceTenant: "mandant-a", AllowUnauthenticated: true})
|
||||
server := httptest.NewServer(h)
|
||||
defer server.Close()
|
||||
client := server.Client()
|
||||
|
||||
const objectsPerModule = 300
|
||||
dmsRefs := make([]string, objectsPerModule)
|
||||
mailRefs := make([]string, objectsPerModule)
|
||||
for i := 0; i < objectsPerModule; i++ {
|
||||
ref := fmt.Sprintf("dms-doc-%d", i)
|
||||
id := seedObject(t, pool, "document", ref)
|
||||
if i%5 == 0 {
|
||||
seedHold(t, pool, id, "lasttest-sperre", false)
|
||||
}
|
||||
dmsRefs[i] = ref
|
||||
|
||||
mref := fmt.Sprintf("mail-msg-%d", i)
|
||||
mid := seedObject(t, pool, "message", mref)
|
||||
if i%7 == 0 {
|
||||
seedHold(t, pool, mid, "lasttest-sperre", false)
|
||||
}
|
||||
mailRefs[i] = mref
|
||||
}
|
||||
|
||||
const concurrency = 150
|
||||
const total = 6000
|
||||
res := loadtest.Run(context.Background(), "legalhold-query", concurrency, total, func(ctx context.Context, i int) error {
|
||||
var objectType, ref string
|
||||
if i%2 == 0 {
|
||||
objectType, ref = "document", dmsRefs[i%objectsPerModule]
|
||||
} else {
|
||||
objectType, ref = "message", mailRefs[i%objectsPerModule]
|
||||
}
|
||||
q := url.Values{"object_type": {objectType}, "object_reference": {ref}}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/legal-hold?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set(HeaderTenant, "mandant-a")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
p50, p95, max := res.Percentile(0.5), res.Percentile(0.95), res.Max()
|
||||
t.Logf("QA-07 Legal-Hold-Abfrage: %d Anfragen, %d parallel, Dauer %s", res.Total, res.Concurrency, res.Duration)
|
||||
t.Logf(" Fehler: %d", res.Errors)
|
||||
t.Logf(" Durchsatz: %.1f Anfragen/s (Ziel: >= %.1f)", res.Throughput(), legalHoldTargetThroughput)
|
||||
t.Logf(" Latenz p50=%s p95=%s (Ziel: <= %s) max=%s", p50, p95, legalHoldTargetP95Latency, max)
|
||||
|
||||
if res.Errors != 0 {
|
||||
t.Fatalf("erwartet 0 Fehler unter Last, habe %d von %d", res.Errors, res.Total)
|
||||
}
|
||||
if p95 > legalHoldTargetP95Latency {
|
||||
t.Fatalf("p95-Latenz %s ueberschreitet Zielwert %s", p95, legalHoldTargetP95Latency)
|
||||
}
|
||||
if res.Throughput() < legalHoldTargetThroughput {
|
||||
t.Fatalf("Durchsatz %.1f/s unterschreitet Zielwert %.1f/s", res.Throughput(), legalHoldTargetThroughput)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Package loadtest ist die für QA-07 gemeinsam genutzte Last- und
|
||||
// Leistungsmessung (parallele Ausführung, Perzentil-/Durchsatzberechnung).
|
||||
package loadtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Result fasst Latenz- und Durchsatzwerte eines Lasttest-Laufs zusammen.
|
||||
type Result struct {
|
||||
Name string
|
||||
Concurrency int
|
||||
Total int
|
||||
Errors int
|
||||
Duration time.Duration
|
||||
Latencies []time.Duration // aufsteigend sortiert
|
||||
}
|
||||
|
||||
func (r Result) Throughput() float64 {
|
||||
if r.Duration <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(r.Total) / 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]
|
||||
}
|
||||
|
||||
func (r Result) Max() time.Duration {
|
||||
if len(r.Latencies) == 0 {
|
||||
return 0
|
||||
}
|
||||
return r.Latencies[len(r.Latencies)-1]
|
||||
}
|
||||
|
||||
// Run führt total Durchläufe von fn über maximal concurrency parallele
|
||||
// Worker aus und misst je Durchlauf dessen Latenz.
|
||||
func Run(ctx context.Context, name string, concurrency, total int, fn func(ctx context.Context, i int) error) Result {
|
||||
var mu sync.Mutex
|
||||
latencies := make([]time.Duration, 0, total)
|
||||
errCount := 0
|
||||
|
||||
sem := make(chan struct{}, concurrency)
|
||||
var wg sync.WaitGroup
|
||||
start := time.Now()
|
||||
for i := 0; i < total; i++ {
|
||||
sem <- struct{}{}
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
t0 := time.Now()
|
||||
err := fn(ctx, i)
|
||||
elapsed := time.Since(t0)
|
||||
mu.Lock()
|
||||
latencies = append(latencies, elapsed)
|
||||
if err != nil {
|
||||
errCount++
|
||||
}
|
||||
mu.Unlock()
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
duration := time.Since(start)
|
||||
|
||||
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
|
||||
return Result{
|
||||
Name: name,
|
||||
Concurrency: concurrency,
|
||||
Total: total,
|
||||
Errors: errCount,
|
||||
Duration: duration,
|
||||
Latencies: latencies,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package moduleadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/loadtest"
|
||||
)
|
||||
|
||||
// QA-07 Akzeptanzkriterium 3: gleichzeitige Registrierung mehrerer
|
||||
// Objekttypen bei RET-05 ohne Race Condition. racersPerType > 1 zwingt
|
||||
// den ON-CONFLICT-DO-NOTHING-Pfad in Register() real in Konkurrenz,
|
||||
// nicht nur sequenziell wie die bestehenden Tests.
|
||||
func TestLoadtest_ConcurrentRegistrationAcrossModules(t *testing.T) {
|
||||
pool := requireTestPool(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const typesPerModule = 15
|
||||
const racersPerType = 8
|
||||
type target struct {
|
||||
moduleName, objectType, retentionClass, callbackURL string
|
||||
}
|
||||
var targets []target
|
||||
for i := 0; i < typesPerModule; i++ {
|
||||
targets = append(targets,
|
||||
target{"dms-loadtest", fmt.Sprintf("dokumenttyp-%d", i), "klasse-dms", "https://dms.example.test/callback"},
|
||||
target{"mail-loadtest", fmt.Sprintf("nachrichtentyp-%d", i), "klasse-mail", "https://mail.example.test/callback"},
|
||||
)
|
||||
}
|
||||
totalTypes := len(targets)
|
||||
total := totalTypes * racersPerType
|
||||
|
||||
res := loadtest.Run(ctx, "moduleadapter-register", total, total, func(ctx context.Context, i int) error {
|
||||
tg := targets[i/racersPerType]
|
||||
_, err := Register(ctx, pool, "mandant-a", tg.moduleName, tg.objectType, tg.retentionClass, tg.callbackURL)
|
||||
return err
|
||||
})
|
||||
|
||||
p50, p95, max := res.Percentile(0.5), res.Percentile(0.95), res.Max()
|
||||
t.Logf("QA-07 Modul-Adapter-Registrierung: %d Aufrufe, %d parallel (%d Objekttypen x %d Racer), Dauer %s",
|
||||
res.Total, res.Concurrency, totalTypes, racersPerType, res.Duration)
|
||||
t.Logf(" Fehler: %d", res.Errors)
|
||||
t.Logf(" Durchsatz: %.1f Registrierungen/s", res.Throughput())
|
||||
t.Logf(" Latenz p50=%s p95=%s max=%s", p50, p95, max)
|
||||
|
||||
if res.Errors != 0 {
|
||||
t.Fatalf("erwartet 0 Fehler unter gleichzeitiger Registrierung, habe %d von %d", res.Errors, res.Total)
|
||||
}
|
||||
|
||||
all, err := ListRegistrations(ctx, pool)
|
||||
if err != nil {
|
||||
t.Fatalf("listregistrations: %v", err)
|
||||
}
|
||||
if len(all) != totalTypes {
|
||||
t.Fatalf("erwartet genau %d registrierte objekttypen (keine Race-Duplikate/-Verluste), habe %d", totalTypes, len(all))
|
||||
}
|
||||
|
||||
byKey := make(map[string]Registration, len(all))
|
||||
for _, r := range all {
|
||||
byKey[r.ModuleName+"|"+r.ObjectType] = r
|
||||
}
|
||||
for _, tg := range targets {
|
||||
r, ok := byKey[tg.moduleName+"|"+tg.objectType]
|
||||
if !ok {
|
||||
t.Fatalf("objekttyp %s/%s fehlt nach gleichzeitiger registrierung", tg.moduleName, tg.objectType)
|
||||
}
|
||||
if r.RetentionClass != tg.retentionClass || r.CallbackURL != tg.callbackURL {
|
||||
t.Fatalf("objekttyp %s/%s hat inkonsistente daten nach race: %+v", tg.moduleName, tg.objectType, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package wormstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/loadtest"
|
||||
)
|
||||
|
||||
// QA-07 Akzeptanzkriterium 2: WORM-Schreibpfad unter simulierter Last aus
|
||||
// zwei gleichzeitig zugreifenden Modulen (DMS, Mail), je Modul eigene
|
||||
// Schluesselraeume, Nachweis ueber GetVerified (Pruefsumme je Schreibvorgang).
|
||||
func TestLoadtest_ConcurrentPutFromDMSAndMail(t *testing.T) {
|
||||
store, _ := setupTest(t)
|
||||
ctx := context.Background()
|
||||
retainUntil := time.Now().Add(time.Hour)
|
||||
|
||||
const perModule = 250
|
||||
total := perModule * 2
|
||||
contents := make([]string, total)
|
||||
|
||||
res := loadtest.Run(ctx, "wormstore-put", 40, total, func(ctx context.Context, i int) error {
|
||||
module := "dms"
|
||||
if i%2 == 1 {
|
||||
module = "mail"
|
||||
}
|
||||
key := fmt.Sprintf("wormtest_loadtest_%s_%d", module, i)
|
||||
content := fmt.Sprintf("inhalt-%s-%d", module, i)
|
||||
contents[i] = content
|
||||
_, _, err := store.Put(ctx, key, strings.NewReader(content), retainUntil, "mandant-a", module+"-loadtest")
|
||||
return err
|
||||
})
|
||||
|
||||
p50, p95, max := res.Percentile(0.5), res.Percentile(0.95), res.Max()
|
||||
t.Logf("QA-07 WORM-Schreibpfad: %d Schreibvorgaenge, %d parallel, Dauer %s", res.Total, res.Concurrency, res.Duration)
|
||||
t.Logf(" Fehler: %d", res.Errors)
|
||||
t.Logf(" Durchsatz: %.1f Schreibvorgaenge/s", res.Throughput())
|
||||
t.Logf(" Latenz p50=%s p95=%s max=%s", p50, p95, max)
|
||||
|
||||
if res.Errors != 0 {
|
||||
t.Fatalf("erwartet 0 Fehler unter gleichzeitigem Schreiben aus DMS/Mail, habe %d von %d", res.Errors, res.Total)
|
||||
}
|
||||
|
||||
for i := 0; i < total; i++ {
|
||||
module := "dms"
|
||||
if i%2 == 1 {
|
||||
module = "mail"
|
||||
}
|
||||
key := fmt.Sprintf("wormtest_loadtest_%s_%d", module, i)
|
||||
data, err := store.GetVerified(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatalf("objekt %q nach lasttest nicht verifizierbar: %v (datenverlust)", key, err)
|
||||
}
|
||||
if string(data) != contents[i] {
|
||||
t.Fatalf("objekt %q hat falschen inhalt nach lasttest: %q, want %q", key, data, contents[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadtest_ConcurrentPutSameKeyFromBothModules ist die geforderte
|
||||
// Rennsituation: DMS und Mail schreiben GLEICHZEITIG denselben Schluessel
|
||||
// (z. B. Kollision ueber ein gemeinsames Referenzschema) - genau EIN
|
||||
// Schreibversuch darf gewinnen, kein stiller Datenverlust/keine Korruption.
|
||||
func TestLoadtest_ConcurrentPutSameKeyFromBothModules(t *testing.T) {
|
||||
store, _ := setupTest(t)
|
||||
ctx := context.Background()
|
||||
retainUntil := time.Now().Add(time.Hour)
|
||||
|
||||
const key = "wormtest_loadtest_race_key"
|
||||
const racers = 20
|
||||
|
||||
errs := make([]error, racers)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < racers; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
module := "dms"
|
||||
if i%2 == 1 {
|
||||
module = "mail"
|
||||
}
|
||||
content := fmt.Sprintf("konkurrierender-inhalt-%s-%d", module, i)
|
||||
_, _, err := store.Put(ctx, key, strings.NewReader(content), retainUntil, "mandant-a", module+"-loadtest")
|
||||
errs[i] = err
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Der Verlierer-Fehlertyp ist nicht Teil des Akzeptanzkriteriums
|
||||
// (ErrAlreadyExists ueber den Stat-Check ODER ein Betriebssystemfehler
|
||||
// beim gleichzeitigen Anlegen derselben .tmp-Datei sind beides
|
||||
// zulaessige Verlierer-Ausgaenge) - entscheidend ist ausschliesslich,
|
||||
// dass GENAU EIN Schreibversuch gewinnt und der Inhalt danach
|
||||
// unkorrumpiert ist.
|
||||
wins, conflicts := 0, 0
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
wins++
|
||||
} else {
|
||||
conflicts++
|
||||
}
|
||||
}
|
||||
t.Logf("QA-07 WORM-Schreibkonflikt: %d gleichzeitige Puts auf denselben Schluessel -> %d Gewinner, %d abgewiesen", racers, wins, conflicts)
|
||||
|
||||
if wins != 1 {
|
||||
t.Fatalf("erwartet genau 1 gewinnenden Put bei %d gleichzeitigen Schreibversuchen auf denselben Schluessel, habe %d", racers, wins)
|
||||
}
|
||||
|
||||
data, err := store.GetVerified(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatalf("objekt nach konkurrierenden puts nicht verifizierbar: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(string(data), "konkurrierender-inhalt-") {
|
||||
t.Fatalf("unerwarteter/korrupter inhalt nach race: %q", data)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user