182 lines
8.0 KiB
TypeScript
182 lines
8.0 KiB
TypeScript
import { setImmediate } from 'node:timers/promises';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
|
|
|
let fixture: Fixture;
|
|
let hostId: string;
|
|
const GiB = 1024 ** 3;
|
|
const epoch = Date.parse('2026-10-05T12:00:00Z');
|
|
const inventory = {
|
|
protocolVersion: 1,
|
|
dockerRunning: true,
|
|
directoriesWritable: true,
|
|
storageAvailable: 100 * GiB,
|
|
cpuCount: 16,
|
|
cpuUsagePercent: 0,
|
|
memoryTotal: 64 * GiB,
|
|
memoryAvailable: 60 * GiB,
|
|
preparedRuntimeImages: ['image'],
|
|
libraries: [
|
|
{ id: 't5-library', state: 'ready' },
|
|
{ id: 't6-library', state: 'ready' }
|
|
]
|
|
};
|
|
const t5 = { libraryId: 't5-library', image: 'image', automatic: true };
|
|
const t6 = { libraryId: 't6-library', image: 'image', automatic: true };
|
|
async function settle() {
|
|
await setImmediate();
|
|
await setImmediate();
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
fixture = await newFixture([]);
|
|
vi.useFakeTimers({ toFake: ['Date'] });
|
|
vi.setSystemTime(epoch);
|
|
const hosts = await import('../../src/lib/server/hosts/index.js');
|
|
hostId = hosts.createHost({ name: 'Availability host', portMin: 28960, portMax: 28962 }).id;
|
|
hosts.enrollHost(hosts.createEnrollment(hostId).token, inventory);
|
|
hosts.heartbeat(hostId, inventory);
|
|
await settle();
|
|
});
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
vi.useRealTimers();
|
|
await fixture.cleanup();
|
|
});
|
|
|
|
describe('cached launch availability', () => {
|
|
it.each([{ preparedRuntimeImages: [] }, { preparedRuntimeImages: ['another:image'] }])(
|
|
'excludes a cached image until it is prepared ($preparedRuntimeImages)',
|
|
async ({ preparedRuntimeImages }) => {
|
|
const { heartbeat } = await import('../../src/lib/server/hosts/index.js');
|
|
const { launchAvailability } =
|
|
await import('../../src/lib/server/hosts/availability.js');
|
|
heartbeat(hostId, {
|
|
...inventory,
|
|
runtimeImages: ['image'],
|
|
preparedRuntimeImages
|
|
});
|
|
const unavailable = launchAvailability([t5]);
|
|
expect(unavailable.availability).toMatchObject({
|
|
estimatedAvailable: 0,
|
|
portsAvailable: 0
|
|
});
|
|
expect(unavailable.warnings.join(' ')).toContain('Waiting for an eligible host');
|
|
|
|
heartbeat(hostId, { ...inventory, runtimeImages: ['image'] });
|
|
expect(launchAvailability([t5]).availability).toMatchObject({
|
|
estimatedAvailable: 1,
|
|
portsAvailable: 3
|
|
});
|
|
}
|
|
);
|
|
|
|
it('reads cached estimates without placement, historical scans, or repeated database reads', async () => {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const capacity = await import('../../src/lib/server/hosts/capacity.js');
|
|
const { launchAvailability } = await import('../../src/lib/server/hosts/availability.js');
|
|
const placement = vi.spyOn(capacity, 'choosePlacement');
|
|
const prepare = vi.spyOn(db(), 'prepare');
|
|
for (let count = 0; count < 100; count++) {
|
|
expect(launchAvailability([t5, t6]).availability).toMatchObject({
|
|
updatedAt: new Date(epoch).toISOString(),
|
|
requested: 2,
|
|
estimatedAvailable: 2,
|
|
portsAvailable: 3
|
|
});
|
|
}
|
|
expect(prepare).not.toHaveBeenCalled();
|
|
expect(placement).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shares CPU, memory and ports across games and refreshes measurements on heartbeat', async () => {
|
|
const { heartbeat } = await import('../../src/lib/server/hosts/index.js');
|
|
const { launchAvailability } = await import('../../src/lib/server/hosts/availability.js');
|
|
heartbeat(hostId, { ...inventory, memoryAvailable: GiB + 500_000_000 });
|
|
const limited = launchAvailability([t5, t6]);
|
|
expect(limited.availability).toMatchObject({
|
|
requested: 2,
|
|
estimatedAvailable: 1,
|
|
portsAvailable: 3,
|
|
cpuCoresAvailable: 15.2,
|
|
memoryBytesAvailable: GiB
|
|
});
|
|
expect(limited.warnings.join(' ')).toContain('1 of 2');
|
|
vi.setSystemTime(epoch + 15_000);
|
|
heartbeat(hostId, { ...inventory, memoryAvailable: 2 * GiB + 500_000_000 });
|
|
expect(launchAvailability([t5, t6]).availability).toMatchObject({
|
|
estimatedAvailable: 2,
|
|
memoryBytesAvailable: 2 * GiB,
|
|
updatedAt: new Date(epoch + 15_000).toISOString()
|
|
});
|
|
heartbeat(hostId, { ...inventory, cpuUsagePercent: 100 });
|
|
expect(launchAvailability([t5]).availability.estimatedAvailable).toBe(0);
|
|
});
|
|
|
|
it('refreshes port reservations and startup queue snapshots after lifecycle events', async () => {
|
|
const { reserveHostPort, releaseHostPorts } =
|
|
await import('../../src/lib/server/hosts/index.js');
|
|
const { launchAvailability } = await import('../../src/lib/server/hosts/availability.js');
|
|
const { beginDeployment, deploymentStage } =
|
|
await import('../../src/lib/server/provisioning/history.js');
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const limit = launchAvailability([t5]).availability.startupLimit!;
|
|
reserveHostPort(hostId, 'reserved', { port: 28960, protocol: 'udp' });
|
|
reserveHostPort(hostId, 'reserved', { port: 28960, protocol: 'tcp' });
|
|
await settle();
|
|
expect(launchAvailability([t5, t6, t5]).availability).toMatchObject({
|
|
portsAvailable: 2,
|
|
estimatedAvailable: 2
|
|
});
|
|
const active = beginDeployment('t5_zombie_theater_1', 'create');
|
|
const queued = beginDeployment('t6_zombie_tranzit_1', 'create');
|
|
db().prepare("UPDATE server_deployments SET state='queued' WHERE id=?").run(queued);
|
|
deploymentStage(queued, 'Awaiting placement', {
|
|
checkpoint: { key: 'queued', label: 'Queued for placement' }
|
|
});
|
|
await settle();
|
|
expect(launchAvailability([t5]).availability).toMatchObject({
|
|
queued: 1,
|
|
startupSlots: Math.max(0, limit - 1)
|
|
});
|
|
db().prepare("UPDATE server_deployments SET state='done' WHERE id=?").run(active);
|
|
releaseHostPorts('reserved');
|
|
await settle();
|
|
expect(launchAvailability([t5]).availability).toMatchObject({
|
|
portsAvailable: 3,
|
|
startupSlots: limit
|
|
});
|
|
});
|
|
|
|
it('stops counting a stale host without presenting old measurements as current capacity', async () => {
|
|
const { launchAvailability } = await import('../../src/lib/server/hosts/availability.js');
|
|
vi.setSystemTime(epoch + 91_000);
|
|
const result = launchAvailability([t5]);
|
|
expect(result.availability).toMatchObject({
|
|
updatedAt: new Date(epoch).toISOString(),
|
|
estimatedAvailable: 0,
|
|
portsAvailable: 0
|
|
});
|
|
expect(result.warnings.join(' ')).toContain('Waiting for an eligible host');
|
|
});
|
|
|
|
it('keeps heartbeats working while provisioning schema is unavailable and refreshes after initialization', async () => {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const { heartbeat } = await import('../../src/lib/server/hosts/index.js');
|
|
const { launchAvailability } = await import('../../src/lib/server/hosts/availability.js');
|
|
const errors = vi.spyOn(console, 'error');
|
|
db().exec('ALTER TABLE native_provisioning RENAME TO unavailable_native_provisioning');
|
|
expect(() => heartbeat(hostId, inventory)).not.toThrow();
|
|
expect(launchAvailability([t5]).availability.updatedAt).toBeNull();
|
|
await settle();
|
|
expect(errors).not.toHaveBeenCalled();
|
|
db().exec('ALTER TABLE unavailable_native_provisioning RENAME TO native_provisioning');
|
|
heartbeat(hostId, inventory);
|
|
expect(launchAvailability([t5]).availability).toMatchObject({
|
|
estimatedAvailable: 1,
|
|
portsAvailable: 3
|
|
});
|
|
});
|
|
});
|