Shuffle/supabase/perf/catalog_search_perf.sql
cschlaefke 9fa91eb684
All checks were successful
CI / flutter (pull_request) Successful in 1m19s
CI / web-build (pull_request) Successful in 57s
CI / supabase (pull_request) Successful in 11s
CI / supabase-tests (pull_request) Successful in 38s
CI / e2e (pull_request) Successful in 2m59s
fix(db): single filter set, filter tests with query, perf guards, evidence docs (KAT-01, 3a review)
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>
2026-11-04 10:39:02 +01:00

225 lines
12 KiB
PL/PgSQL

-- Messung der Katalogsuche (KAT-01, Spec #3 Abschnitt 4.2 "Leistung", Aufgabe B5).
-- KEIN pgTAP-Test und liegt bewusst nicht unter supabase/tests/ (supabase test db würde es sonst mitlaufen lassen).
--
-- Was es tut: legt in EINER Transaktion synthetische Daten an (Standard: 10 000 sichtbare Katalogspiele,
-- 20 000 alternative Titel, 2 000 Verlage, je Spiel zwei Genres und drei Mechaniken), führt ANALYZE aus und
-- misst search_catalog_games ALS anon mit Row Level Security. Am Ende steht ROLLBACK: die Daten bleiben nicht
-- zurück. Achtung: ANALYZE ist NICHT rollback-fähig, die Planer-Statistiken (pg_statistic) der Tabellen zeigen
-- danach die synthetischen Daten. Deshalb läuft nach dem ROLLBACK ein vacuum (analyze) der betroffenen Tabellen.
-- 1. Phase A: je typischer Fall ein Aufruf mit auto_explain (Plan mit Zeiten und Buffers kommt als NOTICE).
-- 2. Phase B: je Fall n_iter (Standard 100) Aufrufe mit wechselnden Begriffen, Ausgabe p50/p95/p99/max in ms.
--
-- Nur lokal! Nie gegen Staging oder Produktion (die Daten werden zwar zurückgerollt, die Last und die Sperren
-- sind es nicht). Schutz in drei Stufen: (1) -v confirm_local=yes (der Wert wird geprüft), (2) die Verbindung muss
-- ein Unix-Socket sein (psql-Variable HOST beginnt mit /, also Aufruf per docker exec im lokalen Container; ein
-- Aufruf über TCP/Host gegen einen Server bricht ab), (3) Abbruch, wenn die Datenbank nach echten Daten aussieht
-- (mehr als 20 Konten oder 20 private Spiele).
--
-- Aufruf (lokaler Stack, Docker-Container der Supabase CLI):
-- docker exec -i supabase_db_shuffle psql -U postgres -d postgres -X -v confirm_local=yes -v n_games=10000 \
-- < supabase/perf/catalog_search_perf.sql
-- Das statement_timeout der Rolle anon (lokal 3 s) wirkt bei set local role nicht von allein und gälte hier für
-- den ganzen DO-Block; deshalb zählt die Ausgabe die Aufrufe über 3 s (Spalte ueber_3s).
\set ON_ERROR_STOP on
\if :{?confirm_local}
\else
\set confirm_local no
\endif
select :'confirm_local' = 'yes' as confirm_ok, coalesce(:'HOST', '') like '/%' as socket_ok \gset
\if :confirm_ok
\else
\echo 'Abbruch: mit -v confirm_local=yes aufrufen (nur lokal, siehe Kopfkommentar).'
\quit
\endif
\if :socket_ok
\else
\echo 'Abbruch: nur per Unix-Socket im lokalen Container (docker exec ... psql), nie über TCP gegen einen Server.'
\quit
\endif
\if :{?n_games}
\else
\set n_games 10000
\endif
\if :{?n_iter}
\else
\set n_iter 100
\endif
begin;
do $guard$
begin
if (select count(*) from auth.users) > 20 or (select count(*) from public.games where visibility = 'private') > 20 then
raise exception 'Abbruch: Die Datenbank sieht nach echten Daten aus (mehr als 20 Konten oder private Spiele). Dieses Skript läuft nur lokal.';
end if;
end
$guard$;
select setseed(0.42);
-- ---------------------------------------------------------------------------------------------
-- Synthetische Daten (als postgres, vor dem Rollenwechsel)
-- ---------------------------------------------------------------------------------------------
create schema perf_tmp;
grant usage on schema perf_tmp to anon;
-- ~3000 Pseudo-Wörter mit ungleicher Häufigkeit (häufige Wörter wie in echten Titeln)
create table perf_tmp.words as
select i, translate(substr(md5('w' || i), 1, 4 + (i % 6)), '0123456789', 'ghijklmnop') as w
from generate_series(1, 3000) i;
alter table perf_tmp.words add primary key (i);
create function perf_tmp.pick() returns text language sql volatile as
$$ select w from perf_tmp.words where i = (select 1 + floor(power(random(), 2.2) * 3000)::int) $$;
insert into public.genres (name_de, name_en)
select 'Perf Genre ' || i, 'Perf genre ' || i from generate_series(1, 15) i;
insert into public.mechanics (name_de, name_en)
select 'Perf Mechanik ' || i, 'Perf mechanic ' || i from generate_series(1, 25) i;
insert into public.publishers (name)
select initcap(perf_tmp.pick()) || ' Spiele ' || i from generate_series(1, 2000) i;
insert into public.games (title, visibility, year, min_players, max_players, min_minutes, max_minutes, min_age, description, description_locale)
select initcap(concat_ws(' ', perf_tmp.pick(), case when k >= 2 then perf_tmp.pick() end, case when k >= 3 then perf_tmp.pick() end,
case when k >= 4 then perf_tmp.pick() end)),
'catalog', 1990 + (g % 35), mn, mn + (g % 5), 10 + (g % 12) * 10, 30 + (g % 12) * 10, 6 + (g % 4) * 3,
'Synthetischer Kurztext ' || g, 'de'
from (select g, 1 + floor(random() * 3.99)::int as k, 1 + (g % 4) as mn from generate_series(1, :n_games) g) s;
-- 2 alternative Titel je Spiel (de/en), Duplikate je Sprache entfallen
insert into public.game_alt_titles (game_id, locale, title)
select g.id, l.locale, initcap(perf_tmp.pick() || ' ' || perf_tmp.pick())
from public.games g, (values ('de'), ('en')) as l(locale)
where g.visibility = 'catalog'
on conflict do nothing;
insert into public.game_publishers (game_id, publisher_id)
select g.id, (select id from public.publishers order by random() limit 1) from public.games g where g.visibility = 'catalog' and g.title <> ''
on conflict do nothing;
insert into public.game_publishers (game_id, publisher_id)
select g.id, (select id from public.publishers order by random() limit 1) from public.games g where random() < 0.3
on conflict do nothing;
insert into public.game_genres (game_id, genre_id)
select g.id, t.id from public.games g cross join lateral
(select x.id from public.genres x where x.name_de like 'Perf Genre %' order by md5(g.id::text || x.id::text) limit 2) t on conflict do nothing;
insert into public.game_mechanics (game_id, mechanic_id)
select g.id, t.id from public.games g cross join lateral
(select x.id from public.mechanics x where x.name_de like 'Perf Mechanik %' order by md5(g.id::text || x.id::text) limit 3) t on conflict do nothing;
analyze public.games;
analyze public.game_alt_titles;
analyze public.publishers;
analyze public.game_publishers;
analyze public.game_genres;
analyze public.game_mechanics;
\echo === Datenumfang ===
select (select count(*) from public.catalog_games) as katalogspiele,
(select count(*) from public.game_alt_titles) as alt_titel,
(select count(*) from public.publishers) as verlage,
(select count(*) from public.game_publishers) as verlag_zuordnungen,
pg_size_pretty(pg_relation_size('public.games')) as games_tabelle,
pg_size_pretty(pg_relation_size('public.games_title_search_trgm_idx')) as games_trgm_index,
pg_size_pretty(pg_relation_size('public.game_alt_titles_title_search_trgm_idx')) as alt_trgm_index;
-- ---------------------------------------------------------------------------------------------
-- Abfragen für die Fälle (Begriffe aus den vorhandenen Daten, damit sie Treffer haben)
-- ---------------------------------------------------------------------------------------------
create table perf_tmp.q (
id serial, fall text, term text, players smallint, minutes smallint, age smallint, genre uuid, mech uuid, off int default 0);
grant select on perf_tmp.q to anon;
create table perf_tmp.r (fall text, ms float8, n bigint);
grant insert, select on perf_tmp.r to anon;
with sample as (select g.id, g.title, lower(g.title) as lt, string_to_array(lower(g.title), ' ') as ws
from public.games g order by random() limit :n_iter),
genre as (select id from public.genres where name_de like 'Perf Genre %' order by random() limit 1),
mech as (select id from public.mechanics where name_de like 'Perf Mechanik %' order by random() limit 1)
insert into perf_tmp.q (fall, term, players, minutes, age, genre, mech, off)
select '01 Titelteil (4 Zeichen)', substr(ws[1], 1, 4), null::smallint, null::smallint, null::smallint, null::uuid, null::uuid, 0 from sample
union all select '02 zwei Zeichen', substr(ws[1], 1, 2), null, null, null, null, null, 0 from sample
union all select '03 ganzes Wort', ws[1], null, null, null, null, null, 0 from sample
union all select '04 zwei Wörter', case when cardinality(ws) >= 2 then ws[1] || ' ' || ws[2] else ws[1] end, null, null, null, null, null, 0 from sample
union all select '05 ohne Leerzeichen', case when cardinality(ws) >= 2 then ws[1] || ws[2] else ws[1] end, null, null, null, null, null, 0 from sample
union all select '06 Verlag (ein Wort)', lower(split_part(p.name, ' ', 1)), null, null, null, null, null, 0
from (select name from public.publishers order by random() limit :n_iter) p
union all select '06b Verlag + häufiges Wort', lower(split_part(p.name, ' ', 1)) || ' spiele', null, null, null, null, null, 0
from (select name from public.publishers order by random() limit :n_iter) p
union all select '07 alternativer Titel', split_part(lower(a.title), ' ', 1), null, null, null, null, null, 0
from (select title from public.game_alt_titles order by random() limit :n_iter) a
union all select '08 Begriff + Filter', ws[1], 3, null, null, (select id from genre), null, 0 from sample
union all select '09 nur Filter (Spielerzahl, Genre)', null, 3, null, null, (select id from genre), null, 0 from sample
union all select '10 nur alle Filter', null, 3, 90, 12, (select id from genre), (select id from mech), 0 from sample
union all select '11 ohne alles (erste Seite)', null, null, null, null, null, null, 0 from sample
union all select '12 ohne alles (Versatz 5000)', null, null, null, null, null, null, 5000 from sample
union all select '13 ohne Treffer', 'zzqqxx' || substr(md5(id::text), 1, 3), null, null, null, null, null, 0 from sample;
-- ---------------------------------------------------------------------------------------------
-- Phase A: Pläne (auto_explain, als anon mit RLS). Die Pläne kommen als NOTICE des inneren Statements.
-- ---------------------------------------------------------------------------------------------
\echo === Phase A: explain (analyze, buffers) als anon mit RLS ===
set local auto_explain.log_min_duration = 0;
set local auto_explain.log_analyze = on;
set local auto_explain.log_buffers = on;
set local auto_explain.log_timing = off; -- die Zeitmessung selbst verfälscht die Zeiten stark; Zeiten stehen in Phase B
set local auto_explain.log_nested_statements = on;
set local auto_explain.log_level = 'notice';
set local role anon;
set local request.jwt.claims = '{"role":"anon"}';
do $a$
declare
c record;
begin
for c in select distinct on (fall) fall, term, players, minutes, age, genre, mech, off from perf_tmp.q order by fall, id loop
raise notice '##### %: term=%, players=%, off=%', c.fall, c.term, c.players, c.off;
perform * from public.search_catalog_games(c.term, c.players, c.minutes, c.age,
case when c.genre is null then null else array[c.genre] end, case when c.mech is null then null else array[c.mech] end, 30, c.off);
end loop;
end
$a$;
reset role;
set local auto_explain.log_min_duration = -1;
-- ---------------------------------------------------------------------------------------------
-- Phase B: Zeiten (clock_timestamp um jeden Aufruf), als anon mit RLS
-- ---------------------------------------------------------------------------------------------
\echo === Phase B: Zeiten als anon mit RLS ===
set local role anon;
set local request.jwt.claims = '{"role":"anon"}';
do $b$
declare
c record;
t0 timestamptz;
n bigint;
begin
for c in select * from perf_tmp.q order by id loop
t0 := clock_timestamp();
select coalesce(max(total_count), 0) into n from public.search_catalog_games(c.term, c.players, c.minutes, c.age,
case when c.genre is null then null else array[c.genre] end, case when c.mech is null then null else array[c.mech] end, 30, c.off);
insert into perf_tmp.r values (c.fall, extract(epoch from clock_timestamp() - t0) * 1000, n);
end loop;
end
$b$;
reset role;
select fall,
count(*) as aufrufe,
round(percentile_cont(0.5) within group (order by ms)::numeric, 1) as p50_ms,
round(percentile_cont(0.95) within group (order by ms)::numeric, 1) as p95_ms,
round(percentile_cont(0.99) within group (order by ms)::numeric, 1) as p99_ms,
round(max(ms)::numeric, 1) as max_ms,
count(*) filter (where ms > 3000) as ueber_3s,
round(avg(n)::numeric, 0) as treffer_mittel
from perf_tmp.r group by fall order by fall;
select 'alle Fälle zusammen' as fall, count(*) as aufrufe,
round(percentile_cont(0.95) within group (order by ms)::numeric, 1) as p95_ms,
round(max(ms)::numeric, 1) as max_ms
from perf_tmp.r;
rollback;
-- ANALYZE lässt sich nicht zurückrollen: Statistiken der betroffenen Tabellen neu berechnen
vacuum (analyze) public.games, public.game_alt_titles, public.publishers, public.game_publishers,
public.game_genres, public.game_mechanics, public.genres, public.mechanics;