mirror of
https://github.com/whyour/qinglong.git
synced 2026-07-17 09:34:31 +08:00
修复添加任务有可能失败
This commit is contained in:
+19
-19
@@ -60,11 +60,11 @@ class Application {
|
|||||||
// Wait for gRPC worker to signal it's ready before starting HTTP worker
|
// Wait for gRPC worker to signal it's ready before starting HTTP worker
|
||||||
this.waitForWorkerReady(grpcWorker, 30000)
|
this.waitForWorkerReady(grpcWorker, 30000)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
Logger.info('✌️ gRPC worker is ready, starting HTTP worker');
|
Logger.info('[boot] gRPC worker is ready, starting HTTP worker');
|
||||||
this.httpWorker = this.forkWorker('http');
|
this.httpWorker = this.forkWorker('http');
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
Logger.error('✌️ Failed to wait for gRPC worker:', error);
|
Logger.error('[boot] Failed to wait for gRPC worker:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ class Application {
|
|||||||
if (metadata) {
|
if (metadata) {
|
||||||
if (!this.isShuttingDown) {
|
if (!this.isShuttingDown) {
|
||||||
Logger.error(
|
Logger.error(
|
||||||
`✌️ ${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
|
`${metadata.serviceType} worker ${worker.process.pid} died (${signal || code
|
||||||
}). Restarting...`,
|
}). Restarting...`,
|
||||||
);
|
);
|
||||||
// If gRPC worker died, restart it and wait for it to be ready
|
// If gRPC worker died, restart it and wait for it to be ready
|
||||||
@@ -81,26 +81,26 @@ class Application {
|
|||||||
const newGrpcWorker = this.forkWorker('grpc');
|
const newGrpcWorker = this.forkWorker('grpc');
|
||||||
this.waitForWorkerReady(newGrpcWorker, 30000)
|
this.waitForWorkerReady(newGrpcWorker, 30000)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
Logger.info('✌️ gRPC worker restarted and ready');
|
Logger.info('gRPC worker restarted and ready');
|
||||||
// Re-register cron jobs by notifying the HTTP worker
|
// Re-register cron jobs by notifying the HTTP worker
|
||||||
if (this.httpWorker) {
|
if (this.httpWorker) {
|
||||||
try {
|
try {
|
||||||
this.httpWorker.send('reregister-crons');
|
this.httpWorker.send('reregister-crons');
|
||||||
Logger.info('✌️ Sent reregister-crons message to HTTP worker');
|
Logger.info('Sent reregister-crons message to HTTP worker');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error('✌️ Failed to send reregister-crons message:', error);
|
Logger.error('Failed to send reregister-crons message:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
Logger.error('✌️ Failed to restart gRPC worker:', error);
|
Logger.error('Failed to restart gRPC worker:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// For HTTP worker, just restart it
|
// For HTTP worker, just restart it
|
||||||
const newWorker = this.forkWorker(metadata.serviceType);
|
const newWorker = this.forkWorker(metadata.serviceType);
|
||||||
this.httpWorker = newWorker;
|
this.httpWorker = newWorker;
|
||||||
Logger.info(`✌️ Restarted ${metadata.serviceType} worker (PID: ${newWorker.process.pid})`);
|
Logger.info(`Restarted ${metadata.serviceType} worker (PID: ${newWorker.process.pid})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@ class Application {
|
|||||||
if (worker) {
|
if (worker) {
|
||||||
const exitPromise = new Promise<void>((resolve) => {
|
const exitPromise = new Promise<void>((resolve) => {
|
||||||
worker.once('exit', () => {
|
worker.once('exit', () => {
|
||||||
Logger.info(`✌️ Worker ${worker.process.pid} exited`);
|
Logger.info(`Worker ${worker.process.pid} exited`);
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ class Application {
|
|||||||
worker.send('shutdown');
|
worker.send('shutdown');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.warn(
|
Logger.warn(
|
||||||
`✌️ Failed to send shutdown to worker ${worker.process.pid}:`,
|
`Failed to send shutdown to worker ${worker.process.pid}:`,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -192,14 +192,14 @@ class Application {
|
|||||||
Promise.all(workerPromises),
|
Promise.all(workerPromises),
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
Logger.warn('✌️ Worker shutdown timeout reached');
|
Logger.warn('Worker shutdown timeout reached');
|
||||||
resolve();
|
resolve();
|
||||||
}, 10000);
|
}, 10000);
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error('✌️ Error during worker shutdown:', error);
|
Logger.error('Error during worker shutdown:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -211,11 +211,11 @@ class Application {
|
|||||||
private async startWorkerProcess() {
|
private async startWorkerProcess() {
|
||||||
const serviceType = process.env.SERVICE_TYPE;
|
const serviceType = process.env.SERVICE_TYPE;
|
||||||
if (!serviceType || !['http', 'grpc'].includes(serviceType)) {
|
if (!serviceType || !['http', 'grpc'].includes(serviceType)) {
|
||||||
Logger.error('✌️ Invalid SERVICE_TYPE:', serviceType);
|
Logger.error('[boot] Invalid SERVICE_TYPE:', serviceType);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info(`✌️ ${serviceType} worker started (PID: ${process.pid})`);
|
Logger.info(`[boot] ${serviceType} worker started (PID: ${process.pid})`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (serviceType === 'http') {
|
if (serviceType === 'http') {
|
||||||
@@ -226,7 +226,7 @@ class Application {
|
|||||||
|
|
||||||
process.send?.('ready');
|
process.send?.('ready');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`✌️ ${serviceType} worker failed:`, error);
|
Logger.error(`[boot] ${serviceType} worker failed:`, error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,13 +269,13 @@ class Application {
|
|||||||
} else if (msg === 'reregister-crons' && serviceType === 'http') {
|
} else if (msg === 'reregister-crons' && serviceType === 'http') {
|
||||||
// Re-register cron jobs when gRPC worker restarts
|
// Re-register cron jobs when gRPC worker restarts
|
||||||
try {
|
try {
|
||||||
Logger.info('✌️ Received reregister-crons message, re-registering cron jobs...');
|
Logger.info('[boot] Received reregister-crons message, re-registering cron jobs...');
|
||||||
const CronService = (await import('./services/cron')).default;
|
const CronService = (await import('./services/cron')).default;
|
||||||
const cronService = Container.get(CronService);
|
const cronService = Container.get(CronService);
|
||||||
await cronService.autosave_crontab();
|
await cronService.autosave_crontab();
|
||||||
Logger.info('✌️ Cron jobs re-registered successfully');
|
Logger.info('[boot] Cron jobs re-registered successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error('✌️ Failed to re-register cron jobs:', error);
|
Logger.error('[boot] Failed to re-register cron jobs:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -297,7 +297,7 @@ class Application {
|
|||||||
}
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`✌️ [${serviceType}] Error during shutdown:`, error);
|
Logger.error(`[${serviceType}] Error during shutdown:`, error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ async function tmpFile(prefix: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function generateAllCerts(): Promise<GrpcTlsConfig> {
|
async function generateAllCerts(): Promise<GrpcTlsConfig> {
|
||||||
Logger.info('Generating gRPC mTLS certificates...');
|
Logger.info('[boot] Generating gRPC mTLS certificates...');
|
||||||
|
|
||||||
const caKeyTmp = await tmpFile('ca_key');
|
const caKeyTmp = await tmpFile('ca_key');
|
||||||
const caCertTmp = await tmpFile('ca_cert');
|
const caCertTmp = await tmpFile('ca_cert');
|
||||||
@@ -83,7 +83,7 @@ async function generateAllCerts(): Promise<GrpcTlsConfig> {
|
|||||||
const clientKey = await fs.readFile(clientKeyTmp, 'utf-8');
|
const clientKey = await fs.readFile(clientKeyTmp, 'utf-8');
|
||||||
|
|
||||||
await cleanup();
|
await cleanup();
|
||||||
Logger.info('gRPC mTLS certificates generated successfully');
|
Logger.info('[boot] gRPC mTLS certificates generated successfully');
|
||||||
|
|
||||||
return { caCert, serverCert, serverKey, clientCert, clientKey };
|
return { caCert, serverCert, serverKey, clientCert, clientKey };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -101,7 +101,7 @@ async function saveCerts(tlsConfig: GrpcTlsConfig): Promise<void> {
|
|||||||
await fs.writeFile(clientCertPath, tlsConfig.clientCert, { mode: 0o644 });
|
await fs.writeFile(clientCertPath, tlsConfig.clientCert, { mode: 0o644 });
|
||||||
await fs.writeFile(clientKeyPath, tlsConfig.clientKey, { mode: 0o600 });
|
await fs.writeFile(clientKeyPath, tlsConfig.clientKey, { mode: 0o600 });
|
||||||
|
|
||||||
Logger.info(`gRPC mTLS certificates saved to ${certDir}`);
|
Logger.info(`[boot] gRPC mTLS certificates saved to ${certDir}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadExistingCerts(): Promise<GrpcTlsConfig | null> {
|
async function loadExistingCerts(): Promise<GrpcTlsConfig | null> {
|
||||||
@@ -125,7 +125,7 @@ async function loadExistingCerts(): Promise<GrpcTlsConfig | null> {
|
|||||||
fs.readFile(clientKeyPath, 'utf-8'),
|
fs.readFile(clientKeyPath, 'utf-8'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Logger.info('Loaded existing gRPC mTLS certificates from disk');
|
Logger.info('[boot] Loaded existing gRPC mTLS certificates from disk');
|
||||||
return { caCert, serverCert, serverKey, clientCert, clientKey };
|
return { caCert, serverCert, serverKey, clientCert, clientKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -9,20 +9,20 @@ import initFile from './initFile';
|
|||||||
|
|
||||||
export default async ({ app }: { app: Application }) => {
|
export default async ({ app }: { app: Application }) => {
|
||||||
depInjectorLoader();
|
depInjectorLoader();
|
||||||
Logger.info('✌️ Dependency loaded');
|
Logger.info('[boot] Dependency loaded');
|
||||||
|
|
||||||
await linkDeps();
|
await linkDeps();
|
||||||
Logger.info('✌️ Link deps loaded');
|
Logger.info('[boot] Link deps loaded');
|
||||||
|
|
||||||
await initFile();
|
await initFile();
|
||||||
Logger.info('✌️ Init file loaded');
|
Logger.info('[boot] Init file loaded');
|
||||||
|
|
||||||
await initData();
|
await initData();
|
||||||
Logger.info('✌️ Init data loaded');
|
Logger.info('[boot] Init data loaded');
|
||||||
|
|
||||||
initTask();
|
initTask();
|
||||||
Logger.info('✌️ Init task loaded');
|
Logger.info('[boot] Init task loaded');
|
||||||
|
|
||||||
expressLoader({ app });
|
expressLoader({ app });
|
||||||
Logger.info('✌️ Express loaded');
|
Logger.info('[boot] Express loaded');
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-2
@@ -58,8 +58,8 @@ export default async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info('✌️ DB loaded');
|
Logger.info('[boot] DB loaded');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error('✌️ DB load failed', error);
|
Logger.error('[boot] DB load failed', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -125,5 +125,5 @@ export default async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info('✌️ Init file down');
|
Logger.info('[boot] Init file down');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Sock from './sock';
|
|||||||
|
|
||||||
export default async ({ server }: { server: Server }) => {
|
export default async ({ server }: { server: Server }) => {
|
||||||
await Sock({ server });
|
await Sock({ server });
|
||||||
Logger.info('✌️ Sock loaded');
|
Logger.info('[boot] Sock loaded');
|
||||||
|
|
||||||
process.on('uncaughtException', (error) => {
|
process.on('uncaughtException', (error) => {
|
||||||
Logger.error('Uncaught exception:', error);
|
Logger.error('Uncaught exception:', error);
|
||||||
|
|||||||
+95
-11
@@ -1,16 +1,79 @@
|
|||||||
import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js';
|
import { ServerUnaryCall, sendUnaryData, status } from '@grpc/grpc-js';
|
||||||
import { AddCronRequest, AddCronResponse } from '../protos/cron';
|
import { AddCronRequest, AddCronResponse } from '../protos/cron';
|
||||||
import nodeSchedule from 'node-schedule';
|
import nodeSchedule from 'node-schedule';
|
||||||
import { scheduleStacks } from './data';
|
import { scheduleStacks } from './data';
|
||||||
import { runCron } from '../shared/runCron';
|
import { runCron } from '../shared/runCron';
|
||||||
import Logger from '../loaders/logger';
|
import Logger from '../loaders/logger';
|
||||||
|
import { tf } from '../shared/i18n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预校验 cron 表达式,检测 node-schedule 会拒绝但 cron-parser 会接受的 pattern。
|
||||||
|
* node-schedule 对 bare /N(字段以 / 开头,如前无星号/数字前缀的 /6)返回 null,
|
||||||
|
* 提前拦截避免走 scheduleJob 后才发现无效。
|
||||||
|
*/
|
||||||
|
const isValidCronField = (cron: string): boolean => {
|
||||||
|
// 检测 bare /N 模式:字段以 / 开头如 "/6",或空格后紧跟 "/6"
|
||||||
|
// node-schedule 会对这种字段返回 null
|
||||||
|
if (/\s\/\d/.test(cron) || /^\/\d/.test(cron)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 检测 ? 字符:Quartz cron 语法,node-schedule 在大多数位置返回 null
|
||||||
|
// cron-parser 接受但 node-schedule 拒绝,提前拦截
|
||||||
|
if (/\?/.test(cron)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const addCron = (
|
const addCron = (
|
||||||
call: ServerUnaryCall<AddCronRequest, AddCronResponse>,
|
call: ServerUnaryCall<AddCronRequest, AddCronResponse>,
|
||||||
callback: sendUnaryData<AddCronResponse>,
|
callback: sendUnaryData<AddCronResponse>,
|
||||||
) => {
|
) => {
|
||||||
|
// ===== 第一遍:预校验所有 cron 表达式 =====
|
||||||
|
const validationErrors: string[] = [];
|
||||||
|
|
||||||
|
for (const item of call.request.crons) {
|
||||||
|
const { id, schedule, extra_schedules } = item;
|
||||||
|
|
||||||
|
if (!isValidCronField(schedule)) {
|
||||||
|
validationErrors.push(
|
||||||
|
tf(
|
||||||
|
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)',
|
||||||
|
String(id),
|
||||||
|
schedule,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extra_schedules?.length) {
|
||||||
|
extra_schedules.forEach((x) => {
|
||||||
|
if (!isValidCronField(x.schedule)) {
|
||||||
|
validationErrors.push(
|
||||||
|
tf(
|
||||||
|
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)',
|
||||||
|
String(id),
|
||||||
|
x.schedule,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
const details = validationErrors.join('\n');
|
||||||
|
const err: any = new Error(details);
|
||||||
|
err.code = status.INVALID_ARGUMENT;
|
||||||
|
err.details = details;
|
||||||
|
callback(err, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 第二遍:注册所有任务 =====
|
||||||
for (const item of call.request.crons) {
|
for (const item of call.request.crons) {
|
||||||
const { id, schedule, command, extra_schedules, name } = item;
|
const { id, schedule, command, extra_schedules, name } = item;
|
||||||
|
|
||||||
|
// 取消该 id 已有的旧任务
|
||||||
if (scheduleStacks.has(id)) {
|
if (scheduleStacks.has(id)) {
|
||||||
scheduleStacks.get(id)?.forEach((x) => x.cancel());
|
scheduleStacks.get(id)?.forEach((x) => x.cancel());
|
||||||
}
|
}
|
||||||
@@ -35,20 +98,41 @@ const addCron = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleStacks.set(id, [
|
const mainJob = nodeSchedule.scheduleJob(id, schedule, async () => {
|
||||||
nodeSchedule.scheduleJob(id, schedule, async () => {
|
|
||||||
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
|
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
|
||||||
runCron(command, item);
|
runCron(command, item);
|
||||||
}),
|
});
|
||||||
...(extra_schedules?.length
|
|
||||||
? extra_schedules.map((x) =>
|
if (!mainJob) {
|
||||||
nodeSchedule.scheduleJob(id, x.schedule, async () => {
|
Logger.warn(
|
||||||
|
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
|
||||||
|
id,
|
||||||
|
schedule,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraJobs = extra_schedules?.length
|
||||||
|
? extra_schedules.map((x) => {
|
||||||
|
const job = nodeSchedule.scheduleJob(id, x.schedule, async () => {
|
||||||
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
|
Logger.info(`[schedule][准备运行任务] 命令: ${command}`);
|
||||||
runCron(command, item);
|
runCron(command, item);
|
||||||
}),
|
});
|
||||||
)
|
if (!job) {
|
||||||
: []),
|
Logger.warn(
|
||||||
]);
|
'[schedule][创建定时任务] scheduleJob 返回 null(不符合预期,已通过预校验): 任务ID: %s, cron: %s',
|
||||||
|
id,
|
||||||
|
x.schedule,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return job;
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// 过滤 null(兜底保护,正常情况下预校验已拦截)
|
||||||
|
const jobs = [mainJob, ...extraJobs].filter((x) => x != null);
|
||||||
|
if (jobs.length > 0) {
|
||||||
|
scheduleStacks.set(id, jobs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
callback(null, null);
|
callback(null, null);
|
||||||
|
|||||||
+39
-7
@@ -117,12 +117,16 @@ export default class CronService {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// 调度器注册为尽力而为,失败时不阻断 crontab.list 与系统 crontab 的同步,
|
// gRPC 注册失败时回滚 DB 记录,避免产生"僵尸任务"
|
||||||
// 调度器重启后会重新注册(见 autosave_crontab)
|
// (DB 和 crontab.list 有记录但调度器永远不会执行)
|
||||||
this.logger.warn(
|
await CrontabModel.destroy({ where: { id: doc.id } });
|
||||||
'[crontab] Failed to register cron job in scheduler:',
|
this.logger.error(
|
||||||
|
'[crontab] Failed to register cron job in scheduler, task creation rolled back:',
|
||||||
error?.message || error,
|
error?.message || error,
|
||||||
);
|
);
|
||||||
|
throw new Error(
|
||||||
|
`${t('调度器注册失败,任务创建已回滚')}: ${(error as any)?.details || error?.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,10 +170,33 @@ export default class CronService {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
} catch (error: any) {
|
} 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(
|
this.logger.warn(
|
||||||
'[crontab] Failed to register cron job in scheduler:',
|
'[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,
|
error?.message || error,
|
||||||
);
|
);
|
||||||
|
throw new Error(
|
||||||
|
`${t('调度器注册失败,任务更新已回滚')}: ${(error as any)?.details || error?.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,10 +753,15 @@ export default class CronService {
|
|||||||
try {
|
try {
|
||||||
await cronClient.addCron(crons);
|
await cronClient.addCron(crons);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.warn(
|
// gRPC 注册失败 → 回滚启用状态,避免 DB 显示已启用但调度器未注册
|
||||||
'[crontab] Failed to register cron job in scheduler:',
|
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,
|
error?.message || error,
|
||||||
);
|
);
|
||||||
|
throw new Error(
|
||||||
|
`${t('调度器注册失败,任务启用已回滚')}: ${(error as any)?.details || error?.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await this.setCrontab();
|
await this.setCrontab();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export class GrpcServerService {
|
|||||||
try {
|
try {
|
||||||
const address = this.formatGrpcAddress(host, grpcPort);
|
const address = this.formatGrpcAddress(host, grpcPort);
|
||||||
await bindAsync(address, credentials);
|
await bindAsync(address, credentials);
|
||||||
Logger.debug(`✌️ gRPC service started successfully on ${address}`);
|
Logger.debug(`[boot] gRPC service started successfully on ${address}`);
|
||||||
metricsService.record('grpc_service_start', 1, {
|
metricsService.record('grpc_service_start', 1, {
|
||||||
port: grpcPort.toString(),
|
port: grpcPort.toString(),
|
||||||
host
|
host
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export class HttpServerService {
|
|||||||
for (const host of hostsToTry) {
|
for (const host of hostsToTry) {
|
||||||
try {
|
try {
|
||||||
const server = await this.tryListen(expressApp, port, host);
|
const server = await this.tryListen(expressApp, port, host);
|
||||||
Logger.debug(`✌️ HTTP service started successfully on ${host}:${port}`);
|
Logger.debug(`[boot] HTTP service started successfully on ${host}:${port}`);
|
||||||
metricsService.record('http_service_start', 1, {
|
metricsService.record('http_service_start', 1, {
|
||||||
port: port.toString(),
|
port: port.toString(),
|
||||||
host
|
host
|
||||||
|
|||||||
@@ -131,6 +131,16 @@ const messages: Record<string, Record<string, string>> = {
|
|||||||
'执行before命令...': 'Execute before command...',
|
'执行before命令...': 'Execute before command...',
|
||||||
'执行after命令...': 'Execute after command...',
|
'执行after命令...': 'Execute after command...',
|
||||||
'## 执行结束... %s 耗时 %s 秒': '## Execution finished... %s elapsed %s seconds',
|
'## 执行结束... %s 耗时 %s 秒': '## Execution finished... %s elapsed %s seconds',
|
||||||
|
'调度器注册失败,任务创建已回滚':
|
||||||
|
'Scheduler registration failed, task creation rolled back',
|
||||||
|
'调度器注册失败,任务更新已回滚':
|
||||||
|
'Scheduler registration failed, task update rolled back',
|
||||||
|
'调度器注册失败,任务启用已回滚':
|
||||||
|
'Scheduler registration failed, task enable rolled back',
|
||||||
|
'任务ID %s: 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)':
|
||||||
|
'Task ID %s: invalid cron expression "%s" (bare /N steps and ? character not supported)',
|
||||||
|
'任务ID %s (extra_schedule): 无效的 cron 表达式 "%s"(不支持裸 /N 步长和 ? 字符)':
|
||||||
|
'Task ID %s (extra_schedule): invalid cron expression "%s" (bare /N steps and ? character not supported)',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ const validateSchedule = (value: string, helpers: any) => {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检测裸 /N 模式:cron-parser 会接受,但 node-schedule 会返回 null
|
||||||
|
// 提前拦截,避免任务入库后调度器注册失败
|
||||||
|
if (/\s\/\d/.test(value) || /^\/\d/.test(value)) {
|
||||||
|
return helpers.error('any.invalid');
|
||||||
|
}
|
||||||
|
// 检测 ? 字符:Quartz cron 语法,node-schedule 在大多数字段上返回 null
|
||||||
|
if (/\?/.test(value)) {
|
||||||
|
return helpers.error('any.invalid');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (CronExpressionParser.parse(value).hasNext()) {
|
if (CronExpressionParser.parse(value).hasNext()) {
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
Reference in New Issue
Block a user