Add configurable server destruction concurrency
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { join } from 'node:path';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
vi.mock('../../src/lib/server/integrations/komodo.js', () => ({
|
||||
komodoCreateDeployment: vi.fn(),
|
||||
komodoDeploy: vi.fn(),
|
||||
komodoDestroyDeployment: vi.fn(),
|
||||
komodoDeleteDeployment: vi.fn(),
|
||||
komodoInspectHealthy: vi.fn(),
|
||||
komodoEnsureTag: vi.fn(),
|
||||
komodoUpdateTagsOnResource: vi.fn()
|
||||
}));
|
||||
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
|
||||
rconGetPlayerCount: vi.fn(), rconStatusResult: vi.fn()
|
||||
}));
|
||||
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) }));
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<void>((done, fail) => { resolve = done; reject = fail; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function command() {
|
||||
return { started: deferred(), response: deferred() };
|
||||
}
|
||||
|
||||
const servers = Array.from({ length: 4 }, (_, index) => `t5_zombie_theater_${index + 1}`);
|
||||
let fixture: Fixture;
|
||||
let commands: Map<string, { destroy: ReturnType<typeof command>; delete: ReturnType<typeof command> }>;
|
||||
let passes: Promise<void>[];
|
||||
let runningAtSubmission: number[];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules(); vi.resetAllMocks();
|
||||
fixture = await newFixture(servers);
|
||||
for (const [key, value] of Object.entries({ KOMODO_ADDRESS: 'http://komodo.test', KOMODO_API_KEY: 'test',
|
||||
KOMODO_API_SECRET: 'test', KOMODO_TARGET_SERVER: 'test-host', RCON_PASSWORD: 'test' })) vi.stubEnv(key, value);
|
||||
commands = new Map(servers.map(server => [server, { destroy: command(), delete: command() }]));
|
||||
passes = [];
|
||||
runningAtSubmission = [];
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
for (const [index, server] of servers.entries()) {
|
||||
db().prepare(`INSERT INTO servers (name, komodo_type, komodo_name, game, map, game_mode, map_slug, game_port, host_dir)
|
||||
VALUES (?, 'deployment', ?, 't5', 'zombie_theater', 'zombie', 'theater', ?, ?)`)
|
||||
.run(server, server, 28960 + index, join(fixture.t5, server));
|
||||
}
|
||||
const { rconGetPlayerCount } = await import('../../src/lib/server/integrations/rcon.js');
|
||||
const { komodoDestroyDeployment, komodoDeleteDeployment, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
vi.mocked(rconGetPlayerCount).mockResolvedValue(0);
|
||||
vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: false, state: 'exited', reason: 'stopped' });
|
||||
vi.mocked(komodoDestroyDeployment).mockImplementation(async server => {
|
||||
runningAtSubmission.push((db().prepare("SELECT COUNT(*) AS n FROM server_deployments WHERE action = 'destroy' AND state = 'running'")
|
||||
.get() as { n: number }).n);
|
||||
const gate = commands.get(server)!.destroy;
|
||||
gate.started.resolve();
|
||||
await gate.response.promise;
|
||||
return { ok: true };
|
||||
});
|
||||
vi.mocked(komodoDeleteDeployment).mockImplementation(async server => {
|
||||
const gate = commands.get(server)!.delete;
|
||||
gate.started.resolve();
|
||||
await gate.response.promise;
|
||||
return { ok: true };
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const server of servers) complete(server);
|
||||
await Promise.allSettled(passes);
|
||||
await fixture.cleanup();
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function complete(server: string) {
|
||||
commands.get(server)!.destroy.response.resolve();
|
||||
commands.get(server)!.delete.response.resolve();
|
||||
}
|
||||
|
||||
function track(pass: Promise<void>) {
|
||||
passes.push(pass);
|
||||
return pass;
|
||||
}
|
||||
|
||||
async function queue(count = servers.length) {
|
||||
const { destroyServer } = await import('../../src/lib/server/fleet/create.js');
|
||||
return servers.slice(0, count).map(server => {
|
||||
const result = destroyServer(server);
|
||||
if (!result.ok) throw new Error(result.message);
|
||||
return result.deploymentId!;
|
||||
});
|
||||
}
|
||||
|
||||
async function states() {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
return (db().prepare("SELECT state FROM server_deployments WHERE action = 'destroy' ORDER BY rowid")
|
||||
.all() as { state: string }[]).map(row => row.state);
|
||||
}
|
||||
|
||||
describe('destruction concurrency', () => {
|
||||
it('defaults to one full removal at a time and refills the slot in the same pass', async () => {
|
||||
await queue(2);
|
||||
const { fleetSettings } = await import('../../src/lib/server/settings.js');
|
||||
const { processDestructions } = await import('../../src/lib/server/fleet/create.js');
|
||||
const { komodoDestroyDeployment } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
expect(fleetSettings().destroyConcurrency).toBe(1);
|
||||
const pass = track(processDestructions());
|
||||
await commands.get(servers[0])!.destroy.started.promise;
|
||||
expect(komodoDestroyDeployment).toHaveBeenCalledTimes(1);
|
||||
expect(await states()).toEqual(['running', 'waiting']);
|
||||
complete(servers[0]);
|
||||
await commands.get(servers[1])!.destroy.started.promise;
|
||||
expect(await states()).toEqual(['done', 'running']);
|
||||
complete(servers[1]);
|
||||
await pass;
|
||||
expect(await states()).toEqual(['done', 'done']);
|
||||
expect(runningAtSubmission).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it('runs two removals concurrently, holds slots through deletion, and coalesces overlapping passes', async () => {
|
||||
await queue();
|
||||
const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js');
|
||||
saveFleetSettings({ ...fleetSettings(), destroyConcurrency: 2 });
|
||||
const { processDestructions } = await import('../../src/lib/server/fleet/create.js');
|
||||
const { komodoDestroyDeployment, komodoDeleteDeployment } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
const pass = track(processDestructions());
|
||||
await Promise.all(servers.slice(0, 2).map(server => commands.get(server)!.destroy.started.promise));
|
||||
const overlapping = track(processDestructions());
|
||||
expect(komodoDestroyDeployment).toHaveBeenCalledTimes(2);
|
||||
expect(await states()).toEqual(['running', 'running', 'waiting', 'waiting']);
|
||||
commands.get(servers[0])!.destroy.response.resolve();
|
||||
await commands.get(servers[0])!.delete.started.promise;
|
||||
expect(komodoDestroyDeployment).toHaveBeenCalledTimes(2);
|
||||
commands.get(servers[0])!.delete.response.resolve();
|
||||
await commands.get(servers[2])!.destroy.started.promise;
|
||||
expect(await states()).toEqual(['done', 'running', 'running', 'waiting']);
|
||||
complete(servers[1]);
|
||||
await commands.get(servers[3])!.destroy.started.promise;
|
||||
complete(servers[2]); complete(servers[3]);
|
||||
await Promise.all([pass, overlapping]);
|
||||
expect(await states()).toEqual(['done', 'done', 'done', 'done']);
|
||||
expect(vi.mocked(komodoDestroyDeployment).mock.calls.map(([server]) => server)).toEqual(servers);
|
||||
expect(komodoDeleteDeployment).toHaveBeenCalledTimes(4);
|
||||
expect(Math.max(...runningAtSubmission)).toBe(2);
|
||||
});
|
||||
|
||||
it('lets active removals finish after lowering the limit and applies it to new admissions', async () => {
|
||||
await queue();
|
||||
const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js');
|
||||
saveFleetSettings({ ...fleetSettings(), destroyConcurrency: 2 });
|
||||
const { processDestructions } = await import('../../src/lib/server/fleet/create.js');
|
||||
const { komodoDestroyDeployment } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
const pass = track(processDestructions());
|
||||
await Promise.all(servers.slice(0, 2).map(server => commands.get(server)!.destroy.started.promise));
|
||||
saveFleetSettings({ ...fleetSettings(), destroyConcurrency: 1 });
|
||||
complete(servers[0]);
|
||||
await vi.waitFor(async () => expect(await states()).toEqual(['done', 'running', 'waiting', 'waiting']));
|
||||
expect(komodoDestroyDeployment).toHaveBeenCalledTimes(2);
|
||||
complete(servers[1]);
|
||||
await commands.get(servers[2])!.destroy.started.promise;
|
||||
expect(komodoDestroyDeployment).toHaveBeenCalledTimes(3);
|
||||
expect(await states()).toEqual(['done', 'done', 'running', 'waiting']);
|
||||
complete(servers[2]);
|
||||
await commands.get(servers[3])!.destroy.started.promise;
|
||||
complete(servers[3]);
|
||||
await pass;
|
||||
expect(runningAtSubmission.slice(2)).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it.each(['rejected', 'thrown'] as const)('releases capacity after a %s teardown failure', async failure => {
|
||||
await queue(3);
|
||||
const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js');
|
||||
saveFleetSettings({ ...fleetSettings(), destroyConcurrency: 2 });
|
||||
const { processDestructions } = await import('../../src/lib/server/fleet/create.js');
|
||||
const { komodoDestroyDeployment, komodoDeleteDeployment } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
if (failure === 'rejected') {
|
||||
vi.mocked(komodoDestroyDeployment).mockImplementationOnce(async () => {
|
||||
commands.get(servers[0])!.destroy.started.resolve();
|
||||
await commands.get(servers[0])!.destroy.response.promise;
|
||||
return { ok: false, kind: 'http-error', message: 'Teardown rejected' };
|
||||
});
|
||||
}
|
||||
const pass = track(processDestructions());
|
||||
await Promise.all(servers.slice(0, 2).map(server => commands.get(server)!.destroy.started.promise));
|
||||
if (failure === 'thrown') commands.get(servers[0])!.destroy.response.reject(new Error('Connection lost'));
|
||||
else commands.get(servers[0])!.destroy.response.resolve();
|
||||
await commands.get(servers[2])!.destroy.started.promise;
|
||||
expect(await states()).toEqual(['failed', 'running', 'running']);
|
||||
complete(servers[1]); complete(servers[2]);
|
||||
await pass;
|
||||
expect(await states()).toEqual(['failed', 'done', 'done']);
|
||||
expect(komodoDestroyDeployment).toHaveBeenCalledTimes(3);
|
||||
expect(komodoDeleteDeployment).not.toHaveBeenCalledWith(servers[0]);
|
||||
});
|
||||
|
||||
it('skips occupied and unknown servers while allowing later empty and stopped servers', async () => {
|
||||
await queue();
|
||||
const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js');
|
||||
saveFleetSettings({ ...fleetSettings(), destroyConcurrency: 2 });
|
||||
const { processDestructions } = await import('../../src/lib/server/fleet/create.js');
|
||||
const { rconGetPlayerCount } = await import('../../src/lib/server/integrations/rcon.js');
|
||||
const { komodoDestroyDeployment, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
vi.mocked(rconGetPlayerCount).mockImplementation(async server => server === servers[0] ? 2 : server === servers[2] ? 0 : null);
|
||||
vi.mocked(komodoInspectHealthy).mockImplementation(async server => server === servers[1]
|
||||
? { ok: false, reason: 'Host unavailable' } : { ok: false, state: 'exited', reason: 'stopped' });
|
||||
for (const server of servers) complete(server);
|
||||
await track(processDestructions());
|
||||
expect(await states()).toEqual(['waiting', 'waiting', 'done', 'done']);
|
||||
expect(vi.mocked(komodoDestroyDeployment).mock.calls.map(([server]) => server)).toEqual(servers.slice(2));
|
||||
expect(rconGetPlayerCount).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
const defaults = { deployKeepDays: 7, restartConcurrency: 1, createConcurrency: 1 };
|
||||
const defaults = { deployKeepDays: 7, restartConcurrency: 1, createConcurrency: 1, destroyConcurrency: 1 };
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture([]);
|
||||
@@ -14,7 +14,7 @@ describe('fleet settings API and persistence', () => {
|
||||
it('returns defaults and saves all settings across a module reload and database reopen', async () => {
|
||||
const { GET, PUT } = await import('../../src/routes/api/settings/+server.js');
|
||||
expect(await GET().json()).toEqual(defaults);
|
||||
const saved = { deployKeepDays: 30, restartConcurrency: 3, createConcurrency: 2 };
|
||||
const saved = { deployKeepDays: 30, restartConcurrency: 3, createConcurrency: 2, destroyConcurrency: 4 };
|
||||
const response = await PUT({ request: new Request('http://localhost/api/settings', { method: 'PUT', body: JSON.stringify(saved) }) });
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual(saved);
|
||||
@@ -37,17 +37,34 @@ describe('fleet settings API and persistence', () => {
|
||||
vi.stubEnv('DEPLOYMENT_RESTART_CONCURRENCY', '4');
|
||||
vi.stubEnv('DEPLOYMENT_CREATE_CONCURRENCY', '2');
|
||||
const { fleetSettings } = await import('../../src/lib/server/settings.js');
|
||||
expect(fleetSettings()).toEqual({ deployKeepDays: 14, restartConcurrency: 4, createConcurrency: 2 });
|
||||
expect(fleetSettings()).toEqual({ deployKeepDays: 14, restartConcurrency: 4, createConcurrency: 2, destroyConcurrency: 1 });
|
||||
closeDb();
|
||||
vi.stubEnv('DEPLOY_KEEP_DAYS', '7');
|
||||
expect(fleetSettings().deployKeepDays).toBe(14);
|
||||
expect(db().prepare("SELECT value FROM settings WHERE key = 'unrelated'").get()).toEqual({ value: 'true' });
|
||||
});
|
||||
|
||||
it('upgrades saved settings with a default destruction limit while retaining existing values', async () => {
|
||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
const previous = { deployKeepDays: 21, restartConcurrency: 3, createConcurrency: 2 };
|
||||
db().prepare("UPDATE settings SET value = ? WHERE key = 'fleet_settings'").run(JSON.stringify(previous));
|
||||
closeDb();
|
||||
vi.stubEnv('DEPLOY_KEEP_DAYS', 'bad');
|
||||
vi.stubEnv('DEPLOYMENT_RESTART_CONCURRENCY', '9');
|
||||
vi.stubEnv('DEPLOYMENT_CREATE_CONCURRENCY', '8');
|
||||
const { GET } = await import('../../src/routes/api/settings/+server.js');
|
||||
const upgraded = { ...previous, destroyConcurrency: 1 };
|
||||
expect(await GET().json()).toEqual(upgraded);
|
||||
expect(JSON.parse((db().prepare("SELECT value FROM settings WHERE key = 'fleet_settings'").get() as { value: string }).value)).toEqual(upgraded);
|
||||
closeDb();
|
||||
expect(await GET().json()).toEqual(upgraded);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'null', '[]', '{}', '{',
|
||||
...['deployKeepDays', 'restartConcurrency', 'createConcurrency'].flatMap(key =>
|
||||
...['deployKeepDays', 'restartConcurrency', 'createConcurrency', 'destroyConcurrency'].flatMap(key =>
|
||||
[0, -1, 1.5, '2', true, null, 9007199254740992].map(value => JSON.stringify({ ...defaults, [key]: value }))),
|
||||
JSON.stringify({ deployKeepDays: 7, restartConcurrency: 1, createConcurrency: 1 }),
|
||||
JSON.stringify({ ...defaults, deployKeepDays: 36501 }),
|
||||
JSON.stringify({ ...defaults, unknown: 1 })
|
||||
])('rejects invalid input without partially saving: %s', async body => {
|
||||
|
||||
Reference in New Issue
Block a user