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
216 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|