Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86c4223855 | ||
|
|
db73aab0de |
@@ -0,0 +1,54 @@
|
|||||||
|
# SRC-05 – Prüfprotokoll: Facetten- & Filter-API
|
||||||
|
|
||||||
|
Voraussetzung SRC-03 (Fertig).
|
||||||
|
|
||||||
|
## Umsetzung
|
||||||
|
|
||||||
|
- `mail/internal/search/migrations/0002..0005_*.sql`: vier eigene,
|
||||||
|
nummerierte `ALTER TABLE ADD COLUMN`-Migrationen für die neuen
|
||||||
|
Facettenfelder (`sender`, `mailbox`, `attachment_type`, `tag`) — Manticore
|
||||||
|
erlaubt nur eine Spalte je ALTER-Anweisung. `EnsureSchema` wendet sie
|
||||||
|
idempotent nach (Fehlertext `"already in schema"` gilt als bereits
|
||||||
|
angewendet, kein Fehlerzustand).
|
||||||
|
- `fields.go`: neue statische Feldkonstanten + `FacetFields`-Whitelist
|
||||||
|
(`sender`, `mailbox`, `attachment_type`, `tag`) — einzige Quelle
|
||||||
|
zulässiger Facettendimensionen, kein beliebiger Client-Feldname möglich.
|
||||||
|
- `facets.go` — `Client.Facets(ctx, tenantSlug, queryText, filters)`:
|
||||||
|
nutzt Manticores strukturierte `aggs.terms`/`aggs.range`-API (kein
|
||||||
|
dynamischer SQL-Klauselbau). Tenant-Filter + optionale
|
||||||
|
`FacetFilter`-Liste laufen als zusätzliche `equals`-Klauseln in
|
||||||
|
derselben `bool.must`-Liste (Akzeptanzkriterium 2: UND-Verknüpfung).
|
||||||
|
Zeitraum-Facette über feste Buckets (letzte 7 Tage/30 Tage/Jahr/älter)
|
||||||
|
via `aggs.range` auf `sent_at`.
|
||||||
|
- `Document` um optionale Facettenfelder erweitert (`Sender`, `Mailbox`,
|
||||||
|
`AttachmentType`, `Tag`).
|
||||||
|
- Kein Umbau: `Search`/`Delete`/`Index`-Verhalten aus SRC-01/SRC-03
|
||||||
|
unverändert, `mail/internal/dedup`/`indexworker`/`storage`/`crypto`/
|
||||||
|
`encstorage` unverändert.
|
||||||
|
|
||||||
|
## Prüfungen
|
||||||
|
|
||||||
|
| # | Prüfung | Ergebnis |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Test: Facettenzahlen stimmen mit tatsächlicher Treffermenge überein | **bestanden** – `TestFacets_CountsMatchActualHits`: 3 reale Dokumente indexiert, Facette `sender` liefert real `alice@example.com`→2, `bob@example.com`→1, Facette `attachment_type` liefert real `pdf`→2 |
|
||||||
|
| 2 | Test: Kombination von drei Filtern liefert korrekt eingeschränkte Treffer | **bestanden** – `TestFacets_ThreeFiltersCombineWithAND`: 4 Dokumente, von denen 3 je genau einen der drei Filter (Sender/Postfach/Anhangstyp) verletzen — nach Kombination aller drei Filter bleibt real genau 1 Treffer übrig |
|
||||||
|
| 3 | Test: Facetten eines Mandanten enthalten keine Werte eines anderen | **bestanden** – `TestFacets_TenantSeparation`: identische Feldstruktur bei zwei Mandanten, Facette bei Mandant B enthält real keinen Wert von Mandant A |
|
||||||
|
|
||||||
|
## Build/Test-Ergebnis (192.168.1.131)
|
||||||
|
|
||||||
|
```
|
||||||
|
go build ./... -> clean
|
||||||
|
go vet ./... -> clean
|
||||||
|
golangci-lint run ./... -> 0 issues
|
||||||
|
TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \
|
||||||
|
TEST_MANTICORE_URL=http://127.0.0.1:9308 \
|
||||||
|
go test ./... -p 1 -> alle Pakete bestanden, inkl. internal/search (10 Tests,
|
||||||
|
keine Regression in dedup/indexworker/storage/encstorage/example/mimeparse/pflichttestgate)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gesamtergebnis
|
||||||
|
|
||||||
|
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
|
||||||
|
real erfüllt. Entsperrt SRC-06, trägt (gemeinsam mit ARC-08, SRC-02,
|
||||||
|
SRC-04, SRC-08, SRC-09, SRC-10) zu QA-03 bei — QA-03 bleibt weiterhin
|
||||||
|
blockiert, bis auch die übrigen vier Tickets fertig sind.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# SRC-09 – Prüfprotokoll: Suchindex-Neuaufbau/Reindexierung
|
||||||
|
|
||||||
|
Voraussetzung SRC-01 (Fertig).
|
||||||
|
|
||||||
|
## Umsetzung
|
||||||
|
|
||||||
|
- `mail/internal/search/reindex.go` — `Reindexer.Rebuild(ctx, onProgress)`:
|
||||||
|
1. legt eine neue physische Manticore-Tabelle an (Name aus striktem
|
||||||
|
Muster `mail_documents_reindex_<Ziffern>`, per Regex validiert —
|
||||||
|
Verteidigung in der Tiefe, obwohl der Wert ausschließlich
|
||||||
|
paketintern erzeugt wird),
|
||||||
|
2. kopiert alle Dokumente aus der lebenden Tabelle seitenweise
|
||||||
|
(Cursor-Paginierung über `id`, strukturierte JSON-API, kein
|
||||||
|
dynamischer SQL-Klauselbau) — die lebende Tabelle wird dabei nur
|
||||||
|
gelesen, nie verändert (Akzeptanzkriterium 1),
|
||||||
|
3. meldet Fortschritt über einen `onProgress`-Callback
|
||||||
|
(Akzeptanzkriterium 2),
|
||||||
|
4. vergleicht Trefferzahlen alt/neu — bei Abweichung kein Umschalten,
|
||||||
|
5. schaltet erst danach per Manticore `ALTER TABLE ... RENAME`
|
||||||
|
(reine Metadaten-Operation) atomar um. Schlägt ein Schritt vor dem
|
||||||
|
Umschalten fehl, wird die Zwischentabelle entfernt, die lebende
|
||||||
|
Tabelle bleibt unverändert (Akzeptanzkriterium 3 / Pflichtprüfung 2).
|
||||||
|
- Echtes Manticore-Verhalten entdeckt und behandelt: frisch eingefügte
|
||||||
|
Dokumente einer neu angelegten RT-Tabelle sind für `match_all`-Zählungen
|
||||||
|
erst nach explizitem `FLUSH RAMCHUNK` zuverlässig sichtbar (SQL-`SELECT`
|
||||||
|
sah sie sofort, `/search`-Zählung zeigte 0) — vor der
|
||||||
|
Konsistenzprüfung eingebaut.
|
||||||
|
- Echte Plattformgrenze gefunden und abgefangen: Manticore unterstützt kein
|
||||||
|
atomares Mehrfach-`RENAME` in einer Anweisung — zwischen den zwei
|
||||||
|
nötigen Einzel-`RENAME`s existiert ein Sub-Millisekunden-Fenster ohne
|
||||||
|
`mail_documents`-Tabelle. `Client.Search` bekam dafür einen begrenzten
|
||||||
|
Retry (bis zu 2 Wiederholungen, 20ms Pause) speziell auf den
|
||||||
|
Manticore-Fehler `"unknown local table"` — real durch eine parallele
|
||||||
|
Suchlast während des Umschaltens nachgewiesen (Pflichtprüfung 1).
|
||||||
|
- Nebenbei einen echten, latenten Fehler in `Search` gefunden und behoben:
|
||||||
|
ohne explizites `limit` begrenzte Manticore Ergebnisse standardmäßig auf
|
||||||
|
20 Treffer — unbemerkt, weil bisherige Tests (SRC-01/03/05) nur auf das
|
||||||
|
Vorhandensein einzelner Treffer prüften, nie auf die Gesamtzahl. Jetzt
|
||||||
|
`searchResultLimit = 1000`.
|
||||||
|
- Kein Umbau: `Index`/`Delete`/`Facets`-Verhalten sonst unverändert,
|
||||||
|
`mail/internal/dedup`/`indexworker`/`storage`/`crypto`/`encstorage`
|
||||||
|
unverändert.
|
||||||
|
|
||||||
|
## Prüfungen
|
||||||
|
|
||||||
|
| # | Prüfung | Ergebnis |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Test: Reindex während laufender Suchanfragen unterbricht die Suche nicht | **bestanden** – `TestRebuild_SearchKeepsWorkingDuringReindex`: 30 reale Dokumente indexiert, parallele Sucher-Goroutine (alle 2ms) läuft während `Rebuild` mit — 0 fehlgeschlagene Suchen über den gesamten Umschaltvorgang, danach weiterhin real alle 30 Treffer auffindbar |
|
||||||
|
| 2 | Test: abgebrochener Reindex hinterlässt keinen inkonsistenten Zustand | **bestanden** – `TestRebuild_AbortedReindexLeavesNoInconsistentState`: Kontext vor `Rebuild` abgebrochen, Fehler kommt real zurück, lebende Tabelle bleibt danach unverändert (weiterhin 1 Treffer real auffindbar), keine verwaisten Zwischentabellen über `SHOW TABLES` real bestätigt |
|
||||||
|
| 3 | Stichprobenvergleich Alt-/Neuindex bestätigt gleiche Trefferzahlen | **bestanden** – `TestRebuild_SampleComparisonMatchesOldAndNewIndex`: 3 unterschiedliche Suchbegriffe vor und nach Reindex real verglichen, identische Trefferzahlen je Stichprobe |
|
||||||
|
|
||||||
|
Zusätzlich (Akzeptanzkriterium 2): `TestRebuild_ReportsProgress` bestätigt
|
||||||
|
reale Fortschrittsmeldungen bis zum vollständigen Abschluss.
|
||||||
|
|
||||||
|
## Build/Test-Ergebnis (192.168.1.131)
|
||||||
|
|
||||||
|
```
|
||||||
|
go build ./... -> clean
|
||||||
|
go vet ./... -> clean
|
||||||
|
golangci-lint run ./... -> 0 issues
|
||||||
|
TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \
|
||||||
|
TEST_MANTICORE_URL=http://127.0.0.1:9308 \
|
||||||
|
go test ./... -v -p 1 -> alle Pakete bestanden, inkl. internal/search (14 Tests,
|
||||||
|
keine Regression in dedup/indexworker/storage/encstorage/example/mimeparse/pflichttestgate)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gesamtergebnis
|
||||||
|
|
||||||
|
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
|
||||||
|
real erfüllt. Trägt (gemeinsam mit ARC-08, SRC-02, SRC-04, SRC-05,
|
||||||
|
SRC-08, SRC-10) zu QA-03 bei — QA-03 bleibt weiterhin blockiert, bis auch
|
||||||
|
ARC-08, SRC-08 und SRC-10 fertig sind.
|
||||||
+112
-22
@@ -22,6 +22,29 @@ import (
|
|||||||
//go:embed migrations/0001_mail_documents.sql
|
//go:embed migrations/0001_mail_documents.sql
|
||||||
var schemaMigration string
|
var schemaMigration string
|
||||||
|
|
||||||
|
// SRC-05: Facettenfelder als eigene, nummerierte ALTER-Migrationen
|
||||||
|
// nachgezogen (Manticore erlaubt nur eine Spalte je ALTER TABLE ADD
|
||||||
|
// COLUMN-Anweisung). Reihenfolge ist die Anwendungsreihenfolge.
|
||||||
|
//
|
||||||
|
//go:embed migrations/0002_mail_documents_facets.sql
|
||||||
|
var migrationAddSender string
|
||||||
|
|
||||||
|
//go:embed migrations/0003_mail_documents_mailbox.sql
|
||||||
|
var migrationAddMailbox string
|
||||||
|
|
||||||
|
//go:embed migrations/0004_mail_documents_attachment_type.sql
|
||||||
|
var migrationAddAttachmentType string
|
||||||
|
|
||||||
|
//go:embed migrations/0005_mail_documents_tag.sql
|
||||||
|
var migrationAddTag string
|
||||||
|
|
||||||
|
var facetMigrations = []string{
|
||||||
|
migrationAddSender,
|
||||||
|
migrationAddMailbox,
|
||||||
|
migrationAddAttachmentType,
|
||||||
|
migrationAddTag,
|
||||||
|
}
|
||||||
|
|
||||||
// Client spricht ausschließlich über die strukturierte Manticore-HTTP-
|
// Client spricht ausschließlich über die strukturierte Manticore-HTTP-
|
||||||
// JSON-API (kein String-Zusammenbau von SQL-Klauseln, siehe fields.go).
|
// JSON-API (kein String-Zusammenbau von SQL-Klauseln, siehe fields.go).
|
||||||
// Die SQL-Schnittstelle wird nur für EnsureSchema verwendet, und dort
|
// Die SQL-Schnittstelle wird nur für EnsureSchema verwendet, und dort
|
||||||
@@ -40,24 +63,46 @@ func NewClient(baseURL string) *Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// EnsureSchema legt den Index gemäß dem versionierten, statischen
|
// EnsureSchema legt den Index gemäß dem versionierten, statischen
|
||||||
// Migrationstext an (Akzeptanzkriterium 1). Idempotent (CREATE TABLE
|
// Migrationstext an (Akzeptanzkriterium 1) und zieht die Facettenfelder
|
||||||
// IF NOT EXISTS im Migrationstext).
|
// (SRC-05) idempotent nach.
|
||||||
func (c *Client) EnsureSchema(ctx context.Context) error {
|
func (c *Client) EnsureSchema(ctx context.Context) error {
|
||||||
form := "query=" + schemaMigration
|
if err := c.runSchemaSQL(ctx, schemaMigration); err != nil {
|
||||||
|
return fmt.Errorf("search: schema anlegen: %w", err)
|
||||||
|
}
|
||||||
|
for _, migration := range facetMigrations {
|
||||||
|
if err := c.runSchemaSQL(ctx, migration); err != nil {
|
||||||
|
// Manticore meldet bei erneutem ADD COLUMN "field already in
|
||||||
|
// schema" — kein Fehler, sondern der bereits angewendete
|
||||||
|
// Migrationsschritt (Idempotenz, gleiche CREATE-TABLE-IF-NOT-
|
||||||
|
// EXISTS-Konvention wie das Basisschema).
|
||||||
|
if strings.Contains(err.Error(), "already in schema") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("search: facettenfeld-migration: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) runSchemaSQL(ctx context.Context, query string) error {
|
||||||
|
form := "query=" + query
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/sql?mode=raw", strings.NewReader(form))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/sql?mode=raw", strings.NewReader(form))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("search: schema-anfrage bauen: %w", err)
|
return fmt.Errorf("anfrage bauen: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("search: schema anlegen: %w", err)
|
return fmt.Errorf("ausführen: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("search: schema anlegen, status %d: %s", resp.StatusCode, string(body))
|
return fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
if strings.Contains(string(body), `"error":"`) && !strings.Contains(string(body), `"error":""`) {
|
||||||
|
return fmt.Errorf("%s", string(body))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -72,6 +117,11 @@ type Document struct {
|
|||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
AttachmentText string `json:"attachment_text"`
|
AttachmentText string `json:"attachment_text"`
|
||||||
SentAtUnixEpoch int64 `json:"sent_at"`
|
SentAtUnixEpoch int64 `json:"sent_at"`
|
||||||
|
// Facettenfelder (SRC-05), optional — leerer String bedeutet "kein Wert".
|
||||||
|
Sender string `json:"sender"`
|
||||||
|
Mailbox string `json:"mailbox"`
|
||||||
|
AttachmentType string `json:"attachment_type"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index legt/ersetzt ein Suchdokument (Akzeptanzkriterium 2: Schreibzugriff
|
// Index legt/ersetzt ein Suchdokument (Akzeptanzkriterium 2: Schreibzugriff
|
||||||
@@ -154,6 +204,52 @@ var fieldWeights = map[string]any{
|
|||||||
FieldAttachmentText: 1,
|
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
|
// Search sucht queryText innerhalb der Volltextfelder, strikt begrenzt auf
|
||||||
// den Mandanten tenantSlug (Akzeptanzkriterium 2: mandantengetrennt
|
// den Mandanten tenantSlug (Akzeptanzkriterium 2: mandantengetrennt
|
||||||
// abfragbar) — der Tenant-Filter läuft über ein strukturiertes "equals"-
|
// abfragbar) — der Tenant-Filter läuft über ein strukturiertes "equals"-
|
||||||
@@ -166,6 +262,8 @@ var fieldWeights = map[string]any{
|
|||||||
// Feld-/Tabellennamen, der beeinflusst werden könnte. Ergebnisse kommen
|
// Feld-/Tabellennamen, der beeinflusst werden könnte. Ergebnisse kommen
|
||||||
// von Manticore bereits nach Relevanz (BM25, gewichtet über fieldWeights)
|
// von Manticore bereits nach Relevanz (BM25, gewichtet über fieldWeights)
|
||||||
// absteigend sortiert zurück (Akzeptanzkriterium 1).
|
// absteigend sortiert zurück (Akzeptanzkriterium 1).
|
||||||
|
const searchResultLimit = 1000
|
||||||
|
|
||||||
func (c *Client) Search(ctx context.Context, tenantSlug, queryText string) ([]Result, error) {
|
func (c *Client) Search(ctx context.Context, tenantSlug, queryText string) ([]Result, error) {
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"index": IndexName,
|
"index": IndexName,
|
||||||
@@ -180,29 +278,21 @@ func (c *Client) Search(ctx context.Context, tenantSlug, queryText string) ([]Re
|
|||||||
"options": map[string]any{
|
"options": map[string]any{
|
||||||
"field_weights": fieldWeights,
|
"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)
|
body, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("search: suchanfrage serialisieren: %w", err)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("search: suchanfrage bauen: %w", err)
|
return nil, 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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var parsed searchResponse
|
var parsed searchResponse
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// SRC-05: Facetten- & Filter-API. Nutzt Manticores strukturierte
|
||||||
|
// Aggregations-API (aggs.terms/aggs.range) — keine dynamische
|
||||||
|
// SQL-Klauselbildung, dieselbe Konvention wie Search/Delete (fields.go).
|
||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FacetFilter schränkt Suche/Facettenberechnung auf einen bereits
|
||||||
|
// gewählten Facettenwert ein. Field MUSS aus FacetFields stammen —
|
||||||
|
// Facets liefert einen Fehler bei jedem anderen Wert (verhindert einen
|
||||||
|
// beliebigen, vom Aufrufer bestimmten Feldnamen in der Anfrage).
|
||||||
|
type FacetFilter struct {
|
||||||
|
Field string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FacetValue ist ein einzelner Facettenwert mit Trefferzahl
|
||||||
|
// (Akzeptanzkriterium 1).
|
||||||
|
type FacetValue struct {
|
||||||
|
Value string
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// DateRangeFacet ist ein Zeitraum-Bucket mit Trefferzahl.
|
||||||
|
type DateRangeFacet struct {
|
||||||
|
Label string
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// FacetResult fasst alle Facettendimensionen einer Anfrage zusammen.
|
||||||
|
type FacetResult struct {
|
||||||
|
// Values ist je FacetFields-Eintrag (sender/mailbox/attachment_type/tag)
|
||||||
|
// befüllt.
|
||||||
|
Values map[string][]FacetValue
|
||||||
|
// DateRanges sind feste Zeitraum-Buckets über FieldSentAt.
|
||||||
|
DateRanges []DateRangeFacet
|
||||||
|
}
|
||||||
|
|
||||||
|
// farFuture ist die obere Grenze des jüngsten Zeitraum-Buckets. Manticores
|
||||||
|
// range-Aggregation verlangt für jeden Bucket ein explizites "to" — ein
|
||||||
|
// hinreichend großer fester Wert (Jahr 2100) übernimmt die Rolle von
|
||||||
|
// "unbegrenzt in die Zukunft", ohne den Feldtyp zu wechseln.
|
||||||
|
const farFuture int64 = 4102444800
|
||||||
|
|
||||||
|
type dateRangeBoundary struct {
|
||||||
|
label string
|
||||||
|
from int64 // 0 = ab Epoch (unbegrenzt in die Vergangenheit)
|
||||||
|
to int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// dateRangeBoundaries berechnet die festen Zeitraum-Buckets relativ zu now
|
||||||
|
// (Parameter statt time.Now() direkt, damit Facets testbar bleibt).
|
||||||
|
func dateRangeBoundaries(now time.Time) []dateRangeBoundary {
|
||||||
|
sevenDaysAgo := now.AddDate(0, 0, -7).Unix()
|
||||||
|
thirtyDaysAgo := now.AddDate(0, 0, -30).Unix()
|
||||||
|
oneYearAgo := now.AddDate(-1, 0, 0).Unix()
|
||||||
|
return []dateRangeBoundary{
|
||||||
|
{label: "letzte_7_tage", from: sevenDaysAgo, to: farFuture},
|
||||||
|
{label: "letzte_30_tage", from: thirtyDaysAgo, to: sevenDaysAgo},
|
||||||
|
{label: "letztes_jahr", from: oneYearAgo, to: thirtyDaysAgo},
|
||||||
|
{label: "aelter", from: 0, to: oneYearAgo},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFacetField(field string) bool {
|
||||||
|
for _, f := range FacetFields {
|
||||||
|
if f == field {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Facets berechnet für jede Filterdimension (Akzeptanzkriterium 1) die
|
||||||
|
// Trefferzahl je Wert, mandantengetrennt (Akzeptanzkriterium 3) und unter
|
||||||
|
// Berücksichtigung bereits gewählter Filter (Akzeptanzkriterium 2: mehrere
|
||||||
|
// Filter kombinieren sich als UND-Verknüpfung in derselben bool.must-Liste
|
||||||
|
// wie der Tenant-Filter).
|
||||||
|
func (c *Client) Facets(ctx context.Context, tenantSlug, queryText string, filters []FacetFilter) (FacetResult, error) {
|
||||||
|
must := []map[string]any{
|
||||||
|
{"equals": map[string]any{FieldTenantSlug: tenantSlug}},
|
||||||
|
}
|
||||||
|
if queryText != "" {
|
||||||
|
must = append(must, map[string]any{"query_string": queryText})
|
||||||
|
}
|
||||||
|
for _, f := range filters {
|
||||||
|
if !isFacetField(f.Field) {
|
||||||
|
return FacetResult{}, fmt.Errorf("search: unbekanntes facettenfeld %q", f.Field)
|
||||||
|
}
|
||||||
|
must = append(must, map[string]any{"equals": map[string]any{f.Field: f.Value}})
|
||||||
|
}
|
||||||
|
|
||||||
|
aggs := map[string]any{}
|
||||||
|
for _, field := range FacetFields {
|
||||||
|
aggs[field] = map[string]any{"terms": map[string]any{"field": field, "size": 100}}
|
||||||
|
}
|
||||||
|
boundaries := dateRangeBoundaries(time.Now())
|
||||||
|
ranges := make([]map[string]any, 0, len(boundaries))
|
||||||
|
for _, b := range boundaries {
|
||||||
|
ranges = append(ranges, map[string]any{"from": b.from, "to": b.to})
|
||||||
|
}
|
||||||
|
aggs["sent_at"] = map[string]any{"range": map[string]any{"field": FieldSentAt, "ranges": ranges}}
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"index": IndexName,
|
||||||
|
"query": map[string]any{"bool": map[string]any{"must": must}},
|
||||||
|
"aggs": aggs,
|
||||||
|
"limit": 0,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return FacetResult{}, fmt.Errorf("search: facettenanfrage serialisieren: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/search", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return FacetResult{}, fmt.Errorf("search: facettenanfrage bauen: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return FacetResult{}, fmt.Errorf("search: facetten abrufen: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return FacetResult{}, fmt.Errorf("search: facetten-antwort lesen: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return FacetResult{}, fmt.Errorf("search: facetten, status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed facetResponse
|
||||||
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||||
|
return FacetResult{}, fmt.Errorf("search: facetten-antwort parsen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := FacetResult{Values: make(map[string][]FacetValue, len(FacetFields))}
|
||||||
|
for _, field := range FacetFields {
|
||||||
|
bucket := parsed.Aggregations[field]
|
||||||
|
values := make([]FacetValue, 0, len(bucket.Buckets))
|
||||||
|
for _, b := range bucket.Buckets {
|
||||||
|
if b.Key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values = append(values, FacetValue{Value: b.Key, Count: b.DocCount})
|
||||||
|
}
|
||||||
|
result.Values[field] = values
|
||||||
|
}
|
||||||
|
|
||||||
|
sentAtBucket := parsed.Aggregations["sent_at"]
|
||||||
|
result.DateRanges = make([]DateRangeFacet, 0, len(boundaries))
|
||||||
|
for i, b := range boundaries {
|
||||||
|
count := int64(0)
|
||||||
|
if i < len(sentAtBucket.Buckets) {
|
||||||
|
count = sentAtBucket.Buckets[i].DocCount
|
||||||
|
}
|
||||||
|
result.DateRanges = append(result.DateRanges, DateRangeFacet{Label: b.label, Count: count})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type facetResponse struct {
|
||||||
|
Aggregations map[string]struct {
|
||||||
|
Buckets []struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
DocCount int64 `json:"doc_count"`
|
||||||
|
} `json:"buckets"`
|
||||||
|
} `json:"aggregations"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Integrationstest (SRC-05): echte Manticore-Instanz, TEST_MANTICORE_URL
|
||||||
|
// (gleiche Konvention wie integration_test.go/ranking_test.go).
|
||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func indexFacetDoc(t *testing.T, client *Client, ctx context.Context, tenant string, doc Document) {
|
||||||
|
t.Helper()
|
||||||
|
doc.TenantSlug = tenant
|
||||||
|
doc.ID = DocumentID(tenant, doc.MessageID)
|
||||||
|
if err := client.Index(ctx, doc); err != nil {
|
||||||
|
t.Fatalf("index %s: %v", doc.MessageID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFacets_CountsMatchActualHits ist die geforderte Pflichtprüfung 1:
|
||||||
|
// Facettenzahlen stimmen mit tatsächlicher Treffermenge überein.
|
||||||
|
func TestFacets_CountsMatchActualHits(t *testing.T) {
|
||||||
|
client := setupClient(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tenant := "mandant-src05-zahlen"
|
||||||
|
|
||||||
|
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-fz-1", Subject: "a", Sender: "alice@example.com", Mailbox: "inbox", AttachmentType: "pdf"})
|
||||||
|
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-fz-2", Subject: "b", Sender: "alice@example.com", Mailbox: "inbox", AttachmentType: "docx"})
|
||||||
|
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-fz-3", Subject: "c", Sender: "bob@example.com", Mailbox: "archiv", AttachmentType: "pdf"})
|
||||||
|
|
||||||
|
result, err := client.Facets(ctx, tenant, "", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("facets: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
senderCounts := toCountMap(result.Values[FieldSender])
|
||||||
|
if senderCounts["alice@example.com"] != 2 {
|
||||||
|
t.Fatalf("erwartete 2 treffer für alice@example.com, habe %d (%+v)", senderCounts["alice@example.com"], result.Values[FieldSender])
|
||||||
|
}
|
||||||
|
if senderCounts["bob@example.com"] != 1 {
|
||||||
|
t.Fatalf("erwartete 1 treffer für bob@example.com, habe %d", senderCounts["bob@example.com"])
|
||||||
|
}
|
||||||
|
|
||||||
|
attachmentCounts := toCountMap(result.Values[FieldAttachmentType])
|
||||||
|
if attachmentCounts["pdf"] != 2 {
|
||||||
|
t.Fatalf("erwartete 2 treffer für pdf, habe %d", attachmentCounts["pdf"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFacets_ThreeFiltersCombineWithAND ist die geforderte Pflichtprüfung
|
||||||
|
// 2: Kombination von drei Filtern liefert korrekt eingeschränkte Treffer.
|
||||||
|
func TestFacets_ThreeFiltersCombineWithAND(t *testing.T) {
|
||||||
|
client := setupClient(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tenant := "mandant-src05-kombi"
|
||||||
|
|
||||||
|
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-kombi-treffer", Subject: "x", Sender: "alice@example.com", Mailbox: "inbox", AttachmentType: "pdf", Tag: "wichtig"})
|
||||||
|
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-kombi-falscher-sender", Subject: "x", Sender: "bob@example.com", Mailbox: "inbox", AttachmentType: "pdf", Tag: "wichtig"})
|
||||||
|
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-kombi-falsche-mailbox", Subject: "x", Sender: "alice@example.com", Mailbox: "archiv", AttachmentType: "pdf", Tag: "wichtig"})
|
||||||
|
indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-kombi-falscher-typ", Subject: "x", Sender: "alice@example.com", Mailbox: "inbox", AttachmentType: "docx", Tag: "wichtig"})
|
||||||
|
|
||||||
|
filters := []FacetFilter{
|
||||||
|
{Field: FieldSender, Value: "alice@example.com"},
|
||||||
|
{Field: FieldMailbox, Value: "inbox"},
|
||||||
|
{Field: FieldAttachmentType, Value: "pdf"},
|
||||||
|
}
|
||||||
|
result, err := client.Facets(ctx, tenant, "", filters)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("facets: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tagCounts := toCountMap(result.Values[FieldTag])
|
||||||
|
if tagCounts["wichtig"] != 1 {
|
||||||
|
t.Fatalf("erwartete genau 1 verbleibenden treffer nach 3 UND-verknüpften filtern, habe %d (%+v)", tagCounts["wichtig"], result.Values[FieldTag])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFacets_TenantSeparation ist die geforderte Pflichtprüfung 3:
|
||||||
|
// Facetten eines Mandanten enthalten keine Werte eines anderen.
|
||||||
|
func TestFacets_TenantSeparation(t *testing.T) {
|
||||||
|
client := setupClient(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
tenantA := "mandant-src05-facetten-a"
|
||||||
|
tenantB := "mandant-src05-facetten-b"
|
||||||
|
|
||||||
|
indexFacetDoc(t, client, ctx, tenantA, Document{MessageID: "msg-fa-1", Subject: "a", Sender: "nur-a@example.com", Mailbox: "inbox", AttachmentType: "pdf"})
|
||||||
|
indexFacetDoc(t, client, ctx, tenantB, Document{MessageID: "msg-fb-1", Subject: "b", Sender: "nur-b@example.com", Mailbox: "inbox", AttachmentType: "pdf"})
|
||||||
|
|
||||||
|
resultB, err := client.Facets(ctx, tenantB, "", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("facets mandant b: %v", err)
|
||||||
|
}
|
||||||
|
senderCountsB := toCountMap(resultB.Values[FieldSender])
|
||||||
|
if _, present := senderCountsB["nur-a@example.com"]; present {
|
||||||
|
t.Fatalf("mandant b sieht facettenwert von mandant a: %+v", resultB.Values[FieldSender])
|
||||||
|
}
|
||||||
|
if senderCountsB["nur-b@example.com"] != 1 {
|
||||||
|
t.Fatalf("erwartete eigenen facettenwert bei mandant b, habe: %+v", resultB.Values[FieldSender])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toCountMap(values []FacetValue) map[string]int64 {
|
||||||
|
m := make(map[string]int64, len(values))
|
||||||
|
for _, v := range values {
|
||||||
|
m[v.Value] = v.Count
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
@@ -24,8 +24,20 @@ const (
|
|||||||
FieldBody = "body"
|
FieldBody = "body"
|
||||||
FieldAttachmentText = "attachment_text"
|
FieldAttachmentText = "attachment_text"
|
||||||
FieldSentAt = "sent_at"
|
FieldSentAt = "sent_at"
|
||||||
|
// Facettenfelder (SRC-05), nachgezogen über migrations/0002..0005.
|
||||||
|
FieldSender = "sender"
|
||||||
|
FieldMailbox = "mailbox"
|
||||||
|
FieldAttachmentType = "attachment_type"
|
||||||
|
FieldTag = "tag"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// FacetFields sind die je Kachel unterstützten Filterdimensionen
|
||||||
|
// (Akzeptanzkriterium 1: Absender, Postfach, Anhangstyp, Tag — Zeitraum
|
||||||
|
// läuft separat über FieldSentAt als Bereichsfacette, siehe facets.go).
|
||||||
|
// Statische Liste — Aufrufer können ausschließlich diese Feldnamen als
|
||||||
|
// Facetten-/Filterdimension angeben, kein beliebiger Client-Feldname.
|
||||||
|
var FacetFields = []string{FieldSender, FieldMailbox, FieldAttachmentType, FieldTag}
|
||||||
|
|
||||||
// DocumentID berechnet deterministisch die Manticore-Dokument-ID aus
|
// DocumentID berechnet deterministisch die Manticore-Dokument-ID aus
|
||||||
// Mandant und Message-ID (FNV-1a, 64 Bit). Deterministisch statt einer
|
// Mandant und Message-ID (FNV-1a, 64 Bit). Deterministisch statt einer
|
||||||
// separat vergebenen ID, damit Re-Indexierung (Index) und Löschung
|
// separat vergebenen ID, damit Re-Indexierung (Index) und Löschung
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE mail_documents ADD COLUMN sender string attribute indexed
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE mail_documents ADD COLUMN mailbox string attribute indexed
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE mail_documents ADD COLUMN attachment_type string attribute indexed
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE mail_documents ADD COLUMN tag string attribute indexed
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
// SRC-09: Suchindex-Neuaufbau/Reindexierung. Baut eine neue physische
|
||||||
|
// Manticore-Tabelle auf, kopiert alle Dokumente aus der aktuell lebenden
|
||||||
|
// Tabelle (Konsistenzwiederherstellung), verifiziert die Trefferzahl und
|
||||||
|
// tauscht erst danach per Manticore RENAME atomar um — die alte Tabelle
|
||||||
|
// bleibt bis zu diesem Moment vollständig abfragbar (Akzeptanzkriterium 3),
|
||||||
|
// RENAME ist eine reine Metadaten-Operation ohne Suchausfall.
|
||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tempTableNamePattern begrenzt generierte Zwischentabellennamen auf ein
|
||||||
|
// festes Präfix + Ziffern — auch wenn der Name ausschließlich von diesem
|
||||||
|
// Paket selbst erzeugt wird (kein externer Eingabewert erreicht ihn),
|
||||||
|
// erzwingt die Prüfung strukturell, dass niemals ein beliebiger String an
|
||||||
|
// dieser Stelle landen kann (Verteidigung in der Tiefe, gleiche Haltung
|
||||||
|
// wie das Feld-Whitelist-Prinzip in fields.go).
|
||||||
|
var tempTableNamePattern = regexp.MustCompile(`^mail_documents_reindex_[0-9]+$`)
|
||||||
|
|
||||||
|
// Progress meldet den Fortschritt eines laufenden Reindex
|
||||||
|
// (Akzeptanzkriterium 2: Fortschritt nachvollziehbar sichtbar).
|
||||||
|
type Progress struct {
|
||||||
|
Copied int64
|
||||||
|
Total int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reindexer baut den Suchindex vollständig neu auf.
|
||||||
|
type Reindexer struct {
|
||||||
|
client *Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReindexer(client *Client) *Reindexer {
|
||||||
|
return &Reindexer{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RebuildResult fasst das Ergebnis eines abgeschlossenen Reindex zusammen.
|
||||||
|
type RebuildResult struct {
|
||||||
|
OldCount int64
|
||||||
|
NewCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild baut den Index vollständig neu auf: neue Tabelle anlegen, alle
|
||||||
|
// Dokumente aus der aktuell lebenden Tabelle seitenweise kopieren
|
||||||
|
// (Akzeptanzkriterium 1: kein Datenverlust im laufenden Betrieb — die
|
||||||
|
// lebende Tabelle wird dabei nur gelesen, nie verändert), Trefferzahlen
|
||||||
|
// vergleichen, dann atomar per RENAME umschalten. Schlägt ein Schritt vor
|
||||||
|
// dem Umschalten fehl (z. B. abgebrochener Kontext), wird die
|
||||||
|
// Zwischentabelle entfernt und die lebende Tabelle bleibt unverändert
|
||||||
|
// (Akzeptanzkriterium 3 / Pflichtprüfung 2: kein inkonsistenter Zustand).
|
||||||
|
func (r *Reindexer) Rebuild(ctx context.Context, onProgress func(Progress)) (RebuildResult, error) {
|
||||||
|
tempTable := fmt.Sprintf("mail_documents_reindex_%d", time.Now().UnixNano())
|
||||||
|
if !tempTableNamePattern.MatchString(tempTable) {
|
||||||
|
return RebuildResult{}, fmt.Errorf("search: erzeugter zwischentabellenname unerwartet ungültig: %q", tempTable)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.client.runSchemaSQL(ctx, buildCreateTableSQL(tempTable)); err != nil {
|
||||||
|
return RebuildResult{}, fmt.Errorf("search: zwischentabelle anlegen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldTotal, err := r.copyAll(ctx, IndexName, tempTable, onProgress)
|
||||||
|
if err != nil {
|
||||||
|
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
|
||||||
|
return RebuildResult{}, fmt.Errorf("search: dokumente kopieren: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manticore macht frisch eingefügte Dokumente einer neu angelegten
|
||||||
|
// RT-Tabelle für Volltext-/match_all-Zählungen erst nach einem
|
||||||
|
// expliziten FLUSH RAMCHUNK zuverlässig sichtbar (beobachtet: SELECT
|
||||||
|
// über SQL sieht die Zeile sofort, /search match_all zählt sie ohne
|
||||||
|
// Flush als 0). Vor der Konsistenzprüfung zwingend nötig.
|
||||||
|
if err := r.client.runSchemaSQL(ctx, "FLUSH RAMCHUNK "+tempTable); err != nil {
|
||||||
|
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
|
||||||
|
return RebuildResult{}, fmt.Errorf("search: zwischentabelle flushen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newTotal, err := r.countAll(ctx, tempTable)
|
||||||
|
if err != nil {
|
||||||
|
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
|
||||||
|
return RebuildResult{}, fmt.Errorf("search: neue tabelle zählen: %w", err)
|
||||||
|
}
|
||||||
|
if newTotal != oldTotal {
|
||||||
|
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
|
||||||
|
return RebuildResult{}, fmt.Errorf("search: trefferzahlen weichen ab (alt %d, neu %d), kein umschalten", oldTotal, newTotal)
|
||||||
|
}
|
||||||
|
|
||||||
|
retiredTable := fmt.Sprintf("mail_documents_retired_%d", time.Now().UnixNano())
|
||||||
|
if err := r.client.runSchemaSQL(ctx, fmt.Sprintf("ALTER TABLE %s RENAME %s", IndexName, retiredTable)); err != nil {
|
||||||
|
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
|
||||||
|
return RebuildResult{}, fmt.Errorf("search: alte tabelle umbenennen: %w", err)
|
||||||
|
}
|
||||||
|
if err := r.client.runSchemaSQL(ctx, fmt.Sprintf("ALTER TABLE %s RENAME %s", tempTable, IndexName)); err != nil {
|
||||||
|
// Kritischer Zustand: alte Tabelle bereits umbenannt, neue kann
|
||||||
|
// nicht einspringen. Umschalten rückgängig machen, statt ohne
|
||||||
|
// abfragbaren Index dazustehen.
|
||||||
|
_ = r.client.runSchemaSQL(context.Background(), fmt.Sprintf("ALTER TABLE %s RENAME %s", retiredTable, IndexName))
|
||||||
|
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+tempTable)
|
||||||
|
return RebuildResult{}, fmt.Errorf("search: neue tabelle aktivieren: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = r.client.runSchemaSQL(context.Background(), "DROP TABLE "+retiredTable)
|
||||||
|
|
||||||
|
return RebuildResult{OldCount: oldTotal, NewCount: newTotal}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyPageSize = 200
|
||||||
|
|
||||||
|
func (r *Reindexer) copyAll(ctx context.Context, sourceIndex, targetIndex string, onProgress func(Progress)) (int64, error) {
|
||||||
|
total, err := r.countAll(ctx, sourceIndex)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cursor uint64
|
||||||
|
var copied int64
|
||||||
|
first := true
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
page, err := r.fetchPage(ctx, sourceIndex, cursor, first)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
if len(page) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for _, doc := range page {
|
||||||
|
if err := r.putRaw(ctx, targetIndex, doc); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
cursor = doc.ID
|
||||||
|
copied++
|
||||||
|
}
|
||||||
|
if onProgress != nil {
|
||||||
|
onProgress(Progress{Copied: copied, Total: total})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return copied, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reindexer) countAll(ctx context.Context, index string) (int64, error) {
|
||||||
|
payload := map[string]any{"index": index, "query": map[string]any{"match_all": map[string]any{}}, "limit": 0}
|
||||||
|
var parsed struct {
|
||||||
|
Hits struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
} `json:"hits"`
|
||||||
|
}
|
||||||
|
if err := r.client.postJSON(ctx, "/search", payload, &parsed); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return parsed.Hits.Total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type scrollHit struct {
|
||||||
|
ID uint64 `json:"_id"`
|
||||||
|
Source json.RawMessage `json:"_source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reindexer) fetchPage(ctx context.Context, index string, afterID uint64, first bool) ([]scrollHit, error) {
|
||||||
|
must := []map[string]any{}
|
||||||
|
if !first {
|
||||||
|
must = append(must, map[string]any{"range": map[string]any{"id": map[string]any{"gt": afterID}}})
|
||||||
|
}
|
||||||
|
query := map[string]any{"match_all": map[string]any{}}
|
||||||
|
if len(must) > 0 {
|
||||||
|
query = map[string]any{"bool": map[string]any{"must": must}}
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"index": index,
|
||||||
|
"query": query,
|
||||||
|
"sort": []map[string]any{{"id": "asc"}},
|
||||||
|
"limit": copyPageSize,
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Hits struct {
|
||||||
|
Hits []scrollHit `json:"hits"`
|
||||||
|
} `json:"hits"`
|
||||||
|
}
|
||||||
|
if err := r.client.postJSON(ctx, "/search", payload, &parsed); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parsed.Hits.Hits, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reindexer) putRaw(ctx context.Context, index string, doc scrollHit) error {
|
||||||
|
payload := map[string]any{
|
||||||
|
"index": index,
|
||||||
|
"id": doc.ID,
|
||||||
|
"doc": json.RawMessage(doc.Source),
|
||||||
|
}
|
||||||
|
return r.client.postJSON(ctx, "/replace", payload, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// postJSON/buildCreateTableSQL sind bewusst hier statt in client.go
|
||||||
|
// angesiedelt: der übrige Suchpfad (Search/Facets) fasst niemals einen
|
||||||
|
// Tabellennamen dynamisch an, Reindex ist die einzige Stelle im Paket, die
|
||||||
|
// das operativ tun muss.
|
||||||
|
func (c *Client) postJSON(ctx context.Context, path string, payload any, out any) error {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("payload serialisieren: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("anfrage bauen: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ausführen: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("antwort lesen: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, out); err != nil {
|
||||||
|
return fmt.Errorf("antwort parsen: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// showTables listet die vorhandenen Manticore-Tabellen (Diagnose-/
|
||||||
|
// Testhilfe, um verwaiste Zwischentabellen nach einem Abbruch
|
||||||
|
// auszuschließen — Pflichtprüfung 2).
|
||||||
|
func (c *Client) showTables(ctx context.Context) ([]string, error) {
|
||||||
|
form := "query=SHOW TABLES"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/sql?mode=raw", bytes.NewReader([]byte(form)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("anfrage bauen: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ausführen: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("antwort lesen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed []struct {
|
||||||
|
Data []struct {
|
||||||
|
Table string `json:"Table"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||||
|
return nil, fmt.Errorf("antwort parsen: %w", err)
|
||||||
|
}
|
||||||
|
names := []string{}
|
||||||
|
if len(parsed) > 0 {
|
||||||
|
for _, row := range parsed[0].Data {
|
||||||
|
names = append(names, row.Table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCreateTableSQL erzeugt die Schema-DDL für eine Zwischentabelle mit
|
||||||
|
// demselben Spaltensatz wie mail_documents (Basis + Facettenfelder aus
|
||||||
|
// SRC-05). tableName ist über tempTableNamePattern in Rebuild bereits
|
||||||
|
// geprüft, bevor diese Funktion aufgerufen wird.
|
||||||
|
func buildCreateTableSQL(tableName string) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"CREATE TABLE %s (%s string attribute indexed, %s string attribute indexed, %s text, %s text, %s text, %s string attribute indexed, %s string attribute indexed, %s string attribute indexed, %s string attribute indexed, %s timestamp)",
|
||||||
|
tableName,
|
||||||
|
FieldTenantSlug, FieldMessageID, FieldSubject, FieldBody, FieldAttachmentText,
|
||||||
|
FieldSender, FieldMailbox, FieldAttachmentType, FieldTag, FieldSentAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user