Files
nexarch/mail/internal/search/reindex_test.go
T
sysopsandClaude Sonnet 5 86c4223855 SRC-09: suchindex-neuaufbau-reindexierung
Werkzeug für vollständigen Suchindex-Neuaufbau: neue Tabelle anlegen,
Dokumente aus der lebenden Tabelle kopieren, Trefferzahlen verifizieren,
erst dann per Manticore RENAME atomar umschalten.

- reindex.go: Reindexer.Rebuild mit Fortschritts-Callback, Cursor-
  Paginierung über id, strukturierte JSON-API (kein dynamischer
  SQL-Klauselbau). Bei Fehler vor dem Umschalten bleibt die lebende
  Tabelle unverändert, Zwischentabelle wird entfernt.
- Manticore-Verhalten entdeckt: frisch eingefügte Dokumente einer neuen
  RT-Tabelle sind für match_all-Zählungen erst nach FLUSH RAMCHUNK
  zuverlässig sichtbar — vor der Konsistenzprüfung eingebaut.
- Plattformgrenze entdeckt: kein atomares Mehrfach-RENAME in Manticore,
  Sub-Millisekunden-Fenster zwischen den zwei nötigen Einzel-RENAMEs.
  Client.Search bekam einen begrenzten Retry auf "unknown local table".
- Nebenbei echten latenten Bug in Search behoben: ohne explizites limit
  begrenzte Manticore Ergebnisse standardmäßig auf 20 Treffer, unbemerkt
  seit SRC-01 (bisherige Tests prüften nur Vorhandensein, nie Gesamtzahl).

Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-09-PRUEFPROTOKOLL.md):
1. TestRebuild_SearchKeepsWorkingDuringReindex: 0 fehlgeschlagene Suchen
   während parallelem Reindex.
2. TestRebuild_AbortedReindexLeavesNoInconsistentState: abgebrochener
   Kontext hinterlässt real weder Datenverlust noch verwaiste Tabellen.
3. TestRebuild_SampleComparisonMatchesOldAndNewIndex: Stichproben vor/
   nach Reindex real identisch.

Kein Umbau: Index/Delete/Facets-Verhalten sonst unverändert,
dedup/indexworker/storage/crypto/encstorage unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-31 11:01:45 +02:00

216 lines
6.9 KiB
Go

