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

104 lines
6.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;
}