mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
378 lines
10 KiB
JavaScript
378 lines
10 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const { test } = require('node:test');
|
|
const {
|
|
LocalSchedulerCoordinator,
|
|
LocalSchedulerLifecycle,
|
|
LocalWorkflowSchedulerCoordinator,
|
|
} = require('../dist/scheduler');
|
|
|
|
function nextMinute(schedule, afterMs) {
|
|
if (schedule.expression !== '* * * * *' || schedule.timezone !== 'UTC') {
|
|
throw new Error('unsupported test schedule');
|
|
}
|
|
return Math.floor(afterMs / 60_000 + 1) * 60_000;
|
|
}
|
|
|
|
function candidate(overrides = {}) {
|
|
return {
|
|
projectId: 'default',
|
|
triggerId: 'trigger-1',
|
|
triggerRevision: 1,
|
|
triggerContentDigest: 'a'.repeat(64),
|
|
triggerUpdatedAtMs: 1,
|
|
taskId: 'task-1',
|
|
taskRevision: 1,
|
|
taskContentDigest: 'b'.repeat(64),
|
|
expression: '* * * * *',
|
|
timezone: 'UTC',
|
|
misfirePolicy: 'skip',
|
|
stateVersion: 0,
|
|
nextFireAtMs: 60_000,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test('coordinates one bounded page and notifies only committed admissions', async () => {
|
|
const committed = [];
|
|
const notified = [];
|
|
let sequence = 0;
|
|
const coordinator = new LocalSchedulerCoordinator(
|
|
{
|
|
async listLocalScheduleCandidates(options) {
|
|
assert.deepEqual(options, { observedAtMs: 61_000, limit: 4 });
|
|
return {
|
|
candidates: [candidate(), candidate({ triggerId: 'trigger-2' })],
|
|
truncated: true,
|
|
};
|
|
},
|
|
async commitLocalScheduleDecision(command) {
|
|
committed.push(command);
|
|
if (committed.length === 2) return { status: 'raced' };
|
|
return {
|
|
status: 'admitted',
|
|
disposition: 'admit',
|
|
runId: command.runId,
|
|
attemptId: command.attemptId,
|
|
};
|
|
},
|
|
},
|
|
{
|
|
pageSize: 4,
|
|
misfireGraceMs: 5_000,
|
|
clock: () => 61_000,
|
|
nextOccurrence: nextMinute,
|
|
createId: () =>
|
|
`019f7500-0000-4000-8000-${String(++sequence).padStart(12, '0')}`,
|
|
onAdmitted: (runId, attemptId) => notified.push([runId, attemptId]),
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(await coordinator.scheduleOnce(), {
|
|
observedAtMs: 61_000,
|
|
scanned: 2,
|
|
initialized: 0,
|
|
skipped: 0,
|
|
admitted: 1,
|
|
raced: 1,
|
|
truncated: true,
|
|
});
|
|
assert.equal(committed.length, 2);
|
|
assert.deepEqual(notified, [[committed[0].runId, committed[0].attemptId]]);
|
|
});
|
|
|
|
test('does not allocate Run identities for initialization or skip decisions', async () => {
|
|
let allocations = 0;
|
|
const commands = [];
|
|
const coordinator = new LocalSchedulerCoordinator(
|
|
{
|
|
async listLocalScheduleCandidates() {
|
|
return {
|
|
candidates: [
|
|
candidate({ nextFireAtMs: null, triggerUpdatedAtMs: 90_000 }),
|
|
candidate({ triggerId: 'trigger-2', nextFireAtMs: 60_000 }),
|
|
],
|
|
truncated: false,
|
|
};
|
|
},
|
|
async commitLocalScheduleDecision(command) {
|
|
commands.push(command);
|
|
return {
|
|
status: 'advanced',
|
|
disposition: command.decision.disposition,
|
|
};
|
|
},
|
|
},
|
|
{
|
|
clock: () => 100_000,
|
|
misfireGraceMs: 5_000,
|
|
nextOccurrence: nextMinute,
|
|
createId() {
|
|
allocations += 1;
|
|
return 'unused';
|
|
},
|
|
},
|
|
);
|
|
const summary = await coordinator.scheduleOnce();
|
|
assert.equal(summary.initialized, 1);
|
|
assert.equal(summary.skipped, 1);
|
|
assert.equal(allocations, 0);
|
|
assert.equal(
|
|
commands.every((command) => command.runId === undefined),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('reuses one scheduler cycle for cancellation, frontier, Task admission and dispatch', async () => {
|
|
const calls = [];
|
|
let dispatches = 0;
|
|
const schedulerSummary = {
|
|
observedAtMs: 10,
|
|
scanned: 0,
|
|
initialized: 0,
|
|
skipped: 0,
|
|
admitted: 0,
|
|
raced: 0,
|
|
truncated: false,
|
|
};
|
|
const coordinator = new LocalWorkflowSchedulerCoordinator(
|
|
{
|
|
async scheduleOnce() {
|
|
calls.push('schedule');
|
|
return schedulerSummary;
|
|
},
|
|
},
|
|
{
|
|
async convergePage(command) {
|
|
calls.push(`cancel:${command.limit}`);
|
|
return {
|
|
scanned: 0,
|
|
settledRuns: 0,
|
|
settledAttempts: 0,
|
|
blocked: 0,
|
|
hasMore: false,
|
|
};
|
|
},
|
|
},
|
|
{
|
|
async listCandidates(command) {
|
|
calls.push(`frontier-list:${command.limit}`);
|
|
return {
|
|
candidates: [
|
|
{
|
|
runId: 'workflow-run',
|
|
planDigest: 'a'.repeat(64),
|
|
admittedAtMs: 1,
|
|
},
|
|
],
|
|
truncated: false,
|
|
};
|
|
},
|
|
async advance(runId) {
|
|
calls.push(`frontier-advance:${runId}`);
|
|
return {};
|
|
},
|
|
},
|
|
{
|
|
async listCandidates(command) {
|
|
calls.push(`task-list:${command.limit}`);
|
|
return {
|
|
candidates: [
|
|
{
|
|
runId: 'workflow-run',
|
|
stepRunId: 'workflow-step',
|
|
readyAtMs: 2,
|
|
planDigest: 'a'.repeat(64),
|
|
},
|
|
],
|
|
truncated: false,
|
|
};
|
|
},
|
|
async admit(runId, stepRunId) {
|
|
calls.push(`task-admit:${runId}:${stepRunId}`);
|
|
return { status: 'created', receipt: {} };
|
|
},
|
|
},
|
|
{
|
|
async dispatchOnce() {
|
|
dispatches += 1;
|
|
calls.push(`dispatch:${dispatches}`);
|
|
const stats = {
|
|
pages: 1,
|
|
candidatesScanned: dispatches === 1 ? 1 : 0,
|
|
plansUnavailable: 0,
|
|
activationRaces: 0,
|
|
};
|
|
return dispatches === 1
|
|
? {
|
|
status: 'activated',
|
|
runId: 'workflow-run',
|
|
attemptId: 'workflow-attempt',
|
|
stats,
|
|
truncated: false,
|
|
}
|
|
: {
|
|
status: 'idle',
|
|
reason: 'no_candidates',
|
|
stats,
|
|
truncated: false,
|
|
};
|
|
},
|
|
},
|
|
{
|
|
cancellationPageSize: 1,
|
|
cancellationMaxPages: 1,
|
|
frontierPageSize: 1,
|
|
frontierMaxPages: 1,
|
|
taskAttemptPageSize: 1,
|
|
taskAttemptMaxPages: 1,
|
|
maxDispatches: 2,
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(await coordinator.scheduleOnce(), schedulerSummary);
|
|
assert.deepEqual(calls, [
|
|
'cancel:1',
|
|
'schedule',
|
|
'frontier-list:1',
|
|
'frontier-advance:workflow-run',
|
|
'task-list:1',
|
|
'task-admit:workflow-run:workflow-step',
|
|
'dispatch:1',
|
|
'dispatch:2',
|
|
]);
|
|
assert.deepEqual(coordinator.latestWorkflowSummary(), {
|
|
cancellation: {
|
|
pages: 1,
|
|
scanned: 0,
|
|
settledRuns: 0,
|
|
settledAttempts: 0,
|
|
blocked: 0,
|
|
hasMore: false,
|
|
remaining: false,
|
|
stopReason: 'complete',
|
|
},
|
|
frontierPages: 1,
|
|
frontierScanned: 1,
|
|
frontierAdvanced: 1,
|
|
frontierTruncated: false,
|
|
taskAttemptPages: 1,
|
|
taskAttemptsScanned: 1,
|
|
taskAttemptsCreated: 1,
|
|
taskAttemptsExisting: 0,
|
|
taskAttemptsTruncated: false,
|
|
dispatches: 2,
|
|
activated: 1,
|
|
activationFailed: 0,
|
|
dispatchIdle: true,
|
|
});
|
|
});
|
|
|
|
test('lifecycle coalesces cycles and stops without leaving a scheduler timer', async () => {
|
|
let calls = 0;
|
|
let release;
|
|
const pending = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const summary = {
|
|
observedAtMs: 1,
|
|
scanned: 0,
|
|
initialized: 0,
|
|
skipped: 0,
|
|
admitted: 0,
|
|
raced: 0,
|
|
truncated: false,
|
|
};
|
|
const lifecycle = new LocalSchedulerLifecycle(
|
|
{
|
|
async scheduleOnce() {
|
|
calls += 1;
|
|
await pending;
|
|
return summary;
|
|
},
|
|
},
|
|
{ intervalMs: 250, stopTimeoutMs: 1_000 },
|
|
);
|
|
|
|
assert.equal(lifecycle.start(), 'started');
|
|
const first = lifecycle.runOnce();
|
|
const second = lifecycle.runOnce();
|
|
assert.equal(first, second);
|
|
const stopping = lifecycle.stopAndDrain();
|
|
release();
|
|
assert.deepEqual(await first, summary);
|
|
assert.deepEqual(await stopping, { status: 'stopped' });
|
|
assert.equal(calls, 1);
|
|
await assert.rejects(lifecycle.runOnce(), /stopping/);
|
|
});
|
|
|
|
test('lifecycle bounds shutdown when a schedule transaction does not settle', async () => {
|
|
const lifecycle = new LocalSchedulerLifecycle(
|
|
{ scheduleOnce: () => new Promise(() => {}) },
|
|
{ intervalMs: 250, stopTimeoutMs: 100 },
|
|
);
|
|
void lifecycle.runOnce();
|
|
assert.deepEqual(await lifecycle.stopAndDrain(), { status: 'timed_out' });
|
|
});
|
|
|
|
test('lifecycle shutdown absorbs an already isolated cycle failure', async () => {
|
|
let rejectCycle;
|
|
const lifecycle = new LocalSchedulerLifecycle(
|
|
{
|
|
scheduleOnce: () =>
|
|
new Promise((resolve, reject) => {
|
|
rejectCycle = reject;
|
|
}),
|
|
},
|
|
{ intervalMs: 250, stopTimeoutMs: 1_000 },
|
|
);
|
|
const cycle = lifecycle.runOnce();
|
|
const stopping = lifecycle.stopAndDrain();
|
|
rejectCycle(new Error('schedule storage unavailable'));
|
|
await assert.rejects(cycle, /schedule storage unavailable/);
|
|
assert.deepEqual(await stopping, { status: 'stopped' });
|
|
});
|
|
|
|
test('lifecycle runs an unrefed non-overlapping cadence and isolates diagnostics', async () => {
|
|
let active = 0;
|
|
let maximumActive = 0;
|
|
let calls = 0;
|
|
const diagnostics = [];
|
|
const lifecycle = new LocalSchedulerLifecycle(
|
|
{
|
|
async scheduleOnce() {
|
|
active += 1;
|
|
maximumActive = Math.max(maximumActive, active);
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
active -= 1;
|
|
calls += 1;
|
|
if (calls === 1) throw new Error('temporary schedule failure');
|
|
return {
|
|
observedAtMs: calls,
|
|
scanned: 0,
|
|
initialized: 0,
|
|
skipped: 0,
|
|
admitted: 0,
|
|
raced: 0,
|
|
truncated: false,
|
|
};
|
|
},
|
|
},
|
|
{
|
|
intervalMs: 250,
|
|
stopTimeoutMs: 1_000,
|
|
onDiagnostic(error, summary) {
|
|
diagnostics.push({ error, summary });
|
|
if (error === undefined) throw new Error('diagnostic sink unavailable');
|
|
},
|
|
},
|
|
);
|
|
|
|
lifecycle.start();
|
|
await new Promise((resolve) => setTimeout(resolve, 650));
|
|
assert.deepEqual(await lifecycle.stopAndDrain(), { status: 'stopped' });
|
|
assert.ok(calls >= 2);
|
|
assert.equal(maximumActive, 1);
|
|
assert.ok(diagnostics.some(({ error }) => error instanceof Error));
|
|
assert.ok(diagnostics.some(({ summary }) => summary?.observedAtMs >= 2));
|
|
});
|