From 25e24b52d93d06ffc0ea2acbc0b4ba14825f6379 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Sat, 19 Sep 2026 15:20:27 -0400 Subject: [PATCH] bug: sanction checks scan every player_sanctions chunk player_sanctions is partitioned on created_at but only ever queried by player_steam_id, so chunk exclusion never prunes and each is_muted / is_gagged / is_banned call sequentially scans every chunk. There is no retention or compression policy on the other side of the trade, and the partitioning also forced the primary key from (id) to (id, created_at). Converts it back to a plain table, restoring PRIMARY KEY (id). --- .../down.sql | 21 ++++ .../up.sql | 96 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 hasura/migrations/default/1886000000800_player_sanctions_drop_hypertable/down.sql create mode 100644 hasura/migrations/default/1886000000800_player_sanctions_drop_hypertable/up.sql diff --git a/hasura/migrations/default/1886000000800_player_sanctions_drop_hypertable/down.sql b/hasura/migrations/default/1886000000800_player_sanctions_drop_hypertable/down.sql new file mode 100644 index 00000000..424bd866 --- /dev/null +++ b/hasura/migrations/default/1886000000800_player_sanctions_drop_hypertable/down.sql @@ -0,0 +1,21 @@ +-- Restores the shape timescale_init left behind: the primary key widened to +-- include the partitioning column, then the hypertable itself. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM timescaledb_information.hypertables + WHERE hypertable_schema = 'public' AND hypertable_name = 'player_sanctions' + ) THEN + RAISE NOTICE 'player_sanctions is already a hypertable, skipping'; + RETURN; + END IF; + + ALTER TABLE public.player_sanctions + DROP CONSTRAINT IF EXISTS player_sanctions_pkey; + + ALTER TABLE public.player_sanctions + ADD CONSTRAINT player_sanctions_pkey PRIMARY KEY (id, created_at); + + PERFORM create_hypertable('player_sanctions', 'created_at', migrate_data => true); +END; +$$; diff --git a/hasura/migrations/default/1886000000800_player_sanctions_drop_hypertable/up.sql b/hasura/migrations/default/1886000000800_player_sanctions_drop_hypertable/up.sql new file mode 100644 index 00000000..082d1310 --- /dev/null +++ b/hasura/migrations/default/1886000000800_player_sanctions_drop_hypertable/up.sql @@ -0,0 +1,96 @@ +-- player_sanctions was converted to a hypertable in timescale_init alongside +-- the six genuine per-event stats tables, but nothing queries it by time: +-- is_muted, is_gagged, is_banned, banned_until, is_admin_sanctioned and +-- sanction_policy all filter on player_steam_id alone. With no predicate on +-- the partitioning column chunk exclusion can never prune anything, so every +-- one of those lookups fans a sequential scan across every chunk instead of +-- using idx_player_sanctions_steam_type -- and a chunk is added every +-- interval, so the cost per call grows without bound. Nothing claims the other +-- side of that trade: there is no retention policy, no compression, and no +-- query anywhere that filters by created_at range. +-- +-- Partitioning also cost the table its primary key. TimescaleDB requires the +-- partitioning column in every unique index, so timescale_init had to widen +-- the original PRIMARY KEY (id) to (id, created_at), which left the uuid that +-- identifies a sanction without a uniqueness guarantee. Converting back +-- restores it. +DO $$ +DECLARE + trigger_definitions text[]; + trigger_definition text; +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM timescaledb_information.hypertables + WHERE hypertable_schema = 'public' AND hypertable_name = 'player_sanctions' + ) THEN + RAISE NOTICE 'player_sanctions is already a plain table, skipping'; + RETURN; + END IF; + + -- Hasura creates its event triggers directly on the table rather than in + -- metadata this migration could replay, and the swap below drops them with + -- the old table. Capture them first and recreate them at the end so event + -- delivery is not silently lost until the next metadata apply. + SELECT coalesce(array_agg(pg_get_triggerdef(oid)), '{}') + INTO trigger_definitions + FROM pg_trigger + WHERE tgrelid = 'public.player_sanctions'::regclass + AND NOT tgisinternal; + + CREATE TABLE public.player_sanctions_plain ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + player_steam_id bigint NOT NULL, + type text NOT NULL, + reason text, + remove_sanction_date timestamptz, + sanctioned_by_steam_id bigint, + created_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + + INSERT INTO public.player_sanctions_plain ( + id, player_steam_id, type, reason, remove_sanction_date, + sanctioned_by_steam_id, created_at, deleted_at + ) + SELECT id, player_steam_id, type, reason, remove_sanction_date, + sanctioned_by_steam_id, created_at, deleted_at + FROM public.player_sanctions; + + DROP TABLE public.player_sanctions; + ALTER TABLE public.player_sanctions_plain RENAME TO player_sanctions; + + ALTER TABLE public.player_sanctions + ADD CONSTRAINT player_sanctions_pkey PRIMARY KEY (id); + + ALTER TABLE public.player_sanctions + ADD CONSTRAINT player_sanctions_player_steam_id_fkey + FOREIGN KEY (player_steam_id) REFERENCES public.players(steam_id) + ON UPDATE CASCADE ON DELETE CASCADE; + + ALTER TABLE public.player_sanctions + ADD CONSTRAINT player_sanctions_sanctioned_by_steam_id_fkey + FOREIGN KEY (sanctioned_by_steam_id) REFERENCES public.players(steam_id) + ON UPDATE CASCADE ON DELETE SET NULL; + + ALTER TABLE public.player_sanctions + ADD CONSTRAINT player_sanctions_type_fkey + FOREIGN KEY (type) REFERENCES public.e_sanction_types(value) + ON UPDATE CASCADE ON DELETE RESTRICT; + + CREATE INDEX idx_player_sanctions_steam_type + ON public.player_sanctions (player_steam_id, type); + + CREATE INDEX idx_player_sanctions_one_auto_ban + ON public.player_sanctions (player_steam_id) + WHERE type = 'ban' AND sanctioned_by_steam_id IS NULL; + + -- TimescaleDB created this one implicitly for the partitioning column. + -- Kept because the moderation screens still order sanctions by recency. + CREATE INDEX player_sanctions_created_at_idx + ON public.player_sanctions (created_at DESC); + + FOREACH trigger_definition IN ARRAY trigger_definitions LOOP + EXECUTE trigger_definition; + END LOOP; +END; +$$;