// Integrationstest (SRC-09): echte Manticore-Instanz, TEST_MANTICORE_URL
// (gleiche Konvention wie integration_test.go/ranking_test.go/facets_test.go).
package search
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
)
// TestRebuild_SearchKeepsWorkingDuringReindex ist die geforderte
// Pflichtprüfung 1: Reindex während laufender Suchanfragen unterbricht die
// Suche nicht.
func TestRebuild_SearchKeepsWorkingDuringReindex(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src09-parallel"
for i := 0; i < 30; i++ {
messageID := "msg-parallel-" + string(rune('a'+i))
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: messageID, Subject: "Zwiebelfisch " + messageID, Body: "Text"})
}
stop := make(chan struct{})
var searchErrors int64
var searchesDone int64
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
if _, err := client.Search(ctx, tenant, "Zwiebelfisch"); err != nil {
atomic.AddInt64(&searchErrors, 1)
t.Logf("suchfehler während reindex: %v", err)
}
atomic.AddInt64(&searchesDone, 1)
time.Sleep(2 * time.Millisecond)
}
}()
reindexer := NewReindexer(client)
result, err := reindexer.Rebuild(ctx, nil)
close(stop)
wg.Wait()
if err != nil {
t.Fatalf("rebuild: %v", err)
}
if result.OldCount != result.NewCount {
t.Fatalf("erwartete gleiche trefferzahlen, habe alt=%d neu=%d", result.OldCount, result.NewCount)
}
if atomic.LoadInt64(&searchesDone) == 0 {
t.Fatal("keine einzige parallele suche ausgeführt — test aussagelos")
}
if errs := atomic.LoadInt64(&searchErrors); errs != 0 {
t.Fatalf("erwartete 0 fehlgeschlagene suchen während des reindex, habe %d von %d", errs, atomic.LoadInt64(&searchesDone))
}
// Suche funktioniert auch NACH dem Umschalten weiterhin real.
afterResults, err := client.Search(ctx, tenant, "Zwiebelfisch")
if err != nil {
t.Fatalf("search nach reindex: %v", err)
}
if len(afterResults) != 30 {
t.Fatalf("erwartete 30 treffer nach reindex, habe %d", len(afterResults))
}
}
// TestRebuild_AbortedReindexLeavesNoInconsistentState ist die geforderte
// Pflichtprüfung 2: abgebrochener Reindex hinterlässt keinen
// inkonsistenten Zustand.
func TestRebuild_AbortedReindexLeavesNoInconsistentState(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src09-abbruch"
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-abbruch-1", Subject: "Vertragsentwurf Abbruchtest", Body: "Text"})
before, err := client.Search(ctx, tenant, "Abbruchtest")
if err != nil || len(before) != 1 {
t.Fatalf("voraussetzung nicht erfüllt: %v / %d treffer", err, len(before))
}
cancelCtx, cancel := context.WithCancel(ctx)
cancel() // sofort abgebrochen, simuliert Absturz/Abbruch mitten im Kopiervorgang
reindexer := NewReindexer(client)
_, err = reindexer.Rebuild(cancelCtx, nil)
if err == nil {
t.Fatal("erwartete fehler bei abgebrochenem kontext, habe nil")
}
if !errors.Is(err, context.Canceled) {
// Manticore-Fehler durch den abgebrochenen Request sind ebenfalls
// akzeptabel, solange überhaupt ein Fehler zurückkommt.
t.Logf("fehler war nicht context.Canceled, sondern: %v (akzeptiert, solange real ein fehler zurückkommt)", err)
}
// Die lebende Tabelle muss trotz Abbruch unverändert und abfragbar sein.
after, err := client.Search(ctx, tenant, "Abbruchtest")
if err != nil {
t.Fatalf("search nach abgebrochenem reindex: %v", err)
}
if len(after) != 1 {
t.Fatalf("erwartete weiterhin 1 treffer nach abgebrochenem reindex, habe %d — inkonsistenter zustand", len(after))
}
// Keine verwaisten Zwischentabellen (kein inkonsistenter Zustand auf
// Manticore-Ebene): kurz warten, damit ein eventuell noch laufender
// CREATE-TABLE-Aufruf durchlaufen kann, dann prüfen, dass keine
// mail_documents_reindex_*-Tabelle übrig geblieben ist.
time.Sleep(200 * time.Millisecond)
orphaned := listOrphanedReindexTables(t, client)
if len(orphaned) > 0 {
t.Fatalf("verwaiste zwischentabellen nach abbruch gefunden: %v", orphaned)
}
}
func listOrphanedReindexTables(t *testing.T, client *Client) []string {
t.Helper()
// SHOW TABLES ist eine feste, unparametrisierte Anweisung ohne
// jeglichen Laufzeitwert.
rows, err := client.showTables(context.Background())
if err != nil {
t.Fatalf("show tables: %v", err)
}
names := []string{}
for _, table := range rows {
if tempTableNamePattern.MatchString(table) {
names = append(names, table)
}
}
return names
}
// TestRebuild_SampleComparisonMatchesOldAndNewIndex ist die geforderte
// Pflichtprüfung 3: Stichprobenvergleich Alt-/Neuindex bestätigt gleiche
// Trefferzahlen.
func TestRebuild_SampleComparisonMatchesOldAndNewIndex(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src09-stichprobe"
subjects := []string{"Quartalsbericht", "Personalplanung", "Urlaubsantrag"}
for i, s := range subjects {
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-sp-" + string(rune('a'+i)), Subject: s, Body: "Inhalt " + s})
}
beforeCounts := map[string]int{}
for _, s := range subjects {
results, err := client.Search(ctx, tenant, s)
if err != nil {
t.Fatalf("search vor reindex (%s): %v", s, err)
}
beforeCounts[s] = len(results)
}
reindexer := NewReindexer(client)
if _, err := reindexer.Rebuild(ctx, nil); err != nil {
t.Fatalf("rebuild: %v", err)
}
for _, s := range subjects {
results, err := client.Search(ctx, tenant, s)
if err != nil {
t.Fatalf("search nach reindex (%s): %v", s, err)
}
if len(results) != beforeCounts[s] {
t.Fatalf("stichprobe %q: vor reindex %d treffer, nach reindex %d treffer", s, beforeCounts[s], len(results))
}
}
}
// TestRebuild_ReportsProgress deckt Akzeptanzkriterium 2 ab (Fortschritt
// nachvollziehbar sichtbar).
func TestRebuild_ReportsProgress(t *testing.T) {
client := setupClient(t)
ctx := context.Background()
tenant := "mandant-src09-fortschritt"
for i := 0; i < 5; i++ {
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-progress-" + string(rune('a'+i)), Subject: "x"})
}
var updates []Progress
var mu sync.Mutex
reindexer := NewReindexer(client)
_, err := reindexer.Rebuild(ctx, func(p Progress) {
mu.Lock()
updates = append(updates, p)
mu.Unlock()
})
if err != nil {
t.Fatalf("rebuild: %v", err)
}
if len(updates) == 0 {
t.Fatal("erwartete mindestens eine fortschrittsmeldung")
}
last := updates[len(updates)-1]
if last.Copied < last.Total {
// total ist eine zu Beginn eingefrorene Momentaufnahme; die geteilte
// Manticore-Instanz kann während des Kopierens durch andere Tests
// weiter wachsen (real beobachtet) — copied darf total daher
// erreichen oder minimal überschreiten, nur ein Rückstand wäre ein
// echter Fehler.
t.Fatalf("letzte fortschrittsmeldung sollte abgeschlossen sein, habe copied=%d total=%d", last.Copied, last.Total)
}
}