@apisurf/ngn-core 0.3.1 → 0.5.0
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/dist/core.d.ts +465 -66
- package/dist/core.js +1642 -170
- package/package.json +4 -4
package/dist/core.js
CHANGED
|
@@ -325,7 +325,8 @@ var Entry = class {
|
|
|
325
325
|
shouldRetry: taskExports.shouldRetry,
|
|
326
326
|
onSuccess: taskExports.onSuccess,
|
|
327
327
|
onError: taskExports.onError,
|
|
328
|
-
onComplete: taskExports.onComplete
|
|
328
|
+
onComplete: taskExports.onComplete,
|
|
329
|
+
next: taskExports.next
|
|
329
330
|
};
|
|
330
331
|
}
|
|
331
332
|
get paths() {
|
|
@@ -364,7 +365,8 @@ function runDeadline({
|
|
|
364
365
|
startedAt,
|
|
365
366
|
scheduledAt,
|
|
366
367
|
nextAt,
|
|
367
|
-
budgetMs
|
|
368
|
+
budgetMs,
|
|
369
|
+
periodMs = null
|
|
368
370
|
}) {
|
|
369
371
|
const candidates = [];
|
|
370
372
|
if (nextAt != null) {
|
|
@@ -372,23 +374,39 @@ function runDeadline({
|
|
|
372
374
|
candidates.push(nextAt - Math.min(5e3, Math.max(0, period) * 0.1));
|
|
373
375
|
}
|
|
374
376
|
if (budgetMs != null) candidates.push(startedAt + budgetMs);
|
|
377
|
+
else if (nextAt == null && periodMs != null) candidates.push(startedAt + periodMs);
|
|
375
378
|
return candidates.length ? Math.floor(Math.min(...candidates)) : null;
|
|
376
379
|
}
|
|
377
|
-
function deadlineSignal(deadline) {
|
|
380
|
+
function deadlineSignal(deadline, outer) {
|
|
378
381
|
const controller = new AbortController();
|
|
379
|
-
|
|
380
|
-
|
|
382
|
+
const onOuter = () => controller.abort(outer?.reason);
|
|
383
|
+
if (outer?.aborted) onOuter();
|
|
384
|
+
else outer?.addEventListener("abort", onOuter, { once: true });
|
|
385
|
+
const detach = () => outer?.removeEventListener("abort", onOuter);
|
|
386
|
+
if (deadline == null) return { signal: controller.signal, dispose: detach };
|
|
381
387
|
const abort = () => controller.abort(new Error("The run reached its deadline"));
|
|
382
388
|
const delay = deadline - Date.now();
|
|
383
389
|
if (delay <= 0) {
|
|
384
390
|
abort();
|
|
385
|
-
return { signal: controller.signal, dispose:
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
+
return { signal: controller.signal, dispose: detach };
|
|
392
|
+
}
|
|
393
|
+
let timer;
|
|
394
|
+
const arm = () => {
|
|
395
|
+
const left = deadline - Date.now();
|
|
396
|
+
if (left <= 0) return abort();
|
|
397
|
+
timer = setTimeout(arm, Math.min(left, MAX_TIMER_MS));
|
|
398
|
+
timer.unref();
|
|
399
|
+
};
|
|
400
|
+
arm();
|
|
401
|
+
return {
|
|
402
|
+
signal: controller.signal,
|
|
403
|
+
dispose: () => {
|
|
404
|
+
clearTimeout(timer);
|
|
405
|
+
detach();
|
|
406
|
+
}
|
|
407
|
+
};
|
|
391
408
|
}
|
|
409
|
+
var MAX_TIMER_MS = 2 ** 31 - 1;
|
|
392
410
|
|
|
393
411
|
// src/runtime/locks.ts
|
|
394
412
|
var LockBusyError = class extends Error {
|
|
@@ -401,10 +419,14 @@ var LockBusyError = class extends Error {
|
|
|
401
419
|
var DEFAULT_TTL_MS = 6e4;
|
|
402
420
|
var MAX_POLL_MS = 1e3;
|
|
403
421
|
var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
422
|
+
var RESERVED_LOCK_PREFIX = "ngn:";
|
|
404
423
|
function assertLockName(name) {
|
|
405
424
|
if (typeof name !== "string" || !name.trim()) {
|
|
406
425
|
throw new Error(`A lock name must be a non-empty string, got ${JSON.stringify(name)}`);
|
|
407
426
|
}
|
|
427
|
+
if (name.startsWith(RESERVED_LOCK_PREFIX)) {
|
|
428
|
+
throw new Error(`Lock "${name}": names starting with "${RESERVED_LOCK_PREFIX}" are ngn's own`);
|
|
429
|
+
}
|
|
408
430
|
}
|
|
409
431
|
function createRunLocks(service, taskRunId) {
|
|
410
432
|
async function tryHold(name, ttlMs) {
|
|
@@ -477,6 +499,11 @@ function exclusiveNames(value, taskPath) {
|
|
|
477
499
|
`Invalid exclusive for ${taskPath}: expected a lock name or a list of them, got ${JSON.stringify(value)}`
|
|
478
500
|
);
|
|
479
501
|
}
|
|
502
|
+
if (name.startsWith(RESERVED_LOCK_PREFIX)) {
|
|
503
|
+
throw new Error(
|
|
504
|
+
`Invalid exclusive for ${taskPath}: "${name}" starts with "${RESERVED_LOCK_PREFIX}", which ngn keeps for itself`
|
|
505
|
+
);
|
|
506
|
+
}
|
|
480
507
|
}
|
|
481
508
|
return names;
|
|
482
509
|
}
|
|
@@ -1176,8 +1203,157 @@ function breakerSkips(failedInARow, breaker) {
|
|
|
1176
1203
|
return Math.min(breaker.maxSkip, 2 ** (failedInARow - breaker.after));
|
|
1177
1204
|
}
|
|
1178
1205
|
|
|
1206
|
+
// src/runtime/clock.ts
|
|
1207
|
+
var systemClock = {
|
|
1208
|
+
now: () => Date.now(),
|
|
1209
|
+
sleep(ms, signal) {
|
|
1210
|
+
return new Promise((resolve2) => {
|
|
1211
|
+
if (signal?.aborted) return resolve2();
|
|
1212
|
+
const done = () => {
|
|
1213
|
+
clearTimeout(timer);
|
|
1214
|
+
signal?.removeEventListener("abort", done);
|
|
1215
|
+
resolve2();
|
|
1216
|
+
};
|
|
1217
|
+
const timer = setTimeout(done, Math.max(0, ms));
|
|
1218
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
var WAIT_STEP_MS = 1e3;
|
|
1223
|
+
|
|
1224
|
+
// src/runtime/cron.ts
|
|
1225
|
+
import { Cron } from "croner";
|
|
1226
|
+
var CRON_OPTIONS = { mode: "5-or-6-parts", paused: true };
|
|
1227
|
+
var parsed = /* @__PURE__ */ new Map();
|
|
1228
|
+
function cronOf(pattern) {
|
|
1229
|
+
let cron = parsed.get(pattern);
|
|
1230
|
+
if (!cron) {
|
|
1231
|
+
cron = new Cron(pattern, CRON_OPTIONS);
|
|
1232
|
+
parsed.set(pattern, cron);
|
|
1233
|
+
}
|
|
1234
|
+
return cron;
|
|
1235
|
+
}
|
|
1236
|
+
function isCronPatternValid(pattern) {
|
|
1237
|
+
try {
|
|
1238
|
+
cronOf(pattern);
|
|
1239
|
+
return true;
|
|
1240
|
+
} catch {
|
|
1241
|
+
return false;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
function nextCronSlot(pattern, after) {
|
|
1245
|
+
return cronOf(pattern).nextRun(new Date(after))?.getTime() ?? null;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// src/runtime/timing.ts
|
|
1249
|
+
var INTERVAL_KEYS = /* @__PURE__ */ new Set(["every", "edge", "min", "max", "backoff"]);
|
|
1250
|
+
var BACKOFF_KEYS = /* @__PURE__ */ new Set(["factor", "jitter"]);
|
|
1251
|
+
var MIN_EVERY_MS = 1e3;
|
|
1252
|
+
var DEFAULT_MAX_FACTOR = 32;
|
|
1253
|
+
var DEFAULT_BACKOFF = { factor: 2, jitter: 0.1 };
|
|
1254
|
+
function unknownKeys(value, known, what = "") {
|
|
1255
|
+
const unknown = Object.keys(value).filter((key) => !known.has(key));
|
|
1256
|
+
if (!unknown.length) return null;
|
|
1257
|
+
return `unknown ${what}${unknown.length === 1 ? "key" : "keys"} ${unknown.join(", ")}`;
|
|
1258
|
+
}
|
|
1259
|
+
function resolveTiming(value, taskPath) {
|
|
1260
|
+
if (value == null) throw new Error(`timing pattern is missing for ${taskPath}`);
|
|
1261
|
+
if (typeof value === "string") {
|
|
1262
|
+
if (!isCronPatternValid(value))
|
|
1263
|
+
throw new Error(`Invalid cron pattern: ${value} for ${taskPath}`);
|
|
1264
|
+
return { kind: "cron", pattern: value };
|
|
1265
|
+
}
|
|
1266
|
+
const fail = (what) => {
|
|
1267
|
+
throw new Error(`Invalid timing for ${taskPath}: ${what}`);
|
|
1268
|
+
};
|
|
1269
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
1270
|
+
fail('expected a cron pattern or an object like { every: "5m" }');
|
|
1271
|
+
}
|
|
1272
|
+
const timing = value;
|
|
1273
|
+
const unknown = unknownKeys(timing, INTERVAL_KEYS);
|
|
1274
|
+
if (unknown) fail(unknown);
|
|
1275
|
+
if (timing.every == null) fail("every is required");
|
|
1276
|
+
const everyMs = toMs(timing.every, `timing.every for ${taskPath}`);
|
|
1277
|
+
if (everyMs < MIN_EVERY_MS) fail("every must be at least 1s");
|
|
1278
|
+
const { edge = "leading" } = timing;
|
|
1279
|
+
if (edge !== "leading" && edge !== "trailing") {
|
|
1280
|
+
fail(`edge must be "leading" or "trailing", got ${JSON.stringify(edge)}`);
|
|
1281
|
+
}
|
|
1282
|
+
return {
|
|
1283
|
+
kind: "interval",
|
|
1284
|
+
everyMs,
|
|
1285
|
+
edge,
|
|
1286
|
+
...resolveLimits(timing, everyMs, taskPath, fail),
|
|
1287
|
+
backoff: resolveBackoff(timing.backoff, fail)
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
function resolveLimits(timing, everyMs, taskPath, fail) {
|
|
1291
|
+
const limit = (key, fallback) => timing[key] == null ? fallback : toMs(timing[key], `timing.${key} for ${taskPath}`);
|
|
1292
|
+
const minMs = limit("min", everyMs);
|
|
1293
|
+
const maxMs = limit("max", everyMs * DEFAULT_MAX_FACTOR);
|
|
1294
|
+
if (minMs < MIN_EVERY_MS) fail("min must be at least 1s");
|
|
1295
|
+
if (minMs > everyMs) fail("min must not be more than every");
|
|
1296
|
+
if (everyMs > maxMs) fail("every must not be more than max");
|
|
1297
|
+
return { minMs, maxMs };
|
|
1298
|
+
}
|
|
1299
|
+
function resolveBackoff(value, fail) {
|
|
1300
|
+
if (value === false) return null;
|
|
1301
|
+
if (value == null || value === true) return DEFAULT_BACKOFF;
|
|
1302
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
1303
|
+
fail("backoff must be false or an object like { factor: 2, jitter: 0.1 }");
|
|
1304
|
+
}
|
|
1305
|
+
const backoff = value;
|
|
1306
|
+
const unknown = unknownKeys(backoff, BACKOFF_KEYS, "backoff ");
|
|
1307
|
+
if (unknown) fail(unknown);
|
|
1308
|
+
const { factor = DEFAULT_BACKOFF.factor, jitter = DEFAULT_BACKOFF.jitter } = backoff;
|
|
1309
|
+
if (typeof factor !== "number" || !Number.isFinite(factor) || factor < 1) {
|
|
1310
|
+
fail(`backoff.factor must be a number of at least 1, got ${JSON.stringify(factor)}`);
|
|
1311
|
+
}
|
|
1312
|
+
if (typeof jitter !== "number" || !(jitter >= 0 && jitter <= 1)) {
|
|
1313
|
+
fail(`backoff.jitter must be a number from 0 to 1, got ${JSON.stringify(jitter)}`);
|
|
1314
|
+
}
|
|
1315
|
+
return { factor, jitter };
|
|
1316
|
+
}
|
|
1317
|
+
function timingFromFlag(flag) {
|
|
1318
|
+
try {
|
|
1319
|
+
if (toMs(flag) >= MIN_EVERY_MS) return { every: flag };
|
|
1320
|
+
} catch {
|
|
1321
|
+
}
|
|
1322
|
+
return isCronPatternValid(flag) ? flag : null;
|
|
1323
|
+
}
|
|
1324
|
+
var NAMED_DELAYS = /* @__PURE__ */ new Set(["every", "min", "max"]);
|
|
1325
|
+
function nextDelay(value) {
|
|
1326
|
+
if (typeof value === "string" && NAMED_DELAYS.has(value)) return value;
|
|
1327
|
+
if (typeof value !== "string" && typeof value !== "number") return null;
|
|
1328
|
+
try {
|
|
1329
|
+
return toMs(value);
|
|
1330
|
+
} catch {
|
|
1331
|
+
return null;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
function parseNextDecision(value) {
|
|
1335
|
+
if (value === void 0) return void 0;
|
|
1336
|
+
const delay = nextDelay(value);
|
|
1337
|
+
if (delay != null) return { delay, reason: null };
|
|
1338
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
1339
|
+
const { in: wanted, reason, ...rest } = value;
|
|
1340
|
+
const inDelay = nextDelay(wanted);
|
|
1341
|
+
if (inDelay != null && Object.keys(rest).length === 0 && (reason == null || typeof reason === "string")) {
|
|
1342
|
+
return { delay: inDelay, reason: reason || null };
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
throw new Error(
|
|
1346
|
+
`next returned ${JSON.stringify(value) ?? String(value)}; expected undefined, a duration, "every", "min", "max" or { in, reason }`
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1179
1350
|
// src/runtime/task.ts
|
|
1180
|
-
var
|
|
1351
|
+
var NEXT_HOOK_TIMEOUT_MS = 5e3;
|
|
1352
|
+
var FAILURE_STREAK_LOOKBACK = 256;
|
|
1353
|
+
function streakOf(outcomes) {
|
|
1354
|
+
const firstOther = outcomes.findIndex((tick) => tick.outcome !== "failure");
|
|
1355
|
+
return firstOther === -1 ? outcomes.length : firstOther;
|
|
1356
|
+
}
|
|
1181
1357
|
var failedRuns = /* @__PURE__ */ new WeakMap();
|
|
1182
1358
|
function failedRunId(error) {
|
|
1183
1359
|
return runFailure(error)?.runId ?? null;
|
|
@@ -1193,10 +1369,9 @@ var Task = class {
|
|
|
1193
1369
|
kv;
|
|
1194
1370
|
sqlite;
|
|
1195
1371
|
meta;
|
|
1372
|
+
schedulingServices;
|
|
1196
1373
|
entry = null;
|
|
1197
1374
|
entryExports = null;
|
|
1198
|
-
/** Executions started and not yet settled. */
|
|
1199
|
-
inFlight = 0;
|
|
1200
1375
|
constructor({
|
|
1201
1376
|
entryParams,
|
|
1202
1377
|
buildConfigFn
|
|
@@ -1205,13 +1380,14 @@ var Task = class {
|
|
|
1205
1380
|
this.entry = new Entry(entryParams, {
|
|
1206
1381
|
onTaskCodeLoaded: this.taskCallbacks?.onTaskCodeLoaded
|
|
1207
1382
|
});
|
|
1208
|
-
const { taskCallbacks, startRun, history, env, kv, sqlite } = buildConfigFn({
|
|
1383
|
+
const { taskCallbacks, startRun, history, env, kv, sqlite, scheduling } = buildConfigFn({
|
|
1209
1384
|
sourcePath: this.entry.paths.source,
|
|
1210
1385
|
compiledPath: this.entry.paths.compiled
|
|
1211
1386
|
});
|
|
1212
1387
|
this.taskCallbacks = taskCallbacks;
|
|
1213
1388
|
this.startRun = startRun;
|
|
1214
1389
|
this.history = history;
|
|
1390
|
+
this.schedulingServices = scheduling;
|
|
1215
1391
|
this.sqlite = sqlite;
|
|
1216
1392
|
this.meta = {
|
|
1217
1393
|
fileTaskId: -1,
|
|
@@ -1241,48 +1417,52 @@ var Task = class {
|
|
|
1241
1417
|
invariant5(this.entry, "Entry not initialized.");
|
|
1242
1418
|
return this.entry.codeHash;
|
|
1243
1419
|
}
|
|
1244
|
-
|
|
1420
|
+
/** The `timing` export, as the latest run (or `loadEntry`) read it. */
|
|
1421
|
+
get timing() {
|
|
1245
1422
|
invariant5(this.entryExports, "Entry exports not initialized.");
|
|
1246
1423
|
return this.entryExports.timing;
|
|
1247
1424
|
}
|
|
1248
|
-
/**
|
|
1249
|
-
|
|
1250
|
-
* else, naming the task, so a typo fails at schedule time.
|
|
1251
|
-
*/
|
|
1252
|
-
get overlapPolicy() {
|
|
1425
|
+
/** The `next` export, as loaded: unchecked, for whoever schedules the task to check. */
|
|
1426
|
+
get nextHook() {
|
|
1253
1427
|
invariant5(this.entryExports, "Entry exports not initialized.");
|
|
1254
|
-
|
|
1255
|
-
invariant5(
|
|
1256
|
-
overlap === "skip" || overlap === "allow",
|
|
1257
|
-
`Invalid overlap "${String(overlap)}" for ${this.taskPath}: expected "skip" or "allow"`
|
|
1258
|
-
);
|
|
1259
|
-
return overlap;
|
|
1428
|
+
return this.entryExports.next;
|
|
1260
1429
|
}
|
|
1261
1430
|
/**
|
|
1262
1431
|
* Check the exports that shape how the task is scheduled, so a bad value
|
|
1263
|
-
* fails when the task is scheduled rather than on its first tick.
|
|
1432
|
+
* fails when the task is scheduled rather than on its first tick. ngn runs
|
|
1433
|
+
* one run of a task at a time, so `overlap` may only say so.
|
|
1264
1434
|
*/
|
|
1265
1435
|
validateExports() {
|
|
1266
1436
|
invariant5(this.entryExports, "Entry exports not initialized.");
|
|
1267
|
-
|
|
1268
|
-
|
|
1437
|
+
const { budget, exclusive, retry, overlap = "skip" } = this.entryExports;
|
|
1438
|
+
invariant5(
|
|
1439
|
+
overlap !== "allow",
|
|
1440
|
+
`overlap "allow" for ${this.taskPath}: ngn runs one run of a task at a time, across every process on the database, so a task cannot run beside itself`
|
|
1441
|
+
);
|
|
1442
|
+
invariant5(
|
|
1443
|
+
overlap === "skip",
|
|
1444
|
+
`Invalid overlap "${String(overlap)}" for ${this.taskPath}: expected "skip"`
|
|
1445
|
+
);
|
|
1269
1446
|
if (budget != null) toMs(budget, `budget for ${this.taskPath}`);
|
|
1270
1447
|
exclusiveNames(exclusive, this.taskPath);
|
|
1271
1448
|
resolveRetry(retry, this.taskPath);
|
|
1272
1449
|
}
|
|
1273
|
-
/**
|
|
1274
|
-
get
|
|
1275
|
-
|
|
1450
|
+
/** The `retry` export, checked. */
|
|
1451
|
+
get retryPolicy() {
|
|
1452
|
+
invariant5(this.entryExports, "Entry exports not initialized.");
|
|
1453
|
+
return resolveRetry(this.entryExports.retry, this.taskPath);
|
|
1454
|
+
}
|
|
1455
|
+
/** The schedule and lease services this task's runs are recorded with. */
|
|
1456
|
+
get scheduling() {
|
|
1457
|
+
return this.schedulingServices;
|
|
1276
1458
|
}
|
|
1277
1459
|
/**
|
|
1278
|
-
*
|
|
1279
|
-
*
|
|
1280
|
-
* execution, which is always so for a run skipped because one is in progress.
|
|
1460
|
+
* Load the task file and register it and its version, without running it:
|
|
1461
|
+
* its schedule is kept against the task's id. Returns that id.
|
|
1281
1462
|
*/
|
|
1282
|
-
async
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
await run.callbacks.onSkip?.({ path: this.entry.paths.relativeEntry, reason });
|
|
1463
|
+
async register() {
|
|
1464
|
+
await this.loadAndRegister();
|
|
1465
|
+
return this.meta.fileTaskId;
|
|
1286
1466
|
}
|
|
1287
1467
|
hasTaskExport() {
|
|
1288
1468
|
return this.entryExports?.task !== void 0;
|
|
@@ -1353,31 +1533,106 @@ var Task = class {
|
|
|
1353
1533
|
}
|
|
1354
1534
|
}
|
|
1355
1535
|
/**
|
|
1356
|
-
* Run the task for one tick, retrying as its `retry` export allows
|
|
1357
|
-
* scheduled tick passes when it fired and when the next one
|
|
1358
|
-
* `ctx.run.deadline` and the retry window are worked out from.
|
|
1536
|
+
* Run the task for one tick, retrying as its `retry` export allows, and say
|
|
1537
|
+
* how it ended. A scheduled tick passes when it fired and when the next one
|
|
1538
|
+
* is due, which `ctx.run.deadline` and the retry window are worked out from.
|
|
1539
|
+
* Never throws: a failure is an outcome, its error recorded on its run.
|
|
1540
|
+
*/
|
|
1541
|
+
executeTick(schedule = {}) {
|
|
1542
|
+
return this.executeWithRetries(schedule);
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* One tick, for a caller that wants the task's value: what it returned, or
|
|
1546
|
+
* undefined for a skip. Throws what the tick failed with.
|
|
1359
1547
|
*/
|
|
1360
1548
|
async execute(schedule = {}) {
|
|
1361
|
-
this.
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
} finally {
|
|
1365
|
-
this.inFlight--;
|
|
1366
|
-
}
|
|
1549
|
+
const outcome = await this.executeTick(schedule);
|
|
1550
|
+
if (outcome.status === "failure") throw outcome.error;
|
|
1551
|
+
return outcome.result;
|
|
1367
1552
|
}
|
|
1368
1553
|
async executeWithRetries(schedule) {
|
|
1369
|
-
const
|
|
1554
|
+
const tick = {
|
|
1370
1555
|
firstRunId: null,
|
|
1556
|
+
runId: null,
|
|
1371
1557
|
retry: resolveRetry(void 0, this.taskPath),
|
|
1372
|
-
failedContext: null
|
|
1558
|
+
failedContext: null,
|
|
1559
|
+
final: null
|
|
1373
1560
|
};
|
|
1561
|
+
const outcome = await this.attempts(schedule, tick);
|
|
1562
|
+
if (schedule.next) outcome.next = await this.askNext(outcome, tick, schedule.next);
|
|
1563
|
+
return outcome;
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Ask the `next` hook when to run next. What it throws, returns that it may
|
|
1567
|
+
* not, or a hook that takes too long, is recorded on the tick's final run in
|
|
1568
|
+
* stage `next`, and leaves the decision to the policy, as does a tick that
|
|
1569
|
+
* never got as far as loading the task's exports.
|
|
1570
|
+
*/
|
|
1571
|
+
async askNext(outcome, tick, { previousDelayMs, timeoutMs = NEXT_HOOK_TIMEOUT_MS }) {
|
|
1572
|
+
const final = tick.final;
|
|
1573
|
+
const hook = final?.exports.next;
|
|
1574
|
+
if (!final || typeof hook !== "function" || outcome.runId == null) return null;
|
|
1575
|
+
const told = {
|
|
1576
|
+
status: outcome.status,
|
|
1577
|
+
result: outcome.result,
|
|
1578
|
+
error: outcome.error,
|
|
1579
|
+
skipReason: outcome.skipReason,
|
|
1580
|
+
failedInARow: await this.failedInARow(),
|
|
1581
|
+
previousDelayMs,
|
|
1582
|
+
runId: outcome.runId
|
|
1583
|
+
};
|
|
1584
|
+
let timer;
|
|
1585
|
+
try {
|
|
1586
|
+
const value = await Promise.race([
|
|
1587
|
+
(async () => hook(told, final.context))(),
|
|
1588
|
+
new Promise((_, reject) => {
|
|
1589
|
+
timer = setTimeout(
|
|
1590
|
+
() => reject(new Error(`next took longer than ${timeoutMs / 1e3}s`)),
|
|
1591
|
+
timeoutMs
|
|
1592
|
+
);
|
|
1593
|
+
})
|
|
1594
|
+
]);
|
|
1595
|
+
return parseNextDecision(value) ?? null;
|
|
1596
|
+
} catch (error) {
|
|
1597
|
+
await final.run.callbacks.onHookError?.({ path: final.path, stage: "next", error });
|
|
1598
|
+
return null;
|
|
1599
|
+
} finally {
|
|
1600
|
+
clearTimeout(timer);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
/** The tick's attempts, retrying as `retry` allows, and how the last one ended. */
|
|
1604
|
+
async attempts(schedule, tick) {
|
|
1605
|
+
const clock = schedule.clock ?? systemClock;
|
|
1606
|
+
const outcome = (attempts, end) => ({
|
|
1607
|
+
result: void 0,
|
|
1608
|
+
error: void 0,
|
|
1609
|
+
skipReason: null,
|
|
1610
|
+
firstRunId: tick.firstRunId,
|
|
1611
|
+
runId: tick.runId,
|
|
1612
|
+
attempts,
|
|
1613
|
+
endedAt: clock.now(),
|
|
1614
|
+
next: null,
|
|
1615
|
+
...end
|
|
1616
|
+
});
|
|
1374
1617
|
for (let attempt = 1; ; attempt++) {
|
|
1375
|
-
|
|
1618
|
+
tick.failedContext = null;
|
|
1619
|
+
if (schedule.signal?.aborted) {
|
|
1620
|
+
return outcome(attempt - 1, { status: "failure", error: schedule.signal.reason });
|
|
1621
|
+
}
|
|
1376
1622
|
try {
|
|
1377
|
-
|
|
1623
|
+
const end = await this.executeOnce(schedule, tick, clock);
|
|
1624
|
+
return end.status === "success" ? outcome(attempt, { status: "success", result: end.result }) : outcome(attempt, { status: "skipped", skipReason: end.reason });
|
|
1378
1625
|
} catch (error) {
|
|
1379
|
-
|
|
1380
|
-
|
|
1626
|
+
let retry;
|
|
1627
|
+
try {
|
|
1628
|
+
retry = await this.shouldRetry(error, attempt, schedule, tick, clock);
|
|
1629
|
+
} catch (thrown) {
|
|
1630
|
+
return outcome(attempt, { status: "failure", error: thrown });
|
|
1631
|
+
}
|
|
1632
|
+
if (!retry || schedule.signal?.aborted)
|
|
1633
|
+
return outcome(attempt, { status: "failure", error });
|
|
1634
|
+
await clock.sleep(tick.retry.delayMs, schedule.signal);
|
|
1635
|
+
if (schedule.signal?.aborted) return outcome(attempt, { status: "failure", error });
|
|
1381
1636
|
}
|
|
1382
1637
|
}
|
|
1383
1638
|
}
|
|
@@ -1386,10 +1641,19 @@ var Task = class {
|
|
|
1386
1641
|
* fails the same way every time. Then it has to fit before the next tick,
|
|
1387
1642
|
* and the task's `shouldRetry` gets the last word.
|
|
1388
1643
|
*/
|
|
1389
|
-
async shouldRetry(error, attempt, { nextAt = null },
|
|
1390
|
-
if (!
|
|
1644
|
+
async shouldRetry(error, attempt, { nextAt = null }, tick, clock) {
|
|
1645
|
+
if (!tick.failedContext) return false;
|
|
1646
|
+
if (!retryFits({ attempt, retry: tick.retry, nextAt, now: clock.now() })) return false;
|
|
1391
1647
|
const { shouldRetry } = this.entryExports ?? {};
|
|
1392
|
-
return shouldRetry ? await shouldRetry(error,
|
|
1648
|
+
return shouldRetry ? await shouldRetry(error, tick.failedContext) !== false : true;
|
|
1649
|
+
}
|
|
1650
|
+
/**
|
|
1651
|
+
* The task's latest ticks that failed in a row, from its run history, so a
|
|
1652
|
+
* restart does not reset the count. Ticks closed as interrupted count, since
|
|
1653
|
+
* their runs were closed as failed; skipped ticks neither break nor extend it.
|
|
1654
|
+
*/
|
|
1655
|
+
async failedInARow() {
|
|
1656
|
+
return streakOf(await this.history.recentTickOutcomes(FAILURE_STREAK_LOOKBACK));
|
|
1393
1657
|
}
|
|
1394
1658
|
/**
|
|
1395
1659
|
* Whether the breaker holds this tick back: after `after` failed ticks in a
|
|
@@ -1399,11 +1663,7 @@ var Task = class {
|
|
|
1399
1663
|
async breakerOpen(retry) {
|
|
1400
1664
|
if (!retry.breaker) return false;
|
|
1401
1665
|
const outcomes = await this.history.recentTickOutcomes(retry.breaker.after + 64);
|
|
1402
|
-
const
|
|
1403
|
-
const wanted = breakerSkips(
|
|
1404
|
-
failedInARow === -1 ? outcomes.length : failedInARow,
|
|
1405
|
-
retry.breaker
|
|
1406
|
-
);
|
|
1666
|
+
const wanted = breakerSkips(streakOf(outcomes), retry.breaker);
|
|
1407
1667
|
if (wanted === 0) return false;
|
|
1408
1668
|
return await this.history.countSkipsSince(BREAKER_SKIP_REASON, outcomes[0].id) < wanted;
|
|
1409
1669
|
}
|
|
@@ -1443,14 +1703,17 @@ var Task = class {
|
|
|
1443
1703
|
* Load the task file, open the attempt's `task_runs` row, then evaluate the
|
|
1444
1704
|
* file's code; what that throws is recorded on the row as failed in `load`.
|
|
1445
1705
|
*/
|
|
1446
|
-
async openRun(
|
|
1706
|
+
async openRun(tick, onRunStarted) {
|
|
1447
1707
|
invariant5(this.entry, "Entry not initialized.");
|
|
1708
|
+
tick.final = null;
|
|
1448
1709
|
await this.loadAndRegister();
|
|
1449
1710
|
const meta = { ...this.meta };
|
|
1450
1711
|
const path = this.entry.paths.relativeEntry;
|
|
1451
|
-
const run = await this.startRun({ retryOf:
|
|
1452
|
-
const firstAttempt =
|
|
1453
|
-
|
|
1712
|
+
const run = await this.startRun({ retryOf: tick.firstRunId });
|
|
1713
|
+
const firstAttempt = tick.firstRunId == null;
|
|
1714
|
+
tick.firstRunId ??= run.id;
|
|
1715
|
+
tick.runId = run.id;
|
|
1716
|
+
onRunStarted?.(run.id);
|
|
1454
1717
|
try {
|
|
1455
1718
|
return { run, meta, path, firstAttempt, ...await this.readExports(path) };
|
|
1456
1719
|
} catch (error) {
|
|
@@ -1464,12 +1727,20 @@ var Task = class {
|
|
|
1464
1727
|
* cannot be loaded at all, or a run row that cannot be written, fails before
|
|
1465
1728
|
* there is a row to record it on.
|
|
1466
1729
|
*/
|
|
1467
|
-
async executeOnce({
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1730
|
+
async executeOnce({
|
|
1731
|
+
scheduledAt = null,
|
|
1732
|
+
nextAt = null,
|
|
1733
|
+
periodMs = null,
|
|
1734
|
+
signal: outer,
|
|
1735
|
+
onRunStarted
|
|
1736
|
+
}, tick, clock) {
|
|
1737
|
+
const { run, meta, path, firstAttempt, exports, task, budgetMs, exclusiveLocks, retry } = await this.openRun(tick, onRunStarted);
|
|
1738
|
+
tick.retry = retry;
|
|
1739
|
+
const startedAt = clock.now();
|
|
1740
|
+
const deadline = runDeadline({ startedAt, scheduledAt, nextAt, budgetMs, periodMs });
|
|
1741
|
+
const { signal, dispose } = deadlineSignal(deadline, outer);
|
|
1472
1742
|
const taskContext = this.buildContext(run, meta, { scheduledAt, nextAt, deadline }, signal);
|
|
1743
|
+
tick.final = { run, path, context: taskContext, exports };
|
|
1473
1744
|
let stage = "start";
|
|
1474
1745
|
let exclusiveHeld = null;
|
|
1475
1746
|
try {
|
|
@@ -1481,25 +1752,25 @@ var Task = class {
|
|
|
1481
1752
|
});
|
|
1482
1753
|
if ("reason" in gate) {
|
|
1483
1754
|
await run.callbacks.onSkip?.({ path, reason: gate.reason });
|
|
1484
|
-
return;
|
|
1755
|
+
return { status: "skipped", reason: gate.reason };
|
|
1485
1756
|
}
|
|
1486
1757
|
exclusiveHeld = gate.held;
|
|
1487
1758
|
stage = "shouldSkip";
|
|
1488
1759
|
if (await exports.shouldSkip?.(taskContext)) {
|
|
1489
1760
|
await run.callbacks.onSkip?.({ path });
|
|
1490
|
-
return;
|
|
1761
|
+
return { status: "skipped", reason: null };
|
|
1491
1762
|
}
|
|
1492
1763
|
stage = "start";
|
|
1493
1764
|
await run.callbacks.onStart?.({ path });
|
|
1494
1765
|
stage = "task";
|
|
1495
|
-
|
|
1766
|
+
tick.failedContext = taskContext;
|
|
1496
1767
|
const value = await task(taskContext);
|
|
1497
|
-
|
|
1768
|
+
tick.failedContext = null;
|
|
1498
1769
|
stage = "record";
|
|
1499
1770
|
await run.callbacks.onSuccess?.({ path, result: value });
|
|
1500
1771
|
await this.runHook(run, "onSuccess", () => exports.onSuccess?.(taskContext));
|
|
1501
1772
|
await this.runHook(run, "onComplete", () => exports.onComplete?.(taskContext));
|
|
1502
|
-
return value;
|
|
1773
|
+
return { status: "success", result: value };
|
|
1503
1774
|
} catch (error) {
|
|
1504
1775
|
await this.failRun(run, stage, error);
|
|
1505
1776
|
if (stage === "task" || stage === "record") {
|
|
@@ -1572,61 +1843,1172 @@ var TaskLibrary = class {
|
|
|
1572
1843
|
};
|
|
1573
1844
|
|
|
1574
1845
|
// src/runtime/taskLibraryScheduler.ts
|
|
1575
|
-
import
|
|
1846
|
+
import invariant7 from "tiny-invariant";
|
|
1576
1847
|
import { logTaskLibraryScheduler, logTaskLibrarySchedulerError } from "@apisurf/ngn-os";
|
|
1577
1848
|
|
|
1578
|
-
// src/runtime/
|
|
1579
|
-
import {
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1849
|
+
// src/runtime/taskSchedule.ts
|
|
1850
|
+
import { canTransition as canTransition2, errorSummary } from "@apisurf/ngn-schema/describe";
|
|
1851
|
+
|
|
1852
|
+
// src/db/services/schedule.ts
|
|
1853
|
+
import {
|
|
1854
|
+
canTransition,
|
|
1855
|
+
failureLine,
|
|
1856
|
+
SCHEDULE_TRANSITIONS
|
|
1857
|
+
} from "@apisurf/ngn-schema/describe";
|
|
1858
|
+
var FIELDS = ["next_at", "reason", "source", "holder", "anchor", "request"];
|
|
1859
|
+
var ScheduleStore = class {
|
|
1860
|
+
constructor(db2) {
|
|
1861
|
+
this.db = db2;
|
|
1588
1862
|
}
|
|
1863
|
+
async read(taskId) {
|
|
1864
|
+
const { rows } = await this.db.execute({
|
|
1865
|
+
sql: "SELECT * FROM schedules WHERE file_task_id = ?",
|
|
1866
|
+
args: [taskId]
|
|
1867
|
+
});
|
|
1868
|
+
return rows[0] ?? null;
|
|
1869
|
+
}
|
|
1870
|
+
/**
|
|
1871
|
+
* Apply `change` to the task's schedule, as it stands now. The new row, or
|
|
1872
|
+
* why it was refused.
|
|
1873
|
+
*/
|
|
1874
|
+
async transition(taskId, change) {
|
|
1875
|
+
const current = await this.read(taskId);
|
|
1876
|
+
const refused = refusal(current, change);
|
|
1877
|
+
if (refused) return { refused };
|
|
1878
|
+
const from = current?.state ?? null;
|
|
1879
|
+
const to = SCHEDULE_TRANSITIONS[change.event].to;
|
|
1880
|
+
const statements = [
|
|
1881
|
+
current ? this.update(taskId, from, to, change) : this.insert(taskId, to, change),
|
|
1882
|
+
this.event(taskId, from, change, current?.next_at ?? null)
|
|
1883
|
+
];
|
|
1884
|
+
if (change.finalRunId != null)
|
|
1885
|
+
statements.push(this.finalRun(change.finalRunId, change, current));
|
|
1886
|
+
const next = change.followedBy;
|
|
1887
|
+
if (next) {
|
|
1888
|
+
statements.push(
|
|
1889
|
+
this.update(taskId, to, SCHEDULE_TRANSITIONS[next.event].to, next, change.at),
|
|
1890
|
+
this.event(taskId, to, next, change.set?.next_at ?? current?.next_at ?? null)
|
|
1891
|
+
);
|
|
1892
|
+
}
|
|
1893
|
+
const results = await this.db.batch(statements, "write");
|
|
1894
|
+
if (!results[0] || results[0].rowsAffected === 0) {
|
|
1895
|
+
return { refused: `${change.event}: the schedule changed before it could be written` };
|
|
1896
|
+
}
|
|
1897
|
+
return await this.read(taskId);
|
|
1898
|
+
}
|
|
1899
|
+
insert(taskId, to, change) {
|
|
1900
|
+
const set = change.set ?? {};
|
|
1901
|
+
return {
|
|
1902
|
+
sql: `INSERT INTO schedules (file_task_id, state, ${FIELDS.join(", ")}, updated_at)
|
|
1903
|
+
VALUES (:taskId, :to, ${FIELDS.map((f) => `:${f}`).join(", ")}, :at)
|
|
1904
|
+
ON CONFLICT(file_task_id) DO NOTHING`,
|
|
1905
|
+
args: {
|
|
1906
|
+
taskId,
|
|
1907
|
+
to,
|
|
1908
|
+
at: change.at,
|
|
1909
|
+
...Object.fromEntries(FIELDS.map((f) => [f, set[f] ?? null]))
|
|
1910
|
+
}
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
/**
|
|
1914
|
+
* The row update, conditional on the state it comes from (and the holder or
|
|
1915
|
+
* request the change names). `after`: the time of the transition this one
|
|
1916
|
+
* follows in the same write, which it applies only on top of.
|
|
1917
|
+
*/
|
|
1918
|
+
update(taskId, from, to, change, after) {
|
|
1919
|
+
const set = change.set ?? {};
|
|
1920
|
+
const fields = FIELDS.filter((f) => f in set);
|
|
1921
|
+
const guards = [
|
|
1922
|
+
change.holder !== void 0 ? "AND holder IS :heldBy" : "",
|
|
1923
|
+
change.requestIs !== void 0 ? "AND request IS :requestIs" : "",
|
|
1924
|
+
after !== void 0 ? "AND updated_at = :after" : ""
|
|
1925
|
+
];
|
|
1926
|
+
return {
|
|
1927
|
+
sql: `UPDATE schedules SET state = :to, updated_at = :at
|
|
1928
|
+
${fields.map((f) => `, ${f} = :${f}`).join("")}
|
|
1929
|
+
WHERE file_task_id = :taskId AND state = :from ${guards.join(" ")}`,
|
|
1930
|
+
args: {
|
|
1931
|
+
taskId,
|
|
1932
|
+
from,
|
|
1933
|
+
to,
|
|
1934
|
+
at: change.at,
|
|
1935
|
+
...change.holder !== void 0 ? { heldBy: change.holder } : {},
|
|
1936
|
+
...change.requestIs !== void 0 ? { requestIs: change.requestIs } : {},
|
|
1937
|
+
...after !== void 0 ? { after } : {},
|
|
1938
|
+
...Object.fromEntries(fields.map((f) => [`${f}`, set[f] ?? null]))
|
|
1939
|
+
}
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
/** The next run and its reason, on the tick's final run, written only if the event was. */
|
|
1943
|
+
finalRun(runId, change, current) {
|
|
1944
|
+
return {
|
|
1945
|
+
sql: `UPDATE task_runs SET next_at = :nextAt, next_reason = :reason
|
|
1946
|
+
WHERE id = :id AND changes() = 1`,
|
|
1947
|
+
args: {
|
|
1948
|
+
id: runId,
|
|
1949
|
+
nextAt: change.set?.next_at ?? current?.next_at ?? null,
|
|
1950
|
+
reason: change.set?.reason ?? current?.reason ?? null
|
|
1951
|
+
}
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
/** The event, copied from the row the update left, written only if it applied. */
|
|
1955
|
+
event(taskId, from, change, previousNextAt) {
|
|
1956
|
+
return {
|
|
1957
|
+
sql: `INSERT INTO schedule_events (file_task_id, at, event, from_state, to_state,
|
|
1958
|
+
${FIELDS.join(", ")}, origin, first_run_id, run_id, previous_next_at)
|
|
1959
|
+
SELECT s.file_task_id, :at, :event, :from, s.state, ${FIELDS.map((f) => `s.${f}`).join(", ")},
|
|
1960
|
+
:origin, :firstRunId, :runId, :previousNextAt
|
|
1961
|
+
FROM schedules s WHERE s.file_task_id = :taskId AND changes() = 1`,
|
|
1962
|
+
args: {
|
|
1963
|
+
taskId,
|
|
1964
|
+
at: change.at,
|
|
1965
|
+
event: change.event,
|
|
1966
|
+
from,
|
|
1967
|
+
origin: change.origin ?? null,
|
|
1968
|
+
firstRunId: change.firstRunId ?? null,
|
|
1969
|
+
runId: change.runId ?? null,
|
|
1970
|
+
previousNextAt
|
|
1971
|
+
}
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
/**
|
|
1975
|
+
* Close the task's runs that never recorded an end, as failed in
|
|
1976
|
+
* `interrupted` with `why` as the error, in one write. `fromRunId` limits it
|
|
1977
|
+
* to one tick's runs. Returns the ids closed, oldest first.
|
|
1978
|
+
*/
|
|
1979
|
+
async closeOpenRuns(taskId, why, { fromRunId = 0, at }) {
|
|
1980
|
+
const open = `file_task_id = :taskId AND id >= :fromRunId AND ended_at IS NULL
|
|
1981
|
+
AND status IN ('pending', 'running')`;
|
|
1982
|
+
const args = { taskId, fromRunId };
|
|
1983
|
+
const log = (value) => ({
|
|
1984
|
+
sql: `INSERT INTO logs (status, value, file_task_id, task_run_id)
|
|
1985
|
+
SELECT 'error', :value, file_task_id, id FROM task_runs WHERE ${open} ORDER BY id`,
|
|
1986
|
+
args: { ...args, value }
|
|
1987
|
+
});
|
|
1988
|
+
const [, , closed] = await this.db.batch(
|
|
1989
|
+
[
|
|
1990
|
+
log(failureLine("interrupted", new Error(why))),
|
|
1991
|
+
log("Task failed"),
|
|
1992
|
+
{
|
|
1993
|
+
sql: `UPDATE task_runs SET status = 'failure', ended_at = :at WHERE ${open} RETURNING id`,
|
|
1994
|
+
args: { ...args, at }
|
|
1995
|
+
}
|
|
1996
|
+
],
|
|
1997
|
+
"write"
|
|
1998
|
+
);
|
|
1999
|
+
return closed.rows.map((row) => Number(row.id)).toSorted((a, b) => a - b);
|
|
2000
|
+
}
|
|
2001
|
+
};
|
|
2002
|
+
function refusal(current, change) {
|
|
2003
|
+
const from = current?.state ?? null;
|
|
2004
|
+
if (!canTransition(from, change.event)) {
|
|
2005
|
+
return `${change.event} is not allowed from ${from ?? "no schedule"}`;
|
|
2006
|
+
}
|
|
2007
|
+
if (change.holder !== void 0 && current?.holder !== change.holder) {
|
|
2008
|
+
return `${change.event}: the task is no longer held by this tick`;
|
|
2009
|
+
}
|
|
2010
|
+
if (change.requestIs !== void 0 && current?.request !== change.requestIs) {
|
|
2011
|
+
return `${change.event}: a request was made since the schedule was read`;
|
|
2012
|
+
}
|
|
2013
|
+
const next = change.followedBy;
|
|
2014
|
+
const to = SCHEDULE_TRANSITIONS[change.event].to;
|
|
2015
|
+
if (next && !canTransition(to, next.event)) return `${next.event} is not allowed from ${to}`;
|
|
2016
|
+
return null;
|
|
1589
2017
|
}
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
2018
|
+
function isRefusal(value) {
|
|
2019
|
+
return "refused" in value;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
// src/runtime/lease.ts
|
|
2023
|
+
var TASK_LEASE_TTL_MS = 6e4;
|
|
2024
|
+
function taskLeaseName(taskPath) {
|
|
2025
|
+
return `${RESERVED_LOCK_PREFIX}task:${taskPath}`;
|
|
2026
|
+
}
|
|
2027
|
+
var TaskLease = class _TaskLease {
|
|
2028
|
+
constructor(locks, name, holder, ttlMs, onLost) {
|
|
2029
|
+
this.locks = locks;
|
|
2030
|
+
this.name = name;
|
|
2031
|
+
this.holder = holder;
|
|
2032
|
+
this.ttlMs = ttlMs;
|
|
2033
|
+
this.onLost = onLost;
|
|
2034
|
+
this.renewal = setInterval(() => void this.renew(), Math.max(10, ttlMs / 3));
|
|
2035
|
+
this.renewal.unref();
|
|
2036
|
+
}
|
|
2037
|
+
controller = new AbortController();
|
|
2038
|
+
renewal = null;
|
|
2039
|
+
lastRenewed = Date.now();
|
|
2040
|
+
released = false;
|
|
2041
|
+
/** Take the task's lease, or say who has it. */
|
|
2042
|
+
static async take(locks, taskPath, {
|
|
2043
|
+
ttlMs = TASK_LEASE_TTL_MS,
|
|
2044
|
+
onLost = () => {
|
|
2045
|
+
}
|
|
2046
|
+
} = {}) {
|
|
2047
|
+
const name = taskLeaseName(taskPath);
|
|
2048
|
+
const holder = await locks.tryAcquire(name, ttlMs, null);
|
|
2049
|
+
if (holder) return new _TaskLease(locks, name, holder, ttlMs, onLost);
|
|
2050
|
+
const held = await locks.holderOf(name);
|
|
2051
|
+
return { busy: { holder: held?.holder ?? "", runId: held?.taskRunId ?? null } };
|
|
2052
|
+
}
|
|
2053
|
+
/** Aborts when the lease is lost. */
|
|
2054
|
+
get signal() {
|
|
2055
|
+
return this.controller.signal;
|
|
2056
|
+
}
|
|
2057
|
+
get lost() {
|
|
2058
|
+
return this.controller.signal.aborted;
|
|
2059
|
+
}
|
|
2060
|
+
/** Record the run the lease is working for, so a refusal can name it. */
|
|
2061
|
+
async setRun(runId) {
|
|
2062
|
+
if (this.released || this.lost) return;
|
|
2063
|
+
await this.locks.setRun(this.name, this.holder, runId).catch(() => {
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
2066
|
+
async renew() {
|
|
2067
|
+
if (this.released || this.lost) return;
|
|
2068
|
+
if (Date.now() - this.lastRenewed > this.ttlMs) return this.lose();
|
|
2069
|
+
try {
|
|
2070
|
+
if (await this.locks.renew(this.name, this.holder, this.ttlMs)) {
|
|
2071
|
+
this.lastRenewed = Date.now();
|
|
2072
|
+
} else {
|
|
2073
|
+
this.lose();
|
|
2074
|
+
}
|
|
2075
|
+
} catch {
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
lose() {
|
|
2079
|
+
if (this.lost) return;
|
|
2080
|
+
this.stopRenewing();
|
|
2081
|
+
this.controller.abort(new Error("the run lost its lease"));
|
|
2082
|
+
this.onLost(this);
|
|
2083
|
+
}
|
|
2084
|
+
stopRenewing() {
|
|
2085
|
+
if (this.renewal) clearInterval(this.renewal);
|
|
2086
|
+
this.renewal = null;
|
|
2087
|
+
}
|
|
2088
|
+
async release() {
|
|
2089
|
+
if (this.released) return;
|
|
2090
|
+
this.released = true;
|
|
2091
|
+
this.stopRenewing();
|
|
2092
|
+
await this.locks.release(this.name, this.holder).catch(() => {
|
|
2093
|
+
});
|
|
2094
|
+
}
|
|
2095
|
+
/** Stop renewing without giving the lease back, as a process that died would. */
|
|
2096
|
+
abandon() {
|
|
2097
|
+
this.released = true;
|
|
2098
|
+
this.stopRenewing();
|
|
2099
|
+
}
|
|
2100
|
+
};
|
|
2101
|
+
|
|
2102
|
+
// src/runtime/scheduleControl.ts
|
|
2103
|
+
import {
|
|
2104
|
+
encodeScheduleRequest,
|
|
2105
|
+
formatClock,
|
|
2106
|
+
formatDelay,
|
|
2107
|
+
parseScheduleRequest
|
|
2108
|
+
} from "@apisurf/ngn-schema/describe";
|
|
2109
|
+
var MIN_DELAY_MS = 1e3;
|
|
2110
|
+
async function controlSchedule(target, command) {
|
|
2111
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2112
|
+
const row = await target.store.read(target.taskId);
|
|
2113
|
+
if (!row) {
|
|
2114
|
+
return {
|
|
2115
|
+
applied: false,
|
|
2116
|
+
error: true,
|
|
2117
|
+
message: `${target.name} has not been scheduled yet: ngn run schedules it`
|
|
2118
|
+
};
|
|
2119
|
+
}
|
|
2120
|
+
const plan = planCommand(row, command, target);
|
|
2121
|
+
if (!("change" in plan)) return plan;
|
|
2122
|
+
const result = await target.store.transition(target.taskId, plan.change);
|
|
2123
|
+
if (!isRefusal(result)) {
|
|
2124
|
+
return { applied: true, event: plan.change.event, row: result, message: plan.message };
|
|
1595
2125
|
}
|
|
1596
|
-
|
|
2126
|
+
}
|
|
2127
|
+
return {
|
|
2128
|
+
applied: false,
|
|
2129
|
+
error: true,
|
|
2130
|
+
message: `the schedule of ${target.name} kept changing; try again`
|
|
2131
|
+
};
|
|
2132
|
+
}
|
|
2133
|
+
function planCommand(row, command, target) {
|
|
2134
|
+
if (command.kind === "pause") return planPause(row, target);
|
|
2135
|
+
if (command.kind === "resume") return planResume(row, target);
|
|
2136
|
+
return planNext(row, command.delayMs, target);
|
|
2137
|
+
}
|
|
2138
|
+
function withRequest(row, more) {
|
|
2139
|
+
return encodeScheduleRequest({ ...parseScheduleRequest(row.request), ...more });
|
|
2140
|
+
}
|
|
2141
|
+
function planPause(row, { name, now }) {
|
|
2142
|
+
if (row.state === "paused") {
|
|
2143
|
+
return { applied: false, error: false, message: `${name} is already paused` };
|
|
2144
|
+
}
|
|
2145
|
+
if (row.state === "running") {
|
|
2146
|
+
return {
|
|
2147
|
+
change: {
|
|
2148
|
+
event: "pause_requested",
|
|
2149
|
+
at: now,
|
|
2150
|
+
set: { request: withRequest(row, { pause: true }) },
|
|
2151
|
+
requestIs: row.request
|
|
2152
|
+
},
|
|
2153
|
+
message: `${name} has a run in progress; it pauses when the run ends`
|
|
2154
|
+
};
|
|
2155
|
+
}
|
|
2156
|
+
return {
|
|
2157
|
+
change: { event: "paused", at: now, set: { holder: null } },
|
|
2158
|
+
message: `paused ${name}: no runs until ngn resume ${name}`
|
|
2159
|
+
};
|
|
2160
|
+
}
|
|
2161
|
+
function planResume(row, { name, now, pattern }) {
|
|
2162
|
+
if (row.state !== "paused") {
|
|
2163
|
+
return { applied: false, error: false, message: `${name} isn't paused (${row.state})` };
|
|
2164
|
+
}
|
|
2165
|
+
const missed = row.next_at == null || row.next_at < now;
|
|
2166
|
+
let set = { reason: "resumed", source: "cli" };
|
|
2167
|
+
if (missed) {
|
|
2168
|
+
const nextAt2 = pattern ? nextCronSlot(pattern, now) ?? now : now;
|
|
2169
|
+
set = {
|
|
2170
|
+
next_at: nextAt2,
|
|
2171
|
+
anchor: nextAt2,
|
|
2172
|
+
source: "cli",
|
|
2173
|
+
reason: pattern ? "resumed; missed slots are not run, next on its pattern" : "resumed; it was due while paused, so it runs now"
|
|
2174
|
+
};
|
|
2175
|
+
}
|
|
2176
|
+
const nextAt = set.next_at ?? row.next_at;
|
|
2177
|
+
return {
|
|
2178
|
+
change: { event: "resumed", at: now, set },
|
|
2179
|
+
message: `resumed ${name}: next run ${nextAt <= now ? "now" : formatClock(nextAt)}`
|
|
2180
|
+
};
|
|
2181
|
+
}
|
|
2182
|
+
function planNext(row, delayMs, { name, now }) {
|
|
2183
|
+
if (row.state === "paused") {
|
|
2184
|
+
return {
|
|
2185
|
+
applied: false,
|
|
2186
|
+
error: true,
|
|
2187
|
+
message: `${name} is paused; run ngn resume ${name} first`
|
|
2188
|
+
};
|
|
2189
|
+
}
|
|
2190
|
+
const delay = delayMs === 0 ? 0 : Math.max(MIN_DELAY_MS, delayMs);
|
|
2191
|
+
const nextAt = now + delay;
|
|
2192
|
+
if (row.state === "running") {
|
|
2193
|
+
return {
|
|
2194
|
+
change: {
|
|
2195
|
+
event: "override_requested",
|
|
2196
|
+
at: now,
|
|
2197
|
+
set: { request: withRequest(row, { nextAt }) },
|
|
2198
|
+
requestIs: row.request
|
|
2199
|
+
},
|
|
2200
|
+
message: `${name} has a run in progress; when it ends, its next run is ${delay === 0 ? "now" : formatClock(nextAt)}`
|
|
2201
|
+
};
|
|
2202
|
+
}
|
|
2203
|
+
return {
|
|
2204
|
+
change: {
|
|
2205
|
+
event: "rescheduled",
|
|
2206
|
+
at: now,
|
|
2207
|
+
set: {
|
|
2208
|
+
next_at: nextAt,
|
|
2209
|
+
reason: `moved with ngn next: ${delay === 0 ? "now" : `in ${formatDelay(delay)}`}`,
|
|
2210
|
+
source: "cli",
|
|
2211
|
+
anchor: nextAt,
|
|
2212
|
+
holder: null
|
|
2213
|
+
}
|
|
2214
|
+
},
|
|
2215
|
+
message: `${name} runs next ${delay === 0 ? "now" : `at ${formatClock(nextAt)}`}`
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
2218
|
+
function honourRequest(change, row) {
|
|
2219
|
+
const { pause, nextAt } = parseScheduleRequest(row?.request);
|
|
2220
|
+
const set = { ...change.set, request: null };
|
|
2221
|
+
if (nextAt != null) {
|
|
2222
|
+
Object.assign(set, {
|
|
2223
|
+
next_at: nextAt,
|
|
2224
|
+
reason: `moved with ngn next to ${formatClock(nextAt)}`,
|
|
2225
|
+
source: "cli",
|
|
2226
|
+
anchor: nextAt
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2229
|
+
return {
|
|
2230
|
+
...change,
|
|
2231
|
+
set,
|
|
2232
|
+
requestIs: row?.request ?? null,
|
|
2233
|
+
...pause ? { followedBy: { event: "paused", at: change.at } } : {}
|
|
2234
|
+
};
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
// src/runtime/timingRule.ts
|
|
2238
|
+
import {
|
|
2239
|
+
describeTiming,
|
|
2240
|
+
formatClock as formatClock2,
|
|
2241
|
+
formatDelay as formatDelay2,
|
|
2242
|
+
formatMs
|
|
2243
|
+
} from "@apisurf/ngn-schema/describe";
|
|
2244
|
+
function gridSlotAfter(anchor, everyMs) {
|
|
2245
|
+
return (t) => anchor + (Math.floor((t - anchor) / everyMs) + 1) * everyMs;
|
|
2246
|
+
}
|
|
2247
|
+
function firstFittingSlot(slotAfter, plannedAt, endedAt) {
|
|
2248
|
+
const due = slotAfter(plannedAt);
|
|
2249
|
+
if (endedAt <= due) return { nextAt: due, due, missed: 0 };
|
|
2250
|
+
const nextAt = slotAfter(endedAt - 1);
|
|
2251
|
+
let missed = 0;
|
|
2252
|
+
for (let slot = due; slot < nextAt && missed < 1e3; slot = slotAfter(slot)) missed++;
|
|
2253
|
+
return { nextAt, due, missed };
|
|
2254
|
+
}
|
|
2255
|
+
function timingRule(timing, exported, random = Math.random) {
|
|
2256
|
+
const words = describeTiming(exported) ?? String(exported);
|
|
2257
|
+
if (timing.kind === "cron") return cronRule(timing.pattern, words);
|
|
2258
|
+
return timing.edge === "trailing" ? trailingRule(timing, words, random) : leadingRule(timing, words, random);
|
|
2259
|
+
}
|
|
2260
|
+
function failures(n) {
|
|
2261
|
+
return n === 1 ? "a failure" : `${n} failures in a row`;
|
|
2262
|
+
}
|
|
2263
|
+
function backoffDelay(timing, failedInARow, random) {
|
|
2264
|
+
const { factor, jitter } = timing.backoff;
|
|
2265
|
+
const base = Math.min(timing.maxMs, timing.everyMs * factor ** failedInARow);
|
|
2266
|
+
const jittered = Math.round(base * (1 + (random() * 2 - 1) * jitter));
|
|
2267
|
+
return Math.min(timing.maxMs, Math.max(timing.minMs, jittered));
|
|
2268
|
+
}
|
|
2269
|
+
function backoffDecision(timing, { status, endedAt, failedInARow }, random) {
|
|
2270
|
+
if (status !== "failure" || !timing.backoff) return null;
|
|
2271
|
+
const n = Math.max(1, failedInARow);
|
|
2272
|
+
const delay = backoffDelay(timing, n, random);
|
|
2273
|
+
const nextAt = endedAt + delay;
|
|
2274
|
+
return {
|
|
2275
|
+
nextAt,
|
|
2276
|
+
reason: `backing off after ${failures(n)}: ${formatDelay2(delay)}`,
|
|
2277
|
+
anchor: timing.edge === "leading" ? nextAt : null,
|
|
2278
|
+
source: "policy"
|
|
2279
|
+
};
|
|
2280
|
+
}
|
|
2281
|
+
function hookDecision(timing, { choice, endedAt }, onSchedule) {
|
|
2282
|
+
if (!choice) return null;
|
|
2283
|
+
if (choice.delay === "every") {
|
|
2284
|
+
const scheduled = onSchedule();
|
|
2285
|
+
return {
|
|
2286
|
+
...scheduled,
|
|
2287
|
+
reason: choice.reason ?? `next hook: ${scheduled.reason}`,
|
|
2288
|
+
source: "hook"
|
|
2289
|
+
};
|
|
2290
|
+
}
|
|
2291
|
+
const named = typeof choice.delay === "string" ? choice.delay : null;
|
|
2292
|
+
const wanted = named === "min" ? timing.minMs : named === "max" ? timing.maxMs : choice.delay;
|
|
2293
|
+
const delay = Math.min(timing.maxMs, Math.max(timing.minMs, wanted));
|
|
2294
|
+
const clamped = delay === wanted ? "" : ` (${formatDelay2(wanted)} kept to ${delay === timing.minMs ? "min" : "max"} ${formatDelay2(delay)})`;
|
|
2295
|
+
const nextAt = endedAt + delay;
|
|
2296
|
+
return {
|
|
2297
|
+
nextAt,
|
|
2298
|
+
reason: `${choice.reason ?? `next hook: ${named ? `${named}, ` : ""}${formatDelay2(delay)}`}${clamped}`,
|
|
2299
|
+
anchor: timing.edge === "leading" ? nextAt : null,
|
|
2300
|
+
source: "hook"
|
|
2301
|
+
};
|
|
2302
|
+
}
|
|
2303
|
+
function intervalLatest(timing, now, slot, input) {
|
|
2304
|
+
if (input.source === "cli") return null;
|
|
2305
|
+
if (input.source != null && input.source !== "policy") return now + timing.maxMs;
|
|
2306
|
+
if (!timing.backoff || input.failedInARow === 0) return slot;
|
|
2307
|
+
const { factor, jitter } = timing.backoff;
|
|
2308
|
+
const longest = timing.everyMs * factor ** input.failedInARow * (1 + jitter);
|
|
2309
|
+
return Math.max(slot, now + Math.min(timing.maxMs, Math.ceil(longest)));
|
|
2310
|
+
}
|
|
2311
|
+
function trailingRule(timing, words, random) {
|
|
2312
|
+
const { everyMs } = timing;
|
|
2313
|
+
return {
|
|
2314
|
+
words,
|
|
2315
|
+
first: (now) => ({ nextAt: now, anchor: null }),
|
|
2316
|
+
window: (plannedAt) => ({ scheduledAt: plannedAt, nextAt: null, periodMs: everyMs }),
|
|
2317
|
+
decide(input) {
|
|
2318
|
+
const onSchedule = () => ({
|
|
2319
|
+
nextAt: input.endedAt + everyMs,
|
|
2320
|
+
reason: words,
|
|
2321
|
+
anchor: null,
|
|
2322
|
+
source: "policy"
|
|
2323
|
+
});
|
|
2324
|
+
return hookDecision(timing, input, onSchedule) ?? backoffDecision(timing, input, random) ?? onSchedule();
|
|
2325
|
+
},
|
|
2326
|
+
overdue: (now) => ({ nextAt: now, catchUp: true }),
|
|
2327
|
+
latest: (now, input) => intervalLatest(timing, now, now + everyMs, input)
|
|
2328
|
+
};
|
|
2329
|
+
}
|
|
2330
|
+
function leadingRule(timing, words, random) {
|
|
2331
|
+
const { everyMs } = timing;
|
|
2332
|
+
const slots = (anchor, fallback) => gridSlotAfter(anchor ?? fallback, everyMs);
|
|
2333
|
+
return {
|
|
2334
|
+
words,
|
|
2335
|
+
first: (now) => ({ nextAt: now, anchor: now }),
|
|
2336
|
+
window: (plannedAt, anchor) => ({
|
|
2337
|
+
scheduledAt: plannedAt,
|
|
2338
|
+
nextAt: slots(anchor, plannedAt)(plannedAt),
|
|
2339
|
+
periodMs: null
|
|
2340
|
+
}),
|
|
2341
|
+
decide(input) {
|
|
2342
|
+
const onSchedule = () => {
|
|
2343
|
+
const { plannedAt, endedAt, anchor } = input;
|
|
2344
|
+
const grid = anchor ?? plannedAt;
|
|
2345
|
+
const slot = firstFittingSlot(gridSlotAfter(grid, everyMs), plannedAt, endedAt);
|
|
2346
|
+
const reason = slot.missed ? `ran ${formatMs(endedAt - slot.due)} past its slot; next slot ${formatClock2(slot.nextAt)}` : words;
|
|
2347
|
+
return { nextAt: slot.nextAt, reason, anchor: grid, source: "policy" };
|
|
2348
|
+
};
|
|
2349
|
+
return hookDecision(timing, input, onSchedule) ?? backoffDecision(timing, input, random) ?? onSchedule();
|
|
2350
|
+
},
|
|
2351
|
+
overdue: (now) => ({ nextAt: now, catchUp: true }),
|
|
2352
|
+
latest: (now, input) => intervalLatest(timing, now, slots(input.anchor, now)(now), input)
|
|
2353
|
+
};
|
|
2354
|
+
}
|
|
2355
|
+
function cronRule(pattern, words) {
|
|
2356
|
+
const slotAfter = (t) => nextCronSlot(pattern, t) ?? Number.MAX_SAFE_INTEGER;
|
|
2357
|
+
return {
|
|
2358
|
+
words,
|
|
2359
|
+
first: (now) => ({ nextAt: slotAfter(now), anchor: null }),
|
|
2360
|
+
window: (plannedAt) => ({
|
|
2361
|
+
scheduledAt: plannedAt,
|
|
2362
|
+
nextAt: slotAfter(plannedAt),
|
|
2363
|
+
periodMs: null
|
|
2364
|
+
}),
|
|
2365
|
+
decide({ plannedAt, endedAt }) {
|
|
2366
|
+
const slot = firstFittingSlot(slotAfter, plannedAt, endedAt);
|
|
2367
|
+
const reason = slot.missed ? `ran past ${slot.missed} ${slot.missed === 1 ? "slot" : "slots"}; next ${formatClock2(slot.nextAt)}` : words;
|
|
2368
|
+
return { nextAt: slot.nextAt, reason, anchor: null, source: "policy" };
|
|
2369
|
+
},
|
|
2370
|
+
overdue: (now) => ({ nextAt: slotAfter(now), catchUp: false }),
|
|
2371
|
+
latest: (now, { source }) => source === "cli" ? null : slotAfter(now)
|
|
2372
|
+
};
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
// src/runtime/taskSchedule.ts
|
|
2376
|
+
var STOP_GRACE_MS = 1e4;
|
|
2377
|
+
var CATCH_UP_REASON = "one catch-up run for what was missed while ngn was not running";
|
|
2378
|
+
var TickHandle = class {
|
|
2379
|
+
constructor(origin, lease) {
|
|
2380
|
+
this.origin = origin;
|
|
2381
|
+
this.lease = lease;
|
|
2382
|
+
this.whenClosed = new Promise((resolve2) => this.markClosed = () => resolve2(null));
|
|
2383
|
+
}
|
|
2384
|
+
controller = new AbortController();
|
|
2385
|
+
firstRunId = null;
|
|
2386
|
+
runId = null;
|
|
2387
|
+
/** Whether it wrote `started`, so its end has a transition to make. */
|
|
2388
|
+
logged = false;
|
|
2389
|
+
/** Closed by shutdown (or a crash): whatever it decides later is not written. */
|
|
2390
|
+
closed = false;
|
|
2391
|
+
done = Promise.resolve();
|
|
2392
|
+
/** Resolves when the tick is closed, so nothing waits on a run that may never end. */
|
|
2393
|
+
whenClosed;
|
|
2394
|
+
markClosed;
|
|
2395
|
+
close() {
|
|
2396
|
+
this.closed = true;
|
|
2397
|
+
this.markClosed();
|
|
2398
|
+
}
|
|
2399
|
+
get signal() {
|
|
2400
|
+
return AbortSignal.any([this.controller.signal, this.lease.signal]);
|
|
2401
|
+
}
|
|
2402
|
+
noteRun = (runId) => {
|
|
2403
|
+
this.firstRunId ??= runId;
|
|
2404
|
+
this.runId = runId;
|
|
2405
|
+
void this.lease.setRun(runId);
|
|
2406
|
+
};
|
|
2407
|
+
};
|
|
2408
|
+
function untilAborted(signal) {
|
|
2409
|
+
return new Promise((resolve2) => {
|
|
2410
|
+
if (signal.aborted) resolve2();
|
|
2411
|
+
else signal.addEventListener("abort", () => resolve2(), { once: true });
|
|
2412
|
+
});
|
|
2413
|
+
}
|
|
2414
|
+
function busyReason(taskPath, busy) {
|
|
2415
|
+
return busy.runId != null ? `run ${busy.runId} of ${taskPath} is in progress` : `a run of ${taskPath} is in progress`;
|
|
2416
|
+
}
|
|
2417
|
+
async function writeTransition(task, taskId, change, onProblem) {
|
|
2418
|
+
try {
|
|
2419
|
+
const result = await task.scheduling.store.transition(taskId, change);
|
|
2420
|
+
if (!isRefusal(result)) return result;
|
|
2421
|
+
onProblem(`schedule of ${task.taskPath}: refused ${result.refused}`);
|
|
1597
2422
|
} catch (error) {
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
2423
|
+
onProblem(
|
|
2424
|
+
`schedule of ${task.taskPath}: could not write ${change.event}: ${errorSummary(error)}`
|
|
2425
|
+
);
|
|
2426
|
+
}
|
|
2427
|
+
return null;
|
|
2428
|
+
}
|
|
2429
|
+
async function writeTickEnd(task, taskId, change, onProblem) {
|
|
2430
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
2431
|
+
const row = await task.scheduling.store.read(taskId);
|
|
2432
|
+
const honoured = honourRequest(change, row);
|
|
2433
|
+
let result;
|
|
2434
|
+
try {
|
|
2435
|
+
result = await task.scheduling.store.transition(taskId, honoured);
|
|
2436
|
+
} catch (error) {
|
|
2437
|
+
onProblem(
|
|
2438
|
+
`schedule of ${task.taskPath}: could not write ${change.event}: ${errorSummary(error)}`
|
|
2439
|
+
);
|
|
2440
|
+
return null;
|
|
2441
|
+
}
|
|
2442
|
+
if (!isRefusal(result)) return result;
|
|
2443
|
+
const now = await task.scheduling.store.read(taskId);
|
|
2444
|
+
if (now?.request === honoured.requestIs) {
|
|
2445
|
+
onProblem(`schedule of ${task.taskPath}: refused ${result.refused}`);
|
|
2446
|
+
return null;
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
onProblem(`schedule of ${task.taskPath}: requests kept arriving; ${change.event} not written`);
|
|
2450
|
+
return null;
|
|
2451
|
+
}
|
|
2452
|
+
async function startManual(deps, onLost = () => {
|
|
2453
|
+
}) {
|
|
2454
|
+
const { task, taskId, clock, onProblem } = deps;
|
|
2455
|
+
const lease = await TaskLease.take(task.scheduling.locks, task.taskPath, {
|
|
2456
|
+
ttlMs: deps.leaseTtlMs,
|
|
2457
|
+
onLost
|
|
2458
|
+
});
|
|
2459
|
+
if ("busy" in lease) return { busy: busyReason(task.taskPath, lease.busy) };
|
|
2460
|
+
const tick = new TickHandle("manual", lease);
|
|
2461
|
+
const outcome = (async () => {
|
|
2462
|
+
try {
|
|
2463
|
+
const row = await task.scheduling.store.read(taskId);
|
|
2464
|
+
if (row && canTransition2(row.state, "started")) {
|
|
2465
|
+
const request = row.state === "paused" ? { request: withRequest(row, { pause: true }) } : {};
|
|
2466
|
+
const started = await writeTransition(
|
|
2467
|
+
task,
|
|
2468
|
+
taskId,
|
|
2469
|
+
{
|
|
2470
|
+
event: "started",
|
|
2471
|
+
at: clock.now(),
|
|
2472
|
+
origin: "manual",
|
|
2473
|
+
set: { holder: lease.holder, ...request }
|
|
2474
|
+
},
|
|
2475
|
+
onProblem
|
|
2476
|
+
);
|
|
2477
|
+
tick.logged = started != null;
|
|
2478
|
+
}
|
|
2479
|
+
const result = await task.executeTick({
|
|
2480
|
+
scheduledAt: null,
|
|
2481
|
+
nextAt: deps.window?.nextAt ?? null,
|
|
2482
|
+
periodMs: deps.window?.periodMs ?? null,
|
|
2483
|
+
clock,
|
|
2484
|
+
signal: tick.signal,
|
|
2485
|
+
onRunStarted: tick.noteRun
|
|
2486
|
+
});
|
|
2487
|
+
if (tick.logged && !tick.closed && !lease.lost) {
|
|
2488
|
+
await writeTickEnd(
|
|
2489
|
+
task,
|
|
2490
|
+
taskId,
|
|
2491
|
+
{
|
|
2492
|
+
event: "ended",
|
|
2493
|
+
at: clock.now(),
|
|
2494
|
+
holder: lease.holder,
|
|
2495
|
+
set: { holder: null },
|
|
2496
|
+
firstRunId: result.firstRunId,
|
|
2497
|
+
runId: result.runId
|
|
2498
|
+
},
|
|
2499
|
+
onProblem
|
|
2500
|
+
);
|
|
2501
|
+
}
|
|
2502
|
+
return result;
|
|
2503
|
+
} finally {
|
|
2504
|
+
await lease.release();
|
|
2505
|
+
}
|
|
2506
|
+
})();
|
|
2507
|
+
tick.done = outcome.catch(() => {
|
|
2508
|
+
});
|
|
2509
|
+
return { tick, outcome };
|
|
2510
|
+
}
|
|
2511
|
+
async function runOnce(task, { clock, leaseTtlMs, onProblem }) {
|
|
2512
|
+
const taskId = await task.register();
|
|
2513
|
+
const started = await startManual({ task, taskId, clock, leaseTtlMs, onProblem });
|
|
2514
|
+
return "busy" in started ? started : started.outcome;
|
|
1612
2515
|
}
|
|
2516
|
+
var TaskSchedule = class {
|
|
2517
|
+
constructor(options) {
|
|
2518
|
+
this.options = options;
|
|
2519
|
+
this.exported = options.timing;
|
|
2520
|
+
this.resolved = resolveTiming(options.timing, options.task.taskPath);
|
|
2521
|
+
}
|
|
2522
|
+
taskId = -1;
|
|
2523
|
+
row = null;
|
|
2524
|
+
resolved;
|
|
2525
|
+
exported;
|
|
2526
|
+
stopping = false;
|
|
2527
|
+
halt = new AbortController();
|
|
2528
|
+
ticks = /* @__PURE__ */ new Set();
|
|
2529
|
+
loopDone = Promise.resolve();
|
|
2530
|
+
get task() {
|
|
2531
|
+
return this.options.task;
|
|
2532
|
+
}
|
|
2533
|
+
get path() {
|
|
2534
|
+
return this.options.task.taskPath;
|
|
2535
|
+
}
|
|
2536
|
+
get store() {
|
|
2537
|
+
return this.options.task.scheduling.store;
|
|
2538
|
+
}
|
|
2539
|
+
get clock() {
|
|
2540
|
+
return this.options.clock;
|
|
2541
|
+
}
|
|
2542
|
+
get ttlMs() {
|
|
2543
|
+
return this.options.leaseTtlMs ?? TASK_LEASE_TTL_MS;
|
|
2544
|
+
}
|
|
2545
|
+
rule() {
|
|
2546
|
+
return timingRule(this.resolved, this.exported, this.options.random);
|
|
2547
|
+
}
|
|
2548
|
+
/**
|
|
2549
|
+
* The failure streak `outcome` ends, this tick included. A tick that failed
|
|
2550
|
+
* before it had a run is not in the history, so it is added here.
|
|
2551
|
+
*/
|
|
2552
|
+
async failedInARow(outcome) {
|
|
2553
|
+
const streak = await this.task.failedInARow();
|
|
2554
|
+
return outcome?.status === "failure" && outcome.runId == null ? streak + 1 : streak;
|
|
2555
|
+
}
|
|
2556
|
+
nextAt() {
|
|
2557
|
+
return this.row?.next_at ?? null;
|
|
2558
|
+
}
|
|
2559
|
+
/** Register the task, bring its stored schedule up to date, and start the loop. */
|
|
2560
|
+
async start() {
|
|
2561
|
+
this.taskId = await this.task.register();
|
|
2562
|
+
await this.reconcile();
|
|
2563
|
+
this.loopDone = this.loop();
|
|
2564
|
+
}
|
|
2565
|
+
/** Transitions written by this process, so the loop can tell a step that moved things on. */
|
|
2566
|
+
writes = 0;
|
|
2567
|
+
write(change, write = writeTransition) {
|
|
2568
|
+
return write(this.task, this.taskId, change, this.options.onProblem).then((row) => {
|
|
2569
|
+
if (row) {
|
|
2570
|
+
this.row = row;
|
|
2571
|
+
this.writes++;
|
|
2572
|
+
}
|
|
2573
|
+
return row;
|
|
2574
|
+
});
|
|
2575
|
+
}
|
|
2576
|
+
/** Write the transition that ends a tick, honouring a pause or next run asked for during it. */
|
|
2577
|
+
writeEnd(change) {
|
|
2578
|
+
return this.write(change, writeTickEnd);
|
|
2579
|
+
}
|
|
2580
|
+
/**
|
|
2581
|
+
* What a start finds in the database: no schedule yet, a next run still to
|
|
2582
|
+
* come, one missed while ngn was not running, one further off than the
|
|
2583
|
+
* current timing could choose, or a tick a dead process left `running`.
|
|
2584
|
+
*/
|
|
2585
|
+
async reconcile() {
|
|
2586
|
+
const now = this.clock.now();
|
|
2587
|
+
const rule = this.rule();
|
|
2588
|
+
const row = await this.store.read(this.taskId);
|
|
2589
|
+
this.row = row;
|
|
2590
|
+
if (!row) {
|
|
2591
|
+
const first = rule.first(now);
|
|
2592
|
+
await this.write({
|
|
2593
|
+
event: "scheduled",
|
|
2594
|
+
at: now,
|
|
2595
|
+
set: { next_at: first.nextAt, anchor: first.anchor, reason: "first run", source: "policy" }
|
|
2596
|
+
});
|
|
2597
|
+
return;
|
|
2598
|
+
}
|
|
2599
|
+
if (row.state === "running") {
|
|
2600
|
+
await this.recoverIfAbandoned();
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
if (row.state !== "waiting" || row.next_at == null) return;
|
|
2604
|
+
if (row.next_at < now - WAIT_STEP_MS) {
|
|
2605
|
+
const overdue = rule.overdue(now, row.anchor);
|
|
2606
|
+
await this.write({
|
|
2607
|
+
event: "overdue",
|
|
2608
|
+
at: now,
|
|
2609
|
+
set: {
|
|
2610
|
+
next_at: overdue.nextAt,
|
|
2611
|
+
reason: overdue.catchUp ? CATCH_UP_REASON : `missed while ngn was not running; ${rule.words}`,
|
|
2612
|
+
source: "policy"
|
|
2613
|
+
}
|
|
2614
|
+
});
|
|
2615
|
+
return;
|
|
2616
|
+
}
|
|
2617
|
+
const latest = rule.latest(now, {
|
|
2618
|
+
anchor: row.anchor,
|
|
2619
|
+
source: row.source,
|
|
2620
|
+
failedInARow: await this.failedInARow()
|
|
2621
|
+
});
|
|
2622
|
+
if (latest != null && row.next_at > latest) {
|
|
2623
|
+
await this.write({
|
|
2624
|
+
event: "reclamped",
|
|
2625
|
+
at: now,
|
|
2626
|
+
set: { next_at: latest, reason: `further off than ${rule.words} allows`, source: "policy" }
|
|
2627
|
+
});
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
async loop() {
|
|
2631
|
+
while (!this.stopping) {
|
|
2632
|
+
const before = this.writes;
|
|
2633
|
+
try {
|
|
2634
|
+
if (!await this.step()) await this.pause(WAIT_STEP_MS);
|
|
2635
|
+
} catch (error) {
|
|
2636
|
+
this.options.onProblem(`schedule of ${this.path}: ${errorSummary(error)}`);
|
|
2637
|
+
await this.pause(WAIT_STEP_MS);
|
|
2638
|
+
}
|
|
2639
|
+
if (this.writes === before) await new Promise((resolve2) => setImmediate(resolve2));
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
/** Wait up to one step, or less; returns early when stopping. */
|
|
2643
|
+
pause(ms) {
|
|
2644
|
+
return this.clock.sleep(Math.max(0, Math.min(ms, WAIT_STEP_MS)), this.halt.signal);
|
|
2645
|
+
}
|
|
2646
|
+
/**
|
|
2647
|
+
* One look at the schedule: wait while the next run is still to come (the
|
|
2648
|
+
* row is read again every step, so another process's changes are seen), and
|
|
2649
|
+
* run it once it is due. False when it did neither, so the loop waits a step.
|
|
2650
|
+
*/
|
|
2651
|
+
async step() {
|
|
2652
|
+
const row = await this.store.read(this.taskId);
|
|
2653
|
+
if (!row) {
|
|
2654
|
+
await this.reconcile();
|
|
2655
|
+
return this.row != null;
|
|
2656
|
+
}
|
|
2657
|
+
this.row = row;
|
|
2658
|
+
if (row.state === "paused") return false;
|
|
2659
|
+
if (row.state === "running") {
|
|
2660
|
+
const own = [...this.ticks][0];
|
|
2661
|
+
if (own) {
|
|
2662
|
+
await Promise.race([own.done, untilAborted(this.halt.signal)]);
|
|
2663
|
+
return true;
|
|
2664
|
+
}
|
|
2665
|
+
return this.recoverIfAbandoned();
|
|
2666
|
+
}
|
|
2667
|
+
const now = this.clock.now();
|
|
2668
|
+
if (row.next_at == null) return false;
|
|
2669
|
+
if (row.next_at > now) {
|
|
2670
|
+
await this.pause(row.next_at - now);
|
|
2671
|
+
return true;
|
|
2672
|
+
}
|
|
2673
|
+
return this.runDue();
|
|
2674
|
+
}
|
|
2675
|
+
takeLease() {
|
|
2676
|
+
return TaskLease.take(this.task.scheduling.locks, this.path, {
|
|
2677
|
+
ttlMs: this.ttlMs,
|
|
2678
|
+
onLost: (lease) => this.leaseLost(lease)
|
|
2679
|
+
});
|
|
2680
|
+
}
|
|
2681
|
+
/**
|
|
2682
|
+
* The lease for a due run, waiting while another run holds it. The first
|
|
2683
|
+
* time it is busy with the run still waiting, the wait is logged as
|
|
2684
|
+
* `blocked`. Null when stopping, or when the row stops asking for a run now:
|
|
2685
|
+
* another process is running the task, which the loop then follows, or a
|
|
2686
|
+
* person paused it or moved its next run.
|
|
2687
|
+
*/
|
|
2688
|
+
async acquire() {
|
|
2689
|
+
while (!this.stopping) {
|
|
2690
|
+
const taken = await this.takeLease();
|
|
2691
|
+
if (!("busy" in taken)) return taken;
|
|
2692
|
+
const row = await this.store.read(this.taskId);
|
|
2693
|
+
if (row?.state !== "waiting" && row?.state !== "blocked") return null;
|
|
2694
|
+
if (row.state === "waiting") {
|
|
2695
|
+
if (row.next_at == null || row.next_at > this.clock.now()) return null;
|
|
2696
|
+
await this.write({
|
|
2697
|
+
event: "blocked",
|
|
2698
|
+
at: this.clock.now(),
|
|
2699
|
+
set: { holder: taken.busy.holder },
|
|
2700
|
+
runId: taken.busy.runId
|
|
2701
|
+
});
|
|
2702
|
+
}
|
|
2703
|
+
await this.pause(WAIT_STEP_MS);
|
|
2704
|
+
}
|
|
2705
|
+
return null;
|
|
2706
|
+
}
|
|
2707
|
+
/**
|
|
2708
|
+
* Run the due run, once this process holds the task. The row is read again
|
|
2709
|
+
* under the lease: another process may have run it in the meantime, which is
|
|
2710
|
+
* what stops two of them starting the same run.
|
|
2711
|
+
*/
|
|
2712
|
+
async runDue() {
|
|
2713
|
+
const lease = await this.acquire();
|
|
2714
|
+
if (!lease) return false;
|
|
2715
|
+
let handedOver = false;
|
|
2716
|
+
try {
|
|
2717
|
+
const row = await this.store.read(this.taskId);
|
|
2718
|
+
const now = this.clock.now();
|
|
2719
|
+
if (!row || row.state !== "waiting" && row.state !== "blocked") return false;
|
|
2720
|
+
if (row.next_at == null || row.next_at > now) {
|
|
2721
|
+
if (row.state === "blocked") {
|
|
2722
|
+
await this.write({ event: "superseded", at: now, set: { holder: null } });
|
|
2723
|
+
}
|
|
2724
|
+
return true;
|
|
2725
|
+
}
|
|
2726
|
+
handedOver = true;
|
|
2727
|
+
return await this.runTick(lease, row);
|
|
2728
|
+
} finally {
|
|
2729
|
+
if (!handedOver) await lease.release();
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
track(tick) {
|
|
2733
|
+
this.ticks.add(tick);
|
|
2734
|
+
void tick.done.finally(() => this.ticks.delete(tick));
|
|
2735
|
+
}
|
|
2736
|
+
/**
|
|
2737
|
+
* A scheduled tick, from `started` to `decided`, holding `lease` throughout.
|
|
2738
|
+
* False when it could not start.
|
|
2739
|
+
*/
|
|
2740
|
+
async runTick(lease, row) {
|
|
2741
|
+
const now = this.clock.now();
|
|
2742
|
+
const rule = this.rule();
|
|
2743
|
+
const catchUp = this.resolved.kind === "interval" && row.reason === CATCH_UP_REASON;
|
|
2744
|
+
const tick = new TickHandle(catchUp ? "catch-up" : "scheduled", lease);
|
|
2745
|
+
let plannedAt = catchUp ? now : row.next_at;
|
|
2746
|
+
const late = rule.window(plannedAt, row.anchor).nextAt;
|
|
2747
|
+
if (late != null && late <= now) plannedAt = now;
|
|
2748
|
+
let settle;
|
|
2749
|
+
tick.done = new Promise((resolve2) => settle = resolve2);
|
|
2750
|
+
this.track(tick);
|
|
2751
|
+
try {
|
|
2752
|
+
const started = await this.write({
|
|
2753
|
+
event: "started",
|
|
2754
|
+
at: now,
|
|
2755
|
+
origin: tick.origin,
|
|
2756
|
+
set: { holder: lease.holder }
|
|
2757
|
+
});
|
|
2758
|
+
if (!started) return false;
|
|
2759
|
+
tick.logged = true;
|
|
2760
|
+
const window = rule.window(plannedAt, row.anchor);
|
|
2761
|
+
const previousDelayMs = row.next_at != null ? Math.max(0, row.next_at - row.updated_at) : null;
|
|
2762
|
+
const next = this.resolved.kind === "interval" ? { previousDelayMs, timeoutMs: this.options.nextTimeoutMs } : void 0;
|
|
2763
|
+
const outcome = await Promise.race([this.execute(tick, window, next), tick.whenClosed]);
|
|
2764
|
+
if (!outcome || tick.closed || lease.lost) return true;
|
|
2765
|
+
this.refreshTiming();
|
|
2766
|
+
const decision = this.rule().decide({
|
|
2767
|
+
plannedAt,
|
|
2768
|
+
endedAt: outcome.endedAt,
|
|
2769
|
+
anchor: row.anchor,
|
|
2770
|
+
status: outcome.status,
|
|
2771
|
+
failedInARow: await this.failedInARow(outcome),
|
|
2772
|
+
choice: outcome.next
|
|
2773
|
+
});
|
|
2774
|
+
await this.writeEnd({
|
|
2775
|
+
event: "decided",
|
|
2776
|
+
at: this.clock.now(),
|
|
2777
|
+
holder: lease.holder,
|
|
2778
|
+
set: {
|
|
2779
|
+
next_at: decision.nextAt,
|
|
2780
|
+
reason: decision.reason,
|
|
2781
|
+
source: decision.source,
|
|
2782
|
+
holder: null,
|
|
2783
|
+
anchor: decision.anchor
|
|
2784
|
+
},
|
|
2785
|
+
firstRunId: outcome.firstRunId,
|
|
2786
|
+
runId: outcome.runId,
|
|
2787
|
+
finalRunId: outcome.runId
|
|
2788
|
+
});
|
|
2789
|
+
return true;
|
|
2790
|
+
} finally {
|
|
2791
|
+
await lease.release();
|
|
2792
|
+
settle();
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
async execute(tick, window, next) {
|
|
2796
|
+
const outcome = await this.task.executeTick({
|
|
2797
|
+
next,
|
|
2798
|
+
scheduledAt: window.scheduledAt,
|
|
2799
|
+
nextAt: window.nextAt,
|
|
2800
|
+
periodMs: window.periodMs,
|
|
2801
|
+
clock: this.clock,
|
|
2802
|
+
signal: tick.signal,
|
|
2803
|
+
onRunStarted: tick.noteRun
|
|
2804
|
+
});
|
|
2805
|
+
if (outcome.status === "failure") this.options.onRunError(outcome.error);
|
|
2806
|
+
return outcome;
|
|
2807
|
+
}
|
|
2808
|
+
/**
|
|
2809
|
+
* Take up a change to the task's `timing` export, which its latest run read
|
|
2810
|
+
* afresh: it applies from this decision on. A switch between cron and an
|
|
2811
|
+
* interval needs a restart.
|
|
2812
|
+
*/
|
|
2813
|
+
refreshTiming() {
|
|
2814
|
+
if (this.options.override) return;
|
|
2815
|
+
const exported = this.task.timing;
|
|
2816
|
+
if (exported === this.exported) return;
|
|
2817
|
+
try {
|
|
2818
|
+
const resolved = resolveTiming(exported, this.path);
|
|
2819
|
+
if (resolved.kind !== this.resolved.kind) {
|
|
2820
|
+
this.options.onProblem(
|
|
2821
|
+
`${this.path} changed its timing between a cron pattern and an interval; restart ngn run to apply it`
|
|
2822
|
+
);
|
|
2823
|
+
return;
|
|
2824
|
+
}
|
|
2825
|
+
this.resolved = resolved;
|
|
2826
|
+
this.exported = exported;
|
|
2827
|
+
} catch (error) {
|
|
2828
|
+
this.options.onProblem(`${errorSummary(error)}; keeping ${this.rule().words}`);
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
/** Run now: one run outside the schedule, through the lease. Resolves once it is under way. */
|
|
2832
|
+
async runNow() {
|
|
2833
|
+
if (this.stopping) return { started: false, reason: "ngn is stopping" };
|
|
2834
|
+
const { periodMs } = this.rule().window(this.clock.now(), this.row?.anchor ?? null);
|
|
2835
|
+
const started = await startManual(
|
|
2836
|
+
{
|
|
2837
|
+
task: this.task,
|
|
2838
|
+
taskId: this.taskId,
|
|
2839
|
+
clock: this.clock,
|
|
2840
|
+
leaseTtlMs: this.ttlMs,
|
|
2841
|
+
window: { nextAt: periodMs == null ? this.nextAt() : null, periodMs },
|
|
2842
|
+
onProblem: this.options.onProblem
|
|
2843
|
+
},
|
|
2844
|
+
(lease) => this.leaseLost(lease)
|
|
2845
|
+
);
|
|
2846
|
+
if ("busy" in started) return { started: false, reason: started.busy };
|
|
2847
|
+
this.track(started.tick);
|
|
2848
|
+
void started.outcome.then((outcome) => {
|
|
2849
|
+
if (outcome.status === "failure") this.options.onRunError(outcome.error);
|
|
2850
|
+
});
|
|
2851
|
+
return { started: true };
|
|
2852
|
+
}
|
|
2853
|
+
/**
|
|
2854
|
+
* What `ngn pause`, `resume` and `next` do, asked of this process (ngnui's
|
|
2855
|
+
* buttons): the same transitions, which the loop then picks up as it would
|
|
2856
|
+
* from the CLI.
|
|
2857
|
+
*/
|
|
2858
|
+
control(command) {
|
|
2859
|
+
return controlSchedule(
|
|
2860
|
+
{
|
|
2861
|
+
store: this.store,
|
|
2862
|
+
taskId: this.taskId,
|
|
2863
|
+
name: this.path,
|
|
2864
|
+
pattern: this.resolved.kind === "cron" ? this.resolved.pattern : null,
|
|
2865
|
+
now: this.clock.now()
|
|
2866
|
+
},
|
|
2867
|
+
command
|
|
2868
|
+
);
|
|
2869
|
+
}
|
|
2870
|
+
/** The lease of a tick in progress went: say so in the log while the task is still ours. */
|
|
2871
|
+
leaseLost(lease) {
|
|
2872
|
+
const tick = [...this.ticks].find((t) => t.lease === lease);
|
|
2873
|
+
if (!tick?.logged || tick.closed) return;
|
|
2874
|
+
void this.write({
|
|
2875
|
+
event: "lease_lost",
|
|
2876
|
+
at: this.clock.now(),
|
|
2877
|
+
holder: lease.holder,
|
|
2878
|
+
firstRunId: tick.firstRunId,
|
|
2879
|
+
runId: tick.runId
|
|
2880
|
+
});
|
|
2881
|
+
}
|
|
2882
|
+
/**
|
|
2883
|
+
* A task left `running` by a process that is gone, which its expired lease
|
|
2884
|
+
* says: close the tick's open runs as interrupted and decide the next run as
|
|
2885
|
+
* for a failed tick. False when the task is still held by a live process,
|
|
2886
|
+
* or the recovery could not be written, so the loop waits before looking again.
|
|
2887
|
+
*/
|
|
2888
|
+
async recoverIfAbandoned() {
|
|
2889
|
+
const held = await this.task.scheduling.locks.holderOf(taskLeaseName(this.path));
|
|
2890
|
+
if (held && held.expiresAt > Date.now()) return false;
|
|
2891
|
+
const lease = await this.takeLease();
|
|
2892
|
+
if ("busy" in lease) return false;
|
|
2893
|
+
try {
|
|
2894
|
+
const row = await this.store.read(this.taskId);
|
|
2895
|
+
if (row?.state !== "running") return row != null;
|
|
2896
|
+
const at = this.clock.now();
|
|
2897
|
+
const closed = await this.store.closeOpenRuns(
|
|
2898
|
+
this.taskId,
|
|
2899
|
+
"the ngn process running it stopped",
|
|
2900
|
+
{ at: Date.now() }
|
|
2901
|
+
);
|
|
2902
|
+
const decision = this.rule().decide({
|
|
2903
|
+
plannedAt: at,
|
|
2904
|
+
endedAt: at,
|
|
2905
|
+
anchor: row.anchor,
|
|
2906
|
+
status: "failure",
|
|
2907
|
+
failedInARow: await this.failedInARow()
|
|
2908
|
+
});
|
|
2909
|
+
const recovered = await this.writeEnd({
|
|
2910
|
+
event: "recovered",
|
|
2911
|
+
at,
|
|
2912
|
+
holder: row.holder,
|
|
2913
|
+
set: {
|
|
2914
|
+
next_at: decision.nextAt,
|
|
2915
|
+
reason: `the process running it stopped; ${decision.reason}`,
|
|
2916
|
+
source: "policy",
|
|
2917
|
+
holder: null,
|
|
2918
|
+
anchor: decision.anchor
|
|
2919
|
+
},
|
|
2920
|
+
firstRunId: closed[0] ?? null,
|
|
2921
|
+
runId: closed.at(-1) ?? null
|
|
2922
|
+
});
|
|
2923
|
+
return recovered != null;
|
|
2924
|
+
} finally {
|
|
2925
|
+
await lease.release();
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
/**
|
|
2929
|
+
* Stop starting runs, ask the ones in progress to stop (their signal aborts),
|
|
2930
|
+
* and wait up to `graceMs` for them. A tick that ends in time is decided as
|
|
2931
|
+
* usual; the rest have their open runs closed as failed in `interrupted`,
|
|
2932
|
+
* and an `interrupted` event sets the next run to now.
|
|
2933
|
+
*/
|
|
2934
|
+
async stop({ graceMs = STOP_GRACE_MS } = {}) {
|
|
2935
|
+
if (this.stopping) return this.loopDone;
|
|
2936
|
+
this.stopping = true;
|
|
2937
|
+
this.halt.abort();
|
|
2938
|
+
const ticks = [...this.ticks];
|
|
2939
|
+
for (const tick of ticks) tick.controller.abort(new Error("ngn is stopping"));
|
|
2940
|
+
if (ticks.length) {
|
|
2941
|
+
const grace = new AbortController();
|
|
2942
|
+
const settled = await Promise.race([
|
|
2943
|
+
Promise.all(ticks.map((tick) => tick.done)).then(() => true),
|
|
2944
|
+
this.clock.sleep(graceMs, grace.signal).then(() => false)
|
|
2945
|
+
]);
|
|
2946
|
+
grace.abort();
|
|
2947
|
+
if (!settled) {
|
|
2948
|
+
for (const tick of ticks) if (this.ticks.has(tick)) await this.interrupt(tick);
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
await this.loopDone;
|
|
2952
|
+
}
|
|
2953
|
+
async interrupt(tick) {
|
|
2954
|
+
tick.close();
|
|
2955
|
+
const at = this.clock.now();
|
|
2956
|
+
if (tick.firstRunId != null) {
|
|
2957
|
+
await this.store.closeOpenRuns(this.taskId, "ngn stopped before the run ended", {
|
|
2958
|
+
fromRunId: tick.firstRunId,
|
|
2959
|
+
at: Date.now()
|
|
2960
|
+
});
|
|
2961
|
+
}
|
|
2962
|
+
if (tick.logged) {
|
|
2963
|
+
const set = tick.origin === "manual" ? { holder: null } : {
|
|
2964
|
+
holder: null,
|
|
2965
|
+
next_at: at,
|
|
2966
|
+
reason: "ngn stopped before the run ended",
|
|
2967
|
+
source: "policy"
|
|
2968
|
+
};
|
|
2969
|
+
await this.writeEnd({
|
|
2970
|
+
event: "interrupted",
|
|
2971
|
+
at,
|
|
2972
|
+
holder: tick.lease.holder,
|
|
2973
|
+
set,
|
|
2974
|
+
firstRunId: tick.firstRunId,
|
|
2975
|
+
runId: tick.runId
|
|
2976
|
+
});
|
|
2977
|
+
}
|
|
2978
|
+
await tick.lease.release();
|
|
2979
|
+
this.ticks.delete(tick);
|
|
2980
|
+
}
|
|
2981
|
+
/**
|
|
2982
|
+
* Stop without cleaning up, the way a process that died would: no more
|
|
2983
|
+
* steps, no lease renewals, nothing written. For tests standing in for a
|
|
2984
|
+
* crash.
|
|
2985
|
+
*/
|
|
2986
|
+
abandon() {
|
|
2987
|
+
this.stopping = true;
|
|
2988
|
+
this.halt.abort();
|
|
2989
|
+
for (const tick of this.ticks) {
|
|
2990
|
+
tick.lease.abandon();
|
|
2991
|
+
tick.close();
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
};
|
|
1613
2995
|
|
|
1614
2996
|
// src/runtime/taskLibraryScheduler.ts
|
|
1615
2997
|
var TaskLibraryScheduler = class {
|
|
1616
2998
|
taskLibrary;
|
|
1617
|
-
|
|
2999
|
+
options;
|
|
1618
3000
|
scheduledTasks = /* @__PURE__ */ new Map();
|
|
1619
3001
|
constructor(options) {
|
|
1620
3002
|
this.taskLibrary = options.taskLibrary;
|
|
1621
|
-
this.
|
|
3003
|
+
this.options = options;
|
|
1622
3004
|
}
|
|
1623
3005
|
async start() {
|
|
1624
3006
|
logTaskLibraryScheduler("Starting scheduler");
|
|
1625
3007
|
const report = { scheduled: [], failed: [] };
|
|
1626
3008
|
const scheduledTasks = this.taskLibrary.getTasks().map(async (task) => {
|
|
1627
3009
|
try {
|
|
1628
|
-
await this.scheduleTask(task);
|
|
1629
|
-
report.scheduled.push({ path: task.taskPath,
|
|
3010
|
+
const timing = await this.scheduleTask(task);
|
|
3011
|
+
report.scheduled.push({ path: task.taskPath, timing });
|
|
1630
3012
|
} catch (error) {
|
|
1631
3013
|
logTaskLibrarySchedulerError(`Error scheduling task: ${task.taskPath}`);
|
|
1632
3014
|
logTaskLibrarySchedulerError(error);
|
|
@@ -1639,58 +3021,106 @@ var TaskLibraryScheduler = class {
|
|
|
1639
3021
|
report.failed.sort(byPath);
|
|
1640
3022
|
return report;
|
|
1641
3023
|
}
|
|
1642
|
-
|
|
3024
|
+
/**
|
|
3025
|
+
* Stop starting runs, and give the runs in progress up to `graceMs` to end
|
|
3026
|
+
* before closing them as interrupted.
|
|
3027
|
+
*/
|
|
3028
|
+
async stop(options = {}) {
|
|
1643
3029
|
logTaskLibraryScheduler("Stopping scheduler");
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
3030
|
+
await Promise.all(
|
|
3031
|
+
[...this.scheduledTasks].map(([path, schedule]) => {
|
|
3032
|
+
logTaskLibraryScheduler(`Stopping scheduled task: ${path}`);
|
|
3033
|
+
return schedule.stop(options);
|
|
3034
|
+
})
|
|
3035
|
+
);
|
|
1648
3036
|
}
|
|
3037
|
+
/** Stop as a process that died would, writing nothing. For tests standing in for a crash. */
|
|
3038
|
+
abandon() {
|
|
3039
|
+
for (const schedule of this.scheduledTasks.values()) schedule.abandon();
|
|
3040
|
+
}
|
|
3041
|
+
/**
|
|
3042
|
+
* Check a loaded task's exports and start its schedule. The timing it is
|
|
3043
|
+
* scheduled on is returned; anything wrong with the exports is thrown,
|
|
3044
|
+
* naming the task, so it fails now rather than on the first tick.
|
|
3045
|
+
*/
|
|
1649
3046
|
async scheduleTask(task) {
|
|
1650
3047
|
logTaskLibraryScheduler(`Scheduling task: ${task.taskPath}`);
|
|
1651
3048
|
await task.loadEntry();
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
3049
|
+
const timing = this.options.timing ?? task.timing;
|
|
3050
|
+
const resolved = resolveTiming(timing, task.taskPath);
|
|
3051
|
+
invariant7(task.hasTaskExport(), `task function is missing for ${task.taskPath}`);
|
|
3052
|
+
task.validateExports();
|
|
3053
|
+
invariant7(
|
|
3054
|
+
resolved.kind === "cron" || !task.retryPolicy.breaker,
|
|
3055
|
+
`retry.breaker for ${task.taskPath}: an interval task does not skip ticks; remove the breaker and use timing.backoff, which slows a failing interval task down`
|
|
1658
3056
|
);
|
|
1659
|
-
const
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
3057
|
+
const next = task.nextHook;
|
|
3058
|
+
invariant7(
|
|
3059
|
+
next == null || typeof next === "function",
|
|
3060
|
+
`next for ${task.taskPath}: expected a function`
|
|
3061
|
+
);
|
|
3062
|
+
invariant7(
|
|
3063
|
+
next == null || resolved.kind === "interval",
|
|
3064
|
+
`next for ${task.taskPath}: only an interval task can choose its next run; a cron task runs on its pattern`
|
|
3065
|
+
);
|
|
3066
|
+
const schedule = new TaskSchedule({
|
|
3067
|
+
task,
|
|
3068
|
+
timing,
|
|
3069
|
+
override: this.options.timing != null,
|
|
3070
|
+
clock: this.options.clock ?? systemClock,
|
|
3071
|
+
leaseTtlMs: this.options.leaseTtlMs,
|
|
3072
|
+
random: this.options.random,
|
|
3073
|
+
nextTimeoutMs: this.options.nextTimeoutMs,
|
|
3074
|
+
onRunError: (error) => {
|
|
3075
|
+
logTaskLibrarySchedulerError(`Run failed: ${task.taskPath}`);
|
|
3076
|
+
logTaskLibrarySchedulerError(error);
|
|
3077
|
+
this.options.onRunError?.(task.taskPath, error, failedRunId(error));
|
|
3078
|
+
},
|
|
3079
|
+
onProblem: (message) => {
|
|
3080
|
+
logTaskLibrarySchedulerError(message);
|
|
3081
|
+
this.options.onScheduleProblem?.(task.taskPath, message);
|
|
3082
|
+
}
|
|
1663
3083
|
});
|
|
1664
|
-
|
|
3084
|
+
await schedule.start();
|
|
3085
|
+
this.scheduledTasks.set(task.taskPath, schedule);
|
|
1665
3086
|
logTaskLibraryScheduler(`Scheduled: ${task.taskPath}`);
|
|
3087
|
+
return timing;
|
|
1666
3088
|
}
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
3089
|
+
/**
|
|
3090
|
+
* Start one run of a scheduled task now, outside its schedule: retries,
|
|
3091
|
+
* budget, locks and hooks all apply, and errors go where a tick's go. It is
|
|
3092
|
+
* not a tick, so the breaker does not hold it back, and it leaves the next
|
|
3093
|
+
* run where it was. Resolves once the run is under way, not when it ends.
|
|
3094
|
+
*
|
|
3095
|
+
* A run of the task still going, in this process or another, is reported
|
|
3096
|
+
* rather than recorded as a skip: the person asking can simply wait.
|
|
3097
|
+
*/
|
|
3098
|
+
async runNow(taskPath) {
|
|
3099
|
+
const schedule = this.scheduledTasks.get(taskPath);
|
|
3100
|
+
if (!schedule)
|
|
3101
|
+
return { started: false, reason: `${taskPath} is not scheduled by this ngn run` };
|
|
3102
|
+
return schedule.runNow();
|
|
3103
|
+
}
|
|
3104
|
+
/** Pause, resume or move a scheduled task's next run, as `ngn pause`, `resume` and `next` do. */
|
|
3105
|
+
async control(taskPath, command) {
|
|
3106
|
+
const schedule = this.scheduledTasks.get(taskPath);
|
|
3107
|
+
if (!schedule) {
|
|
3108
|
+
return {
|
|
3109
|
+
applied: false,
|
|
3110
|
+
error: true,
|
|
3111
|
+
message: `${taskPath} is not scheduled by this ngn run`
|
|
3112
|
+
};
|
|
1671
3113
|
}
|
|
3114
|
+
return schedule.control(command);
|
|
3115
|
+
}
|
|
3116
|
+
async unscheduleTask(task) {
|
|
3117
|
+
const existing = this.scheduledTasks.get(task.taskPath);
|
|
3118
|
+
if (!existing) return;
|
|
1672
3119
|
logTaskLibraryScheduler(`Unscheduling: ${task.taskPath}`);
|
|
1673
|
-
existingTask.stop();
|
|
1674
3120
|
this.scheduledTasks.delete(task.taskPath);
|
|
3121
|
+
await existing.stop();
|
|
1675
3122
|
logTaskLibraryScheduler(`Unscheduled: ${task.taskPath}`);
|
|
1676
3123
|
}
|
|
1677
|
-
isCronPatternValid(cronPattern) {
|
|
1678
|
-
return isCronPatternValid(cronPattern);
|
|
1679
|
-
}
|
|
1680
|
-
// startScheduledTask(path: string) {
|
|
1681
|
-
// const task = this.scheduledTasks.get(path);
|
|
1682
|
-
// if (!task) {
|
|
1683
|
-
// return;
|
|
1684
|
-
// }
|
|
1685
|
-
// task.start();
|
|
1686
|
-
// }
|
|
1687
|
-
// stopScheduledTask(path: string) {
|
|
1688
|
-
// const task = this.scheduledTasks.get(path);
|
|
1689
|
-
// if (!task) {
|
|
1690
|
-
// return;
|
|
1691
|
-
// }
|
|
1692
|
-
// task.stop();
|
|
1693
|
-
// }
|
|
1694
3124
|
};
|
|
1695
3125
|
function byPath(a, b) {
|
|
1696
3126
|
return a.path.localeCompare(b.path);
|
|
@@ -1701,7 +3131,7 @@ function reasonOf(error, path) {
|
|
|
1701
3131
|
}
|
|
1702
3132
|
|
|
1703
3133
|
// src/runtime/taskOnceRunner.ts
|
|
1704
|
-
import
|
|
3134
|
+
import invariant8 from "tiny-invariant";
|
|
1705
3135
|
import { logTaskOnceRunner, logTaskOnceRunnerError } from "@apisurf/ngn-os";
|
|
1706
3136
|
var TaskOnceRunner = class {
|
|
1707
3137
|
task;
|
|
@@ -1712,7 +3142,7 @@ var TaskOnceRunner = class {
|
|
|
1712
3142
|
logTaskOnceRunner(`Preparing to run task: ${this.task.taskPath}`);
|
|
1713
3143
|
await this.task.loadEntry();
|
|
1714
3144
|
logTaskOnceRunner(`Loaded task code: ${this.task.taskPath}`);
|
|
1715
|
-
|
|
3145
|
+
invariant8(this.task.hasTaskExport(), `task function is missing for ${this.task.taskPath}`);
|
|
1716
3146
|
logTaskOnceRunner(`Running task: ${this.task.taskPath}`);
|
|
1717
3147
|
try {
|
|
1718
3148
|
await this.task.execute();
|
|
@@ -1731,10 +3161,10 @@ import {
|
|
|
1731
3161
|
initDbFileIfNotExists,
|
|
1732
3162
|
isDbPath
|
|
1733
3163
|
} from "@apisurf/ngn-persistence";
|
|
1734
|
-
import
|
|
3164
|
+
import invariant9 from "tiny-invariant";
|
|
1735
3165
|
var db;
|
|
1736
3166
|
async function setupDbClient(dbPath) {
|
|
1737
|
-
|
|
3167
|
+
invariant9(isDbPath(dbPath), "Invalid db path");
|
|
1738
3168
|
if (db) {
|
|
1739
3169
|
return db;
|
|
1740
3170
|
}
|
|
@@ -1742,7 +3172,7 @@ async function setupDbClient(dbPath) {
|
|
|
1742
3172
|
return db;
|
|
1743
3173
|
}
|
|
1744
3174
|
function getDbClient() {
|
|
1745
|
-
|
|
3175
|
+
invariant9(db, "DB client not initialized");
|
|
1746
3176
|
return db;
|
|
1747
3177
|
}
|
|
1748
3178
|
|
|
@@ -1753,8 +3183,8 @@ import {
|
|
|
1753
3183
|
logTaskConsoleInfo,
|
|
1754
3184
|
logTaskConsoleWarn
|
|
1755
3185
|
} from "@apisurf/ngn-os";
|
|
1756
|
-
import
|
|
1757
|
-
import { errorSummary, failureLine } from "@apisurf/ngn-schema/describe";
|
|
3186
|
+
import invariant10 from "tiny-invariant";
|
|
3187
|
+
import { errorSummary as errorSummary2, failureLine as failureLine2 } from "@apisurf/ngn-schema/describe";
|
|
1758
3188
|
|
|
1759
3189
|
// src/db/services/fileTask.ts
|
|
1760
3190
|
import { $fileTaskInsert } from "@apisurf/ngn-schema";
|
|
@@ -1943,7 +3373,7 @@ var TaskRunService = class {
|
|
|
1943
3373
|
}
|
|
1944
3374
|
async skipTaskRun(taskRunId, reason = null) {
|
|
1945
3375
|
await this.db.execute({
|
|
1946
|
-
sql: "UPDATE task_runs SET status = 'skipped', ended_at = :ended_at, skip_reason = :reason WHERE id = :id",
|
|
3376
|
+
sql: "UPDATE task_runs SET status = 'skipped', ended_at = :ended_at, skip_reason = :reason WHERE id = :id AND ended_at IS NULL",
|
|
1947
3377
|
args: { id: taskRunId, ended_at: Date.now(), reason }
|
|
1948
3378
|
});
|
|
1949
3379
|
}
|
|
@@ -1978,16 +3408,20 @@ var TaskRunService = class {
|
|
|
1978
3408
|
}
|
|
1979
3409
|
async startTaskRun(taskRunId) {
|
|
1980
3410
|
await this.db.execute({
|
|
1981
|
-
sql: "UPDATE task_runs SET started_at = :started_at, status = 'running' WHERE id = :id",
|
|
3411
|
+
sql: "UPDATE task_runs SET started_at = :started_at, status = 'running' WHERE id = :id AND ended_at IS NULL",
|
|
1982
3412
|
args: {
|
|
1983
3413
|
started_at: Date.now(),
|
|
1984
3414
|
id: taskRunId
|
|
1985
3415
|
}
|
|
1986
3416
|
});
|
|
1987
3417
|
}
|
|
3418
|
+
/**
|
|
3419
|
+
* Record how a run ended. False when it already had an end: ngn closed it as
|
|
3420
|
+
* interrupted while it was still going, and that record stands.
|
|
3421
|
+
*/
|
|
1988
3422
|
async endTaskRun(taskRunId, status, result = null) {
|
|
1989
|
-
await this.db.execute({
|
|
1990
|
-
sql: "UPDATE task_runs SET ended_at = :ended_at, status = :status, result = :result WHERE id = :id",
|
|
3423
|
+
const { rowsAffected } = await this.db.execute({
|
|
3424
|
+
sql: "UPDATE task_runs SET ended_at = :ended_at, status = :status, result = :result WHERE id = :id AND ended_at IS NULL",
|
|
1991
3425
|
args: {
|
|
1992
3426
|
ended_at: Date.now(),
|
|
1993
3427
|
status,
|
|
@@ -1995,6 +3429,7 @@ var TaskRunService = class {
|
|
|
1995
3429
|
id: taskRunId
|
|
1996
3430
|
}
|
|
1997
3431
|
});
|
|
3432
|
+
return rowsAffected > 0;
|
|
1998
3433
|
}
|
|
1999
3434
|
async getById(taskId) {
|
|
2000
3435
|
const { rows } = await this.db.execute({
|
|
@@ -2137,6 +3572,27 @@ var LockService = class {
|
|
|
2137
3572
|
});
|
|
2138
3573
|
return rowsAffected > 0;
|
|
2139
3574
|
}
|
|
3575
|
+
/** Who holds `name`, and until when; null when nobody has it. Expired leases included. */
|
|
3576
|
+
async holderOf(name) {
|
|
3577
|
+
const { rows } = await this.db.execute({
|
|
3578
|
+
sql: "SELECT holder, task_run_id, expires_at FROM locks WHERE name = ?",
|
|
3579
|
+
args: [name]
|
|
3580
|
+
});
|
|
3581
|
+
const row = rows[0];
|
|
3582
|
+
if (!row) return null;
|
|
3583
|
+
return {
|
|
3584
|
+
holder: row.holder,
|
|
3585
|
+
taskRunId: row.task_run_id ?? null,
|
|
3586
|
+
expiresAt: row.expires_at
|
|
3587
|
+
};
|
|
3588
|
+
}
|
|
3589
|
+
/** Record which run a held lease is working for. */
|
|
3590
|
+
async setRun(name, holder, taskRunId) {
|
|
3591
|
+
await this.db.execute({
|
|
3592
|
+
sql: "UPDATE locks SET task_run_id = :taskRunId WHERE name = :name AND holder = :holder",
|
|
3593
|
+
args: { name, holder, taskRunId }
|
|
3594
|
+
});
|
|
3595
|
+
}
|
|
2140
3596
|
async release(name, holder) {
|
|
2141
3597
|
await this.db.execute({
|
|
2142
3598
|
sql: "DELETE FROM locks WHERE name = :name AND holder = :holder",
|
|
@@ -2150,9 +3606,10 @@ async function recordSafely(runId, write) {
|
|
|
2150
3606
|
try {
|
|
2151
3607
|
await write();
|
|
2152
3608
|
} catch (error) {
|
|
2153
|
-
logTaskCallback(`Could not record what run ${runId} threw: ${
|
|
3609
|
+
logTaskCallback(`Could not record what run ${runId} threw: ${errorSummary2(error)}`);
|
|
2154
3610
|
}
|
|
2155
3611
|
}
|
|
3612
|
+
var lateEnd = (outcome) => `finished after ngn closed it as interrupted: ${outcome}`;
|
|
2156
3613
|
function createControlsGenerator({
|
|
2157
3614
|
dbClient,
|
|
2158
3615
|
env,
|
|
@@ -2163,12 +3620,13 @@ function createControlsGenerator({
|
|
|
2163
3620
|
const kvService = new KVService(dbClient);
|
|
2164
3621
|
const timingService = new TimingService(dbClient);
|
|
2165
3622
|
const lockService = new LockService(dbClient);
|
|
3623
|
+
const scheduling = { store: new ScheduleStore(dbClient), locks: lockService };
|
|
2166
3624
|
const generateControls = ({ sourcePath, compiledPath }) => {
|
|
2167
3625
|
let fileTaskId;
|
|
2168
3626
|
let fileTaskVersionId;
|
|
2169
3627
|
const assertMandatoryIds = () => {
|
|
2170
|
-
|
|
2171
|
-
|
|
3628
|
+
invariant10(!!fileTaskId, "Task ID not found");
|
|
3629
|
+
invariant10(!!fileTaskVersionId, "Task version ID not found");
|
|
2172
3630
|
};
|
|
2173
3631
|
const taskCallbacks = {
|
|
2174
3632
|
async onTaskLoadComplete({ paths, md5Hash, compiledCode }) {
|
|
@@ -2212,8 +3670,12 @@ function createControlsGenerator({
|
|
|
2212
3670
|
},
|
|
2213
3671
|
async onFailure({ path, stage, error }) {
|
|
2214
3672
|
await recordSafely(runId, async () => {
|
|
2215
|
-
await taskRunService.endTaskRun(runId, "failure")
|
|
2216
|
-
|
|
3673
|
+
if (!await taskRunService.endTaskRun(runId, "failure")) {
|
|
3674
|
+
await logService.logError(taskId, runId, failureLine2(stage, error));
|
|
3675
|
+
await logService.logWarning(taskId, runId, lateEnd("failed"));
|
|
3676
|
+
return;
|
|
3677
|
+
}
|
|
3678
|
+
await logService.logError(taskId, runId, failureLine2(stage, error));
|
|
2217
3679
|
await logService.logError(taskId, runId, "Task failed");
|
|
2218
3680
|
});
|
|
2219
3681
|
logTaskCallback(`Task failed in ${stage}: ${taskId}, run ID: ${runId}`);
|
|
@@ -2222,13 +3684,16 @@ function createControlsGenerator({
|
|
|
2222
3684
|
async onHookError({ path, stage, error }) {
|
|
2223
3685
|
await recordSafely(
|
|
2224
3686
|
runId,
|
|
2225
|
-
() => logService.logError(taskId, runId,
|
|
3687
|
+
() => logService.logError(taskId, runId, failureLine2(stage, error))
|
|
2226
3688
|
);
|
|
2227
3689
|
logTaskCallback(`Hook ${stage} threw: ${taskId}, run ID: ${runId}`);
|
|
2228
3690
|
report?.({ path, runId, stage, error, fatal: false });
|
|
2229
3691
|
},
|
|
2230
3692
|
async onSuccess({ result }) {
|
|
2231
|
-
await taskRunService.endTaskRun(runId, "success", serializeResult(result))
|
|
3693
|
+
if (!await taskRunService.endTaskRun(runId, "success", serializeResult(result))) {
|
|
3694
|
+
await logService.logWarning(taskId, runId, lateEnd("succeeded"));
|
|
3695
|
+
return;
|
|
3696
|
+
}
|
|
2232
3697
|
await logService.logInfo(taskId, runId, "Task succeeded");
|
|
2233
3698
|
logTaskCallback(`Task succeeded: ${taskId}, run ID: ${runId}`);
|
|
2234
3699
|
}
|
|
@@ -2273,6 +3738,7 @@ function createControlsGenerator({
|
|
|
2273
3738
|
return taskRunService.countSkipsSince(fileTaskId, reason, afterId);
|
|
2274
3739
|
}
|
|
2275
3740
|
},
|
|
3741
|
+
scheduling,
|
|
2276
3742
|
env: env ?? {},
|
|
2277
3743
|
// Scoped to this task's own folder — see createTaskSqlite
|
|
2278
3744
|
sqlite: createTaskSqlite(sourcePath),
|
|
@@ -2295,11 +3761,14 @@ export {
|
|
|
2295
3761
|
Compiler,
|
|
2296
3762
|
LockBusyError,
|
|
2297
3763
|
Queue,
|
|
3764
|
+
STOP_GRACE_MS,
|
|
3765
|
+
ScheduleStore,
|
|
2298
3766
|
Task,
|
|
2299
3767
|
TaskLibrary,
|
|
2300
3768
|
TaskLibraryScheduler,
|
|
2301
3769
|
TaskOnceRunner,
|
|
2302
3770
|
closeTaskDatabases,
|
|
3771
|
+
controlSchedule,
|
|
2303
3772
|
createControlsGenerator,
|
|
2304
3773
|
executeEsm,
|
|
2305
3774
|
failedRunId,
|
|
@@ -2307,7 +3776,10 @@ export {
|
|
|
2307
3776
|
initDbFileIfNotExists,
|
|
2308
3777
|
isCronPatternValid,
|
|
2309
3778
|
releaseTaskDatabases,
|
|
3779
|
+
resolveTiming,
|
|
2310
3780
|
runFailure,
|
|
2311
|
-
|
|
2312
|
-
setupDbClient
|
|
3781
|
+
runOnce,
|
|
3782
|
+
setupDbClient,
|
|
3783
|
+
systemClock,
|
|
3784
|
+
timingFromFlag
|
|
2313
3785
|
};
|