munin/internal/mcpserver/gleichzeitig_test.go

159 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package mcpserver
import (
"context"
"fmt"
"sync"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
"munin/internal/auth"
"munin/internal/store"
"munin/internal/web"
"munin/internal/workspace"
)
// verbinde öffnet eine MCP-Sitzung, über die man mehrere Aufrufe schicken kann.
//
// Anders als ruf meldet es Fehler zurück, statt t.Fatalf aufzurufen — t.Fatalf
// darf nur aus der Goroutine des Tests selbst kommen, nicht aus den
// nebenläufigen Agenten unten.
func verbinde(h *Handler, ws *workspace.Workspace, projekt store.Project, name string) (*mcp.ClientSession, func(), error) {
sess := web.Session{
Subject: "key:" + ws.ID + ":" + name,
WorkspaceID: ws.ID,
Key: store.APIKey{ID: name, Name: name, ProjectID: projekt.ID, Role: string(auth.RoleWrite)},
Project: projekt,
Role: auth.RoleWrite,
}
ctx := web.WithSessionForTest(context.Background(), sess)
ctx = web.WithWorkspaceForTest(ctx, ws)
clientTransport, serverTransport := mcp.NewInMemoryTransports()
serverSession, err := h.serverFor(projekt).Connect(ctx, serverTransport, nil)
if err != nil {
return nil, nil, err
}
client := mcp.NewClient(&mcp.Implementation{Name: name, Version: "1"}, nil)
clientSession, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
serverSession.Close()
return nil, nil, err
}
schliessen := func() {
clientSession.Close()
serverSession.Close()
}
return clientSession, schliessen, nil
}
// TestGleichzeitigesSchreibenHaeltSuchindexAktuell prüft, dass der Suchindex
// nach gleichzeitigen Schreibvorgängen auf denselben Eintrag den Stand der
// Datenbank kennt.
//
// Die Gefahr: remember schreibt erst in die Datenbank und aktualisiert danach
// den Index — zwei getrennte Schritte. Überholen sich zwei Agenten zwischen
// diesen Schritten, landet Fassung 3 in der Datenbank, aber Fassung 2 im Index.
// recall fände den Eintrag dann bis zum Neustart über veraltete Wörter.
//
// Jeder Schreibvorgang bekommt deshalb ein eigenes, eindeutiges Wort. Am Ende
// muss das Wort der letzten Fassung in der Datenbank auch im Index zu finden
// sein.
func TestGleichzeitigesSchreibenHaeltSuchindexAktuell(t *testing.T) {
h, ws, projekt := aufbauMitBudget(t, 100000)
ctx := context.Background()
// Den Index vorher anlegen. Ohne ihn wäre Search.Update ein No-op und der
// Test würde nichts prüfen (siehe Registry.Update).
idx, err := ws.Search.For(ctx, projekt.ID)
if err != nil {
t.Fatal(err)
}
const runden, agenten, jeAgent = 20, 8, 10
for runde := 0; runde < runden; runde++ {
slug := fmt.Sprintf("umkaempft-%02d", runde)
var wg sync.WaitGroup
var mu sync.Mutex
var fehler []error
for a := 0; a < agenten; a++ {
wg.Add(1)
go func(a int) {
defer wg.Done()
melde := func(err error) {
mu.Lock()
fehler = append(fehler, err)
mu.Unlock()
}
client, schliessen, err := verbinde(h, ws, projekt, fmt.Sprintf("agent-%d", a))
if err != nil {
melde(err)
return
}
defer schliessen()
for i := 0; i < jeAgent; i++ {
// Nur Buchstaben, damit der Tokenizer das Wort nicht zerlegt.
wort := fmt.Sprintf("marke%sx%s", buchstaben(runde*1000+a*100+i), "ende")
erg, err := client.CallTool(ctx, &mcp.CallToolParams{
Name: "remember",
Arguments: map[string]any{
"slug": slug, "title": "Umkämpft", "content": "Stand " + wort,
},
})
if err != nil {
melde(err)
return
}
if erg.IsError {
melde(fmt.Errorf("remember: %s", text(erg)))
return
}
}
}(a)
}
wg.Wait()
if len(fehler) > 0 {
t.Fatalf("Runde %d: %d Fehler, erster: %v", runde, len(fehler), fehler[0])
}
e, err := ws.Store.EntryBySlug(ctx, projekt.ID, slug)
if err != nil {
t.Fatal(err)
}
if e.Version != agenten*jeAgent {
t.Errorf("Runde %d: Fassung %d, erwartet %d — ein Schreibvorgang ging verloren",
runde, e.Version, agenten*jeAgent)
}
// Das Wort der letzten Fassung steht nach "Stand " im Inhalt.
letztesWort := e.Content[len("Stand "):]
gefunden := false
for _, r := range idx.Search(letztesWort, 5) {
if r.DocID == slug {
gefunden = true
}
}
if !gefunden {
t.Errorf("Runde %d: Datenbank hat Fassung %d mit %q, der Suchindex kennt das Wort nicht — "+
"er hält einen älteren Stand", runde, e.Version, letztesWort)
}
}
}
// buchstaben schreibt eine Zahl mit den Buchstaben a–j statt der Ziffern 0–9.
// So entsteht für jeden Schreibvorgang ein eindeutiges Wort ohne Ziffern.
func buchstaben(n int) string {
s := fmt.Sprintf("%06d", n)
b := []byte(s)
for i := range b {
b[i] = 'a' + (b[i] - '0')
}
return string(b)
}