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 ], 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 5f18c5a5..7c6f0758 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(), @@ -202,6 +213,123 @@ 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); + + await expect(service.assignServer("match-1")).resolves.toBeUndefined(); + + expect(assignDedicated).not.toHaveBeenCalled(); + 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 + // 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"]), + ); + }); + + // 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..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 @@ -322,6 +371,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); @@ -334,13 +419,17 @@ export class MatchAssistantService { }, id: true, region: true, + source: true, options: { prefer_dedicated_server: true, }, }, }); - if (match.options.prefer_dedicated_server) { + const onDemandOnly = + MatchAssistantService.ON_DEMAND_ONLY_SOURCES.includes(match.source); + + if (!onDemandOnly && match.options.prefer_dedicated_server) { try { const assignedDedicated = await this.assignDedicatedServer( match.id, @@ -374,7 +463,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 () => { @@ -385,12 +474,24 @@ 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.markWaitingForServer(match.id); + return; + } + // we already checked above, so we can skip trying to assign again if (match.options.prefer_dedicated_server) { this.logger.log( `[${matchId}] unable to assign dedicated server, trying on demand`, ); - await this.updateMatchStatus(match.id, "WaitingForServer"); + await this.markWaitingForServer(match.id); return; } @@ -410,7 +511,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); } /** @@ -864,7 +965,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 f0f319e3..b420cce5 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -61,7 +61,8 @@ export class NotificationsService { "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. + // it, and a busy install would post one line per player who had to queue + // for one. "UtilityPracticeInvite", "UtilityPracticeReady", ]); 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/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-occupancy.spec.ts b/src/utility/utility-practice-occupancy.spec.ts index 1afe18c2..01588ee1 100644 --- a/src/utility/utility-practice-occupancy.spec.ts +++ b/src/utility/utility-practice-occupancy.spec.ts @@ -6,12 +6,16 @@ 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() }; - // 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); @@ -44,10 +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. + const markReady = jest + .spyOn(service, "markReady") + .mockResolvedValue(undefined); - return { service, publish, postgres, load }; + return { service, publish, postgres, load, markReady }; } function pushed(publish: jest.Mock) { @@ -119,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 7025404b..970c5e1b 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; + // 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. public static readonly RENDER_GRACE_MINUTES = 90; @@ -153,9 +160,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(); let region: string; @@ -171,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); @@ -196,6 +222,7 @@ export class UtilityPracticeService { // a bug waiting for whichever one is checked second. isOpen: access === "Open", access, + notifyWhenReady: waited, }); try { @@ -710,19 +737,32 @@ export class UtilityPracticeService { // 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. + // "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 { const [session] = await this.postgres.query< - Array<{ id: string; host_steam_id: string | null; map_name: string }> + 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' - RETURNING id::text AS id, host_steam_id::text AS host_steam_id, map_name`, + RETURNING id::text AS id, + host_steam_id::text AS host_steam_id, + map_name, + notify_when_ready`, [matchId], ); - if (!session) { + // 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; } @@ -754,6 +794,7 @@ export class UtilityPracticeService { ].filter((id): id is string => id !== null), ), ]; + if (steamIds.length === 0) { return; } @@ -879,6 +920,7 @@ export class UtilityPracticeService { match_id: string; map_name: string; password: string; + status: string; } | null> { const [row] = await this.postgres.query< Array<{ @@ -886,12 +928,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 @@ -965,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, @@ -1186,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 @@ -1196,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, @@ -1208,6 +1257,7 @@ export class UtilityPracticeService { options.isOpen, options.access, options.isRender === true, + options.notifyWhenReady === true, ], ); @@ -1871,6 +1921,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) { @@ -2062,11 +2126,23 @@ 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`, + [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, @@ -2083,11 +2159,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`, + [await this.waitlistMinutes()], + ); + + return removed.length; } public async releaseOrphanedServers(): Promise { @@ -2179,9 +2276,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 }> >( @@ -2191,10 +2292,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; @@ -2208,9 +2307,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 21ef1ae7..6c143f19 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. @@ -252,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 ?? {}), @@ -328,10 +344,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: { @@ -350,6 +374,97 @@ describe("utility practice sessions (SQL-driven)", () => { "SELECT 1 FROM utility_practice_sessions", ); expect(rows.length).toBe(0); + + 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: 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", + }) + .catch((): undefined => undefined); + + const [session] = await postgres.query>( + "SELECT region FROM utility_practice_sessions", + ); + 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 () => { @@ -367,6 +482,107 @@ 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]); + }); + + 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 () => { + 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(); @@ -1356,6 +1572,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 +1882,11 @@ 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 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(); @@ -1660,19 +1900,80 @@ 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].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", - ); + // 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 () => { + 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; + } }); });