diff --git a/docs/CUT-05-PRUEFPROTOKOLL.md b/docs/CUT-05-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..00d9a125 --- /dev/null +++ b/docs/CUT-05-PRUEFPROTOKOLL.md @@ -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). diff --git a/internal/searchreindex/reindex.go b/internal/searchreindex/reindex.go new file mode 100644 index 00000000..5b22feba --- /dev/null +++ b/internal/searchreindex/reindex.go @@ -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 +} diff --git a/internal/searchreindex/sample.go b/internal/searchreindex/sample.go new file mode 100644 index 00000000..4b04d8b2 --- /dev/null +++ b/internal/searchreindex/sample.go @@ -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 +} diff --git a/internal/searchreindex/searchreindex.go b/internal/searchreindex/searchreindex.go new file mode 100644 index 00000000..b8d33c2f --- /dev/null +++ b/internal/searchreindex/searchreindex.go @@ -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 +} diff --git a/internal/searchreindex/searchreindex_test.go b/internal/searchreindex/searchreindex_test.go new file mode 100644 index 00000000..b1b909c3 --- /dev/null +++ b/internal/searchreindex/searchreindex_test.go @@ -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) +}