mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:38:14 +08:00
393 lines
10 KiB
JavaScript
393 lines
10 KiB
JavaScript
require('ts-node/register/transpile-only');
|
|
|
|
const assert = require('node:assert/strict');
|
|
const { afterEach, test } = require('node:test');
|
|
const { Sequelize } = require('sequelize');
|
|
const {
|
|
defineSchemaMigrationModel,
|
|
} = require('../../back/data/schemaMigration');
|
|
const { runSchemaMigration } = require('../../back/migrations/0002-run-schema');
|
|
const {
|
|
runCancellationRequestMigration,
|
|
} = require('../../back/migrations/0004-run-cancellation-request');
|
|
const {
|
|
runAttemptDeadlineMigration,
|
|
} = require('../../back/migrations/0006-run-attempt-deadline');
|
|
const { runMigrations } = require('../../back/migrations/runner');
|
|
const {
|
|
LegacySequelizeRunRepository,
|
|
} = require('../../back/runtime/adapters/legacy-sequelize/runRepository');
|
|
const {
|
|
ManualPrimaryOwnershipError,
|
|
ManualPrimaryRuntime,
|
|
} = require('../../back/runtime/application/manualPrimaryRuntime');
|
|
const {
|
|
installManualPrimaryExecutionRouter,
|
|
selectManualPrimaryExecutionRouter,
|
|
stopManualPrimaryAttempt,
|
|
stopManualPrimaryCron,
|
|
} = require('../../back/runtime/compatibility/manualPrimaryExecutionBridge');
|
|
const {
|
|
parseLegacyLogOutputRef,
|
|
} = require('../../back/runtime/compatibility/legacyLogOutputRef');
|
|
const {
|
|
RuntimeRolloutPolicy,
|
|
} = require('../../back/runtime/domain/runtimeRollout');
|
|
|
|
const databases = [];
|
|
const restorers = [];
|
|
const BASE_TIME = 1_750_000_000_000;
|
|
let idSequence = 1_100;
|
|
|
|
function nextId() {
|
|
idSequence += 1;
|
|
return '019f7120-0000-7000-8000-' + String(idSequence).padStart(12, '0');
|
|
}
|
|
|
|
function deferred() {
|
|
let resolve;
|
|
let reject;
|
|
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
resolve = resolvePromise;
|
|
reject = rejectPromise;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
async function createRepository() {
|
|
const database = new Sequelize({
|
|
dialect: 'sqlite',
|
|
storage: ':memory:',
|
|
logging: false,
|
|
});
|
|
const migrationModel = defineSchemaMigrationModel(database);
|
|
await runMigrations({
|
|
database,
|
|
migrationModel,
|
|
migrations: [
|
|
runSchemaMigration,
|
|
runCancellationRequestMigration,
|
|
runAttemptDeadlineMigration,
|
|
],
|
|
logger: { info() {} },
|
|
});
|
|
databases.push(database);
|
|
return new LegacySequelizeRunRepository(database);
|
|
}
|
|
|
|
function rollout(mode) {
|
|
return new RuntimeRolloutPolicy({
|
|
defaultMode: 'off',
|
|
origins: { manual: mode },
|
|
allowLegacyFallbackBeforeStart: false,
|
|
});
|
|
}
|
|
|
|
class FakeExecutor {
|
|
type = 'local_process';
|
|
starts = [];
|
|
stops = [];
|
|
completion = deferred();
|
|
|
|
capabilities() {
|
|
return {
|
|
timeout: true,
|
|
processGroupTermination: true,
|
|
workingDirectory: true,
|
|
isolatedEnvironment: true,
|
|
memoryLimit: 'none',
|
|
cpuLimit: 'none',
|
|
filesystemIsolation: 'none',
|
|
networkIsolation: 'none',
|
|
};
|
|
}
|
|
|
|
async start(spec, context) {
|
|
this.starts.push({ spec, context });
|
|
if (context.signal && context.signal.aborted) {
|
|
throw new Error('aborted before fake spawn');
|
|
}
|
|
return {
|
|
id: nextId(),
|
|
executorType: this.type,
|
|
runId: spec.runId,
|
|
attemptId: spec.attemptId,
|
|
startedAtMs: BASE_TIME + 10,
|
|
pid: 7301,
|
|
completion: this.completion.promise,
|
|
};
|
|
}
|
|
|
|
async stop(handle, reason) {
|
|
this.stops.push({ handle, reason });
|
|
return {
|
|
status: 'termination_requested',
|
|
termSignalSent: true,
|
|
killSignalSent: false,
|
|
};
|
|
}
|
|
|
|
async inspect() {
|
|
return { status: 'running' };
|
|
}
|
|
}
|
|
|
|
class FakeLogs {
|
|
prepareCalls = 0;
|
|
closeCalls = 0;
|
|
committedAttempts = [];
|
|
writes = [];
|
|
|
|
async prepare(input) {
|
|
this.prepareCalls += 1;
|
|
return {
|
|
logPath: 'task-' + input.cron.id + '/manual.log',
|
|
output: {
|
|
write: async (output) => {
|
|
this.writes.push(output);
|
|
},
|
|
},
|
|
completionCommitted: async (attemptId) => {
|
|
this.committedAttempts.push(attemptId);
|
|
},
|
|
close: async () => {
|
|
this.closeCalls += 1;
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
function input(cronId = 21) {
|
|
return {
|
|
cron: {
|
|
id: cronId,
|
|
name: 'manual primary',
|
|
command: 'demo.js arg',
|
|
schedule: '0 * * * *',
|
|
extraSchedules: ['30 * * * *'],
|
|
taskBefore: 'echo before',
|
|
taskAfter: 'echo after',
|
|
workDirectory: '/tmp',
|
|
logName: 'task-' + cronId,
|
|
},
|
|
acceptedAtMs: BASE_TIME,
|
|
};
|
|
}
|
|
|
|
afterEach(async () => {
|
|
while (restorers.length > 0) restorers.pop()();
|
|
await Promise.all(databases.splice(0).map((database) => database.close()));
|
|
});
|
|
|
|
test('off mode selects Legacy without preparing logs or spawning', async () => {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
const logs = new FakeLogs();
|
|
const runtime = new ManualPrimaryRuntime(
|
|
repository,
|
|
executor,
|
|
rollout('off'),
|
|
logs,
|
|
{ orchestrator: { createId: nextId } },
|
|
);
|
|
|
|
assert.equal(runtime.ownsNewRuns(), false);
|
|
await assert.rejects(runtime.start(input()), ManualPrimaryOwnershipError);
|
|
assert.equal(logs.prepareCalls, 0);
|
|
assert.equal(executor.starts.length, 0);
|
|
});
|
|
|
|
test('Primary mode builds one durable Run and one legacy-compatible spec', async () => {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
const logs = new FakeLogs();
|
|
const runtime = new ManualPrimaryRuntime(
|
|
repository,
|
|
executor,
|
|
rollout('primary'),
|
|
logs,
|
|
{
|
|
clock: { now: () => BASE_TIME + 20 },
|
|
orchestrator: { createId: nextId },
|
|
},
|
|
);
|
|
|
|
const active = await runtime.start(input());
|
|
assert.equal(executor.starts.length, 1);
|
|
assert.equal(active.pid, 7301);
|
|
assert.equal(active.logPath, 'task-21/manual.log');
|
|
const started = executor.starts[0];
|
|
assert.equal(started.spec.command.kind, 'shell');
|
|
assert.match(
|
|
started.spec.command.command,
|
|
/real_log_path='task-21\/manual\.log'/,
|
|
);
|
|
assert.match(started.spec.command.command, /task demo\.js arg/);
|
|
assert.equal(started.spec.environmentPolicy, 'inherit');
|
|
assert.equal(started.context.signal.aborted, false);
|
|
|
|
const run = await repository.findRunById(active.runId);
|
|
assert.equal(run.executionOwner, 'runtime');
|
|
assert.equal(run.executionOrigin, 'manual');
|
|
assert.equal(parseLegacyLogOutputRef(run.outputRef), 'task-21/manual.log');
|
|
|
|
executor.completion.resolve({
|
|
outcome: 'succeeded',
|
|
startedAtMs: BASE_TIME + 10,
|
|
finishedAtMs: BASE_TIME + 30,
|
|
exitCode: 0,
|
|
});
|
|
const completed = await active.completion;
|
|
assert.equal(completed.outcome, 'succeeded');
|
|
assert.equal(completed.exitCode, 0);
|
|
assert.deepEqual(logs.committedAttempts, [active.attemptId]);
|
|
assert.equal(logs.closeCalls, 1);
|
|
});
|
|
|
|
test('routes stop by cron and attempt through the owning Executor', async () => {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
const runtime = new ManualPrimaryRuntime(
|
|
repository,
|
|
executor,
|
|
rollout('primary'),
|
|
new FakeLogs(),
|
|
{ orchestrator: { createId: nextId } },
|
|
);
|
|
const active = await runtime.start(input(22));
|
|
|
|
assert.deepEqual(await runtime.stopCron(999, BASE_TIME + 40), {
|
|
matched: 0,
|
|
failed: 0,
|
|
});
|
|
assert.deepEqual(
|
|
await runtime.stopAttempt(active.attemptId, BASE_TIME + 41),
|
|
{ matched: 1, failed: 0 },
|
|
);
|
|
assert.equal(executor.stops.length, 1);
|
|
assert.equal(executor.stops[0].reason.kind, 'user');
|
|
|
|
executor.completion.resolve({
|
|
outcome: 'cancelled',
|
|
startedAtMs: BASE_TIME + 10,
|
|
finishedAtMs: BASE_TIME + 50,
|
|
exitCode: 143,
|
|
});
|
|
assert.equal((await active.completion).outcome, 'cancelled');
|
|
});
|
|
|
|
test('a stop racing log preparation aborts before Executor side effects', async () => {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
const preparing = deferred();
|
|
const release = deferred();
|
|
const logs = {
|
|
async prepare() {
|
|
preparing.resolve();
|
|
await release.promise;
|
|
return {
|
|
logPath: 'task-24/racing.log',
|
|
output: { async write() {} },
|
|
async close() {},
|
|
};
|
|
},
|
|
};
|
|
const runtime = new ManualPrimaryRuntime(
|
|
repository,
|
|
executor,
|
|
rollout('primary'),
|
|
logs,
|
|
{ orchestrator: { createId: nextId } },
|
|
);
|
|
|
|
const starting = runtime.start(input(24));
|
|
await preparing.promise;
|
|
assert.deepEqual(await runtime.stopCron(24, BASE_TIME + 1), {
|
|
matched: 1,
|
|
failed: 0,
|
|
});
|
|
release.resolve();
|
|
await assert.rejects(starting);
|
|
assert.equal(executor.starts.length, 1);
|
|
assert.equal(executor.starts[0].context.signal.aborted, true);
|
|
});
|
|
|
|
test('bridge is default-off and keeps stop routing available for its owner', async () => {
|
|
assert.equal(selectManualPrimaryExecutionRouter(), undefined);
|
|
assert.deepEqual(await stopManualPrimaryCron(1, BASE_TIME), {
|
|
matched: 0,
|
|
failed: 0,
|
|
});
|
|
|
|
const calls = [];
|
|
const router = {
|
|
ownsNewRuns: () => true,
|
|
async start() {
|
|
throw new Error('not used');
|
|
},
|
|
async stopCron(cronId, requestedAtMs) {
|
|
calls.push(['cron', cronId, requestedAtMs]);
|
|
return { matched: 1, failed: 0 };
|
|
},
|
|
async stopAttempt(attemptId, requestedAtMs) {
|
|
calls.push(['attempt', attemptId, requestedAtMs]);
|
|
return { matched: 1, failed: 0 };
|
|
},
|
|
};
|
|
restorers.push(installManualPrimaryExecutionRouter(router));
|
|
assert.equal(selectManualPrimaryExecutionRouter(), router);
|
|
assert.deepEqual(await stopManualPrimaryCron(23, BASE_TIME + 1), {
|
|
matched: 1,
|
|
failed: 0,
|
|
});
|
|
assert.deepEqual(
|
|
await stopManualPrimaryAttempt('attempt-23', BASE_TIME + 2),
|
|
{
|
|
matched: 1,
|
|
failed: 0,
|
|
},
|
|
);
|
|
assert.deepEqual(calls, [
|
|
['cron', 23, BASE_TIME + 1],
|
|
['attempt', 'attempt-23', BASE_TIME + 2],
|
|
]);
|
|
});
|
|
|
|
test('rolling new triggers to off preserves the previous in-flight stop owner', async () => {
|
|
const oldOwner = {
|
|
ownsNewRuns: () => true,
|
|
async start() {
|
|
throw new Error('not used');
|
|
},
|
|
async stopCron(cronId) {
|
|
return {
|
|
matched: cronId === 25 ? 1 : 0,
|
|
failed: 0,
|
|
};
|
|
},
|
|
async stopAttempt() {
|
|
return { matched: 0, failed: 0 };
|
|
},
|
|
};
|
|
const offRouter = {
|
|
ownsNewRuns: () => false,
|
|
async start() {
|
|
throw new Error('not used');
|
|
},
|
|
async stopCron() {
|
|
return { matched: 0, failed: 0 };
|
|
},
|
|
async stopAttempt() {
|
|
return { matched: 0, failed: 0 };
|
|
},
|
|
};
|
|
restorers.push(installManualPrimaryExecutionRouter(oldOwner));
|
|
restorers.push(installManualPrimaryExecutionRouter(offRouter));
|
|
|
|
assert.equal(selectManualPrimaryExecutionRouter(), undefined);
|
|
assert.deepEqual(await stopManualPrimaryCron(25, BASE_TIME + 3), {
|
|
matched: 1,
|
|
failed: 0,
|
|
});
|
|
});
|