diff --git a/db/migrations/20260518210000_pg_observability_pg_stat_statements.sql b/db/migrations/20260518210000_pg_observability_pg_stat_statements.sql new file mode 100644 index 000000000..eaac0b1ed --- /dev/null +++ b/db/migrations/20260518210000_pg_observability_pg_stat_statements.sql @@ -0,0 +1,22 @@ +-- Grafana SQL dashboards read pg_stat_statements (see tachi-deploy terraform/dashboards/postgres_pg_stat_statements.json). +-- +-- Applied inside a migration transaction (see tachi-db-migration-engine): cannot use ALTER SYSTEM here. +-- Instance config: prod uses server-infra/docker-compose.infra.yml (shared_preload_libraries + track); +-- dev uses Tachi3/docker-compose-dev.yml. After changing preload, recreate / restart Postgres. +-- Genesis may have created the extension already; IF NOT EXISTS keeps this migration safe. + +CREATE EXTENSION IF NOT EXISTS pg_stat_statements; + +-- SELECT on pg_stat_* views requires pg_read_all_stats (not covered by SELECT grants on public tables). +-- Dev/local uses grafana_ro (see dev/postgres-init.sql); prod compose uses grafana_readonly (tachi-deploy postgres-initdb). +DO $grant_stats$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN + EXECUTE 'GRANT pg_read_all_stats TO grafana_ro'; + END IF; + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_readonly') THEN + EXECUTE 'GRANT pg_read_all_stats TO grafana_readonly'; + END IF; +END +$grant_stats$; diff --git a/db/migrations/20260518221500_score_game_chart_id_idx.sql b/db/migrations/20260518221500_score_game_chart_id_idx.sql new file mode 100644 index 000000000..557e2de86 --- /dev/null +++ b/db/migrations/20260518221500_score_game_chart_id_idx.sql @@ -0,0 +1,2 @@ +-- Speed up per-game score aggregates grouped by chart (GET /games/:game/charts popularity). +CREATE INDEX IF NOT EXISTS score_game_chart_id_idx ON public.score USING btree (game, chart_id); diff --git a/dev/postgres-init.sql b/dev/postgres-init.sql index 669dc5d94..3b3f22b9c 100644 --- a/dev/postgres-init.sql +++ b/dev/postgres-init.sql @@ -16,5 +16,6 @@ GRANT CONNECT ON DATABASE tachi_dev TO grafana_ro; GRANT USAGE ON SCHEMA public TO grafana_ro; GRANT SELECT ON ALL TABLES IN SCHEMA public TO grafana_ro; GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO grafana_ro; +GRANT pg_read_all_stats TO grafana_ro; ALTER DEFAULT PRIVILEGES FOR ROLE tachi IN SCHEMA public GRANT SELECT ON TABLES TO grafana_ro; ALTER DEFAULT PRIVILEGES FOR ROLE tachi IN SCHEMA public GRANT SELECT ON SEQUENCES TO grafana_ro; diff --git a/typescript/server/src/utils/queries/charts.ts b/typescript/server/src/utils/queries/charts.ts index 667719cdd..d30e1bfea 100644 --- a/typescript/server/src/utils/queries/charts.ts +++ b/typescript/server/src/utils/queries/charts.ts @@ -748,7 +748,8 @@ export async function FindUSCChartsByHashSHA1(hash: string): Promise> { - let q = DB.selectFrom("chart") + if (filters?.chartIDs?.length === 0) { + return []; + } + + if (filters?.songIDs?.length === 0) { + return []; + } + + const chartIdFilter = filters?.chartIDs; + + let q = DB.with("score_counts", (db) => { + let sq = db + .selectFrom("score") + .where("score.game", "=", game) + .select(["score.chart_id", sql`count(*)::int`.as("playcount")]) + .groupBy("score.chart_id"); + + if (chartIdFilter !== undefined) { + sq = sq.where("score.chart_id", "in", chartIdFilter); + } + + return sq; + }) + .selectFrom("chart") .innerJoin("song", "song.id", "chart.song_id") - .leftJoin("score", "score.chart_id", "chart.id") + .leftJoin("score_counts", "score_counts.chart_id", "chart.id") .where("chart.game", "=", game) .where(sql`(chart.data->>'2dxtraSet') IS NULL`); - if (filters?.chartIDs) { - // empty array - should be no matches, just short circuit - if (filters.chartIDs.length === 0) { - return []; - } else { - q = q.where("chart.id", "in", filters.chartIDs); - } + if (chartIdFilter !== undefined) { + q = q.where("chart.id", "in", chartIdFilter); } if (filters?.songIDs) { - // empty array - should be no matches, just short circuit - if (filters.songIDs.length === 0) { - return []; - } else { - q = q.where("song.id", "in", filters.songIDs); - } + q = q.where("song.id", "in", filters.songIDs); } const rows = await q .select([ ...SELECT_CHART, // format-bearing comment - sql`count(score.id)::int`.as("playcount"), + sql`coalesce(score_counts.playcount, 0)::int`.as("playcount"), ]) - .groupBy(["chart.id", "song.id"]) - .orderBy(sql`count(score.id)`, "desc") + .orderBy(sql`coalesce(score_counts.playcount, 0)`, "desc") .offset(skip) .limit(limit) .execute();