multi-tasks 3.1.5 → 3.1.7

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/README.md CHANGED
@@ -17,7 +17,7 @@ The API has three parts: the entry functions, the config options, and the `helpe
17
17
  **Entry functions:**
18
18
 
19
19
  - **`multiTasks(config)`** — run tasks; auto-resumes if the task folder already exists or `initialTasks` points to one.
20
- - **`multiTasks.resume(config)`** — resume an interrupted run; pass a config object with `taskFolder` (the full task folder path). The whole config (including `processTask`) is restored from `task_config.json` in that folder; any field you pass overrides the snapshot, and overrides are written back into it.
20
+ - **`multiTasks.resume(config)`** — usually you don't need this: the simplest way to resume is to just run `multiTasks(config)` again, it auto-resumes when the task folder already exists. This method resumes an interrupted run from the task folder alone; pass a config object with `taskFolder` (the full task folder path). The whole config (including `processTask`) is restored from `task_config.json` in that folder; any field you pass overrides the snapshot, and overrides are written back into it.
21
21
  - **`multiTasks.retry_fails(config)`** — same as `resume`, and also retries the failed tasks.
22
22
  - **`multiTasks.restart(config)`** — wipe the task folder's progress and re-run from scratch; pass a config object with `taskFolder`. The config (including `processTask` and `initialTasks`) is restored from `task_config.json`; any field you pass overrides the snapshot, and overrides are written back into it. **All previous progress and results are deleted.**
23
23
 
@@ -33,7 +33,10 @@ The API has three parts: the entry functions, the config options, and the `helpe
33
33
  - **`autoCloseAfterCompletion`** — set `true` to kill workers and exit the process when no tasks are left; default is `false`, i.e. workers stay alive waiting for dynamically created tasks.
34
34
  - **`shouldTerminate(info)`** — return `true` to terminate the whole process.
35
35
  - **`setSysListener(event, payload, meta)`** — optional, handle custom system events (sent via `helper.emitSys`) on the master; `meta` has `fromWorkerId` and `fromWorkerPid`.
36
- - **`onFinish(report)`** — called once after all workers exit (only when `autoCloseAfterCompletion` is `true`); `report` is an empty object for now.
36
+ - **`onFinish(report, helper)`** — called once after all workers exit (only when `autoCloseAfterCompletion` is `true`). `report` summarizes the run: `{ taskId, taskFolder, startTimestamp, endTimestamp, cost, total, succeeded, failed, failedTasks, workers }` — `cost` is the run duration in ms (a resumed run measures only the current round); `failedTasks` lists the failed tasks' subids; `workers` is `{ count, crashed }` — the configured worker count and how many workers crashed and were replaced. `helper` lets you walk every persisted task result:
37
+ - **`helper.foreachSuccResult((count, task, result) => {})`** — iterate all succeeded results (`results/succ`); `count` is a 0-based index, `task` is the original task object (`null` if its task file is missing or unparseable), `result` is the value returned by `processTask` (`undefined` when the task returned nothing).
38
+ - **`helper.foreachErrorResult((count, task, err) => {})`** — iterate all failed results (`results/errors`); `err` is the persisted error object as-is (`{ex}` / `{subTask, exception}` / `{type: 'timeout', ...}` / `{type: 'worker_crash', ...}`).
39
+ - Iteration follows filesystem order (not sorted). `onFinish` runs synchronously at the very end and the master process exits shortly after it returns — don't `await` inside it.
37
40
 
38
41
  **Other options:**
39
42
 
