mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
1184 lines
37 KiB
JavaScript
1184 lines
37 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 {
|
|
runRetryPolicyMigration,
|
|
} = require('../../back/migrations/0011-run-retry-policy');
|
|
const { runMigrations } = require('../../back/migrations/runner');
|
|
const {
|
|
LegacySequelizeRunRepository,
|
|
} = require('../../back/runtime/adapters/legacy-sequelize/runRepository');
|
|
const {
|
|
LegacySequelizePrimaryRunIdempotencyLookup,
|
|
} = require('../../back/runtime/adapters/legacy-sequelize/primaryRunIdempotencyLookup');
|
|
const {
|
|
PrimaryRunDuplicateRequestError,
|
|
PrimaryRunIdempotencyUnavailableError,
|
|
PrimaryRunLaunchError,
|
|
PrimaryRunNotActiveError,
|
|
PrimaryRunOrchestrator,
|
|
PrimaryClaimedRunRejectedError,
|
|
PrimaryRunRetryPolicyAuthorityError,
|
|
} = require('../../back/runtime/application/primaryRunOrchestrator');
|
|
const {
|
|
InvalidRunRetryPolicyError,
|
|
} = require('../../back/runtime/domain/runRetryPolicy');
|
|
const {
|
|
PrimaryRunCreator,
|
|
} = require('../../back/runtime/application/primaryRunCreator');
|
|
const {
|
|
RunLostRetryService,
|
|
} = require('../../back/runtime/application/runLostRetryService');
|
|
const {
|
|
RunCommandService,
|
|
} = require('../../back/runtime/application/runCommandService');
|
|
const {
|
|
PrimaryRunCompletionService,
|
|
hashPrimaryCompletionToken,
|
|
} = require('../../back/runtime/application/primaryRunCompletionService');
|
|
|
|
const databases = [];
|
|
const BASE_TIME = 1_750_000_000_000;
|
|
let idSequence = 500;
|
|
|
|
function nextId() {
|
|
idSequence += 1;
|
|
return `019f70e0-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() {
|
|
return (await createRepositoryWithDatabase()).repository;
|
|
}
|
|
|
|
async function createRepositoryWithDatabase() {
|
|
const database = new Sequelize({
|
|
dialect: 'sqlite',
|
|
storage: ':memory:',
|
|
logging: false,
|
|
});
|
|
const migrationModel = defineSchemaMigrationModel(database);
|
|
await runMigrations({
|
|
database,
|
|
migrationModel,
|
|
migrations: [
|
|
runSchemaMigration,
|
|
runCancellationRequestMigration,
|
|
runAttemptDeadlineMigration,
|
|
runRetryPolicyMigration,
|
|
],
|
|
logger: { info() {} },
|
|
});
|
|
databases.push(database);
|
|
return {
|
|
database,
|
|
repository: new LegacySequelizeRunRepository(database),
|
|
};
|
|
}
|
|
|
|
class FakeExecutor {
|
|
type = 'local_process';
|
|
starts = [];
|
|
stops = [];
|
|
completion = deferred();
|
|
startError = undefined;
|
|
handleOverrides = {};
|
|
onStart = async () => undefined;
|
|
|
|
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 });
|
|
await this.onStart(spec, context);
|
|
if (this.startError) throw this.startError;
|
|
return {
|
|
id: nextId(),
|
|
executorType: this.type,
|
|
runId: spec.runId,
|
|
attemptId: spec.attemptId,
|
|
startedAtMs: BASE_TIME + 10,
|
|
pid: 4321,
|
|
completion: this.completion.promise,
|
|
...this.handleOverrides,
|
|
};
|
|
}
|
|
|
|
async stop(handle, reason) {
|
|
this.stops.push({ handle, reason });
|
|
return {
|
|
status: 'termination_requested',
|
|
termSignalSent: true,
|
|
killSignalSent: false,
|
|
};
|
|
}
|
|
|
|
async inspect() {
|
|
return { status: 'running' };
|
|
}
|
|
}
|
|
|
|
class FailingTransactionRepository {
|
|
transactionCount = 0;
|
|
|
|
constructor(repository, failAt) {
|
|
this.repository = repository;
|
|
this.failAt = failAt;
|
|
}
|
|
|
|
findRunById(runId) {
|
|
return this.repository.findRunById(runId);
|
|
}
|
|
|
|
findAttemptById(attemptId) {
|
|
return this.repository.findAttemptById(attemptId);
|
|
}
|
|
|
|
listEvents(runId, options) {
|
|
return this.repository.listEvents(runId, options);
|
|
}
|
|
|
|
transaction(work) {
|
|
this.transactionCount += 1;
|
|
if (this.transactionCount === this.failAt) {
|
|
return Promise.reject(new Error('injected transaction failure'));
|
|
}
|
|
return this.repository.transaction(work);
|
|
}
|
|
}
|
|
|
|
class CompletionConflictRepository {
|
|
conflict = false;
|
|
|
|
constructor(repository) {
|
|
this.repository = repository;
|
|
}
|
|
|
|
findRunById(runId) {
|
|
return this.repository.findRunById(runId);
|
|
}
|
|
|
|
findAttemptById(attemptId) {
|
|
return this.repository.findAttemptById(attemptId);
|
|
}
|
|
|
|
listEvents(runId, options) {
|
|
return this.repository.listEvents(runId, options);
|
|
}
|
|
|
|
listCancellationRequested(options) {
|
|
return this.repository.listCancellationRequested(options);
|
|
}
|
|
|
|
transaction(work) {
|
|
return this.repository.transaction((transaction) =>
|
|
work(
|
|
new Proxy(transaction, {
|
|
get: (target, property) => {
|
|
if (property === 'compareAndSetRun' && this.conflict) {
|
|
return async () => {
|
|
this.conflict = false;
|
|
return false;
|
|
};
|
|
}
|
|
const value = Reflect.get(target, property, target);
|
|
return typeof value === 'function' ? value.bind(target) : value;
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
function startCommand(overrides = {}) {
|
|
return {
|
|
definition: {
|
|
projectId: 'default',
|
|
taskId: 'legacy-cron:7',
|
|
taskRevision: 'revision-7',
|
|
taskName: 'primary manual run',
|
|
legacyCronId: 7,
|
|
triggerType: 'manual',
|
|
executionOrigin: 'manual',
|
|
acceptedAtMs: BASE_TIME,
|
|
actor: { type: 'user', id: 'user:1' },
|
|
},
|
|
createSpec(reference) {
|
|
return {
|
|
runId: reference.run.id,
|
|
attemptId: reference.attempt.id,
|
|
projectId: reference.run.projectId,
|
|
taskId: reference.run.taskId,
|
|
taskRevision: reference.run.taskRevision,
|
|
command: { kind: 'argv', file: '/bin/true', args: [] },
|
|
environmentPolicy: 'isolated',
|
|
...(overrides.timeoutMs === undefined
|
|
? {}
|
|
: { timeoutMs: overrides.timeoutMs }),
|
|
terminationGraceMs: 100,
|
|
};
|
|
},
|
|
context: {
|
|
environment: {},
|
|
output: { async write() {} },
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function result(outcome, overrides = {}) {
|
|
return {
|
|
outcome,
|
|
startedAtMs: BASE_TIME + 10,
|
|
finishedAtMs: BASE_TIME + 20,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(databases.splice(0).map((database) => database.close()));
|
|
});
|
|
|
|
test('persists only a trusted retry policy resolved from immutable task identity', async () => {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
const requests = [];
|
|
executor.onStart = async (spec) => {
|
|
assert.deepEqual(await repository.findRetryPolicyByRunId(spec.runId), {
|
|
runId: spec.runId,
|
|
maxAttempts: 3,
|
|
retryOnLost: true,
|
|
safety: 'idempotent',
|
|
backoffBaseMs: 1_000,
|
|
backoffMaxMs: 30_000,
|
|
version: 0,
|
|
createdAtMs: BASE_TIME,
|
|
updatedAtMs: BASE_TIME,
|
|
});
|
|
};
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
retryPolicyAdmission: {
|
|
async resolve(request) {
|
|
requests.push(request);
|
|
assert.equal(Object.isFrozen(request), true);
|
|
return {
|
|
maxAttempts: 3,
|
|
retryOnLost: true,
|
|
safety: 'idempotent',
|
|
backoffBaseMs: 1_000,
|
|
backoffMaxMs: 30_000,
|
|
};
|
|
},
|
|
},
|
|
});
|
|
|
|
const active = await orchestrator.start(startCommand());
|
|
assert.deepEqual(requests, [
|
|
{
|
|
projectId: 'default',
|
|
taskId: 'legacy-cron:7',
|
|
taskRevision: 'revision-7',
|
|
triggerType: 'manual',
|
|
executionOrigin: 'manual',
|
|
},
|
|
]);
|
|
assert.equal('actor' in requests[0], false);
|
|
assert.equal('taskName' in requests[0], false);
|
|
executor.completion.resolve(result('succeeded', { exitCode: 0 }));
|
|
await active.completion;
|
|
});
|
|
|
|
test('creates no retry policy when no trusted admission provider is configured', async () => {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
});
|
|
|
|
const active = await orchestrator.start(startCommand());
|
|
assert.equal(await repository.findRetryPolicyByRunId(active.run.id), null);
|
|
executor.completion.resolve(result('succeeded', { exitCode: 0 }));
|
|
await active.completion;
|
|
});
|
|
|
|
test('rejects request-supplied retry safety before creating or spawning a Run', async () => {
|
|
const { database, repository } = await createRepositoryWithDatabase();
|
|
const executor = new FakeExecutor();
|
|
const admissionRequests = [];
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
retryPolicyAdmission: {
|
|
async resolve(request) {
|
|
admissionRequests.push(request);
|
|
return undefined;
|
|
},
|
|
},
|
|
});
|
|
const command = startCommand();
|
|
command.definition.retryPolicy = {
|
|
maxAttempts: 3,
|
|
retryOnLost: true,
|
|
safety: 'idempotent',
|
|
backoffBaseMs: 1_000,
|
|
backoffMaxMs: 30_000,
|
|
};
|
|
|
|
await assert.rejects(
|
|
orchestrator.start(command),
|
|
PrimaryRunRetryPolicyAuthorityError,
|
|
);
|
|
assert.deepEqual(admissionRequests, []);
|
|
assert.equal(executor.starts.length, 0);
|
|
assert.equal(
|
|
Number(
|
|
(await database.query('SELECT COUNT(*) AS count FROM Runs'))[0][0].count,
|
|
),
|
|
0,
|
|
);
|
|
});
|
|
|
|
test('rejects unsafe admitted automatic retry before creating or spawning a Run', async () => {
|
|
const { database, repository } = await createRepositoryWithDatabase();
|
|
const executor = new FakeExecutor();
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
retryPolicyAdmission: {
|
|
async resolve() {
|
|
return {
|
|
maxAttempts: 2,
|
|
retryOnLost: true,
|
|
safety: 'unknown',
|
|
backoffBaseMs: 1_000,
|
|
backoffMaxMs: 30_000,
|
|
};
|
|
},
|
|
},
|
|
});
|
|
|
|
await assert.rejects(
|
|
orchestrator.start(startCommand()),
|
|
InvalidRunRetryPolicyError,
|
|
);
|
|
assert.equal(executor.starts.length, 0);
|
|
assert.equal(
|
|
Number(
|
|
(await database.query('SELECT COUNT(*) AS count FROM Runs'))[0][0].count,
|
|
),
|
|
0,
|
|
);
|
|
});
|
|
|
|
test('activates a recovered claimed Attempt N+1 without creating another Run', async () => {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
retryPolicyAdmission: {
|
|
async resolve() {
|
|
return {
|
|
maxAttempts: 2,
|
|
retryOnLost: true,
|
|
safety: 'idempotent',
|
|
backoffBaseMs: 0,
|
|
backoffMaxMs: 0,
|
|
};
|
|
},
|
|
},
|
|
});
|
|
const first = await orchestrator.start(startCommand());
|
|
executor.completion.resolve(result('lost'));
|
|
assert.equal((await first.completion).run.status, 'lost');
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
const retry = new RunLostRetryService(repository, {
|
|
clock: { now: () => BASE_TIME + 30 },
|
|
createId: nextId,
|
|
});
|
|
assert.equal((await retry.reconcile(first.run.id)).status, 'scheduled');
|
|
const requeued = await retry.reconcile(first.run.id);
|
|
assert.equal(requeued.status, 'requeued');
|
|
assert.equal(requeued.attempt.attempt, 2);
|
|
|
|
await assert.rejects(
|
|
orchestrator.activateClaimed({
|
|
runId: first.run.id,
|
|
attemptId: first.attempt.id,
|
|
createSpec: startCommand().createSpec,
|
|
context: { environment: {}, output: { async write() {} } },
|
|
}),
|
|
(error) =>
|
|
error instanceof PrimaryClaimedRunRejectedError &&
|
|
error.reason === 'not_latest_attempt',
|
|
);
|
|
assert.equal(executor.starts.length, 1);
|
|
|
|
executor.completion = deferred();
|
|
executor.onStart = async (spec, context) => {
|
|
assert.equal(spec.runId, first.run.id);
|
|
assert.equal(spec.attemptId, requeued.attempt.id);
|
|
assert.equal(context.completionCallback.callbackSequence, 1);
|
|
assert.equal(
|
|
(await repository.findAttemptById(first.attempt.id)).status,
|
|
'lost',
|
|
);
|
|
};
|
|
const active = await orchestrator.activateClaimed({
|
|
runId: first.run.id,
|
|
attemptId: requeued.attempt.id,
|
|
createSpec: startCommand().createSpec,
|
|
context: { environment: {}, output: { async write() {} } },
|
|
});
|
|
assert.equal(active.run.id, first.run.id);
|
|
assert.equal(active.attempt.id, requeued.attempt.id);
|
|
assert.equal(active.attempt.attempt, 2);
|
|
executor.completion.resolve(result('succeeded', { exitCode: 0 }));
|
|
const completed = await active.completion;
|
|
assert.equal(completed.run.status, 'succeeded');
|
|
assert.equal(completed.attempt.status, 'succeeded');
|
|
assert.equal(
|
|
(await repository.findLatestAttemptByRunId(first.run.id)).id,
|
|
requeued.attempt.id,
|
|
);
|
|
});
|
|
|
|
test('serializes claimed activation before spawn so only one caller can execute', async () => {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
const reference = await new PrimaryRunCreator(repository, nextId).create(
|
|
{
|
|
projectId: 'default',
|
|
taskId: 'legacy-cron:7',
|
|
taskRevision: 'revision-7',
|
|
triggerType: 'manual',
|
|
executionOrigin: 'manual',
|
|
acceptedAtMs: BASE_TIME,
|
|
actor: { type: 'scheduler' },
|
|
},
|
|
executor.type,
|
|
);
|
|
const enteredSpawn = deferred();
|
|
const allowSpawn = deferred();
|
|
executor.onStart = async () => {
|
|
enteredSpawn.resolve();
|
|
await allowSpawn.promise;
|
|
};
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
});
|
|
const command = {
|
|
runId: reference.run.id,
|
|
attemptId: reference.attempt.id,
|
|
createSpec: startCommand().createSpec,
|
|
context: { environment: {}, output: { async write() {} } },
|
|
};
|
|
|
|
const first = orchestrator.activateClaimed(command);
|
|
await enteredSpawn.promise;
|
|
await assert.rejects(
|
|
orchestrator.activateClaimed(command),
|
|
(error) =>
|
|
error instanceof PrimaryClaimedRunRejectedError &&
|
|
error.reason === 'not_queued',
|
|
);
|
|
assert.equal(executor.starts.length, 1);
|
|
|
|
allowSpawn.resolve();
|
|
const active = await first;
|
|
executor.completion.resolve(result('succeeded', { exitCode: 0 }));
|
|
await active.completion;
|
|
});
|
|
|
|
test('rejects a claimed Attempt owned by another executor before side effects', async () => {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
const reference = await new PrimaryRunCreator(repository, nextId).create(
|
|
{
|
|
projectId: 'default',
|
|
taskId: 'task:remote',
|
|
taskRevision: 'revision-remote',
|
|
triggerType: 'manual',
|
|
executionOrigin: 'manual',
|
|
acceptedAtMs: BASE_TIME,
|
|
actor: { type: 'scheduler' },
|
|
},
|
|
'remote_worker',
|
|
);
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
});
|
|
|
|
await assert.rejects(
|
|
orchestrator.activateClaimed({
|
|
runId: reference.run.id,
|
|
attemptId: reference.attempt.id,
|
|
createSpec: startCommand().createSpec,
|
|
context: { environment: {}, output: { async write() {} } },
|
|
}),
|
|
(error) =>
|
|
error instanceof PrimaryClaimedRunRejectedError &&
|
|
error.reason === 'executor_mismatch',
|
|
);
|
|
assert.equal(executor.starts.length, 0);
|
|
assert.equal(
|
|
(await repository.findRunById(reference.run.id)).status,
|
|
'queued',
|
|
);
|
|
});
|
|
|
|
test('rejects stale execution authority on a claimed Attempt before side effects', async () => {
|
|
const persisted = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
const reference = await new PrimaryRunCreator(persisted, nextId).create(
|
|
{
|
|
projectId: 'default',
|
|
taskId: 'task:contaminated',
|
|
taskRevision: 'revision-contaminated',
|
|
triggerType: 'manual',
|
|
executionOrigin: 'manual',
|
|
acceptedAtMs: BASE_TIME,
|
|
actor: { type: 'scheduler' },
|
|
},
|
|
executor.type,
|
|
);
|
|
const contaminated = {
|
|
...reference.attempt,
|
|
callbackTokenHash: 'a'.repeat(64),
|
|
};
|
|
const repository = {
|
|
findRunById: persisted.findRunById.bind(persisted),
|
|
async findAttemptById() {
|
|
return contaminated;
|
|
},
|
|
async findLatestAttemptByRunId() {
|
|
return contaminated;
|
|
},
|
|
findRetryPolicyByRunId: persisted.findRetryPolicyByRunId.bind(persisted),
|
|
listEvents: persisted.listEvents.bind(persisted),
|
|
listCancellationRequested:
|
|
persisted.listCancellationRequested.bind(persisted),
|
|
transaction: persisted.transaction.bind(persisted),
|
|
};
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
});
|
|
|
|
await assert.rejects(
|
|
orchestrator.activateClaimed({
|
|
runId: reference.run.id,
|
|
attemptId: reference.attempt.id,
|
|
createSpec: startCommand().createSpec,
|
|
context: { environment: {}, output: { async write() {} } },
|
|
}),
|
|
(error) =>
|
|
error instanceof PrimaryClaimedRunRejectedError &&
|
|
error.reason === 'stale_execution_authority',
|
|
);
|
|
assert.equal(executor.starts.length, 0);
|
|
});
|
|
|
|
test('persists the Primary Run and starting Attempt before spawning', async () => {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
const registrations = [];
|
|
executor.onStart = async (spec, context) => {
|
|
const [run, attempt, events] = await Promise.all([
|
|
repository.findRunById(spec.runId),
|
|
repository.findAttemptById(spec.attemptId),
|
|
repository.listEvents(spec.runId),
|
|
]);
|
|
assert.equal(run.status, 'dispatching');
|
|
assert.equal(run.executionOwner, 'runtime');
|
|
assert.equal(attempt.status, 'starting');
|
|
assert.equal(attempt.deadlineAtMs, BASE_TIME + 5 + 5_000);
|
|
assert.equal(context.completionCallback.callbackSequence, 1);
|
|
assert.equal(
|
|
attempt.callbackTokenHash,
|
|
hashPrimaryCompletionToken(context.completionCallback.token),
|
|
);
|
|
assert.doesNotMatch(
|
|
JSON.stringify(attempt),
|
|
new RegExp(context.completionCallback.token),
|
|
);
|
|
assert.deepEqual(
|
|
events.map((event) => event.type),
|
|
['run.created', 'run.queued', 'run.dispatching', 'attempt.starting'],
|
|
);
|
|
assert.deepEqual(registrations, [
|
|
{
|
|
runId: spec.runId,
|
|
attemptId: spec.attemptId,
|
|
registeredAtMs: BASE_TIME,
|
|
},
|
|
]);
|
|
};
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
completionReceiptJournal: {
|
|
async register(command) {
|
|
registrations.push(command);
|
|
},
|
|
},
|
|
});
|
|
|
|
const active = await orchestrator.start(startCommand({ timeoutMs: 5_000 }));
|
|
assert.equal(orchestrator.isActive(active.run.id), true);
|
|
assert.equal(active.run.status, 'running');
|
|
assert.equal(active.attempt.status, 'running');
|
|
assert.equal(active.attempt.executorHandle, active.handle.id);
|
|
assert.equal(active.attempt.pid, 4321);
|
|
assert.equal(active.attempt.deadlineAtMs, BASE_TIME + 5 + 5_000);
|
|
|
|
executor.completion.resolve(result('succeeded', { exitCode: 0 }));
|
|
const completed = await active.completion;
|
|
assert.equal(completed.run.status, 'succeeded');
|
|
assert.equal(completed.attempt.status, 'succeeded');
|
|
assert.equal(completed.attempt.callbackSequence, 1);
|
|
assert.equal(orchestrator.isActive(active.run.id), false);
|
|
assert.deepEqual(
|
|
(await repository.listEvents(active.run.id)).map((event) => event.type),
|
|
[
|
|
'run.created',
|
|
'run.queued',
|
|
'run.dispatching',
|
|
'attempt.starting',
|
|
'attempt.running',
|
|
'run.running',
|
|
'attempt.succeeded',
|
|
'run.succeeded',
|
|
],
|
|
);
|
|
});
|
|
|
|
test('persists a claimed Attempt Artifact before spawn and validates it before state changes', async () => {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
const reference = await new PrimaryRunCreator(repository, nextId).create(
|
|
{
|
|
projectId: 'default',
|
|
taskId: 'task:artifact',
|
|
taskRevision: 'revision-artifact',
|
|
triggerType: 'manual',
|
|
executionOrigin: 'manual',
|
|
acceptedAtMs: BASE_TIME,
|
|
actor: { type: 'scheduler' },
|
|
},
|
|
executor.type,
|
|
);
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
});
|
|
const logArtifactId = `local-${'b'.repeat(30)}`;
|
|
executor.onStart = async () => {
|
|
const starting = await repository.findAttemptById(reference.attempt.id);
|
|
assert.equal(starting.status, 'starting');
|
|
assert.equal(starting.logArtifactId, logArtifactId);
|
|
};
|
|
|
|
await assert.rejects(
|
|
orchestrator.activateClaimed({
|
|
runId: reference.run.id,
|
|
attemptId: reference.attempt.id,
|
|
createSpec: startCommand().createSpec,
|
|
context: { environment: {}, output: { async write() {} } },
|
|
logArtifactId: '../escape',
|
|
}),
|
|
/logArtifactId is invalid/,
|
|
);
|
|
assert.equal(
|
|
(await repository.findRunById(reference.run.id)).status,
|
|
'queued',
|
|
);
|
|
assert.equal(executor.starts.length, 0);
|
|
|
|
const active = await orchestrator.activateClaimed({
|
|
runId: reference.run.id,
|
|
attemptId: reference.attempt.id,
|
|
createSpec: startCommand().createSpec,
|
|
context: { environment: {}, output: { async write() {} } },
|
|
logArtifactId,
|
|
});
|
|
assert.equal(active.attempt.logArtifactId, logArtifactId);
|
|
executor.completion.resolve(result('succeeded', { exitCode: 0 }));
|
|
const completed = await active.completion;
|
|
assert.equal(completed.attempt.logArtifactId, logArtifactId);
|
|
});
|
|
|
|
test('persists a durable Executor handle instead of its process-local id', async () => {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
executor.handleOverrides = { durableHandle: 'ql3lp1.test-durable-handle' };
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
});
|
|
|
|
const active = await orchestrator.start(startCommand());
|
|
assert.equal(active.attempt.executorHandle, 'ql3lp1.test-durable-handle');
|
|
assert.notEqual(active.attempt.executorHandle, active.handle.id);
|
|
|
|
executor.completion.resolve(result('succeeded', { exitCode: 0 }));
|
|
await active.completion;
|
|
});
|
|
|
|
test('records a safe terminal failure when Executor.start rejects', async () => {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
executor.startError = new Error('secret command and path');
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
});
|
|
|
|
let launchError;
|
|
try {
|
|
await orchestrator.start(startCommand());
|
|
} catch (error) {
|
|
launchError = error;
|
|
}
|
|
assert.ok(launchError instanceof PrimaryRunLaunchError);
|
|
const run = await repository.findRunById(launchError.reference.run.id);
|
|
const attempt = await repository.findAttemptById(
|
|
launchError.reference.attempt.id,
|
|
);
|
|
assert.equal(run.status, 'failed');
|
|
assert.equal(attempt.status, 'failed');
|
|
assert.equal(run.errorCode, 'EXECUTOR_START_FAILED');
|
|
assert.doesNotMatch(run.errorSummary, /secret|path/);
|
|
assert.equal(orchestrator.isActive(run.id), false);
|
|
});
|
|
|
|
test('does not spawn when completion receipt journal registration fails', async () => {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
completionReceiptJournal: {
|
|
async register() {
|
|
throw new Error('journal unavailable');
|
|
},
|
|
},
|
|
});
|
|
|
|
let launchError;
|
|
try {
|
|
await orchestrator.start(startCommand());
|
|
} catch (error) {
|
|
launchError = error;
|
|
}
|
|
assert.ok(launchError instanceof PrimaryRunLaunchError);
|
|
assert.equal(executor.starts.length, 0);
|
|
assert.equal(
|
|
(await repository.findRunById(launchError.reference.run.id)).status,
|
|
'failed',
|
|
);
|
|
assert.equal(
|
|
(await repository.findAttemptById(launchError.reference.attempt.id)).status,
|
|
'failed',
|
|
);
|
|
});
|
|
|
|
test('cancels through the owning Executor and persists its eventual result', async () => {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
});
|
|
const active = await orchestrator.start(startCommand());
|
|
|
|
const stopExecutor = executor.stop.bind(executor);
|
|
executor.stop = async (handle, reason) => {
|
|
const [run, events] = await Promise.all([
|
|
repository.findRunById(active.run.id),
|
|
repository.listEvents(active.run.id),
|
|
]);
|
|
assert.equal(run.cancelRequestedAtMs, BASE_TIME + 15);
|
|
assert.equal(run.cancelReason, 'user');
|
|
assert.equal(events.at(-1).type, 'run.cancel_requested');
|
|
return stopExecutor(handle, reason);
|
|
};
|
|
|
|
const stop = await active.cancel({
|
|
kind: 'user',
|
|
requestedAtMs: BASE_TIME + 15,
|
|
});
|
|
assert.equal(stop.status, 'termination_requested');
|
|
assert.equal(executor.stops.length, 1);
|
|
executor.completion.resolve(result('cancelled'));
|
|
|
|
const completed = await active.completion;
|
|
assert.equal(completed.run.status, 'cancelled');
|
|
assert.equal(completed.attempt.status, 'cancelled');
|
|
assert.deepEqual(
|
|
(await repository.listEvents(active.run.id)).map((event) => event.type),
|
|
[
|
|
'run.created',
|
|
'run.queued',
|
|
'run.dispatching',
|
|
'attempt.starting',
|
|
'attempt.running',
|
|
'run.running',
|
|
'run.cancel_requested',
|
|
'attempt.cancelled',
|
|
'run.cancelled',
|
|
],
|
|
);
|
|
await assert.rejects(
|
|
orchestrator.cancel(active.run.id, {
|
|
kind: 'user',
|
|
requestedAtMs: BASE_TIME + 30,
|
|
}),
|
|
PrimaryRunNotActiveError,
|
|
);
|
|
});
|
|
|
|
test('keeps the first cancel request and lets it win over a late success', async () => {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
});
|
|
const active = await orchestrator.start(startCommand());
|
|
|
|
await active.cancel({ kind: 'user', requestedAtMs: BASE_TIME + 15 });
|
|
await active.cancel({ kind: 'policy', requestedAtMs: BASE_TIME + 16 });
|
|
assert.equal(executor.stops.length, 2);
|
|
const requested = await repository.findRunById(active.run.id);
|
|
assert.equal(requested.cancelRequestedAtMs, BASE_TIME + 15);
|
|
assert.equal(requested.cancelReason, 'user');
|
|
assert.equal(
|
|
(await repository.listEvents(active.run.id)).filter(
|
|
(event) => event.type === 'run.cancel_requested',
|
|
).length,
|
|
1,
|
|
);
|
|
|
|
executor.completion.resolve(result('succeeded', { exitCode: 0 }));
|
|
const completed = await active.completion;
|
|
assert.equal(completed.result.outcome, 'succeeded');
|
|
assert.equal(completed.run.status, 'cancelled');
|
|
assert.equal(completed.attempt.status, 'cancelled');
|
|
assert.equal(completed.attempt.exitCode, 0);
|
|
});
|
|
|
|
test('retries the atomic completion transaction after a CAS conflict', async () => {
|
|
const persisted = await createRepository();
|
|
const repository = new CompletionConflictRepository(persisted);
|
|
const executor = new FakeExecutor();
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
});
|
|
const active = await orchestrator.start(startCommand());
|
|
|
|
repository.conflict = true;
|
|
executor.completion.resolve(result('succeeded', { exitCode: 0 }));
|
|
|
|
const completed = await active.completion;
|
|
assert.equal(completed.result.outcome, 'succeeded');
|
|
assert.equal(completed.run.status, 'succeeded');
|
|
assert.equal(completed.attempt.status, 'succeeded');
|
|
assert.equal(
|
|
(await persisted.listEvents(active.run.id)).filter(
|
|
(event) => event.type === 'attempt.succeeded',
|
|
).length,
|
|
1,
|
|
);
|
|
});
|
|
|
|
test('does not signal the Executor when persisting cancellation fails', async () => {
|
|
const persisted = await createRepository();
|
|
const repository = new FailingTransactionRepository(persisted, 6);
|
|
const executor = new FakeExecutor();
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
});
|
|
const active = await orchestrator.start(startCommand());
|
|
|
|
await assert.rejects(
|
|
active.cancel({ kind: 'user', requestedAtMs: BASE_TIME + 15 }),
|
|
/injected transaction failure/,
|
|
);
|
|
assert.equal(executor.stops.length, 0);
|
|
const run = await persisted.findRunById(active.run.id);
|
|
assert.equal(run.cancelRequestedAtMs, undefined);
|
|
assert.equal(
|
|
(await persisted.listEvents(active.run.id)).some(
|
|
(event) => event.type === 'run.cancel_requested',
|
|
),
|
|
false,
|
|
);
|
|
|
|
executor.completion.resolve(result('succeeded', { exitCode: 0 }));
|
|
assert.equal((await active.completion).run.status, 'succeeded');
|
|
});
|
|
|
|
test('does not signal after the Attempt completed before cancellation', async () => {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
});
|
|
const active = await orchestrator.start(startCommand());
|
|
await new PrimaryRunCompletionService(repository, nextId).complete({
|
|
runId: active.run.id,
|
|
attemptId: active.attempt.id,
|
|
callbackSequence: 1,
|
|
result: result('succeeded', { exitCode: 0 }),
|
|
source: { kind: 'executor', executorType: executor.type },
|
|
});
|
|
|
|
const stop = await active.cancel({
|
|
kind: 'user',
|
|
requestedAtMs: BASE_TIME + 21,
|
|
});
|
|
assert.deepEqual(stop, {
|
|
status: 'already_exited',
|
|
termSignalSent: false,
|
|
killSignalSent: false,
|
|
});
|
|
assert.equal(executor.stops.length, 0);
|
|
assert.equal(
|
|
(await repository.listEvents(active.run.id)).some(
|
|
(event) => event.type === 'run.cancel_requested',
|
|
),
|
|
false,
|
|
);
|
|
|
|
executor.completion.resolve(result('succeeded', { exitCode: 0 }));
|
|
assert.equal((await active.completion).run.status, 'succeeded');
|
|
});
|
|
|
|
test('compensates a mismatched spawned handle with stop and lost state', async () => {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
executor.handleOverrides = { attemptId: nextId() };
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
});
|
|
|
|
let launchError;
|
|
try {
|
|
await orchestrator.start(startCommand());
|
|
} catch (error) {
|
|
launchError = error;
|
|
}
|
|
assert.ok(launchError instanceof PrimaryRunLaunchError);
|
|
assert.equal(executor.stops.length, 1);
|
|
assert.equal(executor.stops[0].reason.kind, 'reconcile');
|
|
assert.equal(launchError.reference.run.status, 'lost');
|
|
assert.equal(launchError.reference.attempt.status, 'lost');
|
|
});
|
|
|
|
test('stops the process when persisting running ownership fails', async () => {
|
|
const persisted = await createRepository();
|
|
// create, dispatching, starting, attempt.running, then run.running fails.
|
|
const repository = new FailingTransactionRepository(persisted, 5);
|
|
const executor = new FakeExecutor();
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
});
|
|
|
|
let launchError;
|
|
try {
|
|
await orchestrator.start(startCommand());
|
|
} catch (error) {
|
|
launchError = error;
|
|
}
|
|
assert.ok(launchError instanceof PrimaryRunLaunchError);
|
|
assert.equal(executor.stops.length, 1);
|
|
assert.equal(executor.stops[0].reason.kind, 'reconcile');
|
|
assert.equal(launchError.reference.run.status, 'lost');
|
|
assert.equal(launchError.reference.attempt.status, 'lost');
|
|
assert.equal(repository.transactionCount, 7);
|
|
});
|
|
|
|
test('turns a rejected Executor completion channel into a durable lost state', async () => {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
});
|
|
const active = await orchestrator.start(startCommand());
|
|
|
|
executor.completion.reject(new Error('raw completion secret'));
|
|
const completed = await active.completion;
|
|
assert.equal(completed.run.status, 'lost');
|
|
assert.equal(completed.attempt.status, 'lost');
|
|
assert.equal(completed.run.errorCode, 'EXECUTION_LOST');
|
|
assert.doesNotMatch(completed.run.errorSummary, /raw|secret/);
|
|
assert.equal(orchestrator.isActive(active.run.id), false);
|
|
});
|
|
|
|
test('maps every non-success Executor outcome without persisting raw details', async () => {
|
|
const cases = [
|
|
['failed', 'failed', 'EXECUTION_FAILED'],
|
|
['timed_out', 'timed_out', 'EXECUTION_TIMED_OUT'],
|
|
['lost', 'lost', 'EXECUTION_LOST'],
|
|
];
|
|
|
|
for (const [outcome, status, errorCode] of cases) {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
});
|
|
const active = await orchestrator.start(startCommand());
|
|
executor.completion.resolve(
|
|
result(outcome, {
|
|
errorCode: 'UNTRUSTED_CODE',
|
|
errorSummary: 'raw executor secret',
|
|
}),
|
|
);
|
|
|
|
const completed = await active.completion;
|
|
assert.equal(completed.run.status, status);
|
|
assert.equal(completed.attempt.status, status);
|
|
assert.equal(completed.run.errorCode, errorCode);
|
|
assert.doesNotMatch(completed.run.errorSummary, /raw|secret/);
|
|
}
|
|
});
|
|
|
|
test('requires an explicit lookup when a Primary idempotency key is supplied', async () => {
|
|
const repository = await createRepository();
|
|
const executor = new FakeExecutor();
|
|
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
});
|
|
const command = startCommand();
|
|
command.definition.idempotencyKey = 'manual-request-7';
|
|
|
|
await assert.rejects(
|
|
orchestrator.start(command),
|
|
PrimaryRunIdempotencyUnavailableError,
|
|
);
|
|
assert.equal(executor.starts.length, 0);
|
|
});
|
|
|
|
test('returns the existing Run for a duplicate idempotent request without spawning', async () => {
|
|
const { database, repository } = await createRepositoryWithDatabase();
|
|
const lookup = new LegacySequelizePrimaryRunIdempotencyLookup(database);
|
|
const firstExecutor = new FakeExecutor();
|
|
const first = new PrimaryRunOrchestrator(repository, firstExecutor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
idempotencyLookup: lookup,
|
|
});
|
|
const command = startCommand();
|
|
command.definition.idempotencyKey = 'manual-request-7';
|
|
const active = await first.start(command);
|
|
|
|
const secondExecutor = new FakeExecutor();
|
|
const second = new PrimaryRunOrchestrator(repository, secondExecutor, {
|
|
clock: { now: () => BASE_TIME + 6 },
|
|
createId: nextId,
|
|
idempotencyLookup: lookup,
|
|
});
|
|
let duplicate;
|
|
try {
|
|
await second.start(command);
|
|
} catch (error) {
|
|
duplicate = error;
|
|
}
|
|
assert.ok(duplicate instanceof PrimaryRunDuplicateRequestError);
|
|
assert.equal(duplicate.existingRunId, active.run.id);
|
|
assert.equal(secondExecutor.starts.length, 0);
|
|
|
|
firstExecutor.completion.resolve(result('succeeded', { exitCode: 0 }));
|
|
await active.completion;
|
|
});
|
|
|
|
test('uses the unique index to close an idempotency preflight race', async () => {
|
|
const { database, repository } = await createRepositoryWithDatabase();
|
|
const lookup = new LegacySequelizePrimaryRunIdempotencyLookup(database);
|
|
const firstExecutor = new FakeExecutor();
|
|
const first = new PrimaryRunOrchestrator(repository, firstExecutor, {
|
|
clock: { now: () => BASE_TIME + 5 },
|
|
createId: nextId,
|
|
idempotencyLookup: lookup,
|
|
});
|
|
const command = startCommand();
|
|
command.definition.idempotencyKey = 'racing-request-7';
|
|
const active = await first.start(command);
|
|
|
|
let lookupCalls = 0;
|
|
const staleOnceLookup = {
|
|
async findRunId(projectId, idempotencyKey) {
|
|
lookupCalls += 1;
|
|
if (lookupCalls === 1) return null;
|
|
return lookup.findRunId(projectId, idempotencyKey);
|
|
},
|
|
};
|
|
const secondExecutor = new FakeExecutor();
|
|
const second = new PrimaryRunOrchestrator(repository, secondExecutor, {
|
|
clock: { now: () => BASE_TIME + 6 },
|
|
createId: nextId,
|
|
idempotencyLookup: staleOnceLookup,
|
|
});
|
|
|
|
let duplicate;
|
|
try {
|
|
await second.start(command);
|
|
} catch (error) {
|
|
duplicate = error;
|
|
}
|
|
assert.ok(duplicate instanceof PrimaryRunDuplicateRequestError);
|
|
assert.equal(duplicate.existingRunId, active.run.id);
|
|
assert.equal(lookupCalls, 2);
|
|
assert.equal(secondExecutor.starts.length, 0);
|
|
|
|
firstExecutor.completion.resolve(result('succeeded', { exitCode: 0 }));
|
|
await active.completion;
|
|
});
|