mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-02 15:56:59 +08:00
* fix: skip active single-instance scheduled executions * fix: recover stale single-instance scheduled tasks * test: remove obsolete single-instance replacement assertions --------- Co-authored-by: Patrick Gu <username@users.noreply.github.com> Co-authored-by: whyour <imwhyour@gmail.com>
175 lines
5.9 KiB
TypeScript
175 lines
5.9 KiB
TypeScript
import { t } from './i18n';
|
|
import { spawn } from 'cross-spawn';
|
|
import taskLimit from './pLimit';
|
|
import Logger from '../loaders/logger';
|
|
import { ICron } from '../protos/cron';
|
|
import { CrontabModel, CrontabStatus } from '../data/cron';
|
|
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
|
|
import dayjs from 'dayjs';
|
|
import { observeChildProcess, asError } from './childProcess';
|
|
|
|
export function runCron(
|
|
cmd: string,
|
|
cron: ICron,
|
|
isCurrent: () => boolean = () => true,
|
|
): Promise<number | void> {
|
|
const execute = async () => {
|
|
try {
|
|
if (!isCurrent()) return;
|
|
// A single-instance trigger must not interrupt work already in progress.
|
|
try {
|
|
const existingCron = await CrontabModel.findOne({
|
|
where: { id: Number(cron.id) },
|
|
});
|
|
|
|
// A mutation may commit while this queued run is reading the database.
|
|
if (!isCurrent() || !existingCron || existingCron.isDisabled) return;
|
|
|
|
// Default to single instance mode (0) for backward compatibility
|
|
const allowSingleInstances =
|
|
existingCron?.allow_multiple_instances === 0;
|
|
|
|
if (allowSingleInstances) {
|
|
// A queued manual run is an active reservation, even if its stored PID
|
|
// belongs to a previous execution.
|
|
if (
|
|
existingCron.status === CrontabStatus.queued &&
|
|
existingCron.pid
|
|
) {
|
|
Logger.info(
|
|
'[schedule][Skip queued single-instance task] Task ID: %s, PID: %s',
|
|
cron.id,
|
|
existingCron.pid,
|
|
);
|
|
return;
|
|
}
|
|
if (
|
|
existingCron.status === CrontabStatus.running &&
|
|
existingCron.pid
|
|
) {
|
|
let missing = false;
|
|
try {
|
|
process.kill(existingCron.pid, 0);
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
if (code === 'ESRCH') missing = true;
|
|
else if (code !== 'EPERM') throw error;
|
|
}
|
|
if (!missing) {
|
|
Logger.info(
|
|
'[schedule][Skip active single-instance task] Task ID: %s, PID: %s',
|
|
cron.id,
|
|
existingCron.pid,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Capture old instance IDs before releasing the stale cron state so
|
|
// a concurrent replacement cannot have its instance marked stopped.
|
|
const staleInstances = await RunningInstanceModel.findAll({
|
|
attributes: ['id'],
|
|
where: {
|
|
cron_id: Number(cron.id),
|
|
pid: existingCron.pid,
|
|
status: InstanceStatus.running,
|
|
},
|
|
});
|
|
if (!isCurrent()) return;
|
|
const [recovered] = await CrontabModel.update(
|
|
{
|
|
status: CrontabStatus.idle,
|
|
pid: null,
|
|
queued_token: null,
|
|
} as any,
|
|
{
|
|
where: {
|
|
id: Number(cron.id),
|
|
pid: existingCron.pid,
|
|
status: CrontabStatus.running,
|
|
...(existingCron.log_path !== undefined
|
|
? { log_path: existingCron.log_path }
|
|
: {}),
|
|
...(existingCron.queued_token !== undefined
|
|
? { queued_token: existingCron.queued_token }
|
|
: {}),
|
|
...(existingCron.last_execution_time !== undefined
|
|
? { last_execution_time: existingCron.last_execution_time }
|
|
: {}),
|
|
...(existingCron.isDisabled !== undefined
|
|
? { isDisabled: existingCron.isDisabled }
|
|
: {}),
|
|
allow_multiple_instances: 0,
|
|
},
|
|
},
|
|
);
|
|
if (recovered !== 1) return;
|
|
const staleIds = staleInstances.map((instance) => instance.id!);
|
|
if (staleIds.length) {
|
|
await RunningInstanceModel.update(
|
|
{ status: InstanceStatus.stopped, finished_at: dayjs().unix() },
|
|
{ where: { id: staleIds, status: InstanceStatus.running } },
|
|
);
|
|
}
|
|
Logger.info(
|
|
'[schedule][Recovered stale single-instance task] Task ID: %s, PID: %s',
|
|
cron.id,
|
|
existingCron.pid,
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
Logger.error(
|
|
t('[schedule][检查已运行任务失败] 任务ID: %s, 错误: %s'),
|
|
cron.id,
|
|
error,
|
|
);
|
|
throw error;
|
|
}
|
|
|
|
// Invalidation can happen during the asynchronous database check.
|
|
if (!isCurrent()) return;
|
|
Logger.info(
|
|
t('[schedule][开始执行任务] 参数 %s'),
|
|
JSON.stringify({
|
|
...cron,
|
|
command: cmd,
|
|
}),
|
|
);
|
|
const cp = spawn(cmd, { shell: '/bin/bash' });
|
|
|
|
const { completed } = observeChildProcess(cp, {
|
|
onStderr: async (message) => {
|
|
Logger.info(
|
|
t('[schedule][任务标准错误] 命令: %s, 信息: %s'),
|
|
cmd,
|
|
message,
|
|
);
|
|
},
|
|
});
|
|
const result = await completed;
|
|
if (result.error) {
|
|
Logger.error(
|
|
t('[schedule][执行任务失败] 命令: %s, 错误: %s'),
|
|
cmd,
|
|
result.error.message,
|
|
);
|
|
}
|
|
Logger.info(
|
|
t('[schedule][执行任务结束] 任务ID: %s, 退出码: %j'),
|
|
cron.id,
|
|
result.code,
|
|
);
|
|
return { ...cron, command: cmd, pid: cp.pid, ...result } as any;
|
|
} catch (error) {
|
|
Logger.error(
|
|
t('[schedule][创建任务失败] 命令: %s, 错误: %s'),
|
|
cmd,
|
|
asError(error).message,
|
|
);
|
|
} finally {
|
|
taskLimit.removeQueuedCron(cron.id, execute);
|
|
}
|
|
};
|
|
return taskLimit.runWithCronLimit(cron, execute, undefined, isCurrent);
|
|
}
|