CUT-05: Suchindex-Neuaufbau nach Migration #284

Merged
patrick merged 1 commits from feature/cut-05-suchindex-neuaufbau into main 2026-10-15 21:23:09 +02:00
5 changed files with 558 additions and 0 deletions

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
# CUT-05 – Prüfprotokoll: Suchindex-Neuaufbau nach Migration
## DSGVO-/Scope-Abweichung (bitte zuerst lesen)
Ticket CUT-05 setzt "nach erfolgreicher Migration" voraus, also auf den echten
Stichtag (CUT-04). Der echte Stichtag ist eine reale Produktionsoperation
gegen echte archivmail-/archivdms-Daten und wurde **nicht** von dieser
Implementierung durchgeführt oder simuliert – DSGVO-Konstante dieses
Projekts: niemals echte Kunden-/Mandantendaten anfassen (siehe bereits
gemergte CUT-02-Abweichung, analoges Vorgehen).
Stattdessen wurde:
- der Suchindex-Neuaufbau-Mechanismus als Code gebaut (`internal/searchreindex`),
- gegen eine **synthetische Testkopie** verifiziert, erzeugt mit
`cutoverorchestrator.GenerateDataset` (demselben Datengenerator, den auch
CUT-01/CUT-02/die Generalprobe verwenden),
- auf einer frischen Wegwerf-Postgres-Datenbank auf Host 204 getestet
(kein Zugriff auf `archivdms`-Produktivdaten, siehe Projektvorgabe).
Der echte Produktivlauf (realer Reindex gegen Manticore nach dem echten
Stichtag) bleibt ein separater, späterer operativer Schritt für Menschen mit
echtem Zugriff auf archivmail/archivdms und die produktive Manticore-Instanz.
## Rechercheergebnis: bestehende Such-/Index-Infrastruktur
Vor der Implementierung wurde geprüft, ob im Repository bereits eine
Manticore-Anbindung existiert:
- `dms/internal/searchindex`, `dms/internal/searchsync`, `dms/internal/searchapi`
(Modul `gitea.perlbach24.de/scripte/nexarch/dms`) und
`mail/internal/search`, `mail/internal/indexworker`
(Modul `gitea.perlbach24.de/scripte/nexarch/mail`) enthalten bereits reale
Manticore-Anbindungen (RT-Indizes, Reindex-Jobs, Facetten, Ranking).
- Diese liegen jedoch in eigenständigen Go-Modulen (`dms/go.mod`,
`mail/go.mod`), getrennt vom Root-Modul
(`gitea.perlbach24.de/scripte/nexarch`), in dem `internal/cutoverorchestrator`
und die CUT-Tickets implementiert sind. Das Root-Modul hat **keine**
direkte Abhängigkeit auf diese Module und auch keine eigene
Manticore-Anbindung.
Gemäß Ticketvorgabe ("falls es noch keine echte Manticore-Anbindung im Code
gibt … beschränke dich auf eine Abstraktion") wurde daher im Root-Modul ein
neues, schlankes Paket `internal/searchreindex` angelegt: ein
`SearchIndex`-Interface plus eine testbare In-Memory-Implementierung, die
später 1:1 gegen eine echte Manticore-Anbindung ausgetauscht werden kann
(z. B. durch einen Adapter auf `dms/internal/searchindex`/`mail/internal/search`,
sobald eine Modulgrenze dafür gezogen wird).
## Implementierung
Paket `internal/searchreindex` (Root-Modul):
- `SearchIndex`-Interface (`Index`, `Search`, `Count`) – austauschbar gegen
eine echte Manticore-Anbindung.
- `InMemoryIndex` – einfache, testbare Referenzimplementierung (Substring-
Suche auf Titel/Text, nach Tenant getrennt).
- `Reindex(ctx, pool, idx)` – liest alle migrierten Dokumente (`documents`)
und Mails (`emails`) aus der Ziel-Postgres-Datenbank und speist sie in den
Index ein. Liefert `Stats` (Anzahl gelesen/indexiert je Typ, Dauer,
`Complete()`).
- `CompareSample(ctx, idx, source, n)` – zieht je Tenant eine Stichprobe von
Suchanfragen (aus Dokumenttiteln/Mailbetreffs der synthetischen Quelle) und
vergleicht die Trefferliste des neuen Index mit einer direkten
Substring-Suche über die Quelldaten (vergleichbar mit TXT-01 aus
CUT-02/CUT-01: Zeichen-für-Zeichen-Textvergleich statt Hash-Vergleich).
## Akzeptanzkriterien
### AK1: Suchindex enthält nach dem Neuaufbau alle migrierten Dokumente und Mails je Tenant
Test: `TestReindex_VollstaendigkeitProTenant`
(`internal/searchreindex/searchreindex_test.go`)
- Erzeugt synthetische Testkopie via `GenerateDataset(seed=2026, ...)`.
- Legt Zieltabellen `documents`/`emails` auf frischer Wegwerf-Datenbank an
und befüllt sie (simulierte "migrierte" Ziel-DB).
- Führt `Reindex` aus, prüft `Stats.Complete()` (indexiert == gelesen, keine
Verluste) sowie je Tenant: Indexeinträge ≥ Anzahl migrierter Dokumente.
**Zielwert:** `Complete() == true`, je Tenant `Count ≥ erwartete Dokumentanzahl`.
**Ergebnis:** PASS. Lauf: 24/24 Dokumente, 18/18 Mails indexiert, 0 Verluste.
### AK2: Suche liefert für eine Stichprobe bekannter Inhalte dieselben Treffer wie die synthetische Quelle
Test: `TestCompareSample_TrefferGleichWieQuelle`
(`internal/searchreindex/searchreindex_test.go`)
- Zweiter Datensatz (`seed=4242`), eigene Wegwerf-Datenbank.
- `CompareSample` mit 20 Stichproben je Tenant: Suchwort aus Dokumenttitel/
Mailbetreff, Vergleich Index-Treffer vs. direkte Substring-Suche über die
Quelldaten.
**Zielwert:** 0 Abweichungen über alle Stichproben.
**Ergebnis:** PASS. 42 Anfragen (alle Tenants zusammen, da weniger als 20
Dokumente/Mails je Tenant vorhanden waren), 0 Abweichungen.
## Prüfungen vor Abnahme
### Prüfung 1: Stichprobe von 20 Suchanfragen je Tenant gegen Altsystem-Ergebnis (synthetische Quelle)
Durchgeführt in `TestCompareSample_TrefferGleichWieQuelle` (siehe AK2).
Da der synthetische Datensatz kleiner ist als 20 Dokumente/Mails je Tenant,
wird je Tenant die maximal verfügbare Anzahl (≤ 20) verwendet – `CompareSample`
begrenzt dies automatisch (`n = min(perTenantSamples, len(items))`).
**Ergebnis:** 42 Stichprobenanfragen über alle Tenants, 0 Abweichungen.
### Prüfung 2: Reindexierungsdauer und Vollständigkeit protokolliert
Protokolliert über `t.Logf` in beiden Tests (siehe Testausgabe unten) sowie
über die zurückgegebene `Stats`-Struktur (`DocumentsIndexed`, `DocumentsTotal`,
`EmailsIndexed`, `EmailsTotal`, `Duration`).
**Ergebnis:** Dauer 1,43 ms für 24 Dokumente + 18 Mails (synthetischer
Testdatensatz, lokale Wegwerf-Postgres auf Host 204 — keine Aussage über
Produktionsgrößenordnung/Manticore-Latenz, siehe Abweichungshinweis oben).
## Testlauf (echt ausgeführt, Host 204)
```
$ go test ./internal/searchreindex/... -v -run .
=== RUN TestReindex_VollstaendigkeitProTenant
searchreindex_test.go:120: reindex dauer=1.43108ms dokumente=24/24 mails=18/18
--- PASS: TestReindex_VollstaendigkeitProTenant (0.93s)
=== RUN TestCompareSample_TrefferGleichWieQuelle
searchreindex_test.go:173: stichprobe: 42 anfragen, 0 abweichungen
--- PASS: TestCompareSample_TrefferGleichWieQuelle (0.80s)
PASS
ok gitea.perlbach24.de/scripte/nexarch/internal/searchreindex 1.739s
```
`go build ./...` (gesamtes Root-Modul) erfolgreich.
## Offene Punkte für den echten Produktivlauf (nicht Teil dieses Tickets)
- Austausch von `InMemoryIndex` gegen eine echte Manticore-Anbindung
(Adapter auf `dms/internal/searchindex`/`mail/internal/search`, oder eine
neue root-modul-taugliche Manticore-Anbindung).
- Lesen aus den echten migrierten Ziel-DBs (pro-Tenant-Schema/-Datenbank
gemäß Core-Provisionierung) statt der vereinfachten Testtabellen
`documents`/`emails` mit `tenant_id`-Spalte.
- Messung der Reindexierungsdauer auf Produktionsdatenmenge (nicht
synthetischer Kleindatensatz).
+80
View File
@@ -0,0 +1,80 @@
package searchreindex
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type Stats struct {
DocumentsTotal int
DocumentsIndexed int
EmailsTotal int
EmailsIndexed int
Duration time.Duration
}
func (s Stats) Complete() bool {
return s.DocumentsIndexed == s.DocumentsTotal && s.EmailsIndexed == s.EmailsTotal
}
func Reindex(ctx context.Context, pool *pgxpool.Pool, idx SearchIndex) (Stats, error) {
start := time.Now()
var stats Stats
if err := pool.QueryRow(ctx, `SELECT count(*) FROM documents`).Scan(&stats.DocumentsTotal); err != nil {
return stats, fmt.Errorf("dokumentanzahl: %w", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM emails`).Scan(&stats.EmailsTotal); err != nil {
return stats, fmt.Errorf("mailanzahl: %w", err)
}
docRows, err := pool.Query(ctx, `SELECT id, tenant_id, title, content_text FROM documents`)
if err != nil {
return stats, fmt.Errorf("dokumente lesen: %w", err)
}
for docRows.Next() {
var id, title, text string
var tenantID int64
if err := docRows.Scan(&id, &tenantID, &title, &text); err != nil {
docRows.Close()
return stats, fmt.Errorf("dokumentzeile: %w", err)
}
if err := idx.Index(ctx, IndexedDoc{TenantID: tenantID, Kind: "document", RefID: id, Title: title, Text: text}); err != nil {
docRows.Close()
return stats, fmt.Errorf("dokument %s indexieren: %w", id, err)
}
stats.DocumentsIndexed++
}
if err := docRows.Err(); err != nil {
return stats, fmt.Errorf("dokumente lesen: %w", err)
}
docRows.Close()
mailRows, err := pool.Query(ctx, `SELECT id, tenant_id, subject, body FROM emails`)
if err != nil {
return stats, fmt.Errorf("mails lesen: %w", err)
}
for mailRows.Next() {
var id, subject, body string
var tenantID int64
if err := mailRows.Scan(&id, &tenantID, &subject, &body); err != nil {
mailRows.Close()
return stats, fmt.Errorf("mailzeile: %w", err)
}
if err := idx.Index(ctx, IndexedDoc{TenantID: tenantID, Kind: "email", RefID: id, Title: subject, Text: body}); err != nil {
mailRows.Close()
return stats, fmt.Errorf("mail %s indexieren: %w", id, err)
}
stats.EmailsIndexed++
}
if err := mailRows.Err(); err != nil {
return stats, fmt.Errorf("mails lesen: %w", err)
}
mailRows.Close()
stats.Duration = time.Since(start)
return stats, nil
}
+98
View File
@@ -0,0 +1,98 @@
package searchreindex
import (
"context"
"fmt"
"sort"
"strings"
)
type SourceItem struct {
TenantID int64
Kind string
RefID string
Title string
Text string
}
type SampleResult struct {
TenantID int64
Query string
ExpectedRefs []string
ActualRefs []string
Match bool
}
func sourceQuery(title string) string {
for _, w := range strings.Fields(title) {
if len(w) >= 4 {
return w
}
}
return title
}
func searchSource(items []SourceItem, tenantID int64, query string) []string {
q := strings.ToLower(query)
var refs []string
for _, it := range items {
if it.TenantID != tenantID {
continue
}
if strings.Contains(strings.ToLower(it.Title), q) || strings.Contains(strings.ToLower(it.Text), q) {
refs = append(refs, it.RefID)
}
}
sort.Strings(refs)
return refs
}
// CompareSample prueft Akzeptanzkriterium 2 / Pruefung 1: je Tenant eine
// Stichprobe von Suchanfragen gegen die synthetische Quelle (Ersatz fuer das
// Altsystem) statt gegen den neuen Index.
func CompareSample(ctx context.Context, idx SearchIndex, source []SourceItem, perTenantSamples int) ([]SampleResult, error) {
byTenant := map[int64][]SourceItem{}
for _, it := range source {
byTenant[it.TenantID] = append(byTenant[it.TenantID], it)
}
var tenantIDs []int64
for id := range byTenant {
tenantIDs = append(tenantIDs, id)
}
sort.Slice(tenantIDs, func(i, j int) bool { return tenantIDs[i] < tenantIDs[j] })
var results []SampleResult
for _, tenantID := range tenantIDs {
items := byTenant[tenantID]
sort.Slice(items, func(i, j int) bool { return items[i].RefID < items[j].RefID })
n := perTenantSamples
if n > len(items) {
n = len(items)
}
for i := 0; i < n; i++ {
query := sourceQuery(items[i].Title)
if query == "" {
continue
}
expected := searchSource(items, tenantID, query)
hits, err := idx.Search(ctx, tenantID, query)
if err != nil {
return results, fmt.Errorf("suche tenant %d query %q: %w", tenantID, query, err)
}
actual := make([]string, 0, len(hits))
for _, h := range hits {
actual = append(actual, h.RefID)
}
sort.Strings(actual)
results = append(results, SampleResult{
TenantID: tenantID,
Query: query,
ExpectedRefs: expected,
ActualRefs: actual,
Match: strings.Join(expected, ",") == strings.Join(actual, ","),
})
}
}
return results, nil
}
+62
View File
@@ -0,0 +1,62 @@
package searchreindex
import (
"context"
"strings"
"sync"
)
type IndexedDoc struct {
TenantID int64
Kind string
RefID string
Title string
Text string
}
type Hit struct {
TenantID int64
Kind string
RefID string
}
type SearchIndex interface {
Index(ctx context.Context, doc IndexedDoc) error
Search(ctx context.Context, tenantID int64, query string) ([]Hit, error)
Count(ctx context.Context, tenantID int64) (int, error)
}
type InMemoryIndex struct {
mu sync.RWMutex
docs map[int64][]IndexedDoc
}
func NewInMemoryIndex() *InMemoryIndex {
return &InMemoryIndex{docs: map[int64][]IndexedDoc{}}
}
func (idx *InMemoryIndex) Index(_ context.Context, doc IndexedDoc) error {
idx.mu.Lock()
defer idx.mu.Unlock()
idx.docs[doc.TenantID] = append(idx.docs[doc.TenantID], doc)
return nil
}
func (idx *InMemoryIndex) Search(_ context.Context, tenantID int64, query string) ([]Hit, error) {
idx.mu.RLock()
defer idx.mu.RUnlock()
q := strings.ToLower(query)
var hits []Hit
for _, d := range idx.docs[tenantID] {
if strings.Contains(strings.ToLower(d.Title), q) || strings.Contains(strings.ToLower(d.Text), q) {
hits = append(hits, Hit{TenantID: d.TenantID, Kind: d.Kind, RefID: d.RefID})
}
}
return hits, nil
}
func (idx *InMemoryIndex) Count(_ context.Context, tenantID int64) (int, error) {
idx.mu.RLock()
defer idx.mu.RUnlock()
return len(idx.docs[tenantID]), nil
}
@@ -0,0 +1,174 @@
package searchreindex
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/cutoverorchestrator"
)
func requireAdminDSN(t *testing.T) string {
t.Helper()
dsn := os.Getenv("NEXARCH_CUTOVER_ADMIN_DSN")
if dsn == "" {
dsn = os.Getenv("TEST_ADMIN_DSN")
}
if dsn == "" {
t.Skip("NEXARCH_CUTOVER_ADMIN_DSN/TEST_ADMIN_DSN nicht gesetzt, searchreindex-integrationstest uebersprungen")
}
return dsn
}
func newTargetPool(t *testing.T, adminDSN string) *pgxpool.Pool {
t.Helper()
cluster, err := cutoverorchestrator.NewCluster(adminDSN)
if err != nil {
t.Fatalf("cluster: %v", err)
}
dbName := fmt.Sprintf("srchreidx%d", time.Now().UnixNano())
ctx := context.Background()
if err := cluster.CreateDatabase(ctx, dbName); err != nil {
t.Fatalf("testdatenbank anlegen: %v", err)
}
t.Cleanup(func() {
if _, err := cluster.DropRunDatabases(context.Background(), dbName); err != nil {
t.Logf("testdatenbank aufraeumen: %v", err)
}
})
pool, err := cluster.Connect(ctx, dbName)
if err != nil {
t.Fatalf("verbindung: %v", err)
}
t.Cleanup(pool.Close)
return pool
}
const targetSchema = `
CREATE TABLE documents (
id text PRIMARY KEY,
tenant_id bigint NOT NULL,
title text NOT NULL,
content_text text NOT NULL
);
CREATE TABLE emails (
id text PRIMARY KEY,
tenant_id bigint NOT NULL,
subject text NOT NULL,
body text NOT NULL
);
`
func seedMigratedTarget(t *testing.T, pool *pgxpool.Pool, ds *cutoverorchestrator.Dataset) []SourceItem {
t.Helper()
ctx := context.Background()
if _, err := pool.Exec(ctx, targetSchema); err != nil {
t.Fatalf("schema anlegen: %v", err)
}
var source []SourceItem
for _, d := range ds.Archivdms.Documents {
refID := fmt.Sprintf("doc-%d", d.ID)
text := string(d.Content)
if _, err := pool.Exec(ctx, `INSERT INTO documents (id, tenant_id, title, content_text) VALUES ($1,$2,$3,$4)`,
refID, d.TenantID, d.Title, text); err != nil {
t.Fatalf("dokument %d einfuegen: %v", d.ID, err)
}
source = append(source, SourceItem{TenantID: d.TenantID, Kind: "document", RefID: refID, Title: d.Title, Text: text})
}
for _, e := range ds.Archivmail.Emails {
refID := "mail-" + e.ID
if _, err := pool.Exec(ctx, `INSERT INTO emails (id, tenant_id, subject, body) VALUES ($1,$2,$3,$4)`,
refID, e.TenantID, e.Subject, e.Subject); err != nil {
t.Fatalf("mail %s einfuegen: %v", e.ID, err)
}
source = append(source, SourceItem{TenantID: e.TenantID, Kind: "email", RefID: refID, Title: e.Subject, Text: e.Subject})
}
return source
}
// TestReindex_VollstaendigkeitProTenant prueft Akzeptanzkriterium 1: nach dem
// Neuaufbau enthaelt der Index alle migrierten Dokumente und Mails je Tenant.
func TestReindex_VollstaendigkeitProTenant(t *testing.T) {
adminDSN := requireAdminDSN(t)
pool := newTargetPool(t, adminDSN)
ds, err := cutoverorchestrator.GenerateDataset(2026, time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC), 4)
if err != nil {
t.Fatalf("testdaten erzeugen: %v", err)
}
seedMigratedTarget(t, pool, ds)
ctx := context.Background()
idx := NewInMemoryIndex()
stats, err := Reindex(ctx, pool, idx)
if err != nil {
t.Fatalf("reindex: %v", err)
}
if !stats.Complete() {
t.Fatalf("reindex unvollstaendig: %+v", stats)
}
if stats.DocumentsTotal == 0 || stats.EmailsTotal == 0 {
t.Fatalf("testdaten ohne dokumente/mails erzeugt: %+v", stats)
}
t.Logf("reindex dauer=%s dokumente=%d/%d mails=%d/%d", stats.Duration, stats.DocumentsIndexed, stats.DocumentsTotal, stats.EmailsIndexed, stats.EmailsTotal)
for _, tenant := range ds.Archivdms.Tenants {
var expectedDocs int
for _, d := range ds.Archivdms.Documents {
if d.TenantID == tenant.ID {
expectedDocs++
}
}
count, err := idx.Count(ctx, tenant.ID)
if err != nil {
t.Fatalf("zaehlen tenant %d: %v", tenant.ID, err)
}
if count < expectedDocs {
t.Fatalf("tenant %d: index enthaelt %d eintraege, erwartet mindestens %d dokumente", tenant.ID, count, expectedDocs)
}
}
}
// TestCompareSample_TrefferGleichWieQuelle prueft Akzeptanzkriterium 2 und
// Pruefung 1: Stichprobe von Suchanfragen je Tenant liefert dieselben
// Treffer wie die direkte Suche ueber die synthetische Quelle.
func TestCompareSample_TrefferGleichWieQuelle(t *testing.T) {
adminDSN := requireAdminDSN(t)
pool := newTargetPool(t, adminDSN)
ds, err := cutoverorchestrator.GenerateDataset(4242, time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC), 4)
if err != nil {
t.Fatalf("testdaten erzeugen: %v", err)
}
source := seedMigratedTarget(t, pool, ds)
ctx := context.Background()
idx := NewInMemoryIndex()
if _, err := Reindex(ctx, pool, idx); err != nil {
t.Fatalf("reindex: %v", err)
}
results, err := CompareSample(ctx, idx, source, 20)
if err != nil {
t.Fatalf("stichprobenvergleich: %v", err)
}
if len(results) == 0 {
t.Fatal("keine stichprobe erzeugt")
}
var mismatches int
for _, r := range results {
if !r.Match {
mismatches++
t.Errorf("tenant %d query %q: erwartet=%v erhalten=%v", r.TenantID, r.Query, r.ExpectedRefs, r.ActualRefs)
}
}
t.Logf("stichprobe: %d anfragen, %d abweichungen", len(results), mismatches)
}