multi-tasks 3.5.1 → 4.0.0-rc.1

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/bench-storage.svg CHANGED
@@ -1,17 +1,21 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="720" height="252" viewBox="0 0 720 252" font-family="Segoe UI, Helvetica, Arial, sans-serif">
2
- <rect x="1" y="1" width="718" height="250" rx="8" fill="#ffffff" stroke="#dadce0"/>
3
- <text x="24" y="38" font-size="17" font-weight="600" fill="#202124">task_storage benchmark — 30,000 tasks × 28 workers</text>
4
- <text x="24" y="60" font-size="12" fill="#5f6368">wall-clock time of a full run, average of 3 runs · lower is better</text>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="720" height="310" viewBox="0 0 720 310" font-family="Segoe UI, Helvetica, Arial, sans-serif">
2
+ <rect x="1" y="1" width="718" height="308" rx="8" fill="#ffffff" stroke="#dadce0"/>
3
+ <text x="24" y="38" font-size="17" font-weight="600" fill="#202124">task_storage benchmark — 100,000 tasks × 28 workers</text>
4
+ <text x="24" y="60" font-size="12" fill="#5f6368">wall-clock time of a full run, average of 2 runs · lower is better</text>
5
5
 
6
6
  <text x="112" y="114" font-size="14" fill="#202124" text-anchor="end">file</text>
7
7
  <rect x="120" y="94" width="420" height="34" rx="3" fill="#9aa0a6"/>
8
- <text x="548" y="116" font-size="14" fill="#202124">195.1s · 154 tasks/s</text>
8
+ <text x="548" y="116" font-size="14" fill="#202124">439.0s · 228 tasks/s</text>
9
9
 
10
- <text x="112" y="172" font-size="14" fill="#202124" text-anchor="end">memory</text>
11
- <rect x="120" y="152" width="24" height="34" rx="3" fill="#188038"/>
12
- <text x="152" y="174" font-size="14" fill="#202124">11.2s · 2,676 tasks/s</text>
13
- <text x="360" y="174" font-size="15" font-weight="700" fill="#188038">17.4× faster</text>
10
+ <text x="112" y="172" font-size="14" fill="#202124" text-anchor="end">aof</text>
11
+ <rect x="120" y="152" width="9" height="34" rx="3" fill="#188038"/>
12
+ <text x="137" y="174" font-size="14" fill="#202124">8.7s · 11,468 tasks/s</text>
13
+ <text x="420" y="174" font-size="15" font-weight="700" fill="#188038">50× faster than file</text>
14
14
 
15
- <line x1="120" y1="208" x2="540" y2="208" stroke="#e8eaed"/>
16
- <text x="24" y="232" font-size="11" fill="#5f6368">per-round walls — file: 202.7 / 193.6 / 188.9s · memory: 11.3 / 11.1 / 11.3s · Windows 11, SSD · script: test-manual/bench-storage.js</text>
15
+ <text x="112" y="230" font-size="14" fill="#202124" text-anchor="end">memory</text>
16
+ <rect x="120" y="210" width="7" height="34" rx="3" fill="#1a73e8"/>
17
+ <text x="135" y="232" font-size="14" fill="#202124">7.3s · 13,706 tasks/s</text>
18
+
19
+ <line x1="120" y1="266" x2="540" y2="266" stroke="#e8eaed"/>
20
+ <text x="24" y="290" font-size="11" fill="#5f6368">per-round walls — file: 441.0 / 437.1s · aof: 8.7 / 8.8s · memory: 7.3 / 7.3s · Windows 11, SSD · script: test-manual/bench-storage.js</text>
17
21
  </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multi-tasks",
3
- "version": "3.5.1",
3
+ "version": "4.0.0-rc.1",
4
4
  "description": "Multi-process task scheduling based on Node.js cluster, with crash resume and failed-task restart support",
5
5
  "main": "index.js",
