From 5edf0640f7816b18426e095e5a5351afa31fc43f Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 10 Jul 2026 08:26:11 -0400 Subject: [PATCH 1/4] chore: speed up sql tests further Run known-slow suites first via a custom sequencer, move draft-order off its inline container boot onto the shared template clone, split the warm setup() re-apply out of fixtures.spec so both heavy flows parallelize, and pull the database image in the background of yarn install. Co-Authored-By: Claude Fable 5 --- .github/workflows/tests.yml | 16 ++++---- test/draft-order.spec.ts | 62 +++--------------------------- test/fixtures-warm-reapply.spec.ts | 53 +++++++++++++++++++++++++ test/fixtures.spec.ts | 23 +---------- test/jest-sql.config.js | 1 + test/utils/slow-first-sequencer.js | 26 +++++++++++++ 6 files changed, 96 insertions(+), 85 deletions(-) create mode 100644 test/fixtures-warm-reapply.spec.ts create mode 100644 test/utils/slow-first-sequencer.js diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ff7440c5..4183d3d7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,9 +28,10 @@ jobs: run: yarn test:unit sql: - # Boots a throwaway TimescaleDB per suite via testcontainers and drives the - # real HasuraService.setup() migration pipeline, then exercises the - # triggers/functions. Docker is available on GitHub-hosted runners. + # Boots one shared TimescaleDB via testcontainers, drives the real + # HasuraService.setup() migration pipeline once, then suites clone the + # template database and exercise the triggers/functions in parallel. + # Docker is available on GitHub-hosted runners. runs-on: ubuntu-24.04 steps: - name: Check out the repo @@ -40,9 +41,10 @@ jobs: with: node-version: 22 cache: yarn - - name: Install dependencies - run: yarn install --frozen-lockfile - - name: Pre-pull the database image - run: docker pull timescale/timescaledb:latest-pg17 + - name: Install dependencies (pre-pulling the database image) + run: | + docker pull -q timescale/timescaledb:latest-pg17 & + yarn install --frozen-lockfile + wait - name: Run SQL tests run: yarn test:sql diff --git a/test/draft-order.spec.ts b/test/draft-order.spec.ts index 639031a9..1ec25b09 100644 --- a/test/draft-order.spec.ts +++ b/test/draft-order.spec.ts @@ -1,70 +1,18 @@ -import { Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { - PostgreSqlContainer, - StartedPostgreSqlContainer, -} from "@testcontainers/postgresql"; -import { HasuraService } from "./../src/hasura/hasura.service"; import { PostgresService } from "./../src/postgres/postgres.service"; +import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; describe("draft game pick order (SQL-driven)", () => { - const IMAGE = "timescale/timescaledb:latest-pg17"; - - let container: StartedPostgreSqlContainer; + let db: SqlTestDb; let postgres: PostgresService; let seq = 0; beforeAll(async () => { - container = await new PostgreSqlContainer(IMAGE) - .withDatabase("hasura") - .withUsername("hasura") - .withPassword("hasura") - .withCommand([ - "postgres", - "-c", - "shared_preload_libraries=timescaledb,pg_stat_statements", - ]) - .start(); - - const configService = new ConfigService({ - postgres: { - connections: { - default: { - host: container.getHost(), - port: container.getPort(), - user: container.getUsername(), - password: container.getPassword(), - database: container.getDatabase(), - max: 5, - }, - }, - }, - app: { - demosDomain: "demos.test", - relayDomain: "relay.test", - }, - }); - - const logger = new Logger("DraftOrderTest"); - postgres = new PostgresService(configService, logger); - - await postgres.query("CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE"); - - const hasuraService = new HasuraService( - logger, - null as never, - configService, - postgres, - ); - - await hasuraService.setup(); + db = await bootMigratedDb("DraftOrderTest"); + postgres = db.postgres; }, 600_000); afterAll(async () => { - await ( - postgres as unknown as { pool: { end(): Promise } } - )?.pool?.end(); - await container?.stop(); + await db?.stop(); }); const nextSteam = () => (76561190000000000n + BigInt(++seq)).toString(); diff --git a/test/fixtures-warm-reapply.spec.ts b/test/fixtures-warm-reapply.spec.ts new file mode 100644 index 00000000..3ec64e1f --- /dev/null +++ b/test/fixtures-warm-reapply.spec.ts @@ -0,0 +1,53 @@ +import * as fs from "fs"; +import * as path from "path"; +import { PostgresService } from "./../src/postgres/postgres.service"; +import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; + +// Production upgrades run setup() against a live database. Split out of +// fixtures.spec.ts so this heavy flow and the fresh-apply flow run on +// separate workers instead of back to back. +describe("dev fixtures warm re-apply (hasura/fixtures)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + + const FIXTURES_DIR = path.resolve("./hasura/fixtures"); + + const applyFile = (file: string) => + postgres.query(fs.readFileSync(path.join(FIXTURES_DIR, file), "utf8")); + + const count = async (sql: string) => + Number( + (await postgres.query>(`SELECT count(*) AS c FROM ${sql}`))[0].c, + ); + + beforeAll(async () => { + db = await bootMigratedDb("FixturesWarmTest"); + postgres = db.postgres; + await applyFile("cleanup.sql"); + await applyFile("fixtures.sql"); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + it("setup() re-runs every SQL file over a data-full database", async () => { + // Deleting the stored file digests forces every enum/function/view/trigger + // file to re-apply (migrations stay applied via schema_migrations) — + // catching files that only work on an empty schema, the regression class + // the cold-start suite can't see. + const before = await count("teams"); + expect(before).toBe(8); + + await postgres.query("DELETE FROM settings WHERE name LIKE 'hasura/%'"); + await db.hasura.setup(); + + expect(await count("teams")).toBe(before); + expect(await count("matches")).toBeGreaterThan(0); + + const disabled = await postgres.query>( + `SELECT tgname FROM pg_trigger WHERE tgenabled = 'D'`, + ); + expect(disabled).toEqual([]); + }, 300_000); +}); diff --git a/test/fixtures.spec.ts b/test/fixtures.spec.ts index 71f26c50..d72074a0 100644 --- a/test/fixtures.spec.ts +++ b/test/fixtures.spec.ts @@ -8,6 +8,8 @@ import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; // nothing else catches them drifting from the live schema — this suite // applies them against a freshly migrated database exactly the way // HasuraService.setup() does (cleanup.sql first, then fixtures.sql). +// The warm setup() re-apply over this dataset lives in +// fixtures-warm-reapply.spec.ts so the two heavy flows run in parallel. describe("dev fixtures (hasura/fixtures)", () => { let db: SqlTestDb; let postgres: PostgresService; @@ -80,25 +82,4 @@ describe("dev fixtures (hasura/fixtures)", () => { await applyFile("fixtures.sql"); expect(await count("teams")).toBe(8); }, 180_000); - - it("warm re-apply: setup() re-runs every SQL file over a data-full database", async () => { - // Production upgrades run setup() against a live database. Deleting the - // stored file digests forces every enum/function/view/trigger file to - // re-apply (migrations stay applied via schema_migrations) — catching - // files that only work on an empty schema, the regression class the - // cold-start suite can't see. - const before = await count("teams"); - expect(before).toBe(8); - - await postgres.query("DELETE FROM settings WHERE name LIKE 'hasura/%'"); - await db.hasura.setup(); - - expect(await count("teams")).toBe(before); - expect(await count("matches")).toBeGreaterThan(0); - - const disabled = await postgres.query>( - `SELECT tgname FROM pg_trigger WHERE tgenabled = 'D'`, - ); - expect(disabled).toEqual([]); - }, 300_000); }); diff --git a/test/jest-sql.config.js b/test/jest-sql.config.js index a72845aa..dd92c880 100644 --- a/test/jest-sql.config.js +++ b/test/jest-sql.config.js @@ -11,4 +11,5 @@ module.exports = { roots: ["/../test"], globalSetup: "/../test/utils/jest-global-setup.ts", globalTeardown: "/../test/utils/jest-global-teardown.ts", + testSequencer: "/../test/utils/slow-first-sequencer.js", }; diff --git a/test/utils/slow-first-sequencer.js b/test/utils/slow-first-sequencer.js new file mode 100644 index 00000000..da1e4573 --- /dev/null +++ b/test/utils/slow-first-sequencer.js @@ -0,0 +1,26 @@ +const Sequencer = require("@jest/test-sequencer").default; + +// Jest's default sequencer only knows suite durations after a cached run, and +// CI runs cold — so the slowest suites otherwise start last and tail the whole +// run. Pin the known-heavy suites to the front; everything else keeps jest's +// default order behind them. +const SLOW_FIRST = [ + "fixtures-warm-reapply.spec.ts", + "fixtures.spec.ts", + "tournament-stages.spec.ts", + "tournament-reset.spec.ts", + "tournament-edge-cases.spec.ts", + "tournaments.spec.ts", +]; + +class SlowFirstSequencer extends Sequencer { + sort(tests) { + const rank = (test) => { + const index = SLOW_FIRST.findIndex((name) => test.path.endsWith(name)); + return index === -1 ? SLOW_FIRST.length : index; + }; + return super.sort(tests).sort((a, b) => rank(a) - rank(b)); + } +} + +module.exports = SlowFirstSequencer; From d7225ec98b33bd10560ca1567fd03f0bc886a4a7 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 10 Jul 2026 08:32:10 -0400 Subject: [PATCH 2/4] chore: disable durability in the test database Co-Authored-By: Claude Fable 5 --- test/utils/sql-test-db.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/utils/sql-test-db.ts b/test/utils/sql-test-db.ts index 2e98cf20..0475f2fb 100644 --- a/test/utils/sql-test-db.ts +++ b/test/utils/sql-test-db.ts @@ -82,6 +82,14 @@ export async function bootContainerAndMigrate( // Parallel suites each hold a small pool against this one server. "-c", "max_connections=200", + // Throwaway database: durability off. The write-heavy fixture loads + // are fsync-bound on CI disks. + "-c", + "fsync=off", + "-c", + "synchronous_commit=off", + "-c", + "full_page_writes=off", // Scheduler/telemetry workers open their own connections to every // database with the extension installed; a connection to the template // database would make CREATE DATABASE ... TEMPLATE fail. Tests exercise From f9c521f731e14b9bee40e9eca2642db2c6744717 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 10 Jul 2026 08:58:48 -0400 Subject: [PATCH 3/4] chore: load dev fixtures in replica mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replica mode skips user triggers on hypertables, which DISABLE TRIGGER can't cover and whose per-row stat accumulation dominated the fixture load. The aggregate tables are now recomputed set-based from the inserted player_kills rows at the end of the load, with the same semantics the triggers apply per row — which also fixes the season stats under-attributing kills from the tournament sections, and drops the parallel array bookkeeping the fixture kept for early matches. Co-Authored-By: Claude Fable 5 --- hasura/fixtures/cleanup.sql | 6 +- hasura/fixtures/fixtures.sql | 251 +++++++++++------------------------ 2 files changed, 84 insertions(+), 173 deletions(-) diff --git a/hasura/fixtures/cleanup.sql b/hasura/fixtures/cleanup.sql index 862d3a92..06b67622 100644 --- a/hasura/fixtures/cleanup.sql +++ b/hasura/fixtures/cleanup.sql @@ -63,7 +63,10 @@ BEGIN ALTER TABLE match_options DISABLE TRIGGER ALL; ALTER TABLE match_map_veto_picks DISABLE TRIGGER ALL; - -- Delete player event data + -- Delete player event data. Hypertable triggers can't be disabled; + -- replica mode skips the per-row tad_* stat decrements, which are wasted + -- work here — the aggregate tables are deleted for fixture players below. + PERFORM set_config('session_replication_role', 'replica', true); DELETE FROM player_kills WHERE match_id = ANY(match_ids); DELETE FROM player_damages WHERE match_id = ANY(match_ids); DELETE FROM player_assists WHERE match_id = ANY(match_ids); @@ -71,6 +74,7 @@ BEGIN DELETE FROM player_objectives WHERE match_id = ANY(match_ids); DELETE FROM player_unused_utility WHERE match_id = ANY(match_ids); DELETE FROM player_utility WHERE match_id = ANY(match_ids); + PERFORM set_config('session_replication_role', 'origin', true); -- Delete map veto picks DELETE FROM match_map_veto_picks WHERE match_id = ANY(match_ids); diff --git a/hasura/fixtures/fixtures.sql b/hasura/fixtures/fixtures.sql index 0eabc805..c4a00a35 100644 --- a/hasura/fixtures/fixtures.sql +++ b/hasura/fixtures/fixtures.sql @@ -2,29 +2,11 @@ -- Inserts ~40 players, 8 teams, ~143 matches with scores, map veto picks, utility/flash data, and 4 tournaments -- This file is idempotent: running cleanup.sql first removes prior fixture data --- Disable triggers on affected tables (excluding hypertables which don't support this) --- Hypertables: player_kills, player_damages, player_assists, player_flashes, --- player_objectives, player_utility, player_sanctions -ALTER TABLE players DISABLE TRIGGER ALL; -ALTER TABLE teams DISABLE TRIGGER ALL; -ALTER TABLE team_roster DISABLE TRIGGER ALL; -ALTER TABLE match_options DISABLE TRIGGER ALL; -ALTER TABLE match_lineups DISABLE TRIGGER ALL; -ALTER TABLE matches DISABLE TRIGGER ALL; -ALTER TABLE match_lineup_players DISABLE TRIGGER ALL; -ALTER TABLE match_maps DISABLE TRIGGER ALL; -ALTER TABLE match_map_rounds DISABLE TRIGGER ALL; -ALTER TABLE player_stats DISABLE TRIGGER ALL; -ALTER TABLE player_kills_by_weapon DISABLE TRIGGER ALL; -ALTER TABLE tournaments DISABLE TRIGGER ALL; -ALTER TABLE tournament_stages DISABLE TRIGGER ALL; -ALTER TABLE tournament_teams DISABLE TRIGGER ALL; -ALTER TABLE tournament_team_roster DISABLE TRIGGER ALL; -ALTER TABLE tournament_brackets DISABLE TRIGGER ALL; -ALTER TABLE match_map_veto_picks DISABLE TRIGGER ALL; -ALTER TABLE player_elo DISABLE TRIGGER ALL; -ALTER TABLE player_season_stats DISABLE TRIGGER ALL; -ALTER TABLE seasons DISABLE TRIGGER ALL; +-- Replica mode skips every user trigger — including on hypertables +-- (player_kills etc.), which DISABLE TRIGGER can't cover and whose per-row +-- stat triggers would otherwise dominate the load time. Section 6 seeds the +-- aggregate tables those triggers maintain. +SET session_replication_role = replica; DO $$ DECLARE @@ -115,12 +97,6 @@ DECLARE v_t1_wins int; v_t2_wins int; - -- Stats tracking - kill_counts int[]; - death_counts int[]; - headshot_counts int[]; - weapon_kill_map jsonb; - -- ELO tracking season_elos int[]; tournament_elos int[]; @@ -128,11 +104,6 @@ DECLARE cur_season_idx int; elo_change int; - -- Per-season stats tracking - s_kill_counts int[]; - s_death_counts int[]; - s_headshot_counts int[]; - -- Tournament working variables tourn_stage_id uuid; tourn_team_ids uuid[]; @@ -233,22 +204,11 @@ BEGIN END IF; -- ========================================== - -- 4. INITIALIZE STATS ARRAYS + -- 4. INITIALIZE ELO: everyone starts at 5000 per season -- ========================================== - kill_counts := array_fill(0, ARRAY[40]); - death_counts := array_fill(0, ARRAY[40]); - headshot_counts := array_fill(0, ARRAY[40]); - weapon_kill_map := '{}'::jsonb; - - -- Initialize ELO: everyone starts at 5000 per season season_elos := array_fill(5000, ARRAY[3, 40]); tournament_elos := array_fill(5000, ARRAY[40]); - -- Per-season stats - s_kill_counts := array_fill(0, ARRAY[3, 40]); - s_death_counts := array_fill(0, ARRAY[3, 40]); - s_headshot_counts := array_fill(0, ARRAY[3, 40]); - -- ========================================== -- 5. INSERT MATCHES (~100 matches) -- ========================================== @@ -463,32 +423,6 @@ BEGIN ) ON CONFLICT DO NOTHING; - -- Track stats - kill_counts[attacker_idx] := kill_counts[attacker_idx] + 1; - death_counts[attacked_idx] := death_counts[attacked_idx] + 1; - IF is_headshot THEN - headshot_counts[attacker_idx] := headshot_counts[attacker_idx] + 1; - END IF; - - -- Track per-season stats - IF cur_season_idx > 0 THEN - s_kill_counts[cur_season_idx][attacker_idx] := s_kill_counts[cur_season_idx][attacker_idx] + 1; - s_death_counts[cur_season_idx][attacked_idx] := s_death_counts[cur_season_idx][attacked_idx] + 1; - IF is_headshot THEN - s_headshot_counts[cur_season_idx][attacker_idx] := s_headshot_counts[cur_season_idx][attacker_idx] + 1; - END IF; - END IF; - - -- Track weapon kills - DECLARE - wkey text := p_steam_ids[attacker_idx]::text || ':' || weapons[weapon_idx]; - BEGIN - IF weapon_kill_map ? wkey THEN - weapon_kill_map := jsonb_set(weapon_kill_map, ARRAY[wkey], to_jsonb((weapon_kill_map->>wkey)::int + 1)); - ELSE - weapon_kill_map := weapon_kill_map || jsonb_build_object(wkey, 1); - END IF; - END; END LOOP; -- kills per round -- Generate utility events (flashes, smokes, HE, molotov) @@ -715,22 +649,6 @@ BEGIN is_headshot, kill_time) ON CONFLICT DO NOTHING; - -- Track stats - kill_counts[attacker_idx] := kill_counts[attacker_idx] + 1; - death_counts[attacked_idx] := death_counts[attacked_idx] + 1; - IF is_headshot THEN - headshot_counts[attacker_idx] := headshot_counts[attacker_idx] + 1; - END IF; - - DECLARE - wkey text := p_steam_ids[attacker_idx]::text || ':' || weapons[weapon_idx]; - BEGIN - IF weapon_kill_map ? wkey THEN - weapon_kill_map := jsonb_set(weapon_kill_map, ARRAY[wkey], to_jsonb((weapon_kill_map->>wkey)::int + 1)); - ELSE - weapon_kill_map := weapon_kill_map || jsonb_build_object(wkey, 1); - END IF; - END; END LOOP; -- kills per round -- Utility events (flash + smoke per round) @@ -763,70 +681,10 @@ BEGIN END LOOP; -- additional matches -- ========================================== - -- 6. POPULATE AGGREGATE TABLES + -- 6. AGGREGATE TABLES are populated at the end of this block (section 8), + -- once every section that inserts player_kills has run. -- ========================================== - -- Player stats - FOR i IN 1..40 LOOP - INSERT INTO player_stats (player_steam_id, kills, deaths, assists, headshots, headshot_percentage) - VALUES ( - p_steam_ids[i], - kill_counts[i], - death_counts[i], - 0, - headshot_counts[i], - CASE WHEN kill_counts[i] > 0 THEN headshot_counts[i]::float / kill_counts[i] ELSE 0 END - ) - ON CONFLICT (player_steam_id) DO UPDATE SET - kills = EXCLUDED.kills, - deaths = EXCLUDED.deaths, - headshots = EXCLUDED.headshots, - headshot_percentage = EXCLUDED.headshot_percentage; - END LOOP; - - -- Player kills by weapon - DECLARE - wkey text; - wparts text[]; - w_steam_id bigint; - w_weapon text; - w_count int; - BEGIN - FOR wkey IN SELECT jsonb_object_keys(weapon_kill_map) LOOP - wparts := string_to_array(wkey, ':'); - w_steam_id := wparts[1]::bigint; - w_weapon := wparts[2]; - w_count := (weapon_kill_map->>wkey)::int; - - INSERT INTO player_kills_by_weapon (player_steam_id, "with", kill_count) - VALUES (w_steam_id, w_weapon, w_count) - ON CONFLICT (player_steam_id, "with") DO UPDATE SET kill_count = EXCLUDED.kill_count; - END LOOP; - END; - - -- Player season stats - FOR s IN 1..3 LOOP - FOR i IN 1..40 LOOP - IF s_kill_counts[s][i] > 0 OR s_death_counts[s][i] > 0 THEN - INSERT INTO player_season_stats (player_steam_id, season_id, kills, deaths, assists, headshots, headshot_percentage) - VALUES ( - p_steam_ids[i], - season_ids[s], - s_kill_counts[s][i], - s_death_counts[s][i], - 0, - s_headshot_counts[s][i], - CASE WHEN s_kill_counts[s][i] > 0 THEN s_headshot_counts[s][i]::float / s_kill_counts[s][i] ELSE 0 END - ) - ON CONFLICT (player_steam_id, season_id) DO UPDATE SET - kills = EXCLUDED.kills, - deaths = EXCLUDED.deaths, - headshots = EXCLUDED.headshots, - headshot_percentage = EXCLUDED.headshot_percentage; - END IF; - END LOOP; - END LOOP; - -- ========================================== -- 7. INSERT TOURNAMENTS -- ========================================== @@ -1902,7 +1760,76 @@ BEGIN END; -- ========================================== - -- 8. SET FIXTURES LOADED FLAG + -- 8. POPULATE AGGREGATE TABLES + -- ========================================== + -- Recomputed set-based from the inserted player_kills rows — the same + -- accumulation tai_player_kills performs per row, which replica mode + -- skips. Runs last so the tournament sections' kills are counted too. + + INSERT INTO player_stats (player_steam_id, kills, deaths, assists, headshots, headshot_percentage) + SELECT + p.steam_id, + COALESCE(k.kills, 0), + COALESCE(d.deaths, 0), + 0, + COALESCE(k.headshots, 0), + CASE WHEN COALESCE(k.kills, 0) > 0 THEN COALESCE(k.headshots, 0)::float / k.kills ELSE 0 END + FROM unnest(p_steam_ids) AS p(steam_id) + LEFT JOIN ( + SELECT attacker_steam_id, count(*) AS kills, count(*) FILTER (WHERE headshot) AS headshots + FROM player_kills WHERE attacker_steam_id = ANY(p_steam_ids) GROUP BY 1 + ) k ON k.attacker_steam_id = p.steam_id + LEFT JOIN ( + SELECT attacked_steam_id, count(*) AS deaths + FROM player_kills WHERE attacked_steam_id = ANY(p_steam_ids) GROUP BY 1 + ) d ON d.attacked_steam_id = p.steam_id + ON CONFLICT (player_steam_id) DO UPDATE SET + kills = EXCLUDED.kills, + deaths = EXCLUDED.deaths, + headshots = EXCLUDED.headshots, + headshot_percentage = EXCLUDED.headshot_percentage; + + INSERT INTO player_kills_by_weapon (player_steam_id, "with", kill_count) + SELECT attacker_steam_id, "with", count(*) + FROM player_kills + WHERE attacker_steam_id = ANY(p_steam_ids) + GROUP BY 1, 2 + ON CONFLICT (player_steam_id, "with") DO UPDATE SET kill_count = EXCLUDED.kill_count; + + -- Season attribution mirrors the trigger: season_for_timestamp(m.ended_at). + INSERT INTO player_season_stats (player_steam_id, season_id, kills, deaths, assists, headshots, headshot_percentage) + SELECT steam_id, season_id, kills, deaths, 0, headshots, + CASE WHEN kills > 0 THEN headshots::float / kills ELSE 0 END + FROM ( + SELECT steam_id, season_id, sum(kills) AS kills, sum(deaths) AS deaths, sum(headshots) AS headshots + FROM ( + SELECT pk.attacker_steam_id AS steam_id, + season_for_timestamp(COALESCE(m.ended_at, now())) AS season_id, + count(*) AS kills, + count(*) FILTER (WHERE pk.headshot) AS headshots, + 0 AS deaths + FROM player_kills pk JOIN matches m ON m.id = pk.match_id + WHERE pk.attacker_steam_id = ANY(p_steam_ids) + GROUP BY 1, 2 + UNION ALL + SELECT pk.attacked_steam_id, + season_for_timestamp(COALESCE(m.ended_at, now())), + 0, 0, count(*) + FROM player_kills pk JOIN matches m ON m.id = pk.match_id + WHERE pk.attacked_steam_id = ANY(p_steam_ids) + GROUP BY 1, 2 + ) events + GROUP BY 1, 2 + ) agg + WHERE season_id IS NOT NULL + ON CONFLICT (player_steam_id, season_id) DO UPDATE SET + kills = EXCLUDED.kills, + deaths = EXCLUDED.deaths, + headshots = EXCLUDED.headshots, + headshot_percentage = EXCLUDED.headshot_percentage; + + -- ========================================== + -- 9. SET FIXTURES LOADED FLAG -- ========================================== INSERT INTO settings (name, value) VALUES ('dev.fixtures_loaded', 'true') @@ -1910,27 +1837,7 @@ BEGIN END $$; --- Re-enable triggers (excluding hypertables) -ALTER TABLE players ENABLE TRIGGER ALL; -ALTER TABLE teams ENABLE TRIGGER ALL; -ALTER TABLE team_roster ENABLE TRIGGER ALL; -ALTER TABLE match_options ENABLE TRIGGER ALL; -ALTER TABLE match_lineups ENABLE TRIGGER ALL; -ALTER TABLE matches ENABLE TRIGGER ALL; -ALTER TABLE match_lineup_players ENABLE TRIGGER ALL; -ALTER TABLE match_maps ENABLE TRIGGER ALL; -ALTER TABLE match_map_rounds ENABLE TRIGGER ALL; -ALTER TABLE player_stats ENABLE TRIGGER ALL; -ALTER TABLE player_kills_by_weapon ENABLE TRIGGER ALL; -ALTER TABLE tournaments ENABLE TRIGGER ALL; -ALTER TABLE tournament_stages ENABLE TRIGGER ALL; -ALTER TABLE tournament_teams ENABLE TRIGGER ALL; -ALTER TABLE tournament_team_roster ENABLE TRIGGER ALL; -ALTER TABLE tournament_brackets ENABLE TRIGGER ALL; -ALTER TABLE match_map_veto_picks ENABLE TRIGGER ALL; -ALTER TABLE player_elo ENABLE TRIGGER ALL; -ALTER TABLE player_season_stats ENABLE TRIGGER ALL; -ALTER TABLE seasons ENABLE TRIGGER ALL; +SET session_replication_role = DEFAULT; -- ============================================================ -- Game Server Nodes (one per seeded region) From ffea8e4864b28cca52e59ef1373d7855ad5cda24 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 10 Jul 2026 09:12:41 -0400 Subject: [PATCH 4/4] wip --- .../game-streamer/demo-sessions.controller.ts | 30 ++++++++ .../game-streamer/game-streamer.service.ts | 68 ++++++++++++++++--- 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/src/matches/game-streamer/demo-sessions.controller.ts b/src/matches/game-streamer/demo-sessions.controller.ts index dd32c56f..1208be2c 100644 --- a/src/matches/game-streamer/demo-sessions.controller.ts +++ b/src/matches/game-streamer/demo-sessions.controller.ts @@ -53,6 +53,36 @@ export class DemoSessionsController { response.status(204).end(); } + // 1Hz state pushes from the pod's spec-server, fanned out to the + // watcher; the per-viewer poll via demoControl("state") is the fallback. + @Post("state") + public async pushState( + @Param("sessionId") sessionId: string, + @Body() body: Record, + @Res() response: Response, + ) { + if (!body || typeof body !== "object") { + return response.status(400).json({ error: "state body required" }); + } + try { + const known = await this.gameStreamer.pushDemoSessionState( + sessionId, + body, + ); + if (!known) { + // 404 tells the pod's pusher to stop warning. + return response.status(404).json({ error: "unknown session" }); + } + } catch (error) { + this.logger.error( + `[demo ${sessionId}] pushDemoSessionState failed: ${(error as Error)?.message}`, + (error as Error)?.stack, + ); + return response.status(500).json({ error: "internal" }); + } + response.status(204).end(); + } + @Post("snapshot") @UseInterceptors(FileInterceptor("file")) public async putSnapshot( diff --git a/src/matches/game-streamer/game-streamer.service.ts b/src/matches/game-streamer/game-streamer.service.ts index 1c035201..196f3c16 100644 --- a/src/matches/game-streamer/game-streamer.service.ts +++ b/src/matches/game-streamer/game-streamer.service.ts @@ -131,10 +131,8 @@ export class GameStreamerService { private readonly steamConfig: SteamConfig; private readonly redis: Redis; - // demoControl runs per keypress and per 1Hz state poll — a Hasura - // round-trip to re-resolve the same session row on every call is the - // bulk of the perceived control latency. Cache the resolution; - // invalidated on stop and on any pod fetch failure. + // Re-resolving the session row via Hasura per control was the bulk of + // control latency. Invalidated on stop and on pod fetch failure. private readonly demoSessionCache = new Map< string, { @@ -143,6 +141,15 @@ export class GameStreamerService { } >(); private readonly demoActivityBumpedAt = new Map(); + // Routing for the pod's 1Hz state pushes; a null route caches a miss + // (straggler pushes from a reaped session) with the same expiry. + private readonly demoStateRouteCache = new Map< + string, + { + route: { matchMapId: string; watcherSteamId: string } | null; + expiresAt: number; + } + >(); constructor( private readonly logger: Logger, @@ -1118,12 +1125,12 @@ export class GameStreamerService { } } this.demoActivityBumpedAt.delete(sessionId); + this.demoStateRouteCache.delete(sessionId); } - // last_activity_at feeds the 60s idle reaper and is already bumped - // every 10s by the page's watch ping — per-control bumps only need - // to cover non-page consumers, so 20s granularity is plenty. Fire- - // and-forget: a control must not wait on a Hasura write. + // The page's 10s watch ping already feeds the 60s idle reaper; this + // covers non-page consumers. Fire-and-forget — a control must not + // wait on a Hasura write. private bumpDemoSessionActivityThrottled(sessionId: string) { const last = this.demoActivityBumpedAt.get(sessionId) ?? 0; if (Date.now() - last < 20_000) { @@ -1182,6 +1189,51 @@ export class GameStreamerService { }); } + // Rides the send-message-to-steam-id Redis channel so it reaches the + // watcher's sockets on any api instance. false = session row gone. + public async pushDemoSessionState( + sessionId: string, + state: Record, + ): Promise { + let cached = this.demoStateRouteCache.get(sessionId); + if (!cached || cached.expiresAt <= Date.now()) { + const { match_demo_sessions_by_pk } = await this.hasura.query({ + match_demo_sessions_by_pk: { + __args: { id: sessionId }, + match_map_id: true, + watcher_steam_id: true, + }, + }); + cached = { + route: match_demo_sessions_by_pk + ? { + matchMapId: match_demo_sessions_by_pk.match_map_id, + watcherSteamId: match_demo_sessions_by_pk.watcher_steam_id, + } + : null, + expiresAt: Date.now() + 60_000, + }; + this.demoStateRouteCache.set(sessionId, cached); + } + if (!cached.route) { + return false; + } + await this.redis.publish( + "send-message-to-steam-id", + JSON.stringify({ + steamId: cached.route.watcherSteamId, + event: "demo-session:state", + data: { + match_map_id: cached.route.matchMapId, + // Lets the web quiet its fallback poll. + pushed: true, + state, + }, + }), + ); + return true; + } + public async reportDemoStatus( sessionId: string, body: GameStreamerStatusDto,