munin/last_test.go

425 lines
14 KiB
Go

//go:build last
// Lasttest: viele Agenten greifen gleichzeitig über echtes HTTP und MCP zu.
//
// Er läuft NICHT bei jedem "go test ./...", weil er Zeit braucht und die
// Maschine auslastet. Das Build-Tag oben schaltet ihn erst ein, wenn man
// ausdrücklich danach fragt:
//
// go test -tags last -run TestLast -v .
//
// Mit eigenen Werten (alle Schalter siehe die flag.-Zeilen unten):
//
// go test -tags last -run TestLast -v . -last.agenten=64 -last.dauer=30s
//
// Drei Aufstellungen mit derselben Zahl Agenten, damit die Zahlen vergleichbar
// sind:
//
// - ein Projekt: alle Agenten im selben Projekt
// - ein Workspace, mehrere Projekte: teilen sich eine Datei und damit die
// eine Schreibverbindung (siehe openPool in internal/store/store.go)
// - mehrere Workspaces: je eine eigene Datei, schreiben echt parallel
//
// Am Ende prüft jede Aufstellung, ob nichts verloren ging und ob der
// Suchindex zum Stand der Datenbank passt.
package main
import (
"context"
"flag"
"fmt"
"io"
"log"
"log/slog"
"math/rand/v2"
"net/http"
"net/http/httptest"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"munin/internal/auth"
"munin/internal/search"
"munin/internal/store"
"munin/internal/workspace"
)
var (
lastAgenten = flag.Int("last.agenten", 32, "Zahl der gleichzeitigen Agenten je Aufstellung")
lastDauer = flag.Duration("last.dauer", 10*time.Second, "Laufzeit je Aufstellung")
lastProjekte = flag.Int("last.projekte", 4, "Projekte bzw. Workspaces in den Aufstellungen mit mehreren")
lastEintraege = flag.Int("last.eintraege", 500, "Einträge, die jedes Projekt vorab bekommt")
lastSlugs = flag.Int("last.slugs", 20, "Bezeichner, auf die die Agenten je Projekt schreiben — klein heißt viel Gedränge auf denselben Einträgen")
lastSchreiben = flag.Int("last.schreibanteil", 30, "Anteil schreibender Aufrufe in Prozent, der Rest ist recall und get")
)
func TestLast(t *testing.T) {
// Jede Anfrage würde sonst eine Logzeile schreiben — bei zehntausenden
// Aufrufen geht die Auswertung darin unter.
log.SetOutput(io.Discard)
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
n := *lastProjekte
aufstellungen := []struct {
name string
workspaces, projekte int // projekte = je Workspace
}{
{"ein Projekt", 1, 1},
{fmt.Sprintf("ein Workspace, %d Projekte", n), 1, n},
{fmt.Sprintf("%d Workspaces, je 1 Projekt", n), n, 1},
}
for _, a := range aufstellungen {
t.Run(a.name, func(t *testing.T) {
lauf(t, a.workspaces, a.projekte)
})
}
}
// ziel ist ein Projekt samt den Keys, mit denen Agenten es ansprechen.
type ziel struct {
ws *workspace.Workspace
projekt store.Project
keys []string
}
// lauf baut eine Aufstellung auf, lässt die Agenten laufen und wertet aus.
func lauf(t *testing.T, anzahlWorkspaces, projekteJeWorkspace int) {
ctx := context.Background()
dir := t.TempDir()
control, err := store.OpenControl(filepath.Join(dir, "control.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { control.Close() })
workspaces := workspace.NewManager(dir)
t.Cleanup(func() { workspaces.Close() })
// Dieselbe Verdrahtung wie im Betrieb. Schreibbudget 0 = unbegrenzt — es
// soll eine Schleife stoppen und würde hier die Messung abwürgen.
handler, err := baueHandler(control, workspaces, nil, config{
sessionSecret: strings.Repeat("x", 32),
adminToken: strings.Repeat("y", 32),
maxAPIBody: defaultMaxAPIBodyBytes,
writeLimitHourly: 0,
})
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
// ── Daten anlegen ────────────────────────────────────────────────────────
agenten := *lastAgenten
anzahlZiele := anzahlWorkspaces * projekteJeWorkspace
var ziele []*ziel
for w := 0; w < anzahlWorkspaces; w++ {
wsEintrag, err := control.CreateWorkspace(ctx, fmt.Sprintf("Workspace %d", w))
if err != nil {
t.Fatal(err)
}
ws, err := workspaces.Get(wsEintrag.ID)
if err != nil {
t.Fatal(err)
}
for p := 0; p < projekteJeWorkspace; p++ {
projekt, err := ws.Store.CreateProject(ctx, wsEintrag.ID, fmt.Sprintf("projekt-%d", p), "Projekt", "")
if err != nil {
t.Fatal(err)
}
z := &ziel{ws: ws, projekt: projekt}
ziele = append(ziele, z)
for i := 0; i < *lastEintraege; i++ {
if _, err := ws.Store.PutEntry(ctx, store.EntryWrite{
ProjectID: projekt.ID, Slug: fmt.Sprintf("vorab-%04d", i), Title: fmt.Sprintf("Vorab %d", i),
Content: fuellText(i), Source: store.SourceHuman,
}); err != nil {
t.Fatal(err)
}
}
}
}
// Jeder Agent bekommt einen eigenen Key, wie ein echter Nutzer.
for a := 0; a < agenten; a++ {
z := ziele[a%anzahlZiele]
klartext, err := auth.GenerateKey()
if err != nil {
t.Fatal(err)
}
hash := auth.HashKey(klartext)
if _, err := z.ws.Store.CreateAPIKey(ctx, z.projekt.ID, fmt.Sprintf("agent-%d", a), hash, string(auth.RoleWrite)); err != nil {
t.Fatal(err)
}
if err := control.IndexKey(ctx, hash, z.ws.ID); err != nil {
t.Fatal(err)
}
z.keys = append(z.keys, klartext)
}
// ── Agenten laufen lassen ────────────────────────────────────────────────
// Ein gemeinsamer Transport, der je Agent eine Verbindung offen hält. Mit
// den Vorgaben von Go (2 offene Verbindungen je Ziel) öffnen die Agenten
// sonst laufend neue, bis dem Testrechner die lokalen Ports ausgehen
// ("can't assign requested address"). Echte Agenten sitzen auf
// verschiedenen Rechnern und kennen das Problem nicht — es wäre also ein
// Fehler der Messung, nicht des Servers.
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
httpTransport.MaxIdleConnsPerHost = agenten
t.Cleanup(httpTransport.CloseIdleConnections)
messung := newMessung()
ende := time.Now().Add(*lastDauer)
var wg sync.WaitGroup
for a := 0; a < agenten; a++ {
z := ziele[a%anzahlZiele]
key := z.keys[a/anzahlZiele]
wg.Add(1)
go func(a int) {
defer wg.Done()
agent(ctx, httpTransport, server.URL, key, a, ende, messung)
}(a)
}
wg.Wait()
// ── Auswerten ────────────────────────────────────────────────────────────
messung.bericht(t, *lastDauer)
if anzahl, erste := messung.fehler(); anzahl > 0 {
t.Errorf("%d fehlgeschlagene Aufrufe, erste: %v", anzahl, erste)
}
pruefeBestand(t, ziele, messung.erfolgreicheSchreibvorgaenge())
}
// agent verhält sich wie ein Agent in einer Schleife: suchen, lesen,
// gelegentlich schreiben — bis die Zeit um ist.
func agent(ctx context.Context, httpTransport http.RoundTripper, url, key string, nr int, ende time.Time, m *messung) {
httpClient := &http.Client{
Transport: &mitKey{key: key, next: httpTransport},
Timeout: 30 * time.Second,
}
transport := &mcp.StreamableClientTransport{
Endpoint: url + "/mcp",
HTTPClient: httpClient,
// Der zusätzliche Rückkanal für Nachrichten vom Server wird hier nicht
// gebraucht und würde nur eine weitere offene Verbindung je Agent kosten.
DisableStandaloneSSE: true,
}
client := mcp.NewClient(&mcp.Implementation{Name: fmt.Sprintf("agent-%d", nr), Version: "1"}, nil)
start := time.Now()
sitzung, err := client.Connect(ctx, transport, nil)
m.erfasse("verbinden", time.Since(start), err)
if err != nil {
return
}
defer sitzung.Close()
zufall := rand.New(rand.NewPCG(uint64(nr), 42))
for i := 0; time.Now().Before(ende); i++ {
var werkzeug string
var args map[string]any
switch w := zufall.IntN(100); {
case w < *lastSchreiben:
werkzeug = "remember"
// Ein eindeutiges Wort je Schreibvorgang — daran erkennt
// pruefeBestand, ob der Suchindex den letzten Stand kennt.
args = map[string]any{
"slug": fmt.Sprintf("gemeinsam-%02d", zufall.IntN(*lastSlugs)),
"title": "Gemeinsamer Eintrag",
"content": "Stand " + eindeutigesWort(nr, i) + "\n\n" + fuellText(i),
}
case w < *lastSchreiben+(100-*lastSchreiben)*2/3:
werkzeug = "recall"
args = map[string]any{"query": suchbegriffe[zufall.IntN(len(suchbegriffe))]}
default:
werkzeug = "get"
args = map[string]any{"slug": fmt.Sprintf("vorab-%04d", zufall.IntN(max(1, *lastEintraege)))}
}
start := time.Now()
erg, err := sitzung.CallTool(ctx, &mcp.CallToolParams{Name: werkzeug, Arguments: args})
if err == nil && erg.IsError {
err = fmt.Errorf("%s: %s", werkzeug, ergebnisText(erg))
}
m.erfasse(werkzeug, time.Since(start), err)
}
}
// pruefeBestand vergleicht am Ende Datenbank und Suchindex.
//
// Zwei Dinge müssen stimmen:
// - Kein Schreibvorgang ging verloren: Jeder erfolgreiche remember-Aufruf
// hat eine Fassung erzeugt. Die Summe der Fassungen aller umkämpften
// Einträge muss also genau die Zahl der erfolgreichen Aufrufe sein.
// - Der Suchindex kennt den letzten Stand: Das eindeutige Wort der letzten
// Fassung muss den Eintrag finden.
func pruefeBestand(t *testing.T, ziele []*ziel, erfolgreich int) {
t.Helper()
ctx := context.Background()
summeFassungen, veraltet := 0, 0
for _, z := range ziele {
idx, err := z.ws.Search.For(ctx, z.projekt.ID)
if err != nil {
t.Fatal(err)
}
for s := 0; s < *lastSlugs; s++ {
slug := fmt.Sprintf("gemeinsam-%02d", s)
e, err := z.ws.Store.EntryBySlug(ctx, z.projekt.ID, slug)
if err != nil {
continue // nie beschrieben
}
summeFassungen += e.Version
wort := strings.Fields(e.Content)[1]
gefunden := slices.ContainsFunc(idx.Search(wort, 5), func(r search.Result) bool { return r.DocID == slug })
if !gefunden {
veraltet++
}
}
}
if summeFassungen != erfolgreich {
t.Errorf("%d erfolgreiche remember-Aufrufe, aber %d Fassungen gespeichert", erfolgreich, summeFassungen)
}
if veraltet > 0 {
t.Errorf("%d Einträge: Der Suchindex kennt die letzte Fassung nicht", veraltet)
}
t.Logf("Bestand geprüft: %d Fassungen, Suchindex %s", summeFassungen,
map[bool]string{true: "aktuell", false: "VERALTET"}[veraltet == 0])
}
// ── Messung ──────────────────────────────────────────────────────────────────
// messung sammelt Dauer und Fehler aller Aufrufe, getrennt nach Werkzeug.
type messung struct {
mu sync.Mutex
dauern map[string][]time.Duration
fehl map[string]int
erste []error
}
func newMessung() *messung {
return &messung{dauern: map[string][]time.Duration{}, fehl: map[string]int{}}
}
func (m *messung) erfasse(werkzeug string, d time.Duration, err error) {
m.mu.Lock()
defer m.mu.Unlock()
if err != nil {
m.fehl[werkzeug]++
if len(m.erste) < 10 {
m.erste = append(m.erste, err)
}
return
}
m.dauern[werkzeug] = append(m.dauern[werkzeug], d)
}
// fehler gibt die Zahl aller fehlgeschlagenen Aufrufe und die ersten davon
// zurück.
func (m *messung) fehler() (anzahl int, erste []error) {
m.mu.Lock()
defer m.mu.Unlock()
for _, n := range m.fehl {
anzahl += n
}
return anzahl, m.erste
}
func (m *messung) erfolgreicheSchreibvorgaenge() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.dauern["remember"])
}
// bericht schreibt eine Tabelle: Aufrufe je Sekunde und wie lange die
// typische (p50), die langsamsten 5 % (p95) und 1 % (p99) Aufrufe dauerten.
func (m *messung) bericht(t *testing.T, dauer time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
var b strings.Builder
fmt.Fprintf(&b, "\n%-10s %8s %8s %9s %9s %9s %9s %7s\n", "Werkzeug", "Aufrufe", "je s", "p50", "p95", "p99", "max", "Fehler")
for _, w := range []string{"verbinden", "recall", "get", "remember"} {
d := m.dauern[w]
slices.Sort(d)
je := float64(len(d)) / dauer.Seconds()
if w == "verbinden" {
je = 0 // einmal je Agent, keine Rate
}
fmt.Fprintf(&b, "%-10s %8d %8.0f %9s %9s %9s %9s %7d\n", w, len(d), je,
perzentil(d, 50), perzentil(d, 95), perzentil(d, 99), perzentil(d, 100), m.fehl[w])
}
t.Log(b.String())
}
func perzentil(sortiert []time.Duration, p int) string {
if len(sortiert) == 0 {
return "-"
}
i := (len(sortiert) - 1) * p / 100
return sortiert[i].Round(10 * time.Microsecond).String()
}
// ── Hilfen ───────────────────────────────────────────────────────────────────
// mitKey hängt den Bearer-Key an jede Anfrage — so wie es ein MCP-Client mit
// "headers" in seiner Konfiguration tut.
type mitKey struct {
key string
next http.RoundTripper
}
func (m *mitKey) RoundTrip(r *http.Request) (*http.Response, error) {
r = r.Clone(r.Context())
r.Header.Set("Authorization", "Bearer "+m.key)
return m.next.RoundTrip(r)
}
func ergebnisText(r *mcp.CallToolResult) string {
var b strings.Builder
for _, c := range r.Content {
if t, ok := c.(*mcp.TextContent); ok {
b.WriteString(t.Text)
}
}
return b.String()
}
// eindeutigesWort bildet aus Agent und Durchlauf ein Wort nur aus Buchstaben,
// damit der Tokenizer es nicht an Ziffern zerlegt.
func eindeutigesWort(agent, durchlauf int) string {
s := []byte(fmt.Sprintf("%04d%07d", agent, durchlauf))
for i := range s {
s[i] = 'a' + (s[i] - '0')
}
return "marke" + string(s)
}
var suchbegriffe = []string{"deployment", "server", "datenbank", "zugang", "backup", "monitoring", "release", "fehler"}
// fuellText erzeugt einen Absatz mit wechselnden Fachbegriffen, damit der
// Suchindex realistisch viele Begriffe und Treffer hat.
func fuellText(i int) string {
var b strings.Builder
for j := 0; j < 40; j++ {
b.WriteString(suchbegriffe[(i+j*3)%len(suchbegriffe)])
b.WriteString(" wird hier beschrieben, mit Details zu Schritt ")
b.WriteString(fmt.Sprint(j))
b.WriteString(". ")
}
return b.String()
}