From 10ef140aa6e78ad540fc3ee771cab247ddab4942 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Sat, 29 Aug 2026 09:11:55 -0400 Subject: [PATCH 1/6] bug: wait for practice server to load / fix on demand practice server --- hasura/enums/notification-types.sql | 1 - .../down.sql | 3 + .../up.sql | 16 ++ .../match-assistant.service.spec.ts | 60 ++++++++ .../match-assistant.service.ts | 23 ++- src/notifications/notifications.service.ts | 1 - .../preferences/notification-categories.ts | 3 +- .../push/notification-delivery.ts | 4 - .../utilities/notificationUrl.ts | 3 +- .../utility-practice-occupancy.spec.ts | 8 +- src/utility/utility-practice.service.ts | 95 ++++-------- test/utility-practice.spec.ts | 140 ++++++++++++++++-- 12 files changed, 262 insertions(+), 95 deletions(-) create mode 100644 hasura/migrations/default/1880000012000_drop_utility_practice_ready_notification/down.sql create mode 100644 hasura/migrations/default/1880000012000_drop_utility_practice_ready_notification/up.sql diff --git a/hasura/enums/notification-types.sql b/hasura/enums/notification-types.sql index 27a2c727..43d715f0 100644 --- a/hasura/enums/notification-types.sql +++ b/hasura/enums/notification-types.sql @@ -43,7 +43,6 @@ INSERT INTO e_notification_types ("value", "description") VALUES ('EventReminder', 'An event you are attending starts soon'), ('SeasonEnded', 'A season has ended'), ('UtilityPracticeInvite', 'You were invited to a utility practice session'), - ('UtilityPracticeReady', 'Your utility practice server is ready'), ('UtilityDriftScanFinished', 'A utility drift scan finished') ON CONFLICT("value") DO UPDATE SET "description" = EXCLUDED."description"; diff --git a/hasura/migrations/default/1880000012000_drop_utility_practice_ready_notification/down.sql b/hasura/migrations/default/1880000012000_drop_utility_practice_ready_notification/down.sql new file mode 100644 index 00000000..9857622f --- /dev/null +++ b/hasura/migrations/default/1880000012000_drop_utility_practice_ready_notification/down.sql @@ -0,0 +1,3 @@ +INSERT INTO public.e_notification_types ("value", "description") VALUES + ('UtilityPracticeReady', 'Your utility practice server is ready') +ON CONFLICT("value") DO NOTHING; diff --git a/hasura/migrations/default/1880000012000_drop_utility_practice_ready_notification/up.sql b/hasura/migrations/default/1880000012000_drop_utility_practice_ready_notification/up.sql new file mode 100644 index 00000000..665b3d84 --- /dev/null +++ b/hasura/migrations/default/1880000012000_drop_utility_practice_ready_notification/up.sql @@ -0,0 +1,16 @@ +-- The practice bar in the top nav already says a server is booting and when it +-- is up, on every page, for as long as the session lasts. A bell row saying the +-- same thing arrived a moment later, said less, and outlived the server it was +-- about -- the reaper stops an empty session, so a row sitting unread in the +-- bell points at nothing. +-- +-- notifications.type is FK'd to this table ON DELETE CASCADE, so removing the +-- enum value takes every row of it with it. Existing installs seeded the value +-- from hasura/enums/notification-types.sql, which no longer lists it; a fresh +-- install never had it, and this is a no-op there. +DELETE FROM public.e_notification_types WHERE "value" = 'UtilityPracticeReady'; + +-- notification_preferences.key is plain text with no foreign key, so the +-- per-player in-app toggle for the type would otherwise sit in the table +-- forever, unreachable and unreadable. +DELETE FROM public.notification_preferences WHERE "key" = 'UtilityPracticeReady'; diff --git a/src/matches/match-assistant/match-assistant.service.spec.ts b/src/matches/match-assistant/match-assistant.service.spec.ts index 5f18c5a5..3e3bec43 100644 --- a/src/matches/match-assistant/match-assistant.service.spec.ts +++ b/src/matches/match-assistant/match-assistant.service.spec.ts @@ -202,6 +202,66 @@ describe("MatchAssistantService", () => { expect(startMatch).not.toHaveBeenCalled(); }); + // A dedicated server runs the match plugin; the utility practice plugin ships + // only in the on-demand image. Falling back to one gave a practice session a + // connect string -- so the website read "ready to join" -- for a box that can + // never answer GET /utility/session, which is what turns the session Ready. + it("never falls back to a dedicated server for a practice match", async () => { + hasura.query.mockResolvedValue({ + matches_by_pk: { + id: "match-1", + region: "USE", + source: "practice", + options: { + prefer_dedicated_server: false, + }, + }, + }); + + jest.spyOn(service as any, "assignOnDemandServer").mockResolvedValue(false); + const assignDedicated = jest + .spyOn(service as any, "assignDedicatedServer") + .mockResolvedValue(true); + const updateMatchStatus = jest + .spyOn(service, "updateMatchStatus") + .mockResolvedValue(undefined); + + await expect(service.assignServer("match-1")).resolves.toBeUndefined(); + + expect(assignDedicated).not.toHaveBeenCalled(); + expect(updateMatchStatus).toHaveBeenCalledWith( + "match-1", + "WaitingForServer", + ); + }); + + // The same guard on the other side of the branch: prefer_dedicated_server is + // an option a practice match never sets, but nothing stops it being set. + it("ignores prefer_dedicated_server on a practice match", async () => { + hasura.query.mockResolvedValue({ + matches_by_pk: { + id: "match-1", + region: "USE", + source: "practice", + options: { + prefer_dedicated_server: true, + }, + }, + }); + + const assignOnDemand = jest + .spyOn(service as any, "assignOnDemandServer") + .mockResolvedValue(true); + const assignDedicated = jest + .spyOn(service as any, "assignDedicatedServer") + .mockResolvedValue(true); + + await expect(service.assignServer("match-1")).resolves.toBeUndefined(); + + expect(assignOnDemand).toHaveBeenCalled(); + expect(assignDedicated).not.toHaveBeenCalled(); + }); + it("schedules the next on-demand server boot check after 15 seconds", async () => { await service.delayCheckOnDemandServer("match-1"); diff --git a/src/matches/match-assistant/match-assistant.service.ts b/src/matches/match-assistant/match-assistant.service.ts index dd063cc4..b682ffd6 100644 --- a/src/matches/match-assistant/match-assistant.service.ts +++ b/src/matches/match-assistant/match-assistant.service.ts @@ -334,13 +334,22 @@ export class MatchAssistantService { }, id: true, region: true, + source: true, options: { prefer_dedicated_server: true, }, }, }); - if (match.options.prefer_dedicated_server) { + // A practice match runs the utility practice plugin, and only the on-demand + // image installs it -- a dedicated server is running the match plugin + // instead. Handing one to a practice session is worse than failing: the + // connect string resolves the moment the server is assigned, so the website + // reads "ready to join" for a box that will never answer GET + // /utility/session and never comes up as a practice server at all. + const onDemandOnly = match.source === "practice"; + + if (!onDemandOnly && match.options.prefer_dedicated_server) { try { const assignedDedicated = await this.assignDedicatedServer( match.id, @@ -385,6 +394,18 @@ export class MatchAssistantService { } } + // No pod, and no second pool to fall back on. Saying so is what turns the + // session Failed -- match_events reads WaitingForServer off a practice + // match as "no practice server was available" -- rather than leaving it + // Starting until the boot grace runs out. + if (onDemandOnly) { + this.logger.log( + `[${matchId}] practice match, and no on demand server could be booted`, + ); + await this.updateMatchStatus(match.id, "WaitingForServer"); + return; + } + // we already checked above, so we can skip trying to assign again if (match.options.prefer_dedicated_server) { this.logger.log( diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index f0f319e3..abf92550 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -63,7 +63,6 @@ export class NotificationsService { // One player's own practice server. A staff channel has nothing to do with // it, and a busy install would post one line per session boot. "UtilityPracticeInvite", - "UtilityPracticeReady", ]); // Nobody has seen a notification in six months who hasn't signed in, and a diff --git a/src/notifications/preferences/notification-categories.ts b/src/notifications/preferences/notification-categories.ts index 5ccd6a19..5e638803 100644 --- a/src/notifications/preferences/notification-categories.ts +++ b/src/notifications/preferences/notification-categories.ts @@ -48,7 +48,7 @@ export const PUSH_CATEGORIES: Record = { ], teams: ["FormTeamSuggestion"], invites: ["TeamInvite", "TournamentTeamInvite", "DraftInvite"], - utility: ["UtilityPracticeInvite", "UtilityPracticeReady"], + utility: ["UtilityPracticeInvite"], account: ["NameChangeApproved", "NameChangeDenied", "PlayerSanctioned", "AwardGranted"], news: ["NewsPublished"], staff_moderation: ["MatchSupport", "MatchAbandoned", "NameChangeRequest"], @@ -111,7 +111,6 @@ export const IN_APP_KEYS: PreferenceKey[] = [ { key: "ScrimAlertMatch", defaultEnabled: true }, { key: "LeagueMatchUnscheduled", defaultEnabled: true }, { key: "UtilityPracticeInvite", defaultEnabled: true }, - { key: "UtilityPracticeReady", defaultEnabled: true }, ]; const PUSH_CATEGORY_BY_TYPE: Record = Object.fromEntries( diff --git a/src/notifications/push/notification-delivery.ts b/src/notifications/push/notification-delivery.ts index 716b4c5a..6cbc8a90 100644 --- a/src/notifications/push/notification-delivery.ts +++ b/src/notifications/push/notification-delivery.ts @@ -67,10 +67,6 @@ const DELIVERY_POLICIES: Record = { "LeagueRegistrationDecision", "LeagueRosterUndersized", "UtilityPracticeInvite", - // A practice server is up for as long as somebody is on it and the reaper - // stops it when nobody is: a late buzz sends a player to a session that has - // already been torn down, so this one is worth nothing bundled. - "UtilityPracticeReady", ], // Fan-outs to the whole player base. Already collapsed into one job by diff --git a/src/notifications/utilities/notificationUrl.ts b/src/notifications/utilities/notificationUrl.ts index 663addb8..22c713d7 100644 --- a/src/notifications/utilities/notificationUrl.ts +++ b/src/notifications/utilities/notificationUrl.ts @@ -50,12 +50,11 @@ const PATH_BY_TYPE: Record string> = { LeagueRegistrationDecision: () => `/league`, LeagueRosterUndersized: () => `/league`, // A practice session is a dialog on the map board, not a page, so there is no - // route an id can be turned into. Both of these carry the real target + // route an id can be turned into. The invite carries the real target // (/utility/?practice=) in the message, which is read first; this is // only the fallback for a row authored without one, and the library index is // the closest honest place to land. UtilityPracticeInvite: () => `/utility`, - UtilityPracticeReady: () => `/utility`, // The admin drift review, which lists scans with their verdict counts. It // does not read a scan id today, so the id on the row stays off the path. UtilityDriftScanFinished: () => `/utility/drift`, diff --git a/src/utility/utility-practice-occupancy.spec.ts b/src/utility/utility-practice-occupancy.spec.ts index 1afe18c2..fe015b82 100644 --- a/src/utility/utility-practice-occupancy.spec.ts +++ b/src/utility/utility-practice-occupancy.spec.ts @@ -10,8 +10,9 @@ describe("UtilityPracticeService.reportOccupancy", () => { const publish = jest.fn().mockResolvedValue(undefined); const postgres = { query: jest.fn() }; - // The UPDATE ... RETURNING is the first query; the two session bookkeeping - // writes that follow it only run when somebody is present. + // The UPDATE ... RETURNING is the first query this spec lets through -- + // markReady is stubbed below, so its own write never lands here. The two + // session bookkeeping writes that follow only run when somebody is present. postgres.query .mockResolvedValueOnce(flipped) .mockResolvedValue([] as Array); @@ -46,6 +47,9 @@ describe("UtilityPracticeService.reportOccupancy", () => { password: "hunter2", }); jest.spyOn(service, "touch").mockResolvedValue(undefined); + // A tick is also the proof that the server came up, but that is the + // readiness path's business, not the push's. + jest.spyOn(service, "markReady").mockResolvedValue(undefined); return { service, publish, postgres, load }; } diff --git a/src/utility/utility-practice.service.ts b/src/utility/utility-practice.service.ts index 7025404b..74c90672 100644 --- a/src/utility/utility-practice.service.ts +++ b/src/utility/utility-practice.service.ts @@ -153,9 +153,21 @@ export class UtilityPracticeService { // A practice server is a pool of its own -- matchmaking never sees // type = 'Practice' -- so booking one costs matchmaking nothing and // skips both the region search and the headroom reserve. + // + // A named region is the ON DEMAND answer and nothing else. The picker + // lists every standing practice server as a row of its own, so a caller + // who wanted the box in US-EAST asked for it by id; one who picked + // US-EAST out of the region group asked for a pod to be booted there. + // Substituting the standing box handed back a server nobody asked for -- + // and if it is not actually up, one that never answers. Only the + // automatic choice may take a standing one, which is what its own hint + // promises: "a free practice server if one is standing, otherwise a + // fresh one". const server = input.server_id ? await this.practiceServer(input.server_id) - : await this.freePracticeServer(input.region); + : input.region + ? null + : await this.freePracticeServer(null); let region: string; @@ -708,79 +720,16 @@ export class UtilityPracticeService { return row ?? null; } - // The plugin polls, so this runs on every GET /utility/session. Only the row - // the UPDATE actually moved out of 'Starting' comes back, which is what keeps - // "your server is ready" a single buzz rather than one per poll. + // The plugin polls, so this runs on every GET /utility/session, and the + // occupancy tick posts it again every minute. The status guard is what makes + // that idempotent: only a row still in 'Starting' is moved. public async markReady(matchId: string): Promise { - const [session] = await this.postgres.query< - Array<{ id: string; host_steam_id: string | null; map_name: string }> - >( + await this.postgres.query( `UPDATE public.utility_practice_sessions SET status = 'Ready', failure_reason = NULL, last_occupied_at = now() - WHERE match_id = $1::uuid AND status = 'Starting' - RETURNING id::text AS id, host_steam_id::text AS host_steam_id, map_name`, + WHERE match_id = $1::uuid AND status = 'Starting'`, [matchId], ); - - if (!session) { - return; - } - - await this.notifyReady(matchId, session); - } - - private async notifyReady( - matchId: string, - session: { id: string; host_steam_id: string | null; map_name: string }, - ): Promise { - // The host plus anyone already added to the lineup: they were let in while - // the server was still booting, so the link they were handed only starts - // working now. - const players = await this.postgres.query>( - `SELECT DISTINCT mlp.steam_id::text AS steam_id - FROM public.match_lineup_players mlp - INNER JOIN public.match_lineups ml ON ml.id = mlp.match_lineup_id - WHERE ml.match_id = $1::uuid - AND mlp.steam_id IS NOT NULL`, - [matchId], - ); - - // A render session has no host and no roster -- nobody to tell it is ready. - const steamIds = [ - ...new Set( - [ - session.host_steam_id, - ...players.map((player) => player.steam_id), - ].filter((id): id is string => id !== null), - ), - ]; - if (steamIds.length === 0) { - return; - } - - const map = NotificationsService.escapeHtml(session.map_name); - - try { - await this.notifications.notifyPlayers( - "UtilityPracticeReady" as e_notification_types_enum, - { - title: "Practice Server Ready", - message: - `Your utility practice server on ${map} is up. ` + - `Open the board.`, - role: "user" as e_player_roles_enum, - // Suffixed, not the bare session id: the bell stacks rows that share - // an entity_id, so an invite and a ready for the same session would - // collapse into one another and the second would never be seen. - entity_id: `${session.id}:ready`, - steamIds, - }, - ); - } catch (error) { - this.logger.warn( - `[utility-practice] unable to announce ${session.id} as ready: ${(error as Error)?.message}`, - ); - } } // A practice session has no page of its own -- it is a dialog on the map @@ -1852,6 +1801,14 @@ export class UtilityPracticeService { return; } + // The second proof that the server is up, and the reason there has to be + // one: GET /utility/session is asked once, at map load, and a plugin whose + // first ask failed never asks again -- leaving a session Starting behind a + // server that is running and talking. A practice pod does not ping + // /game-server-node, so nothing else would ever notice. This tick is the + // plugin on a loaded map, which is all Ready has ever meant. + await this.markReady(session.match_id); + const present = steamIds .map((steamId) => String(steamId ?? "").trim()) .filter((steamId) => /^\d{5,20}$/.test(steamId)); diff --git a/test/utility-practice.spec.ts b/test/utility-practice.spec.ts index 21ef1ae7..f5a31782 100644 --- a/test/utility-practice.spec.ts +++ b/test/utility-practice.spec.ts @@ -86,6 +86,19 @@ describe("utility practice sessions (SQL-driven)", () => { return inserted.id; } + // A dedicated practice box: already running, already listed in the picker, + // and the thing an on-demand choice must not be quietly answered with. + async function standingPracticeServer(region: string): Promise { + const [row] = await postgres.query>( + `INSERT INTO servers + (host, label, rcon_password, port, enabled, connected, region, type, is_dedicated) + VALUES ('127.0.0.1', $1, $2, 27960, true, true, $3, 'Practice', true) + RETURNING id::text AS id`, + [`practice-${region}`, Buffer.from("password"), region], + ); + return row.id; + } + // The exact shape startUtilityPractice builds: a one-map Custom pool, no veto, // Competitive with enough substitutes for a full practice server, and // source='practice' so match_events takes the practice branch. @@ -352,6 +365,67 @@ describe("utility practice sessions (SQL-driven)", () => { expect(rows.length).toBe(0); }); + // The region group in the picker is headed ON DEMAND, and every standing + // practice server is a row of its own beside it. Answering "US-EAST" with + // the box already running in US-EAST handed back a server nobody asked for + // -- and when that box is not really up, one that never answers, behind a + // connect string that made the website say "ready to join". + it("boots on demand for a named region rather than taking a standing server", async () => { + const host = await fx.player(); + await setting("public.utility_practice_enabled", "true"); + await setting("public.utility_practice_reserved_servers", "2"); + await standingPracticeServer("TestA"); + + const service = makeService({ + matchAssistant: { + countFreeOnDemandServers: jest.fn(async (): Promise => 2), + }, + }); + + await expect( + service.start({ steam_id: host, role: "user" } as never, { + map_name: "de_mirage", + region: "TestA", + }), + ).rejects.toThrow(/no practice servers are free/); + + const [server] = await postgres.query>( + `SELECT reserved_by_match_id::text AS reserved + FROM servers WHERE type = 'Practice'`, + ); + expect(server.reserved).toBeNull(); + }); + + // The other half of the same rule: automatic is the choice whose own hint + // promises "a free practice server if one is standing", so it takes one and + // never spends a slot the headroom is holding back. + it("still takes a standing server for the automatic choice", async () => { + const host = await fx.player(); + await setting("public.utility_practice_enabled", "true"); + await setting("public.utility_practice_reserved_servers", "2"); + await standingPracticeServer("TestA"); + + const service = makeService({ + matchAssistant: { + countFreeOnDemandServers: jest.fn(async (): Promise => 2), + }, + }); + + // The match behind it needs hasura, which this suite does not stand up -- + // so it gets as far as the session row and no further. That row is the + // whole assertion: the headroom never turned it away. + await expect( + service.start({ steam_id: host, role: "user" } as never, { + map_name: "de_mirage", + }), + ).rejects.not.toThrow(/no practice servers are free/); + + const [session] = await postgres.query>( + "SELECT region FROM utility_practice_sessions", + ); + expect(session.region).toBe("TestA"); + }); + it("refuses to start at all while the feature is off", async () => { const host = await fx.player(); await setting("public.utility_practice_enabled", "false"); @@ -1356,6 +1430,28 @@ describe("utility practice sessions (SQL-driven)", () => { const service = makeService({}); expect(await service.sessionForServer(serverId)).toBeNull(); }); + + // GET /utility/session is asked once, at map load, and a plugin whose first + // ask failed never asks again -- so the session sat Starting behind a server + // that was up and posting. A practice pod pings nothing else, so this tick + // is the only other proof there is. + it("takes an occupancy tick as proof the server came up", async () => { + const host = await fx.player(); + const { matchId } = await createPracticeMatch(host); + const sessionId = await insertSession(host, { + match_id: matchId, + status: "Starting", + }); + const serverId = await reservedServer("practice-a", matchId, 27964); + + await makeService({}).reportOccupancy(serverId, []); + + const [session] = await postgres.query>( + "SELECT status FROM utility_practice_sessions WHERE id = $1::uuid", + [sessionId], + ); + expect(session.status).toBe("Ready"); + }); }); describe("the reaper", () => { @@ -1644,9 +1740,12 @@ describe("utility practice sessions (SQL-driven)", () => { expect(notified).toHaveLength(1); }); - // The plugin polls GET /utility/session, so markReady runs over and over. - // Only the poll that moved the session out of Starting may announce it. - it("announces a ready server once, to the host and the lineup", async () => { + // The bell row that used to fire here is gone: the practice bar in the top + // nav says the same thing, on every page, for as long as the session lasts. + // What has to survive is the status guard, which is now load-bearing for a + // different reason -- the plugin polls GET /utility/session and the + // occupancy tick posts every minute, so markReady runs over and over. + it("moves a session to Ready without buzzing anybody", async () => { const host = await fx.player(); const { matchId, lineupId } = await createPracticeMatch(host); const mate = await fx.player(); @@ -1663,16 +1762,31 @@ describe("utility practice sessions (SQL-driven)", () => { await service.markReady(matchId); await service.markReady(matchId); - expect(notified).toHaveLength(1); - expect(notified[0].type).toBe("UtilityPracticeReady"); - expect(notified[0].steamIds.sort()).toEqual([host, mate].sort()); - // Suffixed so the bell does not stack it onto the invite for the same - // session. - expect(notified[0].entity_id).toBe(`${sessionId}:ready`); - // No deeper target exists; the host already holds the dialog. - expect(notified[0].message).toContain( - "https://5stack.test/utility/de_mirage", - ); + expect(await statusOf(sessionId)).toBe("Ready"); + expect(notified).toHaveLength(0); }); + + // A late poll from a server that is being torn down must not put the + // session back on the board. + it("never brings a session back out of a terminal status", async () => { + const host = await fx.player(); + const { matchId } = await createPracticeMatch(host); + const sessionId = await insertSession(host, { + match_id: matchId, + status: "Ended", + }); + + await makeService({}).markReady(matchId); + + expect(await statusOf(sessionId)).toBe("Ended"); + }); + + async function statusOf(sessionId: string): Promise { + const [session] = await postgres.query>( + "SELECT status FROM utility_practice_sessions WHERE id = $1::uuid", + [sessionId], + ); + return session.status; + } }); }); From 9e9677ceed4d7ec6cc6a3c069b1ab12515054295 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Sat, 29 Aug 2026 09:36:53 -0400 Subject: [PATCH 2/6] wip --- .../match-assistant.service.spec.ts | 44 ++++++++++++++--- .../match-assistant.service.ts | 46 ++++++++++++++++-- src/notifications/notifications.service.ts | 4 +- .../utility-practice-occupancy.spec.ts | 31 ++++++++++-- src/utility/utility-practice.service.ts | 33 ++++++++----- test/utility-practice.spec.ts | 48 +++++++------------ 6 files changed, 146 insertions(+), 60 deletions(-) diff --git a/src/matches/match-assistant/match-assistant.service.spec.ts b/src/matches/match-assistant/match-assistant.service.spec.ts index 3e3bec43..0c119168 100644 --- a/src/matches/match-assistant/match-assistant.service.spec.ts +++ b/src/matches/match-assistant/match-assistant.service.spec.ts @@ -24,6 +24,17 @@ describe("MatchAssistantService", () => { getDelayed: jest.Mock; }; + // Every "we could not get you a server" write goes out as one conditional + // update_matches, so the assertions read the statement rather than a status + // setter that is no longer how the assignment path says it. + function waitingForServerWrites() { + return hasura.mutation.mock.calls + .map(([mutation]) => mutation?.update_matches) + .filter( + (update) => update?.__args?._set?.status === "WaitingForServer", + ); + } + beforeEach(() => { hasura = { query: jest.fn(), @@ -222,16 +233,37 @@ describe("MatchAssistantService", () => { const assignDedicated = jest .spyOn(service as any, "assignDedicatedServer") .mockResolvedValue(true); - const updateMatchStatus = jest - .spyOn(service, "updateMatchStatus") - .mockResolvedValue(undefined); await expect(service.assignServer("match-1")).resolves.toBeUndefined(); expect(assignDedicated).not.toHaveBeenCalled(); - expect(updateMatchStatus).toHaveBeenCalledWith( - "match-1", - "WaitingForServer", + expect(waitingForServerWrites()).toHaveLength(1); + }); + + // The boot attempt outlives the host pressing Stop. Writing the status back + // without a condition moved the already Canceled match to WaitingForServer, + // and with the practice session already Ended nothing was left to move it + // again -- so the row sat there forever. + it("cannot move a finished match back to waiting for a server", async () => { + hasura.query.mockResolvedValue({ + matches_by_pk: { + id: "match-1", + region: "USE", + source: "practice", + options: { + prefer_dedicated_server: false, + }, + }, + }); + + jest.spyOn(service as any, "assignOnDemandServer").mockResolvedValue(false); + + await service.assignServer("match-1"); + + const [write] = waitingForServerWrites(); + + expect(write.__args.where.status._nin).toEqual( + expect.arrayContaining(["Canceled", "Finished", "WaitingForServer"]), ); }); diff --git a/src/matches/match-assistant/match-assistant.service.ts b/src/matches/match-assistant/match-assistant.service.ts index b682ffd6..c99adf25 100644 --- a/src/matches/match-assistant/match-assistant.service.ts +++ b/src/matches/match-assistant/match-assistant.service.ts @@ -322,6 +322,42 @@ export class MatchAssistantService { }); } + /** + * Say a match is waiting for a server, unless it is past caring. + * + * A boot attempt outlives the host's Stop: the plain write would move an + * already Canceled match back to WaitingForServer, and with the practice + * session already Ended nothing is left that would move it again. Conditional + * in the statement rather than read-then-write, because a Stop landing + * between the two would win the read and lose the row. + * + * Excluding WaitingForServer itself is what keeps the assignment path from + * firing the status webhook twice when the step that failed already said so. + */ + private async markWaitingForServer(matchId: string): Promise { + await this.hasura.mutation({ + update_matches: { + __args: { + where: { + id: { + _eq: matchId, + }, + status: { + _nin: [ + ...MatchAssistantService.TERMINAL_MATCH_STATUSES, + "WaitingForServer", + ], + }, + }, + _set: { + status: "WaitingForServer", + }, + }, + affected_rows: true, + }, + }); + } + public async assignServer(matchId: string, tries = 0): Promise { if (tries === 0) { await this.setServerError(matchId, null); @@ -383,7 +419,7 @@ export class MatchAssistantService { this.logger.error( `[${matchId}] max retries reached for server assignment`, ); - await this.updateMatchStatus(matchId, "WaitingForServer"); + await this.markWaitingForServer(matchId); return; } setTimeout(async () => { @@ -402,7 +438,7 @@ export class MatchAssistantService { this.logger.log( `[${matchId}] practice match, and no on demand server could be booted`, ); - await this.updateMatchStatus(match.id, "WaitingForServer"); + await this.markWaitingForServer(match.id); return; } @@ -411,7 +447,7 @@ export class MatchAssistantService { this.logger.log( `[${matchId}] unable to assign dedicated server, trying on demand`, ); - await this.updateMatchStatus(match.id, "WaitingForServer"); + await this.markWaitingForServer(match.id); return; } @@ -431,7 +467,7 @@ export class MatchAssistantService { `[${matchId}] unable to assign dedicated server, updating match status to waiting for server`, ); - await this.updateMatchStatus(match.id, "WaitingForServer"); + await this.markWaitingForServer(match.id); } /** @@ -885,7 +921,7 @@ export class MatchAssistantService { `[${matchId}] no free on-demand server row in the pool — waiting`, ); if (!options?.preserveMatchStatus) { - await this.updateMatchStatus(matchId, "WaitingForServer"); + await this.markWaitingForServer(matchId); } return false; } diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index abf92550..7ebc869e 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -60,8 +60,8 @@ export class NotificationsService { "LeagueMatchUnscheduled", "LeagueRegistrationDecision", "LeagueRosterUndersized", - // One player's own practice server. A staff channel has nothing to do with - // it, and a busy install would post one line per session boot. + // One player inviting a friend to their own practice server. A staff + // channel has nothing to do with it. "UtilityPracticeInvite", ]); diff --git a/src/utility/utility-practice-occupancy.spec.ts b/src/utility/utility-practice-occupancy.spec.ts index fe015b82..01588ee1 100644 --- a/src/utility/utility-practice-occupancy.spec.ts +++ b/src/utility/utility-practice-occupancy.spec.ts @@ -6,7 +6,10 @@ import { UtilityPracticeService } from "./utility-practice.service"; // nobody else. A reconciling post that finds nothing changed must be silent, or // every idle server would wake every tab on it once a minute. describe("UtilityPracticeService.reportOccupancy", () => { - function makeService(flipped: Array<{ steam_id: string }>) { + function makeService( + flipped: Array<{ steam_id: string }>, + status = "Starting", + ) { const publish = jest.fn().mockResolvedValue(undefined); const postgres = { query: jest.fn() }; @@ -45,13 +48,16 @@ describe("UtilityPracticeService.reportOccupancy", () => { match_id: "match-1", map_name: "de_mirage", password: "hunter2", + status, }); jest.spyOn(service, "touch").mockResolvedValue(undefined); // A tick is also the proof that the server came up, but that is the // readiness path's business, not the push's. - jest.spyOn(service, "markReady").mockResolvedValue(undefined); + const markReady = jest + .spyOn(service, "markReady") + .mockResolvedValue(undefined); - return { service, publish, postgres, load }; + return { service, publish, postgres, load, markReady }; } function pushed(publish: jest.Mock) { @@ -123,6 +129,25 @@ describe("UtilityPracticeService.reportOccupancy", () => { expect(params[1]).toEqual(["76561100000000001"]); }); + it("marks a session ready on the tick that finds it still starting", async () => { + const { service, markReady } = makeService([], "Starting"); + + await service.reportOccupancy("server-1", ["76561100000000001"]); + + expect(markReady).toHaveBeenCalledWith("match-1"); + }); + + // The plugin ticks for the life of the session. A row that is already Ready + // has nothing left to move, and writing it again once a minute per server is + // a write nobody reads. + it("does not write readiness again once the session is ready", async () => { + const { service, markReady } = makeService([], "Ready"); + + await service.reportOccupancy("server-1", ["76561100000000001"]); + + expect(markReady).not.toHaveBeenCalled(); + }); + it("does not fail the occupancy write when a push cannot be delivered", async () => { const { service, publish } = makeService([{ steam_id: "76561100000000001" }]); diff --git a/src/utility/utility-practice.service.ts b/src/utility/utility-practice.service.ts index 74c90672..98d68367 100644 --- a/src/utility/utility-practice.service.ts +++ b/src/utility/utility-practice.service.ts @@ -720,9 +720,9 @@ export class UtilityPracticeService { return row ?? null; } - // The plugin polls, so this runs on every GET /utility/session, and the - // occupancy tick posts it again every minute. The status guard is what makes - // that idempotent: only a row still in 'Starting' is moved. + // The plugin polls, so this runs on every GET /utility/session. The status + // guard is what makes that idempotent: only a row still in 'Starting' is + // moved. public async markReady(matchId: string): Promise { await this.postgres.query( `UPDATE public.utility_practice_sessions @@ -828,6 +828,7 @@ export class UtilityPracticeService { match_id: string; map_name: string; password: string; + status: string; } | null> { const [row] = await this.postgres.query< Array<{ @@ -835,12 +836,14 @@ export class UtilityPracticeService { match_id: string; map_name: string; password: string; + status: string; }> >( `SELECT s.id::text AS session_id, m.id::text AS match_id, s.map_name, - m.password + m.password, + s.status FROM public.servers srv INNER JOIN public.matches m ON m.id = srv.reserved_by_match_id INNER JOIN public.utility_practice_sessions s ON s.match_id = m.id @@ -1801,14 +1804,6 @@ export class UtilityPracticeService { return; } - // The second proof that the server is up, and the reason there has to be - // one: GET /utility/session is asked once, at map load, and a plugin whose - // first ask failed never asks again -- leaving a session Starting behind a - // server that is running and talking. A practice pod does not ping - // /game-server-node, so nothing else would ever notice. This tick is the - // plugin on a loaded map, which is all Ready has ever meant. - await this.markReady(session.match_id); - const present = steamIds .map((steamId) => String(steamId ?? "").trim()) .filter((steamId) => /^\d{5,20}$/.test(steamId)); @@ -1828,6 +1823,20 @@ export class UtilityPracticeService { [session.match_id, present], ); + // The second proof that the server is up, and the reason there has to be + // one: GET /utility/session is asked once, at map load, and a plugin whose + // first ask failed never asks again -- leaving a session Starting behind a + // server that is running and talking. A practice pod does not ping + // /game-server-node, so nothing else would ever notice. This tick is the + // plugin on a loaded map, which is all Ready has ever meant. + // + // Behind the roster write and behind the status the session row already + // reported: the reaper reads is_connected, so a throw here must not be + // what leaves an occupied session looking empty. + if (session.status === "Starting") { + await this.markReady(session.match_id); + } + await this.pushWhereAmI(flipped.map(({ steam_id }) => steam_id)); if (present.length > 0) { diff --git a/test/utility-practice.spec.ts b/test/utility-practice.spec.ts index f5a31782..4386bca5 100644 --- a/test/utility-practice.spec.ts +++ b/test/utility-practice.spec.ts @@ -341,10 +341,18 @@ describe("utility practice sessions (SQL-driven)", () => { }); describe("server headroom", () => { - it("refuses to start when only the reserved servers are free", async () => { + // Two rules on one fixture, because a standing server in the named region + // is what makes the refusal meaningful. The region group in the picker is + // headed ON DEMAND, and every standing practice server is a row of its own + // beside it: answering "US-EAST" with the box already running in US-EAST + // handed back a server nobody asked for -- and when that box is not really + // up, one that never answers, behind a connect string that made the website + // say "ready to join". + it("refuses a named region on reserved headroom without taking the standing server", async () => { const host = await fx.player(); await setting("public.utility_practice_enabled", "true"); await setting("public.utility_practice_reserved_servers", "2"); + await standingPracticeServer("TestA"); const service = makeService({ matchAssistant: { @@ -363,31 +371,6 @@ describe("utility practice sessions (SQL-driven)", () => { "SELECT 1 FROM utility_practice_sessions", ); expect(rows.length).toBe(0); - }); - - // The region group in the picker is headed ON DEMAND, and every standing - // practice server is a row of its own beside it. Answering "US-EAST" with - // the box already running in US-EAST handed back a server nobody asked for - // -- and when that box is not really up, one that never answers, behind a - // connect string that made the website say "ready to join". - it("boots on demand for a named region rather than taking a standing server", async () => { - const host = await fx.player(); - await setting("public.utility_practice_enabled", "true"); - await setting("public.utility_practice_reserved_servers", "2"); - await standingPracticeServer("TestA"); - - const service = makeService({ - matchAssistant: { - countFreeOnDemandServers: jest.fn(async (): Promise => 2), - }, - }); - - await expect( - service.start({ steam_id: host, role: "user" } as never, { - map_name: "de_mirage", - region: "TestA", - }), - ).rejects.toThrow(/no practice servers are free/); const [server] = await postgres.query>( `SELECT reserved_by_match_id::text AS reserved @@ -413,17 +396,18 @@ describe("utility practice sessions (SQL-driven)", () => { // The match behind it needs hasura, which this suite does not stand up -- // so it gets as far as the session row and no further. That row is the - // whole assertion: the headroom never turned it away. - await expect( - service.start({ steam_id: host, role: "user" } as never, { + // whole assertion: a start turned away by the headroom never reaches it, + // and the region on it is the standing server's. + await service + .start({ steam_id: host, role: "user" } as never, { map_name: "de_mirage", - }), - ).rejects.not.toThrow(/no practice servers are free/); + }) + .catch((): undefined => undefined); const [session] = await postgres.query>( "SELECT region FROM utility_practice_sessions", ); - expect(session.region).toBe("TestA"); + expect(session?.region).toBe("TestA"); }); it("refuses to start at all while the feature is off", async () => { From 0ef0b592ff4032ca55311c6efffc4c317e60032c Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Sat, 29 Aug 2026 09:40:44 -0400 Subject: [PATCH 3/6] wip From 6febe62536565d2207ca832388d6c9c9bbc4a6dc Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Sat, 29 Aug 2026 10:01:10 -0400 Subject: [PATCH 4/6] wip --- hasura/enums/notification-types.sql | 1 + .../down.sql | 3 - .../up.sql | 16 -- .../down.sql | 2 + .../up.sql | 10 + .../match-assistant.service.spec.ts | 36 +++ .../match-assistant.service.ts | 58 ++++- src/notifications/notifications.service.ts | 6 +- .../preferences/notification-categories.ts | 3 +- .../push/notification-delivery.ts | 4 + .../utilities/notificationUrl.ts | 3 +- src/utility/errors/NoPracticeServerHere.ts | 13 ++ src/utility/utility-practice.service.ts | 192 +++++++++++++--- test/utility-practice-servers.spec.ts | 13 +- test/utility-practice.spec.ts | 208 +++++++++++++++++- 15 files changed, 499 insertions(+), 69 deletions(-) delete mode 100644 hasura/migrations/default/1880000012000_drop_utility_practice_ready_notification/down.sql delete mode 100644 hasura/migrations/default/1880000012000_drop_utility_practice_ready_notification/up.sql create mode 100644 hasura/migrations/default/1880000013000_utility_practice_notify_when_ready/down.sql create mode 100644 hasura/migrations/default/1880000013000_utility_practice_notify_when_ready/up.sql create mode 100644 src/utility/errors/NoPracticeServerHere.ts diff --git a/hasura/enums/notification-types.sql b/hasura/enums/notification-types.sql index 43d715f0..27a2c727 100644 --- a/hasura/enums/notification-types.sql +++ b/hasura/enums/notification-types.sql @@ -43,6 +43,7 @@ INSERT INTO e_notification_types ("value", "description") VALUES ('EventReminder', 'An event you are attending starts soon'), ('SeasonEnded', 'A season has ended'), ('UtilityPracticeInvite', 'You were invited to a utility practice session'), + ('UtilityPracticeReady', 'Your utility practice server is ready'), ('UtilityDriftScanFinished', 'A utility drift scan finished') ON CONFLICT("value") DO UPDATE SET "description" = EXCLUDED."description"; diff --git a/hasura/migrations/default/1880000012000_drop_utility_practice_ready_notification/down.sql b/hasura/migrations/default/1880000012000_drop_utility_practice_ready_notification/down.sql deleted file mode 100644 index 9857622f..00000000 --- a/hasura/migrations/default/1880000012000_drop_utility_practice_ready_notification/down.sql +++ /dev/null @@ -1,3 +0,0 @@ -INSERT INTO public.e_notification_types ("value", "description") VALUES - ('UtilityPracticeReady', 'Your utility practice server is ready') -ON CONFLICT("value") DO NOTHING; diff --git a/hasura/migrations/default/1880000012000_drop_utility_practice_ready_notification/up.sql b/hasura/migrations/default/1880000012000_drop_utility_practice_ready_notification/up.sql deleted file mode 100644 index 665b3d84..00000000 --- a/hasura/migrations/default/1880000012000_drop_utility_practice_ready_notification/up.sql +++ /dev/null @@ -1,16 +0,0 @@ --- The practice bar in the top nav already says a server is booting and when it --- is up, on every page, for as long as the session lasts. A bell row saying the --- same thing arrived a moment later, said less, and outlived the server it was --- about -- the reaper stops an empty session, so a row sitting unread in the --- bell points at nothing. --- --- notifications.type is FK'd to this table ON DELETE CASCADE, so removing the --- enum value takes every row of it with it. Existing installs seeded the value --- from hasura/enums/notification-types.sql, which no longer lists it; a fresh --- install never had it, and this is a no-op there. -DELETE FROM public.e_notification_types WHERE "value" = 'UtilityPracticeReady'; - --- notification_preferences.key is plain text with no foreign key, so the --- per-player in-app toggle for the type would otherwise sit in the table --- forever, unreachable and unreadable. -DELETE FROM public.notification_preferences WHERE "key" = 'UtilityPracticeReady'; diff --git a/hasura/migrations/default/1880000013000_utility_practice_notify_when_ready/down.sql b/hasura/migrations/default/1880000013000_utility_practice_notify_when_ready/down.sql new file mode 100644 index 00000000..b0fb60c8 --- /dev/null +++ b/hasura/migrations/default/1880000013000_utility_practice_notify_when_ready/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE "public"."utility_practice_sessions" + DROP COLUMN IF EXISTS "notify_when_ready"; diff --git a/hasura/migrations/default/1880000013000_utility_practice_notify_when_ready/up.sql b/hasura/migrations/default/1880000013000_utility_practice_notify_when_ready/up.sql new file mode 100644 index 00000000..dfe9abdb --- /dev/null +++ b/hasura/migrations/default/1880000013000_utility_practice_notify_when_ready/up.sql @@ -0,0 +1,10 @@ +-- Who is still owed a "your server is up". +-- +-- The practice bar in the top nav says it already, on every page, for as long +-- as the session lasts -- so for somebody who pressed Start and stayed there, +-- a bell row says the same thing a moment later and says it worse. The +-- exception is a player who was turned away for want of a server: they queued, +-- and the whole point of a queue is that you stop watching it. Only their +-- session carries this. +ALTER TABLE "public"."utility_practice_sessions" + ADD COLUMN IF NOT EXISTS "notify_when_ready" boolean NOT NULL DEFAULT false; diff --git a/src/matches/match-assistant/match-assistant.service.spec.ts b/src/matches/match-assistant/match-assistant.service.spec.ts index 0c119168..7c6f0758 100644 --- a/src/matches/match-assistant/match-assistant.service.spec.ts +++ b/src/matches/match-assistant/match-assistant.service.spec.ts @@ -240,6 +240,42 @@ describe("MatchAssistantService", () => { expect(waitingForServerWrites()).toHaveLength(1); }); + // countFreeOnDemandServers answers zero for "everything is busy" and for + // "there is no node here", and only the second is hopeless -- the practice + // start reads this to decide whether queuing could ever help. + describe("hasOnDemandNodes", () => { + it("is false when no node in the region can take a pod", async () => { + hasura.query.mockResolvedValue({ game_server_nodes: [] }); + + expect(await service.hasOnDemandNodes("USE")).toBe(false); + }); + + it("asks only about the region it was given", async () => { + hasura.query.mockResolvedValue({ game_server_nodes: [{ id: "node-1" }] }); + + expect(await service.hasOnDemandNodes("USE")).toBe(true); + + const [[query]] = hasura.query.mock.calls; + + expect(query.game_server_nodes.__args.where.region).toEqual({ + _eq: "USE", + }); + expect(query.game_server_nodes.__args.where.status).toEqual({ + _eq: "Online", + }); + }); + + it("asks about the whole install when given no region", async () => { + hasura.query.mockResolvedValue({ game_server_nodes: [{ id: "node-1" }] }); + + await service.hasOnDemandNodes(); + + const [[query]] = hasura.query.mock.calls; + + expect(query.game_server_nodes.__args.where.region).toBeUndefined(); + }); + }); + // The boot attempt outlives the host pressing Stop. Writing the status back // without a condition moved the already Canceled match to WaitingForServer, // and with the practice session already Ended nothing was left to move it diff --git a/src/matches/match-assistant/match-assistant.service.ts b/src/matches/match-assistant/match-assistant.service.ts index c99adf25..eb00feb4 100644 --- a/src/matches/match-assistant/match-assistant.service.ts +++ b/src/matches/match-assistant/match-assistant.service.ts @@ -48,6 +48,19 @@ export class MatchAssistantService { ]; private static readonly TERMINAL_MATCH_STATUSES: readonly e_match_status_enum[] = ["Finished", "Canceled", "Forfeit", "Tie", "Surrendered"]; + // Sources only the on-demand image can run. The utility practice plugin ships + // in that image and not in the dedicated one, so a dedicated server handed to + // one of these is worse than failing: the connect string resolves the moment + // the server is assigned, so the website reads "ready to join" for a box that + // will never answer GET /utility/session and never comes up as a practice + // server at all. + // + // Not the same rule as the `source === "practice"` branch in match_events, + // which is about skipping Discord, ELO and the rest -- a source can want one + // without the other. + private static readonly ON_DEMAND_ONLY_SOURCES: ReadonlyArray = [ + "practice", + ]; public static readonly ON_DEMAND_SERVER_BOOT_CHECK_DELAY_MS = 15 * 1000; private static readonly INITIAL_BOOT_STATUS_DETAIL = "Waiting for Kubernetes to create the match server pod."; @@ -131,6 +144,42 @@ export class MatchAssistantService { } } + // Whether a pod could be booted here at all, as opposed to whether one is + // free right now. countFreeOnDemandServers answers zero for both, and telling + // them apart is what keeps a player queuing for a server no node could ever + // provide. Same node predicate assignOnDemandServer runs before it takes the + // pool lock. + public async hasOnDemandNodes(region?: string | null): Promise { + const { game_server_nodes } = await this.hasura.query({ + game_server_nodes: { + __args: { + where: { + status: { + _eq: "Online", + }, + enabled: { + _eq: true, + }, + enabled_for_match_making: { + _eq: true, + }, + ...(region + ? { + region: { + _eq: region, + }, + } + : {}), + }, + limit: 1, + }, + id: true, + }, + }); + + return game_server_nodes.length > 0; + } + // The same set assignOnDemandServer picks from, counted rather than taken. // Anything that wants to know whether there is room to boot another server // has to ask with this exact predicate, or it will promise a slot that the @@ -377,13 +426,8 @@ export class MatchAssistantService { }, }); - // A practice match runs the utility practice plugin, and only the on-demand - // image installs it -- a dedicated server is running the match plugin - // instead. Handing one to a practice session is worse than failing: the - // connect string resolves the moment the server is assigned, so the website - // reads "ready to join" for a box that will never answer GET - // /utility/session and never comes up as a practice server at all. - const onDemandOnly = match.source === "practice"; + const onDemandOnly = + MatchAssistantService.ON_DEMAND_ONLY_SOURCES.includes(match.source); if (!onDemandOnly && match.options.prefer_dedicated_server) { try { diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index 7ebc869e..b420cce5 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -60,9 +60,11 @@ export class NotificationsService { "LeagueMatchUnscheduled", "LeagueRegistrationDecision", "LeagueRosterUndersized", - // One player inviting a friend to their own practice server. A staff - // channel has nothing to do with it. + // One player's own practice server. A staff channel has nothing to do with + // it, and a busy install would post one line per player who had to queue + // for one. "UtilityPracticeInvite", + "UtilityPracticeReady", ]); // Nobody has seen a notification in six months who hasn't signed in, and a diff --git a/src/notifications/preferences/notification-categories.ts b/src/notifications/preferences/notification-categories.ts index 5e638803..5ccd6a19 100644 --- a/src/notifications/preferences/notification-categories.ts +++ b/src/notifications/preferences/notification-categories.ts @@ -48,7 +48,7 @@ export const PUSH_CATEGORIES: Record = { ], teams: ["FormTeamSuggestion"], invites: ["TeamInvite", "TournamentTeamInvite", "DraftInvite"], - utility: ["UtilityPracticeInvite"], + utility: ["UtilityPracticeInvite", "UtilityPracticeReady"], account: ["NameChangeApproved", "NameChangeDenied", "PlayerSanctioned", "AwardGranted"], news: ["NewsPublished"], staff_moderation: ["MatchSupport", "MatchAbandoned", "NameChangeRequest"], @@ -111,6 +111,7 @@ export const IN_APP_KEYS: PreferenceKey[] = [ { key: "ScrimAlertMatch", defaultEnabled: true }, { key: "LeagueMatchUnscheduled", defaultEnabled: true }, { key: "UtilityPracticeInvite", defaultEnabled: true }, + { key: "UtilityPracticeReady", defaultEnabled: true }, ]; const PUSH_CATEGORY_BY_TYPE: Record = Object.fromEntries( diff --git a/src/notifications/push/notification-delivery.ts b/src/notifications/push/notification-delivery.ts index 6cbc8a90..716b4c5a 100644 --- a/src/notifications/push/notification-delivery.ts +++ b/src/notifications/push/notification-delivery.ts @@ -67,6 +67,10 @@ const DELIVERY_POLICIES: Record = { "LeagueRegistrationDecision", "LeagueRosterUndersized", "UtilityPracticeInvite", + // A practice server is up for as long as somebody is on it and the reaper + // stops it when nobody is: a late buzz sends a player to a session that has + // already been torn down, so this one is worth nothing bundled. + "UtilityPracticeReady", ], // Fan-outs to the whole player base. Already collapsed into one job by diff --git a/src/notifications/utilities/notificationUrl.ts b/src/notifications/utilities/notificationUrl.ts index 22c713d7..663addb8 100644 --- a/src/notifications/utilities/notificationUrl.ts +++ b/src/notifications/utilities/notificationUrl.ts @@ -50,11 +50,12 @@ const PATH_BY_TYPE: Record string> = { LeagueRegistrationDecision: () => `/league`, LeagueRosterUndersized: () => `/league`, // A practice session is a dialog on the map board, not a page, so there is no - // route an id can be turned into. The invite carries the real target + // route an id can be turned into. Both of these carry the real target // (/utility/?practice=) in the message, which is read first; this is // only the fallback for a row authored without one, and the library index is // the closest honest place to land. UtilityPracticeInvite: () => `/utility`, + UtilityPracticeReady: () => `/utility`, // The admin drift review, which lists scans with their verdict counts. It // does not read a scan id today, so the id on the row stays off the path. UtilityDriftScanFinished: () => `/utility/drift`, diff --git a/src/utility/errors/NoPracticeServerHere.ts b/src/utility/errors/NoPracticeServerHere.ts new file mode 100644 index 00000000..e6580bc8 --- /dev/null +++ b/src/utility/errors/NoPracticeServerHere.ts @@ -0,0 +1,13 @@ +/** + * Turned away because nothing here could ever serve them, as opposed to + * because everything is busy right now. + * + * The difference is the waitlist. Queuing is what puts a max length on whoever + * is currently holding a server, so a row that can never be served costs every + * other player time and buys the person who filed it nothing. + */ +export class NoPracticeServerHere extends Error { + constructor(message = "no practice server can be started here") { + super(message); + } +} diff --git a/src/utility/utility-practice.service.ts b/src/utility/utility-practice.service.ts index 98d68367..62b9992f 100644 --- a/src/utility/utility-practice.service.ts +++ b/src/utility/utility-practice.service.ts @@ -24,6 +24,7 @@ import { UtilityScratchLineup, } from "./utility-load.service"; import { UtilityPracticeModeService } from "./utility-practice-mode.service"; +import { NoPracticeServerHere } from "./errors/NoPracticeServerHere"; export type UtilityPracticeSession = { id: string; @@ -91,6 +92,12 @@ export class UtilityPracticeService { public static readonly CONNECT_MINUTES = 5; public static readonly IDLE_MINUTES = 5; public static readonly MAX_MINUTES = 60; + // How long a queue entry means anything. Nothing serves this table -- it is + // only ever cleared by the same player getting a server -- so without an age + // bound one person who tried once and walked away leaves a row that says + // "somebody is waiting" forever, and MAX_MINUTES then caps every session on + // the install for the life of the database. + public static readonly WAITLIST_MINUTES = 30; // A render batch that has not finished in this long is not going to; the // server it is holding is worth more than the last few clips. public static readonly RENDER_GRACE_MINUTES = 90; @@ -167,7 +174,7 @@ export class UtilityPracticeService { ? await this.practiceServer(input.server_id) : input.region ? null - : await this.freePracticeServer(null); + : await this.freePracticeServer(); let region: string; @@ -183,17 +190,24 @@ export class UtilityPracticeService { } } catch (error) { // Turned away for want of a server: that is the queue, and it is what - // puts a max length on whoever is currently holding one. - await this.joinWaitlist( - user.steam_id, - input.map_name, - input.region ?? null, - ); + // puts a max length on whoever is currently holding one. Nothing about + // an install that cannot boot here belongs in it -- the row would never + // be served, and every other session would run under the max clock + // until somebody noticed. + if (!(error instanceof NoPracticeServerHere)) { + await this.joinWaitlist( + user.steam_id, + input.map_name, + input.region ?? null, + ); + } throw error; } - // Got one -- stop counting against the people still waiting. - await this.leaveWaitlist(user.steam_id); + // Got one -- stop counting against the people still waiting. Whether + // they were in the queue at all is the answer to who still needs telling + // when the server comes up. + const waited = await this.leaveWaitlist(user.steam_id); const access = UtilityPracticeService.accessFor(input); @@ -208,6 +222,7 @@ export class UtilityPracticeService { // a bug waiting for whichever one is checked second. isOpen: access === "Open", access, + notifyWhenReady: waited, }); try { @@ -720,16 +735,93 @@ export class UtilityPracticeService { return row ?? null; } - // The plugin polls, so this runs on every GET /utility/session. The status - // guard is what makes that idempotent: only a row still in 'Starting' is - // moved. + // The plugin polls, so this runs on every GET /utility/session. Only the row + // the UPDATE actually moved out of 'Starting' comes back, which is what keeps + // "your server is ready" a single buzz rather than one per poll -- the status + // guard alone makes the write idempotent, not the announcement. public async markReady(matchId: string): Promise { - await this.postgres.query( + const [session] = await this.postgres.query< + Array<{ + id: string; + host_steam_id: string | null; + map_name: string; + notify_when_ready: boolean; + }> + >( `UPDATE public.utility_practice_sessions SET status = 'Ready', failure_reason = NULL, last_occupied_at = now() - WHERE match_id = $1::uuid AND status = 'Starting'`, + WHERE match_id = $1::uuid AND status = 'Starting' + RETURNING id::text AS id, + host_steam_id::text AS host_steam_id, + map_name, + notify_when_ready`, [matchId], ); + + // Everybody else is already being told: the practice bar in the top nav + // follows the session on every page, and says both that it is booting and + // that it is up. The bell is for the player who was turned away first, + // queued, and had every reason to stop watching. + if (!session?.notify_when_ready) { + return; + } + + await this.notifyReady(matchId, session); + } + + private async notifyReady( + matchId: string, + session: { id: string; host_steam_id: string | null; map_name: string }, + ): Promise { + // The host plus anyone already added to the lineup: they were let in while + // the server was still booting, so the link they were handed only starts + // working now. + const players = await this.postgres.query>( + `SELECT DISTINCT mlp.steam_id::text AS steam_id + FROM public.match_lineup_players mlp + INNER JOIN public.match_lineups ml ON ml.id = mlp.match_lineup_id + WHERE ml.match_id = $1::uuid + AND mlp.steam_id IS NOT NULL`, + [matchId], + ); + + // A render session has no host and no roster -- nobody to tell it is ready. + const steamIds = [ + ...new Set( + [ + session.host_steam_id, + ...players.map((player) => player.steam_id), + ].filter((id): id is string => id !== null), + ), + ]; + + if (steamIds.length === 0) { + return; + } + + const map = NotificationsService.escapeHtml(session.map_name); + + try { + await this.notifications.notifyPlayers( + "UtilityPracticeReady" as e_notification_types_enum, + { + title: "Practice Server Ready", + message: + `Your utility practice server on ${map} is up. ` + + `Open the board.`, + role: "user" as e_player_roles_enum, + // Suffixed, not the bare session id: the bell stacks rows that share + // an entity_id, so an invite and a ready for the same session would + // collapse into one another and the second would never be seen. + entity_id: `${session.id}:ready`, + steamIds, + }, + ); + } catch (error) { + this.logger.warn( + `[utility-practice] unable to announce ${session.id} as ready: ${(error as Error)?.message}`, + ); + } } // A practice session has no page of its own -- it is a dialog on the map @@ -917,6 +1009,10 @@ export class UtilityPracticeService { } public async reapIdle(): Promise { + // Before contention is read, not after: a stale row would otherwise put + // every session on this pass under the max-length clock. + await this.sweepWaitlist(); + const idle = await this.minutes( SystemSettingName.UtilityPracticeIdleMinutes, UtilityPracticeService.IDLE_MINUTES, @@ -1138,6 +1234,7 @@ export class UtilityPracticeService { isOpen: boolean; access: string; isRender?: boolean; + notifyWhenReady?: boolean; }, ): Promise { // generate_utility_invite_code() is 50 bits, so a collision is vanishingly @@ -1148,8 +1245,8 @@ export class UtilityPracticeService { const [row] = await this.postgres.query>( `INSERT INTO public.utility_practice_sessions (host_steam_id, map_name, region, team_id, collection_id, is_open, - access, is_render) - VALUES ($1::bigint, $2, $3, $4::uuid, $5::uuid, $6, $7, $8) + access, is_render, notify_when_ready) + VALUES ($1::bigint, $2, $3, $4::uuid, $5::uuid, $6, $7, $8, $9) RETURNING id::text AS id`, [ hostSteamId, @@ -1160,6 +1257,7 @@ export class UtilityPracticeService { options.isOpen, options.access, options.isRender === true, + options.notifyWhenReady === true, ], ); @@ -2028,7 +2126,12 @@ export class UtilityPracticeService { private async anyoneWaiting(): Promise { const [row] = await this.postgres.query>( - "SELECT EXISTS (SELECT 1 FROM public.utility_practice_waitlist) AS waiting", + `SELECT EXISTS ( + SELECT 1 + FROM public.utility_practice_waitlist + WHERE created_at > now() - ($1 || ' minutes')::interval + ) AS waiting`, + [UtilityPracticeService.WAITLIST_MINUTES], ); return row?.waiting === true; } @@ -2049,11 +2152,32 @@ export class UtilityPracticeService { ); } - public async leaveWaitlist(steamId: string): Promise { - await this.postgres.query( - "DELETE FROM public.utility_practice_waitlist WHERE steam_id = $1", + // Answers whether they had been queuing, because that is the one thing that + // separates a player watching the dialog from a player who was told to come + // back later -- and only the second is owed a buzz when the server is up. + public async leaveWaitlist(steamId: string): Promise { + const rows = await this.postgres.query>( + `DELETE FROM public.utility_practice_waitlist + WHERE steam_id = $1 + RETURNING steam_id::text AS steam_id`, [steamId], ); + + return rows.length > 0; + } + + // The sweep the queue never had. Kept beside the reaper rather than on its + // own timer: the rows only matter to anyoneWaiting, which is what the reaper + // asks. + public async sweepWaitlist(): Promise { + const removed = await this.postgres.query>( + `DELETE FROM public.utility_practice_waitlist + WHERE created_at <= now() - ($1 || ' minutes')::interval + RETURNING steam_id::text AS steam_id`, + [UtilityPracticeService.WAITLIST_MINUTES], + ); + + return removed.length; } public async releaseOrphanedServers(): Promise { @@ -2145,9 +2269,13 @@ export class UtilityPracticeService { return { id: row.id, region: row.region }; } - private async freePracticeServer( - region?: string | null, - ): Promise<{ id: string; region: string } | null> { + // Unfiltered on purpose. Only the automatic choice reaches this -- a named + // region is the on-demand answer and a named server is taken by id -- so + // there is no caller left that wants a region-scoped standing server. + private async freePracticeServer(): Promise<{ + id: string; + region: string; + } | null> { const [row] = await this.postgres.query< Array<{ id: string; region: string }> >( @@ -2157,10 +2285,8 @@ export class UtilityPracticeService { AND s.enabled = true AND s.connected = true AND s.reserved_by_match_id IS NULL - AND ($1::text IS NULL OR s.region = $1::text) ORDER BY s.region LIMIT 1`, - [region ?? null], ); return row ? { id: row.id, region: row.region } : null; @@ -2174,9 +2300,21 @@ export class UtilityPracticeService { const headroom = Number.isFinite(reserved) && reserved >= 0 ? reserved : 2; const free = await this.matchAssistant.countFreeOnDemandServers(region); - if (free <= headroom) { - throw Error("no practice servers are free right now"); + if (free > headroom) { + return; } + + // Busy and impossible both count zero here, and they are not the same + // answer: one is worth waiting for and the other never will be. An install + // with no node in this region has no pod to boot no matter who gives up + // theirs. + if (!(await this.matchAssistant.hasOnDemandNodes(region))) { + throw new NoPracticeServerHere( + `no practice server can be started in ${region}`, + ); + } + + throw Error("no practice servers are free right now"); } private async resolveMap(mapName: string): Promise { diff --git a/test/utility-practice-servers.spec.ts b/test/utility-practice-servers.spec.ts index 620c6c5c..51790030 100644 --- a/test/utility-practice-servers.spec.ts +++ b/test/utility-practice-servers.spec.ts @@ -234,12 +234,15 @@ describe("utility practice servers (SQL-driven)", () => { ).rejects.toThrow(/already in use/); }); - it("auto-picks a free one in the region", async () => { - const id = await practiceServer({ region: "TestA" }); + // Only the automatic choice gets here -- a named region is the on-demand + // answer and a named server is taken by id -- so this picks from the whole + // standing pool rather than from one region of it. + it("auto-picks a free one wherever it is standing", async () => { + const id = await practiceServer({ region: "TestB" }); - const picked = await makeService()["freePracticeServer"]("TestA"); + const picked = await makeService()["freePracticeServer"](); - expect(picked).toEqual({ id, region: "TestA" }); + expect(picked).toEqual({ id, region: "TestB" }); }); it("picks nothing when every practice server is taken", async () => { @@ -251,7 +254,7 @@ describe("utility practice servers (SQL-driven)", () => { [id, matchId], ); - expect(await makeService()["freePracticeServer"]("TestA")).toBeNull(); + expect(await makeService()["freePracticeServer"]()).toBeNull(); }); }); diff --git a/test/utility-practice.spec.ts b/test/utility-practice.spec.ts index 4386bca5..5d1425c7 100644 --- a/test/utility-practice.spec.ts +++ b/test/utility-practice.spec.ts @@ -265,6 +265,9 @@ describe("utility practice sessions (SQL-driven)", () => { } as unknown as never, { countFreeOnDemandServers: jest.fn(async (): Promise => 10), + // An install that can boot pods, unless a test says otherwise: the + // other answer is a different refusal, not a busier one. + hasOnDemandNodes: jest.fn(async (): Promise => true), sendUtilityPracticeRefresh: jest.fn(async (): Promise => undefined), updateMatchStatus: jest.fn(async (): Promise => undefined), ...(overrides.matchAssistant ?? {}), @@ -410,6 +413,60 @@ describe("utility practice sessions (SQL-driven)", () => { expect(session?.region).toBe("TestA"); }); + // Busy is worth waiting for; impossible is not. Queuing is what puts a max + // length on everybody currently holding a server, so a row nothing can ever + // serve costs every other player time and buys its author nothing. + it("does not queue a player for a region that has no node to boot on", async () => { + const host = await fx.player(); + await setting("public.utility_practice_enabled", "true"); + await setting("public.utility_practice_reserved_servers", "2"); + + const service = makeService({ + matchAssistant: { + countFreeOnDemandServers: jest.fn(async (): Promise => 0), + hasOnDemandNodes: jest.fn(async (): Promise => false), + }, + }); + + await expect( + service.start({ steam_id: host, role: "user" } as never, { + map_name: "de_mirage", + region: "TestA", + }), + ).rejects.toThrow(/no practice server can be started in TestA/); + + const waiting = await postgres.query>( + "SELECT 1 FROM utility_practice_waitlist", + ); + expect(waiting.length).toBe(0); + }); + + it("queues a player when the servers exist and are merely busy", async () => { + const host = await fx.player(); + await setting("public.utility_practice_enabled", "true"); + await setting("public.utility_practice_reserved_servers", "2"); + + const service = makeService({ + matchAssistant: { + countFreeOnDemandServers: jest.fn(async (): Promise => 2), + hasOnDemandNodes: jest.fn(async (): Promise => true), + }, + }); + + await expect( + service.start({ steam_id: host, role: "user" } as never, { + map_name: "de_mirage", + region: "TestA", + }), + ).rejects.toThrow(/no practice servers are free/); + + const [waiting] = await postgres.query>( + "SELECT region FROM utility_practice_waitlist WHERE steam_id = $1", + [host], + ); + expect(waiting?.region).toBe("TestA"); + }); + it("refuses to start at all while the feature is off", async () => { const host = await fx.player(); await setting("public.utility_practice_enabled", "false"); @@ -425,6 +482,98 @@ describe("utility practice sessions (SQL-driven)", () => { }); }); + // Nothing serves this table -- it is only ever cleared by the same player + // getting a server -- so an abandoned row used to say "somebody is waiting" + // for the life of the database, and MAX_MINUTES then capped every session on + // the install. + describe("the waitlist", () => { + async function waitlist(steamId: string, minutesAgo: number) { + await postgres.query( + `INSERT INTO utility_practice_waitlist (steam_id, map_name, region, created_at) + VALUES ($1, 'de_mirage', 'TestA', now() - ($2 || ' minutes')::interval)`, + [steamId, minutesAgo], + ); + } + + it("drops entries older than the waiting window", async () => { + const stale = await fx.player(); + const fresh = await fx.player(); + await waitlist(stale, UtilityPracticeService.WAITLIST_MINUTES + 5); + await waitlist(fresh, 1); + + expect(await makeService({}).sweepWaitlist()).toBe(1); + + const rows = await postgres.query>( + "SELECT steam_id::text AS steam_id FROM utility_practice_waitlist", + ); + expect(rows.map(({ steam_id }) => steam_id)).toEqual([fresh]); + }); + + // The reaper is the only reader of contention, so the sweep runs there + // rather than on a timer of its own. + it("is swept before the reaper reads contention", async () => { + const stale = await fx.player(); + await waitlist(stale, UtilityPracticeService.WAITLIST_MINUTES + 5); + + await makeService({}).reapIdle(); + + const rows = await postgres.query>( + "SELECT 1 FROM utility_practice_waitlist", + ); + expect(rows.length).toBe(0); + }); + + // What the flag is for: the session remembers that its host had been sent + // away, so markReady knows there is somebody who stopped watching. + it("marks a session that came out of the queue for a buzz", async () => { + const host = await fx.player(); + await setting("public.utility_practice_enabled", "true"); + await standingPracticeServer("TestA"); + await waitlist(host, 1); + + // The match behind it needs hasura, which this suite does not stand up, + // so it gets as far as the session row and no further. + await makeService({}) + .start({ steam_id: host, role: "user" } as never, { + map_name: "de_mirage", + }) + .catch((): undefined => undefined); + + const [session] = await postgres.query< + Array<{ notify_when_ready: boolean }> + >("SELECT notify_when_ready FROM utility_practice_sessions"); + expect(session?.notify_when_ready).toBe(true); + }); + + it("leaves a walk-up session to the nav bar", async () => { + const host = await fx.player(); + await setting("public.utility_practice_enabled", "true"); + await standingPracticeServer("TestA"); + + await makeService({}) + .start({ steam_id: host, role: "user" } as never, { + map_name: "de_mirage", + }) + .catch((): undefined => undefined); + + const [session] = await postgres.query< + Array<{ notify_when_ready: boolean }> + >("SELECT notify_when_ready FROM utility_practice_sessions"); + expect(session?.notify_when_ready).toBe(false); + }); + + it("reports whether the player was actually queued", async () => { + const queued = await fx.player(); + const walkUp = await fx.player(); + await waitlist(queued, 1); + + const service = makeService({}); + + expect(await service.leaveWaitlist(queued)).toBe(true); + expect(await service.leaveWaitlist(walkUp)).toBe(false); + }); + }); + describe("the practice match", () => { it("materializes exactly one match map from a one-map Custom pool", async () => { const host = await fx.player(); @@ -1724,12 +1873,11 @@ describe("utility practice sessions (SQL-driven)", () => { expect(notified).toHaveLength(1); }); - // The bell row that used to fire here is gone: the practice bar in the top - // nav says the same thing, on every page, for as long as the session lasts. - // What has to survive is the status guard, which is now load-bearing for a - // different reason -- the plugin polls GET /utility/session and the - // occupancy tick posts every minute, so markReady runs over and over. - it("moves a session to Ready without buzzing anybody", async () => { + // The practice bar in the top nav says a server is booting and when it is + // up, on every page, for as long as the session lasts -- so somebody who + // pressed Start and stayed there is already being told, and a bell row a + // moment later says the same thing worse. + it("moves a session to Ready without buzzing anybody who was watching", async () => { const host = await fx.player(); const { matchId, lineupId } = await createPracticeMatch(host); const mate = await fx.player(); @@ -1743,13 +1891,59 @@ describe("utility practice sessions (SQL-driven)", () => { }); const service = makeService({}); - await service.markReady(matchId); await service.markReady(matchId); expect(await statusOf(sessionId)).toBe("Ready"); expect(notified).toHaveLength(0); }); + // The exception, and the only one: they were turned away, queued, and the + // whole point of a queue is that you stop watching it. + it("buzzes the session that had to wait for its turn", async () => { + const host = await fx.player(); + const { matchId, lineupId } = await createPracticeMatch(host); + const mate = await fx.player(); + await postgres.query( + "INSERT INTO match_lineup_players (match_lineup_id, steam_id) VALUES ($1, $2)", + [lineupId, mate], + ); + const sessionId = await insertSession(host, { + match_id: matchId, + status: "Starting", + notify_when_ready: true, + }); + + await makeService({}).markReady(matchId); + + expect(notified).toHaveLength(1); + expect(notified[0].type).toBe("UtilityPracticeReady"); + expect(notified[0].entity_id).toBe(`${sessionId}:ready`); + // The host plus whoever was let into the lineup while it booted: the + // link they were handed only starts working now. + expect([...notified[0].steamIds].sort()).toEqual([host, mate].sort()); + }); + + // The plugin polls GET /utility/session and the occupancy tick posts every + // minute, so this runs over and over. Only the row the UPDATE actually + // moved comes back, which is what keeps it one buzz rather than one a + // minute for the life of the session. + it("buzzes once however many times the plugin asks", async () => { + const host = await fx.player(); + const { matchId } = await createPracticeMatch(host); + await insertSession(host, { + match_id: matchId, + status: "Starting", + notify_when_ready: true, + }); + const service = makeService({}); + + await service.markReady(matchId); + await service.markReady(matchId); + await service.markReady(matchId); + + expect(notified).toHaveLength(1); + }); + // A late poll from a server that is being torn down must not put the // session back on the board. it("never brings a session back out of a terminal status", async () => { From 688abde7d38eb3e6ba5d99e0e37082496743849c Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Sat, 29 Aug 2026 10:07:11 -0400 Subject: [PATCH 5/6] wip --- generated/schema.graphql | 18 ++++++++++++++++++ generated/schema.ts | 28 +++++++++++++++++----------- generated/types.ts | 18 ++++++++++++++++++ 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/generated/schema.graphql b/generated/schema.graphql index 3bf999e5..53d3cc61 100644 --- a/generated/schema.graphql +++ b/generated/schema.graphql @@ -127754,6 +127754,7 @@ type utility_practice_sessions { """An object relationship""" match: matches match_id: uuid + notify_when_ready: Boolean! """An object relationship""" playbook: utility_playbooks @@ -127892,6 +127893,7 @@ input utility_practice_sessions_bool_exp { map_name: String_comparison_exp match: matches_bool_exp match_id: uuid_comparison_exp + notify_when_ready: Boolean_comparison_exp playbook: utility_playbooks_bool_exp playbook_id: uuid_comparison_exp region: String_comparison_exp @@ -127958,6 +127960,7 @@ input utility_practice_sessions_insert_input { map_name: String match: matches_obj_rel_insert_input match_id: uuid + notify_when_ready: Boolean playbook: utility_playbooks_obj_rel_insert_input playbook_id: uuid region: String @@ -128133,6 +128136,7 @@ input utility_practice_sessions_order_by { map_name: order_by match: matches_order_by match_id: order_by + notify_when_ready: order_by playbook: utility_playbooks_order_by playbook_id: order_by region: order_by @@ -128199,6 +128203,9 @@ enum utility_practice_sessions_select_column { """column name""" match_id + """column name""" + notify_when_ready + """column name""" playbook_id @@ -128224,6 +128231,9 @@ enum utility_practice_sessions_select_column_utility_practice_sessions_aggregate """column name""" is_render + + """column name""" + notify_when_ready } """ @@ -128235,6 +128245,9 @@ enum utility_practice_sessions_select_column_utility_practice_sessions_aggregate """column name""" is_render + + """column name""" + notify_when_ready } """ @@ -128257,6 +128270,7 @@ input utility_practice_sessions_set_input { map_changing_at: timestamptz map_name: String match_id: uuid + notify_when_ready: Boolean playbook_id: uuid region: String status: e_utility_practice_statuses_enum @@ -128329,6 +128343,7 @@ input utility_practice_sessions_stream_cursor_value_input { map_changing_at: timestamptz map_name: String match_id: uuid + notify_when_ready: Boolean playbook_id: uuid region: String status: e_utility_practice_statuses_enum @@ -128400,6 +128415,9 @@ enum utility_practice_sessions_update_column { """column name""" match_id + """column name""" + notify_when_ready + """column name""" playbook_id diff --git a/generated/schema.ts b/generated/schema.ts index 485987ad..c4a29041 100644 --- a/generated/schema.ts +++ b/generated/schema.ts @@ -37247,6 +37247,7 @@ export interface utility_practice_sessions { /** An object relationship */ match: (matches | null) match_id: (Scalars['uuid'] | null) + notify_when_ready: Scalars['Boolean'] /** An object relationship */ playbook: (utility_playbooks | null) playbook_id: (Scalars['uuid'] | null) @@ -37361,15 +37362,15 @@ export interface utility_practice_sessions_mutation_response { /** select columns of table "utility_practice_sessions" */ -export type utility_practice_sessions_select_column = 'access' | 'collection_id' | 'created_at' | 'empty_since' | 'expires_at' | 'failure_reason' | 'first_joined_at' | 'host_steam_id' | 'id' | 'invite_code' | 'is_open' | 'is_render' | 'last_occupied_at' | 'map_changing_at' | 'map_name' | 'match_id' | 'playbook_id' | 'region' | 'status' | 'team_id' | 'updated_at' +export type utility_practice_sessions_select_column = 'access' | 'collection_id' | 'created_at' | 'empty_since' | 'expires_at' | 'failure_reason' | 'first_joined_at' | 'host_steam_id' | 'id' | 'invite_code' | 'is_open' | 'is_render' | 'last_occupied_at' | 'map_changing_at' | 'map_name' | 'match_id' | 'notify_when_ready' | 'playbook_id' | 'region' | 'status' | 'team_id' | 'updated_at' /** select "utility_practice_sessions_aggregate_bool_exp_bool_and_arguments_columns" columns of table "utility_practice_sessions" */ -export type utility_practice_sessions_select_column_utility_practice_sessions_aggregate_bool_exp_bool_and_arguments_columns = 'is_open' | 'is_render' +export type utility_practice_sessions_select_column_utility_practice_sessions_aggregate_bool_exp_bool_and_arguments_columns = 'is_open' | 'is_render' | 'notify_when_ready' /** select "utility_practice_sessions_aggregate_bool_exp_bool_or_arguments_columns" columns of table "utility_practice_sessions" */ -export type utility_practice_sessions_select_column_utility_practice_sessions_aggregate_bool_exp_bool_or_arguments_columns = 'is_open' | 'is_render' +export type utility_practice_sessions_select_column_utility_practice_sessions_aggregate_bool_exp_bool_or_arguments_columns = 'is_open' | 'is_render' | 'notify_when_ready' /** aggregate stddev on columns */ @@ -37401,7 +37402,7 @@ export interface utility_practice_sessions_sum_fields { /** update columns of table "utility_practice_sessions" */ -export type utility_practice_sessions_update_column = 'access' | 'collection_id' | 'created_at' | 'empty_since' | 'expires_at' | 'failure_reason' | 'first_joined_at' | 'host_steam_id' | 'id' | 'invite_code' | 'is_open' | 'is_render' | 'last_occupied_at' | 'map_changing_at' | 'map_name' | 'match_id' | 'playbook_id' | 'region' | 'status' | 'team_id' | 'updated_at' +export type utility_practice_sessions_update_column = 'access' | 'collection_id' | 'created_at' | 'empty_since' | 'expires_at' | 'failure_reason' | 'first_joined_at' | 'host_steam_id' | 'id' | 'invite_code' | 'is_open' | 'is_render' | 'last_occupied_at' | 'map_changing_at' | 'map_name' | 'match_id' | 'notify_when_ready' | 'playbook_id' | 'region' | 'status' | 'team_id' | 'updated_at' /** aggregate var_pop on columns */ @@ -113533,6 +113534,7 @@ export interface utility_practice_sessionsGenqlSelection{ /** An object relationship */ match?: matchesGenqlSelection match_id?: boolean | number + notify_when_ready?: boolean | number /** An object relationship */ playbook?: utility_playbooksGenqlSelection playbook_id?: boolean | number @@ -113605,7 +113607,7 @@ export interface utility_practice_sessions_avg_order_by {host_steam_id?: (order_ /** Boolean expression to filter rows from the table "utility_practice_sessions". All fields are combined with a logical 'AND'. */ -export interface utility_practice_sessions_bool_exp {_and?: (utility_practice_sessions_bool_exp[] | null),_not?: (utility_practice_sessions_bool_exp | null),_or?: (utility_practice_sessions_bool_exp[] | null),access?: (e_utility_practice_access_enum_comparison_exp | null),can_manage?: (Boolean_comparison_exp | null),can_view?: (Boolean_comparison_exp | null),collection?: (utility_collections_bool_exp | null),collection_id?: (uuid_comparison_exp | null),connection_link?: (String_comparison_exp | null),connection_string?: (String_comparison_exp | null),created_at?: (timestamptz_comparison_exp | null),e_utility_practice_status?: (e_utility_practice_statuses_bool_exp | null),empty_since?: (timestamptz_comparison_exp | null),expires_at?: (timestamptz_comparison_exp | null),failure_reason?: (String_comparison_exp | null),first_joined_at?: (timestamptz_comparison_exp | null),host?: (players_bool_exp | null),host_steam_id?: (bigint_comparison_exp | null),id?: (uuid_comparison_exp | null),invite_code?: (String_comparison_exp | null),invites?: (utility_practice_invites_bool_exp | null),invites_aggregate?: (utility_practice_invites_aggregate_bool_exp | null),is_member?: (Boolean_comparison_exp | null),is_open?: (Boolean_comparison_exp | null),is_render?: (Boolean_comparison_exp | null),last_occupied_at?: (timestamptz_comparison_exp | null),map_changing_at?: (timestamptz_comparison_exp | null),map_name?: (String_comparison_exp | null),match?: (matches_bool_exp | null),match_id?: (uuid_comparison_exp | null),playbook?: (utility_playbooks_bool_exp | null),playbook_id?: (uuid_comparison_exp | null),region?: (String_comparison_exp | null),status?: (e_utility_practice_statuses_enum_comparison_exp | null),team?: (teams_bool_exp | null),team_id?: (uuid_comparison_exp | null),updated_at?: (timestamptz_comparison_exp | null)} +export interface utility_practice_sessions_bool_exp {_and?: (utility_practice_sessions_bool_exp[] | null),_not?: (utility_practice_sessions_bool_exp | null),_or?: (utility_practice_sessions_bool_exp[] | null),access?: (e_utility_practice_access_enum_comparison_exp | null),can_manage?: (Boolean_comparison_exp | null),can_view?: (Boolean_comparison_exp | null),collection?: (utility_collections_bool_exp | null),collection_id?: (uuid_comparison_exp | null),connection_link?: (String_comparison_exp | null),connection_string?: (String_comparison_exp | null),created_at?: (timestamptz_comparison_exp | null),e_utility_practice_status?: (e_utility_practice_statuses_bool_exp | null),empty_since?: (timestamptz_comparison_exp | null),expires_at?: (timestamptz_comparison_exp | null),failure_reason?: (String_comparison_exp | null),first_joined_at?: (timestamptz_comparison_exp | null),host?: (players_bool_exp | null),host_steam_id?: (bigint_comparison_exp | null),id?: (uuid_comparison_exp | null),invite_code?: (String_comparison_exp | null),invites?: (utility_practice_invites_bool_exp | null),invites_aggregate?: (utility_practice_invites_aggregate_bool_exp | null),is_member?: (Boolean_comparison_exp | null),is_open?: (Boolean_comparison_exp | null),is_render?: (Boolean_comparison_exp | null),last_occupied_at?: (timestamptz_comparison_exp | null),map_changing_at?: (timestamptz_comparison_exp | null),map_name?: (String_comparison_exp | null),match?: (matches_bool_exp | null),match_id?: (uuid_comparison_exp | null),notify_when_ready?: (Boolean_comparison_exp | null),playbook?: (utility_playbooks_bool_exp | null),playbook_id?: (uuid_comparison_exp | null),region?: (String_comparison_exp | null),status?: (e_utility_practice_statuses_enum_comparison_exp | null),team?: (teams_bool_exp | null),team_id?: (uuid_comparison_exp | null),updated_at?: (timestamptz_comparison_exp | null)} /** input type for incrementing numeric columns in table "utility_practice_sessions" */ @@ -113613,7 +113615,7 @@ export interface utility_practice_sessions_inc_input {host_steam_id?: (Scalars[' /** input type for inserting data into table "utility_practice_sessions" */ -export interface utility_practice_sessions_insert_input {access?: (e_utility_practice_access_enum | null),collection?: (utility_collections_obj_rel_insert_input | null),collection_id?: (Scalars['uuid'] | null),created_at?: (Scalars['timestamptz'] | null),e_utility_practice_status?: (e_utility_practice_statuses_obj_rel_insert_input | null),empty_since?: (Scalars['timestamptz'] | null),expires_at?: (Scalars['timestamptz'] | null),failure_reason?: (Scalars['String'] | null),first_joined_at?: (Scalars['timestamptz'] | null),host?: (players_obj_rel_insert_input | null),host_steam_id?: (Scalars['bigint'] | null),id?: (Scalars['uuid'] | null),invite_code?: (Scalars['String'] | null),invites?: (utility_practice_invites_arr_rel_insert_input | null),is_open?: (Scalars['Boolean'] | null),is_render?: (Scalars['Boolean'] | null),last_occupied_at?: (Scalars['timestamptz'] | null),map_changing_at?: (Scalars['timestamptz'] | null),map_name?: (Scalars['String'] | null),match?: (matches_obj_rel_insert_input | null),match_id?: (Scalars['uuid'] | null),playbook?: (utility_playbooks_obj_rel_insert_input | null),playbook_id?: (Scalars['uuid'] | null),region?: (Scalars['String'] | null),status?: (e_utility_practice_statuses_enum | null),team?: (teams_obj_rel_insert_input | null),team_id?: (Scalars['uuid'] | null),updated_at?: (Scalars['timestamptz'] | null)} +export interface utility_practice_sessions_insert_input {access?: (e_utility_practice_access_enum | null),collection?: (utility_collections_obj_rel_insert_input | null),collection_id?: (Scalars['uuid'] | null),created_at?: (Scalars['timestamptz'] | null),e_utility_practice_status?: (e_utility_practice_statuses_obj_rel_insert_input | null),empty_since?: (Scalars['timestamptz'] | null),expires_at?: (Scalars['timestamptz'] | null),failure_reason?: (Scalars['String'] | null),first_joined_at?: (Scalars['timestamptz'] | null),host?: (players_obj_rel_insert_input | null),host_steam_id?: (Scalars['bigint'] | null),id?: (Scalars['uuid'] | null),invite_code?: (Scalars['String'] | null),invites?: (utility_practice_invites_arr_rel_insert_input | null),is_open?: (Scalars['Boolean'] | null),is_render?: (Scalars['Boolean'] | null),last_occupied_at?: (Scalars['timestamptz'] | null),map_changing_at?: (Scalars['timestamptz'] | null),map_name?: (Scalars['String'] | null),match?: (matches_obj_rel_insert_input | null),match_id?: (Scalars['uuid'] | null),notify_when_ready?: (Scalars['Boolean'] | null),playbook?: (utility_playbooks_obj_rel_insert_input | null),playbook_id?: (Scalars['uuid'] | null),region?: (Scalars['String'] | null),status?: (e_utility_practice_statuses_enum | null),team?: (teams_obj_rel_insert_input | null),team_id?: (Scalars['uuid'] | null),updated_at?: (Scalars['timestamptz'] | null)} /** aggregate max on columns */ @@ -113702,7 +113704,7 @@ export interface utility_practice_sessions_on_conflict {constraint: utility_prac /** Ordering options when selecting data from "utility_practice_sessions". */ -export interface utility_practice_sessions_order_by {access?: (order_by | null),can_manage?: (order_by | null),can_view?: (order_by | null),collection?: (utility_collections_order_by | null),collection_id?: (order_by | null),connection_link?: (order_by | null),connection_string?: (order_by | null),created_at?: (order_by | null),e_utility_practice_status?: (e_utility_practice_statuses_order_by | null),empty_since?: (order_by | null),expires_at?: (order_by | null),failure_reason?: (order_by | null),first_joined_at?: (order_by | null),host?: (players_order_by | null),host_steam_id?: (order_by | null),id?: (order_by | null),invite_code?: (order_by | null),invites_aggregate?: (utility_practice_invites_aggregate_order_by | null),is_member?: (order_by | null),is_open?: (order_by | null),is_render?: (order_by | null),last_occupied_at?: (order_by | null),map_changing_at?: (order_by | null),map_name?: (order_by | null),match?: (matches_order_by | null),match_id?: (order_by | null),playbook?: (utility_playbooks_order_by | null),playbook_id?: (order_by | null),region?: (order_by | null),status?: (order_by | null),team?: (teams_order_by | null),team_id?: (order_by | null),updated_at?: (order_by | null)} +export interface utility_practice_sessions_order_by {access?: (order_by | null),can_manage?: (order_by | null),can_view?: (order_by | null),collection?: (utility_collections_order_by | null),collection_id?: (order_by | null),connection_link?: (order_by | null),connection_string?: (order_by | null),created_at?: (order_by | null),e_utility_practice_status?: (e_utility_practice_statuses_order_by | null),empty_since?: (order_by | null),expires_at?: (order_by | null),failure_reason?: (order_by | null),first_joined_at?: (order_by | null),host?: (players_order_by | null),host_steam_id?: (order_by | null),id?: (order_by | null),invite_code?: (order_by | null),invites_aggregate?: (utility_practice_invites_aggregate_order_by | null),is_member?: (order_by | null),is_open?: (order_by | null),is_render?: (order_by | null),last_occupied_at?: (order_by | null),map_changing_at?: (order_by | null),map_name?: (order_by | null),match?: (matches_order_by | null),match_id?: (order_by | null),notify_when_ready?: (order_by | null),playbook?: (utility_playbooks_order_by | null),playbook_id?: (order_by | null),region?: (order_by | null),status?: (order_by | null),team?: (teams_order_by | null),team_id?: (order_by | null),updated_at?: (order_by | null)} /** primary key columns input for table: utility_practice_sessions */ @@ -113710,7 +113712,7 @@ export interface utility_practice_sessions_pk_columns_input {id: Scalars['uuid'] /** input type for updating data in table "utility_practice_sessions" */ -export interface utility_practice_sessions_set_input {access?: (e_utility_practice_access_enum | null),collection_id?: (Scalars['uuid'] | null),created_at?: (Scalars['timestamptz'] | null),empty_since?: (Scalars['timestamptz'] | null),expires_at?: (Scalars['timestamptz'] | null),failure_reason?: (Scalars['String'] | null),first_joined_at?: (Scalars['timestamptz'] | null),host_steam_id?: (Scalars['bigint'] | null),id?: (Scalars['uuid'] | null),invite_code?: (Scalars['String'] | null),is_open?: (Scalars['Boolean'] | null),is_render?: (Scalars['Boolean'] | null),last_occupied_at?: (Scalars['timestamptz'] | null),map_changing_at?: (Scalars['timestamptz'] | null),map_name?: (Scalars['String'] | null),match_id?: (Scalars['uuid'] | null),playbook_id?: (Scalars['uuid'] | null),region?: (Scalars['String'] | null),status?: (e_utility_practice_statuses_enum | null),team_id?: (Scalars['uuid'] | null),updated_at?: (Scalars['timestamptz'] | null)} +export interface utility_practice_sessions_set_input {access?: (e_utility_practice_access_enum | null),collection_id?: (Scalars['uuid'] | null),created_at?: (Scalars['timestamptz'] | null),empty_since?: (Scalars['timestamptz'] | null),expires_at?: (Scalars['timestamptz'] | null),failure_reason?: (Scalars['String'] | null),first_joined_at?: (Scalars['timestamptz'] | null),host_steam_id?: (Scalars['bigint'] | null),id?: (Scalars['uuid'] | null),invite_code?: (Scalars['String'] | null),is_open?: (Scalars['Boolean'] | null),is_render?: (Scalars['Boolean'] | null),last_occupied_at?: (Scalars['timestamptz'] | null),map_changing_at?: (Scalars['timestamptz'] | null),map_name?: (Scalars['String'] | null),match_id?: (Scalars['uuid'] | null),notify_when_ready?: (Scalars['Boolean'] | null),playbook_id?: (Scalars['uuid'] | null),region?: (Scalars['String'] | null),status?: (e_utility_practice_statuses_enum | null),team_id?: (Scalars['uuid'] | null),updated_at?: (Scalars['timestamptz'] | null)} /** aggregate stddev on columns */ @@ -113758,7 +113760,7 @@ ordering?: (cursor_ordering | null)} /** Initial value of the column from where the streaming should start */ -export interface utility_practice_sessions_stream_cursor_value_input {access?: (e_utility_practice_access_enum | null),collection_id?: (Scalars['uuid'] | null),created_at?: (Scalars['timestamptz'] | null),empty_since?: (Scalars['timestamptz'] | null),expires_at?: (Scalars['timestamptz'] | null),failure_reason?: (Scalars['String'] | null),first_joined_at?: (Scalars['timestamptz'] | null),host_steam_id?: (Scalars['bigint'] | null),id?: (Scalars['uuid'] | null),invite_code?: (Scalars['String'] | null),is_open?: (Scalars['Boolean'] | null),is_render?: (Scalars['Boolean'] | null),last_occupied_at?: (Scalars['timestamptz'] | null),map_changing_at?: (Scalars['timestamptz'] | null),map_name?: (Scalars['String'] | null),match_id?: (Scalars['uuid'] | null),playbook_id?: (Scalars['uuid'] | null),region?: (Scalars['String'] | null),status?: (e_utility_practice_statuses_enum | null),team_id?: (Scalars['uuid'] | null),updated_at?: (Scalars['timestamptz'] | null)} +export interface utility_practice_sessions_stream_cursor_value_input {access?: (e_utility_practice_access_enum | null),collection_id?: (Scalars['uuid'] | null),created_at?: (Scalars['timestamptz'] | null),empty_since?: (Scalars['timestamptz'] | null),expires_at?: (Scalars['timestamptz'] | null),failure_reason?: (Scalars['String'] | null),first_joined_at?: (Scalars['timestamptz'] | null),host_steam_id?: (Scalars['bigint'] | null),id?: (Scalars['uuid'] | null),invite_code?: (Scalars['String'] | null),is_open?: (Scalars['Boolean'] | null),is_render?: (Scalars['Boolean'] | null),last_occupied_at?: (Scalars['timestamptz'] | null),map_changing_at?: (Scalars['timestamptz'] | null),map_name?: (Scalars['String'] | null),match_id?: (Scalars['uuid'] | null),notify_when_ready?: (Scalars['Boolean'] | null),playbook_id?: (Scalars['uuid'] | null),region?: (Scalars['String'] | null),status?: (e_utility_practice_statuses_enum | null),team_id?: (Scalars['uuid'] | null),updated_at?: (Scalars['timestamptz'] | null)} /** aggregate sum on columns */ @@ -149583,6 +149585,7 @@ export const enumUtilityPracticeSessionsSelectColumn = { map_changing_at: 'map_changing_at' as const, map_name: 'map_name' as const, match_id: 'match_id' as const, + notify_when_ready: 'notify_when_ready' as const, playbook_id: 'playbook_id' as const, region: 'region' as const, status: 'status' as const, @@ -149592,12 +149595,14 @@ export const enumUtilityPracticeSessionsSelectColumn = { export const enumUtilityPracticeSessionsSelectColumnUtilityPracticeSessionsAggregateBoolExpBoolAndArgumentsColumns = { is_open: 'is_open' as const, - is_render: 'is_render' as const + is_render: 'is_render' as const, + notify_when_ready: 'notify_when_ready' as const } export const enumUtilityPracticeSessionsSelectColumnUtilityPracticeSessionsAggregateBoolExpBoolOrArgumentsColumns = { is_open: 'is_open' as const, - is_render: 'is_render' as const + is_render: 'is_render' as const, + notify_when_ready: 'notify_when_ready' as const } export const enumUtilityPracticeSessionsUpdateColumn = { @@ -149617,6 +149622,7 @@ export const enumUtilityPracticeSessionsUpdateColumn = { map_changing_at: 'map_changing_at' as const, map_name: 'map_name' as const, match_id: 'match_id' as const, + notify_when_ready: 'notify_when_ready' as const, playbook_id: 'playbook_id' as const, region: 'region' as const, status: 'status' as const, diff --git a/generated/types.ts b/generated/types.ts index a1e4bff4..6c7711cb 100644 --- a/generated/types.ts +++ b/generated/types.ts @@ -141996,6 +141996,9 @@ export default { "match_id": [ 6365 ], + "notify_when_ready": [ + 6 + ], "playbook": [ 6250 ], @@ -142300,6 +142303,9 @@ export default { "match_id": [ 6367 ], + "notify_when_ready": [ + 7 + ], "playbook": [ 6254 ], @@ -142398,6 +142404,9 @@ export default { "match_id": [ 6365 ], + "notify_when_ready": [ + 6 + ], "playbook": [ 6261 ], @@ -142774,6 +142783,9 @@ export default { "match_id": [ 3558 ], + "notify_when_ready": [ + 3558 + ], "playbook": [ 6263 ], @@ -142859,6 +142871,9 @@ export default { "match_id": [ 6365 ], + "notify_when_ready": [ + 6 + ], "playbook_id": [ 6365 ], @@ -142986,6 +143001,9 @@ export default { "match_id": [ 6365 ], + "notify_when_ready": [ + 6 + ], "playbook_id": [ 6365 ], From fb4e365d57e90eece3e676b5254f4295f5fdfba2 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Sat, 29 Aug 2026 10:09:36 -0400 Subject: [PATCH 6/6] wip --- src/system/enums/SystemSettingName.ts | 5 +++++ src/utility/utility-practice.service.ts | 21 ++++++++++++++------- test/utility-practice.spec.ts | 9 +++++++++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/system/enums/SystemSettingName.ts b/src/system/enums/SystemSettingName.ts index 2f12e9c7..9c94fb3e 100644 --- a/src/system/enums/SystemSettingName.ts +++ b/src/system/enums/SystemSettingName.ts @@ -41,6 +41,11 @@ export enum SystemSettingName { UtilityPracticeIdleMinutes = "public.utility_practice_idle_minutes", UtilityPracticeConnectMinutes = "public.utility_practice_connect_minutes", UtilityPracticeMaxMinutes = "public.utility_practice_max_minutes", + // Minutes a queue entry means anything. Nothing serves the waitlist -- it is + // only ever cleared by the same player getting a server -- so an unbounded + // one lets a player who tried once and walked away hold every other session + // under the max-length clock indefinitely. + UtilityPracticeWaitlistMinutes = "public.utility_practice_waitlist_minutes", // On-demand server slots a practice session will never take. Without it a // player idly practising can consume the last slot a scheduled tournament // match was going to boot into. diff --git a/src/utility/utility-practice.service.ts b/src/utility/utility-practice.service.ts index 62b9992f..970c5e1b 100644 --- a/src/utility/utility-practice.service.ts +++ b/src/utility/utility-practice.service.ts @@ -92,11 +92,11 @@ export class UtilityPracticeService { public static readonly CONNECT_MINUTES = 5; public static readonly IDLE_MINUTES = 5; public static readonly MAX_MINUTES = 60; - // How long a queue entry means anything. Nothing serves this table -- it is - // only ever cleared by the same player getting a server -- so without an age - // bound one person who tried once and walked away leaves a row that says - // "somebody is waiting" forever, and MAX_MINUTES then caps every session on - // the install for the life of the database. + // Default for how long a queue entry means anything. Nothing serves this + // table -- it is only ever cleared by the same player getting a server -- so + // without an age bound one person who tried once and walked away leaves a row + // that says "somebody is waiting" forever, and MAX_MINUTES then caps every + // session on the install for the life of the database. public static readonly WAITLIST_MINUTES = 30; // A render batch that has not finished in this long is not going to; the // server it is holding is worth more than the last few clips. @@ -2131,11 +2131,18 @@ export class UtilityPracticeService { FROM public.utility_practice_waitlist WHERE created_at > now() - ($1 || ' minutes')::interval ) AS waiting`, - [UtilityPracticeService.WAITLIST_MINUTES], + [await this.waitlistMinutes()], ); return row?.waiting === true; } + private async waitlistMinutes(): Promise { + return await this.minutes( + SystemSettingName.UtilityPracticeWaitlistMinutes, + UtilityPracticeService.WAITLIST_MINUTES, + ); + } + public async joinWaitlist( steamId: string, mapName: string, @@ -2174,7 +2181,7 @@ export class UtilityPracticeService { `DELETE FROM public.utility_practice_waitlist WHERE created_at <= now() - ($1 || ' minutes')::interval RETURNING steam_id::text AS steam_id`, - [UtilityPracticeService.WAITLIST_MINUTES], + [await this.waitlistMinutes()], ); return removed.length; diff --git a/test/utility-practice.spec.ts b/test/utility-practice.spec.ts index 5d1425c7..6c143f19 100644 --- a/test/utility-practice.spec.ts +++ b/test/utility-practice.spec.ts @@ -509,6 +509,15 @@ describe("utility practice sessions (SQL-driven)", () => { expect(rows.map(({ steam_id }) => steam_id)).toEqual([fresh]); }); + it("takes the waiting window from the settings table", async () => { + const stale = await fx.player(); + await setting("public.utility_practice_waitlist_minutes", "5"); + // Inside the 30-minute default, outside the 5 the operator asked for. + await waitlist(stale, 10); + + expect(await makeService({}).sweepWaitlist()).toBe(1); + }); + // The reaper is the only reader of contention, so the sweep runs there // rather than on a timer of its own. it("is swept before the reaper reads contention", async () => {