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 <noreply@anthropic.com>
This commit is contained in:
parent
683b5c830e
commit
729e51620c
1 changed files with 207 additions and 0 deletions
207
supabase/perf/catalog_search_perf.sql
Normal file
207
supabase/perf/catalog_search_perf.sql
Normal file
|
|
@ -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;
|
||||
Loading…
Reference in a new issue