mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-15 19:57:07 +08:00
fix: 修复任务生命周期与调度就绪,优化执行和构建开销 (#3069)
* fix: harden task lifecycle and scheduler readiness * fix: confine log writes to the configured log directory * fix: verify complete build artifacts and untracked inputs * fix: reconcile scheduler state and make stop win startup races * fix: isolate cron generations and serialize scheduler recovery
This commit is contained in:
@@ -10,12 +10,26 @@ import {
|
||||
InstanceStatus,
|
||||
} from '../data/runningInstance';
|
||||
import { t } from '../shared/i18n';
|
||||
import cronClient from '../schedule/client';
|
||||
|
||||
const route = Router();
|
||||
|
||||
export default (app: Router) => {
|
||||
app.use('/crons', route);
|
||||
|
||||
route.use(async (req, res, next) => {
|
||||
// Keep stop/status callbacks available even when the scheduler is down.
|
||||
if (['POST', 'PUT', 'DELETE'].includes(req.method) &&
|
||||
['/', '/run', '/enable', '/disable', '/views/enable', '/views/disable'].includes(req.path)) {
|
||||
try {
|
||||
await cronClient.readiness.ensureReady();
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
route.get(
|
||||
'/views',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
|
||||
+2
-2
@@ -11,8 +11,8 @@ export default (app: Router) => {
|
||||
try {
|
||||
const healthService = Container.get(HealthService);
|
||||
const health = await healthService.check();
|
||||
res.status(200).send({
|
||||
code: 200,
|
||||
res.status(health.status === 'ok' ? 200 : 503).send({
|
||||
code: health.status === 'ok' ? 200 : 503,
|
||||
data: health,
|
||||
});
|
||||
} catch (err: any) {
|
||||
|
||||
+5
-2
@@ -273,8 +273,11 @@ export default (app: Router) => {
|
||||
},
|
||||
onEnd: async (cp, endTime, diff) => {
|
||||
// Close the stream after task completion
|
||||
await logStreamManager.closeStream(await handleLogPath(logPath));
|
||||
res.end();
|
||||
try {
|
||||
await logStreamManager.closeStream(await handleLogPath(logPath));
|
||||
} finally {
|
||||
res.end();
|
||||
}
|
||||
},
|
||||
onError: async (message: string) => {
|
||||
res.write(message);
|
||||
|
||||
+17
-12
@@ -11,6 +11,7 @@ import { monitoringMiddleware } from './middlewares/monitoring';
|
||||
import { errStack } from './config/util';
|
||||
import { type GrpcServerService } from './services/grpc';
|
||||
import { type HttpServerService } from './services/http';
|
||||
import cronClient from './schedule/client';
|
||||
|
||||
interface WorkerMetadata {
|
||||
id: number;
|
||||
@@ -79,6 +80,11 @@ class Application {
|
||||
);
|
||||
// If gRPC worker died, restart it and wait for it to be ready
|
||||
if (metadata.serviceType === 'grpc') {
|
||||
try {
|
||||
this.httpWorker?.send('scheduler-unavailable');
|
||||
} catch (error) {
|
||||
Logger.warn('Unable to notify HTTP worker of scheduler exit');
|
||||
}
|
||||
const newGrpcWorker = this.forkWorker('grpc');
|
||||
this.waitForWorkerReady(newGrpcWorker, 30000)
|
||||
.then(() => {
|
||||
@@ -132,7 +138,14 @@ class Application {
|
||||
}
|
||||
|
||||
private forkWorker(serviceType: string): Worker {
|
||||
const worker = cluster.fork({ SERVICE_TYPE: serviceType });
|
||||
const workerEnv: NodeJS.ProcessEnv = { SERVICE_TYPE: serviceType };
|
||||
// PM2's fork launcher is inherited by our own cluster workers. Their APM
|
||||
// messages go to this primary, not PM2, and duplicate its sampling work.
|
||||
// Keep primary monitoring and allow restoring the inherited worker APM.
|
||||
if (process.env.pm_id !== undefined && process.env.QL_WORKER_APM !== 'true') {
|
||||
workerEnv.pmx = 'false';
|
||||
}
|
||||
const worker = cluster.fork(workerEnv);
|
||||
|
||||
this.workerMetadataMap.set(worker.id, {
|
||||
id: worker.id,
|
||||
@@ -264,17 +277,9 @@ class Application {
|
||||
process.on('message', async (msg) => {
|
||||
if (msg === 'shutdown') {
|
||||
this.gracefulShutdown(serviceType);
|
||||
} else if (msg === 'reregister-crons' && serviceType === 'http') {
|
||||
// Re-register cron jobs when gRPC worker restarts
|
||||
try {
|
||||
Logger.info('[boot] Received reregister-crons message, re-registering cron jobs...');
|
||||
const CronService = (await import('./services/cron')).default;
|
||||
const cronService = Container.get(CronService);
|
||||
await cronService.autosave_crontab();
|
||||
Logger.info('[boot] Cron jobs re-registered successfully');
|
||||
} catch (error) {
|
||||
Logger.error(`[boot] Failed to re-register cron jobs:\n${errStack(error)}`);
|
||||
}
|
||||
} else if (serviceType === 'http' &&
|
||||
(msg === 'reregister-crons' || msg === 'scheduler-unavailable')) {
|
||||
cronClient.readiness.invalidate();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+44
-12
@@ -12,6 +12,7 @@ import { DependenceTypes } from '../data/dependence';
|
||||
import { FormData } from 'undici';
|
||||
import os from 'os';
|
||||
import { maybeSudo, isInContainer } from './container';
|
||||
import { resolveFileAccess } from '../shared/fileAccess';
|
||||
|
||||
export * from './share';
|
||||
|
||||
@@ -144,7 +145,8 @@ export async function handleLogPath(
|
||||
logPath: string,
|
||||
data: string = '',
|
||||
): Promise<string> {
|
||||
const absolutePath = path.resolve(config.logPath, logPath);
|
||||
const absolutePath = resolveFileAccess(config.logPath, [logPath]);
|
||||
if (!absolutePath) throw new Error('Log path is outside the log directory');
|
||||
const logFileExist = await fileExist(absolutePath);
|
||||
if (!logFileExist) {
|
||||
await createFile(absolutePath, data);
|
||||
@@ -487,18 +489,48 @@ export function psTree(pid: number): Promise<number[]> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function killTask(pid: number) {
|
||||
const pids = await psTree(pid);
|
||||
|
||||
if (pids.length) {
|
||||
try {
|
||||
[pid, ...pids].reverse().forEach((x) => {
|
||||
process.kill(x, 15);
|
||||
});
|
||||
} catch (error) { }
|
||||
} else {
|
||||
process.kill(pid, 2);
|
||||
export async function killTask(pid: number, waitForExit = false) {
|
||||
const descendants = await psTree(pid);
|
||||
if (!waitForExit) {
|
||||
if (descendants.length) {
|
||||
try {
|
||||
[pid, ...descendants]
|
||||
.reverse()
|
||||
.forEach((target) => process.kill(target, 15));
|
||||
} catch {}
|
||||
} else process.kill(pid, 2);
|
||||
return;
|
||||
}
|
||||
const pids = [...descendants.reverse(), pid];
|
||||
const signal = (target: number, sig: NodeJS.Signals) => {
|
||||
try {
|
||||
process.kill(target, sig);
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ESRCH') throw error;
|
||||
}
|
||||
};
|
||||
for (const target of pids) signal(target, 'SIGTERM');
|
||||
const alive = (target: number) => {
|
||||
try {
|
||||
process.kill(target, 0);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ESRCH') return false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const wait = async () => {
|
||||
const deadline = Date.now() + 1000;
|
||||
while (pids.some(alive) && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
return pids.filter(alive);
|
||||
};
|
||||
let remaining = await wait();
|
||||
for (const target of remaining) signal(target, 'SIGKILL');
|
||||
remaining = await wait();
|
||||
if (remaining.length)
|
||||
throw new Error(`Task processes did not exit: ${remaining.join(', ')}`);
|
||||
}
|
||||
|
||||
export async function getPid(cmd: string) {
|
||||
|
||||
@@ -13,6 +13,7 @@ export class Crontab {
|
||||
pid?: number;
|
||||
isDisabled?: 1 | 0;
|
||||
log_path?: string;
|
||||
queued_token?: string | null;
|
||||
isPinned?: 1 | 0;
|
||||
labels?: string[];
|
||||
last_running_time?: number;
|
||||
@@ -83,6 +84,7 @@ export const CrontabModel = sequelize.define<CronInstance>('Crontab', {
|
||||
isDisabled: DataTypes.NUMBER,
|
||||
isPinned: DataTypes.NUMBER,
|
||||
log_path: DataTypes.STRING,
|
||||
queued_token: DataTypes.STRING,
|
||||
labels: DataTypes.JSON,
|
||||
last_running_time: DataTypes.NUMBER,
|
||||
last_execution_time: DataTypes.NUMBER,
|
||||
|
||||
+3
-35
@@ -9,6 +9,7 @@ import { CrontabViewModel } from '../data/cronView';
|
||||
import { CrontabStatModel } from '../data/cronStats';
|
||||
import { RunningInstanceModel } from '../data/runningInstance';
|
||||
import { sequelize } from '../data';
|
||||
import { migrateSchema } from '../shared/schemaMigrations';
|
||||
|
||||
export default async () => {
|
||||
try {
|
||||
@@ -22,44 +23,11 @@ export default async () => {
|
||||
await CrontabStatModel.sync();
|
||||
await RunningInstanceModel.sync();
|
||||
|
||||
// 初始化新增字段
|
||||
const migrations = [
|
||||
{
|
||||
table: 'CrontabViews',
|
||||
column: 'filterRelation',
|
||||
type: 'VARCHAR(255)',
|
||||
},
|
||||
{ table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' },
|
||||
{ table: 'CrontabViews', column: 'type', type: 'NUMBER' },
|
||||
{ table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' },
|
||||
{ table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' },
|
||||
{ table: 'Crontabs', column: 'sub_id', type: 'NUMBER' },
|
||||
{ table: 'Crontabs', column: 'extra_schedules', type: 'JSON' },
|
||||
{ table: 'Crontabs', column: 'task_before', type: 'TEXT' },
|
||||
{ table: 'Crontabs', column: 'task_after', type: 'TEXT' },
|
||||
{ table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' },
|
||||
{
|
||||
table: 'Crontabs',
|
||||
column: 'allow_multiple_instances',
|
||||
type: 'NUMBER',
|
||||
},
|
||||
{ table: 'Crontabs', column: 'work_dir', type: 'VARCHAR(255)' },
|
||||
{ table: 'Envs', column: 'isPinned', type: 'NUMBER' },
|
||||
{ table: 'Envs', column: 'labels', type: 'JSON' },
|
||||
];
|
||||
|
||||
for (const migration of migrations) {
|
||||
try {
|
||||
await sequelize.query(
|
||||
`alter table ${migration.table} add column ${migration.column} ${migration.type}`,
|
||||
);
|
||||
} catch (error) {
|
||||
// Column already exists or other error, continue
|
||||
}
|
||||
}
|
||||
await migrateSchema(sequelize);
|
||||
|
||||
Logger.info('[boot] DB loaded');
|
||||
} catch (error) {
|
||||
Logger.error('[boot] DB load failed', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ import { createRandomString, fileExist, isDemoEnv, safeJSONParse } from '../conf
|
||||
import OpenService from '../services/open';
|
||||
import { shareStore } from '../shared/store';
|
||||
import Logger from './logger';
|
||||
import cronClient from '../schedule/client';
|
||||
import { AppModel } from '../data/open';
|
||||
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
|
||||
import { setLang, systemLang } from '../shared/i18n';
|
||||
@@ -236,7 +237,8 @@ export default async () => {
|
||||
} catch { }
|
||||
|
||||
// 初始化保存一次ck和定时任务数据
|
||||
await cronService.autosave_crontab();
|
||||
cronClient.readiness.configure(() => cronService.autosave_crontab(true));
|
||||
await cronClient.readiness.recover();
|
||||
|
||||
await envService.set_envs();
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@ message ICron {
|
||||
string name = 5;
|
||||
}
|
||||
|
||||
message AddCronRequest { repeated ICron crons = 1; }
|
||||
message AddCronRequest {
|
||||
repeated ICron crons = 1;
|
||||
bool replace = 2;
|
||||
}
|
||||
|
||||
message AddCronResponse {}
|
||||
|
||||
|
||||
+22
-3
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v2.6.1
|
||||
// protoc v3.21.12
|
||||
// protoc v3.17.3
|
||||
// source: back/protos/cron.proto
|
||||
|
||||
/* eslint-disable */
|
||||
@@ -35,6 +35,7 @@ export interface ICron {
|
||||
|
||||
export interface AddCronRequest {
|
||||
crons: ICron[];
|
||||
replace: boolean;
|
||||
}
|
||||
|
||||
export interface AddCronResponse {
|
||||
@@ -232,7 +233,7 @@ export const ICron: MessageFns<ICron> = {
|
||||
};
|
||||
|
||||
function createBaseAddCronRequest(): AddCronRequest {
|
||||
return { crons: [] };
|
||||
return { crons: [], replace: false };
|
||||
}
|
||||
|
||||
export const AddCronRequest: MessageFns<AddCronRequest> = {
|
||||
@@ -240,6 +241,9 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
|
||||
for (const v of message.crons) {
|
||||
ICron.encode(v!, writer.uint32(10).fork()).join();
|
||||
}
|
||||
if (message.replace !== false) {
|
||||
writer.uint32(16).bool(message.replace);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
@@ -258,6 +262,14 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
|
||||
message.crons.push(ICron.decode(reader, reader.uint32()));
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 16) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.replace = reader.bool();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
@@ -268,7 +280,10 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
|
||||
},
|
||||
|
||||
fromJSON(object: any): AddCronRequest {
|
||||
return { crons: globalThis.Array.isArray(object?.crons) ? object.crons.map((e: any) => ICron.fromJSON(e)) : [] };
|
||||
return {
|
||||
crons: globalThis.Array.isArray(object?.crons) ? object.crons.map((e: any) => ICron.fromJSON(e)) : [],
|
||||
replace: isSet(object.replace) ? globalThis.Boolean(object.replace) : false,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: AddCronRequest): unknown {
|
||||
@@ -276,6 +291,9 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
|
||||
if (message.crons?.length) {
|
||||
obj.crons = message.crons.map((e) => ICron.toJSON(e));
|
||||
}
|
||||
if (message.replace !== false) {
|
||||
obj.replace = message.replace;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
@@ -285,6 +303,7 @@ export const AddCronRequest: MessageFns<AddCronRequest> = {
|
||||
fromPartial<I extends Exact<DeepPartial<AddCronRequest>, I>>(object: I): AddCronRequest {
|
||||
const message = createBaseAddCronRequest();
|
||||
message.crons = object.crons?.map((e) => ICron.fromPartial(e)) || [];
|
||||
message.replace = object.replace ?? false;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -69,6 +69,15 @@ const addCron = (
|
||||
return;
|
||||
}
|
||||
|
||||
// Recovery replaces the whole snapshot, including deletions and disabled jobs.
|
||||
// Validation above must finish before touching the previous schedule.
|
||||
if (call.request.replace) {
|
||||
for (const jobs of scheduleStacks.values()) {
|
||||
for (const job of jobs) job?.cancel();
|
||||
}
|
||||
scheduleStacks.clear();
|
||||
}
|
||||
|
||||
// ===== 第二遍:注册所有任务 =====
|
||||
for (const item of call.request.crons) {
|
||||
const { id, schedule, command, extra_schedules, name } = item;
|
||||
|
||||
+62
-10
@@ -1,4 +1,4 @@
|
||||
import { credentials } from '@grpc/grpc-js';
|
||||
import { credentials, status, Metadata } from '@grpc/grpc-js';
|
||||
import {
|
||||
AddCronRequest,
|
||||
AddCronResponse,
|
||||
@@ -9,7 +9,41 @@ import {
|
||||
import config from '../config';
|
||||
import { getGrpcCerts } from '../config/grpcCerts';
|
||||
|
||||
import { HealthService } from '../protos/health';
|
||||
import { SchedulerReadiness } from '../shared/schedulerReadiness';
|
||||
|
||||
class Client {
|
||||
readonly readiness = new SchedulerReadiness(() => this.probe());
|
||||
|
||||
private async waitForReady(timeoutMs: number) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.client.waitForReady(Date.now() + timeoutMs, (err) =>
|
||||
err ? reject(err) : resolve()
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
this.readiness.invalidate();
|
||||
throw Object.assign(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async probe(): Promise<void> {
|
||||
await this.waitForReady(1000);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.client.makeUnaryRequest(
|
||||
HealthService.check.path,
|
||||
HealthService.check.requestSerialize,
|
||||
HealthService.check.responseDeserialize,
|
||||
{ service: 'scheduler' },
|
||||
{ deadline: Date.now() + 1000 },
|
||||
(err, res) => err ? reject(err) : res?.status === 1 ? resolve() : reject(new Error('Scheduler unavailable')),
|
||||
);
|
||||
});
|
||||
}
|
||||
private _client: CronClient | null = null;
|
||||
|
||||
private get client(): CronClient {
|
||||
@@ -28,22 +62,40 @@ class Client {
|
||||
return this._client;
|
||||
}
|
||||
|
||||
addCron(request: AddCronRequest['crons']): Promise<AddCronResponse> {
|
||||
async addCron(
|
||||
request: AddCronRequest['crons'],
|
||||
replace = false
|
||||
): Promise<AddCronResponse> {
|
||||
await this.waitForReady(2000);
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client.addCron({ crons: request }, (err, res) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
this.client.addCron(
|
||||
{ crons: request, replace },
|
||||
new Metadata(),
|
||||
{ deadline: Date.now() + 5000 },
|
||||
(err, res) => {
|
||||
if (err) {
|
||||
if (err.code === status.UNAVAILABLE) {
|
||||
this.readiness.invalidate();
|
||||
Object.assign(err, { status: 503 });
|
||||
}
|
||||
return reject(err);
|
||||
}
|
||||
resolve(res);
|
||||
}
|
||||
resolve(res);
|
||||
});
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
delCron(request: DeleteCronRequest['ids']): Promise<DeleteCronResponse> {
|
||||
async delCron(request: DeleteCronRequest['ids']): Promise<DeleteCronResponse> {
|
||||
await this.waitForReady(2000);
|
||||
return new Promise((resolve, reject) => {
|
||||
this.client.delCron({ ids: request }, (err, res) => {
|
||||
this.client.delCron({ ids: request }, new Metadata(), { deadline: Date.now() + 5000 }, (err, res) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
if (err.code === status.UNAVAILABLE) {
|
||||
this.readiness.invalidate();
|
||||
Object.assign(err, { status: 503 });
|
||||
}
|
||||
return reject(err);
|
||||
}
|
||||
resolve(res);
|
||||
});
|
||||
|
||||
@@ -52,6 +52,9 @@ const check = async (
|
||||
callback: sendUnaryData<HealthCheckResponse>,
|
||||
) => {
|
||||
switch (call.request.service) {
|
||||
// Local scheduler liveness only: never call HTTP from this probe.
|
||||
case 'scheduler':
|
||||
return callback(null, { status: 1 });
|
||||
case 'cron': {
|
||||
const healthUrl = `http://localhost:${config.port}${
|
||||
config.baseUrl || ''
|
||||
|
||||
+388
-256
@@ -1,3 +1,8 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
withSchedulerMutation,
|
||||
schedulerRegistrationError,
|
||||
} from '../shared/schedulerMutationLock';
|
||||
import { Service, Inject } from 'typedi';
|
||||
import winston from 'winston';
|
||||
import config from '../config';
|
||||
@@ -13,7 +18,6 @@ import {
|
||||
getFileContentByName,
|
||||
fileExist,
|
||||
killTask,
|
||||
killAllTasks,
|
||||
getUniqPath,
|
||||
safeJSONParse,
|
||||
isDemoEnv,
|
||||
@@ -31,8 +35,10 @@ import { writeFileWithLock } from '../shared/utils';
|
||||
import { t } from '../shared/i18n';
|
||||
import { ScheduleType } from '../interface/schedule';
|
||||
import { logStreamManager } from '../shared/logStreamManager';
|
||||
import { observeChildProcess, asError } from '../shared/childProcess';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { LogReadOptions, readLogChunk } from '../shared/logReader';
|
||||
import { resolveFileAccess } from '../shared/fileAccess';
|
||||
|
||||
@Service()
|
||||
export default class CronService {
|
||||
@@ -97,42 +103,45 @@ export default class CronService {
|
||||
}
|
||||
|
||||
public async create(payload: Crontab): Promise<Crontab> {
|
||||
const tab = new Crontab(payload);
|
||||
tab.saved = false;
|
||||
tab.log_name = await this.getLogName(tab);
|
||||
const doc = await this.insert(tab);
|
||||
return withSchedulerMutation(async () => {
|
||||
const tab = new Crontab(payload);
|
||||
tab.saved = false;
|
||||
tab.log_name = await this.getLogName(tab);
|
||||
const doc = await this.insert(tab);
|
||||
|
||||
if (isDemoEnv()) {
|
||||
return doc;
|
||||
}
|
||||
|
||||
if (this.shouldUseCronClient(doc)) {
|
||||
try {
|
||||
await cronClient.addCron([
|
||||
{
|
||||
name: doc.name || '',
|
||||
id: String(doc.id),
|
||||
schedule: doc.schedule!,
|
||||
command: this.makeCommand(doc),
|
||||
extra_schedules: doc.extra_schedules || [],
|
||||
},
|
||||
]);
|
||||
} catch (error: any) {
|
||||
// gRPC 注册失败时回滚 DB 记录,避免产生"僵尸任务"
|
||||
// (DB 和 crontab.list 有记录但调度器永远不会执行)
|
||||
await CrontabModel.destroy({ where: { id: doc.id } });
|
||||
this.logger.error(
|
||||
'[crontab] Failed to register cron job in scheduler, task creation rolled back:',
|
||||
error?.message || error,
|
||||
);
|
||||
throw new Error(
|
||||
`${t('调度器注册失败,任务创建已回滚')}: ${(error as any)?.details || error?.message}`,
|
||||
);
|
||||
if (isDemoEnv()) {
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
|
||||
await this.setCrontab();
|
||||
return doc;
|
||||
if (this.shouldUseCronClient(doc)) {
|
||||
try {
|
||||
await cronClient.addCron([
|
||||
{
|
||||
name: doc.name || '',
|
||||
id: String(doc.id),
|
||||
schedule: doc.schedule!,
|
||||
command: this.makeCommand(doc),
|
||||
extra_schedules: doc.extra_schedules || [],
|
||||
},
|
||||
]);
|
||||
} catch (error: any) {
|
||||
// gRPC 注册失败时回滚 DB 记录,避免产生"僵尸任务"
|
||||
// (DB 和 crontab.list 有记录但调度器永远不会执行)
|
||||
await CrontabModel.destroy({ where: { id: doc.id } });
|
||||
this.logger.error(
|
||||
'[crontab] Failed to register cron job in scheduler, task creation rolled back:',
|
||||
error?.message || error,
|
||||
);
|
||||
throw schedulerRegistrationError(
|
||||
`${t('调度器注册失败,任务创建已回滚')}: ${(error as any)?.details || error?.message}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.setCrontab();
|
||||
return doc;
|
||||
});
|
||||
}
|
||||
|
||||
public async insert(payload: Crontab): Promise<Crontab> {
|
||||
@@ -140,69 +149,74 @@ export default class CronService {
|
||||
}
|
||||
|
||||
public async update(payload: Partial<Crontab>): Promise<Crontab> {
|
||||
const doc = await this.getDb({ id: payload.id });
|
||||
const tab = new Crontab({ ...doc, ...payload });
|
||||
tab.saved = false;
|
||||
tab.log_name = await this.getLogName(tab);
|
||||
const newDoc = await this.updateDb(tab);
|
||||
return withSchedulerMutation(async () => {
|
||||
const doc = await this.getDb({ id: payload.id });
|
||||
const tab = new Crontab({ ...doc, ...payload });
|
||||
tab.saved = false;
|
||||
tab.log_name = await this.getLogName(tab);
|
||||
const newDoc = await this.updateDb(tab);
|
||||
|
||||
if (doc.isDisabled === 1 || isDemoEnv()) {
|
||||
return newDoc;
|
||||
}
|
||||
if (doc.isDisabled === 1 || isDemoEnv()) {
|
||||
return newDoc;
|
||||
}
|
||||
|
||||
try {
|
||||
await cronClient.delCron([String(newDoc.id)]);
|
||||
} catch (error: any) {
|
||||
this.logger.warn(
|
||||
'[crontab] Failed to unregister cron job in scheduler:',
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.shouldUseCronClient(newDoc)) {
|
||||
try {
|
||||
await cronClient.addCron([
|
||||
{
|
||||
name: doc.name || '',
|
||||
id: String(newDoc.id),
|
||||
schedule: newDoc.schedule!,
|
||||
command: this.makeCommand(newDoc),
|
||||
extra_schedules: newDoc.extra_schedules || [],
|
||||
},
|
||||
]);
|
||||
await cronClient.delCron([String(newDoc.id)]);
|
||||
} catch (error: any) {
|
||||
// gRPC 注册新任务失败 → 回滚 DB 到旧数据,并尝试恢复旧调度注册
|
||||
await CrontabModel.update(doc, { where: { id: doc.id } });
|
||||
if (this.shouldUseCronClient(doc)) {
|
||||
try {
|
||||
await cronClient.addCron([
|
||||
{
|
||||
name: doc.name || '',
|
||||
id: String(doc.id),
|
||||
schedule: doc.schedule!,
|
||||
command: this.makeCommand(doc),
|
||||
extra_schedules: doc.extra_schedules || [],
|
||||
},
|
||||
]);
|
||||
} catch (_recoveryError: any) {
|
||||
this.logger.warn(
|
||||
'[crontab] Failed to restore old cron job in scheduler after rollback:',
|
||||
_recoveryError?.message || _recoveryError,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.logger.error(
|
||||
'[crontab] Failed to register updated cron job in scheduler, update rolled back:',
|
||||
this.logger.warn(
|
||||
'[crontab] Failed to unregister cron job in scheduler:',
|
||||
error?.message || error,
|
||||
);
|
||||
throw new Error(
|
||||
`${t('调度器注册失败,任务更新已回滚')}: ${(error as any)?.details || error?.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.setCrontab();
|
||||
return newDoc;
|
||||
if (this.shouldUseCronClient(newDoc)) {
|
||||
try {
|
||||
await cronClient.addCron([
|
||||
{
|
||||
name: doc.name || '',
|
||||
id: String(newDoc.id),
|
||||
schedule: newDoc.schedule!,
|
||||
command: this.makeCommand(newDoc),
|
||||
extra_schedules: newDoc.extra_schedules || [],
|
||||
},
|
||||
]);
|
||||
} catch (error: any) {
|
||||
// gRPC 注册新任务失败 → 回滚 DB 到旧数据,并尝试恢复旧调度注册
|
||||
await CrontabModel.update(omit(doc, ['queued_token']), {
|
||||
where: { id: doc.id },
|
||||
});
|
||||
if (this.shouldUseCronClient(doc)) {
|
||||
try {
|
||||
await cronClient.addCron([
|
||||
{
|
||||
name: doc.name || '',
|
||||
id: String(doc.id),
|
||||
schedule: doc.schedule!,
|
||||
command: this.makeCommand(doc),
|
||||
extra_schedules: doc.extra_schedules || [],
|
||||
},
|
||||
]);
|
||||
} catch (_recoveryError: any) {
|
||||
this.logger.warn(
|
||||
'[crontab] Failed to restore old cron job in scheduler after rollback:',
|
||||
_recoveryError?.message || _recoveryError,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.logger.error(
|
||||
'[crontab] Failed to register updated cron job in scheduler, update rolled back:',
|
||||
error?.message || error,
|
||||
);
|
||||
throw schedulerRegistrationError(
|
||||
`${t('调度器注册失败,任务更新已回滚')}: ${(error as any)?.details || error?.message}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.setCrontab();
|
||||
return newDoc;
|
||||
});
|
||||
}
|
||||
|
||||
public async updateDb(payload: Crontab): Promise<Crontab> {
|
||||
@@ -290,16 +304,18 @@ export default class CronService {
|
||||
}
|
||||
|
||||
public async remove(ids: number[]) {
|
||||
await CrontabModel.destroy({ where: { id: ids } });
|
||||
try {
|
||||
await cronClient.delCron(ids.map(String));
|
||||
} catch (error: any) {
|
||||
this.logger.warn(
|
||||
'[crontab] Failed to unregister cron job in scheduler:',
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
await this.setCrontab();
|
||||
return withSchedulerMutation(async () => {
|
||||
await CrontabModel.destroy({ where: { id: ids } });
|
||||
try {
|
||||
await cronClient.delCron(ids.map(String));
|
||||
} catch (error: any) {
|
||||
this.logger.warn(
|
||||
'[crontab] Failed to unregister cron job in scheduler:',
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
await this.setCrontab();
|
||||
});
|
||||
}
|
||||
|
||||
public async pin(ids: number[]) {
|
||||
@@ -570,46 +586,99 @@ export default class CronService {
|
||||
}
|
||||
|
||||
public async run(ids: number[]) {
|
||||
const queuedToken = randomUUID();
|
||||
await CrontabModel.update(
|
||||
{ status: CrontabStatus.queued },
|
||||
{ status: CrontabStatus.queued, queued_token: queuedToken },
|
||||
{ where: { id: ids } },
|
||||
);
|
||||
ids.forEach((id) => {
|
||||
this.runSingle(id);
|
||||
this.runSingle(id, queuedToken);
|
||||
});
|
||||
}
|
||||
|
||||
public async stop(ids: number[]) {
|
||||
const docs = await CrontabModel.findAll({ where: { id: ids } });
|
||||
// Cancel the queued snapshot first, so a late spawn cannot claim it.
|
||||
for (const doc of docs) {
|
||||
// Kill all running instances of this task
|
||||
try {
|
||||
if (doc.pid) {
|
||||
await killTask(doc.pid);
|
||||
}
|
||||
const command = doc.command.replace(/\s+/g, ' ').trim();
|
||||
await killAllTasks(command);
|
||||
this.logger.info(
|
||||
`[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`,
|
||||
if (doc.status === CrontabStatus.queued) {
|
||||
const [cancelled] = await CrontabModel.update(
|
||||
{ status: CrontabStatus.idle, pid: null, queued_token: null } as any,
|
||||
{
|
||||
where: {
|
||||
id: doc.id,
|
||||
status: CrontabStatus.queued,
|
||||
[Op.and]: [
|
||||
where(colFn('log_path'), { [Op.eq]: doc.log_path ?? null }),
|
||||
where(colFn('queued_token'), {
|
||||
[Op.eq]: doc.queued_token ?? null,
|
||||
}),
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
// A concurrent claim may have won; capture its PID before signalling.
|
||||
if (!cancelled) await doc.reload();
|
||||
}
|
||||
}
|
||||
const stoppingInstances = await RunningInstanceModel.findAll({
|
||||
attributes: ['id', 'pid', 'cron_id'],
|
||||
where: { cron_id: ids, status: InstanceStatus.running },
|
||||
});
|
||||
const targets = new Set<number>(
|
||||
[
|
||||
...stoppingInstances.map((instance) => instance.pid),
|
||||
...docs
|
||||
.filter((doc) => doc.status === CrontabStatus.running)
|
||||
.map((doc) => doc.pid),
|
||||
].filter((pid): pid is number => typeof pid === 'number' && pid > 0)
|
||||
);
|
||||
const stopped = new Set<number>();
|
||||
for (const pid of targets) {
|
||||
try {
|
||||
await killTask(pid, true);
|
||||
stopped.add(pid);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[panel][停止任务失败] 任务ID: ${doc.id}, 错误: ${error}`,
|
||||
'[panel][停止任务失败] PID: %s, 错误: %s',
|
||||
pid,
|
||||
asError(error).message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark all running instances as stopped
|
||||
const finishedAt = dayjs().unix();
|
||||
await RunningInstanceModel.update(
|
||||
{ status: InstanceStatus.stopped, finished_at: finishedAt },
|
||||
{ where: { cron_id: ids, status: InstanceStatus.running } },
|
||||
);
|
||||
|
||||
await CrontabModel.update(
|
||||
{ status: CrontabStatus.idle, pid: undefined },
|
||||
{ where: { id: ids } },
|
||||
);
|
||||
const stoppedIds = stoppingInstances
|
||||
.filter((instance) => instance.pid && stopped.has(instance.pid))
|
||||
.map((instance) => instance.id!);
|
||||
if (stoppedIds.length) {
|
||||
await RunningInstanceModel.update(
|
||||
{ status: InstanceStatus.stopped, finished_at: dayjs().unix() },
|
||||
{ where: { id: stoppedIds } }
|
||||
);
|
||||
}
|
||||
for (const doc of docs) {
|
||||
if (
|
||||
doc.status !== CrontabStatus.running ||
|
||||
(doc.pid && !stopped.has(doc.pid))
|
||||
)
|
||||
continue;
|
||||
const remaining = await RunningInstanceModel.count({
|
||||
where: { cron_id: doc.id, status: InstanceStatus.running },
|
||||
});
|
||||
if (remaining) continue;
|
||||
await CrontabModel.update(
|
||||
{ status: CrontabStatus.idle, pid: null, queued_token: null } as any,
|
||||
{
|
||||
where: {
|
||||
id: doc.id,
|
||||
status: CrontabStatus.running,
|
||||
[Op.and]: [
|
||||
where(colFn('queued_token'), { [Op.eq]: doc.queued_token ?? null }),
|
||||
where(colFn('pid'), { [Op.eq]: doc.pid ?? null }),
|
||||
where(colFn('log_path'), { [Op.eq]: doc.log_path ?? null }),
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async stopInstance(instanceId: number) {
|
||||
@@ -646,125 +715,191 @@ export default class CronService {
|
||||
return { code: 200, message: t('实例已停止') };
|
||||
}
|
||||
|
||||
private async runSingle(cronId: number): Promise<number | void> {
|
||||
return taskLimit.manualRunWithCronLimit(() => {
|
||||
return new Promise(async (resolve: any) => {
|
||||
private async runSingle(
|
||||
cronId: number,
|
||||
expectedToken?: string,
|
||||
): Promise<number | void> {
|
||||
return taskLimit.manualRunWithCronLimit(async () => {
|
||||
let absolutePath: string | undefined;
|
||||
let logPath: string | undefined;
|
||||
let queuedLogPath: string | null | undefined;
|
||||
let queuedToken: string | null = null;
|
||||
let claimed = false;
|
||||
try {
|
||||
const cron = await this.getDb({ id: cronId });
|
||||
const params = {
|
||||
name: cron.name,
|
||||
command: cron.command,
|
||||
schedule: cron.schedule,
|
||||
extra_schedules: cron.extra_schedules,
|
||||
};
|
||||
if (cron.status !== CrontabStatus.queued) {
|
||||
resolve(params);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
`[panel][开始执行任务] 参数: ${JSON.stringify(params)}`,
|
||||
);
|
||||
|
||||
let { id, command, log_name } = cron;
|
||||
|
||||
if (
|
||||
cron.status !== CrontabStatus.queued ||
|
||||
(expectedToken !== undefined && cron.queued_token !== expectedToken)
|
||||
) return;
|
||||
queuedToken = cron.queued_token ?? null;
|
||||
queuedLogPath = cron.log_path ?? null;
|
||||
const { id, command, log_name } = cron;
|
||||
const uniqPath =
|
||||
log_name === '/dev/null' || !log_name
|
||||
? await getUniqPath(command, `${id}`)
|
||||
: log_name;
|
||||
const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS');
|
||||
const logDirPath = path.resolve(config.logPath, `${uniqPath}`);
|
||||
await fs.mkdir(logDirPath, { recursive: true });
|
||||
const logPath = `${uniqPath}/${logTime}.log`;
|
||||
const absolutePath = path.resolve(config.logPath, `${logPath}`);
|
||||
logPath = `${uniqPath}/${logTime}.log`;
|
||||
absolutePath = resolveFileAccess(config.logPath, [logPath]);
|
||||
if (!absolutePath)
|
||||
throw new Error('Log path is outside the log directory');
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
const outputPath = absolutePath;
|
||||
const cp = spawn(
|
||||
`real_log_path=${logPath} no_delay=true ${this.makeCommand(
|
||||
cron,
|
||||
true,
|
||||
true
|
||||
)}`,
|
||||
{ shell: '/bin/bash' },
|
||||
{ shell: '/bin/bash' }
|
||||
);
|
||||
|
||||
await CrontabModel.update(
|
||||
{ status: CrontabStatus.running, pid: cp.pid, log_path: logPath },
|
||||
{ where: { id } },
|
||||
);
|
||||
cp.stdout.on('data', async (data) => {
|
||||
await logStreamManager.write(absolutePath, data.toString());
|
||||
// Install observers before the first await: very short children may already exit.
|
||||
const { completed } = observeChildProcess(cp, {
|
||||
onStart: async () => {
|
||||
try {
|
||||
const [count] = await CrontabModel.update(
|
||||
{
|
||||
status: CrontabStatus.running,
|
||||
pid: cp.pid,
|
||||
log_path: logPath,
|
||||
},
|
||||
{
|
||||
where: {
|
||||
id,
|
||||
status: CrontabStatus.queued,
|
||||
[Op.and]: [
|
||||
where(colFn('queued_token'), { [Op.eq]: queuedToken }),
|
||||
where(colFn('log_path'), { [Op.eq]: queuedLogPath }),
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
if (count !== 1)
|
||||
throw new Error(
|
||||
'Task was stopped or superseded before startup'
|
||||
);
|
||||
claimed = true;
|
||||
} catch (error) {
|
||||
if (cp.pid) await killTask(cp.pid, true);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
onStdout: (message) => logStreamManager.write(outputPath, message),
|
||||
onStderr: (message) => logStreamManager.write(outputPath, message),
|
||||
});
|
||||
cp.stderr.on('data', async (data) => {
|
||||
this.logger.info(
|
||||
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
|
||||
command,
|
||||
data.toString(),
|
||||
);
|
||||
await logStreamManager.write(absolutePath, data.toString());
|
||||
});
|
||||
cp.on('error', async (err) => {
|
||||
const result = await completed;
|
||||
if (result.error) {
|
||||
this.logger.error(
|
||||
'[panel][创建任务失败] 命令: %s, 错误信息: %j',
|
||||
command,
|
||||
err,
|
||||
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
|
||||
id,
|
||||
result.error.message
|
||||
);
|
||||
await logStreamManager.write(absolutePath, JSON.stringify(err));
|
||||
});
|
||||
|
||||
cp.on('exit', async (code) => {
|
||||
this.logger.info(
|
||||
'[panel][执行任务结束] 参数: %s, 退出码: %j',
|
||||
JSON.stringify(params),
|
||||
code,
|
||||
}
|
||||
this.logger.info(
|
||||
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
|
||||
id,
|
||||
result.code
|
||||
);
|
||||
return { ...cron, pid: cp.pid, ...result } as any;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'[panel][创建任务失败] 任务ID: %s, 错误: %s',
|
||||
cronId,
|
||||
asError(error).message
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
if (absolutePath) await logStreamManager.closeStream(absolutePath);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'[panel][关闭任务日志失败] %s',
|
||||
asError(error).message
|
||||
);
|
||||
await logStreamManager.closeStream(absolutePath);
|
||||
resolve({ ...params, pid: cp.pid, code });
|
||||
});
|
||||
});
|
||||
}
|
||||
try {
|
||||
// Do not overwrite a newer run's state or its script-reported exit code.
|
||||
await CrontabModel.update(
|
||||
{ status: CrontabStatus.idle, pid: null, queued_token: null } as any,
|
||||
{
|
||||
where: {
|
||||
id: cronId,
|
||||
[Op.and]: where(colFn('queued_token'), { [Op.eq]: queuedToken }),
|
||||
[Op.or]: [
|
||||
...(queuedLogPath !== undefined && !claimed
|
||||
? [
|
||||
{
|
||||
status: CrontabStatus.queued,
|
||||
[Op.and]: where(colFn('log_path'), {
|
||||
[Op.eq]: queuedLogPath,
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(claimed && logPath
|
||||
? [{ log_path: logPath, status: CrontabStatus.running }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'[panel][清理任务状态失败] %s',
|
||||
asError(error).message
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async disabled(ids: number[]) {
|
||||
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
|
||||
try {
|
||||
await cronClient.delCron(ids.map(String));
|
||||
} catch (error: any) {
|
||||
this.logger.warn(
|
||||
'[crontab] Failed to unregister cron job in scheduler:',
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
await this.setCrontab();
|
||||
return withSchedulerMutation(async () => {
|
||||
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
|
||||
try {
|
||||
await cronClient.delCron(ids.map(String));
|
||||
} catch (error: any) {
|
||||
this.logger.warn(
|
||||
'[crontab] Failed to unregister cron job in scheduler:',
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
await this.setCrontab();
|
||||
});
|
||||
}
|
||||
|
||||
public async enabled(ids: number[]) {
|
||||
await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
|
||||
const docs = await CrontabModel.findAll({ where: { id: ids } });
|
||||
const crons = docs
|
||||
.filter((x) => this.shouldUseCronClient(x))
|
||||
.map((doc) => ({
|
||||
name: doc.name || '',
|
||||
id: String(doc.id),
|
||||
schedule: doc.schedule!,
|
||||
command: this.makeCommand(doc),
|
||||
extra_schedules: doc.extra_schedules || [],
|
||||
}));
|
||||
return withSchedulerMutation(async () => {
|
||||
await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } });
|
||||
const docs = await CrontabModel.findAll({ where: { id: ids } });
|
||||
const crons = docs
|
||||
.filter((x) => this.shouldUseCronClient(x))
|
||||
.map((doc) => ({
|
||||
name: doc.name || '',
|
||||
id: String(doc.id),
|
||||
schedule: doc.schedule!,
|
||||
command: this.makeCommand(doc),
|
||||
extra_schedules: doc.extra_schedules || [],
|
||||
}));
|
||||
|
||||
if (isDemoEnv()) {
|
||||
return;
|
||||
}
|
||||
if (isDemoEnv()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await cronClient.addCron(crons);
|
||||
} catch (error: any) {
|
||||
// gRPC 注册失败 → 回滚启用状态,避免 DB 显示已启用但调度器未注册
|
||||
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
|
||||
this.logger.error(
|
||||
'[crontab] Failed to register cron job in scheduler, enable rolled back:',
|
||||
error?.message || error,
|
||||
);
|
||||
throw new Error(
|
||||
`${t('调度器注册失败,任务启用已回滚')}: ${(error as any)?.details || error?.message}`,
|
||||
);
|
||||
}
|
||||
await this.setCrontab();
|
||||
try {
|
||||
await cronClient.addCron(crons);
|
||||
} catch (error: any) {
|
||||
// gRPC 注册失败 → 回滚启用状态,避免 DB 显示已启用但调度器未注册
|
||||
await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } });
|
||||
this.logger.error(
|
||||
'[crontab] Failed to register cron job in scheduler, enable rolled back:',
|
||||
error?.message || error,
|
||||
);
|
||||
throw schedulerRegistrationError(
|
||||
`${t('调度器注册失败,任务启用已回滚')}: ${(error as any)?.details || error?.message}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
await this.setCrontab();
|
||||
});
|
||||
}
|
||||
|
||||
public async log(
|
||||
@@ -957,39 +1092,42 @@ export default class CronService {
|
||||
});
|
||||
}
|
||||
|
||||
public async autosave_crontab() {
|
||||
const tabs = await this.crontabs();
|
||||
const regularCrons = tabs.data
|
||||
.filter(
|
||||
(x) =>
|
||||
x.isDisabled !== 1 &&
|
||||
this.shouldUseCronClient(x),
|
||||
)
|
||||
.map((doc) => ({
|
||||
name: doc.name || '',
|
||||
id: String(doc.id),
|
||||
schedule: doc.schedule!,
|
||||
command: this.makeCommand(doc),
|
||||
extra_schedules: doc.extra_schedules || [],
|
||||
}));
|
||||
public async autosave_crontab(requireScheduler = false) {
|
||||
return withSchedulerMutation(async () => {
|
||||
const tabs = await this.crontabs();
|
||||
const regularCrons = tabs.data
|
||||
.filter(
|
||||
(x) =>
|
||||
x.isDisabled !== 1 &&
|
||||
this.shouldUseCronClient(x),
|
||||
)
|
||||
.map((doc) => ({
|
||||
name: doc.name || '',
|
||||
id: String(doc.id),
|
||||
schedule: doc.schedule!,
|
||||
command: this.makeCommand(doc),
|
||||
extra_schedules: doc.extra_schedules || [],
|
||||
}));
|
||||
|
||||
if (isDemoEnv()) {
|
||||
await writeFileWithLock(config.crontabFile, '');
|
||||
return;
|
||||
}
|
||||
if (isDemoEnv()) {
|
||||
await writeFileWithLock(config.crontabFile, '');
|
||||
return;
|
||||
}
|
||||
|
||||
// 先同步 crontab.list 与系统 crontab,确保其始终反映数据库真实状态。
|
||||
// gRPC 调度注册为尽力而为:失败时不阻断文件同步,调度器重启后会重新注册。
|
||||
// 这避免了因调度器短暂不可用导致 crontab.list 与数据库脱节(订阅更新误判任务已存在)。
|
||||
await this.setCrontab(tabs);
|
||||
try {
|
||||
await cronClient.addCron(regularCrons);
|
||||
} catch (error: any) {
|
||||
this.logger.warn(
|
||||
'[crontab] Failed to register cron job in scheduler:',
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
// 先同步 crontab.list 与系统 crontab,确保其始终反映数据库真实状态。
|
||||
// gRPC 调度注册为尽力而为:失败时不阻断文件同步,调度器重启后会重新注册。
|
||||
// 这避免了因调度器短暂不可用导致 crontab.list 与数据库脱节(订阅更新误判任务已存在)。
|
||||
await this.setCrontab(tabs);
|
||||
try {
|
||||
await cronClient.addCron(regularCrons, requireScheduler);
|
||||
} catch (error: any) {
|
||||
this.logger.warn(
|
||||
'[crontab] Failed to register cron job in scheduler:',
|
||||
error?.message || error,
|
||||
);
|
||||
if (requireScheduler) throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async bootTask() {
|
||||
@@ -998,13 +1136,7 @@ export default class CronService {
|
||||
(x) => !x.isDisabled && this.isBootSchedule(x.schedule),
|
||||
);
|
||||
if (bootTasks.length > 0) {
|
||||
await CrontabModel.update(
|
||||
{ status: CrontabStatus.queued },
|
||||
{ where: { id: bootTasks.map((t) => t.id!) } },
|
||||
);
|
||||
for (const task of bootTasks) {
|
||||
this.runSingle(task.id!);
|
||||
}
|
||||
await this.run(bootTasks.map((task) => task.id!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Service } from 'typedi';
|
||||
import Logger from '../loaders/logger';
|
||||
import { GrpcServerService } from './grpc';
|
||||
import cronClient from '../schedule/client';
|
||||
import { HttpServerService } from './http';
|
||||
|
||||
interface HealthStatus {
|
||||
@@ -23,7 +23,6 @@ export class HealthService {
|
||||
private startTime = Date.now();
|
||||
|
||||
constructor(
|
||||
private grpcServerService: GrpcServerService,
|
||||
private httpServerService: HttpServerService,
|
||||
) {}
|
||||
|
||||
@@ -56,8 +55,7 @@ export class HealthService {
|
||||
}
|
||||
|
||||
try {
|
||||
const grpcServer = this.grpcServerService.getServer();
|
||||
if (!grpcServer) {
|
||||
if (!(await cronClient.readiness.check())) {
|
||||
status.services.grpc = false;
|
||||
status.status = 'error';
|
||||
}
|
||||
|
||||
@@ -39,7 +39,13 @@ export class HttpServerService {
|
||||
|
||||
private async tryListen(expressApp: express.Application, port: number, host: string): Promise<Server> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = expressApp.listen(port, host, () => {
|
||||
// There is one HTTP worker; accepting here avoids primary IPC handoff
|
||||
// for every connection. Restore shared listening for custom clusters.
|
||||
const server = expressApp.listen({
|
||||
port,
|
||||
host,
|
||||
exclusive: process.env.QL_HTTP_SHARED_LISTEN !== 'true',
|
||||
}, () => {
|
||||
resolve(server);
|
||||
});
|
||||
|
||||
|
||||
+80
-67
@@ -11,6 +11,7 @@ import {
|
||||
import dayjs from 'dayjs';
|
||||
import taskLimit from '../shared/pLimit';
|
||||
import { spawn } from 'cross-spawn';
|
||||
import { observeChildProcess, asError, ProcessResult } from '../shared/childProcess';
|
||||
|
||||
export interface ScheduleTaskType {
|
||||
id?: number;
|
||||
@@ -27,7 +28,7 @@ export interface TaskCallbacks {
|
||||
startTime: dayjs.Dayjs,
|
||||
) => Promise<void>;
|
||||
onEnd?: (
|
||||
cp: ChildProcessWithoutNullStreams,
|
||||
cp: ChildProcessWithoutNullStreams | undefined,
|
||||
endTime: dayjs.Dayjs,
|
||||
diff: number,
|
||||
) => Promise<void>;
|
||||
@@ -63,73 +64,85 @@ export default class ScheduleService {
|
||||
) {
|
||||
const { runOrigin, ...others } = params;
|
||||
|
||||
return taskLimit[this.taskLimitMap[runOrigin]](others, () => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
this.logger.info(
|
||||
`[panel][开始执行任务] 参数: ${JSON.stringify({
|
||||
...others,
|
||||
command,
|
||||
})}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const startTime = dayjs();
|
||||
await callbacks.onBefore?.(startTime);
|
||||
|
||||
const cp = spawn(command, { shell: '/bin/bash' });
|
||||
|
||||
callbacks.onStart?.(cp, startTime);
|
||||
completionTime === 'start' && resolve(cp.pid);
|
||||
|
||||
cp.stdout.on('data', async (data) => {
|
||||
await callbacks.onLog?.(data.toString());
|
||||
});
|
||||
|
||||
cp.stderr.on('data', async (data) => {
|
||||
this.logger.info(
|
||||
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
|
||||
command,
|
||||
data.toString(),
|
||||
);
|
||||
await callbacks.onError?.(data.toString());
|
||||
});
|
||||
|
||||
cp.on('error', async (err) => {
|
||||
this.logger.error(
|
||||
'[panel][创建任务失败] 命令: %s, 错误信息: %j',
|
||||
command,
|
||||
err,
|
||||
);
|
||||
await callbacks.onError?.(JSON.stringify(err));
|
||||
});
|
||||
|
||||
cp.on('exit', async (code) => {
|
||||
this.logger.info(
|
||||
'[panel][执行任务结束] 参数: %s, 退出码: %j',
|
||||
JSON.stringify({
|
||||
...others,
|
||||
command,
|
||||
}),
|
||||
code,
|
||||
);
|
||||
const endTime = dayjs();
|
||||
await callbacks.onEnd?.(
|
||||
cp,
|
||||
endTime,
|
||||
endTime.diff(startTime, 'seconds'),
|
||||
);
|
||||
resolve({ ...others, pid: cp.pid, code });
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'[panel][执行任务失败] 命令: %s, 错误信息: %j',
|
||||
command,
|
||||
error,
|
||||
);
|
||||
await callbacks.onError?.(JSON.stringify(error));
|
||||
}
|
||||
});
|
||||
let resolveStart!: (pid: number | undefined) => void;
|
||||
let rejectStart!: (error: Error) => void;
|
||||
const startResult = new Promise<number | undefined>((resolve, reject) => {
|
||||
resolveStart = resolve;
|
||||
rejectStart = reject;
|
||||
});
|
||||
// Most scheduled callers only observe completion (or intentionally detach).
|
||||
void startResult.catch(() => {});
|
||||
const completion = taskLimit[this.taskLimitMap[runOrigin]](
|
||||
others,
|
||||
async () => {
|
||||
const startTime = dayjs();
|
||||
let cp: ChildProcessWithoutNullStreams | undefined;
|
||||
let result: ProcessResult = { code: null, signal: null };
|
||||
try {
|
||||
this.logger.info('[panel][开始执行任务] 任务ID: %s', others.id);
|
||||
await callbacks.onBefore?.(startTime);
|
||||
cp = spawn(command, { shell: '/bin/bash' });
|
||||
const child = cp;
|
||||
const observed = observeChildProcess(child, {
|
||||
onStart: async () => {
|
||||
await callbacks.onStart?.(child, startTime);
|
||||
},
|
||||
onStdout: callbacks.onLog,
|
||||
onStderr: callbacks.onError,
|
||||
});
|
||||
observed.started.then(resolveStart, rejectStart);
|
||||
result = await observed.completed;
|
||||
} catch (error) {
|
||||
result.error = asError(error);
|
||||
rejectStart(result.error);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
this.logger.error(
|
||||
'[panel][执行任务失败] 任务ID: %s, 错误: %s',
|
||||
others.id,
|
||||
result.error.message,
|
||||
);
|
||||
try {
|
||||
await callbacks.onError?.(result.error.message);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'[panel][任务错误回调失败] %s',
|
||||
asError(error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Cleanup also runs after setup/spawn failure, and only after both pipes drain.
|
||||
const endTime = dayjs();
|
||||
try {
|
||||
await callbacks.onEnd?.(
|
||||
cp,
|
||||
endTime,
|
||||
endTime.diff(startTime, 'seconds'),
|
||||
);
|
||||
} catch (error) {
|
||||
result.error ??= asError(error);
|
||||
this.logger.error(
|
||||
'[panel][任务结束回调失败] %s',
|
||||
asError(error).message,
|
||||
);
|
||||
}
|
||||
this.logger.info(
|
||||
'[panel][执行任务结束] 任务ID: %s, 退出码: %j',
|
||||
others.id,
|
||||
result.code,
|
||||
);
|
||||
return { ...others, pid: cp?.pid, ...result };
|
||||
},
|
||||
).catch((error) => {
|
||||
// Queue/setup failures must not become unhandled rejections in detached callers.
|
||||
rejectStart(asError(error));
|
||||
this.logger.error('[panel][任务队列失败] %s', asError(error).message);
|
||||
return { ...others, code: null, signal: null, error: asError(error) };
|
||||
});
|
||||
|
||||
// Returning a PID must not release the execution slot while the process runs.
|
||||
return completionTime === 'start' ? startResult : completion;
|
||||
}
|
||||
|
||||
async createCronTask(
|
||||
|
||||
@@ -159,48 +159,54 @@ export default class SubscriptionService {
|
||||
);
|
||||
},
|
||||
onEnd: async (cp, endTime, diff) => {
|
||||
const sub = await this.getDb({ id: doc.id });
|
||||
const absolutePath = await handleLogPath(sub.log_path as string);
|
||||
|
||||
// 执行 sub_after
|
||||
let afterStr = '';
|
||||
let absolutePath: string | undefined;
|
||||
try {
|
||||
if (sub.sub_after) {
|
||||
await logStreamManager.write(absolutePath, `\n\n## ${t('执行after命令...')}\n\n`);
|
||||
afterStr = await promiseExec(sub.sub_after);
|
||||
const sub = await this.getDb({ id: doc.id });
|
||||
absolutePath = await handleLogPath(sub.log_path as string);
|
||||
|
||||
// 执行 sub_after
|
||||
let afterStr = '';
|
||||
try {
|
||||
if (sub.sub_after) {
|
||||
await logStreamManager.write(
|
||||
absolutePath,
|
||||
`\n\n## ${t('执行after命令...')}\n\n`,
|
||||
);
|
||||
afterStr = await promiseExec(sub.sub_after);
|
||||
}
|
||||
} catch (error: any) {
|
||||
afterStr =
|
||||
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
|
||||
}
|
||||
if (afterStr) {
|
||||
await logStreamManager.write(absolutePath, `${afterStr}\n`);
|
||||
}
|
||||
|
||||
await logStreamManager.write(
|
||||
absolutePath,
|
||||
'\n' +
|
||||
tf(
|
||||
'## 执行结束... %s 耗时 %s 秒',
|
||||
endTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||
String(diff),
|
||||
) +
|
||||
LOG_END_SYMBOL,
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
if (absolutePath) await logStreamManager.closeStream(absolutePath);
|
||||
} finally {
|
||||
await SubscriptionModel.update(
|
||||
{ status: SubscriptionStatus.idle, pid: null } as any,
|
||||
{ where: { id: doc.id } },
|
||||
);
|
||||
this.sockService.sendMessage({
|
||||
type: 'runSubscriptionEnd',
|
||||
message: t('订阅执行完成'),
|
||||
references: [doc.id as number],
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
afterStr =
|
||||
(error.stderr && error.stderr.toString()) || JSON.stringify(error);
|
||||
}
|
||||
if (afterStr) {
|
||||
await logStreamManager.write(absolutePath, `${afterStr}\n`);
|
||||
}
|
||||
|
||||
await logStreamManager.write(
|
||||
absolutePath,
|
||||
'\n' +
|
||||
tf(
|
||||
'## 执行结束... %s 耗时 %s 秒',
|
||||
endTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||
String(diff),
|
||||
) +
|
||||
LOG_END_SYMBOL,
|
||||
);
|
||||
|
||||
// Close the stream after task completion
|
||||
await logStreamManager.closeStream(absolutePath);
|
||||
|
||||
await SubscriptionModel.update(
|
||||
{ status: SubscriptionStatus.idle, pid: undefined },
|
||||
{ where: { id: sub.id } },
|
||||
);
|
||||
|
||||
this.sockService.sendMessage({
|
||||
type: 'runSubscriptionEnd',
|
||||
message: t('订阅执行完成'),
|
||||
references: [doc.id as number],
|
||||
});
|
||||
},
|
||||
onError: async (message: string) => {
|
||||
const sub = await this.getDb({ id: doc.id });
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ChildProcessWithoutNullStreams } from 'child_process';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export interface ProcessResult {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export function asError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
/** Attach immediately after spawn, before awaiting database or user callbacks. */
|
||||
export function observeChildProcess(
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
callbacks: {
|
||||
onStart?: () => Promise<void>;
|
||||
onStdout?: (message: string) => Promise<void>;
|
||||
onStderr?: (message: string) => Promise<void>;
|
||||
} = {},
|
||||
) {
|
||||
let failure: Error | undefined;
|
||||
const recordError = (error: unknown) => {
|
||||
failure ??= asError(error);
|
||||
};
|
||||
const spawned = new Promise<void>((resolve, reject) => {
|
||||
child.once('spawn', resolve);
|
||||
// Keep the listener through close: errors can occur after a successful spawn.
|
||||
child.on('error', (error) => {
|
||||
recordError(error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
const closed = new Promise<ProcessResult>((resolve) => {
|
||||
child.once('close', (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
const started = spawned.then(async () => {
|
||||
await callbacks.onStart?.();
|
||||
return child.pid;
|
||||
});
|
||||
// The caller can ask only for completion, without an unhandled start rejection.
|
||||
const ready = started.catch(recordError);
|
||||
|
||||
const consume = async (
|
||||
stream: Readable,
|
||||
callback?: (message: string) => Promise<void>,
|
||||
) => {
|
||||
// StringDecoder in Readable preserves UTF-8 characters split across chunks.
|
||||
stream.setEncoding('utf8');
|
||||
let callbackFailed = false;
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
await ready;
|
||||
if (!callbackFailed && callback) {
|
||||
try {
|
||||
await callback(String(chunk));
|
||||
} catch (error) {
|
||||
recordError(error);
|
||||
callbackFailed = true;
|
||||
}
|
||||
}
|
||||
// Even if a log sink fails, drain the pipe so the child can finish.
|
||||
}
|
||||
} catch (error) {
|
||||
recordError(error);
|
||||
}
|
||||
};
|
||||
const output = Promise.all([
|
||||
consume(child.stdout, callbacks.onStdout),
|
||||
consume(child.stderr, callbacks.onStderr),
|
||||
]);
|
||||
const completed = Promise.all([closed, ready, output]).then(([result]) => ({
|
||||
...result,
|
||||
error: failure,
|
||||
}));
|
||||
return { started, completed };
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createWriteStream, WriteStream } from 'fs';
|
||||
import { EventEmitter } from 'events';
|
||||
import path from 'path';
|
||||
import config from '../config';
|
||||
import { resolveFileAccess } from './fileAccess';
|
||||
|
||||
/**
|
||||
* Manages write streams for log files to improve performance by avoiding repeated file opens
|
||||
@@ -8,83 +11,90 @@ export class LogStreamManager extends EventEmitter {
|
||||
private streams: Map<string, WriteStream> = new Map();
|
||||
private pendingWrites: Map<string, Promise<void>> = new Map();
|
||||
|
||||
/**
|
||||
* Write data to a log file using a managed stream
|
||||
* @param filePath - Absolute path to the log file
|
||||
* @param data - Data to write to the log file
|
||||
*/
|
||||
async write(filePath: string, data: string): Promise<void> {
|
||||
// Wait for any pending writes to this file to complete
|
||||
const pending = this.pendingWrites.get(filePath);
|
||||
if (pending) {
|
||||
await pending;
|
||||
}
|
||||
private closingStreams = new Map<string, Promise<void>>();
|
||||
private closedStreams = new WeakSet<WriteStream>();
|
||||
private streamErrors = new Map<string, Error>();
|
||||
|
||||
// Create a new promise for this write operation
|
||||
const writePromise = new Promise<void>((resolve, reject) => {
|
||||
let stream = this.streams.get(filePath);
|
||||
|
||||
if (!stream) {
|
||||
// Create a new write stream if one doesn't exist
|
||||
stream = createWriteStream(filePath, { flags: 'a' });
|
||||
this.streams.set(filePath, stream);
|
||||
|
||||
// Handle stream errors
|
||||
stream.on('error', (error) => {
|
||||
this.emit('error', { filePath, error });
|
||||
// Remove the stream from the map on error
|
||||
this.streams.delete(filePath);
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
|
||||
// Write the data
|
||||
const canContinue = stream.write(data, 'utf8', (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle backpressure
|
||||
if (!canContinue) {
|
||||
stream.once('drain', () => {
|
||||
// Stream is ready for more data
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.pendingWrites.set(filePath, writePromise);
|
||||
|
||||
try {
|
||||
await writePromise;
|
||||
} finally {
|
||||
this.pendingWrites.delete(filePath);
|
||||
}
|
||||
constructor(private readonly logRoot = config.logPath) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the stream for a specific file path
|
||||
* @param filePath - Absolute path to the log file
|
||||
*/
|
||||
async closeStream(filePath: string): Promise<void> {
|
||||
// Wait for any pending writes to complete
|
||||
const pending = this.pendingWrites.get(filePath);
|
||||
if (pending) {
|
||||
await pending.catch(() => {
|
||||
// Ignore errors on pending writes during close
|
||||
});
|
||||
/** Register each write synchronously, so concurrent callers cannot lose the tail. */
|
||||
async write(filePath: string, data: string): Promise<void> {
|
||||
if (this.closingStreams.has(filePath)) {
|
||||
throw new Error(`Log stream is closing: ${filePath}`);
|
||||
}
|
||||
const previous = this.pendingWrites.get(filePath) || Promise.resolve();
|
||||
const pending = previous.then(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const failure = this.streamErrors.get(filePath);
|
||||
if (failure) return reject(failure);
|
||||
let stream = this.streams.get(filePath);
|
||||
if (!stream) {
|
||||
// Validate only when opening: subsequent chunks reuse the same descriptor.
|
||||
const root = path.resolve(this.logRoot);
|
||||
const target = path.resolve(filePath);
|
||||
if (
|
||||
!target.startsWith(root + path.sep) ||
|
||||
!resolveFileAccess(root, [target])
|
||||
) {
|
||||
return reject(new Error('Log path is outside the log directory'));
|
||||
}
|
||||
stream = createWriteStream(target, { flags: 'a' });
|
||||
this.streams.set(filePath, stream);
|
||||
const current = stream;
|
||||
stream.once('close', () => this.closedStreams.add(current));
|
||||
stream.on('error', (error) => {
|
||||
this.streamErrors.set(filePath, error);
|
||||
// EventEmitter's unobserved "error" event would crash the caller.
|
||||
if (this.listenerCount('error') > 0)
|
||||
this.emit('error', { filePath, error });
|
||||
});
|
||||
}
|
||||
stream.write(data, 'utf8', (error) =>
|
||||
error ? reject(error) : resolve(),
|
||||
);
|
||||
}),
|
||||
);
|
||||
this.pendingWrites.set(filePath, pending);
|
||||
// Keep the tail until close, including failures; never reopen a failed log mid-run.
|
||||
return pending;
|
||||
}
|
||||
|
||||
const stream = this.streams.get(filePath);
|
||||
if (stream) {
|
||||
return new Promise<void>((resolve) => {
|
||||
stream.end(() => {
|
||||
this.streams.delete(filePath);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
async closeStream(filePath: string): Promise<void> {
|
||||
const closing = this.closingStreams.get(filePath);
|
||||
if (closing) return closing;
|
||||
const pending = this.pendingWrites.get(filePath);
|
||||
const result = (async () => {
|
||||
let failure: unknown;
|
||||
try {
|
||||
await pending;
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
const stream = this.streams.get(filePath);
|
||||
try {
|
||||
if (stream && !this.closedStreams.has(stream)) {
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.once('close', resolve);
|
||||
if (failure || stream.destroyed) stream.destroy();
|
||||
else stream.end();
|
||||
});
|
||||
}
|
||||
failure ||= this.streamErrors.get(filePath);
|
||||
if (failure) throw failure;
|
||||
} finally {
|
||||
this.streams.delete(filePath);
|
||||
this.pendingWrites.delete(filePath);
|
||||
this.streamErrors.delete(filePath);
|
||||
}
|
||||
})();
|
||||
this.closingStreams.set(filePath, result);
|
||||
try {
|
||||
await result;
|
||||
} finally {
|
||||
this.closingStreams.delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +102,11 @@ export class LogStreamManager extends EventEmitter {
|
||||
* Close all open streams
|
||||
*/
|
||||
async closeAll(): Promise<void> {
|
||||
const closePromises = Array.from(this.streams.keys()).map((filePath) =>
|
||||
const paths = new Set([
|
||||
...this.streams.keys(),
|
||||
...this.pendingWrites.keys(),
|
||||
]);
|
||||
const closePromises = Array.from(paths).map((filePath) =>
|
||||
this.closeStream(filePath),
|
||||
);
|
||||
await Promise.all(closePromises);
|
||||
|
||||
+38
-31
@@ -4,15 +4,13 @@ import Logger from '../loaders/logger';
|
||||
import { ICron } from '../protos/cron';
|
||||
import { CrontabModel, CrontabStatus } from '../data/cron';
|
||||
import { killTask } from '../config/util';
|
||||
import {
|
||||
RunningInstanceModel,
|
||||
InstanceStatus,
|
||||
} from '../data/runningInstance';
|
||||
import { RunningInstanceModel, InstanceStatus } from '../data/runningInstance';
|
||||
import dayjs from 'dayjs';
|
||||
import { observeChildProcess, asError } from './childProcess';
|
||||
|
||||
export function runCron(cmd: string, cron: ICron): Promise<number | void> {
|
||||
return taskLimit.runWithCronLimit(cron, () => {
|
||||
return new Promise(async (resolve: any) => {
|
||||
return taskLimit.runWithCronLimit(cron, async () => {
|
||||
try {
|
||||
// Check if the cron is already running and stop it (only if multiple instances are not allowed)
|
||||
try {
|
||||
const existingCron = await CrontabModel.findOne({
|
||||
@@ -38,7 +36,12 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
|
||||
const stoppedAt = dayjs().unix();
|
||||
await RunningInstanceModel.update(
|
||||
{ status: InstanceStatus.stopped, finished_at: stoppedAt },
|
||||
{ where: { cron_id: Number(cron.id), status: InstanceStatus.running } },
|
||||
{
|
||||
where: {
|
||||
cron_id: Number(cron.id),
|
||||
status: InstanceStatus.running,
|
||||
},
|
||||
},
|
||||
);
|
||||
// Update the status to idle after killing
|
||||
await CrontabModel.update(
|
||||
@@ -60,33 +63,37 @@ export function runCron(cmd: string, cron: ICron): Promise<number | void> {
|
||||
);
|
||||
const cp = spawn(cmd, { shell: '/bin/bash' });
|
||||
|
||||
cp.stderr.on('data', (data) => {
|
||||
Logger.info(
|
||||
'[schedule][执行任务失败] 命令: %s, 错误信息: %j',
|
||||
cmd,
|
||||
data.toString(),
|
||||
);
|
||||
const { completed } = observeChildProcess(cp, {
|
||||
onStderr: async (message) => {
|
||||
Logger.info(
|
||||
'[schedule][任务标准错误] 命令: %s, 信息: %s',
|
||||
cmd,
|
||||
message,
|
||||
);
|
||||
},
|
||||
});
|
||||
cp.on('error', (err) => {
|
||||
const result = await completed;
|
||||
if (result.error) {
|
||||
Logger.error(
|
||||
'[schedule][创建任务失败] 命令: %s, 错误信息: %j',
|
||||
'[schedule][执行任务失败] 命令: %s, 错误: %s',
|
||||
cmd,
|
||||
err,
|
||||
result.error.message,
|
||||
);
|
||||
});
|
||||
|
||||
cp.on('exit', async (code) => {
|
||||
taskLimit.removeQueuedCron(cron.id);
|
||||
Logger.info(
|
||||
'[schedule][执行任务结束] 参数: %s, 退出码: %j',
|
||||
JSON.stringify({
|
||||
...cron,
|
||||
command: cmd,
|
||||
}),
|
||||
code,
|
||||
);
|
||||
resolve({ ...cron, command: cmd, pid: cp.pid, code });
|
||||
});
|
||||
});
|
||||
}
|
||||
Logger.info(
|
||||
'[schedule][执行任务结束] 任务ID: %s, 退出码: %j',
|
||||
cron.id,
|
||||
result.code,
|
||||
);
|
||||
return { ...cron, command: cmd, pid: cp.pid, ...result } as any;
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
'[schedule][创建任务失败] 命令: %s, 错误: %s',
|
||||
cmd,
|
||||
asError(error).message,
|
||||
);
|
||||
} finally {
|
||||
taskLimit.removeQueuedCron(cron.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import lockfile from 'proper-lockfile';
|
||||
import config from '../config';
|
||||
|
||||
// HTTP and gRPC both mutate cron definitions. Hold one shared lock from the
|
||||
// initial DB read/write through scheduler registration (including rollback).
|
||||
// Recovery takes the same lock before reading its replacement snapshot.
|
||||
export async function withSchedulerMutation<T>(
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
let release: () => Promise<void>;
|
||||
try {
|
||||
release = await lockfile.lock(config.crontabFile, {
|
||||
realpath: false,
|
||||
lockfilePath: `${config.crontabFile}.scheduler.lock`,
|
||||
stale: 30000,
|
||||
update: 10000,
|
||||
retries: { retries: 50, factor: 1, minTimeout: 100, maxTimeout: 100 },
|
||||
});
|
||||
} catch (cause) {
|
||||
throw Object.assign(
|
||||
new Error('Scheduler configuration is busy', { cause }),
|
||||
{
|
||||
status: 503,
|
||||
},
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
export function schedulerRegistrationError(message: string, cause: any): Error {
|
||||
return Object.assign(new Error(message, { cause }), {
|
||||
status: cause?.status === 503 ? 503 : 500,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Recovery is single-flight and retried only while unavailable, never at idle.
|
||||
export class SchedulerReadiness {
|
||||
private ready = false;
|
||||
private generation = 0;
|
||||
private restore?: () => Promise<void>;
|
||||
private pending?: Promise<boolean>;
|
||||
private retry?: NodeJS.Timeout;
|
||||
|
||||
constructor(private probe: () => Promise<void>, private retryMs = 1000) {}
|
||||
|
||||
configure(restore: () => Promise<void>) {
|
||||
this.restore = restore;
|
||||
}
|
||||
|
||||
invalidate() {
|
||||
this.ready = false;
|
||||
this.generation++;
|
||||
void this.recover();
|
||||
}
|
||||
|
||||
recover(): Promise<boolean> {
|
||||
if (this.pending) return this.pending;
|
||||
if (!this.restore) return Promise.resolve(false);
|
||||
clearTimeout(this.retry);
|
||||
const generation = this.generation;
|
||||
this.ready = false;
|
||||
this.pending = (async () => {
|
||||
try {
|
||||
await this.probe();
|
||||
await this.restore!();
|
||||
await this.probe();
|
||||
this.ready = generation === this.generation;
|
||||
} catch {
|
||||
this.ready = false;
|
||||
}
|
||||
return this.ready;
|
||||
})().finally(() => {
|
||||
this.pending = undefined;
|
||||
if (!this.ready) {
|
||||
this.retry = setTimeout(() => void this.recover(), this.retryMs);
|
||||
this.retry.unref();
|
||||
}
|
||||
});
|
||||
return this.pending;
|
||||
}
|
||||
|
||||
async check(): Promise<boolean> {
|
||||
if (!this.ready) return false;
|
||||
const generation = this.generation;
|
||||
try {
|
||||
await this.probe();
|
||||
return this.ready && generation === this.generation;
|
||||
} catch {
|
||||
this.invalidate();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureReady(timeoutMs = 2000): Promise<void> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
const available = await Promise.race([
|
||||
(async () => (await this.check()) || (await this.recover()))(),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
if (!available) {
|
||||
throw Object.assign(new Error('Scheduler is recovering; try again later'), {
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { QueryTypes, Sequelize } from 'sequelize';
|
||||
|
||||
// Append new entries; IDs are persisted and must not be renumbered or reused.
|
||||
const columns = [
|
||||
{
|
||||
table: 'CrontabViews',
|
||||
column: 'filterRelation',
|
||||
type: 'VARCHAR(255)',
|
||||
},
|
||||
{ table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' },
|
||||
{ table: 'CrontabViews', column: 'type', type: 'NUMBER' },
|
||||
{ table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' },
|
||||
{ table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' },
|
||||
{ table: 'Crontabs', column: 'sub_id', type: 'NUMBER' },
|
||||
{ table: 'Crontabs', column: 'extra_schedules', type: 'JSON' },
|
||||
{ table: 'Crontabs', column: 'task_before', type: 'TEXT' },
|
||||
{ table: 'Crontabs', column: 'task_after', type: 'TEXT' },
|
||||
{ table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' },
|
||||
{
|
||||
table: 'Crontabs',
|
||||
column: 'allow_multiple_instances',
|
||||
type: 'NUMBER',
|
||||
},
|
||||
{ table: 'Crontabs', column: 'work_dir', type: 'VARCHAR(255)' },
|
||||
{ table: 'Envs', column: 'isPinned', type: 'NUMBER' },
|
||||
{ table: 'Envs', column: 'labels', type: 'JSON' },
|
||||
{ table: 'Crontabs', column: 'queued_token', type: 'VARCHAR(255)' },
|
||||
];
|
||||
|
||||
export async function migrateSchema(database: Sequelize): Promise<void> {
|
||||
await database.transaction(async (transaction) => {
|
||||
await database.query(
|
||||
'CREATE TABLE IF NOT EXISTS "SchemaMigrations" ("id" TEXT PRIMARY KEY, "applied_at" TEXT NOT NULL)',
|
||||
{ transaction },
|
||||
);
|
||||
const applied = await database.query<{ id: string }>(
|
||||
'SELECT "id" FROM "SchemaMigrations"',
|
||||
{ type: QueryTypes.SELECT, transaction },
|
||||
);
|
||||
const appliedIds = new Set(applied.map(({ id }) => id));
|
||||
for (const { table, column, type } of columns) {
|
||||
const id = `add-${table}-${column}`;
|
||||
const fields = await database.query<{ name: string }>(
|
||||
`PRAGMA table_info("${table}")`,
|
||||
{
|
||||
type: QueryTypes.SELECT,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
if (fields.length === 0)
|
||||
throw new Error(`Migration table is missing: ${table}`);
|
||||
if (!fields.some((field) => field.name === column)) {
|
||||
// table/column/type come only from the static migration manifest above.
|
||||
await database.query(
|
||||
`ALTER TABLE "${table}" ADD COLUMN "${column}" ${type}`,
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
if (!appliedIds.has(id)) {
|
||||
await database.query(
|
||||
'INSERT INTO "SchemaMigrations" ("id", "applied_at") VALUES (:id, :appliedAt)',
|
||||
{
|
||||
replacements: { id, appliedAt: new Date().toISOString() },
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user