echoapi-cron-scheduler-batch 1.0.3 → 1.0.5
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 +273 -147
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -16,7 +16,7 @@ const { execSync } = require('child_process');
|
|
|
16
16
|
class CronScheduler {
|
|
17
17
|
constructor(config = {}) {
|
|
18
18
|
// 配置初始化
|
|
19
|
-
this.apiUrl = config.apiUrl || process?.env["OPENAPI_DOMAIN"] || "https://
|
|
19
|
+
this.apiUrl = config.apiUrl || process?.env["OPENAPI_DOMAIN"] || "https://ee.apipost.cc";
|
|
20
20
|
this.dbFile = config.dbPath || path.resolve(os.tmpdir(), 'echoapi-batch-tasks.sqlite');
|
|
21
21
|
this.workerNum = config.workerNum || os.cpus().length;
|
|
22
22
|
|
|
@@ -33,6 +33,8 @@ class CronScheduler {
|
|
|
33
33
|
* 内部方法:初始化数据库表结构
|
|
34
34
|
*/
|
|
35
35
|
_initDB() {
|
|
36
|
+
// 1. 任务配置表
|
|
37
|
+
|
|
36
38
|
this.db.pragma('journal_mode = WAL');
|
|
37
39
|
this.db.prepare(`
|
|
38
40
|
CREATE TABLE IF NOT EXISTS jobs (
|
|
@@ -46,6 +48,17 @@ class CronScheduler {
|
|
|
46
48
|
create_dtime INTEGER
|
|
47
49
|
)
|
|
48
50
|
`).run();
|
|
51
|
+
|
|
52
|
+
// 2. 新增:中间结果结果暂存表 (用于卸载 Master 内存压力)
|
|
53
|
+
this.db.prepare(`
|
|
54
|
+
CREATE TABLE IF NOT EXISTS task_results (
|
|
55
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
56
|
+
execution_id TEXT,
|
|
57
|
+
data TEXT,
|
|
58
|
+
create_at INTEGER
|
|
59
|
+
)
|
|
60
|
+
`).run();
|
|
61
|
+
this.db.prepare(`CREATE INDEX IF NOT EXISTS idx_exec_id ON task_results(execution_id)`).run();
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
/**
|
|
@@ -100,14 +113,29 @@ class CronScheduler {
|
|
|
100
113
|
|
|
101
114
|
if (record) {
|
|
102
115
|
|
|
103
|
-
record.received.push(_.assign(data, {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}));
|
|
108
|
-
// 检查是否收齐了该 Job 下所有的 runners 结果
|
|
109
|
-
if (record.received.length >= record.total) {
|
|
110
|
-
|
|
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);
|
|
111
139
|
}
|
|
112
140
|
}
|
|
113
141
|
}
|
|
@@ -117,103 +145,103 @@ class CronScheduler {
|
|
|
117
145
|
/**
|
|
118
146
|
* 将聚合后的结果一次性推送到 OpenAPI
|
|
119
147
|
*/
|
|
120
|
-
async _reportAggregated(executionId, record) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
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
|
+
// }
|
|
217
245
|
/**
|
|
218
246
|
* 查看当前所有待执行(活跃)的任务及其详细信息
|
|
219
247
|
*/
|
|
@@ -375,6 +403,86 @@ class CronScheduler {
|
|
|
375
403
|
});
|
|
376
404
|
}
|
|
377
405
|
|
|
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
|
+
user_uid: record.job_info.user_uid,
|
|
452
|
+
execution_id: executionId,
|
|
453
|
+
start_at: this.formatTimeToISO(record.job_info.start_time),
|
|
454
|
+
end_at: this.formatTimeToISO(Date.now()),
|
|
455
|
+
http_pass_rate: `${httpRate}%`,
|
|
456
|
+
assert_pass_rate: `${assertRate}%`,
|
|
457
|
+
avg_response_time: `${avgResp}ms`,
|
|
458
|
+
total_units: record.total,
|
|
459
|
+
actual_units: results.length
|
|
460
|
+
},
|
|
461
|
+
results: results
|
|
462
|
+
};
|
|
463
|
+
// fs.writeFileSync('finalPayload.json', JSON.stringify(finalPayload))
|
|
464
|
+
// 5. 上报 API
|
|
465
|
+
const response = await axios.post(`${this.apiUrl}/open/hnzycfc/scheduled_task/report/add`, finalPayload, {
|
|
466
|
+
headers: { 'Content-Type': 'application/json', 'api-token': record.api_token },
|
|
467
|
+
maxContentLength: Infinity,
|
|
468
|
+
maxBodyLength: Infinity
|
|
469
|
+
});
|
|
470
|
+
// console.log(response?.data, 'response?.data');
|
|
471
|
+
|
|
472
|
+
if (response?.data?.code == 0) {
|
|
473
|
+
console.log(`[CronScheduler] Execution ${executionId} reported success.`);
|
|
474
|
+
// 成功后删除数据库里的临时结果
|
|
475
|
+
this.db.prepare(`DELETE FROM task_results WHERE execution_id = ?`).run(executionId);
|
|
476
|
+
} else {
|
|
477
|
+
console.error(`[Report Error] ${executionId}: ${response?.data?.msg}`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
} catch (err) {
|
|
481
|
+
console.error(`[Aggregation Critical Error]`, err.stack);
|
|
482
|
+
} finally {
|
|
483
|
+
this.resultCollector.delete(executionId);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
378
486
|
/**
|
|
379
487
|
* 分发逻辑:将一个批次内的所有 Runner 均衡分配给 Workers
|
|
380
488
|
*/
|
|
@@ -386,7 +494,7 @@ class CronScheduler {
|
|
|
386
494
|
const executionId = `${job.job_id}_${Date.now()}`;
|
|
387
495
|
this.resultCollector.set(executionId, {
|
|
388
496
|
total: runners.length,
|
|
389
|
-
|
|
497
|
+
received_count: 0, // 仅计数
|
|
390
498
|
api_token: job.api_token,
|
|
391
499
|
job_info: {
|
|
392
500
|
job_id: job.job_id,
|
|
@@ -400,8 +508,10 @@ class CronScheduler {
|
|
|
400
508
|
runners.forEach((runner, index) => {
|
|
401
509
|
// 负载均衡:选取当前存活的 worker
|
|
402
510
|
const workers = Object.values(cluster.workers).filter(w => w.isConnected());
|
|
511
|
+
console.log(`[Dispatch] Active workers count: ${workers.length}`);
|
|
403
512
|
const worker = _.sample(workers);
|
|
404
513
|
if (worker) {
|
|
514
|
+
console.log(`[Dispatch] Sending Unit ${index} to Worker ${worker.process.pid}`);
|
|
405
515
|
worker.send({
|
|
406
516
|
action: 'EXECUTE_UNIT',
|
|
407
517
|
payload: {
|
|
@@ -412,6 +522,8 @@ class CronScheduler {
|
|
|
412
522
|
unit_index: index
|
|
413
523
|
}
|
|
414
524
|
});
|
|
525
|
+
} else {
|
|
526
|
+
console.error(`[Dispatch Error] No active workers available to handle job ${job.job_id}`);
|
|
415
527
|
}
|
|
416
528
|
});
|
|
417
529
|
} catch (e) {
|
|
@@ -424,50 +536,64 @@ class CronScheduler {
|
|
|
424
536
|
// ==========================================
|
|
425
537
|
|
|
426
538
|
_startWorker() {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
539
|
+
try {
|
|
540
|
+
console.log(`[Worker ${process.pid}] Starting initialization...`);
|
|
541
|
+
const { run: runner } = require('runner-runtime');
|
|
542
|
+
const net = require('net');
|
|
543
|
+
|
|
544
|
+
process.on('message', async (msg) => {
|
|
545
|
+
if (msg.action === 'EXECUTE_UNIT') {
|
|
546
|
+
const { executionId, api_token, test_events, option } = msg.payload;
|
|
547
|
+
const socketPath = path.join(os.tmpdir(), `echoapi_${uuidv4()}.sock`);
|
|
548
|
+
|
|
549
|
+
const server = net.createServer((socket) => {
|
|
550
|
+
socket.on('data', async (stream) => {
|
|
551
|
+
try {
|
|
552
|
+
const info = JSON.parse(stream.toString());
|
|
553
|
+
const { action, data } = info;
|
|
554
|
+
await this._handleUnitScript(socket, action, data);
|
|
555
|
+
} catch (e) {
|
|
556
|
+
socket.write(JSON.stringify({ status: 'error', message: e.message }) + "\n\n");
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// --- 这里是关键!注册错误监听 ---
|
|
562
|
+
server.on('error', (err) => {
|
|
563
|
+
// 在服务器上看到这个日志,就能定位是权限问题还是路径问题
|
|
564
|
+
console.error(`[Worker Socket Server Error] PID: ${process.pid}`);
|
|
565
|
+
console.error(`[Error Details] Code: ${err.code} | Message: ${err.message}`);
|
|
566
|
+
console.error(`[Attempted Path] ${socketPath}`);
|
|
444
567
|
});
|
|
445
|
-
});
|
|
446
568
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
569
|
+
server.listen(socketPath, () => {
|
|
570
|
+
const finalOptions = _.cloneDeep(option || {});
|
|
571
|
+
const base64Pipe = Buffer.from(socketPath).toString('base64');
|
|
572
|
+
_.set(finalOptions, 'env.ELECTRON_PIPE', base64Pipe);
|
|
573
|
+
|
|
574
|
+
runner(test_events, finalOptions, (res) => {
|
|
575
|
+
if (res?.action === 'complete') {
|
|
576
|
+
process.send({
|
|
577
|
+
action: 'UNIT_COMPLETED',
|
|
578
|
+
payload: { executionId, data: res.data, api_token }
|
|
579
|
+
});
|
|
580
|
+
server.close();
|
|
581
|
+
if (fs.existsSync(socketPath)) {
|
|
582
|
+
try { fs.unlinkSync(socketPath); } catch (e) { }
|
|
583
|
+
}
|
|
461
584
|
}
|
|
462
|
-
}
|
|
585
|
+
});
|
|
463
586
|
});
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
})
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
process.on('uncaughtException', (err) => {
|
|
590
|
+
console.error('[Worker Fatal Error] 捕获到沙箱崩溃:', err.message);
|
|
591
|
+
// 即使崩溃也不要让子进程立即退出,或者让 Master 自动重启它
|
|
592
|
+
});
|
|
593
|
+
} catch (error) {
|
|
594
|
+
console.error(`[Worker ${process.pid}] Critical Boot Error:`, err.stack);
|
|
595
|
+
process.exit(1);
|
|
596
|
+
}
|
|
471
597
|
}
|
|
472
598
|
|
|
473
599
|
async _handleUnitScript(socket, action, data) {
|