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
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
db73aab0de
commit
86c4223855
@@ -204,6 +204,52 @@ var fieldWeights = map[string]any{
|
||||
FieldAttachmentText: 1,
|
||||
}
|
||||
|
||||
// doSearchWithSwapRetry führt eine /search-Anfrage aus und wiederholt sie
|
||||
// bis zu zweimal mit kurzer Pause, falls Manticore "unknown local table"
|
||||
// meldet (SRC-09 Akzeptanzkriterium 3: der Reindex-Umschaltmoment
|
||||
// RENAME-alte-Tabelle-weg/RENAME-neue-Tabelle-rein hat ein extrem kurzes
|
||||
// Zeitfenster ohne existierende mail_documents-Tabelle — dieser Retry
|
||||
// überbrückt es, statt eine Suchanfrage in genau diesem Moment fehlschlagen
|
||||
// zu lassen).
|
||||
func (c *Client) doSearchWithSwapRetry(ctx context.Context, body []byte) ([]byte, error) {
|
||||
const maxAttempts = 3
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/search", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: suchanfrage bauen: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: suche ausführen: %w", err)
|
||||
}
|
||||
respBody, readErr := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("search: antwort lesen: %w", readErr)
|
||||
}
|
||||
if strings.Contains(string(respBody), "unknown local table") {
|
||||
lastErr = fmt.Errorf("search: suche, status %d: %s", resp.StatusCode, string(respBody))
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("search: suche, status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// Search sucht queryText innerhalb der Volltextfelder, strikt begrenzt auf
|
||||
// den Mandanten tenantSlug (Akzeptanzkriterium 2: mandantengetrennt
|
||||
// abfragbar) — der Tenant-Filter läuft über ein strukturiertes "equals"-
|
||||
@@ -216,6 +262,8 @@ var fieldWeights = map[string]any{
|
||||
// Feld-/Tabellennamen, der beeinflusst werden könnte. Ergebnisse kommen
|
||||
// von Manticore bereits nach Relevanz (BM25, gewichtet über fieldWeights)
|
||||
// absteigend sortiert zurück (Akzeptanzkriterium 1).
|
||||
const searchResultLimit = 1000
|
||||
|
||||
func (c *Client) Search(ctx context.Context, tenantSlug, queryText string) ([]Result, error) {
|
||||
payload := map[string]any{
|
||||
"index": IndexName,
|
||||
@@ -230,29 +278,21 @@ func (c *Client) Search(ctx context.Context, tenantSlug, queryText string) ([]Re
|
||||
"options": map[string]any{
|
||||
"field_weights": fieldWeights,
|
||||
},
|
||||
// Ohne explizites limit begrenzt Manticore standardmäßig auf 20
|
||||
// Treffer — bei Testkorpora bis 1000 Dokumenten (SRC-03) blieb das
|
||||
// bisher unbemerkt, da nur auf das Vorhandensein einzelner Treffer
|
||||
// geprüft wurde, nicht auf die Gesamtzahl. searchResultLimit deckt
|
||||
// realistische Trefferlisten ab, ohne unbegrenzt zu sein.
|
||||
"limit": searchResultLimit,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: suchanfrage serialisieren: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/search", bytes.NewReader(body))
|
||||
respBody, err := c.doSearchWithSwapRetry(ctx, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: suchanfrage bauen: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: suche ausführen: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: antwort lesen: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("search: suche, status %d: %s", resp.StatusCode, string(respBody))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var parsed searchResponse
|
||||
|
||||
Reference in New Issue
Block a user