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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Sonnet 5 committed 2026-10-16 12:15:14 +00:00
1 parent dac7fff504
commit d03281d366
5 files changed
+569

No files matched your search

+196
View File
@@ -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)
}
}
+90
View File
@@ -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)
}
}
}
+120
View File
@@ -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)
}
}