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