Files
qinglong/test/back/primaryCronProjection.test.cjs
T

486 lines
15 KiB
JavaScript

require('ts-node/register/transpile-only');
const assert = require('node:assert/strict');
const { afterEach, test } = require('node:test');
const { DataTypes, QueryTypes, 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 {
runningInstanceRunReferenceMigration,
} = require('../../back/migrations/0003-running-instance-run-reference');
const { runMigrations } = require('../../back/migrations/runner');
const {
PrimaryCronProjection,
} = require('../../back/runtime/adapters/legacy-sequelize/primaryCronProjection');
const {
LegacySequelizeProjectedRunRepository,
} = require('../../back/runtime/adapters/legacy-sequelize/projectedRunRepository');
const {
LegacySequelizeRunRepository,
} = require('../../back/runtime/adapters/legacy-sequelize/runRepository');
const {
PrimaryRunCreator,
} = require('../../back/runtime/application/primaryRunCreator');
const {
PrimaryRunOrchestrator,
} = require('../../back/runtime/application/primaryRunOrchestrator');
const {
RunCommandService,
} = require('../../back/runtime/application/runCommandService');
const {
createLegacyLogOutputRef,
} = require('../../back/runtime/compatibility/legacyLogOutputRef');
const databases = [];
const BASE_TIME = 1_750_000_000_000;
let idSequence = 900;
function nextId() {
idSequence += 1;
return '019f7110-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 createLegacyTables(database) {
const queryInterface = database.getQueryInterface();
await queryInterface.createTable('Crontabs', {
id: {
type: DataTypes.INTEGER,
allowNull: false,
primaryKey: true,
autoIncrement: true,
},
name: { type: DataTypes.STRING, allowNull: true },
command: { type: DataTypes.STRING, allowNull: false },
status: { type: DataTypes.INTEGER, allowNull: true },
pid: { type: DataTypes.INTEGER, allowNull: true },
log_path: { type: DataTypes.STRING, allowNull: true },
last_running_time: { type: DataTypes.INTEGER, allowNull: true },
last_execution_time: { type: DataTypes.INTEGER, allowNull: true },
});
await queryInterface.createTable('RunningInstances', {
id: {
type: DataTypes.INTEGER,
allowNull: false,
primaryKey: true,
autoIncrement: true,
},
cron_id: { type: DataTypes.INTEGER, allowNull: false },
pid: { type: DataTypes.INTEGER, allowNull: true },
log_path: { type: DataTypes.STRING, allowNull: true },
started_at: { type: DataTypes.INTEGER, allowNull: false },
finished_at: { type: DataTypes.INTEGER, allowNull: true },
status: { type: DataTypes.INTEGER, allowNull: false },
exit_code: { type: DataTypes.INTEGER, allowNull: true },
});
}
async function createDatabase() {
const database = new Sequelize({
dialect: 'sqlite',
storage: ':memory:',
logging: false,
});
await createLegacyTables(database);
const migrationModel = defineSchemaMigrationModel(database);
await runMigrations({
database,
migrationModel,
migrations: [
runSchemaMigration,
runningInstanceRunReferenceMigration,
runCancellationRequestMigration,
runAttemptDeadlineMigration,
],
logger: { info() {} },
});
databases.push(database);
return database;
}
async function seedCron(database, id) {
await database.getQueryInterface().bulkInsert('Crontabs', [
{
id,
name: 'cron-' + id,
command: 'echo projected',
status: 1,
pid: null,
log_path: null,
last_running_time: null,
last_execution_time: null,
},
]);
}
async function readCron(database, id) {
const rows = await database.query(
'SELECT id, status, pid, log_path, last_running_time, last_execution_time FROM Crontabs WHERE id = :id',
{
replacements: { id },
type: QueryTypes.SELECT,
},
);
return rows[0] || null;
}
async function readInstances(database, cronId) {
return database.query(
'SELECT id, cron_id, run_id, attempt_id, pid, log_path, started_at, finished_at, status, exit_code FROM RunningInstances WHERE cron_id = :cronId ORDER BY id',
{
replacements: { cronId },
type: QueryTypes.SELECT,
},
);
}
function projectedRepository(database, extraParticipants = []) {
return new LegacySequelizeProjectedRunRepository(database, [
new PrimaryCronProjection(database),
...extraParticipants,
]);
}
class FakeExecutor {
type = 'local_process';
completion = deferred();
constructor(pid, startedAtMs) {
this.pid = pid;
this.startedAtMs = startedAtMs;
}
capabilities() {
return {
timeout: true,
processGroupTermination: true,
workingDirectory: true,
isolatedEnvironment: true,
memoryLimit: 'none',
cpuLimit: 'none',
filesystemIsolation: 'none',
networkIsolation: 'none',
};
}
async start(spec) {
return {
id: 'handle-' + this.pid,
executorType: this.type,
runId: spec.runId,
attemptId: spec.attemptId,
startedAtMs: this.startedAtMs,
pid: this.pid,
completion: this.completion.promise,
};
}
async stop() {
return {
status: 'termination_requested',
termSignalSent: true,
killSignalSent: false,
};
}
async inspect() {
return { status: 'running' };
}
}
function startCommand(cronId, acceptedAtMs, logPath) {
return {
definition: {
projectId: 'default',
taskId: 'legacy-cron:' + cronId,
taskRevision: 'revision-' + cronId,
taskName: 'projected run ' + cronId,
legacyCronId: cronId,
triggerType: 'manual',
executionOrigin: 'manual',
outputRef: createLegacyLogOutputRef(logPath),
acceptedAtMs,
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',
terminationGraceMs: 100,
};
},
context: {
environment: {},
output: { async write() {} },
},
};
}
function result(outcome, startedAtMs, finishedAtMs, exitCode) {
return {
outcome,
startedAtMs,
finishedAtMs,
...(exitCode === undefined ? {} : { exitCode }),
};
}
afterEach(async () => {
await Promise.all(databases.splice(0).map((database) => database.close()));
});
test('projects a Primary Run and RunningInstance through success', async () => {
const database = await createDatabase();
await seedCron(database, 7);
const repository = projectedRepository(database);
const executor = new FakeExecutor(4321, BASE_TIME + 2_000);
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
clock: { now: () => BASE_TIME + 3_000 },
createId: nextId,
});
const active = await orchestrator.start(
startCommand(7, BASE_TIME, 'task-7/primary.log'),
);
const runningCron = await readCron(database, 7);
assert.equal(runningCron.status, 0);
assert.equal(runningCron.pid, 4321);
assert.equal(runningCron.log_path, 'task-7/primary.log');
assert.equal(runningCron.last_execution_time, 1_750_000_003);
let instances = await readInstances(database, 7);
assert.equal(instances.length, 1);
assert.equal(instances[0].run_id, active.run.id);
assert.equal(instances[0].attempt_id, active.attempt.id);
assert.equal(instances[0].status, 0);
assert.equal(instances[0].pid, 4321);
assert.equal(instances[0].log_path, 'task-7/primary.log');
executor.completion.resolve(
result('succeeded', BASE_TIME + 2_000, BASE_TIME + 7_000, 0),
);
await active.completion;
const finishedCron = await readCron(database, 7);
assert.equal(finishedCron.status, 1);
assert.equal(finishedCron.pid, null);
assert.equal(finishedCron.log_path, 'task-7/primary.log');
assert.equal(finishedCron.last_running_time, 4);
instances = await readInstances(database, 7);
assert.equal(instances.length, 1);
assert.equal(instances[0].status, 1);
assert.equal(instances[0].finished_at, 1_750_000_007);
assert.equal(instances[0].exit_code, 0);
});
test('maps failed and cancelled attempts into legacy instance states', async () => {
const cases = [
{ cronId: 8, outcome: 'failed', expectedStatus: 3, exitCode: 17 },
{ cronId: 9, outcome: 'cancelled', expectedStatus: 2, exitCode: 143 },
];
for (const item of cases) {
const database = await createDatabase();
await seedCron(database, item.cronId);
const repository = projectedRepository(database);
const executor = new FakeExecutor(4400 + item.cronId, BASE_TIME + 1_000);
const orchestrator = new PrimaryRunOrchestrator(repository, executor, {
clock: { now: () => BASE_TIME + 2_000 },
createId: nextId,
});
const active = await orchestrator.start(
startCommand(
item.cronId,
BASE_TIME,
'task-' + item.cronId + '/primary.log',
),
);
executor.completion.resolve(
result(item.outcome, BASE_TIME + 1_000, BASE_TIME + 4_000, item.exitCode),
);
await active.completion;
const cron = await readCron(database, item.cronId);
const instances = await readInstances(database, item.cronId);
assert.equal(cron.status, 1);
assert.equal(cron.pid, null);
assert.equal(instances.length, 1);
assert.equal(instances[0].status, item.expectedStatus);
assert.equal(instances[0].exit_code, item.exitCode);
}
});
test('keeps Crontab running while another Primary instance remains active', async () => {
const database = await createDatabase();
await seedCron(database, 10);
const repository = projectedRepository(database);
const firstExecutor = new FakeExecutor(5101, BASE_TIME + 1_000);
const firstOrchestrator = new PrimaryRunOrchestrator(
repository,
firstExecutor,
{
clock: { now: () => BASE_TIME + 2_000 },
createId: nextId,
},
);
const first = await firstOrchestrator.start(
startCommand(10, BASE_TIME, 'task-10/first.log'),
);
const secondExecutor = new FakeExecutor(5102, BASE_TIME + 101_000);
const secondOrchestrator = new PrimaryRunOrchestrator(
repository,
secondExecutor,
{
clock: { now: () => BASE_TIME + 102_000 },
createId: nextId,
},
);
const second = await secondOrchestrator.start(
startCommand(10, BASE_TIME + 100_000, 'task-10/second.log'),
);
assert.equal((await readCron(database, 10)).pid, 5102);
secondExecutor.completion.resolve(
result('succeeded', BASE_TIME + 101_000, BASE_TIME + 104_000, 0),
);
await second.completion;
const stillRunning = await readCron(database, 10);
assert.equal(stillRunning.status, 0);
assert.equal(stillRunning.pid, 5101);
assert.equal(stillRunning.log_path, 'task-10/first.log');
firstExecutor.completion.resolve(
result('succeeded', BASE_TIME + 1_000, BASE_TIME + 106_000, 0),
);
await first.completion;
assert.equal((await readCron(database, 10)).status, 1);
assert.equal((await readInstances(database, 10)).length, 2);
});
test('rolls back Run, event and instance writes when projection fails', async () => {
const database = await createDatabase();
await seedCron(database, 11);
const failure = {
enabled: false,
async apply() {
if (this.enabled) throw new Error('injected projection failure');
},
};
const repository = projectedRepository(database, [failure]);
const creator = new PrimaryRunCreator(repository, nextId);
const reference = await creator.create(
{
projectId: 'default',
taskId: 'legacy-cron:11',
taskRevision: 'revision-11',
legacyCronId: 11,
triggerType: 'manual',
executionOrigin: 'manual',
outputRef: createLegacyLogOutputRef('task-11/rollback.log'),
acceptedAtMs: BASE_TIME,
actor: { type: 'user', id: 'user:1' },
},
'local_process',
);
const commands = new RunCommandService(repository, nextId);
const dispatching = await commands.transitionRun({
runId: reference.run.id,
to: 'dispatching',
expectedVersion: reference.run.version,
atMs: BASE_TIME + 1_000,
actor: { type: 'system' },
});
failure.enabled = true;
await assert.rejects(
commands.transitionRunAttempt({
runId: reference.run.id,
attemptId: reference.attempt.id,
to: 'starting',
expectedRunVersion: dispatching.run.version,
atMs: BASE_TIME + 2_000,
actor: { type: 'executor', id: 'local_process' },
}),
/injected projection failure/,
);
const run = await repository.findRunById(reference.run.id);
const attempt = await repository.findAttemptById(reference.attempt.id);
assert.equal(run.status, 'dispatching');
assert.equal(run.version, dispatching.run.version);
assert.equal(attempt.status, 'claimed');
assert.equal((await repository.listEvents(run.id)).length, 3);
assert.equal((await readInstances(database, 11)).length, 0);
assert.equal((await readCron(database, 11)).status, 3);
});
test('legacy Shadow repository never writes the Primary projection', async () => {
const database = await createDatabase();
await seedCron(database, 12);
const repository = new LegacySequelizeRunRepository(database);
const run = {
id: nextId(),
projectId: 'default',
taskId: 'legacy-cron:12',
taskRevision: 'revision-12',
legacyCronId: 12,
triggerType: 'manual',
executionOrigin: 'manual',
executionOwner: 'legacy',
status: 'running',
version: 0,
eventSequence: 0,
priority: 0,
outputRef: createLegacyLogOutputRef('task-12/shadow.log'),
createdAtMs: BASE_TIME,
startedAtMs: BASE_TIME + 1_000,
};
const attempt = {
id: nextId(),
runId: run.id,
attempt: 1,
status: 'running',
executorType: 'legacy_local',
pid: 6200,
callbackSequence: 0,
createdAtMs: BASE_TIME,
startedAtMs: BASE_TIME + 1_000,
};
await repository.transaction(async (transaction) => {
await transaction.insertRun(run);
await transaction.insertAttempt(attempt);
});
const cron = await readCron(database, 12);
assert.equal(cron.status, 1);
assert.equal(cron.pid, null);
assert.equal(
await readInstances(database, 12).then((rows) => rows.length),
0,
);
});