292 lines
15 KiB
TypeScript
292 lines
15 KiB
TypeScript
import { config } from '../config.js';
|
|
import { processProvisioning } from '../provisioning/worker.js';
|
|
import { sweep } from './sweep.js';
|
|
import { betaTick } from './beta-runner.js';
|
|
import { emit } from './bus.js';
|
|
import type { ServerStatusEntry } from './bus.js';
|
|
import type { RconFailure } from '../integrations/rcon.js';
|
|
import { getServer, listServers, setServerFields, type ServerRow } from '../fleet/servers.js';
|
|
import { inspectForMonitoring, rconForMonitoring, prepareMonitoringRcon, monitoringStatus, monitoringStatusWriter, MonitoringChangedError, MonitoringSkippedError } from '../fleet/health-observations.js';
|
|
import { restartSettling } from '../provisioning/index.js';
|
|
import { serverObservation, observationReader } from '../fleet/status.js';
|
|
import { telemetryEvidence, latestGameSubmission } from '../fleet/status-evidence.js';
|
|
import { isProvisioning } from '../fleet/provisioning.js';
|
|
import { STATUS_REFRESH_MS } from '../../server-freshness.js';
|
|
import { recordMetrics } from '../fleet/metrics.js';
|
|
import { selectedZombieMap } from '../../map-aliases.js';
|
|
import { runMaintenance } from '../maintenance/index.js';
|
|
import { cleanupControllerLibraries } from '../game-files/index.js';
|
|
import { trackActivity } from './activity.js';
|
|
import { withOperationSource } from '../hosts/operation-context.js';
|
|
|
|
const TICK_MS = 15_000;
|
|
// Match each agent's two health lanes without making unrelated hosts wait.
|
|
const STATUS_CONCURRENCY_PER_HOST = 2;
|
|
|
|
let started = false;
|
|
let tickTimer: NodeJS.Timeout | null = null;
|
|
let statusTimer: NodeJS.Timeout | null = null;
|
|
|
|
let tickInFlight: Promise<void> | null = null;
|
|
let tickRequested = false;
|
|
const requesterFlights = new Map<string, Promise<void>>();
|
|
|
|
function tick(requestAnother = false): Promise<void> {
|
|
if (requestAnother) tickRequested = true;
|
|
if (tickInFlight) return tickInFlight;
|
|
tickInFlight = (async () => {
|
|
do {
|
|
tickRequested = false;
|
|
try { await trackActivity('provisioning', 'Processing provisioning requests', () => processProvisioning({ wait: false })); }
|
|
catch (err) { console.error('[worker] provisioning error:', err); }
|
|
} while (tickRequested);
|
|
})().finally(() => { tickInFlight = null; });
|
|
return tickInFlight;
|
|
}
|
|
|
|
function reconcileRequesters(): void {
|
|
// Observers submit durable intent independently. Provisioning still owns
|
|
// admission and rechecks source eligibility immediately before mutation.
|
|
const requesters: [string, string, () => Promise<unknown>][] = [
|
|
['campaign', 'Reconciling campaigns', sweep],
|
|
['beta', 'Reconciling beta tests', betaTick],
|
|
['maintenance', 'Checking maintenance and recovery', runMaintenance],
|
|
['game-files', 'Removing unused game files', cleanupControllerLibraries]
|
|
];
|
|
for (const [key, label, work] of requesters) {
|
|
if (requesterFlights.has(key)) continue;
|
|
const flight = trackActivity(key, label, work)
|
|
.catch(err => { console.error(`[worker] ${key} error:`, err); })
|
|
.then(async () => { if (key !== 'game-files') await tick(true); })
|
|
.finally(() => { requesterFlights.delete(key); });
|
|
requesterFlights.set(key, flight);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Encode a structured RCON failure as `"reason:detail"` so `last_error`
|
|
* stays a single column but the UI can split it apart when rendering the
|
|
* diagnostic panel. Reasons are stable; details are human-facing.
|
|
*/
|
|
function encodeError(reason: RconFailure, detail: string): string {
|
|
return `${reason}:${detail}`;
|
|
}
|
|
|
|
// Deduplicate queued/running checks per server. A slow server must not prevent
|
|
// completed servers from being polled again on the next 30-second tick.
|
|
const pendingStatus = new Map<string, Promise<number | null>>();
|
|
interface StatusQueue { active: number; queued: Array<() => Promise<void>> }
|
|
const statusQueues = new Map<string, StatusQueue>();
|
|
const changedStatus = new Map<string, ServerStatusEntry>();
|
|
function publishStatus(entry: ServerStatusEntry) {
|
|
const scheduled = changedStatus.size > 0;
|
|
changedStatus.set(entry.server, entry);
|
|
if (!scheduled) queueMicrotask(() => {
|
|
const entries = [...changedStatus.values()]; changedStatus.clear();
|
|
emit({ type: 'server-status', delta: true, entries });
|
|
});
|
|
}
|
|
|
|
function drainStatusQueue(host: string, queue: StatusQueue): void {
|
|
while (queue.active < STATUS_CONCURRENCY_PER_HOST && queue.queued.length) {
|
|
const next = queue.queued.shift()!;
|
|
queue.active++;
|
|
void next().finally(() => { queue.active--; drainStatusQueue(host, queue); });
|
|
}
|
|
if (!queue.active && !queue.queued.length) statusQueues.delete(host);
|
|
}
|
|
|
|
function queueStatus(srv: ServerRow, probe?: () => ReturnType<typeof rconForMonitoring>): Promise<number | null> {
|
|
const server = srv.name;
|
|
const pending = pendingStatus.get(server);
|
|
if (pending) return pending;
|
|
// Host queues keep slow instances from consuming other hosts' slots.
|
|
const host = srv.host_id ? `native:${srv.host_id}` : `unregistered:${srv.name}`;
|
|
let queue = statusQueues.get(host);
|
|
if (!queue) { queue = { active: 0, queued: [] }; statusQueues.set(host, queue); }
|
|
let finish!: (capacity: number | null) => void;
|
|
const work = new Promise<number | null>(resolve => { finish = resolve; });
|
|
pendingStatus.set(server, work);
|
|
queue.queued.push(async () => {
|
|
let capacity: number | null = null;
|
|
try {
|
|
capacity = await withOperationSource('Status monitor', () => refreshServerStatus(server, probe));
|
|
// Publish this server's latest evidence without waiting for the fleet.
|
|
const current = getServer(server);
|
|
if (current) publishStatus(statusEntry(current));
|
|
} catch (err) { console.error(`[worker] status error for ${server}:`, err); }
|
|
finally { pendingStatus.delete(server); finish(capacity); }
|
|
});
|
|
drainStatusQueue(host, queue);
|
|
return work;
|
|
}
|
|
|
|
export async function fanoutStatus(): Promise<void> {
|
|
const servers = listServers();
|
|
if (!servers.length) return;
|
|
const probes = prepareMonitoringRcon(servers.filter(server => !pendingStatus.has(server.name)
|
|
&& !isProvisioning(server.name) && !restartSettling(server.name) && !telemetryEvidence(server.name)).map(server => server.name));
|
|
const results = await Promise.all(servers.map(server => queueStatus(server, probes.get(server.name))));
|
|
const capacities = new Map(servers.map((server, i) => [server.name, results[i]]));
|
|
try {
|
|
// Metrics remain complete fleet collections, using freshness at capture
|
|
// time rather than the status saved at the beginning of a long scan.
|
|
const observe = observationReader();
|
|
recordMetrics(listServers().map(srv => {
|
|
const current = observe(srv);
|
|
return { server: srv.name, game: srv.game ?? 'unknown',
|
|
map: selectedZombieMap(srv.game, current.last_map ?? srv.map, srv.map_slug)?.friendly ?? current.last_map ?? srv.map ?? 'Unknown map',
|
|
players: current.health === 'online' ? current.last_players : null,
|
|
capacity: capacities.get(srv.name) ?? null };
|
|
}));
|
|
} catch (err) { console.error('[worker] metrics error:', err); }
|
|
}
|
|
|
|
async function refreshServerStatus(server: string, probeStatus?: () => ReturnType<typeof rconForMonitoring>): Promise<number | null> {
|
|
const srv = getServer(server);
|
|
if (!srv) return null;
|
|
let capacity: number | null = null;
|
|
let updates: Partial<Record<keyof ServerRow, string | number | null>> = {};
|
|
const flush = () => { setServerFields(srv.name, updates); updates = {}; };
|
|
if (!isProvisioning(srv.name)) {
|
|
const previousGame = monitoringStatus(srv.name)?.game;
|
|
const publishObservation = monitoringStatusWriter(srv.name);
|
|
let gameObservation: Awaited<ReturnType<typeof rconForMonitoring>> | undefined;
|
|
const settling = restartSettling(srv.name);
|
|
const epoch = latestGameSubmission(srv.name);
|
|
const unchanged = () => {
|
|
const current = getServer(srv.name);
|
|
return epoch === latestGameSubmission(srv.name) && !isProvisioning(srv.name)
|
|
&& current?.created_at === srv.created_at && current.desired_state === srv.desired_state
|
|
&& current.native_instance_id === srv.native_instance_id && current.instance_generation === srv.instance_generation;
|
|
};
|
|
const inspectionStartedAt = new Date().toISOString();
|
|
const inspected = settling
|
|
? { result: { ok: false as const, state: 'restarting', reason: 'Restart requested' }, observedAt: inspectionStartedAt }
|
|
: await inspectForMonitoring(srv.name).catch(err => err instanceof MonitoringChangedError ? null
|
|
: { result: { ok: false as const, state: 'unknown', reason: (err as Error).message }, observedAt: inspectionStartedAt });
|
|
if (!inspected) return null;
|
|
const container = inspected.result;
|
|
// A lifecycle request may be submitted while an inspection is in flight.
|
|
if (unchanged() && (getServer(srv.name)?.container_checked_at ?? '') <= inspected.observedAt) {
|
|
updates.container_state = container.ok ? 'running' : container.state?.toLowerCase() ?? 'unknown';
|
|
updates.container_checked_at = inspected.observedAt;
|
|
// An unavailable inspection is not evidence that the game stopped.
|
|
// Probe it just as a manual RCON check would; explicit lifecycle or
|
|
// unhealthy states still take precedence over game replies.
|
|
const unavailable = updates.container_state === 'unknown';
|
|
if (!container.ok && !unavailable) {
|
|
updates.last_error = null;
|
|
updates.rcon_failures = 0;
|
|
updates.last_reply_snippet = null;
|
|
} else if (unavailable || !telemetryEvidence(srv.name)) {
|
|
flush();
|
|
const probeStartedAt = new Date().toISOString();
|
|
const probe = await (probeStatus ? probeStatus() : rconForMonitoring(srv.name)).catch(err => {
|
|
if (err instanceof MonitoringSkippedError) {
|
|
// The container was checked, but game evidence retains its original age.
|
|
if (unchanged()) publishObservation(inspected, previousGame);
|
|
return null;
|
|
}
|
|
if (err instanceof MonitoringChangedError) return null;
|
|
return { result: { ok: false as const, endpoint: null, reason: 'send-error' as RconFailure, detail: (err as Error).message, rawReply: undefined }, observedAt: probeStartedAt };
|
|
});
|
|
if (!probe) return null;
|
|
const result = probe.result;
|
|
const previous = getServer(srv.name);
|
|
if (unchanged() && previous && (previous.last_checked_at ?? '') <= probe.observedAt) {
|
|
gameObservation = probe;
|
|
const now = probe.observedAt;
|
|
updates.last_checked_at = now;
|
|
updates.last_reply_snippet = result.rawReply ?? null;
|
|
if (result.ok) {
|
|
capacity = result.status.capacity ?? null;
|
|
updates.last_players = result.status.players;
|
|
updates.last_map = result.status.map;
|
|
updates.last_gametype = result.status.gametype;
|
|
updates.last_roster = JSON.stringify(result.status.roster);
|
|
updates.last_seen_at = now;
|
|
updates.last_error = null;
|
|
updates.rcon_failures = 0;
|
|
} else {
|
|
// Only distinct accepted checks in the current lifecycle count.
|
|
// Skipped/superseded checks return above without changing the streak.
|
|
const consecutive = previous.last_error && (!epoch || (previous.last_checked_at ?? '') > epoch);
|
|
const failures = consecutive ? previous.rcon_failures || 1 : 0;
|
|
updates.rcon_failures = consecutive && previous.last_checked_at === now
|
|
? failures : Math.min(2, failures + 1);
|
|
updates.last_error = encodeError(result.reason, result.detail);
|
|
}
|
|
}
|
|
}
|
|
if (!settling && unchanged()) publishObservation(inspected, gameObservation);
|
|
}
|
|
} else {
|
|
updates.last_error = null;
|
|
updates.rcon_failures = 0;
|
|
updates.last_reply_snippet = null;
|
|
}
|
|
flush();
|
|
const current = getServer(srv.name) ?? srv;
|
|
const status = serverObservation(current);
|
|
// Occupancy timers and match clearing follow the same source as the UI.
|
|
if (status.health === 'online' && status.last_players !== null) {
|
|
if (status.last_players > 0) {
|
|
updates.active_since = current.active_since ?? status.last_seen_at;
|
|
updates.inactive_since = null;
|
|
} else {
|
|
updates.active_since = null;
|
|
updates.inactive_since = current.inactive_since ?? status.last_seen_at;
|
|
updates.game_started_at = null;
|
|
updates.game_ended_at = null;
|
|
}
|
|
}
|
|
flush();
|
|
return capacity;
|
|
}
|
|
|
|
export function statusEntry(srv: ServerRow, observe = serverObservation): ServerStatusEntry {
|
|
const final = observe(srv);
|
|
return { server: srv.name, players: final.last_players, map: final.last_map ?? srv.map,
|
|
gametype: final.last_gametype, roster: final.roster, health: final.health, lastSeenAt: final.last_seen_at,
|
|
lastError: final.last_error, replySnippet: final.replySnippet,
|
|
endpoint: srv.game_port ? { host: srv.rcon_host ?? config.rcon.host, port: srv.game_port } : null, reason: final.last_error ? final.last_error.split(':')[0] as RconFailure : null,
|
|
activeSince: final.active_since, inactiveSince: final.inactive_since,
|
|
gameStartedAt: final.game_started_at, gameEndedAt: final.game_ended_at,
|
|
containerState: final.container_state, containerCheckedAt: final.container_checked_at,
|
|
telemetryAfter: final.telemetry_after, rconStatus: final.rcon_status, telemetry: final.telemetry };
|
|
}
|
|
|
|
export function startWorker(): void {
|
|
if (started) return;
|
|
started = true;
|
|
|
|
void tick();
|
|
reconcileRequesters();
|
|
void fanoutStatus();
|
|
tickTimer = setInterval(() => { void tick(); reconcileRequesters(); }, TICK_MS);
|
|
statusTimer = setInterval(fanoutStatus, STATUS_REFRESH_MS);
|
|
|
|
console.log('[worker] started — sweep every', TICK_MS, 'ms, status every', STATUS_REFRESH_MS, 'ms');
|
|
}
|
|
|
|
export function stopWorker(): void {
|
|
if (tickTimer) clearInterval(tickTimer);
|
|
if (statusTimer) clearInterval(statusTimer);
|
|
tickTimer = null;
|
|
statusTimer = null;
|
|
started = false;
|
|
}
|
|
|
|
/**
|
|
* External trigger — called from `/api/push` and beta endpoints so a sweep
|
|
* fires immediately after a state change instead of waiting for the next tick.
|
|
*/
|
|
export async function kick(): Promise<void> {
|
|
reconcileRequesters();
|
|
await tick(true);
|
|
}
|
|
|
|
/** Agent receipts need observation promptly, without launching another fleet scan. */
|
|
export async function kickProvisioning(): Promise<void> { await tick(true); }
|