171 lines
7.0 KiB
TypeScript
171 lines
7.0 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { rebalanceSettings, type RebalanceSettings } from '../../src/lib/rebalancing.js';
|
|
import {
|
|
planRebalance,
|
|
rebalanceMoveBlocker,
|
|
type RebalancingHost,
|
|
type RebalancingInstance,
|
|
type RebalancingSnapshot
|
|
} from '../../src/lib/server/rebalancing/planner.js';
|
|
|
|
const gib = 1024 ** 3;
|
|
const settings: RebalanceSettings = { hostIds: ['a', 'b'], game: 'all', maxMoves: 20 };
|
|
const host = (id: string, cpu: number, count = 0): RebalancingHost => ({
|
|
id,
|
|
name: id,
|
|
cpuCount: 8,
|
|
memoryTotal: 16 * gib,
|
|
used: { cpuCores: cpu, memoryBytes: gib },
|
|
running: { cpuCores: 0.5, memoryBytes: gib },
|
|
startup: { cpuCores: 1, memoryBytes: 2 * gib },
|
|
instances: count,
|
|
ports: 20,
|
|
reason: null
|
|
});
|
|
const instance = (n: number, source = 'a', cpu = 1): RebalancingInstance => ({
|
|
server: `t6_zombie_town_${n}`,
|
|
instanceId: `i${n}`,
|
|
generation: 1,
|
|
sourceHostId: source,
|
|
game: 't6',
|
|
relief: { cpuCores: cpu, memoryBytes: gib },
|
|
demand: { cpuCores: cpu, memoryBytes: gib },
|
|
destinations: { a: null, b: null }
|
|
});
|
|
const snapshot = (
|
|
hosts = [host('a', 6, 6), host('b', 0)],
|
|
instances = Array.from({ length: 6 }, (_, n) => instance(n))
|
|
): RebalancingSnapshot => ({ hosts, instances, exclusions: [], warnings: [] });
|
|
|
|
describe('rebalance planning', () => {
|
|
it('validates bounded hosts and moves without silently choosing all hosts', () => {
|
|
expect(rebalanceSettings({ ...settings, hostIds: ['b', 'a'] }).hostIds).toEqual(['a', 'b']);
|
|
for (const input of [
|
|
{},
|
|
{ ...settings, hostIds: ['a'] },
|
|
{ ...settings, hostIds: ['a', 'a'] },
|
|
{ ...settings, game: 't4' },
|
|
{ ...settings, maxMoves: 0 },
|
|
{ ...settings, maxMoves: 21 },
|
|
{ ...settings, maxMoves: 2.5 }
|
|
])
|
|
expect(() => rebalanceSettings(input)).toThrow();
|
|
});
|
|
|
|
it('spreads pressure with the fewest useful moves and never mutates live measurements', () => {
|
|
const input = snapshot(),
|
|
saved = structuredClone(input);
|
|
const result = planRebalance(input, settings);
|
|
expect(
|
|
result.moves.map((move) => [move.server, move.sourceHostId, move.targetHostId])
|
|
).toEqual([0, 1, 2].map((n) => [`t6_zombie_town_${n}`, 'a', 'b']));
|
|
expect(result.hosts.map((item) => item.after.instances)).toEqual([3, 3]);
|
|
expect(result.hosts.map((item) => item.after.cpuPercent)).toEqual([37.5, 37.5]);
|
|
expect(new Set(result.moves.map((move) => move.instanceId)).size).toBe(result.moves.length);
|
|
expect(input).toEqual(saved);
|
|
});
|
|
|
|
it('uses host size and both resource dimensions rather than equal counts', () => {
|
|
const small = host('a', 3, 3);
|
|
small.cpuCount = 4;
|
|
const large = host('b', 4, 4);
|
|
large.cpuCount = 16;
|
|
expect(planRebalance(snapshot([small, large], [instance(1)]), settings).moves).toHaveLength(
|
|
1
|
|
);
|
|
small.used = { cpuCores: 1, memoryBytes: 12 * gib };
|
|
large.used = { cpuCores: 1, memoryBytes: 2 * gib };
|
|
const memory = instance(1);
|
|
memory.relief.memoryBytes = 2 * gib;
|
|
memory.demand.memoryBytes = 2 * gib;
|
|
expect(
|
|
planRebalance(snapshot([small, large], [memory]), settings).moves[0].improvement
|
|
).toBeCloseTo(12.5);
|
|
});
|
|
|
|
it('respects startup fit, port capacity, compatibility, host eligibility, and reservations', () => {
|
|
for (const mutate of [
|
|
(target: RebalancingHost) => {
|
|
target.ports = 0;
|
|
},
|
|
(target: RebalancingHost) => {
|
|
target.reason = 'Host disabled.';
|
|
},
|
|
(target: RebalancingHost) => {
|
|
target.startup.cpuCores = 8;
|
|
},
|
|
(target: RebalancingHost) => {
|
|
target.startup.memoryBytes = 16 * gib;
|
|
},
|
|
(target: RebalancingHost) => {
|
|
target.used.cpuCores = 7;
|
|
}
|
|
]) {
|
|
const input = snapshot();
|
|
mutate(input.hosts[1]);
|
|
expect(planRebalance(input, settings).moves).toEqual([]);
|
|
}
|
|
const input = snapshot();
|
|
input.instances.forEach((item) => {
|
|
item.destinations.b = 'Missing the deployed runtime.';
|
|
});
|
|
expect(planRebalance(input, settings).moves).toEqual([]);
|
|
expect(planRebalance(input, settings).exclusions[0].reason).toContain(
|
|
'Missing the deployed runtime'
|
|
);
|
|
input.instances.forEach((item) => {
|
|
delete item.destinations.b;
|
|
});
|
|
expect(planRebalance(input, settings).moves).toEqual([]);
|
|
});
|
|
|
|
it('requires five percentage points of improvement, preserves limits, and does not move twice', () => {
|
|
const input = snapshot([host('a', 4), host('b', 2)], [instance(1, 'a', 0.3)]);
|
|
expect(planRebalance(input, settings).moves).toEqual([]);
|
|
input.instances[0].relief.cpuCores = 0.4;
|
|
input.instances[0].demand.cpuCores = 0.4;
|
|
expect(planRebalance(input, settings).moves[0].improvement).toBeCloseTo(5);
|
|
expect(planRebalance(snapshot(), { ...settings, maxMoves: 1 }).moves).toHaveLength(1);
|
|
expect(planRebalance(snapshot(), { ...settings, game: 't5' }).moves).toHaveLength(0);
|
|
const onePort = snapshot();
|
|
onePort.hosts[1].ports = 1;
|
|
expect(planRebalance(onePort, settings).moves).toHaveLength(1);
|
|
});
|
|
|
|
it('prefers the largest improvement deterministically and revalidates the approved pair', () => {
|
|
const input = snapshot(
|
|
[host('a', 6, 6), host('b', 0)],
|
|
[instance(3), instance(2, 'a', 2), instance(1)]
|
|
);
|
|
const move = planRebalance(input, { ...settings, maxMoves: 1 }).moves[0];
|
|
expect(move.server).toBe('t6_zombie_town_2');
|
|
expect(rebalanceMoveBlocker(input, move)).toBeNull();
|
|
expect(rebalanceMoveBlocker(input, { ...move, generation: 2 })).toContain(
|
|
'generation has changed'
|
|
);
|
|
input.hosts[1].used.cpuCores = 6;
|
|
expect(rebalanceMoveBlocker(input, move)).toBeTruthy();
|
|
input.instances = [];
|
|
input.exclusions.push({ server: move.server, reason: 'Players are present.' });
|
|
expect(rebalanceMoveBlocker(input, move)).toBe('Players are present.');
|
|
});
|
|
|
|
it('only credits source ports actually released inside the configured range and bind', () => {
|
|
const a = host('a', 6.8, 1),
|
|
b = host('b', 5.2, 1),
|
|
c = host('c', 0);
|
|
a.ports = 0;
|
|
b.ports = 0;
|
|
c.ports = 1;
|
|
const first = instance(1, 'a', 4),
|
|
second = instance(2, 'b', 1);
|
|
first.destinations.c = null;
|
|
second.destinations.c = null;
|
|
const input = snapshot([a, b, c], [first, second]);
|
|
const selection = { ...settings, hostIds: ['a', 'b', 'c'] };
|
|
expect(planRebalance(input, selection).moves).toHaveLength(1);
|
|
first.releasesPort = true;
|
|
expect(planRebalance(input, selection).moves).toHaveLength(2);
|
|
});
|
|
});
|