From dd2fe2cc7a1744c002379f245791fb9b1f8dab0d Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Tue, 6 Oct 2026 12:56:30 -0400 Subject: [PATCH] Return queued lifecycle jobs before waking provisioning --- src/routes/api/provisioning/+server.ts | 6 ++- src/routes/api/servers/+server.ts | 7 ++- src/routes/api/servers/[name]/+server.ts | 6 ++- tests/unit/provisioning-response.test.ts | 54 ++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 tests/unit/provisioning-response.test.ts diff --git a/src/routes/api/provisioning/+server.ts b/src/routes/api/provisioning/+server.ts index 94ee4a2..d35814f 100644 --- a/src/routes/api/provisioning/+server.ts +++ b/src/routes/api/provisioning/+server.ts @@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit'; import { deploymentListPage } from '$lib/server/provisioning/history.js'; import { requestLifecycle } from '$lib/server/provisioning/index.js'; import { provisioningActions, runtimeActions, type ProvisioningAction, type RuntimeAction } from '$lib/provisioning.js'; -import { kick } from '$lib/server/pipeline/worker.js'; +import { kickProvisioning } from '$lib/server/pipeline/worker.js'; export function GET({ url }: { url: URL }) { const params = url.searchParams; @@ -25,7 +25,9 @@ export async function POST({ request }: { request: Request }) { try { const result = requestLifecycle(body.server, body.action as RuntimeAction); if (!result.ok) return json({ error: result.error }, { status: result.status }); - void kick().catch(err => console.error('[provisioning] worker kick failed:', err)); + setImmediate(() => { + void kickProvisioning().catch(err => console.error('[provisioning] worker kick failed:', err)); + }); return json(result, { status: 202 }); } catch (err) { return json({ error: (err as Error).message }, { status: 400 }); } } diff --git a/src/routes/api/servers/+server.ts b/src/routes/api/servers/+server.ts index 178c758..ba6b271 100644 --- a/src/routes/api/servers/+server.ts +++ b/src/routes/api/servers/+server.ts @@ -1,5 +1,5 @@ import { fleetSnapshot } from '$lib/server/fleet/snapshot.js'; -import { kick } from '$lib/server/pipeline/worker.js'; +import { kickProvisioning } from '$lib/server/pipeline/worker.js'; import { json } from '@sveltejs/kit'; import { queueCreateServer } from '$lib/server/provisioning/index.js'; import type { GameMode, ZombieGame } from '$lib/server/fleet/map-aliases.js'; @@ -37,6 +37,9 @@ export async function POST({ request }: { request: Request }) { : 500; return json({ ok: false, error: result.message, kind: result.kind }, { status }); } - void kick().catch((err) => console.error('[create] worker kick failed:', err)); + // Return the durable job ID before the worker's synchronous admission work. + setImmediate(() => { + void kickProvisioning().catch((err) => console.error('[create] worker kick failed:', err)); + }); return json({ ok: true, name: result.name, port: result.port, deploymentId: result.deploymentId }, { status: 202 }); } diff --git a/src/routes/api/servers/[name]/+server.ts b/src/routes/api/servers/[name]/+server.ts index 578d5a2..526630b 100644 --- a/src/routes/api/servers/[name]/+server.ts +++ b/src/routes/api/servers/[name]/+server.ts @@ -1,4 +1,4 @@ -import { kick } from '$lib/server/pipeline/worker.js'; +import { kickProvisioning } from '$lib/server/pipeline/worker.js'; import { json } from '@sveltejs/kit'; import { destroyServer } from '$lib/server/provisioning/index.js'; import { serverSnapshot } from '$lib/server/fleet/snapshot.js'; @@ -17,6 +17,8 @@ export async function DELETE({ params }: { params: { name: string } }) { const status = result.kind === 'not-found' ? 404 : result.kind === 'conflict' ? 409 : 500; return json({ ok: false, error: result.message, kind: result.kind, warnings: result.warnings }, { status }); } - void kick().catch((err) => console.error('[destroy] worker kick failed:', err)); + setImmediate(() => { + void kickProvisioning().catch((err) => console.error('[destroy] worker kick failed:', err)); + }); return json({ ok: true, deploymentId: result.deploymentId }, { status: 202 }); } diff --git a/tests/unit/provisioning-response.test.ts b/tests/unit/provisioning-response.test.ts new file mode 100644 index 0000000..92f6ffa --- /dev/null +++ b/tests/unit/provisioning-response.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { queueCreateServer, destroyServer, requestLifecycle } from '../../src/lib/server/provisioning/index.js'; +import { kick, kickProvisioning } from '../../src/lib/server/pipeline/worker.js'; +import { POST as create } from '../../src/routes/api/servers/+server.js'; +import { DELETE as destroy } from '../../src/routes/api/servers/[name]/+server.js'; +import { POST as lifecycle } from '../../src/routes/api/provisioning/+server.js'; + +vi.mock('../../src/lib/server/provisioning/index.js', () => ({ + queueCreateServer: vi.fn(), destroyServer: vi.fn(), requestLifecycle: vi.fn() +})); +vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ + kick: vi.fn(async () => {}), kickProvisioning: vi.fn(async () => {}) +})); +vi.mock('../../src/lib/server/fleet/snapshot.js', () => ({ fleetSnapshot: vi.fn(), serverSnapshot: vi.fn() })); +vi.mock('../../src/lib/server/provisioning/history.js', () => ({ deploymentListPage: vi.fn() })); + +beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ toFake: ['setImmediate'] }); +}); +afterEach(() => vi.useRealTimers()); + +const request = (body: unknown) => new Request('http://localhost/api/servers', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) +}); +const routes = [ + { name: 'create', queue: queueCreateServer, invoke: () => create({ request: request({ game: 't5', gameMode: 'zombie', mapSlug: 'theater' }) }) }, + { name: 'destroy', queue: destroyServer, invoke: () => destroy({ params: { name: 'server' } }) }, + { name: 'restart', queue: requestLifecycle, invoke: () => lifecycle({ request: request({ server: 'server', action: 'restart' }) }) } +]; + +it.each(routes)('returns the queued $name job before starting provisioning or unrelated observers', async ({ queue, invoke }) => { + vi.mocked(queue).mockReturnValue({ ok: true, name: 'server', port: 28960, deploymentId: 'job', warnings: [] }); + const response = await invoke(); + expect(response.status).toBe(202); + expect(await response.json()).toMatchObject({ ok: true, deploymentId: 'job' }); + expect(queue).toHaveBeenCalledTimes(1); + expect(kickProvisioning).not.toHaveBeenCalled(); + expect(kick).not.toHaveBeenCalled(); + + await vi.runAllTimersAsync(); + expect(kickProvisioning).toHaveBeenCalledTimes(1); + expect(kick).not.toHaveBeenCalled(); +}); + +it.each(routes)('does not wake workers when $name admission fails', async ({ queue, invoke }) => { + vi.mocked(queue).mockReturnValue({ ok: false, kind: 'conflict', message: 'Busy', error: 'Busy', status: 409, warnings: [] }); + const response = await invoke(); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ error: 'Busy' }); + await vi.runAllTimersAsync(); + expect(kickProvisioning).not.toHaveBeenCalled(); + expect(kick).not.toHaveBeenCalled(); +});