@@ -84,8 +87,14 @@ multiTasks({
84
87
  shouldTerminate:(info)=>{
85
88
  //return true if you need to terminate the whole process
86
89
  },
87
- onFinish: (report)=>{
90
+ onFinish: (report, helper)=>{
88
91
  console.log('finish callback', report);
92
+ helper.foreachSuccResult((count, task, result)=>{
93
+ console.log(`#${count} succeeded:`, task.name, result);
94
+ });
95
+ helper.foreachErrorResult((count, task, err)=>{
96
+ console.log(`#${count} failed:`, task.name, err);
97
+ });
89
98
  }
90
99
  });
91
100
 
@@ -263,12 +272,14 @@ Unlike `retry_fails` (which re-runs only the failed tasks), `restart` re-runs **
263
272
 
264
273
  ### Changelog:
265
274
 
275
+ - 3.1.7 onFinish: iterate all succ/failed results via helper.foreachSuccResult/foreachErrorResult
276
+ - 3.1.6 Fix silent task loss under heavy load with maxTaskRetries
266
277
  - 3.1.5 Update README
267
- - 3.1.4 Rewrite `multiTasks.resume`, `multiTasks.retry_fails` and `multiTasks.restart`
278
+ - 3.1.4 Rewrite methods multiTasks.resume/retry_fails/restart
268
279
  - 3.1.3 Update README
269
280
  - 3.1.2 Update README
270
281
  - 3.1.1 Fix readme documentation
271
- - 3.1.0 Support worker broadcast ('helper.emit' and 'helper.setListener') and system events ('helper.emitSys' and 'setSysListener', with built-in 'TERMINATE_ALL_WORKERS'); default numberOfWorkers is now "50%" of CPU cores (was core count minus 1)
282
+ - 3.1.0 Support worker broadcast and system events; default numberOfWorkers is now "50%" of CPU cores
272
283
  - 3.0.4 numberOfWorkers accepts a percentage string of CPU cores, e.g. "50%"
273
284
  - 3.0.3 Support 'maxTaskRetries'
274
285
  - 3.0.2 Support 'taskTimeout'
package/package.json CHANGED
@@ -1,36 +1,36 @@
1
- {
2
- "name": "multi-tasks",
3
- "version": "3.1.5",
4
- "description": "Multi-process task scheduling based on Node.js cluster, with crash resume and failed-task restart support",
5
- "main": "index.js",
6
- "files": [
7
- "index.js",
8
- "workers",
9
- "utils/*.js"
10
- ],
11
- "repository": {
12
- "type": "git",
13
- "url": "https://gitee.com/zhanglei923/multi-tasks.git"
14
- },
15
- "directories": {
16
- "example": "examples"
17
- },
18
- "engines": {
19
- "node": ">=14.14"
20
- },
21
- "scripts": {
22
- "test": "jest",
23
- "prepublishOnly": "npm test"
24
- },
25
- "keywords": [
26
- "multitask",
27
- "worker",
28
- "multithread"
29
- ],
30
- "author": "zhanglei923@gmail.com",
31
- "license": "MIT",
32
- "devDependencies": {
33
- "cli-progress": "^3.12.0",
34
- "jest": "^29.7.0"
35
- }
36
- }
1
+ {
2
+ "name": "multi-tasks",
3
+ "version": "3.1.7",
4
+ "description": "Multi-process task scheduling based on Node.js cluster, with crash resume and failed-task restart support",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "workers",
9
+ "utils/*.js"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://gitee.com/zhanglei923/multi-tasks.git"
14
+ },
15
+ "directories": {
16
+ "example": "examples"
17
+ },
18
+ "engines": {
19
+ "node": ">=14.14"
20
+ },
21
+ "scripts": {
22
+ "test": "jest",
23
+ "prepublishOnly": "npm test"
24
+ },
25
+ "keywords": [
26
+ "multitask",
27
+ "worker",
28
+ "multithread"
29
+ ],
30
+ "author": "zhanglei923@gmail.com",
31
+ "license": "MIT",
32
+ "devDependencies": {
33
+ "cli-progress": "^3.12.0",
34
+ "jest": "^29.7.0"
35
+ }
36
+ }
@@ -0,0 +1,30 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ //同步遍历某个结果目录(results/succ 或 results/errors)下的全部 .json 结果文件,
5
+ //逐个回调 cb(count, task, result):
6
+ // count —— 0 起始的遍历序号;
7
+ // task —— 按 subid 从 taskProgressFolder 读回的任务文件内容(缺失/解析失败为 null);
8
+ // result —— 结果文件的 JSON 内容(解析失败, 如字面量 'undefined', 传 undefined, 不跳过)。
9
+ //仅在 onFinish 时机使用: 此时文件已全部落盘, 无并发写。
10
+ const forEachResult = (resultFolder, taskProgressFolder, cb) => {
11
+ const files = fs.readdirSync(resultFolder).filter((f) => f.endsWith('.json'));
12
+ files.forEach((fname, count) => {
13
+ const subid = fname.replace(/\.json$/, '');
14
+ let task = null;
15
+ try {
16
+ task = JSON.parse(fs.readFileSync(path.join(taskProgressFolder, `${subid}.task.json`), 'utf-8'));
17
+ } catch (e) {
18
+ task = null;
19
+ };
20
+ let result;
21
+ try {
22
+ result = JSON.parse(fs.readFileSync(path.join(resultFolder, fname), 'utf-8'));
23
+ } catch (e) {
24
+ result = undefined;
25
+ };
26
+ cb(count, task, result);
27
+ });
28
+ };
29
+
30
+ module.exports = { forEachResult };
@@ -58,7 +58,10 @@ const start = (config)=>{
58
58
  console.log(` PID=${PID}`);
59
59
 
60
60
  const AsyncQueue = require(`../utils/asyncQueue`);
61
- const queue = new AsyncQueue();
61
+ const queue = new AsyncQueue();
62
+
63
+ const masterStartTimestamp = new Date()*1;//本轮 master 启动时间(resume 不追溯首轮)
64
+ let workerCrashCount = 0;//累计崩溃补员次数, 进 onFinish report
62
65
 
63
66
  let clearTaskTimer = (workerId)=>{
64
67
  let rec = workerTaskTimers[workerId];
@@ -217,6 +220,7 @@ const start = (config)=>{
217
220
  clearTaskTimer(worker.id);//worker 退出(含崩溃)时清掉其超时计时器
218
221
  if (code !== 0 && !worker.exitedAfterDisconnect) {
219
222
  console.log('Worker died unexpectedly starting a new one...');
223
+ workerCrashCount++;
220
224
  if(rec && rec.subTask){
221
225
  handleCrashedTask(rec.subTask);
222
226
  };
@@ -230,7 +234,25 @@ const start = (config)=>{
230
234
  }
231
235
  if(allkilled){
232
236
  if(USER_CONFIG.onFinish){
233
- USER_CONFIG.onFinish({});
237
+ let endTimestamp = new Date()*1;
238
+ let stats = TaskMgr.getFinalStats();
239
+ let report = {
240
+ taskId: config.masterTaskId,
241
+ taskFolder: stats.taskFolder,
242
+ startTimestamp: masterStartTimestamp,
243
+ endTimestamp,
244
+ cost: endTimestamp - masterStartTimestamp,
245
+ total: stats.succeeded + stats.failed,
246
+ succeeded: stats.succeeded,
247
+ failed: stats.failed,
248
+ failedTasks: stats.failedTasks,
249
+ workers: {count: numOfWorkers, crashed: workerCrashCount},
250
+ };
251
+ let onFinishHelper = {
252
+ foreachSuccResult: (cb) => TaskMgr.forEachSuccResult(cb),
253
+ foreachErrorResult: (cb) => TaskMgr.forEachErrorResult(cb),
254
+ };
255
+ USER_CONFIG.onFinish(report, onFinishHelper);
234
256
  delete USER_CONFIG.onFinish;
235
257
  }
236
258
  setTimeout(()=>{
@@ -309,7 +331,7 @@ function runTask(config, subTask, subTaskLog){
309
331
  resultData = data;
310
332
  }).catch((ex)=>{
311
333
  errors.push({ex: serializeError(ex)});
312
- }).finally(()=>{
334
+ }).finally(async ()=>{
313
335
  let {startTimestamp} = subTaskLog;
314
336
  let endTimestamp = new Date()*1;
315
337
  let cost = endTimestamp - startTimestamp;
@@ -325,8 +347,14 @@ function runTask(config, subTask, subTaskLog){
325
347
  let retryCount = TaskMgr.incrementTaskRetry(subTask.subid);
326
348
  if(retryCount <= config.maxTaskRetries){
327
349
  console.warn(`[Task Retry] "${subTask.subid}" failed, retry (${retryCount}/${config.maxTaskRetries})`);
328
- TaskMgr.mvTask(subTask.subid, 'running', 'new', ()=>{});
329
350
  retried = true;
351
+ //等 rename 落盘再 resolve/请求新任务: fire-and-forget 的异步 rename 会随 worker 被
352
+ //autoClose 杀掉而消亡, 任务文件滞留 running 被静默丢弃(高负载偶发丢任务的根因, 勿回退)
353
+ try{
354
+ await TaskMgr.mvTaskPromise(subTask.subid, 'running', 'new');
355
+ }catch(newPath){
356
+ console.warn(`[Task Retry] "${subTask.subid}" failed to move back to new, left in running (recoverable via resume)`);
357
+ };
330
358
  };
331
359
  };
332
360
  TaskMgr.saveTaskLog(subTask.subid, subTaskLog);
@@ -3,6 +3,7 @@ const pathutil = require('path');
3
3
  const makeDir = require('../utils/makedir');
4
4
  const randomsUtil = require('../utils/randoms');
5
5
  const { serializeConfig } = require('../utils/serializeFns');
6
+ const { forEachResult } = require('../utils/foreachResults');
6
7
 
7
8
  let allSubTasks = {
8
9
  new:[],
@@ -175,6 +176,21 @@ const TaskMgr = {
175
176
  callback(files);
176
177
  });
177
178
  },
179
+ getFinalStats:()=>{//master 收尾用: 以盘上终态为准统计成功/失败(resume 跨轮累计也是对的)
180
+ const {taskFolder, taskFolderFinished, taskFolderFailed} = main_config;
181
+ const listSubids = (folder)=>{
182
+ return fs.readdirSync(folder)
183
+ .filter((fname)=>fname.endsWith('.task.json'))
184
+ .map((fname)=>fname.replace(/\.task\.json$/, ''));
185
+ };
186
+ const failedTasks = listSubids(taskFolderFailed);
187
+ return {
188
+ taskFolder,
189
+ succeeded: listSubids(taskFolderFinished).length,
190
+ failed: failedTasks.length,
191
+ failedTasks,
192
+ };
193
+ },
178
194
  mvTaskPromise: (subid, from, to)=> {
179
195
  return new Promise((resolve, reject)=>{
180
196
  TaskMgr.mvTask(subid, from, to, (newPath, succ)=>{
@@ -257,6 +273,14 @@ const TaskMgr = {
257
273
  delete task.__retryCount;
258
274
  fs.writeFileSync(fpath, JSON.stringify(task));
259
275
  },
276
+ forEachSuccResult:(cb)=>{
277
+ let {taskFolderResult, taskFolderFinished} = main_config;
278
+ forEachResult(taskFolderResult, taskFolderFinished, cb);
279
+ },
280
+ forEachErrorResult:(cb)=>{
281
+ let {taskFolderResultFailed, taskFolderFailed} = main_config;
282
+ forEachResult(taskFolderResultFailed, taskFolderFailed, cb);
283
+ },
260
284
  resetTaskFolders:(taskFolder)=>{
261
285
  fs.rmSync(taskFolder, {recursive: true, force: true});
262
286
  },