From 1e5863b1df6244e328bcee1737d8fdb7e0d54bcc Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 16 Oct 2026 09:45:20 -0400 Subject: [PATCH 1/3] perf: friends list reads in-match and live lineup state from the active matches is_in_another_match and the friends subscription's player_lineup Live filter walked every friend's whole match history on each refresh. is_in_another_match now starts from the active matches (partial index widened to every active status), and a players.live_lineup computed field returns only lineup rows in Live matches so Hasura's checks run on at most one row. --- .../players/get_player_live_lineup.sql | 20 ++ .../functions/players/is_in_another_match.sql | 26 ++- .../default/tables/public_players.yaml | 5 + .../down.sql | 4 + .../up.sql | 6 + test/friends-live-state.spec.ts | 210 ++++++++++++++++++ 6 files changed, 264 insertions(+), 7 deletions(-) create mode 100644 hasura/functions/players/get_player_live_lineup.sql create mode 100644 hasura/migrations/default/1890000001080_matches_active_status_index/down.sql create mode 100644 hasura/migrations/default/1890000001080_matches_active_status_index/up.sql create mode 100644 test/friends-live-state.spec.ts diff --git a/hasura/functions/players/get_player_live_lineup.sql b/hasura/functions/players/get_player_live_lineup.sql new file mode 100644 index 000000000..8940e5797 --- /dev/null +++ b/hasura/functions/players/get_player_live_lineup.sql @@ -0,0 +1,20 @@ +-- Starts from the few Live matches, never from the player's history. The +-- LIMIT keeps the lineup lookup a per-match index probe: written as a plain +-- join, the cached generic plan assumes an average player and walks every +-- lineup the player has ever been in. +CREATE OR REPLACE FUNCTION public.get_player_live_lineup(player public.players) +RETURNS SETOF public.match_lineup_players +LANGUAGE sql +STABLE +AS $$ + SELECT mlp.* + FROM matches m + CROSS JOIN LATERAL ( + SELECT * + FROM match_lineup_players mlp + WHERE mlp.steam_id = player.steam_id + AND mlp.match_lineup_id IN (m.lineup_1_id, m.lineup_2_id) + LIMIT 1 + ) mlp + WHERE m.status = 'Live'; +$$; diff --git a/hasura/functions/players/is_in_another_match.sql b/hasura/functions/players/is_in_another_match.sql index 39fbe4c9a..0b6e299f3 100644 --- a/hasura/functions/players/is_in_another_match.sql +++ b/hasura/functions/players/is_in_another_match.sql @@ -1,19 +1,31 @@ +-- Starts from the few active matches, never from the player's history. The +-- LIMIT keeps the lineup lookup a per-match index probe (a plain join or +-- EXISTS lets the cached generic plan walk every lineup the player has ever +-- been in), and the CTE keeps match_series_decided to the player's own matches. CREATE OR REPLACE FUNCTION public.is_in_another_match(player public.players) RETURNS boolean LANGUAGE sql STABLE AS $$ + WITH mine AS MATERIALIZED ( + SELECT m.id, m.status, m.scheduled_at + FROM matches m + CROSS JOIN LATERAL ( + SELECT 1 + FROM match_lineup_players mlp + WHERE mlp.steam_id = player.steam_id + AND mlp.match_lineup_id IN (m.lineup_1_id, m.lineup_2_id) + LIMIT 1 + ) member + WHERE m.status IN ('Veto', 'WaitingForCheckIn', 'WaitingForServer', 'Live', 'Scheduled') + AND m.source <> 'practice' + ) SELECT EXISTS ( SELECT 1 - FROM get_player_matches(player) AS pm - -- A utility practice server is a Live match row seating its host, and - -- the host can leave it whenever they like. - WHERE pm.source <> 'practice' - AND ( + FROM mine pm + WHERE ( pm.status IN ('Veto', 'WaitingForCheckIn', 'WaitingForServer') OR (pm.status = 'Live' AND NOT match_series_decided(pm.id)) - -- A scheduled match only ties the player up once it's within an hour - -- of kickoff; before that they're free to play other matches. OR ( pm.status = 'Scheduled' AND pm.scheduled_at IS NOT NULL diff --git a/hasura/metadata/databases/default/tables/public_players.yaml b/hasura/metadata/databases/default/tables/public_players.yaml index f15bfadad..bc6aa5720 100644 --- a/hasura/metadata/databases/default/tables/public_players.yaml +++ b/hasura/metadata/databases/default/tables/public_players.yaml @@ -356,6 +356,11 @@ computed_fields: function: name: is_muted schema: public + - name: live_lineup + definition: + function: + name: get_player_live_lineup + schema: public - name: losses definition: function: diff --git a/hasura/migrations/default/1890000001080_matches_active_status_index/down.sql b/hasura/migrations/default/1890000001080_matches_active_status_index/down.sql new file mode 100644 index 000000000..e953e422f --- /dev/null +++ b/hasura/migrations/default/1890000001080_matches_active_status_index/down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS public.idx_matches_status_lineup; +CREATE INDEX idx_matches_status_lineup + ON public.matches (status, lineup_1_id, lineup_2_id) + WHERE status IN ('Live', 'Veto'); diff --git a/hasura/migrations/default/1890000001080_matches_active_status_index/up.sql b/hasura/migrations/default/1890000001080_matches_active_status_index/up.sql new file mode 100644 index 000000000..79799a4c2 --- /dev/null +++ b/hasura/migrations/default/1890000001080_matches_active_status_index/up.sql @@ -0,0 +1,6 @@ +-- is_in_another_match and get_player_live_lineup start from the active +-- matches, so the partial index has to hold every status they look for. +DROP INDEX IF EXISTS public.idx_matches_status_lineup; +CREATE INDEX idx_matches_status_lineup + ON public.matches (status, lineup_1_id, lineup_2_id) + WHERE status IN ('Veto', 'WaitingForCheckIn', 'WaitingForServer', 'Live', 'Scheduled'); diff --git a/test/friends-live-state.spec.ts b/test/friends-live-state.spec.ts new file mode 100644 index 000000000..25caa49f0 --- /dev/null +++ b/test/friends-live-state.spec.ts @@ -0,0 +1,210 @@ +import { PostgresService } from "../src/postgres/postgres.service"; +import { Fixtures, MatchRow } from "./utils/fixtures"; +import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; + +// The friends list asks is_in_another_match and live_lineup of every friend on +// every refresh, so both must answer from the panel's few active matches +// rather than from how many matches the friend has ever played. +describe("friends list live state", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + let poolId: string; + + beforeAll(async () => { + db = await bootMigratedDb("FriendsLiveStateTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199300000000n); + await fx.region(); + poolId = (await fx.mapPool(1)).poolId; + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + const matchIn = async ( + status?: string, + ): Promise => { + const match = await fx.match({ bestOf: 1, mapPoolId: poolId }); + if (status) { + await postgres.query("UPDATE matches SET status = $2 WHERE id = $1", [ + match.id, + status, + ]); + } + return match; + }; + + const inAnotherMatch = async (steam: string) => { + const [row] = await postgres.query>( + "SELECT is_in_another_match(p) AS v FROM players p WHERE steam_id = $1", + [steam], + ); + return row.v; + }; + + const liveLineup = async (steam: string) => + ( + await postgres.query>( + "SELECT l.id FROM players p CROSS JOIN LATERAL get_player_live_lineup(p) l WHERE p.steam_id = $1 ORDER BY l.id", + [steam], + ) + ).map(({ id }) => id); + + // What the friends subscription asked of player_lineup before: rows whose + // lineup's match is Live. + const liveLineupThroughTheMatch = async (steam: string) => + ( + await postgres.query>( + `SELECT mlp.id + FROM match_lineup_players mlp + JOIN match_lineups ml ON ml.id = mlp.match_lineup_id + JOIN matches m ON m.id = ml.match_id + WHERE mlp.steam_id = $1 AND m.status = 'Live' + ORDER BY mlp.id`, + [steam], + ) + ).map(({ id }) => id); + + describe("is_in_another_match", () => { + it.each([ + ["Veto", true], + ["WaitingForCheckIn", true], + ["WaitingForServer", true], + ["Live", true], + ["Canceled", false], + [undefined, false], + ])("a lineup player in a %s match: %s", async (status, expected) => { + const match = await matchIn(status); + const player = await fx.lineupPlayer(match.lineup_1_id); + + expect(await inAnotherMatch(player)).toBe(expected); + }); + + it("counts the second lineup and nobody outside the match", async () => { + const match = await matchIn("Live"); + const opponent = await fx.lineupPlayer(match.lineup_2_id); + const bystander = await fx.player(); + + expect(await inAnotherMatch(opponent)).toBe(true); + expect(await inAnotherMatch(bystander)).toBe(false); + }); + + it("lets go of a player whose match is in a paused tournament", async () => { + const inTournament = async (status: string) => { + const match = await matchIn("Live"); + const [tournament] = await postgres.query>( + `INSERT INTO tournaments (name, start, organizer_steam_id, match_options_id, status) + VALUES ($1, now(), $2, $3, 'Setup') RETURNING id`, + [fx.nextName("cup"), await fx.player(), match.options_id], + ); + const [stage] = await postgres.query>( + `INSERT INTO tournament_stages (tournament_id, type, "order", min_teams, max_teams) + VALUES ($1, 'SingleElimination', 1, 4, 8) RETURNING id`, + [tournament.id], + ); + await postgres.query( + `INSERT INTO tournament_brackets (tournament_stage_id, round, match_number, path, match_id) + VALUES ($1, 1, 1, 'WB', $2)`, + [stage.id, match.id], + ); + // Pausing for real needs a launched tournament; only the status matters. + await postgres.transaction(async (client) => { + await client.query("SET LOCAL session_replication_role = replica"); + await client.query( + "UPDATE tournaments SET status = $2 WHERE id = $1", + [tournament.id, status], + ); + }); + return fx.lineupPlayer(match.lineup_1_id); + }; + + expect(await inAnotherMatch(await inTournament("Setup"))).toBe(true); + expect(await inAnotherMatch(await inTournament("Paused"))).toBe(false); + }); + }); + + describe("get_player_live_lineup", () => { + it("returns the lineup row of each Live match the player is in, as the match join does", async () => { + const live = await matchIn("Live"); + const otherLive = await matchIn("Live"); + const veto = await matchIn("Veto"); + const player = await fx.lineupPlayer(live.lineup_1_id); + const opponent = await fx.lineupPlayer(live.lineup_2_id); + await fx.lineupPlayer(otherLive.lineup_2_id, player); + const vetoing = await fx.lineupPlayer(veto.lineup_1_id); + const idle = await fx.player(); + + for (const steam of [player, opponent, vetoing, idle]) { + expect(await liveLineup(steam)).toEqual( + await liveLineupThroughTheMatch(steam), + ); + } + expect(await liveLineup(player)).toHaveLength(2); + expect(await liveLineup(opponent)).toHaveLength(1); + expect(await liveLineup(vetoing)).toEqual([]); + expect(await liveLineup(idle)).toEqual([]); + }); + + it("drops the row once the match leaves Live", async () => { + const match = await matchIn("Live"); + const player = await fx.lineupPlayer(match.lineup_1_id); + expect(await liveLineup(player)).toHaveLength(1); + + await postgres.query( + "UPDATE matches SET status = 'Canceled' WHERE id = $1", + [match.id], + ); + + expect(await liveLineup(player)).toEqual([]); + }); + }); + + describe("cost", () => { + // Shared buffers one call reads with the plan Hasura's prepared statements + // settle on: the cached generic plan, not one planned for this player. + const buffersFor = (sql: string, steam: string) => + postgres.transaction(async (client) => { + await client.query("SET LOCAL plan_cache_mode = force_generic_plan"); + const explain = `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${sql}`; + for (let i = 0; i < 6; i++) { + await client.query(explain, [steam]); + } + const { rows } = await client.query(explain, [steam]); + const plan = rows[0]["QUERY PLAN"][0].Plan; + return plan["Shared Hit Blocks"] + plan["Shared Read Blocks"]; + }); + + const withHistory = async (matches: number) => { + const steam = await fx.player(); + for (let i = 0; i < matches; i++) { + const { matchId } = await fx.bareMatch(new Date().toISOString()); + const [match] = await postgres.query>( + "SELECT lineup_1_id FROM matches WHERE id = $1", + [matchId], + ); + await fx.lineupPlayer(match.lineup_1_id, steam); + } + return steam; + }; + + it("does not grow with the number of matches a player has played", async () => { + for (const status of ["Live", "Live", "Veto", "WaitingForServer"]) { + const match = await matchIn(status); + await fx.lineupPlayer(match.lineup_1_id); + } + const veteran = await withHistory(150); + const newcomer = await withHistory(1); + + for (const sql of [ + "SELECT is_in_another_match(p) FROM players p WHERE steam_id = $1", + "SELECT (SELECT count(*) FROM get_player_live_lineup(p)) FROM players p WHERE steam_id = $1", + ]) { + const veteranBuffers = await buffersFor(sql, veteran); + const newcomerBuffers = await buffersFor(sql, newcomer); + expect(veteranBuffers - newcomerBuffers).toBeLessThanOrEqual(10); + } + }, 120_000); + }); +}); From 51482fcf963dfec52ee95e2b874219d13c784307 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 16 Oct 2026 10:14:48 -0400 Subject: [PATCH 2/3] perf: friends live state stays flat on import-heavy and schedule-heavy panels Review of the first commit found three gaps: on a panel of one-off players the generic plan probed lineups through the steam_id index and re-read the whole history per active match; matches scheduled far ahead were checked for every friend; and live_lineup returned practice sessions the matches permissions hide. Lineups are now probed on their unique key, a new (status, scheduled_at) partial index serves the active statuses and the Scheduled cut-off as two ranges (additive, the old index stays), and practice is excluded. Each gap has a cost or behavior test that fails on the first commit. --- .../players/get_player_live_lineup.sql | 21 ++- .../functions/players/is_in_another_match.sql | 27 ++-- .../down.sql | 1 + .../up.sql | 7 + .../down.sql | 4 - .../up.sql | 6 - test/friends-live-state.spec.ts | 130 ++++++++++++++++-- 7 files changed, 160 insertions(+), 36 deletions(-) create mode 100644 hasura/migrations/default/1890000001080_matches_active_schedule_index/down.sql create mode 100644 hasura/migrations/default/1890000001080_matches_active_schedule_index/up.sql delete mode 100644 hasura/migrations/default/1890000001080_matches_active_status_index/down.sql delete mode 100644 hasura/migrations/default/1890000001080_matches_active_status_index/up.sql diff --git a/hasura/functions/players/get_player_live_lineup.sql b/hasura/functions/players/get_player_live_lineup.sql index 8940e5797..9356eb604 100644 --- a/hasura/functions/players/get_player_live_lineup.sql +++ b/hasura/functions/players/get_player_live_lineup.sql @@ -1,7 +1,8 @@ --- Starts from the few Live matches, never from the player's history. The --- LIMIT keeps the lineup lookup a per-match index probe: written as a plain --- join, the cached generic plan assumes an average player and walks every --- lineup the player has ever been in. +-- Starts from the few Live matches, never from the player's history. Each +-- lineup is probed on both columns of its unique key: with steam_id alone the +-- cached generic plan can pick the steam_id index and re-read the player's +-- whole history once per Live match. Practice sessions are Live matches that +-- the matches permissions hide, so they are left out here too. CREATE OR REPLACE FUNCTION public.get_player_live_lineup(player public.players) RETURNS SETOF public.match_lineup_players LANGUAGE sql @@ -12,9 +13,15 @@ AS $$ CROSS JOIN LATERAL ( SELECT * FROM match_lineup_players mlp - WHERE mlp.steam_id = player.steam_id - AND mlp.match_lineup_id IN (m.lineup_1_id, m.lineup_2_id) + WHERE mlp.match_lineup_id = m.lineup_1_id + AND mlp.steam_id = player.steam_id + UNION ALL + SELECT * + FROM match_lineup_players mlp + WHERE mlp.match_lineup_id = m.lineup_2_id + AND mlp.steam_id = player.steam_id LIMIT 1 ) mlp - WHERE m.status = 'Live'; + WHERE m.status = 'Live' + AND m.source <> 'practice'; $$; diff --git a/hasura/functions/players/is_in_another_match.sql b/hasura/functions/players/is_in_another_match.sql index 0b6e299f3..2fc40ee29 100644 --- a/hasura/functions/players/is_in_another_match.sql +++ b/hasura/functions/players/is_in_another_match.sql @@ -1,7 +1,10 @@ --- Starts from the few active matches, never from the player's history. The --- LIMIT keeps the lineup lookup a per-match index probe (a plain join or --- EXISTS lets the cached generic plan walk every lineup the player has ever --- been in), and the CTE keeps match_series_decided to the player's own matches. +-- Starts from the few active matches, never from the player's history. +-- idx_matches_active_schedule answers the active statuses and the Scheduled +-- cut-off as two index ranges, so matches scheduled further out are never read. +-- Each lineup is probed on both columns of its unique key: with steam_id alone +-- the cached generic plan can pick the steam_id index and re-read the player's +-- whole history once per active match. The CTE keeps match_series_decided to +-- the player's own matches. CREATE OR REPLACE FUNCTION public.is_in_another_match(player public.players) RETURNS boolean LANGUAGE sql @@ -13,12 +16,20 @@ AS $$ CROSS JOIN LATERAL ( SELECT 1 FROM match_lineup_players mlp - WHERE mlp.steam_id = player.steam_id - AND mlp.match_lineup_id IN (m.lineup_1_id, m.lineup_2_id) + WHERE mlp.match_lineup_id = m.lineup_1_id + AND mlp.steam_id = player.steam_id + UNION ALL + SELECT 1 + FROM match_lineup_players mlp + WHERE mlp.match_lineup_id = m.lineup_2_id + AND mlp.steam_id = player.steam_id LIMIT 1 ) member - WHERE m.status IN ('Veto', 'WaitingForCheckIn', 'WaitingForServer', 'Live', 'Scheduled') - AND m.source <> 'practice' + WHERE m.source <> 'practice' + AND ( + m.status IN ('Veto', 'WaitingForCheckIn', 'WaitingForServer', 'Live') + OR (m.status = 'Scheduled' AND m.scheduled_at <= NOW() + INTERVAL '1 hour') + ) ) SELECT EXISTS ( SELECT 1 diff --git a/hasura/migrations/default/1890000001080_matches_active_schedule_index/down.sql b/hasura/migrations/default/1890000001080_matches_active_schedule_index/down.sql new file mode 100644 index 000000000..cc3634c99 --- /dev/null +++ b/hasura/migrations/default/1890000001080_matches_active_schedule_index/down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS public.idx_matches_active_schedule; diff --git a/hasura/migrations/default/1890000001080_matches_active_schedule_index/up.sql b/hasura/migrations/default/1890000001080_matches_active_schedule_index/up.sql new file mode 100644 index 000000000..c41bb784a --- /dev/null +++ b/hasura/migrations/default/1890000001080_matches_active_schedule_index/up.sql @@ -0,0 +1,7 @@ +-- is_in_another_match and get_player_live_lineup start from the active +-- matches: the active statuses, plus Scheduled matches by start time so the +-- ones further out are never read. +CREATE INDEX IF NOT EXISTS idx_matches_active_schedule + ON public.matches (status, scheduled_at) + INCLUDE (id, lineup_1_id, lineup_2_id, source) + WHERE status IN ('Veto', 'WaitingForCheckIn', 'WaitingForServer', 'Live', 'Scheduled'); diff --git a/hasura/migrations/default/1890000001080_matches_active_status_index/down.sql b/hasura/migrations/default/1890000001080_matches_active_status_index/down.sql deleted file mode 100644 index e953e422f..000000000 --- a/hasura/migrations/default/1890000001080_matches_active_status_index/down.sql +++ /dev/null @@ -1,4 +0,0 @@ -DROP INDEX IF EXISTS public.idx_matches_status_lineup; -CREATE INDEX idx_matches_status_lineup - ON public.matches (status, lineup_1_id, lineup_2_id) - WHERE status IN ('Live', 'Veto'); diff --git a/hasura/migrations/default/1890000001080_matches_active_status_index/up.sql b/hasura/migrations/default/1890000001080_matches_active_status_index/up.sql deleted file mode 100644 index 79799a4c2..000000000 --- a/hasura/migrations/default/1890000001080_matches_active_status_index/up.sql +++ /dev/null @@ -1,6 +0,0 @@ --- is_in_another_match and get_player_live_lineup start from the active --- matches, so the partial index has to hold every status they look for. -DROP INDEX IF EXISTS public.idx_matches_status_lineup; -CREATE INDEX idx_matches_status_lineup - ON public.matches (status, lineup_1_id, lineup_2_id) - WHERE status IN ('Veto', 'WaitingForCheckIn', 'WaitingForServer', 'Live', 'Scheduled'); diff --git a/test/friends-live-state.spec.ts b/test/friends-live-state.spec.ts index 25caa49f0..31761afd8 100644 --- a/test/friends-live-state.spec.ts +++ b/test/friends-live-state.spec.ts @@ -53,7 +53,8 @@ describe("friends list live state", () => { ).map(({ id }) => id); // What the friends subscription asked of player_lineup before: rows whose - // lineup's match is Live. + // lineup's match is Live and visible, and the matches permissions hide + // practice sessions from every role. const liveLineupThroughTheMatch = async (steam: string) => ( await postgres.query>( @@ -61,7 +62,7 @@ describe("friends list live state", () => { FROM match_lineup_players mlp JOIN match_lineups ml ON ml.id = mlp.match_lineup_id JOIN matches m ON m.id = ml.match_id - WHERE mlp.steam_id = $1 AND m.status = 'Live' + WHERE mlp.steam_id = $1 AND m.status = 'Live' AND m.source <> 'practice' ORDER BY mlp.id`, [steam], ) @@ -147,6 +148,18 @@ describe("friends list live state", () => { expect(await liveLineup(idle)).toEqual([]); }); + it("keeps a practice session out, as the matches permissions do", async () => { + const match = await matchIn("Live"); + const player = await fx.lineupPlayer(match.lineup_1_id); + await postgres.query( + "UPDATE matches SET source = 'practice' WHERE id = $1", + [match.id], + ); + + expect(await liveLineup(player)).toEqual([]); + expect(await liveLineupThroughTheMatch(player)).toEqual([]); + }); + it("drops the row once the match leaves Live", async () => { const match = await matchIn("Live"); const player = await fx.lineupPlayer(match.lineup_1_id); @@ -189,22 +202,117 @@ describe("friends list live state", () => { return steam; }; - it("does not grow with the number of matches a player has played", async () => { - for (const status of ["Live", "Live", "Veto", "WaitingForServer"]) { + const FUNCTIONS = [ + "SELECT is_in_another_match(p) FROM players p WHERE steam_id = $1", + "SELECT (SELECT count(*) FROM get_player_live_lineup(p)) FROM players p WHERE steam_id = $1", + ]; + + const activeMatches = async () => { + for (const status of [ + "Live", + "Live", + "Live", + "Veto", + "WaitingForServer", + "Live", + "Veto", + "Live", + ]) { const match = await matchIn(status); await fx.lineupPlayer(match.lineup_1_id); } + }; + + const extraBuffers = async (veteran: string, newcomer: string) => { + const extra: Record = {}; + for (const sql of FUNCTIONS) { + extra[sql] = + (await buffersFor(sql, veteran)) - (await buffersFor(sql, newcomer)); + } + return extra; + }; + + it("does not grow with the number of matches a player has played", async () => { + await activeMatches(); const veteran = await withHistory(150); const newcomer = await withHistory(1); - for (const sql of [ - "SELECT is_in_another_match(p) FROM players p WHERE steam_id = $1", - "SELECT (SELECT count(*) FROM get_player_live_lineup(p)) FROM players p WHERE steam_id = $1", - ]) { - const veteranBuffers = await buffersFor(sql, veteran); - const newcomerBuffers = await buffersFor(sql, newcomer); - expect(veteranBuffers - newcomerBuffers).toBeLessThanOrEqual(10); + for (const extra of Object.values( + await extraBuffers(veteran, newcomer), + )) { + expect(extra).toBeLessThanOrEqual(10); } }, 120_000); + + // An import-heavy panel: most players appear in one lineup, so the planner + // rates a lookup on steam_id alone as just as selective as the unique key. + it("does not grow with history spread among one-off players", async () => { + await activeMatches(); + const veteran = await fx.player(); + const newcomer = await withHistory(1); + await postgres.transaction(async (client) => { + await client.query("SET LOCAL session_replication_role = replica"); + await client.query( + `INSERT INTO players (steam_id, name, role) + SELECT 76561199390000000 + g, 'one-off ' || g, 'user' FROM generate_series(1, 6000) g`, + ); + await client.query( + `CREATE TEMP TABLE one_off_lineups AS + SELECT g, gen_random_uuid() AS id FROM generate_series(1, 6150) g`, + ); + await client.query( + "INSERT INTO match_lineups (id) SELECT id FROM one_off_lineups ORDER BY g", + ); + await client.query( + `INSERT INTO match_lineup_players (match_lineup_id, steam_id) + SELECT id, CASE WHEN g % 41 = 0 THEN $1::bigint ELSE 76561199390000000 + g - g / 41 END + FROM one_off_lineups ORDER BY g`, + [veteran], + ); + }); + await postgres.query("VACUUM ANALYZE match_lineup_players"); + + for (const extra of Object.values( + await extraBuffers(veteran, newcomer), + )) { + expect(extra).toBeLessThanOrEqual(10); + } + }, 120_000); + + it("does not grow with the panel's finished or far-off scheduled matches", async () => { + await activeMatches(); + const player = await withHistory(1); + const before = await Promise.all( + FUNCTIONS.map((sql) => buffersFor(sql, player)), + ); + + const added: Array = []; + for (let i = 0; i < 400; i++) { + added.push((await fx.bareMatch(new Date().toISOString())).matchId); + } + // Only the statuses matter here, not the transitions that lead to them. + await postgres.transaction(async (client) => { + await client.query("SET LOCAL session_replication_role = replica"); + await client.query( + `UPDATE matches SET status = 'Scheduled', scheduled_at = now() + interval '7 days' + WHERE id = ANY($1::uuid[])`, + [added.slice(0, 200)], + ); + await client.query( + "UPDATE matches SET status = 'Finished' WHERE id = ANY($1::uuid[])", + [added.slice(200)], + ); + }); + await postgres.query("VACUUM ANALYZE matches"); + + const after = await Promise.all( + FUNCTIONS.map((sql) => buffersFor(sql, player)), + ); + const growth = FUNCTIONS.map((sql, i) => ({ + sql, + buffers: after[i] - before[i], + })); + expect(growth.filter(({ buffers }) => buffers > 10)).toEqual([]); + }, 120_000); }); }); From fc8c14edde9339b22e2194f0d5b777f5bf59b680 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 16 Oct 2026 10:30:10 -0400 Subject: [PATCH 3/3] perf: drop the steam_id-only lineup index the probe could tie with Re-review found the planner prices idx_match_lineup_players_steam_id exactly like the unique key for a (match_lineup_id, steam_id) probe and breaks the tie by index age, so a REINDEX of it brought back the read-the-whole-history plan (1008 extra buffers). idx_match_lineup_players_steam_id_lineup_id already serves every steam_id lookup. The spread-history test now rebuilds every index first, and a catalog test keeps a steam_id-only index from coming back. --- .../players/get_player_live_lineup.sql | 9 +++++---- .../functions/players/is_in_another_match.sql | 8 ++++---- .../down.sql | 2 ++ .../up.sql | 6 ++++++ test/friends-live-state.spec.ts | 20 +++++++++++++++++++ 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/hasura/functions/players/get_player_live_lineup.sql b/hasura/functions/players/get_player_live_lineup.sql index 9356eb604..270846995 100644 --- a/hasura/functions/players/get_player_live_lineup.sql +++ b/hasura/functions/players/get_player_live_lineup.sql @@ -1,8 +1,9 @@ -- Starts from the few Live matches, never from the player's history. Each --- lineup is probed on both columns of its unique key: with steam_id alone the --- cached generic plan can pick the steam_id index and re-read the player's --- whole history once per Live match. Practice sessions are Live matches that --- the matches permissions hide, so they are left out here too. +-- lineup is probed on both columns of its unique key, so any index the planner +-- picks reads a single entry; an index on steam_id alone would read the +-- player's whole history instead, which is why match_lineup_players has none. +-- Practice sessions are Live matches the matches permissions hide, so they are +-- left out here too. CREATE OR REPLACE FUNCTION public.get_player_live_lineup(player public.players) RETURNS SETOF public.match_lineup_players LANGUAGE sql diff --git a/hasura/functions/players/is_in_another_match.sql b/hasura/functions/players/is_in_another_match.sql index 2fc40ee29..beeefaa1e 100644 --- a/hasura/functions/players/is_in_another_match.sql +++ b/hasura/functions/players/is_in_another_match.sql @@ -1,10 +1,10 @@ -- Starts from the few active matches, never from the player's history. -- idx_matches_active_schedule answers the active statuses and the Scheduled -- cut-off as two index ranges, so matches scheduled further out are never read. --- Each lineup is probed on both columns of its unique key: with steam_id alone --- the cached generic plan can pick the steam_id index and re-read the player's --- whole history once per active match. The CTE keeps match_series_decided to --- the player's own matches. +-- Each lineup is probed on both columns of its unique key, so any index the +-- planner picks reads a single entry; an index on steam_id alone would read the +-- player's whole history instead, which is why match_lineup_players has none. +-- The CTE keeps match_series_decided to the player's own matches. CREATE OR REPLACE FUNCTION public.is_in_another_match(player public.players) RETURNS boolean LANGUAGE sql diff --git a/hasura/migrations/default/1890000001080_matches_active_schedule_index/down.sql b/hasura/migrations/default/1890000001080_matches_active_schedule_index/down.sql index cc3634c99..9e0d9033e 100644 --- a/hasura/migrations/default/1890000001080_matches_active_schedule_index/down.sql +++ b/hasura/migrations/default/1890000001080_matches_active_schedule_index/down.sql @@ -1 +1,3 @@ +CREATE INDEX IF NOT EXISTS idx_match_lineup_players_steam_id + ON public.match_lineup_players (steam_id); DROP INDEX IF EXISTS public.idx_matches_active_schedule; diff --git a/hasura/migrations/default/1890000001080_matches_active_schedule_index/up.sql b/hasura/migrations/default/1890000001080_matches_active_schedule_index/up.sql index c41bb784a..edfeb0000 100644 --- a/hasura/migrations/default/1890000001080_matches_active_schedule_index/up.sql +++ b/hasura/migrations/default/1890000001080_matches_active_schedule_index/up.sql @@ -5,3 +5,9 @@ CREATE INDEX IF NOT EXISTS idx_matches_active_schedule ON public.matches (status, scheduled_at) INCLUDE (id, lineup_1_id, lineup_2_id, source) WHERE status IN ('Veto', 'WaitingForCheckIn', 'WaitingForServer', 'Live', 'Scheduled'); + +-- Both functions probe a lineup on (match_lineup_id, steam_id). An index on +-- steam_id alone costs the same to the planner, and when it wins that tie the +-- probe reads the player's whole history once per active match. +-- idx_match_lineup_players_steam_id_lineup_id still serves steam_id lookups. +DROP INDEX IF EXISTS public.idx_match_lineup_players_steam_id; diff --git a/test/friends-live-state.spec.ts b/test/friends-live-state.spec.ts index 31761afd8..98d6e8d2f 100644 --- a/test/friends-live-state.spec.ts +++ b/test/friends-live-state.spec.ts @@ -271,6 +271,9 @@ describe("friends list live state", () => { ); }); await postgres.query("VACUUM ANALYZE match_lineup_players"); + // Equally cheap indexes are tried newest first, so rebuild them all + // rather than let the fixture's build order pick the winner. + await postgres.query("REINDEX TABLE CONCURRENTLY match_lineup_players"); for (const extra of Object.values( await extraBuffers(veteran, newcomer), @@ -279,6 +282,23 @@ describe("friends list live state", () => { } }, 120_000); + // The planner prices a steam_id-only index the same as the unique key for + // a (match_lineup_id, steam_id) probe; whenever it wins that tie the probe + // reads the player's whole history. + it("keeps no index on match_lineup_players.steam_id alone", async () => { + const indexes = await postgres.query>( + `SELECT i.relname AS indexname + FROM pg_index x + JOIN pg_class i ON i.oid = x.indexrelid + WHERE x.indrelid = 'public.match_lineup_players'::regclass + AND x.indnkeyatts = 1 + AND x.indkey[0] = (SELECT attnum FROM pg_attribute + WHERE attrelid = 'public.match_lineup_players'::regclass + AND attname = 'steam_id')`, + ); + expect(indexes).toEqual([]); + }); + it("does not grow with the panel's finished or far-off scheduled matches", async () => { await activeMatches(); const player = await withHistory(1);