From 729e51620cb93b90b10696ef3bb8cc2b2c4a9e72 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 01:40:43 +0100 Subject: [PATCH] test(db): add local search measurement script (KAT-01 B5) supabase/perf/catalog_search_perf.sql loads synthetic data in one rolled-back transaction and measures search_catalog_games as anon with RLS (explain and p50/p95/p99 per case). Lives outside supabase/tests so it never runs in CI. Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- supabase/perf/catalog_search_perf.sql | 207 ++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 supabase/perf/catalog_search_perf.sql diff --git a/supabase/perf/catalog_search_perf.sql b/supabase/perf/catalog_search_perf.sql new file mode 100644 index 0000000..0c3e7be --- /dev/null +++ b/supabase/perf/catalog_search_perf.sql @@ -0,0 +1,207 @@ +-- 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: nichts bleibt zurück. +-- 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: Aufruf nur mit -v confirm_local=yes, und das Skript bricht ab, 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 + \echo 'Abbruch: mit -v confirm_local=yes aufrufen (nur lokal, siehe Kopfkommentar).' + \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;