mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user