Files
electric-cherry/tests/unit/rebalancing-planner.test.ts
T

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);
});
});