echoapi-cron-scheduler-batch 1.0.8 → 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +394 -635
- package/index_bak.js +725 -0
- package/package.json +2 -2
package/index.js
CHANGED
|
@@ -11,32 +11,79 @@ const { v4: uuidv4 } = require('uuid');
|
|
|
11
11
|
const { execSync } = require('child_process');
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* CronScheduler - 企业级多进程自动化测试调度器
|
|
14
|
+
* CronScheduler - 企业级多进程自动化测试调度器 (生产增强版)
|
|
15
15
|
*/
|
|
16
16
|
class CronScheduler {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (cluster.isPrimary) {
|
|
27
|
-
this.db = new Database(this.dbFile);
|
|
28
|
-
this._initDB();
|
|
29
|
-
}
|
|
17
|
+
constructor(config = {}) {
|
|
18
|
+
// 1. 配置初始化
|
|
19
|
+
this.apiUrl = config.apiUrl || process?.env["OPENAPI_DOMAIN"] || "https://ee.apipost.cc";
|
|
20
|
+
|
|
21
|
+
// 【优化】不要直接放 /tmp,在项目目录下创建持久化 data 目录
|
|
22
|
+
const dataDir = path.resolve(process?.env?.TEMP_DIR || os.tmpdir(), 'scheduler_data');
|
|
23
|
+
if (cluster.isPrimary && !fs.existsSync(dataDir)) {
|
|
24
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
30
25
|
}
|
|
31
26
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
27
|
+
this.dbFile = config.dbPath || path.join(dataDir, 'echoapi-batch-tasks.sqlite');
|
|
28
|
+
this.workerNum = config.workerNum || os.cpus().length;
|
|
29
|
+
|
|
30
|
+
// 结果聚合收集器 (仅在 Master 内存中存在)
|
|
31
|
+
this.resultCollector = new Map();
|
|
37
32
|
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
// 2. 数据库连接与安全自检 (仅 Master 进程持有写连接)
|
|
34
|
+
if (cluster.isPrimary) {
|
|
35
|
+
this._connectDB();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 内部方法:建立数据库连接并增加损坏自动修复逻辑
|
|
41
|
+
*/
|
|
42
|
+
_connectDB() {
|
|
43
|
+
try {
|
|
44
|
+
// 预检:如果是 0 字节损坏文件,直接删除
|
|
45
|
+
if (fs.existsSync(this.dbFile) && fs.statSync(this.dbFile).size === 0) {
|
|
46
|
+
fs.unlinkSync(this.dbFile);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
this.db = new Database(this.dbFile, { timeout: 10000 });
|
|
50
|
+
|
|
51
|
+
// 【核心】执行完整性自检,解决 malformed 和 not a database 问题
|
|
52
|
+
const check = this.db.pragma('integrity_check');
|
|
53
|
+
if (check[0].integrity_check !== 'ok') {
|
|
54
|
+
throw new Error('SQLite integrity check failed');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
this._initDB();
|
|
58
|
+
console.log(`[Database] Connection verified and initialized.`);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
console.error(`[Database Critical] 数据库损坏(Malformed/NotADb): ${e.message}`);
|
|
61
|
+
if (this.db) this.db.close();
|
|
62
|
+
|
|
63
|
+
// 自动备份损坏文件,腾出位置重建
|
|
64
|
+
const backupPath = `${this.dbFile}.corrupted_${Date.now()}`;
|
|
65
|
+
if (fs.existsSync(this.dbFile)) {
|
|
66
|
+
fs.renameSync(this.dbFile, backupPath);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 重建数据库
|
|
70
|
+
this.db = new Database(this.dbFile);
|
|
71
|
+
this._initDB();
|
|
72
|
+
console.warn(`[Database] 坏库已隔离至 ${backupPath},新库已就绪。`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 初始化表结构与性能参数
|
|
78
|
+
*/
|
|
79
|
+
_initDB() {
|
|
80
|
+
// 性能调优:WAL 模式能极大减少多进程读取时的锁竞争
|
|
81
|
+
this.db.pragma('journal_mode = WAL');
|
|
82
|
+
this.db.pragma('synchronous = NORMAL');
|
|
83
|
+
this.db.pragma('busy_timeout = 5000');
|
|
84
|
+
|
|
85
|
+
// 1. 任务配置表
|
|
86
|
+
this.db.prepare(`
|
|
40
87
|
CREATE TABLE IF NOT EXISTS jobs (
|
|
41
88
|
job_id TEXT PRIMARY KEY,
|
|
42
89
|
name TEXT,
|
|
@@ -44,13 +91,13 @@ class CronScheduler {
|
|
|
44
91
|
is_cancel INTEGER DEFAULT 0,
|
|
45
92
|
api_token TEXT,
|
|
46
93
|
project_id TEXT,
|
|
47
|
-
cases TEXT,
|
|
94
|
+
cases TEXT,
|
|
48
95
|
create_dtime INTEGER
|
|
49
96
|
)
|
|
50
97
|
`).run();
|
|
51
98
|
|
|
52
|
-
|
|
53
|
-
|
|
99
|
+
// 2. 中间结果暂存表 (Master 写入,聚合后删除)
|
|
100
|
+
this.db.prepare(`
|
|
54
101
|
CREATE TABLE IF NOT EXISTS task_results (
|
|
55
102
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
56
103
|
execution_id TEXT,
|
|
@@ -58,649 +105,361 @@ class CronScheduler {
|
|
|
58
105
|
create_at INTEGER
|
|
59
106
|
)
|
|
60
107
|
`).run();
|
|
61
|
-
|
|
108
|
+
this.db.prepare(`CREATE INDEX IF NOT EXISTS idx_exec_id ON task_results(execution_id)`).run();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
run() {
|
|
112
|
+
if (cluster.isPrimary) {
|
|
113
|
+
this._startMaster();
|
|
114
|
+
} else {
|
|
115
|
+
this._startWorker();
|
|
62
116
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
*/
|
|
77
|
-
formatTimeToISO(time) {
|
|
78
|
-
const dayjs = require('dayjs');
|
|
79
|
-
const utc = require('dayjs/plugin/utc');
|
|
80
|
-
const timezone = require('dayjs/plugin/timezone');
|
|
81
|
-
dayjs.extend(utc);
|
|
82
|
-
dayjs.extend(timezone);
|
|
83
|
-
return dayjs(time).tz('Asia/Shanghai').format();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
formatTimeToISO(time) {
|
|
120
|
+
if (!time) return null;
|
|
121
|
+
try {
|
|
122
|
+
const dayjs = require('dayjs');
|
|
123
|
+
const utc = require('dayjs/plugin/utc');
|
|
124
|
+
const timezone = require('dayjs/plugin/timezone');
|
|
125
|
+
dayjs.extend(utc);
|
|
126
|
+
dayjs.extend(timezone);
|
|
127
|
+
return dayjs(time).tz('Asia/Shanghai').format();
|
|
128
|
+
} catch (e) {
|
|
129
|
+
return new Date(time).toISOString();
|
|
84
130
|
}
|
|
131
|
+
}
|
|
85
132
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
_startMaster() {
|
|
91
|
-
console.log(`[CronScheduler] Master process ${process.pid} is running.`);
|
|
92
|
-
|
|
93
|
-
// 衍生子进程
|
|
94
|
-
for (let i = 0; i < this.workerNum; i++) {
|
|
95
|
-
this._bindWorker(cluster.fork());
|
|
96
|
-
}
|
|
133
|
+
// ==========================================
|
|
134
|
+
// Master 进程逻辑:单点读写
|
|
135
|
+
// ==========================================
|
|
97
136
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
console.warn(`[CronScheduler] Worker ${worker.process.pid} died. Forking a new one...`);
|
|
101
|
-
this._bindWorker(cluster.fork());
|
|
102
|
-
});
|
|
137
|
+
_startMaster() {
|
|
138
|
+
console.log(`[CronScheduler] Master ${process.pid} is running.`);
|
|
103
139
|
|
|
104
|
-
|
|
105
|
-
|
|
140
|
+
for (let i = 0; i < this.workerNum; i++) {
|
|
141
|
+
this._bindWorker(cluster.fork());
|
|
106
142
|
}
|
|
107
143
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const record = this.resultCollector.get(executionId);
|
|
113
|
-
|
|
114
|
-
if (record) {
|
|
115
|
-
|
|
116
|
-
// record.received.push(_.assign(data, {
|
|
117
|
-
// source: 'scheduled',
|
|
118
|
-
// start_at: this.formatTimeToISO(data?.start_at),
|
|
119
|
-
// end_at: this.formatTimeToISO(data?.end_at)
|
|
120
|
-
// }));
|
|
121
|
-
// // 检查是否收齐了该 Job 下所有的 runners 结果
|
|
122
|
-
// if (record.received.length >= record.total) {
|
|
123
|
-
// await this._reportAggregated(executionId, record);
|
|
124
|
-
// }
|
|
125
|
-
try {
|
|
126
|
-
// 【关键优化】不再存入内存 Map,而是存入 SQLite 临时表
|
|
127
|
-
this.db.prepare(`INSERT INTO task_results (execution_id, data, create_at) VALUES (?, ?, ?)`)
|
|
128
|
-
.run(executionId, JSON.stringify(data), Date.now());
|
|
129
|
-
|
|
130
|
-
record.received_count++;
|
|
131
|
-
console.log(record.total, record.received_count, 'record.received_count');
|
|
132
|
-
|
|
133
|
-
// 检查是否收齐
|
|
134
|
-
if (record.received_count >= record.total) {
|
|
135
|
-
await this._reportFromDB(executionId, record);
|
|
136
|
-
}
|
|
137
|
-
} catch (e) {
|
|
138
|
-
console.error(`[Master Result Collect Error]`, e.message);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
+
cluster.on('exit', (worker, code) => {
|
|
145
|
+
console.warn(`[CronScheduler] Worker ${worker.process.pid} died (code:${code}). Forking...`);
|
|
146
|
+
this._bindWorker(cluster.fork());
|
|
147
|
+
});
|
|
144
148
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
*/
|
|
148
|
-
// async _reportAggregated(executionId, record) {
|
|
149
|
-
// try {
|
|
150
|
-
|
|
151
|
-
// const results = record.received;
|
|
152
|
-
// const endTime = Date.now();
|
|
153
|
-
|
|
154
|
-
// // --- 核心统计逻辑 ---
|
|
155
|
-
// let stats = {
|
|
156
|
-
// http_total: 0,
|
|
157
|
-
// http_success: 0,
|
|
158
|
-
// assert_total: 0,
|
|
159
|
-
// assert_success: 0,
|
|
160
|
-
// total_response_time: 0,
|
|
161
|
-
// count: 0
|
|
162
|
-
// };
|
|
163
|
-
|
|
164
|
-
// results.forEach(item => {
|
|
165
|
-
// // 累加 HTTP 统计
|
|
166
|
-
// stats.http_total += (item.http?.total || 0);
|
|
167
|
-
// stats.http_success += (item.http?.success || 0);
|
|
168
|
-
|
|
169
|
-
// // 累加 断言 统计
|
|
170
|
-
// stats.assert_total += (item.assert?.total || 0);
|
|
171
|
-
// stats.assert_success += (item.assert?.success || 0);
|
|
172
|
-
|
|
173
|
-
// // 累加 响应时间 用于计算平均值
|
|
174
|
-
// stats.total_response_time += (item.total_response_time || 0);
|
|
175
|
-
// stats.count++;
|
|
176
|
-
// });
|
|
177
|
-
|
|
178
|
-
// // 计算通过率 (保留两位小数)
|
|
179
|
-
// const httpPassRate = stats.http_total > 0
|
|
180
|
-
// ? ((stats.http_success / stats.http_total) * 100).toFixed(2)
|
|
181
|
-
// : "0.00";
|
|
182
|
-
|
|
183
|
-
// const assertPassRate = stats.assert_total > 0
|
|
184
|
-
// ? ((stats.assert_success / stats.assert_total) * 100).toFixed(2)
|
|
185
|
-
// : "0.00";
|
|
186
|
-
|
|
187
|
-
// // 计算平均响应时间
|
|
188
|
-
// const avgResponseTime = stats.count > 0
|
|
189
|
-
// ? (stats.total_response_time / stats.count).toFixed(0)
|
|
190
|
-
// : 0;
|
|
191
|
-
|
|
192
|
-
// // --- 封装最终 Payload ---
|
|
193
|
-
// const finalPayload = {
|
|
194
|
-
// info: {
|
|
195
|
-
// job_id: record.job_info.job_id,
|
|
196
|
-
// report_name: record.job_info.name,
|
|
197
|
-
// project_id: record.job_info.project_id,
|
|
198
|
-
// execution_id: executionId,
|
|
199
|
-
// start_at: this.formatTimeToISO(record.job_info.start_time),
|
|
200
|
-
// end_at: this.formatTimeToISO(endTime),
|
|
201
|
-
|
|
202
|
-
// // 业务统计字段
|
|
203
|
-
// http_pass_rate: `${httpPassRate}%`,
|
|
204
|
-
// assert_pass_rate: `${assertPassRate}%`,
|
|
205
|
-
// avg_response_time: `${avgResponseTime}ms`,
|
|
206
|
-
|
|
207
|
-
// // 原始计数
|
|
208
|
-
// total_http: stats.http_total,
|
|
209
|
-
// success_http: stats.http_success,
|
|
210
|
-
// total_assert: stats.assert_total,
|
|
211
|
-
// success_assert: stats.assert_success,
|
|
212
|
-
|
|
213
|
-
// total_units: record.total,
|
|
214
|
-
// actual_units: results.length
|
|
215
|
-
// },
|
|
216
|
-
// results: results // 原始详细列表
|
|
217
|
-
// };
|
|
218
|
-
|
|
219
|
-
// // 写入本地备份,增加 try-catch 避免权限问题
|
|
220
|
-
// try {
|
|
221
|
-
// // fs.writeFileSync('last_report.json', JSON.stringify(finalPayload));
|
|
222
|
-
// } catch (e) { }
|
|
223
|
-
|
|
224
|
-
// console.log(`[CronScheduler] Batch job finished. ExecutionID: ${executionId}. Aggregating ${record.total} results...`);
|
|
225
|
-
// // console.log(`[CronScheduler] TOKEN ${record.api_token}`);
|
|
226
|
-
|
|
227
|
-
// const response = await axios.post(`${this.apiUrl}/open/hnzycfc/scheduled_task/report/add`, finalPayload, {
|
|
228
|
-
// headers: {
|
|
229
|
-
// 'Content-Type': 'application/json',
|
|
230
|
-
// 'api-token': record.api_token
|
|
231
|
-
// }
|
|
232
|
-
// });
|
|
233
|
-
// if (response?.data?.code != 0) {
|
|
234
|
-
// console.log(`Execution Id ${executionId} reporting error: ${response?.data?.msg}`);
|
|
235
|
-
// } else {
|
|
236
|
-
// console.log(`Execution Id ${executionId} reported success...`);
|
|
237
|
-
// }
|
|
238
|
-
// } catch (err) {
|
|
239
|
-
// console.error(`[CronScheduler] Report aggregation failed:`, err.message);
|
|
240
|
-
// } finally {
|
|
241
|
-
// this.resultCollector.delete(executionId); // 清理内存
|
|
242
|
-
// console.info(`[CronScheduler] Result collector cleared for ${executionId}`);
|
|
243
|
-
// }
|
|
244
|
-
// }
|
|
245
|
-
/**
|
|
246
|
-
* 查看当前所有待执行(活跃)的任务及其详细信息
|
|
247
|
-
*/
|
|
248
|
-
getPendingJobs() {
|
|
249
|
-
// 1. 从内存中获取 node-schedule 注册的所有 job_id
|
|
250
|
-
const activeTimerIds = Object.keys(schedule.scheduledJobs);
|
|
251
|
-
|
|
252
|
-
if (activeTimerIds.length === 0) {
|
|
253
|
-
return [];
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// 2. 从数据库中查询这些活跃任务的详细配置
|
|
257
|
-
// 使用 IN 语句批量查询,效率更高
|
|
258
|
-
const placeholders = activeTimerIds.map(() => '?').join(',');
|
|
259
|
-
const query = `SELECT job_id, name, frequency, project_id, create_dtime FROM jobs WHERE job_id IN (${placeholders}) AND is_cancel < 1`;
|
|
260
|
-
|
|
261
|
-
try {
|
|
262
|
-
const jobs = this.db.prepare(query).all(...activeTimerIds);
|
|
263
|
-
|
|
264
|
-
// 3. 结合内存中的下次执行时间进行封装
|
|
265
|
-
return _.map(jobs, job => {
|
|
266
|
-
const cronRule = this.convertToCron(JSON5.parse(job.frequency));
|
|
267
|
-
const nextTime = this.getNextExecutionTime(cronRule);
|
|
268
|
-
|
|
269
|
-
return {
|
|
270
|
-
job_id: job.job_id,
|
|
271
|
-
name: job.name,
|
|
272
|
-
project_id: job.project_id,
|
|
273
|
-
cron: cronRule,
|
|
274
|
-
next_run_at: typeof nextTime === 'number' ? new Date(nextTime).toLocaleString() : nextTime,
|
|
275
|
-
timestamp: nextTime // 方便前端排序
|
|
276
|
-
};
|
|
277
|
-
}).sort((a, b) => (a.timestamp - b.timestamp)); // 按执行时间先后排序
|
|
278
|
-
} catch (e) {
|
|
279
|
-
console.error('[View Jobs Error]', e.message);
|
|
280
|
-
return [];
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* 增量更新定时器:解决时间漂移和重复触发的关键
|
|
286
|
-
*/
|
|
287
|
-
upsertTimer(job_id, name, frequency, jobData) {
|
|
288
|
-
// 1. 如果已存在,先取消旧的内存定时器
|
|
289
|
-
if (schedule.scheduledJobs[job_id]) {
|
|
290
|
-
schedule.scheduledJobs[job_id].cancel();
|
|
291
|
-
}
|
|
149
|
+
this.loadJobs();
|
|
150
|
+
}
|
|
292
151
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
// 重新创建定时器
|
|
300
|
-
schedule.scheduleJob(job_id, cronRule, () => {
|
|
301
|
-
const now = new Date().toLocaleString();
|
|
302
|
-
console.log(`[Timer Triggered] ${name} (${job_id}) at ${now}`);
|
|
303
|
-
|
|
304
|
-
// 触发时重新从数据库获取最新数据,确保分发的是最新的 runners
|
|
305
|
-
const currentJob = this.db.prepare(`SELECT * FROM jobs WHERE job_id = ?`).get(job_id);
|
|
306
|
-
if (currentJob && currentJob.is_cancel < 1) {
|
|
307
|
-
this._dispatch(currentJob);
|
|
308
|
-
}
|
|
309
|
-
});
|
|
310
|
-
} catch (e) {
|
|
311
|
-
console.error(`[CronScheduler] Upsert timer error (ID: ${job_id}):`, e.message);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
152
|
+
_bindWorker(worker) {
|
|
153
|
+
worker.on('message', async (msg) => {
|
|
154
|
+
if (msg.action === 'UNIT_COMPLETED') {
|
|
155
|
+
const { executionId, data } = msg.payload;
|
|
156
|
+
const record = this.resultCollector.get(executionId);
|
|
315
157
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
158
|
+
if (record) {
|
|
159
|
+
try {
|
|
160
|
+
// 【单进程写入】只有 Master 负责操作数据库,规避并发写导致的损坏
|
|
161
|
+
this.db.prepare(`INSERT INTO task_results (execution_id, data, create_at) VALUES (?, ?, ?)`)
|
|
162
|
+
.run(executionId, JSON.stringify(data), Date.now());
|
|
319
163
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
createJob(payload) {
|
|
324
|
-
const { job_id, name, frequency, api_token, project_id, runners } = payload;
|
|
325
|
-
const casesJson = JSON.stringify(Array.isArray(runners) ? runners : []);
|
|
326
|
-
const freqJson = JSON.stringify(frequency);
|
|
164
|
+
// 计数检查
|
|
165
|
+
if (typeof record.received_count !== 'number') record.received_count = 0;
|
|
166
|
+
record.received_count++;
|
|
327
167
|
|
|
328
|
-
|
|
329
|
-
INSERT INTO jobs (job_id, name, frequency, api_token, project_id, cases, create_dtime)
|
|
330
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
331
|
-
ON CONFLICT(job_id) DO UPDATE SET
|
|
332
|
-
name=excluded.name,
|
|
333
|
-
frequency=excluded.frequency,
|
|
334
|
-
cases=excluded.cases,
|
|
335
|
-
api_token=excluded.api_token,
|
|
336
|
-
is_cancel=0
|
|
337
|
-
`);
|
|
338
|
-
stmt.run(job_id, name, freqJson, api_token, project_id, casesJson, Date.now());
|
|
168
|
+
console.log(`[Progress] ${executionId}: ${record.received_count}/${record.total}`);
|
|
339
169
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
this.db.prepare(`UPDATE jobs SET is_cancel = 1 WHERE job_id = ?`).run(job_id);
|
|
347
|
-
if (schedule.scheduledJobs[job_id]) {
|
|
348
|
-
schedule.scheduledJobs[job_id].cancel();
|
|
170
|
+
if (record.received_count >= record.total) {
|
|
171
|
+
await this._reportFromDB(executionId, record);
|
|
172
|
+
}
|
|
173
|
+
} catch (e) {
|
|
174
|
+
console.error(`[Master DB Write Error]`, e.message);
|
|
175
|
+
}
|
|
349
176
|
}
|
|
350
|
-
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async _reportFromDB(executionId, record) {
|
|
182
|
+
try {
|
|
183
|
+
console.log(`[CronScheduler] Aggregating results for: ${executionId}`);
|
|
184
|
+
const rows = this.db.prepare(`SELECT data FROM task_results WHERE execution_id = ?`).all(executionId);
|
|
185
|
+
|
|
186
|
+
let stats = {
|
|
187
|
+
http_total: 0, http_success: 0,
|
|
188
|
+
assert_total: 0, assert_success: 0,
|
|
189
|
+
total_resp_time: 0
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const results = rows.map(row => {
|
|
193
|
+
const item = JSON.parse(row.data);
|
|
194
|
+
stats.http_total += (item.http?.total || 0);
|
|
195
|
+
stats.http_success += (item.http?.success || 0);
|
|
196
|
+
stats.assert_total += (item.assert?.total || 0);
|
|
197
|
+
stats.assert_success += (item.assert?.success || 0);
|
|
198
|
+
stats.total_resp_time += (item.total_response_time || 0);
|
|
199
|
+
|
|
200
|
+
return _.assign(item, {
|
|
201
|
+
source: 'scheduled',
|
|
202
|
+
start_at: this.formatTimeToISO(item?.start_at),
|
|
203
|
+
end_at: this.formatTimeToISO(item?.end_at)
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const httpRate = stats.http_total > 0 ? ((stats.http_success / stats.http_total) * 100).toFixed(2) : "0.00";
|
|
208
|
+
const assertRate = stats.assert_total > 0 ? ((stats.assert_success / stats.assert_total) * 100).toFixed(2) : "0.00";
|
|
209
|
+
const avgResp = results.length > 0 ? (stats.total_resp_time / results.length).toFixed(0) : 0;
|
|
210
|
+
|
|
211
|
+
const finalPayload = {
|
|
212
|
+
info: {
|
|
213
|
+
job_id: record.job_info.job_id,
|
|
214
|
+
report_name: record.job_info.name,
|
|
215
|
+
project_id: record.job_info.project_id,
|
|
216
|
+
execution_id: executionId,
|
|
217
|
+
start_at: this.formatTimeToISO(record.job_info.start_time),
|
|
218
|
+
end_at: this.formatTimeToISO(Date.now()),
|
|
219
|
+
http_pass_rate: `${httpRate}%`,
|
|
220
|
+
assert_pass_rate: `${assertRate}%`,
|
|
221
|
+
avg_response_time: `${avgResp}ms`,
|
|
222
|
+
total_http: stats.http_total,
|
|
223
|
+
success_http: stats.http_success,
|
|
224
|
+
total_assert: stats.assert_total,
|
|
225
|
+
success_assert: stats.assert_success,
|
|
226
|
+
total_units: record.total,
|
|
227
|
+
actual_units: results.length
|
|
228
|
+
},
|
|
229
|
+
results: results
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const response = await axios.post(`${this.apiUrl}/open/hnzycfc/scheduled_task/report/add`, finalPayload, {
|
|
233
|
+
headers: { 'Content-Type': 'application/json', 'api-token': record.api_token },
|
|
234
|
+
maxContentLength: Infinity,
|
|
235
|
+
maxBodyLength: Infinity,
|
|
236
|
+
timeout: 60000 // 聚合报告可能很大,增加超时时间
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (response?.data?.code == 0) {
|
|
240
|
+
console.log(`[CronScheduler] Execution ${executionId} Success.`);
|
|
241
|
+
// 上报成功后清理中间数据
|
|
242
|
+
this.db.prepare(`DELETE FROM task_results WHERE execution_id = ?`).run(executionId);
|
|
243
|
+
} else {
|
|
244
|
+
console.error(`[Report Error] ${executionId}: ${response?.data?.msg}`);
|
|
245
|
+
}
|
|
246
|
+
} catch (err) {
|
|
247
|
+
console.error(`[Aggregation Error]`, err.stack);
|
|
248
|
+
} finally {
|
|
249
|
+
this.resultCollector.delete(executionId);
|
|
351
250
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
_dispatch(job) {
|
|
254
|
+
try {
|
|
255
|
+
const runners = JSON5.parse(job.cases || '[]');
|
|
256
|
+
if (runners.length === 0) return;
|
|
257
|
+
|
|
258
|
+
const executionId = `${job.job_id}_${Date.now()}`;
|
|
259
|
+
this.resultCollector.set(executionId, {
|
|
260
|
+
total: runners.length,
|
|
261
|
+
received_count: 0,
|
|
262
|
+
api_token: job.api_token,
|
|
263
|
+
job_info: {
|
|
264
|
+
job_id: job.job_id,
|
|
265
|
+
name: job.name,
|
|
266
|
+
project_id: job.project_id,
|
|
267
|
+
start_time: Date.now()
|
|
358
268
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
if (
|
|
365
|
-
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const activeWorkers = Object.values(cluster.workers).filter(w => w.isConnected());
|
|
272
|
+
runners.forEach((runner, index) => {
|
|
273
|
+
const worker = _.sample(activeWorkers);
|
|
274
|
+
if (worker) {
|
|
275
|
+
worker.send({
|
|
276
|
+
action: 'EXECUTE_UNIT',
|
|
277
|
+
payload: { executionId, api_token: job.api_token, test_events: runner.test_events, option: runner.option, unit_index: index }
|
|
278
|
+
});
|
|
366
279
|
}
|
|
367
|
-
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
280
|
+
});
|
|
281
|
+
} catch (e) { console.error(`[Dispatch Error]:`, e.message); }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ==========================================
|
|
285
|
+
// Worker 进程逻辑:仅负责计算,不碰数据库
|
|
286
|
+
// ==========================================
|
|
287
|
+
|
|
288
|
+
_startWorker() {
|
|
289
|
+
console.log(`[Worker ${process.pid}] Ready.`);
|
|
290
|
+
const { run: runner } = require('runner-runtime');
|
|
291
|
+
const net = require('net');
|
|
292
|
+
|
|
293
|
+
process.on('message', async (msg) => {
|
|
294
|
+
if (msg.action === 'EXECUTE_UNIT') {
|
|
295
|
+
const { executionId, api_token, test_events, option } = msg.payload;
|
|
296
|
+
const socketPath = path.join(os.tmpdir(), `ea_${uuidv4().slice(0, 8)}.sock`);
|
|
297
|
+
|
|
298
|
+
const server = net.createServer((socket) => {
|
|
299
|
+
socket.on('data', async (stream) => {
|
|
300
|
+
try {
|
|
301
|
+
const info = JSON.parse(stream.toString());
|
|
302
|
+
await this._handleUnitScript(socket, info.action, info.data);
|
|
303
|
+
} catch (e) {
|
|
304
|
+
if (!socket.destroyed) socket.write(JSON.stringify({ status: 'error', message: e.message }) + "\n\n");
|
|
383
305
|
}
|
|
384
|
-
|
|
385
|
-
return {
|
|
386
|
-
..._.omit(job, ['cases']), // 排除大数据字段
|
|
387
|
-
status: job.is_cancel === 0 ? 0 : 1, // 0:运行中, 1:已暂停
|
|
388
|
-
next_run_time: nextRunTime,
|
|
389
|
-
frequency_display: cronRule, // 返回原始 Cron 规则供前端参考
|
|
390
|
-
runners: _.map(cases, (v) => v?.option?.testing_id)
|
|
391
|
-
};
|
|
306
|
+
});
|
|
392
307
|
});
|
|
393
|
-
}
|
|
394
308
|
|
|
395
|
-
|
|
396
|
-
console.log('[CronScheduler] Initializing timers from database...');
|
|
397
|
-
// 启动时全量清理一次
|
|
398
|
-
Object.values(schedule.scheduledJobs).forEach(j => j.cancel());
|
|
309
|
+
server.on('error', (err) => console.error(`[Socket Server Error] PID ${process.pid}: ${err.message}`));
|
|
399
310
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
311
|
+
server.listen(socketPath, () => {
|
|
312
|
+
const finalOptions = _.cloneDeep(option || {});
|
|
313
|
+
const base64Pipe = Buffer.from(socketPath).toString('base64');
|
|
314
|
+
_.set(finalOptions, 'env.ELECTRON_PIPE', base64Pipe);
|
|
405
315
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
// 1. 从 DB 读取该执行批次的所有结果
|
|
414
|
-
const rows = this.db.prepare(`SELECT data FROM task_results WHERE execution_id = ?`).all(executionId);
|
|
415
|
-
|
|
416
|
-
let stats = {
|
|
417
|
-
http_total: 0, http_success: 0,
|
|
418
|
-
assert_total: 0, assert_success: 0,
|
|
419
|
-
total_resp_time: 0
|
|
420
|
-
};
|
|
421
|
-
|
|
422
|
-
// 2. 转换数据并计算统计信息
|
|
423
|
-
const results = rows.map(row => {
|
|
424
|
-
const item = JSON.parse(row.data);
|
|
425
|
-
|
|
426
|
-
// 累加统计
|
|
427
|
-
stats.http_total += (item.http?.total || 0);
|
|
428
|
-
stats.http_success += (item.http?.success || 0);
|
|
429
|
-
stats.assert_total += (item.assert?.total || 0);
|
|
430
|
-
stats.assert_success += (item.assert?.success || 0);
|
|
431
|
-
stats.total_resp_time += (item.total_response_time || 0);
|
|
432
|
-
|
|
433
|
-
return _.assign(item, {
|
|
434
|
-
source: 'scheduled',
|
|
435
|
-
start_at: this.formatTimeToISO(item?.start_at),
|
|
436
|
-
end_at: this.formatTimeToISO(item?.end_at)
|
|
437
|
-
});
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
// 3. 计算通过率等指标
|
|
441
|
-
const httpRate = stats.http_total > 0 ? ((stats.http_success / stats.http_total) * 100).toFixed(2) : "0.00";
|
|
442
|
-
const assertRate = stats.assert_total > 0 ? ((stats.assert_success / stats.assert_total) * 100).toFixed(2) : "0.00";
|
|
443
|
-
const avgResp = results.length > 0 ? (stats.total_resp_time / results.length).toFixed(0) : 0;
|
|
444
|
-
|
|
445
|
-
// 4. 封装 Payload
|
|
446
|
-
const finalPayload = {
|
|
447
|
-
info: {
|
|
448
|
-
job_id: record.job_info.job_id,
|
|
449
|
-
report_name: record.job_info.name,
|
|
450
|
-
project_id: record.job_info.project_id,
|
|
451
|
-
execution_id: executionId,
|
|
452
|
-
start_at: this.formatTimeToISO(record.job_info.start_time),
|
|
453
|
-
end_at: this.formatTimeToISO(Date.now()),
|
|
454
|
-
http_pass_rate: `${httpRate}%`,
|
|
455
|
-
assert_pass_rate: `${assertRate}%`,
|
|
456
|
-
avg_response_time: `${avgResp}ms`,
|
|
457
|
-
total_http: stats.http_total,
|
|
458
|
-
success_http: stats.http_success,
|
|
459
|
-
total_assert: stats.assert_total,
|
|
460
|
-
success_assert: stats.assert_success,
|
|
461
|
-
total_units: record.total,
|
|
462
|
-
actual_units: results.length
|
|
463
|
-
},
|
|
464
|
-
results: results
|
|
465
|
-
};
|
|
466
|
-
fs.writeFileSync('finalPayload.json', JSON.stringify(finalPayload))
|
|
467
|
-
// 5. 上报 API
|
|
468
|
-
const response = await axios.post(`${this.apiUrl}/open/hnzycfc/scheduled_task/report/add`, finalPayload, {
|
|
469
|
-
headers: { 'Content-Type': 'application/json', 'api-token': record.api_token },
|
|
470
|
-
maxContentLength: Infinity,
|
|
471
|
-
maxBodyLength: Infinity
|
|
472
|
-
});
|
|
473
|
-
// console.log(response?.data, 'response?.data');
|
|
474
|
-
|
|
475
|
-
if (response?.data?.code == 0) {
|
|
476
|
-
console.log(`[CronScheduler] Execution ${executionId} reported success.`);
|
|
477
|
-
// 成功后删除数据库里的临时结果
|
|
478
|
-
this.db.prepare(`DELETE FROM task_results WHERE execution_id = ?`).run(executionId);
|
|
479
|
-
} else {
|
|
480
|
-
console.error(`[Report Error] ${executionId}: ${response?.data?.msg}`);
|
|
316
|
+
runner(test_events, finalOptions, (res) => {
|
|
317
|
+
if (res?.action === 'complete') {
|
|
318
|
+
if (process.connected) {
|
|
319
|
+
process.send({ action: 'UNIT_COMPLETED', payload: { executionId, data: res.data, api_token } });
|
|
320
|
+
}
|
|
321
|
+
server.close();
|
|
322
|
+
try { if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath); } catch (e) { }
|
|
481
323
|
}
|
|
482
|
-
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
process.on('uncaughtException', (err) => {
|
|
330
|
+
console.error('[Worker Fatal] Exception:', err.message);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ==========================================
|
|
335
|
+
// 业务与辅助方法
|
|
336
|
+
// ==========================================
|
|
337
|
+
|
|
338
|
+
async _handleUnitScript(socket, action, data) {
|
|
339
|
+
try {
|
|
340
|
+
switch (action) {
|
|
341
|
+
case 'queryDatabase': {
|
|
342
|
+
const { DatabaseQuery } = require('database-query');
|
|
343
|
+
const result = await DatabaseQuery(data.dbconfig, data.query);
|
|
344
|
+
socket.write(JSON.stringify(result) + "\n\n");
|
|
345
|
+
break;
|
|
487
346
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
if (runners.length === 0) return;
|
|
496
|
-
|
|
497
|
-
const executionId = `${job.job_id}_${Date.now()}`;
|
|
498
|
-
this.resultCollector.set(executionId, {
|
|
499
|
-
total: runners.length,
|
|
500
|
-
received_count: 0, // 仅计数
|
|
501
|
-
api_token: job.api_token,
|
|
502
|
-
job_info: {
|
|
503
|
-
job_id: job.job_id,
|
|
504
|
-
name: job.name,
|
|
505
|
-
project_id: job.project_id,
|
|
506
|
-
start_time: Date.now()
|
|
507
|
-
// 如果数据库有创建人字段,也可以存入:creator: job.creator
|
|
508
|
-
}
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
runners.forEach((runner, index) => {
|
|
512
|
-
// 负载均衡:选取当前存活的 worker
|
|
513
|
-
const workers = Object.values(cluster.workers).filter(w => w.isConnected());
|
|
514
|
-
console.log(`[Dispatch] Active workers count: ${workers.length}`);
|
|
515
|
-
const worker = _.sample(workers);
|
|
516
|
-
if (worker) {
|
|
517
|
-
console.log(`[Dispatch] Sending Unit ${index} to Worker ${worker.process.pid}`);
|
|
518
|
-
worker.send({
|
|
519
|
-
action: 'EXECUTE_UNIT',
|
|
520
|
-
payload: {
|
|
521
|
-
executionId,
|
|
522
|
-
api_token: job.api_token,
|
|
523
|
-
test_events: runner.test_events,
|
|
524
|
-
option: runner.option,
|
|
525
|
-
unit_index: index
|
|
526
|
-
}
|
|
527
|
-
});
|
|
528
|
-
} else {
|
|
529
|
-
console.error(`[Dispatch Error] No active workers available to handle job ${job.job_id}`);
|
|
530
|
-
}
|
|
531
|
-
});
|
|
532
|
-
} catch (e) {
|
|
533
|
-
console.error(`[CronScheduler] Dispatch error:`, e.message);
|
|
347
|
+
case 'execute': {
|
|
348
|
+
const commandMap = { 'jar': 'java -jar', 'php': 'php -f', 'js': 'node', 'py': 'python3', 'py3': 'python3', 'go': 'go run', 'sh': 'sh' };
|
|
349
|
+
const ext = path.extname(data.file).slice(1).toLowerCase();
|
|
350
|
+
const command = `${commandMap[ext] || ''} ${data.file} ${(data.args || []).join(' ')}`;
|
|
351
|
+
const output = execSync(command, _.assign({ encoding: process.platform === 'win32' ? 'cp936' : 'utf8' }, data.option || {}));
|
|
352
|
+
socket.write(JSON.stringify({ status: 'success', result: String(output) }) + "\n\n");
|
|
353
|
+
break;
|
|
534
354
|
}
|
|
355
|
+
}
|
|
356
|
+
} catch (err) {
|
|
357
|
+
socket.write(JSON.stringify({ status: 'error', message: err.message }) + "\n\n");
|
|
535
358
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
process.on('message', async (msg) => {
|
|
548
|
-
if (msg.action === 'EXECUTE_UNIT') {
|
|
549
|
-
const { executionId, api_token, test_events, option } = msg.payload;
|
|
550
|
-
const socketPath = path.join(process?.env?.TEMP_DIR || os.tmpdir(), `echoapi_${uuidv4()}.sock`);
|
|
551
|
-
|
|
552
|
-
const server = net.createServer((socket) => {
|
|
553
|
-
socket.on('data', async (stream) => {
|
|
554
|
-
try {
|
|
555
|
-
const info = JSON.parse(stream.toString());
|
|
556
|
-
const { action, data } = info;
|
|
557
|
-
await this._handleUnitScript(socket, action, data);
|
|
558
|
-
} catch (e) {
|
|
559
|
-
socket.write(JSON.stringify({ status: 'error', message: e.message }) + "\n\n");
|
|
560
|
-
}
|
|
561
|
-
});
|
|
562
|
-
});
|
|
563
|
-
|
|
564
|
-
// --- 这里是关键!注册错误监听 ---
|
|
565
|
-
server.on('error', (err) => {
|
|
566
|
-
// 在服务器上看到这个日志,就能定位是权限问题还是路径问题
|
|
567
|
-
console.error(`[Worker Socket Server Error] PID: ${process.pid}`);
|
|
568
|
-
console.error(`[Error Details] Code: ${err.code} | Message: ${err.message}`);
|
|
569
|
-
console.error(`[Attempted Path] ${socketPath}`);
|
|
570
|
-
});
|
|
571
|
-
|
|
572
|
-
server.listen(socketPath, () => {
|
|
573
|
-
const finalOptions = _.cloneDeep(option || {});
|
|
574
|
-
const base64Pipe = Buffer.from(socketPath).toString('base64');
|
|
575
|
-
_.set(finalOptions, 'env.ELECTRON_PIPE', base64Pipe);
|
|
576
|
-
|
|
577
|
-
runner(test_events, finalOptions, (res) => {
|
|
578
|
-
if (res?.action === 'complete') {
|
|
579
|
-
process.send({
|
|
580
|
-
action: 'UNIT_COMPLETED',
|
|
581
|
-
payload: { executionId, data: res.data, api_token }
|
|
582
|
-
});
|
|
583
|
-
server.close();
|
|
584
|
-
if (fs.existsSync(socketPath)) {
|
|
585
|
-
try { fs.unlinkSync(socketPath); } catch (e) { }
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
});
|
|
589
|
-
});
|
|
590
|
-
}
|
|
591
|
-
});
|
|
592
|
-
process.on('uncaughtException', (err) => {
|
|
593
|
-
console.error('[Worker Fatal Error] 捕获到沙箱崩溃:', err.message);
|
|
594
|
-
// 即使崩溃也不要让子进程立即退出,或者让 Master 自动重启它
|
|
595
|
-
});
|
|
596
|
-
} catch (error) {
|
|
597
|
-
console.error(`[Worker ${process.pid}] Critical Boot Error:`, err.stack);
|
|
598
|
-
process.exit(1);
|
|
599
|
-
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
loadJobs() {
|
|
362
|
+
console.log('[CronScheduler] Loading timers from DB...');
|
|
363
|
+
Object.values(schedule.scheduledJobs).forEach(j => j.cancel());
|
|
364
|
+
try {
|
|
365
|
+
const jobs = this.db.prepare(`SELECT * FROM jobs WHERE is_cancel < 1`).all();
|
|
366
|
+
jobs.forEach(job => this.upsertTimer(job.job_id, job.name, job.frequency, job));
|
|
367
|
+
} catch (e) {
|
|
368
|
+
console.error('[Load Jobs Error]', e.message);
|
|
600
369
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
}
|
|
612
|
-
case 'execute': {
|
|
613
|
-
const { file, args = [], option: execOption = {} } = data;
|
|
614
|
-
const ext = path.extname(file).slice(1).toLowerCase();
|
|
615
|
-
let command = "";
|
|
616
|
-
|
|
617
|
-
switch (ext) {
|
|
618
|
-
case 'jar': command = `java -jar ${file} ${args.join(' ')}`; break;
|
|
619
|
-
case 'php': command = `php -f ${file} ${args.join(' ')}`; break;
|
|
620
|
-
case 'js': command = `node ${file} ${args.join(' ')}`; break;
|
|
621
|
-
case 'py':
|
|
622
|
-
case 'py3': command = `python3 ${file} ${args.join(' ')}`; break;
|
|
623
|
-
case 'go': command = `go run ${file} ${args.join(' ')}`; break;
|
|
624
|
-
case 'sh': command = `sh ${file} ${args.join(' ')}`; break;
|
|
625
|
-
default: command = `${file} ${args.join(' ')}`;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
const isWindows = process.platform === 'win32';
|
|
629
|
-
const config = _.assign(isWindows ? { encoding: 'cp936' } : { encoding: 'utf8' }, execOption);
|
|
630
|
-
const output = String(execSync(command, config));
|
|
631
|
-
|
|
632
|
-
socket.write(JSON.stringify({ status: 'success', result: output }) + "\n\n");
|
|
633
|
-
break;
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
} catch (err) {
|
|
637
|
-
socket.write(JSON.stringify({ status: 'error', message: err.message }) + "\n\n");
|
|
638
|
-
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
upsertTimer(job_id, name, frequency, jobData) {
|
|
373
|
+
if (schedule.scheduledJobs[job_id]) schedule.scheduledJobs[job_id].cancel();
|
|
374
|
+
if (jobData?.is_cancel < 1) {
|
|
375
|
+
const cronRule = this.convertToCron(frequency);
|
|
376
|
+
schedule.scheduleJob(job_id, cronRule, () => {
|
|
377
|
+
const currentJob = this.db.prepare(`SELECT * FROM jobs WHERE job_id = ?`).get(job_id);
|
|
378
|
+
if (currentJob && currentJob.is_cancel < 1) this._dispatch(currentJob);
|
|
379
|
+
});
|
|
639
380
|
}
|
|
381
|
+
}
|
|
640
382
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
383
|
+
createJob(payload) {
|
|
384
|
+
const { job_id, name, frequency, api_token, project_id, runners } = payload;
|
|
385
|
+
const stmt = this.db.prepare(`
|
|
386
|
+
INSERT INTO jobs (job_id, name, frequency, api_token, project_id, cases, create_dtime)
|
|
387
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
388
|
+
ON CONFLICT(job_id) DO UPDATE SET name=excluded.name, frequency=excluded.frequency, cases=excluded.cases, api_token=excluded.api_token, is_cancel=0
|
|
389
|
+
`);
|
|
390
|
+
stmt.run(job_id, name, JSON.stringify(frequency), api_token, project_id, JSON.stringify(runners || []), Date.now());
|
|
391
|
+
this.upsertTimer(job_id, name, frequency, { is_cancel: 0 });
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
cancelJob(job_id) {
|
|
396
|
+
this.db.prepare(`UPDATE jobs SET is_cancel = 1 WHERE job_id = ?`).run(job_id);
|
|
397
|
+
if (schedule.scheduledJobs[job_id]) schedule.scheduledJobs[job_id].cancel();
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* 重启已暂停的任务
|
|
647
403
|
*/
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
switch (cycle) {
|
|
665
|
-
case 'minute':
|
|
666
|
-
// 修正:强制 0 秒开始,每 interval 分钟执行
|
|
667
|
-
return `0 */${config.interval} * * * *`;
|
|
668
|
-
|
|
669
|
-
case 'hour':
|
|
670
|
-
// 修正:强制 0 秒 0 分开始,每 interval 小时执行
|
|
671
|
-
return `0 0 */${config.interval} * * *`;
|
|
672
|
-
|
|
673
|
-
case 'day': {
|
|
674
|
-
// 兼容 '10:30' 这种格式
|
|
675
|
-
const [hour, minute] = (config.time || "0:0").split(':');
|
|
676
|
-
return `0 ${parseInt(minute)} ${parseInt(hour)} * * *`;
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
case 'week': {
|
|
680
|
-
const [hour, minute] = (config.time || "0:0").split(':');
|
|
681
|
-
// config.weekdays 应该是 [1, 3, 5] 这种数组
|
|
682
|
-
const days = Array.isArray(config.weekdays) ? config.weekdays.join(',') : '*';
|
|
683
|
-
return `0 ${parseInt(minute)} ${parseInt(hour)} * * ${days}`;
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
default:
|
|
687
|
-
return '0 0 0 * * *';
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
return '0 0 0 * * *';
|
|
404
|
+
restartJob(job_id) {
|
|
405
|
+
try {
|
|
406
|
+
// 1. 更新数据库状态
|
|
407
|
+
this.db.prepare(`UPDATE jobs SET is_cancel = 0 WHERE job_id = ?`).run(job_id);
|
|
408
|
+
|
|
409
|
+
// 2. 获取最新数据并重新注册定时器
|
|
410
|
+
const job = this.db.prepare(`SELECT * FROM jobs WHERE job_id = ?`).get(job_id);
|
|
411
|
+
if (job) {
|
|
412
|
+
this.upsertTimer(job.job_id, job.name, job.frequency, job);
|
|
413
|
+
console.log(`[CronScheduler] Job ${job_id} restarted.`);
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
return false;
|
|
417
|
+
} catch (e) {
|
|
418
|
+
console.error(`[Restart Job Error] ${job_id}:`, e.message);
|
|
419
|
+
return false;
|
|
692
420
|
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
deleteJob(job_id) {
|
|
424
|
+
this.db.prepare(`DELETE FROM jobs WHERE job_id = ?`).run(job_id);
|
|
425
|
+
if (schedule.scheduledJobs[job_id]) schedule.scheduledJobs[job_id].cancel();
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
getAllJobs(project_id) {
|
|
430
|
+
const jobs = this.db.prepare(`SELECT * FROM jobs WHERE project_id = ?`).all(project_id);
|
|
431
|
+
return jobs.map(job => ({
|
|
432
|
+
..._.omit(job, ['cases']),
|
|
433
|
+
status: job.is_cancel === 0 ? 0 : 1,
|
|
434
|
+
next_run_time: this.getNextExecutionTime(this.convertToCron(job.frequency)),
|
|
435
|
+
runners: JSON5.parse(job.cases || '[]').map(v => v?.option?.testing_id)
|
|
436
|
+
}));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
convertToCron(freq) {
|
|
440
|
+
const data = typeof freq === 'string' ? JSON5.parse(freq) : freq;
|
|
441
|
+
if (data.type === 'cron') {
|
|
442
|
+
const exp = data.cron?.expression || '0 0 * * *';
|
|
443
|
+
const parts = exp.trim().split(/\s+/);
|
|
444
|
+
return parts.length === 5 ? `0 ${exp}` : exp;
|
|
445
|
+
}
|
|
446
|
+
if (data.type === 'preset') {
|
|
447
|
+
const { cycle, config } = data.preset;
|
|
448
|
+
const [h, m] = (config.time || "0:0").split(':');
|
|
449
|
+
if (cycle === 'minute') return `0 */${config.interval} * * * *`;
|
|
450
|
+
if (cycle === 'hour') return `0 0 */${config.interval} * * *`;
|
|
451
|
+
if (cycle === 'day') return `0 ${parseInt(m)} ${parseInt(h)} * * *`;
|
|
452
|
+
if (cycle === 'week') return `0 ${parseInt(m)} ${parseInt(h)} * * ${Array.isArray(config.weekdays) ? config.weekdays.join(',') : '*'}`;
|
|
703
453
|
}
|
|
454
|
+
return '0 0 0 * * *';
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
getNextExecutionTime(cron) {
|
|
458
|
+
try {
|
|
459
|
+
const { CronExpressionParser } = require('cron-parser');
|
|
460
|
+
return CronExpressionParser.parse(cron, { tz: 'Asia/Shanghai' }).next().getTime();
|
|
461
|
+
} catch (e) { return '-'; }
|
|
462
|
+
}
|
|
704
463
|
}
|
|
705
464
|
|
|
706
465
|
module.exports = CronScheduler;
|