From cb65145ec25098e3a38bf587371bff7c875aa2ec Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 7 Aug 2026 15:14:56 -0400 Subject: [PATCH 1/4] bug: fix tournament start fks --- .../down.sql | 3 + .../up.sql | 11 ++ hasura/views/v_event_matches.sql | 8 + test/event-tournament-links.spec.ts | 151 ++++++++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 hasura/migrations/default/1876000000200_defer_event_match_links_match_id_fkey/down.sql create mode 100644 hasura/migrations/default/1876000000200_defer_event_match_links_match_id_fkey/up.sql create mode 100644 test/event-tournament-links.spec.ts diff --git a/hasura/migrations/default/1876000000200_defer_event_match_links_match_id_fkey/down.sql b/hasura/migrations/default/1876000000200_defer_event_match_links_match_id_fkey/down.sql new file mode 100644 index 00000000..61c44790 --- /dev/null +++ b/hasura/migrations/default/1876000000200_defer_event_match_links_match_id_fkey/down.sql @@ -0,0 +1,3 @@ +alter table "public"."event_match_links" + alter constraint "event_match_links_match_id_fkey" + not deferrable initially immediate; diff --git a/hasura/migrations/default/1876000000200_defer_event_match_links_match_id_fkey/up.sql b/hasura/migrations/default/1876000000200_defer_event_match_links_match_id_fkey/up.sql new file mode 100644 index 00000000..9186b710 --- /dev/null +++ b/hasura/migrations/default/1876000000200_defer_event_match_links_match_id_fkey/up.sql @@ -0,0 +1,11 @@ +-- Safety net for the same ordering hazard v_event_matches now guards against: +-- schedule_tournament_match() deliberately points tournament_brackets.match_id +-- at a matches row it has not inserted yet (that FK is DEFERRABLE INITIALLY +-- DEFERRED so tai_match can see the bracket link). Any trigger that fires in +-- that window and derives a link from a bracket would hit an immediate FK here +-- and abort the caller's whole transaction -- which is how starting a +-- tournament attached to an event broke. Deferring the check to commit means +-- such a link is judged once the matches insert has landed. +alter table "public"."event_match_links" + alter constraint "event_match_links_match_id_fkey" + deferrable initially deferred; diff --git a/hasura/views/v_event_matches.sql b/hasura/views/v_event_matches.sql index df2d7784..15794398 100644 --- a/hasura/views/v_event_matches.sql +++ b/hasura/views/v_event_matches.sql @@ -35,6 +35,14 @@ WITH windowed AS ( ) SELECT DISTINCT s.event_id, s.match_id FROM ( + -- The join to matches is load-bearing, not cosmetic: + -- tournament_brackets.match_id is DEFERRABLE INITIALLY DEFERRED and + -- schedule_tournament_match() sets it BEFORE inserting the matches row. + -- The bracket's AFTER UPDATE OF match_id trigger fires inside that window, + -- so an unjoined tb.match_id yields a match that does not exist yet and + -- event_match_links' immediate FK rejects it, aborting the whole + -- transaction. Ignoring the bracket until its match exists is safe: the + -- matches INSERT trigger re-derives the link a moment later. SELECT et.event_id, tb.match_id FROM event_tournaments et JOIN tournament_stages ts ON ts.tournament_id = et.tournament_id diff --git a/test/event-tournament-links.spec.ts b/test/event-tournament-links.spec.ts new file mode 100644 index 00000000..1fc30026 --- /dev/null +++ b/test/event-tournament-links.spec.ts @@ -0,0 +1,151 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { Fixtures } from "./utils/fixtures"; +import { TournamentFixtures } from "./utils/tournament-fixtures"; +import { + bootMigratedDb, + seedRegionWithServer, + SqlTestDb, +} from "./utils/sql-test-db"; + +// Reproduces "insert or update on table event_match_links violates foreign key +// constraint event_match_links_match_id_fkey", raised when taking a tournament +// that is attached to an event Live. +// +// schedule_tournament_match() sets tournament_brackets.match_id BEFORE +// inserting the matches row (that FK is DEFERRABLE INITIALLY DEFERRED so +// tai_match can already see the bracket link). tg_brackets_sync_event_match_links +// fires in that gap, and v_event_matches' tournament branch read tb.match_id +// without joining matches, so the sync tried to link a match that did not exist +// yet against an immediate FK — killing the whole status transition. +describe("event <-> tournament match links (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let fx: Fixtures; + let tfx: TournamentFixtures; + + beforeAll(async () => { + db = await bootMigratedDb("EventTournamentLinksTest"); + postgres = db.postgres; + fx = new Fixtures(postgres, 76561199300000000n); + tfx = new TournamentFixtures(postgres, fx); + await seedRegionWithServer(postgres, "TestA"); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + await postgres.query("DELETE FROM events"); + await postgres.query("DELETE FROM matches"); + await postgres.query("DELETE FROM tournaments"); + await postgres.query("DELETE FROM match_options"); + await postgres.query("DELETE FROM teams"); + await postgres.query("DELETE FROM players"); + }); + + const createEvent = async (startsAt: string, endsAt: string | null) => { + const organizer = await fx.player(); + const [event] = await postgres.query>( + `INSERT INTO events (name, starts_at, ends_at, organizer_steam_id) + VALUES ($1, $2, $3, $4) RETURNING id`, + [fx.nextName("event"), startsAt, endsAt, organizer], + ); + return event.id; + }; + + const attach = (eventId: string, tournamentId: string) => + postgres.query( + "INSERT INTO event_tournaments (event_id, tournament_id) VALUES ($1, $2)", + [eventId, tournamentId], + ); + + const linkedMatchIds = async (eventId: string) => { + const rows = await postgres.query>( + "SELECT match_id FROM event_match_links WHERE event_id = $1 ORDER BY match_id", + [eventId], + ); + return rows.map((r) => r.match_id); + }; + + const bracketMatchIds = async (stageId: string) => { + const brackets = await tfx.getBrackets(stageId); + return brackets + .map((b) => b.match_id) + .filter((id): id is string => id !== null) + .sort(); + }; + + it("reproduces the bug: taking an attached tournament Live schedules matches without an FK violation", async () => { + const eventId = await createEvent(new Date().toISOString(), null); + + const tournament = await tfx.createTournament([ + { type: "SingleElimination", order: 1, minTeams: 4, maxTeams: 8 }, + ]); + await attach(eventId, tournament.id); + + await tfx.setStatus(tournament.id, tournament.organizer, "RegistrationOpen"); + for (let i = 0; i < 4; i++) { + await tfx.registerTeam(tournament.id, await fx.team(1)); + } + await tfx.setStatus( + tournament.id, + tournament.organizer, + "RegistrationClosed", + ); + + // This is the mutation that failed in production with + // event_match_links_match_id_fkey; it must simply go through. + await tfx.setStatus(tournament.id, tournament.organizer, "Live"); + + expect(await tfx.tournamentStatus(tournament.id)).toBe("Live"); + + // The scheduled matches are linked to the event once the matches rows land. + const scheduled = await bracketMatchIds(tournament.stageIds[0]); + expect(scheduled.length).toBeGreaterThan(0); + expect(await linkedMatchIds(eventId)).toEqual(scheduled); + }); + + it("the tournament branch never emits a match that does not exist", async () => { + const eventId = await createEvent(new Date().toISOString(), null); + const tournament = await tfx.launch( + [{ type: "SingleElimination", order: 1, minTeams: 4, maxTeams: 8 }], + 4, + ); + await attach(eventId, tournament.id); + + const [{ count: phantoms }] = await postgres.query< + Array<{ count: number }> + >( + `SELECT count(*)::int AS count + FROM v_event_matches v + LEFT JOIN matches m ON m.id = v.match_id + WHERE m.id IS NULL`, + ); + expect(phantoms).toBe(0); + + // Attaching after the fact still backfills the links. + expect(await linkedMatchIds(eventId)).toEqual( + await bracketMatchIds(tournament.stageIds[0]), + ); + }); + + // The tournament branch is deliberately unwindowed: an attached tournament's + // matches are the event's whatever the dates say. Guards the new join to + // matches against accidentally dragging that branch under `windowed`. + it("links an attached tournament's matches even outside the event window", async () => { + const eventId = await createEvent( + "2020-01-01T00:00:00Z", + "2020-01-02T00:00:00Z", + ); + const tournament = await tfx.launch( + [{ type: "SingleElimination", order: 1, minTeams: 4, maxTeams: 8 }], + 4, + ); + await attach(eventId, tournament.id); + + const scheduled = await bracketMatchIds(tournament.stageIds[0]); + expect(scheduled.length).toBeGreaterThan(0); + expect(await linkedMatchIds(eventId)).toEqual(scheduled); + }); +}); From 72c080062920f66593ba40dd076d54379f59b947 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 7 Aug 2026 15:16:37 -0400 Subject: [PATCH 2/4] wip --- hasura/views/v_event_matches.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/hasura/views/v_event_matches.sql b/hasura/views/v_event_matches.sql index 15794398..c0109189 100644 --- a/hasura/views/v_event_matches.sql +++ b/hasura/views/v_event_matches.sql @@ -47,6 +47,7 @@ FROM ( FROM event_tournaments et JOIN tournament_stages ts ON ts.tournament_id = et.tournament_id JOIN tournament_brackets tb ON tb.tournament_stage_id = ts.id + JOIN matches m ON m.id = tb.match_id WHERE tb.match_id IS NOT NULL UNION ALL From 2635a849ff4b92c27cb79dafe96a266667056acf Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 7 Aug 2026 15:20:37 -0400 Subject: [PATCH 3/4] wip --- test/event-tournament-links.spec.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/event-tournament-links.spec.ts b/test/event-tournament-links.spec.ts index 1fc30026..fd83c792 100644 --- a/test/event-tournament-links.spec.ts +++ b/test/event-tournament-links.spec.ts @@ -106,6 +106,9 @@ describe("event <-> tournament match links (SQL-driven)", () => { expect(await linkedMatchIds(eventId)).toEqual(scheduled); }); + // Pins the view's invariant directly rather than waiting for the FK to blow + // up: event_match_links_match_id_fkey is deferrable, so a phantom row would + // survive to COMMIT and this suite would go green on a broken view. it("the tournament branch never emits a match that does not exist", async () => { const eventId = await createEvent(new Date().toISOString(), null); const tournament = await tfx.launch( @@ -114,6 +117,30 @@ describe("event <-> tournament match links (SQL-driven)", () => { ); await attach(eventId, tournament.id); + // Recreate the window schedule_tournament_match() opens: the bracket points + // at a matches row that does not exist yet (its FK is deferred), so the + // view must simply not yield that bracket. + let phantomsInWindow = -1; + await expect( + postgres.transaction(async (client) => { + await client.query( + `UPDATE tournament_brackets SET match_id = gen_random_uuid() + WHERE id = (SELECT id FROM tournament_brackets + WHERE tournament_stage_id = $1 ORDER BY match_number LIMIT 1)`, + [tournament.stageIds[0]], + ); + const { rows } = await client.query( + `SELECT count(*)::int AS count + FROM v_event_matches v + LEFT JOIN matches m ON m.id = v.match_id + WHERE m.id IS NULL`, + ); + phantomsInWindow = rows[0].count; + throw new Error("__rollback__"); + }), + ).rejects.toThrow("__rollback__"); + expect(phantomsInWindow).toBe(0); + const [{ count: phantoms }] = await postgres.query< Array<{ count: number }> >( From 2983db2add984588b41a5c8e50ca71f2efe82103 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 7 Aug 2026 15:22:42 -0400 Subject: [PATCH 4/4] wip --- .../down.sql | 3 --- .../up.sql | 11 ----------- 2 files changed, 14 deletions(-) delete mode 100644 hasura/migrations/default/1876000000200_defer_event_match_links_match_id_fkey/down.sql delete mode 100644 hasura/migrations/default/1876000000200_defer_event_match_links_match_id_fkey/up.sql diff --git a/hasura/migrations/default/1876000000200_defer_event_match_links_match_id_fkey/down.sql b/hasura/migrations/default/1876000000200_defer_event_match_links_match_id_fkey/down.sql deleted file mode 100644 index 61c44790..00000000 --- a/hasura/migrations/default/1876000000200_defer_event_match_links_match_id_fkey/down.sql +++ /dev/null @@ -1,3 +0,0 @@ -alter table "public"."event_match_links" - alter constraint "event_match_links_match_id_fkey" - not deferrable initially immediate; diff --git a/hasura/migrations/default/1876000000200_defer_event_match_links_match_id_fkey/up.sql b/hasura/migrations/default/1876000000200_defer_event_match_links_match_id_fkey/up.sql deleted file mode 100644 index 9186b710..00000000 --- a/hasura/migrations/default/1876000000200_defer_event_match_links_match_id_fkey/up.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Safety net for the same ordering hazard v_event_matches now guards against: --- schedule_tournament_match() deliberately points tournament_brackets.match_id --- at a matches row it has not inserted yet (that FK is DEFERRABLE INITIALLY --- DEFERRED so tai_match can see the bracket link). Any trigger that fires in --- that window and derives a link from a bracket would hit an immediate FK here --- and abort the caller's whole transaction -- which is how starting a --- tournament attached to an event broke. Deferring the check to commit means --- such a link is judged once the matches insert has landed. -alter table "public"."event_match_links" - alter constraint "event_match_links_match_id_fkey" - deferrable initially deferred;