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.
Files changed (3) hide show
  1. package/index.js +394 -635
  2. package/index_bak.js +725 -0
  3. 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
- constructor(config = {}) {
18
- // 配置初始化
19
- this.apiUrl = config.apiUrl || process?.env["OPENAPI_DOMAIN"] || "https://ee.apipost.cc";
20
- this.dbFile = config.dbPath || path.resolve(process?.env?.TEMP_DIR || os.tmpdir(), 'echoapi-batch-tasks.sqlite');
21
- this.workerNum = config.workerNum || os.cpus().length;
22
-
23
- // 结果聚合收集器:用于收集同一批次下所有 Runner 的结果
24
- this.resultCollector = new Map();
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
- _initDB() {
36
- // 1. 任务配置表
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
- this.db.pragma('journal_mode = WAL');
39
- this.db.prepare(`
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, -- 存储 runners 数组 JSON
94
+ cases TEXT,
48
95
  create_dtime INTEGER
49
96
  )
50
97
  `).run();
51
98
 
52
- // 2. 新增:中间结果结果暂存表 (用于卸载 Master 内存压力)
53
- this.db.prepare(`
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
- this.db.prepare(`CREATE INDEX IF NOT EXISTS idx_exec_id ON task_results(execution_id)`).run();
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
- run() {
68
- if (cluster.isPrimary) {
69
- this._startMaster();
70
- } else {
71
- this._startWorker();
72
- }
73
- }
74
- /**
75
- * Converts timestamp to ISO format
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
- // Master 进程逻辑:负责任务分发与结果聚合
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
- cluster.on('exit', (worker) => {
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
- this.loadJobs();
140
+ for (let i = 0; i < this.workerNum; i++) {
141
+ this._bindWorker(cluster.fork());
106
142
  }
107
143
 
108
- _bindWorker(worker) {
109
- worker.on('message', async (msg) => {
110
- if (msg.action === 'UNIT_COMPLETED') {
111
- const { executionId, data } = msg.payload;
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
- * 将聚合后的结果一次性推送到 OpenAPI
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
- // 2. 如果任务未被取消 (is_cancel < 1)
294
- if (jobData && jobData.is_cancel < 1) {
295
- try {
296
- const freq = typeof frequency === 'string' ? JSON5.parse(frequency) : frequency;
297
- const cronRule = this.convertToCron(freq);
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
- // 公共接口:供 Express 路由层调用
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
- const stmt = this.db.prepare(`
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
- // 使用增量更新,不再 loadJobs()
341
- this.upsertTimer(job_id, name, frequency, { is_cancel: 0 });
342
- return true;
343
- }
344
-
345
- cancelJob(job_id) {
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
- return true;
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
- restartJob(job_id) {
354
- this.db.prepare(`UPDATE jobs SET is_cancel = 0 WHERE job_id = ?`).run(job_id);
355
- const job = this.db.prepare(`SELECT * FROM jobs WHERE job_id = ?`).get(job_id);
356
- if (job) {
357
- this.upsertTimer(job.job_id, job.name, job.frequency, job);
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
- return true;
360
- }
361
-
362
- deleteJob(job_id) {
363
- this.db.prepare(`DELETE FROM jobs WHERE job_id = ?`).run(job_id);
364
- if (schedule.scheduledJobs[job_id]) {
365
- schedule.scheduledJobs[job_id].cancel();
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
- return true;
368
- }
369
-
370
- getAllJobs(project_id) {
371
- const jobs = this.db.prepare(`SELECT * FROM jobs WHERE project_id = ?`).all(project_id);
372
- // 返回列表时剔除巨大的 cases 字段,优化内存
373
- return _.map(jobs, job => {
374
- const freq = JSON5.parse(job.frequency || '{}');
375
- const cases = JSON5.parse(job.cases || '[]');
376
- const cronRule = this.convertToCron(freq);
377
-
378
- // 获取下次执行时间
379
- // 如果任务已取消,则不显示下次执行时间
380
- let nextRunTime = '-';
381
- if (job.is_cancel < 1) {
382
- nextRunTime = this.getNextExecutionTime(cronRule);
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
- loadJobs() {
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
- const jobs = this.db.prepare(`SELECT * FROM jobs WHERE is_cancel < 1`).all();
401
- jobs.forEach(job => {
402
- this.upsertTimer(job.job_id, job.name, job.frequency, job);
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
- async _reportFromDB(executionId, record) {
410
- try {
411
- console.log(`[CronScheduler] Batch job finished. Aggregating results from DB for: ${executionId}`);
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
- } catch (err) {
484
- console.error(`[Aggregation Critical Error]`, err.stack);
485
- } finally {
486
- this.resultCollector.delete(executionId);
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
- * 分发逻辑:将一个批次内的所有 Runner 均衡分配给 Workers
491
- */
492
- _dispatch(job) {
493
- try {
494
- const runners = JSON5.parse(job.cases || '[]');
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
- // Worker 进程逻辑:负责具体的任务执行
539
- // ==========================================
540
-
541
- _startWorker() {
542
- try {
543
- console.log(`[Worker ${process.pid}] Starting initialization...`);
544
- const { run: runner } = require('runner-runtime');
545
- const net = require('net');
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
- async _handleUnitScript(socket, action, data) {
603
- try {
604
- switch (action) {
605
- case 'queryDatabase': {
606
- const { dbconfig, query } = data;
607
- const { DatabaseQuery } = require('database-query');
608
- const result = await DatabaseQuery(dbconfig, query);
609
- socket.write(JSON.stringify(result) + "\n\n");
610
- break;
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
- * 修正后的 Cron 转换逻辑:确保每一项都从第 0 秒开始执行
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
- convertToCron(freq) {
649
- if (!freq) return '0 0 0 * * *';
650
- const data = typeof freq === 'string' ? JSON5.parse(freq) : freq;
651
-
652
- // 情况 A:用户直接传 Cron 表达式
653
- if (data.type === 'cron') {
654
- const exp = data.cron?.expression || '0 0 * * *';
655
- const parts = exp.trim().split(/\s+/);
656
- // 如果是 5 位,补齐第 0 位为 0 秒,确保不重复触发
657
- return parts.length === 5 ? `0 ${exp}` : exp;
658
- }
659
-
660
- // 情况 B:预设周期配置
661
- if (data.type === 'preset') {
662
- const { cycle, config } = data.preset;
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
- getNextExecutionTime(cron) {
695
-
696
- try {
697
- const { CronExpressionParser } = require('cron-parser');
698
- const interval = CronExpressionParser.parse(cron, { tz: 'Asia/Shanghai', utc: false });
699
- return new Date(interval.next().toISOString()).getTime()
700
- } catch (e) {
701
- return '-';
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;