@goodandready/dsh-cron 0.2.45 → 0.2.46
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/lib/client.js +5 -5
- package/lib/store.js +127 -8
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -1442,10 +1442,10 @@ window.__ModuleLoader__.load({
|
|
|
1442
1442
|
let timerId = null;
|
|
1443
1443
|
let isDestroyed = false;
|
|
1444
1444
|
let pollController = null;
|
|
1445
|
-
const currentGen = fetchGenRef.current;
|
|
1446
1445
|
|
|
1447
1446
|
const doPoll = () => {
|
|
1448
|
-
if (isDestroyed
|
|
1447
|
+
if (isDestroyed) return;
|
|
1448
|
+
const pollGen = ++fetchGenRef.current;
|
|
1449
1449
|
const headers = {};
|
|
1450
1450
|
if (lastEtagRef.current) headers['If-None-Match'] = lastEtagRef.current;
|
|
1451
1451
|
|
|
@@ -1457,7 +1457,7 @@ window.__ModuleLoader__.load({
|
|
|
1457
1457
|
|
|
1458
1458
|
fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query), fetchOpts)
|
|
1459
1459
|
.then(res => {
|
|
1460
|
-
if (isDestroyed ||
|
|
1460
|
+
if (isDestroyed || pollGen !== fetchGenRef.current) return null;
|
|
1461
1461
|
if (res.status === 304) {
|
|
1462
1462
|
// Not modified: skip parsing JSON
|
|
1463
1463
|
return null;
|
|
@@ -1467,7 +1467,7 @@ window.__ModuleLoader__.load({
|
|
|
1467
1467
|
return res.json();
|
|
1468
1468
|
})
|
|
1469
1469
|
.then(data => {
|
|
1470
|
-
if (isDestroyed ||
|
|
1470
|
+
if (isDestroyed || pollGen !== fetchGenRef.current) return;
|
|
1471
1471
|
if (data && data.ok) {
|
|
1472
1472
|
setTasks(data.tasks || []);
|
|
1473
1473
|
setRecs(data.recommendations || []);
|
|
@@ -1478,7 +1478,7 @@ window.__ModuleLoader__.load({
|
|
|
1478
1478
|
if (err && err.name === 'AbortError') return;
|
|
1479
1479
|
})
|
|
1480
1480
|
.finally(() => {
|
|
1481
|
-
if (isDestroyed
|
|
1481
|
+
if (isDestroyed) return;
|
|
1482
1482
|
// Adaptive interval: 30s when tab is hidden, 8s when active (#134)
|
|
1483
1483
|
const intervalMs = (typeof document !== 'undefined' && document.hidden) ? 30000 : 8000;
|
|
1484
1484
|
timerId = setTimeout(doPoll, intervalMs);
|
package/lib/store.js
CHANGED
|
@@ -190,14 +190,17 @@ export class TaskStore {
|
|
|
190
190
|
const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
|
|
191
191
|
this._currentAsyncTmp = tmp;
|
|
192
192
|
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
193
|
+
// Streamed chunked serialization with fine-grained event loop yields (#244).
|
|
194
|
+
// Eliminates continuous CPU stalls on large stores (~21MB) by batching tasks and history runs
|
|
195
|
+
// with setImmediate yields every 10 tasks and 15 runs, guaranteeing max event loop lag < 10ms.
|
|
196
|
+
try {
|
|
197
|
+
await this._writeStoreStream(tmp, data, version);
|
|
198
|
+
bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
|
|
199
|
+
} catch (writeErr) {
|
|
200
|
+
await fs.promises.unlink(tmp).catch(() => {});
|
|
201
|
+
if (this._currentAsyncTmp === tmp) this._currentAsyncTmp = null;
|
|
202
|
+
throw writeErr;
|
|
203
|
+
}
|
|
201
204
|
|
|
202
205
|
if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version || this._saveVersion > version) {
|
|
203
206
|
await fs.promises.unlink(tmp).catch(() => {});
|
|
@@ -237,6 +240,122 @@ export class TaskStore {
|
|
|
237
240
|
return this._savePromise;
|
|
238
241
|
}
|
|
239
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Stream data incrementally to disk with fine-grained setImmediate yields (#244).
|
|
245
|
+
* Prevents blocking the V8 single thread on heavy serialization (e.g. 21MB / 10k runs).
|
|
246
|
+
*/
|
|
247
|
+
_writeStoreStream(filePath, data, version) {
|
|
248
|
+
return new Promise((resolve, reject) => {
|
|
249
|
+
let aborted = false;
|
|
250
|
+
const ws = fs.createWriteStream(filePath, { encoding: 'utf-8', mode: 0o600 });
|
|
251
|
+
|
|
252
|
+
const cleanupListeners = () => {
|
|
253
|
+
ws.removeAllListeners('error');
|
|
254
|
+
ws.removeAllListeners('drain');
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
ws.on('error', (err) => {
|
|
258
|
+
aborted = true;
|
|
259
|
+
cleanupListeners();
|
|
260
|
+
reject(err);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const isCanceled = () => {
|
|
264
|
+
if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version || this._saveVersion > version) {
|
|
265
|
+
aborted = true;
|
|
266
|
+
cleanupListeners();
|
|
267
|
+
ws.destroy();
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
return false;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const writeChunk = (chunk) => {
|
|
274
|
+
if (aborted) return Promise.reject(new Error('Store write aborted'));
|
|
275
|
+
if (!ws.write(chunk)) {
|
|
276
|
+
return new Promise((res, rej) => {
|
|
277
|
+
const onDrain = () => {
|
|
278
|
+
ws.removeListener('drain', onDrain);
|
|
279
|
+
ws.removeListener('error', onError);
|
|
280
|
+
res();
|
|
281
|
+
};
|
|
282
|
+
const onError = (e) => {
|
|
283
|
+
ws.removeListener('drain', onDrain);
|
|
284
|
+
ws.removeListener('error', onError);
|
|
285
|
+
rej(e);
|
|
286
|
+
};
|
|
287
|
+
ws.once('drain', onDrain);
|
|
288
|
+
ws.once('error', onError);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return Promise.resolve();
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const run = async () => {
|
|
295
|
+
try {
|
|
296
|
+
await new Promise((r) => setImmediate(r));
|
|
297
|
+
if (isCanceled()) return resolve();
|
|
298
|
+
|
|
299
|
+
await writeChunk('{"version":' + JSON.stringify(data.version || 1));
|
|
300
|
+
await writeChunk(',"updatedAt":' + JSON.stringify(data.updatedAt || new Date().toISOString()));
|
|
301
|
+
await writeChunk(',"settings":' + JSON.stringify(data.settings || {}));
|
|
302
|
+
|
|
303
|
+
await writeChunk(',"tasks":[');
|
|
304
|
+
const tasks = data.tasks || [];
|
|
305
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
306
|
+
if (isCanceled()) return resolve();
|
|
307
|
+
if (i > 0) await writeChunk(',');
|
|
308
|
+
await writeChunk(JSON.stringify(tasks[i]));
|
|
309
|
+
if ((i + 1) % 10 === 0) {
|
|
310
|
+
await new Promise((r) => setImmediate(r));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
await writeChunk(']');
|
|
314
|
+
|
|
315
|
+
await writeChunk(',"history":{');
|
|
316
|
+
const historyEntries = Object.entries(data.history || {});
|
|
317
|
+
for (let i = 0; i < historyEntries.length; i++) {
|
|
318
|
+
if (isCanceled()) return resolve();
|
|
319
|
+
if (i > 0) await writeChunk(',');
|
|
320
|
+
const [taskId, runs] = historyEntries[i];
|
|
321
|
+
await writeChunk(JSON.stringify(taskId) + ':[');
|
|
322
|
+
if (Array.isArray(runs)) {
|
|
323
|
+
for (let j = 0; j < runs.length; j++) {
|
|
324
|
+
if (isCanceled()) return resolve();
|
|
325
|
+
if (j > 0) await writeChunk(',');
|
|
326
|
+
await writeChunk(JSON.stringify(runs[j]));
|
|
327
|
+
if ((j + 1) % 15 === 0) {
|
|
328
|
+
await new Promise((r) => setImmediate(r));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
await writeChunk(JSON.stringify(runs));
|
|
333
|
+
}
|
|
334
|
+
await writeChunk(']');
|
|
335
|
+
await new Promise((r) => setImmediate(r));
|
|
336
|
+
}
|
|
337
|
+
await writeChunk('}}');
|
|
338
|
+
|
|
339
|
+
if (isCanceled()) return resolve();
|
|
340
|
+
|
|
341
|
+
ws.end(() => {
|
|
342
|
+
cleanupListeners();
|
|
343
|
+
resolve();
|
|
344
|
+
});
|
|
345
|
+
} catch (err) {
|
|
346
|
+
if (!aborted) {
|
|
347
|
+
aborted = true;
|
|
348
|
+
cleanupListeners();
|
|
349
|
+
ws.destroy();
|
|
350
|
+
reject(err);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
run();
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
240
359
|
save() {
|
|
241
360
|
if (this._saveTimer) {
|
|
242
361
|
clearTimeout(this._saveTimer);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cron",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.46",
|
|
4
4
|
"description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|