Files
electric-cherry/src/lib/server/fleet/beta-selection.ts
T

167 lines
7.0 KiB
TypeScript

import { db } from '../db/index.js';
import { listHosts } from '../hosts/index.js';
import { protectedWork } from '../autoscaling/snapshot.js';
import { freshStatusTimestamp } from '../../server-freshness.js';
import { listServers, type ServerRow } from './servers.js';
import { observationReader } from './status.js';
import type { ScriptRow } from './scripts.js';
const games = ['t5', 't6'] as const;
type Game = (typeof games)[number];
type Counts = Partial<Record<Game, number>>;
export function parseBetaInstanceCounts(value: unknown): Counts {
if (!value || typeof value !== 'object' || Array.isArray(value) || !Object.keys(value).length) {
throw new Error('instancesPerGame must contain at least one game and instance count');
}
const counts: Counts = {};
for (const [game, count] of Object.entries(value)) {
if (
!games.includes(game as Game) ||
!Number.isSafeInteger(count) ||
count < 1 ||
count > 1000
) {
throw new Error('Instance counts must be integers from 1 to 1000 for t5 or t6');
}
counts[game as Game] = count;
}
return counts;
}
function matches(script: ScriptRow, server: ServerRow): boolean {
return (
!script.scope_type ||
!script.scope_value ||
(script.scope_type === 'game' ? server.game : server.map) === script.scope_value
);
}
/** Choose at submission time using the same fresh observations shown in Fleet. */
export function selectBetaServers(scripts: ScriptRow[], instancesPerGame: unknown): string[] {
const counts = parseBetaInstanceCounts(instancesPerGame),
observe = observationReader(),
now = Date.now();
const hosts = new Map(listHosts().map((host) => [host.id, host]));
const busy = new Set(
(
db()
.prepare(
`SELECT server FROM server_deployments WHERE server_deleted_at IS NULL
AND (state IN ('queued','waiting','running','awaiting-health','interrupted')
OR (action IN ('destroy','rebuild') AND state = 'failed'
AND stage IN ('Stopping and removing container','Removing server files and registry')))`
)
.all() as { server: string }[]
).map((row) => row.server)
);
const candidates = listServers()
.flatMap((server) => {
if (
!counts[server.game as Game] ||
server.desired_state === 'stopped' ||
busy.has(server.name) ||
!scripts.some((script) => matches(script, server)) ||
protectedWork(server.name)
)
return [];
const host = server.host_id ? hosts.get(server.host_id) : null;
if (server.host_id && (!host?.connected || host.mode !== 'active' || host.decommission))
return [];
const observation = observe(server);
if (
observation.health !== 'online' ||
observation.last_players === null ||
!freshStatusTimestamp(observation.last_seen_at, now)
)
return [];
// An occupied RCON observation must not rank as empty just because a
// competing telemetry report claims zero players.
const rconPlayers =
freshStatusTimestamp(server.last_seen_at, now) &&
(!observation.telemetry_after ||
Date.parse(server.last_seen_at!) > Date.parse(observation.telemetry_after))
? (server.last_players ?? 0)
: 0;
return [{ server, players: Math.max(observation.last_players, rconPlayers) }];
})
.sort((a, b) => a.players - b.players || a.server.name.localeCompare(b.server.name));
for (const game of games) {
const available = candidates.filter((candidate) => candidate.server.game === game).length;
if ((counts[game] ?? 0) > available) {
throw new Error(
`${game.toUpperCase()} needs ${counts[game]} available instances, but only ${available} have fresh status and no pending work. Reduce the count or retry when more servers are available.`
);
}
}
for (const script of scripts) {
if (!candidates.some((candidate) => matches(script, candidate.server))) {
throw new Error(
`${script.name}: no available server in the requested games matches this file's scope.`
);
}
}
// Map scopes need a representative even when the least populated servers
// all run the same map. Reserve enough remaining slots for every map.
const requiredMaps = new Set(
scripts.filter((script) => script.scope_type === 'map').map((script) => script.scope_value!)
);
function canComplete(remaining: typeof candidates, slots: Counts, maps: Set<string>): boolean {
const options = new Map<string, Set<Game>>();
for (const game of games) {
if (
remaining.filter((candidate) => candidate.server.game === game).length <
(slots[game] ?? 0)
)
return false;
}
for (const map of maps) {
const choices = new Set(
remaining
.filter(
(candidate) =>
candidate.server.map === map &&
(slots[candidate.server.game as Game] ?? 0) > 0
)
.map((candidate) => candidate.server.game as Game)
);
if (!choices.size) return false;
options.set(map, choices);
}
// There are two supported games: single-game maps reserve that game's
// slots, and maps available in either game use any remaining slot.
return (
games.every(
(game) =>
[...options.values()].filter(
(choices) => choices.size === 1 && choices.has(game)
).length <= (slots[game] ?? 0)
) && maps.size <= games.reduce((total, game) => total + (slots[game] ?? 0), 0)
);
}
if (!canComplete(candidates, counts, requiredMaps)) {
throw new Error(
"Increase the instance counts to cover every selected file's map scope, or choose servers in advanced options."
);
}
const selected: string[] = [],
slots = { ...counts };
let maps = requiredMaps;
for (let index = 0; index < candidates.length; index++) {
const { server } = candidates[index],
game = server.game as Game;
if (!slots[game]) continue;
const nextSlots = { ...slots, [game]: slots[game]! - 1 },
nextMaps = new Set(maps);
if (server.map) nextMaps.delete(server.map);
if (!canComplete(candidates.slice(index + 1), nextSlots, nextMaps)) continue;
selected.push(server.name);
slots[game] = nextSlots[game];
maps = nextMaps;
if (games.every((game) => !slots[game])) break;
}
return selected;
}