Files
qinglong/test/back/approvedActionRuntimeLifecycle.test.cjs
T

354 lines
9.2 KiB
JavaScript

require('ts-node/register/transpile-only');
const assert = require('node:assert/strict');
const { test } = require('node:test');
const {
ApprovedActionRuntimeLifecycle,
} = require('../../back/runtime/application/approvedActionRuntimeLifecycle');
const {
ApprovedActionRuntimeSupervisor,
} = require('../../back/runtime/application/approvedActionRuntimeSupervisor');
const {
localPrimaryResourcePolicy,
} = require('../../back/runtime/domain/deploymentProfile');
function dispatchPage(overrides = {}) {
return {
scanned: 1,
claimed: 1,
started: 1,
succeeded: 1,
failed: 0,
blocked: 0,
retrying: 0,
deferred: 0,
recoveryRequired: 0,
alreadyTerminal: 0,
unavailable: 0,
truncated: false,
...overrides,
};
}
function recoveryPage(overrides = {}) {
return {
scanned: 1,
claimed: 1,
verifiedSucceeded: 1,
verifiedFailed: 0,
deferred: 0,
manualRequired: 0,
executionActive: 0,
alreadyResolved: 0,
unavailable: 0,
truncated: false,
...overrides,
};
}
function runtimeSummary(overrides = {}) {
return {
recovery: {
pages: 1,
...recoveryPage(),
stopReason: 'complete',
remaining: false,
...overrides.recovery,
},
dispatch: {
pages: 1,
...dispatchPage(),
stopReason: 'complete',
remaining: false,
...overrides.dispatch,
},
};
}
function deferred() {
let resolve;
const promise = new Promise((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function fakeScheduler() {
let id = 0;
const pending = new Map();
const cleared = [];
return {
pending,
cleared,
scheduler: {
setTimeout(callback, delayMs) {
const timer = {
id: ++id,
delayMs,
unrefCalls: 0,
unref() {
this.unrefCalls += 1;
},
};
pending.set(timer.id, { timer, callback });
return timer;
},
clearTimeout(timer) {
cleared.push(timer.id);
pending.delete(timer.id);
},
},
fireNext() {
const next = pending.values().next().value;
assert.ok(next, 'expected a scheduled timer');
pending.delete(next.timer.id);
next.callback();
return next.timer;
},
};
}
async function flush() {
await new Promise((resolve) => setImmediate(resolve));
}
test('supervisor runs recovery first and aggregates bounded dispatch pages', async () => {
const calls = [];
let dispatchPageNumber = 0;
const supervisor = new ApprovedActionRuntimeSupervisor(
{
async dispatchBatch(options) {
calls.push(['dispatch', options]);
dispatchPageNumber += 1;
if (dispatchPageNumber === 1) {
return dispatchPage({
truncated: true,
nextCursor: { eligibleAtMs: 11, dispatchId: 'dispatch-1' },
});
}
return dispatchPage({ claimed: 0, succeeded: 0, unavailable: 1 });
},
},
{
async reconcileBatch(options) {
calls.push(['recovery', options]);
return recoveryPage();
},
},
);
const summary = await supervisor.runCycle({
recovery: { pageSize: 4, maxPages: 1 },
dispatch: { pageSize: 8, maxPages: 2 },
});
assert.deepEqual(calls, [
['recovery', { limit: 4 }],
['dispatch', { limit: 8 }],
[
'dispatch',
{
cursor: { eligibleAtMs: 11, dispatchId: 'dispatch-1' },
limit: 8,
},
],
]);
assert.equal(summary.recovery.verifiedSucceeded, 1);
assert.equal(summary.dispatch.pages, 2);
assert.equal(summary.dispatch.scanned, 2);
assert.equal(summary.dispatch.claimed, 1);
assert.equal(summary.dispatch.succeeded, 1);
assert.equal(summary.dispatch.unavailable, 1);
assert.equal(summary.dispatch.remaining, false);
});
test('supervisor exposes page-limit cursors and refuses stalled scans', async () => {
const dispatchCursor = { eligibleAtMs: 20, dispatchId: 'dispatch-2' };
const recoveryCursor = { nextScanAtMs: 30, dispatchId: 'dispatch-3' };
const limited = new ApprovedActionRuntimeSupervisor(
{
async dispatchBatch() {
return dispatchPage({ truncated: true, nextCursor: dispatchCursor });
},
},
{
async reconcileBatch() {
return recoveryPage({ truncated: true, nextCursor: recoveryCursor });
},
},
);
const limitedSummary = await limited.runCycle({
dispatch: { pageSize: 1, maxPages: 1 },
recovery: { pageSize: 1, maxPages: 1 },
});
assert.equal(limitedSummary.dispatch.stopReason, 'page_limit');
assert.deepEqual(limitedSummary.dispatch.nextCursor, dispatchCursor);
assert.equal(limitedSummary.recovery.stopReason, 'page_limit');
assert.deepEqual(limitedSummary.recovery.nextCursor, recoveryCursor);
const stalled = new ApprovedActionRuntimeSupervisor(
{
async dispatchBatch(options) {
return dispatchPage({ truncated: true, nextCursor: options.cursor });
},
},
{
async reconcileBatch() {
return recoveryPage();
},
},
);
const stalledSummary = await stalled.runCycle({
dispatch: { cursor: dispatchCursor, pageSize: 1, maxPages: 2 },
});
assert.equal(stalledSummary.dispatch.stopReason, 'cursor_stalled');
assert.equal(stalledSummary.dispatch.remaining, true);
});
test('recovery storage failure prevents new dispatch work in the same cycle', async () => {
let dispatchCalls = 0;
const supervisor = new ApprovedActionRuntimeSupervisor(
{
async dispatchBatch() {
dispatchCalls += 1;
return dispatchPage();
},
},
{
async reconcileBatch() {
throw new Error('recovery index unavailable');
},
},
);
await assert.rejects(supervisor.runCycle(), /recovery index unavailable/);
assert.equal(dispatchCalls, 0);
});
test('lifecycle uses one unref timer, never overlaps, and resumes both cursors', async () => {
const timers = fakeScheduler();
const first = deferred();
const calls = [];
const policy = localPrimaryResourcePolicy('edge').approvedAction;
const lifecycle = new ApprovedActionRuntimeLifecycle(
{
async runCycle(options) {
calls.push(options);
if (calls.length === 1) return first.promise;
return runtimeSummary();
},
},
{
intervalMs: policy.intervalMs,
initialDelayMs: policy.initialDelayMs,
stopTimeoutMs: policy.stopTimeoutMs,
cycle: {
dispatch: policy.dispatch,
recovery: policy.recovery,
},
scheduler: timers.scheduler,
},
);
assert.equal(timers.pending.size, 0);
assert.equal(lifecycle.start(), true);
assert.equal(lifecycle.start(), false);
const initial = timers.fireNext();
assert.equal(initial.delayMs, 0);
assert.equal(initial.unrefCalls, 1);
await flush();
assert.equal(calls.length, 1);
assert.equal(timers.pending.size, 0);
first.resolve(
runtimeSummary({
recovery: {
remaining: true,
stopReason: 'page_limit',
nextCursor: { nextScanAtMs: 40, dispatchId: 'recovery-resume' },
},
dispatch: {
remaining: true,
stopReason: 'page_limit',
nextCursor: { eligibleAtMs: 50, dispatchId: 'dispatch-resume' },
},
}),
);
await flush();
const next = timers.fireNext();
assert.equal(next.delayMs, 30_000);
assert.equal(next.unrefCalls, 1);
await flush();
assert.deepEqual(calls[1], {
recovery: {
pageSize: 8,
maxPages: 1,
cursor: { nextScanAtMs: 40, dispatchId: 'recovery-resume' },
},
dispatch: {
pageSize: 8,
maxPages: 1,
cursor: { eligibleAtMs: 50, dispatchId: 'dispatch-resume' },
},
});
assert.equal(await lifecycle.stop(), 'drained');
});
test('lifecycle isolates diagnostics and bounds an in-flight shutdown', async () => {
const timers = fakeScheduler();
const running = deferred();
const errors = [];
const lifecycle = new ApprovedActionRuntimeLifecycle(
{
async runCycle() {
return running.promise;
},
},
{
intervalMs: 500,
stopTimeoutMs: 5,
scheduler: timers.scheduler,
onError(error) {
errors.push(error.message);
},
},
);
lifecycle.start();
timers.fireNext();
await flush();
assert.equal(await lifecycle.stop(), 'timed_out');
assert.equal(timers.pending.size, 0);
assert.equal(lifecycle.start(), false);
running.resolve(runtimeSummary());
await flush();
assert.equal(lifecycle.start(), true);
assert.equal(await lifecycle.stop(), 'drained');
assert.deepEqual(errors, []);
});
test('supervisor and lifecycle reject hot loops and unbounded pages', async () => {
const supervisor = new ApprovedActionRuntimeSupervisor(
{
async dispatchBatch() {
return dispatchPage();
},
},
{
async reconcileBatch() {
return recoveryPage();
},
},
);
await assert.rejects(
supervisor.runCycle({ dispatch: { maxPages: 65 } }),
RangeError,
);
assert.throws(
() => new ApprovedActionRuntimeLifecycle(supervisor, { intervalMs: 249 }),
RangeError,
);
assert.throws(
() =>
new ApprovedActionRuntimeLifecycle(supervisor, {
intervalMs: 500,
stopTimeoutMs: 60_001,
}),
RangeError,
);
});