6
6
  "files": [
package/utils/makedir.js CHANGED
@@ -7,14 +7,15 @@ function sync(directoryPath) {
7
7
  }
8
8
  function getDirectFiles(dir, callback) {
9
9
  let childrenfiles = [];
10
- if(!fs.existsSync(dir)){
11
- console.log('FATAL: directory is missing:', dir);
12
- return process.exit(1);
13
- }
14
10
  let entries;
15
11
  try{
16
12
  entries = fs.readdirSync(dir, { withFileTypes: true });//一次拿齐 isFile, 省掉逐文件 statSync(大任务量启动/resume 显著)
17
13
  }catch(err){
14
+ //目录缺失检测并入 readdirSync 的 catch(省掉独立 existsSync 预检): ENOENT = 目录丢失, 语义不变
15
+ if(err && err.code === 'ENOENT'){
16
+ console.log('FATAL: directory is missing:', dir);
17
+ return process.exit(1);
18
+ };
18
19
  console.error("Could not list the directory.", err);
19
20
  return callback([]);
20
21
  }
@@ -260,6 +260,10 @@ const workerStatus = (id, payload) => {
260
260
  };
261
261
 
262
262
  const finish = () => {//收尾: 换行定格进度条行 + 输出计数器行(master 退出前调用); 未开启为 no-op
263
+ if (renderTimer) {//收尾后不再渲染: 尾随节流帧/workerExited 触发的晚到帧会回卷光标、把计数器行抹掉
264
+ clearTimeout(renderTimer);
265
+ renderTimer = null;
266
+ };
263
267
  if (resizeSettleTimer) {//收尾后不再重锚定重绘: 防抖帧会落在计数器行之后
264
268
  clearTimeout(resizeSettleTimer);
265
269
  resizeSettleTimer = null;
@@ -270,6 +274,9 @@ const finish = () => {//收尾: 换行定格进度条行 + 输出计数器行(ma
270
274
  let stats = getStats();
271
275
  view.writeLine(`[Tasks] succeeded=${stats.succeeded}, failed=${stats.failed}, succ-but-save-failed=${stats.succButSaveFailed || 0}`);
272
276
  };
277
+ //渲染路径整体解除: render/resize 防抖/resize 监听均以 view 为开关, 置 null 后晚到事件全部自然失效
278
+ view = null;
279
+ progressOn = false;
273
280
  };
274
281
  };
275
282
 
package/utils/runLog.js CHANGED
@@ -32,8 +32,9 @@ const isEnabled = () => {
32
32
  return state.enabled;
33
33
  };
34
34
 
35
- //拦截 console: 把 console.log/warn/error 替换为写 run.log 的实现,
35
+ //拦截 console: 把 console.log/info/debug/warn/error 替换为写 run.log 的实现,
36
36
  //用户代码(processTask/onFinish 等)里的 console 输出同样进文件。
37
+ //info/debug 是 console 实例上的独立方法(并非 log 的别名), 漏钩会在非 echo 模式直写 TTY 挤偏进度帧且不进 run.log。
37
38
  //echo 为真时同时调原实现上屏(progressBar 未开/非 TTY 回退); 为假时只写文件(进度区接管终端)。
38
39
  //reset/unhookConsole 恢复原实现(必须在 enabled 变 false 之前恢复, 否则 write 透传 console 会递归)。
39
40
  const hookConsole = ({ echo } = {}) => {
@@ -42,8 +43,10 @@ const hookConsole = ({ echo } = {}) => {
42
43
  };
43
44
  state.hooked = true;
44
45
  state.echo = !!echo;
45
- originalConsole = { log: console.log, warn: console.warn, error: console.error };
46
+ originalConsole = { log: console.log, info: console.info, debug: console.debug, warn: console.warn, error: console.error };
46
47
  console.log = write;
48
+ console.info = write;
49
+ console.debug = write;
47
50
  console.warn = warn;
48
51
  console.error = error;
49
52
  };
@@ -53,6 +56,8 @@ const unhookConsole = () => {
53
56
  return;
54
57
  };
55
58
  console.log = originalConsole.log;
59
+ console.info = originalConsole.info;
60
+ console.debug = originalConsole.debug;
56
61
  console.warn = originalConsole.warn;
57
62
  console.error = originalConsole.error;
58
63
  originalConsole = null;
@@ -19,12 +19,17 @@ const renderTitleBox = (mode) => {
19
19
  };
20
20
 
21
21
  const renderFieldLines = (info) => {
22
+ //默认值落地的 run 在 task_storage 行追加升级提示(4.0.0 默认值 file -> aof); 显式设置时只显示模式本身
23
+ const storageValue = info.storageDefaultApplied
24
+ ? `${info.taskStorage} (default since 4.0.0, was 'file'; set task_storage: 'file' for the classic per-task-file layout)`
25
+ : info.taskStorage;
22
26
  const rows = [
23
27
  ['taskId', info.taskId],
24
28
  ['taskFolder', info.taskFolder],
25
29
  ['workers', `${info.numOfWorkers} (numCPUs=${info.numCPUs})`],
26
30
  ['master PID', `${info.pid}`],
27
31
  ['new tasks', `${info.newTasks}`],
32
+ ['task_storage', storageValue],
28
33
  ['run.log', info.sysLogFile || '(not available)'],
29
34
  ['started at', info.startedAt],
30
35
  ['monitor install cmd', 'npm install multi-tasks-monitor'],
@@ -0,0 +1,349 @@
1
+ const fs = require('fs');
2
+ const pathUtil = require('path');
3
+ const { createMemoryStore } = require('./MemoryStore');
4
+ const IpcTaskStore = require('./IpcTaskStore');
5
+ const runLog = require('../utils/runLog');
6
+
7
+ //AofStore: aof 档 master 侧存储 = MemoryStore(内存权威状态) + append-only aof(崩溃恢复)。
8
+ //核心原则(拍板, 勿回退):
9
+ // master 是 aof 唯一写者; 每个变更 op 先应用 MemoryStore、后追加 aof 行({op,args} NDJSON);
10
+ // 重放 = 逐行 apply 到 MemoryStore, replay 与在线路径零分叉;
11
+ // checkpoint = 先写 snapshot(临时文件+rename 原子替换)、后截断 aof, 顺序勿反;
12
+ // op 应用/aof 追加/checkpoint 全在 Multithread 同一串行 queue, checkpoint 天然对应 aof 某前缀一致态;
13
+ // saveResult/deleteResult 除内存+aof 行外, 追加结果行到聚合 NDJSON 文件 results/succ.json /
14
+ // errors.json(流式追加 10 万行仅 ~0.5s, 取代逐任务小文件——那是 aof 与 memory 差 10 倍的唯一来源);
15
+ // 收尾 flushResults() 按内存终态整体重写两个文件(墓碑/历史行清除), 与 checkpoint 一并 await。
16
+ const AOF_FNAME = 'aof.log';
17
+ const SNAPSHOT_FNAME = 'aof.snapshot.json';
18
+ //周期 checkpoint 双模式(拍板):
19
+ // aof_checkpoint_ops 显式设置 = 旧固定行数模式(<=0 关周期触发), 作新旧对比开关保留;
20
+ // ops 未设置 = 字节几何模式: aof 自上次 checkpoint 增长 N 字节触发, N = aof_checkpoint_bytes
21
+ // (默认 32MB, <=0 关), 每次触发后 N ×2 封顶 256MB——结账成本 ∝ 状态大小, 几何间隔让全运行期
22
+ // 总结账量收敛到 ~2 倍最终态(固定间隔随任务量平方级放大, 已实测 10 万任务 +49% 耗时)。
23
+ const DEFAULT_CHECKPOINT_BYTES = 32 * 1024 * 1024;
24
+ const MAX_CHECKPOINT_BYTES = 256 * 1024 * 1024;
25
+
26
+ //与 IPC 协议同一字面量(单一来源在 IpcTaskStore, 勿复制)
27
+ const UNDEFINED_SENTINEL = IpcTaskStore.__UNDEFINED_SENTINEL;
28
+
29
+ const encodeArg = (value) => (typeof value === 'undefined' ? { [UNDEFINED_SENTINEL]: true } : value);
30
+ const decodeArg = (value) => (value && value[UNDEFINED_SENTINEL] ? undefined : value);
31
+
32
+ //变更 op 全集(重放白名单同此): 与 MemoryStore 方法一一对应。
33
+ //注意 aof 行用 MemoryStore 方法名(addMessage/clearMessages), 不是 IPC op 名(saveUserMessage/deleteUserMessage)
34
+ const AOF_OPS = ['add', 'addMany', 'move', 'saveResult', 'deleteResult', 'saveLog', 'incrementRetry', 'resetRetry', 'addMessage', 'clearMessages'];
35
+
36
+ const createAofStore = (config, innerStore) => {
37
+ const { masterTaskId, taskFolder } = config || {};
38
+ if (!masterTaskId || !taskFolder) {
39
+ throw new Error('createAofStore: config requires {masterTaskId, taskFolder}');
40
+ };
41
+ //checkpointOps: 显式 number = 旧固定行数模式; 未设置(null) = 字节几何模式(语义见文件头)
42
+ const checkpointOps = typeof config.aof_checkpoint_ops === 'number' ? config.aof_checkpoint_ops : null;
43
+ const byteCap = typeof config.__aof_checkpoint_bytes_cap === 'number' ? config.__aof_checkpoint_bytes_cap : MAX_CHECKPOINT_BYTES;
44
+ let byteThreshold = checkpointOps === null
45
+ ? (typeof config.aof_checkpoint_bytes === 'number' ? config.aof_checkpoint_bytes : DEFAULT_CHECKPOINT_BYTES)
46
+ : 0;
47
+
48
+ const store = innerStore || createMemoryStore(config);
49
+ const aofPath = pathUtil.resolve(taskFolder, '.sys', AOF_FNAME);
50
+ const snapshotPath = pathUtil.resolve(taskFolder, '.sys', SNAPSHOT_FNAME);
51
+
52
+ //结果聚合文件(用户拍板 2026-09 二版): 每任务一行的 NDJSON 聚合文件 results/succ.json /
53
+ // errors.json, 取代逐任务小文件(逐任务 writeFile+rename 是 aof 与 memory 差 10 倍的唯一来源,
54
+ // 而流式追加 10 万行仅 ~0.5s; 整体重写+线程池调大都救不了 Windows 单文件操作延迟, 实测封顶 ~2000 ops/s):
55
+ // 运行中 saveResult 追加 {subid,data} 行(data undefined 时该键省略), deleteResult 追加
56
+ // {subid,deleted:true} 墓碑行(读者按 subid 取最后一行为准); 收尾 flushResults 按内存终态
57
+ // tmp+rename 整体重写两个文件(墓碑/翻盘历史行全部清掉); resume 时 __reconcileResultFiles 只修
58
+ // 撕坏的尾行+清 .tmp 残件(不重写——Windows 不能 rename 占用中的文件, 追加流可能与重写交叉)
59
+ //内部开关 aof_append_result_files(不对外文档化, 与 aof_checkpoint_* 同属内部调参键): aof 档缺省 true,
60
+ // 显式 false 关闭结果聚合文件(结果只留内存/onFinish, aof 状态行与可恢复性不受影响)
61
+ const resultFilesEnabled = config.aof_append_result_files !== false;
62
+ const resultPath = (hasError) => pathUtil.resolve(taskFolder, 'results', hasError ? 'errors.json' : 'succ.json');
63
+ const resultStreams = { succ: null, errors: null };
64
+ let resultBroken = false;
65
+ let fileOpWarned = false;
66
+
67
+ const warnFileOnce = (...args) => {
68
+ if (!fileOpWarned) {
69
+ fileOpWarned = true;
70
+ runLog.warn(...args);
71
+ };
72
+ };
73
+
74
+ const appendResultLine = (hasError, record) => {
75
+ if (!resultFilesEnabled || resultBroken) return;
76
+ const side = hasError ? 'errors' : 'succ';
77
+ if (!resultStreams[side]) {
78
+ resultStreams[side] = fs.createWriteStream(resultPath(hasError), { flags: 'a' });
79
+ resultStreams[side].on('error', (e) => {
80
+ resultBroken = true;
81
+ warnFileOnce('[AofStore]: result stream broken, result files degraded for this run', e);
82
+ });
83
+ };
84
+ resultStreams[side].write(JSON.stringify(record) + '\n');
85
+ };
86
+
87
+ //内存终态 → 一侧文件内容(NDJSON, 插入序): 只含当前存在的终态结果, 墓碑/历史行不进
88
+ const buildSideContent = (hasError) => {
89
+ const lines = [];
90
+ store.__serialize().results.forEach(([subid, entry]) => {
91
+ if (entry.hasError === hasError) {
92
+ lines.push(JSON.stringify(typeof entry.data === 'undefined' ? { subid } : { subid, data: entry.data }));
93
+ };
94
+ });
95
+ return lines.length ? lines.join('\n') + '\n' : '';
96
+ };
97
+
98
+ //tmp+rename 整体重写一侧(同 checkpoint snapshot 原子模式): kill 中途只留 .tmp 残件, 目标文件不破
99
+ const rewriteSide = async (hasError) => {
100
+ const finalPath = resultPath(hasError);
101
+ await fs.promises.writeFile(`${finalPath}.tmp`, buildSideContent(hasError));
102
+ await fs.promises.rename(`${finalPath}.tmp`, finalPath);
103
+ };
104
+
105
+ let stream = null;
106
+ let broken = false;//流出错整轮丢弃(同 run.log best-effort): 本次运行失去可恢复性, 退化为 memory 档语义
107
+ let linesSinceCheckpoint = 0;
108
+ let bytesSinceCheckpoint = 0;
109
+ let checkpointInFlight = null;
110
+
111
+ const onStreamError = (e) => {
112
+ broken = true;
113
+ runLog.warn('[AofStore]: aof stream broken, recoverability lost for this run', e);
114
+ };
115
+
116
+ const openStream = (flags) => {
117
+ stream = fs.createWriteStream(aofPath, { flags });
118
+ stream.on('error', onStreamError);
119
+ };
120
+
121
+ const appendLine = (op, args) => {
122
+ if (broken) return;
123
+ if (!stream) openStream('a');
124
+ const line = JSON.stringify({ op, args: args.map(encodeArg) }) + '\n';
125
+ stream.write(line);
126
+ linesSinceCheckpoint++;
127
+ bytesSinceCheckpoint += Buffer.byteLength(line, 'utf-8');
128
+ };
129
+
130
+ const aofOps = {};
131
+ AOF_OPS.forEach((op) => {
132
+ aofOps[op] = (...args) => {
133
+ const result = store[op](...args);//先应用(add 会先赋 subid 再落行)
134
+ appendLine(op, args);
135
+ return result;
136
+ };
137
+ });
138
+
139
+ //saveResult/deleteResult 在「内存 + aof 行」外追加结果聚合行(运行中即可读; 读者按 subid 取最后一行)
140
+ const rawSaveResult = aofOps.saveResult;
141
+ aofOps.saveResult = (subid, hasError, data) => {
142
+ const result = rawSaveResult(subid, hasError, data);
143
+ appendResultLine(hasError, typeof data === 'undefined' ? { subid } : { subid, data });
144
+ return result;
145
+ };
146
+ const rawDeleteResult = aofOps.deleteResult;
147
+ aofOps.deleteResult = (subid) => {
148
+ const type = store.getSavedResultType(subid);//'succ'/'error'/null: 删前取侧, 墓碑行追加到对应文件
149
+ const result = rawDeleteResult(subid);
150
+ if (type) appendResultLine(type === 'error', { subid, deleted: true });
151
+ return result;
152
+ };
153
+
154
+ //收尾(三条退出路径与 checkpoint 一并 await): 先关追加流(缓冲全冲下、句柄释放, Windows 上
155
+ //句柄未关 rename 会 EPERM) → 两侧按内存终态 tmp+rename 整体重写(墓碑/翻盘历史行清掉) →
156
+ //清旧版逐任务结果文件与 .tmp 残件; 失败只告警不 reject(收尾路径直接 await, reject 会挂死 master)
157
+ const flushResults = async () => {
158
+ for (const side of ['succ', 'errors']) {
159
+ const s = resultStreams[side];
160
+ if (s) {
161
+ resultStreams[side] = null;
162
+ //用 end(cb) 而非等 'close': 流中途出错已销毁时 'close' 早已发完不再重发, 等它会永远悬挂
163
+ //(退出路径 await 本函数, 悬挂 = 挂死 master 收尾); end(cb) 对已销毁流也回调
164
+ await new Promise((resolve) => { s.end(resolve); });
165
+ };
166
+ };
167
+ for (const hasError of [false, true]) {
168
+ try {
169
+ await rewriteSide(hasError);
170
+ } catch (e) {
171
+ warnFileOnce('[AofStore]: flush result files failed, later failures suppressed', e);
172
+ };
173
+ };
174
+ for (const side of ['succ', 'errors']) {//旧版(逐任务双写/物化)目录残留, 只清文件不动目录
175
+ const dir = pathUtil.resolve(taskFolder, 'results', side);
176
+ let files = [];
177
+ try {
178
+ files = fs.readdirSync(dir);
179
+ } catch (e) {
180
+ continue;
181
+ };
182
+ await Promise.all(files.map(async (fname) => {
183
+ if (!fname.endsWith('.json') && !fname.endsWith('.json.tmp')) return;
184
+ try {
185
+ await fs.promises.unlink(pathUtil.resolve(dir, fname));
186
+ } catch (e) {};
187
+ }));
188
+ };
189
+ };
190
+
191
+ //resume 修复(loadFromDisk 调用, 同步): torn write 只可能撕坏最后一行(单写者单 append 流),
192
+ //截回干净行边界; 顺带清 rewrite 被 kill 打断留下的 .tmp 残件。不重写文件——追加流还要继续写
193
+ const repairResultFile = (fpath) => {
194
+ try {
195
+ fs.unlinkSync(`${fpath}.tmp`);
196
+ } catch (e) {};
197
+ if (!fs.existsSync(fpath)) return;
198
+ //按字节定位行边界(truncateSync 的长度是字节数; 字符串下标对多字节数据会截进前一行,
199
+ //与 loadFromDisk 修 aof.log 用 Buffer.byteLength 同源)
200
+ const buf = fs.readFileSync(fpath);
201
+ if (buf.length === 0 || buf[buf.length - 1] === 0x0a) return;
202
+ const cut = buf.lastIndexOf(0x0a);
203
+ fs.truncateSync(fpath, cut + 1);
204
+ };
205
+ const reconcileResultFiles = () => {
206
+ if (!resultFilesEnabled) return;
207
+ repairResultFile(resultPath(false));
208
+ repairResultFile(resultPath(true));
209
+ };
210
+
211
+ //pop 出队即 new->running, 落成 move 行(否则重放时该任务滞留 new 会被重复派发,
212
+ //且后续 running->finished 的 move 行因 from 不匹配被跳过 → 已完成任务被重跑)
213
+ const pop = () => {
214
+ const task = store.pop();
215
+ if (task) appendLine('move', [task.subid, 'new', 'running']);
216
+ return task;
217
+ };
218
+
219
+ //checkpoint = 写 snapshot(临时文件+rename 原子替换) + 截断 aof。
220
+ //顺序硬性约束: 先 snapshot 落盘、后截断——截断失败只留冗余(下次再压), 反过来丢数据。
221
+ //调用点必须在 Multithread 串行 queue 内(op 应用与 append 无并发), snapshot 才对应 aof 某前缀一致态。
222
+ const checkpoint = () => {
223
+ if (checkpointInFlight) return checkpointInFlight;
224
+ const run = (async () => {
225
+ const tmpPath = `${snapshotPath}.tmp`;
226
+ await fs.promises.writeFile(tmpPath, JSON.stringify(store.__serialize()));
227
+ await fs.promises.rename(tmpPath, snapshotPath);
228
+ //截断前先把已 append 的行全部冲下盘(stream FIFO), 再 'w' 重开截断
229
+ if (stream) {
230
+ await new Promise((resolve) => { stream.end(resolve); });
231
+ };
232
+ openStream('w');
233
+ //'w' 的截断发生在异步 open 完成时, 等它落地再 resolve, 保证 checkpoint 返回时盘上已截断;
234
+ //open 失败也放行(onStreamError 已置 broken 告警, 本回合退化 memory 语义)
235
+ await new Promise((resolve) => {
236
+ stream.once('open', resolve);
237
+ stream.once('error', resolve);
238
+ });
239
+ linesSinceCheckpoint = 0;
240
+ bytesSinceCheckpoint = 0;
241
+ })();
242
+ checkpointInFlight = run.catch((e) => {
243
+ //snapshot 未落盘则不截断(上面顺序保证), 下个周期重试; 不翻转任务成败、不中断运行
244
+ runLog.warn('[AofStore]: checkpoint failed, aof kept untruncated', e);
245
+ }).finally(() => {
246
+ checkpointInFlight = null;
247
+ });
248
+ return checkpointInFlight;
249
+ };
250
+
251
+ const maybeCheckpoint = () => {
252
+ if (checkpointOps !== null) {
253
+ if (checkpointOps > 0 && linesSinceCheckpoint >= checkpointOps) return checkpoint();
254
+ return null;
255
+ };
256
+ //字节几何模式: 达阈值先翻倍(同步, 下次生效)再触发; 触发失败计数器不清零, 下轮按新阈值重试
257
+ if (byteThreshold > 0 && bytesSinceCheckpoint >= byteThreshold) {
258
+ byteThreshold = Math.min(byteThreshold * 2, byteCap);
259
+ return checkpoint();
260
+ };
261
+ return null;
262
+ };
263
+
264
+ return {
265
+ ...aofOps,
266
+ pop,
267
+ checkpoint,
268
+ maybeCheckpoint,
269
+ //aof_append_result_files=false 时不挂 flushResults: Multithread 收尾 typeof 检查自动跳过
270
+ ...(resultFilesEnabled ? { flushResults } : {}),
271
+ getSavedResultType: store.getSavedResultType,
272
+ getState: store.getState,
273
+ getTasks: store.getTasks,
274
+ getRemaining: store.getRemaining,
275
+ stats: store.stats,
276
+ forEachSuccResult: store.forEachSuccResult,
277
+ forEachErrorResult: store.forEachErrorResult,
278
+ forEachResult: store.forEachResult,
279
+ __reconcileResultFiles: reconcileResultFiles,
280
+ __innerStore: store,//测试与 loadFromDisk 内部用
281
+ };
282
+ };
283
+
284
+ //重放单行进 MemoryStore: op 白名单即 AOF_OPS, 未知 op throw(不静默跳过——
285
+ //跳过等于丢状态变更, 重放结果与在线态分叉)
286
+ const replayOp = (store, rec) => {
287
+ if (!rec || AOF_OPS.indexOf(rec.op) < 0) {
288
+ throw new Error(`AofStore: unknown aof op "${rec && rec.op}"`);
289
+ };
290
+ const args = (rec.args || []).map(decodeArg);
291
+ store[rec.op](...args);
292
+ };
293
+
294
+ //resume 入口: snapshot(若有且可解析) + aof 增量重放。
295
+ //torn write 只可能截断最后一行(单写者单 append 流): 跳过坏尾行并把文件截回干净边界,
296
+ //否则后续追加会接在半行后面永久污染; 中间行损坏 = 真腐败, throw 走 resume FATAL exit(1) 链路。
297
+ const loadFromDisk = (config) => {
298
+ const { taskFolder } = config || {};
299
+ if (!taskFolder) {
300
+ throw new Error('AofStore.loadFromDisk: config requires {taskFolder}');
301
+ };
302
+ const aofPath = pathUtil.resolve(taskFolder, '.sys', AOF_FNAME);
303
+ const snapshotPath = pathUtil.resolve(taskFolder, '.sys', SNAPSHOT_FNAME);
304
+
305
+ let snapshot = null;
306
+ if (fs.existsSync(snapshotPath)) {
307
+ try {
308
+ snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8'));
309
+ } catch (e) {
310
+ runLog.warn('[AofStore]: snapshot corrupted, falling back to full aof replay', e);
311
+ snapshot = null;
312
+ };
313
+ };
314
+ const inner = createMemoryStore(config, snapshot);
315
+
316
+ if (fs.existsSync(aofPath)) {
317
+ const lines = fs.readFileSync(aofPath, 'utf-8').split('\n');
318
+ let offset = 0;
319
+ let truncateAt = -1;
320
+ for (let i = 0; i < lines.length; i++) {
321
+ const line = lines[i];
322
+ if (line === '') {
323
+ offset += 1;
324
+ continue;
325
+ };
326
+ let rec = null;
327
+ try {
328
+ rec = JSON.parse(line);
329
+ } catch (e) {
330
+ const isTail = lines.slice(i + 1).every((l) => l === '');
331
+ if (!isTail) {
332
+ throw new Error(`AofStore: corrupted aof line ${i + 1} in ${aofPath}`);
333
+ };
334
+ truncateAt = offset;
335
+ break;
336
+ };
337
+ replayOp(inner, rec);
338
+ offset += Buffer.byteLength(line, 'utf-8') + 1;
339
+ };
340
+ if (truncateAt >= 0) {
341
+ fs.truncateSync(aofPath, truncateAt);
342
+ };
343
+ };
344
+ const store = createAofStore(config, inner);
345
+ store.__reconcileResultFiles();//修上一轮 kill 撕坏的结果文件尾行/清残件(重放只建内存, 终态重写在收尾)
346
+ return store;
347
+ };
348
+
349
+ module.exports = { createAofStore, loadFromDisk, AOF_OPS, AOF_FNAME, SNAPSHOT_FNAME };