munin/internal/mcpserver/concurrency_test.go
2026-10-19 10:13:56 +02:00

163 lines
4.8 KiB
Go
Raw Permalink 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"
)
// connect öffnet eine MCP-Sitzung, über die man mehrere Aufrufe schicken kann.
//
// Anders als call 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 connect(h *Handler, ws *workspace.Workspace, project 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: project.ID, Role: string(auth.RoleWrite)},
Project: project,
Role: auth.RoleWrite,
}
ctx := web.WithSessionForTest(context.Background(), sess)
ctx = web.WithWorkspaceForTest(ctx, ws)
clientTransport, serverTransport := mcp.NewInMemoryTransports()
serverSession, err := h.serverFor(project).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
}
closeIt := func() {
clientSession.Close()
serverSession.Close()
}
return clientSession, closeIt, nil
}
// TestConcurrentWritesKeepSearchIndexCurrent 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 TestConcurrentWritesKeepSearchIndexCurrent(t *testing.T) {
h, ws, project := setupWithBudget(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, project.ID)
if err != nil {
t.Fatal(err)
}
const rounds, agents, perAgent = 20, 8, 10
for round := 0; round < rounds; round++ {
slug := fmt.Sprintf("umkaempft-%02d", round)
var wg sync.WaitGroup
var mu sync.Mutex
var errs []error
for a := 0; a < agents; a++ {
wg.Add(1)
go func(a int) {
defer wg.Done()
report := func(err error) {
mu.Lock()
errs = append(errs, err)
mu.Unlock()
}
client, closeIt, err := connect(h, ws, project, fmt.Sprintf("agent-%d", a))
if err != nil {
report(err)
return
}
defer closeIt()
for i := 0; i < perAgent; i++ {
// Nur Buchstaben, damit der Tokenizer das Wort nicht zerlegt.
word := fmt.Sprintf("marke%sx%s", letters(round*1000+a*100+i), "ende")
res, err := client.CallTool(ctx, &mcp.CallToolParams{
Name: "remember",
// force: Jede Runde legt unter neuem Bezeichner mit
// demselben Titel an. Die Dublettenprüfung würde das
// zu Recht anhalten — hier geht es aber ums
// gleichzeitige Schreiben, nicht um Dubletten.
Arguments: map[string]any{
"slug": slug, "title": "Umkämpft", "content": "Stand " + word, "force": true,
},
})
if err != nil {
report(err)
return
}
if res.IsError {
report(fmt.Errorf("remember: %s", text(res)))
return
}
}
}(a)
}
wg.Wait()
if len(errs) > 0 {
t.Fatalf("Runde %d: %d Fehler, erster: %v", round, len(errs), errs[0])
}
e, err := ws.Store.EntryBySlug(ctx, project.ID, slug)
if err != nil {
t.Fatal(err)
}
if e.Version != agents*perAgent {
t.Errorf("Runde %d: Fassung %d, erwartet %d — ein Schreibvorgang ging verloren",
round, e.Version, agents*perAgent)
}
// Das Wort der letzten Fassung steht nach "Stand " im Inhalt.
lastWord := e.Content[len("Stand "):]
found := false
for _, r := range idx.Search(lastWord, 5) {
if r.DocID == slug {
found = true
}
}
if !found {
t.Errorf("Runde %d: Datenbank hat Fassung %d mit %q, der Suchindex kennt das Wort nicht — "+
"er hält einen älteren Stand", round, e.Version, lastWord)
}
}
}
// letters 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 letters(n int) string {
s := fmt.Sprintf("%06d", n)
b := []byte(s)
for i := range b {
b[i] = 'a' + (b[i] - '0')
}
return string(b)
}