Fix rebuild generation reconciliation (Refs #155)

This commit is contained in:
2026-10-14 12:36:32 -04:00
parent 61dbfb2c5f
commit dedf15d9af
17 changed files with 672 additions and 43 deletions
+283 -1
View File
@@ -465,7 +465,16 @@ describe('provisioning health handoff', () => {
.run(old, old, old, job.name);
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(Date.now() + 1000);
const observedAt = new Date().toISOString();
if (action === 'rebuild') {
// A new generation now establishes its own durable evidence boundary.
// Observe game health strictly after that transition.
const { nativeRequest } = await import('../../src/lib/server/backends/native.js');
vi.mocked(nativeRequest).mockImplementationOnce(async () => {
vi.setSystemTime(Date.now() + 1);
return { healthy: true, state: 'running', exists: true } as never;
});
}
const observedAt = new Date(Date.now() + (action === 'rebuild' ? 1 : 0)).toISOString();
const roster = [
{ num: 0, name: 'Player', guid: '12345678901234567', score: 500, ping: 20 }
];
@@ -4288,3 +4297,276 @@ describe('game key pool launch lifecycle', () => {
expect(serverKeyPool('t5')).toEqual({ total: 1, available: 1 });
});
});
describe('completed rebuild generation receipts (#155)', () => {
async function rebuild() {
const job = await queue();
await advance();
await acknowledge(job.deploymentId);
const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js');
const requested = requestLifecycle(job.name, 'rebuild');
if (!requested.ok) throw new Error(requested.error);
await advance();
return {
job,
id: requested.deploymentId,
operation: (await operationFor(requested.deploymentId))!
};
}
async function inventoryGeneration(
instanceId: string,
generation: number,
observedAt = new Date().toISOString(),
targetHost = hostId
) {
const { heartbeat } = await import('../../src/lib/server/hosts/index.js');
heartbeat(targetHost, {
...inventory(),
instanceStates: [
{ instanceId, generation, state: 'running', healthy: true, observedAt }
]
});
}
it('repairs an older job binding from the actual command before verification, including duplicate receipts and restart', async () => {
const { job, id } = await rebuild();
await acknowledge(id);
const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js');
const next = requestLifecycle(job.name, 'rebuild');
if (!next.ok) throw new Error(next.error);
await advance();
const operation = (await operationFor(next.deploymentId))!;
expect(operation.generation).toBe(2);
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
// Historical job snapshots could stay on generation 1 while the submitted
// command correctly targeted 2 -> 3. The old completion path kept 2.
db()
.prepare('UPDATE native_provisioning SET generation=1 WHERE deployment_id=?')
.run(next.deploymentId);
const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
reportHostOperation(hostId, operation.id, {
state: 'completed',
result: { instanceId: operation.instanceId, generation: 3, healthy: true }
});
await inventoryGeneration(operation.instanceId!, 3);
closeDb();
const { nativeRequest, nativeInstance } =
await import('../../src/lib/server/backends/native.js');
vi.mocked(nativeRequest).mockClear();
remote.state = 'starting';
await advance();
expect(nativeInstance(job.name)?.generation).toBe(3);
expect(nativeRequest).toHaveBeenCalledWith(
expect.objectContaining({ id: operation.instanceId, generation: 3 }),
'instance.inspect'
);
expect(
db()
.prepare(
'SELECT generation,applied_at FROM native_provisioning WHERE deployment_id=?'
)
.get(next.deploymentId)
).toEqual({ generation: 2, applied_at: expect.any(String) });
const { reconcileNativeProvisioningReceipt } =
await import('../../src/lib/server/provisioning/native.js');
const before = db()
.prepare('SELECT generation_reconciled_at FROM native_instances WHERE id=?')
.get(operation.instanceId);
reconcileNativeProvisioningReceipt(next.deploymentId);
closeDb();
reconcileNativeProvisioningReceipt(next.deploymentId);
expect(nativeInstance(job.name)?.generation).toBe(3);
expect(
db()
.prepare('SELECT generation_reconciled_at FROM native_instances WHERE id=?')
.get(operation.instanceId)
).toEqual(before);
expect((await detail(next.deploymentId)).state).toBe('awaiting-health');
for (const observation of await observations(job.name))
expect(observation.health).not.toBe('online');
const { latestGameSubmission, latestGameSubmissions } =
await import('../../src/lib/server/fleet/lifecycle-evidence.js');
expect(latestGameSubmission(job.name)).toBe(
(before as { generation_reconciled_at: string }).generation_reconciled_at
);
expect(latestGameSubmissions().get(job.name)).toBe(latestGameSubmission(job.name));
remote.state = 'running';
await advance();
expect((await detail(next.deploymentId)).state).toBe('done');
const restart = requestLifecycle(job.name, 'restart');
if (!restart.ok) throw new Error(restart.error);
await advance();
expect(await operationFor(restart.deploymentId)).toMatchObject({ generation: 3 });
});
it.each(['before', 'after'] as const)(
'reconciles delayed completion reported %s cancellation across restart',
async (when) => {
const { job, id, operation } = await rebuild();
const { reportHostOperation, pendingHostOperations } =
await import('../../src/lib/server/hosts/index.js');
const { cancelProvisioningJob } =
await import('../../src/lib/server/provisioning/cancellation.js');
const { closeDb } = await import('../../src/lib/server/db/index.js');
pendingHostOperations(hostId);
if (when === 'before')
reportHostOperation(hostId, operation.id, { state: 'completed' });
expect(cancelProvisioningJob(id)).toBe(true);
closeDb();
if (when === 'after') reportHostOperation(hostId, operation.id, { state: 'completed' });
await advance();
await advance();
const { nativeInstance } = await import('../../src/lib/server/backends/native.js');
expect(nativeInstance(job.name)?.generation).toBe(2);
expect((await detail(id)).state).toBe('cancelled');
}
);
it('recovers an already-applied cancelled mismatch through the API and targets replacement logs, health and removal', async () => {
const { job, id, operation } = await rebuild();
const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
reportHostOperation(hostId, operation.id, {
state: 'completed',
result: { instanceId: operation.instanceId, generation: 2 }
});
const { cancelProvisioningJob } =
await import('../../src/lib/server/provisioning/cancellation.js');
expect(cancelProvisioningJob(id)).toBe(true);
const { db } = await import('../../src/lib/server/db/index.js');
db()
.prepare('UPDATE native_provisioning SET applied_at=? WHERE deployment_id=?')
.run(operation.updatedAt, id);
await inventoryGeneration(operation.instanceId!, 2);
const { getDeployment } = await import('../../src/lib/server/provisioning/history.js');
expect(getDeployment(id)?.generationRecovery).toMatchObject({
expectedGeneration: 1,
observedGeneration: 2,
available: true
});
const { POST } =
await import('../../src/routes/api/provisioning/[id]/reconcile-generation/+server.js');
expect(await POST({ params: { id } }).json()).toEqual({ ok: true, generation: 2 });
expect(POST({ params: { id } }).status).toBe(200);
expect((await detail(id)).state).toBe('cancelled');
expect(getDeployment(id)?.generationRecovery).toBeNull();
const backend = await import('../../src/lib/server/backends/native.js');
vi.mocked(backend.nativeRequest).mockRestore();
const hosts = await import('../../src/lib/server/hosts/index.js');
vi.spyOn(hosts, 'waitHostOperationStatus').mockImplementation(async (id) =>
hosts.reportHostOperationStatus(hostId, id, {
state: 'completed',
result: { healthy: true, state: 'running' }
})
);
await backend.nativeRequest(backend.nativeInstance(job.name)!, 'logs.read', {
file: 'console.log'
});
expect(await backend.nativeHealth(job.name)).toMatchObject({ ok: true });
expect(
db()
.prepare(
"SELECT generation FROM ec_host_operations WHERE kind IN ('logs.read','instance.inspect')"
)
.all()
.every((row) => (row as { generation: number }).generation === 2)
).toBe(true);
const { destroyServer } = await import('../../src/lib/server/provisioning/instances.js');
const removal = destroyServer(job.name);
if (!removal.ok || !removal.deploymentId) throw new Error('Removal not queued');
await advance();
expect(await operationFor(removal.deploymentId)).toMatchObject({
generation: 2,
kind: 'instance.remove'
});
});
it.each([
'host',
'instance',
'result-instance',
'result-generation',
'payload',
'stale-inventory',
'missing-inventory',
'inventory-instance'
] as const)('rejects recovery evidence with %s mismatch', async (mismatch) => {
const { job, id, operation } = await rebuild();
const { reportHostOperation, createHost } =
await import('../../src/lib/server/hosts/index.js');
reportHostOperation(hostId, operation.id, {
state: 'completed',
result: {
instanceId:
mismatch === 'result-instance' ? 'another-instance' : operation.instanceId,
generation: mismatch === 'result-generation' ? 3 : 2
}
});
const { db } = await import('../../src/lib/server/db/index.js');
if (mismatch === 'host')
db()
.prepare('UPDATE ec_host_operations SET host_id=? WHERE id=?')
.run(createHost({ name: 'Other host' }).id, operation.id);
if (mismatch === 'instance')
db()
.prepare('UPDATE ec_host_operations SET instance_id=? WHERE id=?')
.run('another-instance', operation.id);
if (mismatch === 'payload')
db()
.prepare(
"UPDATE ec_host_operation_payloads SET payload=json_set(payload,'$.nextGeneration',3) WHERE operation_id=?"
)
.run(operation.id);
if (mismatch !== 'missing-inventory')
await inventoryGeneration(
mismatch === 'inventory-instance' ? 'another-instance' : operation.instanceId!,
2,
mismatch === 'stale-inventory' ? '2000-01-01T00:00:00Z' : new Date().toISOString()
);
const { POST } =
await import('../../src/routes/api/provisioning/[id]/reconcile-generation/+server.js');
expect(POST({ params: { id } }).status).toBe(409);
const { nativeInstance } = await import('../../src/lib/server/backends/native.js');
expect(nativeInstance(job.name)?.generation).toBe(1);
});
it('pauses after an agent stale-generation rejection even when inventory is unavailable', async () => {
const { id, operation } = await rebuild();
const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
reportHostOperation(hostId, operation.id, { state: 'completed' });
const { nativeRequest } = await import('../../src/lib/server/backends/native.js');
vi.mocked(nativeRequest).mockClear();
vi.mocked(nativeRequest).mockRejectedValueOnce(
new Error('Instance generation has been superseded or removed')
);
await advance();
expect((await detail(id)).state).toBe('interrupted');
expect((await detail(id)).error).toContain('controller expects 2, host reports unknown');
await advance();
expect(nativeRequest).toHaveBeenCalledTimes(1);
});
it('stops verification retries on a confirmed generation mismatch and keeps expected/observed evidence', async () => {
const { id, operation } = await rebuild();
const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
reportHostOperation(hostId, operation.id, { state: 'completed' });
await inventoryGeneration(operation.instanceId!, 3);
const { nativeRequest } = await import('../../src/lib/server/backends/native.js');
vi.mocked(nativeRequest).mockClear();
await advance();
const { getDeployment } = await import('../../src/lib/server/provisioning/history.js');
const before = getDeployment(id)!;
expect(before.deployment.state).toBe('interrupted');
expect(before.progress?.blocker).toMatchObject({
code: 'generation-recovery',
automatic: false,
message: expect.stringContaining(
'controller expects 1, completed rebuild requests 2, host reports 3'
)
});
await advance();
await advance();
expect(nativeRequest).not.toHaveBeenCalled();
expect(getDeployment(id)?.events).toEqual(before.events);
});
});