Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions hasura/functions/regions/has_available_server_region.sql
Original file line number Diff line number Diff line change
@@ -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
);
$$;
201 changes: 201 additions & 0 deletions hasura/tests/draft-regions/01_no_region_guards.sql
Original file line number Diff line number Diff line change
@@ -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$;
35 changes: 35 additions & 0 deletions hasura/tests/draft-regions/README.md
Original file line number Diff line number Diff line change
@@ -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 <x>` 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
```
19 changes: 19 additions & 0 deletions hasura/tests/draft-regions/cleanup.sql
Original file line number Diff line number Diff line change
@@ -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;
26 changes: 26 additions & 0 deletions hasura/tests/draft-regions/run.sh
Original file line number Diff line number Diff line change
@@ -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."
10 changes: 10 additions & 0 deletions hasura/triggers/draft_games.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
10 changes: 10 additions & 0 deletions hasura/triggers/matches.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
24 changes: 23 additions & 1 deletion src/draft-games/draft-match.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading