All checks were successful
Filters are evaluated once into an id set used by both search branches. Adds tests for age/genre/mechanic filters with a search term, rank 2 vs 3 and null p_limit. approxNormalize uses NFKD. Perf script checks confirm_local value and unix socket and re-analyzes after rollback. Adds docs/specs/3-katalog-suche-umsetzung.md (measurements, log check incl. GET finding), release checklist section 2a and the CSV-only note for alt titles. Refs #3 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
72 lines
7.5 KiB
Markdown
72 lines
7.5 KiB
Markdown
# Spec #3 – KAT-01 Spiele suchen: Umsetzungs-Nachtrag PR 3a (Backend)
|
||
|
||
Ergänzt `docs/specs/3-katalog-suche.md` (die Spec selbst wird hier nicht geändert). Stand: 2026-11-04, Branch `feature/3a-katalog-suche-backend`, Migration `20261103235947_catalog_search.sql`, Tests `supabase/tests/database/09_catalog_search.test.sql`.
|
||
|
||
## 1. Abweichungen und Präzisierungen zum Vertrag
|
||
|
||
- `null` für `p_limit`/`p_offset` gilt als Standardwert (30/0). Nur Leerraum (auch Tab, Zeilenumbruch) gilt als kein Suchbegriff.
|
||
- Ein `smallint`-Parameter außerhalb des Typbereichs scheitert beim Umwandeln mit 22003, nicht mit P0001.
|
||
- Die Ähnlichkeit (zweites Sortierkriterium) ist das Maximum über Haupttitel und alle alternativen Titel. Sortierung binär (`collate "C"`), dann `id`.
|
||
- Rang 0 prüft nur `replace(ts,' ','') = qc` (schließt `ts = q` ein).
|
||
- Von mehreren treffenden alternativen Titeln gewinnt die beste Stufe, dann die größte Ähnlichkeit, dann die Schreibweise. Bei gleicher Stufe von Haupt- und alternativem Titel ist `matched_alt_title` null.
|
||
- **Stufe 4 trifft auch ohne Verlag:** Jedes Suchwort muss in einem Titel (Haupt- oder alternativer Titel) oder einem Verlagsnamen des Spiels vorkommen. Verteilen sich die Wörter auf Haupt- und alternativen Titel (kein einzelner Titel enthält alle), ist der Treffer ebenfalls Stufe 4, obwohl kein Verlag beteiligt ist. Die Spec nennt als Stufe-4-Fall nur den Verlagstreffer.
|
||
- Doppelt eingegebene Wörter zählen einfach.
|
||
- **Filter einmal berechnet:** Spielerzahl, Dauer, Alter, Genre und Mechanik werden zu einer ID-Menge ausgewertet, die beide Zweige (mit und ohne Suchbegriff) nutzen.
|
||
- **Messskript unter `supabase/perf/`** statt `supabase/tests/perf/`: `supabase-db-tests.sh` und `supabase test db` laufen rekursiv über `tests/` und würden es mitlaufen lassen.
|
||
- Testplan: `00_schema` 22 Tabellen; `07`/`08` tragen `game_alt_titles.game_id` als "bewusst ausgelassen" ein; `06` bekam die Prüfabfrage-Zeile `Info-Alt` (statt einer zweiten `Info`) und Grants für die Test-Rolle `catalog_importer`.
|
||
- **Für SAM-08:** Die Import-Rolle braucht `execute` auf `public.search_normalize(text)`, `usage` auf das Schema `extensions` und Schreibrechte auf `game_alt_titles`.
|
||
- **Performance-Fehler der ersten Fassung:** korrelierte Unterabfragen je Spiel (Rang 4, Filter) brauchten 13 s bei 10 000 Spielen. Behoben durch Mengenoperationen (`word_hits`/`word_cover`, gehashte ID-Menge, `like all`, Ähnlichkeit nur für Treffer). Stufen 1 und 2 (Kandidaten-CTE, `title_compact`) sind nicht gebaut.
|
||
|
||
## 2. Messung B5 (lokal, Docker auf macOS, Postgres 17.11)
|
||
|
||
Skript `supabase/perf/catalog_search_perf.sql`: 10 000 Katalogspiele, 20 002 alternative Titel, 2 000 Verlage, 12 919 Verlagszuordnungen, je Spiel 2 Genres und 3 Mechaniken; eine Transaktion mit Rollback (danach `vacuum (analyze)`, weil `ANALYZE` nicht zurückrollbar ist); Messung als `anon` mit RLS, `set local role anon`, Generic Plans (ab der 6. Ausführung, wie bei PostgREST-Verbindungen). Das Rollen-`statement_timeout` (3 s) gilt bei `set local role` nicht und wird nicht angewandt; Aufrufe über 3 s wurden gezählt (keiner).
|
||
|
||
### 2.1 Zeiten je Fall (Endstand, n = 100 je Fall, 1400 Aufrufe, Host-Last 1,7 bis 2,2)
|
||
|
||
| Fall | p50 ms | p95 ms | p99 ms | max ms |
|
||
|---|---|---|---|---|
|
||
| 01 Titelteil (4 Zeichen) | 70,4 | 77,4 | 84,8 | 115,8 |
|
||
| 02 zwei Zeichen | 77,8 | 90,7 | 112,6 | 116,6 |
|
||
| 03 ganzes Wort | 70,4 | 75,8 | 78,6 | 82,8 |
|
||
| 04 zwei Wörter | 73,5 | 77,9 | 89,0 | 105,2 |
|
||
| 05 ohne Leerzeichen | 69,4 | 73,4 | 85,2 | 98,4 |
|
||
| 06 Verlag (ein Wort) | 69,8 | 73,1 | 81,9 | 91,8 |
|
||
| 06b Verlag + häufiges Wort | 76,6 | 81,3 | 86,6 | 147,7 |
|
||
| 07 alternativer Titel | 70,1 | 77,9 | 87,2 | 112,4 |
|
||
| 08 Begriff + Filter | 18,6 | 20,5 | 22,6 | 23,4 |
|
||
| 09 nur Filter (Spielerzahl, Genre) | 8,5 | 8,7 | 8,8 | 8,9 |
|
||
| 10 nur alle Filter | 10,7 | 11,3 | 12,8 | 12,8 |
|
||
| 11 ohne alles, erste Seite | 8,9 | 9,4 | 10,4 | 11,4 |
|
||
| 12 ohne alles, Versatz 5000 | 10,7 | 12,3 | 13,3 | 13,3 |
|
||
| 13 ohne Treffer | 66,3 | 72,3 | 93,7 | 101,3 |
|
||
| **alle** | | **78,6** | | 147,7 |
|
||
|
||
Budget p95 ≤ 150 ms: erfüllt. Frühere Läufe derselben Fassung (vor dem einmaligen Filter-Set): ruhiger Host gesamt 110 ms (je Fall 77 bis 131 ms); auf einem stark belasteten Host (Last 8 bis 15) je Textfall 190 bis 325 ms, gesamt 215 ms, also über Budget. Das Ergebnis hängt von der CPU-Verfügbarkeit ab. Die Fixkosten sind der Volltext-Scan über rund 30 000 Titel (Suche ohne Treffer: 66 bis 72 ms). **Hetzner/Staging ist nicht gemessen.**
|
||
|
||
### 2.2 Pläne (`auto_explain`, `analyze`, `buffers`, als `anon` mit RLS)
|
||
|
||
Das Skript gibt für alle 13 Fälle den Plan mit Zeilenzahlen und Buffers als NOTICE aus (`log_timing` aus, weil die Zeitmessung selbst die Zeiten stark verfälscht). Gesamtdauer der Anweisung und Buffers, sechs Fälle:
|
||
|
||
| Fall | Dauer der Anweisung | Treffer (Zeilen) | Buffers (shared hit) | Plan |
|
||
|---|---|---|---|---|
|
||
| 01 Titelteil | 47,7 ms | 30 | 10 102 | Custom Plan |
|
||
| 02 zwei Zeichen | 75,8 ms | 30 | 9 795 | Custom Plan |
|
||
| 06 Verlag | 75,1 ms | 11 | 177 098 | Generic Plan |
|
||
| 08 Begriff + Filter | 21,5 ms | 2 | 26 236 | Generic Plan |
|
||
| 10 alle Filter | 13,3 ms | 30 | 14 437 | Generic Plan |
|
||
| 13 ohne Treffer | 72,2 ms | 0 | 176 884 | Generic Plan |
|
||
|
||
Die Pläne zeigen `Seq Scan` auf `games` (View und Join) und auf `game_alt_titles`; die Trigramm-Indizes werden nicht genutzt (Zweizeichen-Begriffe, Regel ohne Leerzeichen, RLS-Prüfungen für `anon`), wie in Spec 3.2 Nr. 3 vorhergesagt. Die Generic Plans lesen mehr Buffers (RLS-Probe auf `games` je Verlagszeile), bleiben aber in der Zeit gleich.
|
||
|
||
## 3. Protokollprüfung (QUE-04)
|
||
|
||
Lokale Werte: `log_min_duration_statement = -1`, `log_statement = ddl`, `log_parameter_max_length = -1`, `log_parameter_max_length_on_error = 0`, `pgaudit.log = none`, `pgaudit.log_parameter = off`, `auto_explain.log_min_duration = 10 s` mit `auto_explain.log_parameter_max_length = -1`.
|
||
|
||
- **POST über Kong:** Ein Aufruf mit dem Marker `geheimerbegriff9z` (Erfolg und Fehler `search_query_too_long`, `search_paging_invalid`) steht weder im Datenbank- noch im PostgREST-Protokoll. Fehler stehen mit dem Platzhalter `$1` im Protokoll (`STATEMENT: WITH pgrst_source AS (SELECT $1 AS json_data) …`), weil `log_parameter_max_length_on_error = 0`.
|
||
- **GET über Kong (Befund der Review, am 2026-11-04 bestätigt):** `GET /rest/v1/rpc/search_catalog_games?p_query=markergetxyz` liefert 200 und steht im **Zugriffsprotokoll des Gateways (Kong)**; das POST-Gegenstück (`markerpostxyz`) nicht. Datenbank-Log in beiden Fällen ohne Marker. Die App nutzt POST; der GET-Weg muss im Proxy gesperrt werden (Release-Checkliste 2a).
|
||
- **Abhängigkeit:** Suchbegriffe bleiben nur deshalb aus dem `auto_explain`-Log, weil die Rollen-Zeitlimits (3 s, 8 s) kürzer als 10 s sind und abgebrochene Anweisungen keine `auto_explain`-Ausgabe erzeugen. `log_parameter_max_length_on_error`, `auto_explain.log_min_duration` und die Rollen-Zeitlimits müssen zusammen geprüft werden (pgTAP prüft die lokalen Annahmen; Runbook `docs/katalog-pflege.md` 8.2).
|
||
- Hetzner/Staging nicht geprüft.
|
||
|
||
## 4. Mutationsprüfung
|
||
|
||
Mutationen in einer Kopie der Testdatei (Rollback), nicht am echten Schema: 103 von 107 SQL-Mutationen (RPC, `search_normalize`, Rechte, Tabelle, Check, Indizes) erkannt; Überlebende: ID-Tie-Break innen und außen (der Upstream-Plan liefert bereits ID-Reihenfolge), beide `collate "C"`-Mutationen (lokal ICU-Sortierung, für `[a-z0-9 ]` gleich). Fixtures: 4 von 5 (überlebt: Rohtitel-Vergleich, gleicher Endzustand durch die Semantik datenändernder CTEs). Deno-Werkzeug: 17 von 17.
|