-- 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;