munin/LASTTEST.md
2026-10-19 10:13:56 +02:00

246 lines
11 KiB
Markdown

# Lasttest
Wie viel hält Munin aus, wenn viele Agenten gleichzeitig lesen und schreiben,
in einem Projekt, in mehreren Projekten eines Workspace oder über mehrere
Workspaces verteilt? Der Lasttest in `last_test.go` beantwortet das mit echten
Aufrufen über HTTP und MCP, also über denselben Weg wie im Betrieb:
Key-Prüfung, Protokoll, Datenbank und Suchindex.
---
## Ausführen
Der Test läuft **nicht** bei `go test ./...` mit. Er braucht Zeit und lastet
den Rechner voll aus. Das Build-Tag `last` schaltet ihn erst ein, wenn man
ausdrücklich danach fragt:
```sh
go test -tags last -run TestLast -v .
```
Mit eigenen Werten:
```sh
go test -tags last -run TestLast -v . -last.agenten=128 -last.dauer=30s
```
Mit dem Race-Detektor, der Datenwettläufe zwischen Goroutinen meldet. Er macht
alles etwa zehnmal langsamer, deshalb mit kleineren Werten:
```sh
go test -race -tags last -run TestLast . -last.dauer=3s -last.agenten=16 -last.eintraege=100
```
Der Test legt alles in einem temporären Verzeichnis an und räumt danach auf.
Echte Daten in `data/` fasst er nicht an.
### Schalter
| Schalter | Vorgabe | Bedeutung |
|---|---|---|
| `-last.agenten` | 32 | Gleichzeitige Agenten je Aufstellung. Jeder hat einen eigenen Key und eine eigene MCP-Sitzung. |
| `-last.dauer` | 10s | Laufzeit je Aufstellung. Es gibt drei Aufstellungen, die Gesamtdauer ist also etwa das Dreifache. |
| `-last.projekte` | 4 | Zahl der Projekte bzw. Workspaces in den Aufstellungen mit mehreren |
| `-last.eintraege` | 500 | Einträge, die jedes Projekt vorab bekommt, damit die Suche einen realistischen Index hat |
| `-last.slugs` | 20 | Bezeichner, auf die die Agenten je Projekt schreiben. Weniger heißt mehr Gedränge auf denselben Einträgen. |
| `-last.schreibanteil` | 30 | Anteil von `remember` in Prozent. Der Rest teilt sich 2 : 1 auf `recall` und `get` auf. |
---
## Was der Test macht
### Drei Aufstellungen
Alle drei laufen mit derselben Zahl Agenten, damit die Zahlen vergleichbar
sind:
| Aufstellung | Was sie zeigt |
|---|---|
| **ein Projekt** | Alle Agenten im selben Projekt. Der Grundwert. |
| **ein Workspace, N Projekte** | Die Projekte liegen in einer Datei und teilen sich die **eine Schreibverbindung** (siehe `openPool` in `internal/store/store.go`). Bremsen sich die Projekte gegenseitig? |
| **N Workspaces, je 1 Projekt** | Jeder Workspace hat seine eigene Datei. Hier kann echt parallel geschrieben werden. |
### Was ein Agent tut
Jeder Agent verbindet sich mit seinem Key über `/mcp` und ruft bis zum Ende der
Laufzeit in einer Schleife zufällig auf:
- `remember` auf einen der umkämpften Bezeichner (`gemeinsam-00` …). Jeder
Schreibvorgang enthält ein **eindeutiges Wort**, an dem sich später erkennen
lässt, welche Fassung wo gelandet ist.
- `recall` mit einem von acht Fachbegriffen, die in den vorab angelegten
Einträgen vorkommen
- `get` auf einen der vorab angelegten Einträge
Das Schreibbudget (Ausbaustufe 3.8) ist im Test abgeschaltet. Es soll eine
Schleife stoppen und würde sonst die Messung abwürgen.
### Was am Ende geprüft wird
Der Test schlägt fehl, wenn eines davon nicht stimmt:
1. **Kein Aufruf ist fehlgeschlagen.**
2. **Kein Schreibvorgang ging verloren:** Jedes erfolgreiche `remember` erzeugt
eine Fassung. Die Summe der Fassungen aller umkämpften Einträge muss genau
der Zahl der erfolgreichen Aufrufe entsprechen.
3. **Der Suchindex kennt den letzten Stand:** Das eindeutige Wort der letzten
gespeicherten Fassung muss den Eintrag über die Suche finden.
### Die Ausgabe lesen
Für jede Aufstellung erscheint eine Tabelle:
```
Werkzeug Aufrufe je s p50 p95 p99 max Fehler
verbinden 128 0 18.03ms 33.46ms 35.04ms 35.54ms 0
recall 21496 2150 4.45ms 8.02ms 23.05ms 40.33ms 0
get 11381 1138 1.04ms 2.4ms 3.18ms 7.33ms 0
remember 14269 1427 67.84ms 190.04ms 269.16ms 439.31ms 0
```
- **je s:** erfolgreiche Aufrufe pro Sekunde
- **p50:** Die Hälfte der Aufrufe war schneller, also die typische Dauer.
- **p95 / p99:** Die langsamsten 5 % bzw. 1 % der Aufrufe dauerten mindestens
so lange. Das sind die Werte, die ein Nutzer als „hängt manchmal“ erlebt.
- **max:** der langsamste Aufruf überhaupt
- **verbinden:** der MCP-Verbindungsaufbau, einmal je Agent
Darunter steht `Bestand geprüft: … Fassungen, Suchindex aktuell`.
---
## Ergebnis vom 2026-10-06
**Testrechner:** Mac mit 10 Kernen. Clients und Server liefen auf derselben
Maschine, jeweils 500 Einträge vorab, 30 % Schreiben, 20 umkämpfte Bezeichner
je Projekt, 4 Projekte bzw. Workspaces.
### 128 Agenten, 10 Sekunden
| Aufstellung | `remember` je s | `remember` p50 / p95 / p99 | `recall` p50 / p99 | `get` p50 / p99 | Fehler |
|---|---|---|---|---|---|
| ein Projekt | 1.427 | 68 / 190 / 269 ms | 4,5 / 23 ms | 1,0 / 3,2 ms | 0 |
| ein Workspace, 4 Projekte | 1.355 | 73 / 201 / 286 ms | 4,5 / 24 ms | 1,0 / 3,4 ms | 0 |
| 4 Workspaces, je 1 Projekt | 1.328 | 60 / 151 / 205 ms | 15 / 36 ms | 3,8 / 12 ms | 0 |
### 32 Agenten, 5 Sekunden
| Aufstellung | `remember` je s | `remember` p50 / p99 | `recall` p50 / p99 | Fehler |
|---|---|---|---|---|
| ein Projekt | 1.374 | 13 / 42 ms | 4,7 / 11 ms | 0 |
| ein Workspace, 4 Projekte | 1.387 | 13 / 46 ms | 4,4 / 10 ms | 0 |
| 4 Workspaces, je 1 Projekt | 1.141 | 8 / 18 ms | 10 / 24 ms | 0 |
In allen Läufen ging kein Schreibvorgang verloren, und der Suchindex war am
Ende aktuell. Auch der Lauf mit Race-Detektor war ohne Befund.
### Was die Zahlen sagen
- **Last ist bei echter Nutzung kein Thema.** Der Server schafft rund 1.300
Schreibvorgänge pro *Sekunde*. Das Schreibbudget erlaubt 1.000 pro *Stunde*
und Projekt, und echte Agenten liegen bei ein paar Dutzend pro Stunde.
- **Projekte im selben Workspace verhalten sich wie ein Projekt.** Sie teilen
sich die Schreibverbindung, Schreibvorgänge stellen sich also an. Mit mehr
Agenten wachsen deshalb die Wartezeiten beim Schreiben (p50 von 13 ms bei 32
auf 68 ms bei 128 Agenten), aber nicht die Fehler.
- **Mehrere Workspaces schreiben etwas schneller, suchen aber langsamer.**
Getrennte Dateien heben die Warteschlange beim Schreiben auf. Dafür arbeiten
dann mehr Schreiber gleichzeitig und konkurrieren mit der Suche um dieselben
Kerne. Auf einem Rechner ist die CPU die eigentliche Grenze, nicht SQLite.
- **Der volle Weg kostet etwa das Sechsfache** gegenüber dem reinen Schreiben in
die Datenbank (8.656/s, siehe `AUSBAUSTUFEN.md` 1.2). Dazu kommen HTTP, MCP,
Key-Prüfung, Protokolleintrag und Suchindex.
### Grenzen der Messung
- **Clients und Server teilen sich den Rechner.** Die Agenten verbrauchen selbst
CPU. Ein Server allein schafft mehr, die Zahlen sind eher eine Untergrenze.
- **Kaltstart nicht gemessen.** Der Suchindex jedes Projekts ist nach dem
Anlegen der Einträge schon aufgebaut. Der erste Zugriff auf ein großes
Projekt nach einem Neustart (etwa 170 ms je 10.000 Einträge, siehe
`AUSBAUSTUFEN.md` 1.1) ist nicht enthalten. Während dieses Aufbaus wartet nur
dieses eine Projekt. Bis 2026-10-06 warteten auch alle anderen Projekte
desselben Workspace, das ist behoben (siehe unten).
- **Nur MCP.** Die Weboberfläche ist nicht dabei.
- **Kein Rückkanal:** Der zusätzliche SSE-Kanal, den manche MCP-Clients offen
halten, ist abgeschaltet. Er kostet je Agent eine offene Verbindung, aber
kaum Rechenzeit.
- **Gleichmäßige Dauerlast.** Echte Agenten arbeiten in Schüben mit Pausen
dazwischen. Der Test ist also deutlich härter als die Wirklichkeit.
---
## Dabei gefunden
**Suchindex hielt eine ältere Fassung (behoben).** Speichern und Index
aktualisieren waren zwei getrennte Schritte, und der Index übernahm den Stand,
den der Aufrufer mitgab. Überholten sich zwei Schreiber auf demselben Eintrag
zwischen diesen Schritten, stand Fassung 3 in der Datenbank, aber Fassung 2 im
Index. `recall` fand den Eintrag dann bis zum Neustart über veraltete Wörter.
Genauso konnte ein gerade gelöschter Eintrag durch eine verspätete
Aktualisierung wieder in der Suche auftauchen.
Unter Last ließ sich das nicht zuverlässig auslösen, das Zeitfenster liegt bei
Mikrosekunden. Mit gezielt vertauschter Reihenfolge war es sofort
reproduzierbar.
**Behebung:** `Registry.Refresh` in `internal/search/registry.go` liest nach
jedem Schreiben den aktuellen Stand aus der Datenbank nach. Der letzte Aufruf
liest damit immer den neuesten Stand, egal in welcher Reihenfolge die Aufrufe
ankommen. Abgesichert durch `internal/search/registry_test.go` und
`internal/mcpserver/concurrency_test.go`.
**Aufbau des Suchindex blockierte den ganzen Workspace (behoben).** Die
Registry hatte nur eine Sperre für alle Projekte eines Workspace und hielt sie
auch, während der Index eines Projekts aus der Datenbank aufgebaut wurde. Nach
einem Neustart musste so jedes Projekt warten, bis das größte fertig
eingelesen war. Seit 2026-10-06 hat jedes Projekt seine eigene Sperre.
Abgesichert durch `TestSlowBuildDoesNotBlockOtherProjects` und
`TestChangeDuringBuildIsNotLost`: Ein Schreibvorgang
während des Aufbaus wartet, bis der Aufbau fertig ist, und trägt seinen Stand
danach nach.
**Stolperstein beim Messen:** Mit den Vorgaben von Go hält ein HTTP-Client nur
zwei Verbindungen je Ziel offen. Bei 128 Agenten öffnete der Test dadurch
laufend neue Verbindungen, bis dem Mac die lokalen Ports ausgingen
(`connect: can't assign requested address`). Das ist ein Fehler der Messung,
nicht des Servers, denn echte Agenten sitzen auf verschiedenen Rechnern. Der
Test nutzt deshalb einen gemeinsamen Transport mit einer offenen Verbindung je
Agent. Taucht die Meldung wieder auf, liegt die Ursache dort.
---
## Wann erneut messen
- Nach Änderungen am Schreibweg: `PutEntry`, Protokoll, Suchindex,
Verbindungspools in `internal/store/store.go`
- Vor einem Umzug auf andere Hardware oder in einen Container mit begrenzter
CPU. Dann am besten Server und Test auf getrennten Rechnern, oder den Test
mit weniger Agenten.
- Wenn `AUSBAUSTUFEN.md` 4.9 (mehrere Keys in einem MCP-Block) gebaut wird:
Ein `recall` über mehrere Projekte belastet die Suche anders als heute.
Neue Ergebnisse bitte mit Datum unter **Ergebnis** ergänzen, die alten stehen
lassen. So bleibt sichtbar, wie sich Änderungen ausgewirkt haben.
---
## Nachmessung vom 2026-10-14: Suchprotokoll und Wortvarianten
32 Agenten, 10 Sekunden, ein Projekt. Seit diesem Stand schreibt jeder `recall`
eine Zeile ins Suchprotokoll, und die Suche prüft Wortvarianten.
| Stand | `remember` je s | `recall` p50 / p99 |
|---|---|---|
| 2026-10-06 (5 s) | 1.374 | 4,7 / 11 ms |
| Wortvarianten, ohne Suchprotokoll | 1.278 | 5,5 / 12 ms |
| Wortvarianten und Suchprotokoll | 1.074 | 8,5 / 30 ms |
Fast der ganze Unterschied kommt vom Suchprotokoll: Jede Suche ist jetzt auch
ein Schreibvorgang und stellt sich an derselben Schreibverbindung an wie
`remember`. Für echte Nutzung bedeutungslos — dort sind es Dutzende Aufrufe
pro Stunde, nicht tausend pro Sekunde.
**Falls es je stört:** die Zeilen in einem Puffer sammeln und alle paar
hundert Millisekunden gebündelt in einer Transaktion schreiben. Dann fällt das
Protokoll aus dem Weg der Anfrage.