Files
electric-cherry/src/lib/server/autoscaling/planner.ts
T
2026-10-15 20:40:03 -04:00

186 lines
7.8 KiB
TypeScript

import type {
ScalingDecision,
ScalingMember,
ScalingPolicy,
ScalingSnapshot
} from '../../autoscaling.js';
import { parseServerName, zombieMaps } from '../../map-aliases.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js';
export interface EmptyObservation {
identity: string;
observedAt: number | null;
emptySince: number | null;
evaluatedAt: number;
}
/** Only distinct, continuous evidence can advance an empty interval. */
export function emptyObservation(
member: ScalingMember,
previous: EmptyObservation | undefined,
now: number
): EmptyObservation {
const stamp = member.observedAt;
const empty =
member.state === 'empty' && stamp !== null && stamp <= now && now - stamp <= ONLINE_MAX_MS;
const continuous =
empty &&
previous?.identity === member.identity &&
previous.emptySince !== null &&
(!member.emptyStartedAt || previous.emptySince >= member.emptyStartedAt) &&
previous.observedAt !== null &&
stamp >= previous.observedAt &&
stamp - previous.observedAt <= ONLINE_MAX_MS &&
now >= previous.evaluatedAt &&
now - previous.evaluatedAt <= ONLINE_MAX_MS;
return {
identity: member.identity,
observedAt: stamp,
emptySince: empty ? (continuous ? previous!.emptySince : stamp) : null,
evaluatedAt: now
};
}
export function planScaling(
policy: ScalingPolicy,
snapshot: ScalingSnapshot,
now: number
): ScalingDecision {
if (policy.mapSlug === '*') {
const maps = zombieMaps(policy.game);
const decisions = maps.map((map) => {
const matches = (name: string) =>
(snapshot.members.find((m) => m.name === name)?.mapSlug ??
parseServerName(name)?.mapSlug) === map.slug;
return planScaling(
{ ...policy, mapSlug: map.slug },
{
...snapshot,
members: snapshot.members.filter((m) => matches(m.name)),
reservedNames: snapshot.reservedNames.filter(matches)
},
now
);
});
// Serialize game-wide work and prefer replenishing any map before trimming another.
const pending = snapshot.members.find(
(m) => m.state === 'creating' || m.state === 'removing'
);
const selected =
decisions.find((d) => d.action === 'create') ??
decisions.find((d) => d.action === 'destroy') ??
decisions.find((d) => d.deficit > 0 || d.unknown > 0) ??
decisions[0];
const map = maps[decisions.indexOf(selected)];
return {
...selected,
action: pending ? 'wait' : selected.action,
server: pending ? null : selected.server,
reason: pending
? (pending.blocker ?? `Waiting for ${pending.name}: ${pending.state}.`)
: `${map.friendly}: ${selected.reason}`,
desired: decisions.reduce((n, d) => n + d.desired, 0),
deficit: decisions.reduce((n, d) => n + d.deficit, 0),
occupied: decisions.reduce((n, d) => n + d.occupied, 0),
empty: decisions.reduce((n, d) => n + d.empty, 0),
pending: decisions.reduce((n, d) => n + d.pending, 0),
unknown: decisions.reduce((n, d) => n + d.unknown, 0)
};
}
const { members } = snapshot;
const occupied = members.filter((m) => m.state === 'occupied').length,
empty = members.filter((m) => m.state === 'empty').length;
const pending = members.filter((m) => m.state === 'creating' || m.state === 'removing').length;
const unknown = members.filter((m) => m.state === 'unknown' || m.state === 'stopped').length;
const desired = Math.min(policy.maximum, Math.max(policy.minimum, occupied + policy.spares));
const result = (
action: ScalingDecision['action'],
reason: string,
server: string | null = null
): ScalingDecision => ({
action,
reason,
server,
desired,
deficit: Math.max(0, policy.spares - empty),
occupied,
empty,
pending,
unknown
});
if (policy.mode === 'off') return result('wait', policy.pausedReason ?? 'Policy is off.');
if (policy.pausedReason) return result('wait', policy.pausedReason);
if (snapshot.blockers.length) return result('wait', snapshot.blockers[0]);
const blocked = members.find((m) => !['occupied', 'empty'].includes(m.state));
if (blocked)
return result('wait', blocked.blocker ?? `Waiting for ${blocked.name}: ${blocked.state}.`);
if (
members.some(
(m) => m.observedAt === null || m.observedAt > now || now - m.observedAt > ONLINE_MAX_MS
)
)
return result('wait', 'Waiting for fresh occupancy evidence.');
if (occupied + empty < desired) {
// Brief joins do not increase spare demand until their match reaches round two.
// Keep the ordinary desired count for reporting and surplus removal so a
// new match or missing telemetry cannot retire an existing spare.
const established = members.filter(
(m) => m.state === 'occupied' && m.round !== null && m.round >= 2
).length;
const growthTarget = Math.min(
policy.maximum,
Math.max(policy.minimum, established + policy.spares)
);
if (occupied + empty >= growthTarget)
return result(
'wait',
'Waiting for occupied lobbies to reach round 2 before adding a spare.'
);
if (snapshot.reservedNames.length >= policy.maximum)
return result('wait', 'Maximum lobby reservations reached.');
if (!snapshot.keyPool.available)
return result(
'wait',
snapshot.keyPool.total
? `All ${policy.game.toUpperCase()} Plutonium keys are in use. Waiting for a free key.`
: `No Plutonium keys are saved for ${policy.game.toUpperCase()}.`
);
const available = Array.from(
{ length: 20 },
(_, index) => `${policy.game}_zombie_${policy.mapSlug}_${index + 1}`
).find((name) => !snapshot.reservedNames.includes(name));
return available
? result('create', 'Replenish empty lobby capacity.', available)
: result('wait', 'No unused lobby identity is available.');
}
if (occupied + empty > desired && empty > policy.spares && occupied + empty > policy.minimum) {
// Protect the lowest-numbered spares before checking removal timers.
// Lobby 1 must survive even when a higher lobby has only just emptied.
const orderedEmpty = members
.filter((m) => m.state === 'empty')
.sort(
(a, b) =>
(parseServerName(a.name)?.lobby ?? Number.MAX_SAFE_INTEGER) -
(parseServerName(b.name)?.lobby ?? Number.MAX_SAFE_INTEGER) ||
a.name.localeCompare(b.name, 'en', { numeric: true })
);
const requiredEmpty = Math.max(policy.spares, policy.minimum - occupied);
const candidates = orderedEmpty
.slice(requiredEmpty)
.reverse()
.filter(
(m) =>
m.emptySince !== null &&
m.observedAt! - m.emptySince >= policy.emptyMinutes * 60_000
);
if (candidates.length)
return result('destroy', 'Surplus lobby has remained empty.', candidates[0].name);
return result('wait', 'Waiting for a continuous empty interval.');
}
return result(
'wait',
empty < policy.spares
? 'Maximum reached; the empty spare target cannot be met.'
: 'Empty spare target is satisfied.'
);
}