From aab70fe5379787946957bede08931b6f29b49392 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 10 Jul 2026 10:41:08 -0400 Subject: [PATCH 1/2] bug: fix draft with no regions available --- .../regions/has_available_server_region.sql | 10 ++++++++ hasura/triggers/draft_games.sql | 10 ++++++++ hasura/triggers/matches.sql | 10 ++++++++ src/draft-games/draft-match.service.ts | 24 ++++++++++++++++++- 4 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 hasura/functions/regions/has_available_server_region.sql diff --git a/hasura/functions/regions/has_available_server_region.sql b/hasura/functions/regions/has_available_server_region.sql new file mode 100644 index 00000000..f12d2d06 --- /dev/null +++ b/hasura/functions/regions/has_available_server_region.sql @@ -0,0 +1,10 @@ +drop function if exists public.has_available_server_region(); +CREATE OR REPLACE FUNCTION public.has_available_server_region() RETURNS boolean + LANGUAGE sql STABLE + AS $$ + SELECT EXISTS ( + SELECT 1 + FROM server_regions sr + WHERE total_region_server_count(sr) > 0 + ); +$$; diff --git a/hasura/triggers/draft_games.sql b/hasura/triggers/draft_games.sql index 24fd6655..37cc15ae 100644 --- a/hasura/triggers/draft_games.sql +++ b/hasura/triggers/draft_games.sql @@ -2,6 +2,10 @@ CREATE OR REPLACE FUNCTION public.tbi_draft_games() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN + IF NOT has_available_server_region() THEN + RAISE EXCEPTION 'No game server regions are currently available' USING ERRCODE = '22000'; + END IF; + NEW.capacity := CASE NEW.type WHEN 'Duel' THEN 2 WHEN 'Wingman' THEN 4 @@ -36,6 +40,12 @@ BEGIN -- Reject an unready "start" so the row can never get stuck in Filled. IF OLD.status = 'Open' AND NEW.status = 'Filled' THEN + -- Match creation would fail on the region sanitizer and strand the + -- draft in CreatingMatch, so refuse the start outright. + IF NOT has_available_server_region() THEN + RAISE EXCEPTION 'No game server regions are currently available' USING ERRCODE = '22000'; + END IF; + SELECT count(*) FILTER (WHERE status = 'Accepted'), count(*) FILTER (WHERE status = 'Accepted' AND lineup = 1), diff --git a/hasura/triggers/matches.sql b/hasura/triggers/matches.sql index 5c6af260..b0b592d3 100644 --- a/hasura/triggers/matches.sql +++ b/hasura/triggers/matches.sql @@ -329,6 +329,16 @@ BEGIN END IF; IF(NEW.status = 'Live') THEN + -- Going live with a region that has no attached servers can never be + -- fulfilled (the match would silently bounce back to Veto or park in + -- WaitingForServer forever), so refuse the transition outright. + IF OLD.status != 'Live' AND NEW.region IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM server_regions sr + WHERE sr.value = NEW.region AND total_region_server_count(sr) > 0 + ) THEN + RAISE EXCEPTION USING ERRCODE = '22000', MESSAGE = 'No game servers are available in region ' || NEW.region; + END IF; + SELECT COUNT(*) INTO _match_map_count FROM match_maps WHERE match_id = NEW.id; SELECT * INTO _match_options FROM match_options WHERE id = NEW.match_options_id; diff --git a/src/draft-games/draft-match.service.ts b/src/draft-games/draft-match.service.ts index d0062219..5c12b032 100644 --- a/src/draft-games/draft-match.service.ts +++ b/src/draft-games/draft-match.service.ts @@ -54,7 +54,29 @@ export class DraftMatchService { match = await this.findExistingMatch(beforeCreate); if (!match) { - match = await this.createMatch(beforeCreate); + try { + match = await this.createMatch(beforeCreate); + } catch (error) { + // A draft left in CreatingMatch is stuck forever: the expiry job + // only cleans Open rows and its players stay locked out of every + // other lobby. Cancel it so everyone is released. + this.logger.error( + `unable to create match for draft game ${draftGameId}, canceling the draft`, + error, + ); + + await this.hasura.mutation({ + update_draft_games_by_pk: { + __args: { + pk_columns: { id: draftGameId }, + _set: { status: "Canceled" }, + }, + __typename: true, + }, + }); + + return; + } await this.hasura.mutation({ update_draft_games_by_pk: { From f189a5b2e28851c7cf8920570dfb205d2ed15c02 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 10 Jul 2026 13:00:20 -0400 Subject: [PATCH 2/2] wip --- .../draft-regions/01_no_region_guards.sql | 201 ++++++++++++++++++ hasura/tests/draft-regions/README.md | 35 +++ hasura/tests/draft-regions/cleanup.sql | 19 ++ hasura/tests/draft-regions/run.sh | 26 +++ 4 files changed, 281 insertions(+) create mode 100644 hasura/tests/draft-regions/01_no_region_guards.sql create mode 100644 hasura/tests/draft-regions/README.md create mode 100644 hasura/tests/draft-regions/cleanup.sql create mode 100755 hasura/tests/draft-regions/run.sh diff --git a/hasura/tests/draft-regions/01_no_region_guards.sql b/hasura/tests/draft-regions/01_no_region_guards.sql new file mode 100644 index 00000000..7f863e3a --- /dev/null +++ b/hasura/tests/draft-regions/01_no_region_guards.sql @@ -0,0 +1,201 @@ +-- Draft lobbies + matches must refuse to start when no game server region +-- has servers, instead of stranding the draft in CreatingMatch (or silently +-- bouncing a match back to Veto). Cancel must always keep working. +\set ON_ERROR_STOP on + +SELECT set_config('hasura.user', '{"x-hasura-role": "admin", "x-hasura-user-id": "86600000000000001"}', false); +SELECT set_config('fivestack.app_key', 'draft-region-test-app-key', false); + +DO $suite$ +DECLARE + _pool_id uuid; + _map_id uuid; + _options_id uuid; + _draft_id uuid; + _match_id uuid; + _server_id uuid; + _status text; + _raised boolean; + i int; +BEGIN + RAISE NOTICE '=== SETUP: players, maps, one region with one server ==='; + + FOR i IN 1..10 LOOP + INSERT INTO players (steam_id, name, profile_url, avatar_url, role, country, name_registered, created_at) + VALUES (86600000000000000 + i, 'Draft Region Player ' || i, + 'https://example.com/p' || i, 'https://example.com/a.jpg', + 'user', 'US', true, NOW() - INTERVAL '90 days') + ON CONFLICT (steam_id) DO NOTHING; + END LOOP; + + INSERT INTO map_pools (type, enabled, seed) VALUES ('Competitive', true, false) + RETURNING id INTO _pool_id; + FOR i IN 1..3 LOOP + INSERT INTO maps (name, type, active_pool) VALUES ('de_draftregion_test_' || i, 'Competitive', true) + RETURNING id INTO _map_id; + INSERT INTO _map_pool (map_id, map_pool_id) VALUES (_map_id, _pool_id); + END LOOP; + + INSERT INTO server_regions (value, is_lan) VALUES ('DraftRegionTest', false) + ON CONFLICT (value) DO NOTHING; + INSERT INTO servers (host, label, rcon_password, port, enabled, region, type, is_dedicated) + VALUES ('127.0.0.1', 'draft-region-test-server', '\x00'::bytea, 27915, true, 'DraftRegionTest', 'Ranked', true) + RETURNING id INTO _server_id; + + INSERT INTO match_options (map_pool_id, type, mr, best_of) + VALUES (_pool_id, 'Competitive', 12, 1) + RETURNING id INTO _options_id; + + RAISE NOTICE '=== TEST 1: draft lobby fills while a server exists ==='; + + INSERT INTO draft_games (host_steam_id, type, mode, match_options_id, regions) + VALUES (86600000000000001, 'Competitive', 'Captains', _options_id, ARRAY['DraftRegionTest']) + RETURNING id INTO _draft_id; + + FOR i IN 1..10 LOOP + INSERT INTO draft_game_players (draft_game_id, steam_id, status, elo_snapshot) + VALUES (_draft_id, 86600000000000000 + i, 'Accepted', 1000 + i) + ON CONFLICT DO NOTHING; + END LOOP; + RAISE NOTICE 'PASSED: draft created and filled with a server available'; + + RAISE NOTICE '=== TEST 2: starting the draft with no servers raises ==='; + DELETE FROM servers WHERE id = _server_id; + + _raised := false; + BEGIN + UPDATE draft_games SET status = 'Filled' WHERE id = _draft_id; + EXCEPTION WHEN OTHERS THEN + _raised := true; + IF SQLERRM NOT LIKE '%No game server regions%' THEN + RAISE EXCEPTION 'ASSERT: unexpected start error: %', SQLERRM; + END IF; + END; + IF NOT _raised THEN + RAISE EXCEPTION 'ASSERT: draft start succeeded with no regions available'; + END IF; + + SELECT status INTO _status FROM draft_games WHERE id = _draft_id; + IF _status != 'Open' THEN + RAISE EXCEPTION 'ASSERT: draft should remain Open, got %', _status; + END IF; + RAISE NOTICE 'PASSED: draft start refused, lobby still Open (host can retry or cancel)'; + + RAISE NOTICE '=== TEST 3: creating a new draft lobby with no servers raises ==='; + _raised := false; + BEGIN + INSERT INTO draft_games (host_steam_id, type, mode) + VALUES (86600000000000002, 'Competitive', 'Captains'); + EXCEPTION WHEN OTHERS THEN + _raised := true; + IF SQLERRM NOT LIKE '%No game server regions%' THEN + RAISE EXCEPTION 'ASSERT: unexpected create error: %', SQLERRM; + END IF; + END; + IF NOT _raised THEN + RAISE EXCEPTION 'ASSERT: draft creation succeeded with no regions available'; + END IF; + RAISE NOTICE 'PASSED: draft creation refused with no regions'; + + RAISE NOTICE '=== TEST 4: match insert without regions raises (finalize safety net relies on this) ==='; + _raised := false; + BEGIN + INSERT INTO matches (match_options_id, organizer_steam_id) + VALUES (_options_id, 86600000000000001); + EXCEPTION WHEN OTHERS THEN + _raised := true; + END; + IF NOT _raised THEN + RAISE EXCEPTION 'ASSERT: match insert succeeded with no regions available'; + END IF; + RAISE NOTICE 'PASSED: match creation refused with no regions'; + + RAISE NOTICE '=== TEST 5: server returns -> draft start works again ==='; + INSERT INTO servers (host, label, rcon_password, port, enabled, region, type, is_dedicated) + VALUES ('127.0.0.1', 'draft-region-test-server', '\x00'::bytea, 27915, true, 'DraftRegionTest', 'Ranked', true) + RETURNING id INTO _server_id; + + UPDATE draft_games SET status = 'Filled' WHERE id = _draft_id; + SELECT status INTO _status FROM draft_games WHERE id = _draft_id; + IF _status != 'Filled' THEN + RAISE EXCEPTION 'ASSERT: draft should be Filled, got %', _status; + END IF; + RAISE NOTICE 'PASSED: draft start works once a server is back'; + + RAISE NOTICE '=== TEST 6: match in Veto whose region loses all servers ==='; + INSERT INTO match_options (map_pool_id, type, mr, best_of, region_veto, map_veto) + VALUES (_pool_id, 'Competitive', 12, 1, true, true) + RETURNING id INTO _options_id; + + INSERT INTO matches (match_options_id, organizer_steam_id) + VALUES (_options_id, 86600000000000001) + RETURNING id INTO _match_id; + + UPDATE matches SET status = 'Veto' WHERE id = _match_id; + SELECT region INTO _status FROM matches WHERE id = _match_id; + IF _status IS NULL THEN + RAISE EXCEPTION 'ASSERT: single available region should have been auto-selected'; + END IF; + + DELETE FROM servers WHERE id = _server_id; + + _raised := false; + BEGIN + UPDATE matches SET status = 'Live' WHERE id = _match_id; + EXCEPTION WHEN OTHERS THEN + _raised := true; + IF SQLERRM NOT LIKE '%No game servers are available in region%' THEN + RAISE EXCEPTION 'ASSERT: unexpected match start error: %', SQLERRM; + END IF; + END; + IF NOT _raised THEN + SELECT status INTO _status FROM matches WHERE id = _match_id; + RAISE EXCEPTION 'ASSERT: match start succeeded with a dead region (ended in %)', _status; + END IF; + RAISE NOTICE 'PASSED: match start refused when the selected region has no servers'; + + RAISE NOTICE '=== TEST 7: cancel keeps working with no servers (escape hatch) ==='; + UPDATE matches SET status = 'Canceled' WHERE id = _match_id; + SELECT status INTO _status FROM matches WHERE id = _match_id; + IF _status != 'Canceled' THEN + RAISE EXCEPTION 'ASSERT: match cancel failed, got %', _status; + END IF; + + DELETE FROM draft_games WHERE id = _draft_id; + RAISE NOTICE 'PASSED: match cancel and draft delete work with no servers'; + + RAISE NOTICE '=== TEST 8: running Live match is not disrupted by servers vanishing ==='; + INSERT INTO servers (host, label, rcon_password, port, enabled, region, type, is_dedicated) + VALUES ('127.0.0.1', 'draft-region-test-server', '\x00'::bytea, 27915, true, 'DraftRegionTest', 'Ranked', true) + RETURNING id INTO _server_id; + + -- maps only auto-populate when the pool size equals best_of + INSERT INTO map_pools (type, enabled, seed) VALUES ('Competitive', true, false) + RETURNING id INTO _pool_id; + INSERT INTO _map_pool (map_id, map_pool_id) + SELECT id, _pool_id FROM maps WHERE name = 'de_draftregion_test_1'; + + INSERT INTO match_options (map_pool_id, type, mr, best_of, region_veto, map_veto) + VALUES (_pool_id, 'Competitive', 12, 1, false, false) + RETURNING id INTO _options_id; + + INSERT INTO matches (match_options_id, organizer_steam_id, region) + VALUES (_options_id, 86600000000000001, 'DraftRegionTest') + RETURNING id INTO _match_id; + + UPDATE matches SET status = 'Live' WHERE id = _match_id; + + DELETE FROM servers WHERE id = _server_id; + + -- updates to an already-Live match must not hit the region guard + UPDATE matches SET status = 'Live' WHERE id = _match_id; + SELECT status INTO _status FROM matches WHERE id = _match_id; + IF _status != 'Live' THEN + RAISE EXCEPTION 'ASSERT: live match disturbed, got %', _status; + END IF; + UPDATE matches SET status = 'Canceled' WHERE id = _match_id; + RAISE NOTICE 'PASSED: live match updates unaffected by the guard'; + + RAISE NOTICE 'ALL DRAFT-REGION GUARD TESTS PASSED'; +END; +$suite$; diff --git a/hasura/tests/draft-regions/README.md b/hasura/tests/draft-regions/README.md new file mode 100644 index 00000000..7f90a50e --- /dev/null +++ b/hasura/tests/draft-regions/README.md @@ -0,0 +1,35 @@ +# Draft-region guard SQL test suite + +Regression tests for the "draft lobby stuck with no regions" bug: when no +server region had any attached servers, a draft lobby could still be created +and started, then `DraftMatchService.finalize` died on the match insert +(`sanitize_match_options_regions` raises) after already setting the draft to +`CreatingMatch`. Nothing ever cleans a `CreatingMatch` row (the expiry job +only deletes `Open` drafts) and its players stay locked out of every other +lobby. Similarly, starting a match whose selected region lost its servers +silently bounced Live → Veto with no error. + +| Suite | Covers | +| --- | --- | +| `01_no_region_guards.sql` | Draft creation/start refused with no available regions, lobby stays `Open` on refusal, match insert refusal (the finalize safety-net trigger), start working again once a server returns, match start refused when the selected region has no servers, cancel/delete always working, live matches unaffected by the guard | + +The guards under test: + +- `has_available_server_region()` — true when any `server_regions` row has + `total_region_server_count > 0` (same notion the sanitizer uses). +- `tbi_draft_games` / `tbu_draft_games` (`Open -> Filled`) raise + `No game server regions are currently available`. +- `tbu_matches` raises `No game servers are available in region ` on a + non-Live → Live transition when the selected region has no servers. +- The API-side safety net (`DraftMatchService.finalize` canceling the draft + when match creation fails) is TypeScript and not covered here. + +## Setup + run + +Same disposable-database setup as the leagues suites — see +`../leagues/README.md`. Then: + +```bash +PGHOST=localhost PGPORT=5432 PGUSER=postgres PGDATABASE=draft_test \ + ./run.sh +``` diff --git a/hasura/tests/draft-regions/cleanup.sql b/hasura/tests/draft-regions/cleanup.sql new file mode 100644 index 00000000..0ed91f9e --- /dev/null +++ b/hasura/tests/draft-regions/cleanup.sql @@ -0,0 +1,19 @@ +-- Removes fixture data created by the draft-region suites. +SELECT set_config('hasura.user', '{"x-hasura-role": "admin", "x-hasura-user-id": "86600000000000001"}', false); +SELECT set_config('fivestack.app_key', 'draft-region-test-app-key', false); + +DELETE FROM draft_games WHERE host_steam_id BETWEEN 86600000000000001 AND 86600000000000010; +DELETE FROM matches WHERE organizer_steam_id BETWEEN 86600000000000001 AND 86600000000000010; +DELETE FROM servers WHERE label = 'draft-region-test-server'; +DELETE FROM server_regions WHERE value = 'DraftRegionTest'; +DELETE FROM match_options WHERE map_pool_id IN ( + SELECT DISTINCT map_pool_id FROM _map_pool + WHERE map_id IN (SELECT id FROM maps WHERE name LIKE 'de_draftregion_test_%') +); +DELETE FROM _map_pool WHERE map_id IN (SELECT id FROM maps WHERE name LIKE 'de_draftregion_test_%'); +DELETE FROM maps WHERE name LIKE 'de_draftregion_test_%'; +DELETE FROM map_pools mp WHERE mp.type = 'Competitive' AND mp.seed = false + AND NOT EXISTS (SELECT 1 FROM _map_pool WHERE map_pool_id = mp.id) + AND NOT EXISTS (SELECT 1 FROM match_options WHERE map_pool_id = mp.id) + AND NOT EXISTS (SELECT 1 FROM draft_games WHERE map_pool_id = mp.id); +DELETE FROM players WHERE steam_id BETWEEN 86600000000000001 AND 86600000000000010; diff --git a/hasura/tests/draft-regions/run.sh b/hasura/tests/draft-regions/run.sh new file mode 100755 index 00000000..8e3bad8e --- /dev/null +++ b/hasura/tests/draft-regions/run.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Runs the draft-region guard suites against a disposable database. +# +# PGHOST=... PGPORT=... PGUSER=... PGDATABASE=... ./run.sh +# +# The target database must have the full schema loaded the same way the API +# boots it: migrations (in version order), then hasura/enums, hasura/functions, +# hasura/views, hasura/triggers. See ../leagues/README.md for setup details. +# Never point this at a production database — the suites insert and delete +# fixture data. +set -euo pipefail +cd "$(dirname "$0")" + +SUITES=(01_no_region_guards) + +for suite in "${SUITES[@]}"; do + echo "==> cleanup" + psql -q -f cleanup.sql >/dev/null + echo "==> ${suite}" + psql -v ON_ERROR_STOP=1 -f "${suite}.sql" 2>&1 | grep -E "PASSED|ERROR|ASSERT" || { + echo "SUITE FAILED: ${suite}" + exit 1 + } +done + +echo "All draft-region suites passed."