@apisurf/ngn-core 0.4.0 → 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 +461 -80
- package/dist/core.js +1627 -192
- 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,18 +374,21 @@ 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
|
-
} };
|
|
391
|
+
return { signal: controller.signal, dispose: detach };
|
|
387
392
|
}
|
|
388
393
|
let timer;
|
|
389
394
|
const arm = () => {
|
|
@@ -393,7 +398,13 @@ function deadlineSignal(deadline) {
|
|
|
393
398
|
timer.unref();
|
|
394
399
|
};
|
|
395
400
|
arm();
|
|
396
|
-
return {
|
|
401
|
+
return {
|
|
402
|
+
signal: controller.signal,
|
|
403
|
+
dispose: () => {
|
|
404
|
+
clearTimeout(timer);
|
|
405
|
+
detach();
|
|
406
|
+
}
|
|
407
|
+
};
|
|
397
408
|
}
|
|
398
409
|
var MAX_TIMER_MS = 2 ** 31 - 1;
|
|
399
410
|
|
|
@@ -408,10 +419,14 @@ var LockBusyError = class extends Error {
|
|
|
408
419
|
var DEFAULT_TTL_MS = 6e4;
|
|
409
420
|
var MAX_POLL_MS = 1e3;
|
|
410
421
|
var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
422
|
+
var RESERVED_LOCK_PREFIX = "ngn:";
|
|
411
423
|
function assertLockName(name) {
|
|
412
424
|
if (typeof name !== "string" || !name.trim()) {
|
|
413
425
|
throw new Error(`A lock name must be a non-empty string, got ${JSON.stringify(name)}`);
|
|
414
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
|
+
}
|
|
415
430
|
}
|
|
416
431
|
function createRunLocks(service, taskRunId) {
|
|
417
432
|
async function tryHold(name, ttlMs) {
|
|
@@ -484,6 +499,11 @@ function exclusiveNames(value, taskPath) {
|
|
|
484
499
|
`Invalid exclusive for ${taskPath}: expected a lock name or a list of them, got ${JSON.stringify(value)}`
|
|
485
500
|
);
|
|
486
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
|
+
}
|
|
487
507
|
}
|
|
488
508
|
return names;
|
|
489
509
|
}
|
|
@@ -1183,8 +1203,157 @@ function breakerSkips(failedInARow, breaker) {
|
|
|
1183
1203
|
return Math.min(breaker.maxSkip, 2 ** (failedInARow - breaker.after));
|
|
1184
1204
|
}
|
|
1185
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
|
+
|
|
1186
1350
|
// src/runtime/task.ts
|
|
1187
|
-
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
|
+
}
|
|
1188
1357
|
var failedRuns = /* @__PURE__ */ new WeakMap();
|
|
1189
1358
|
function failedRunId(error) {
|
|
1190
1359
|
return runFailure(error)?.runId ?? null;
|
|
@@ -1200,10 +1369,9 @@ var Task = class {
|
|
|
1200
1369
|
kv;
|
|
1201
1370
|
sqlite;
|
|
1202
1371
|
meta;
|
|
1372
|
+
schedulingServices;
|
|
1203
1373
|
entry = null;
|
|
1204
1374
|
entryExports = null;
|
|
1205
|
-
/** Executions started and not yet settled. */
|
|
1206
|
-
inFlight = 0;
|
|
1207
1375
|
constructor({
|
|
1208
1376
|
entryParams,
|
|
1209
1377
|
buildConfigFn
|
|
@@ -1212,13 +1380,14 @@ var Task = class {
|
|
|
1212
1380
|
this.entry = new Entry(entryParams, {
|
|
1213
1381
|
onTaskCodeLoaded: this.taskCallbacks?.onTaskCodeLoaded
|
|
1214
1382
|
});
|
|
1215
|
-
const { taskCallbacks, startRun, history, env, kv, sqlite } = buildConfigFn({
|
|
1383
|
+
const { taskCallbacks, startRun, history, env, kv, sqlite, scheduling } = buildConfigFn({
|
|
1216
1384
|
sourcePath: this.entry.paths.source,
|
|
1217
1385
|
compiledPath: this.entry.paths.compiled
|
|
1218
1386
|
});
|
|
1219
1387
|
this.taskCallbacks = taskCallbacks;
|
|
1220
1388
|
this.startRun = startRun;
|
|
1221
1389
|
this.history = history;
|
|
1390
|
+
this.schedulingServices = scheduling;
|
|
1222
1391
|
this.sqlite = sqlite;
|
|
1223
1392
|
this.meta = {
|
|
1224
1393
|
fileTaskId: -1,
|
|
@@ -1248,48 +1417,52 @@ var Task = class {
|
|
|
1248
1417
|
invariant5(this.entry, "Entry not initialized.");
|
|
1249
1418
|
return this.entry.codeHash;
|
|
1250
1419
|
}
|
|
1251
|
-
|
|
1420
|
+
/** The `timing` export, as the latest run (or `loadEntry`) read it. */
|
|
1421
|
+
get timing() {
|
|
1252
1422
|
invariant5(this.entryExports, "Entry exports not initialized.");
|
|
1253
1423
|
return this.entryExports.timing;
|
|
1254
1424
|
}
|
|
1255
|
-
/**
|
|
1256
|
-
|
|
1257
|
-
* else, naming the task, so a typo fails at schedule time.
|
|
1258
|
-
*/
|
|
1259
|
-
get overlapPolicy() {
|
|
1425
|
+
/** The `next` export, as loaded: unchecked, for whoever schedules the task to check. */
|
|
1426
|
+
get nextHook() {
|
|
1260
1427
|
invariant5(this.entryExports, "Entry exports not initialized.");
|
|
1261
|
-
|
|
1262
|
-
invariant5(
|
|
1263
|
-
overlap === "skip" || overlap === "allow",
|
|
1264
|
-
`Invalid overlap "${String(overlap)}" for ${this.taskPath}: expected "skip" or "allow"`
|
|
1265
|
-
);
|
|
1266
|
-
return overlap;
|
|
1428
|
+
return this.entryExports.next;
|
|
1267
1429
|
}
|
|
1268
1430
|
/**
|
|
1269
1431
|
* Check the exports that shape how the task is scheduled, so a bad value
|
|
1270
|
-
* 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.
|
|
1271
1434
|
*/
|
|
1272
1435
|
validateExports() {
|
|
1273
1436
|
invariant5(this.entryExports, "Entry exports not initialized.");
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
+
);
|
|
1276
1446
|
if (budget != null) toMs(budget, `budget for ${this.taskPath}`);
|
|
1277
1447
|
exclusiveNames(exclusive, this.taskPath);
|
|
1278
1448
|
resolveRetry(retry, this.taskPath);
|
|
1279
1449
|
}
|
|
1280
|
-
/**
|
|
1281
|
-
get
|
|
1282
|
-
|
|
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;
|
|
1283
1458
|
}
|
|
1284
1459
|
/**
|
|
1285
|
-
*
|
|
1286
|
-
*
|
|
1287
|
-
* 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.
|
|
1288
1462
|
*/
|
|
1289
|
-
async
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
await run.callbacks.onSkip?.({ path: this.entry.paths.relativeEntry, reason });
|
|
1463
|
+
async register() {
|
|
1464
|
+
await this.loadAndRegister();
|
|
1465
|
+
return this.meta.fileTaskId;
|
|
1293
1466
|
}
|
|
1294
1467
|
hasTaskExport() {
|
|
1295
1468
|
return this.entryExports?.task !== void 0;
|
|
@@ -1360,31 +1533,106 @@ var Task = class {
|
|
|
1360
1533
|
}
|
|
1361
1534
|
}
|
|
1362
1535
|
/**
|
|
1363
|
-
* Run the task for one tick, retrying as its `retry` export allows
|
|
1364
|
-
* scheduled tick passes when it fired and when the next one
|
|
1365
|
-
* `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.
|
|
1366
1547
|
*/
|
|
1367
1548
|
async execute(schedule = {}) {
|
|
1368
|
-
this.
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
} finally {
|
|
1372
|
-
this.inFlight--;
|
|
1373
|
-
}
|
|
1549
|
+
const outcome = await this.executeTick(schedule);
|
|
1550
|
+
if (outcome.status === "failure") throw outcome.error;
|
|
1551
|
+
return outcome.result;
|
|
1374
1552
|
}
|
|
1375
1553
|
async executeWithRetries(schedule) {
|
|
1376
|
-
const
|
|
1554
|
+
const tick = {
|
|
1377
1555
|
firstRunId: null,
|
|
1556
|
+
runId: null,
|
|
1378
1557
|
retry: resolveRetry(void 0, this.taskPath),
|
|
1379
|
-
failedContext: null
|
|
1558
|
+
failedContext: null,
|
|
1559
|
+
final: null
|
|
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
|
|
1380
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
|
+
});
|
|
1381
1617
|
for (let attempt = 1; ; attempt++) {
|
|
1382
|
-
|
|
1618
|
+
tick.failedContext = null;
|
|
1619
|
+
if (schedule.signal?.aborted) {
|
|
1620
|
+
return outcome(attempt - 1, { status: "failure", error: schedule.signal.reason });
|
|
1621
|
+
}
|
|
1383
1622
|
try {
|
|
1384
|
-
|
|
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 });
|
|
1385
1625
|
} catch (error) {
|
|
1386
|
-
|
|
1387
|
-
|
|
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 });
|
|
1388
1636
|
}
|
|
1389
1637
|
}
|
|
1390
1638
|
}
|
|
@@ -1393,10 +1641,19 @@ var Task = class {
|
|
|
1393
1641
|
* fails the same way every time. Then it has to fit before the next tick,
|
|
1394
1642
|
* and the task's `shouldRetry` gets the last word.
|
|
1395
1643
|
*/
|
|
1396
|
-
async shouldRetry(error, attempt, { nextAt = null },
|
|
1397
|
-
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;
|
|
1398
1647
|
const { shouldRetry } = this.entryExports ?? {};
|
|
1399
|
-
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));
|
|
1400
1657
|
}
|
|
1401
1658
|
/**
|
|
1402
1659
|
* Whether the breaker holds this tick back: after `after` failed ticks in a
|
|
@@ -1406,11 +1663,7 @@ var Task = class {
|
|
|
1406
1663
|
async breakerOpen(retry) {
|
|
1407
1664
|
if (!retry.breaker) return false;
|
|
1408
1665
|
const outcomes = await this.history.recentTickOutcomes(retry.breaker.after + 64);
|
|
1409
|
-
const
|
|
1410
|
-
const wanted = breakerSkips(
|
|
1411
|
-
failedInARow === -1 ? outcomes.length : failedInARow,
|
|
1412
|
-
retry.breaker
|
|
1413
|
-
);
|
|
1666
|
+
const wanted = breakerSkips(streakOf(outcomes), retry.breaker);
|
|
1414
1667
|
if (wanted === 0) return false;
|
|
1415
1668
|
return await this.history.countSkipsSince(BREAKER_SKIP_REASON, outcomes[0].id) < wanted;
|
|
1416
1669
|
}
|
|
@@ -1450,14 +1703,17 @@ var Task = class {
|
|
|
1450
1703
|
* Load the task file, open the attempt's `task_runs` row, then evaluate the
|
|
1451
1704
|
* file's code; what that throws is recorded on the row as failed in `load`.
|
|
1452
1705
|
*/
|
|
1453
|
-
async openRun(
|
|
1706
|
+
async openRun(tick, onRunStarted) {
|
|
1454
1707
|
invariant5(this.entry, "Entry not initialized.");
|
|
1708
|
+
tick.final = null;
|
|
1455
1709
|
await this.loadAndRegister();
|
|
1456
1710
|
const meta = { ...this.meta };
|
|
1457
1711
|
const path = this.entry.paths.relativeEntry;
|
|
1458
|
-
const run = await this.startRun({ retryOf:
|
|
1459
|
-
const firstAttempt =
|
|
1460
|
-
|
|
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);
|
|
1461
1717
|
try {
|
|
1462
1718
|
return { run, meta, path, firstAttempt, ...await this.readExports(path) };
|
|
1463
1719
|
} catch (error) {
|
|
@@ -1471,12 +1727,20 @@ var Task = class {
|
|
|
1471
1727
|
* cannot be loaded at all, or a run row that cannot be written, fails before
|
|
1472
1728
|
* there is a row to record it on.
|
|
1473
1729
|
*/
|
|
1474
|
-
async executeOnce({
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
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);
|
|
1479
1742
|
const taskContext = this.buildContext(run, meta, { scheduledAt, nextAt, deadline }, signal);
|
|
1743
|
+
tick.final = { run, path, context: taskContext, exports };
|
|
1480
1744
|
let stage = "start";
|
|
1481
1745
|
let exclusiveHeld = null;
|
|
1482
1746
|
try {
|
|
@@ -1488,25 +1752,25 @@ var Task = class {
|
|
|
1488
1752
|
});
|
|
1489
1753
|
if ("reason" in gate) {
|
|
1490
1754
|
await run.callbacks.onSkip?.({ path, reason: gate.reason });
|
|
1491
|
-
return;
|
|
1755
|
+
return { status: "skipped", reason: gate.reason };
|
|
1492
1756
|
}
|
|
1493
1757
|
exclusiveHeld = gate.held;
|
|
1494
1758
|
stage = "shouldSkip";
|
|
1495
1759
|
if (await exports.shouldSkip?.(taskContext)) {
|
|
1496
1760
|
await run.callbacks.onSkip?.({ path });
|
|
1497
|
-
return;
|
|
1761
|
+
return { status: "skipped", reason: null };
|
|
1498
1762
|
}
|
|
1499
1763
|
stage = "start";
|
|
1500
1764
|
await run.callbacks.onStart?.({ path });
|
|
1501
1765
|
stage = "task";
|
|
1502
|
-
|
|
1766
|
+
tick.failedContext = taskContext;
|
|
1503
1767
|
const value = await task(taskContext);
|
|
1504
|
-
|
|
1768
|
+
tick.failedContext = null;
|
|
1505
1769
|
stage = "record";
|
|
1506
1770
|
await run.callbacks.onSuccess?.({ path, result: value });
|
|
1507
1771
|
await this.runHook(run, "onSuccess", () => exports.onSuccess?.(taskContext));
|
|
1508
1772
|
await this.runHook(run, "onComplete", () => exports.onComplete?.(taskContext));
|
|
1509
|
-
return value;
|
|
1773
|
+
return { status: "success", result: value };
|
|
1510
1774
|
} catch (error) {
|
|
1511
1775
|
await this.failRun(run, stage, error);
|
|
1512
1776
|
if (stage === "task" || stage === "record") {
|
|
@@ -1579,61 +1843,1172 @@ var TaskLibrary = class {
|
|
|
1579
1843
|
};
|
|
1580
1844
|
|
|
1581
1845
|
// src/runtime/taskLibraryScheduler.ts
|
|
1582
|
-
import
|
|
1846
|
+
import invariant7 from "tiny-invariant";
|
|
1583
1847
|
import { logTaskLibraryScheduler, logTaskLibrarySchedulerError } from "@apisurf/ngn-os";
|
|
1584
1848
|
|
|
1585
|
-
// src/runtime/
|
|
1586
|
-
import {
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
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;
|
|
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);
|
|
1595
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;
|
|
1596
2017
|
}
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
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
|
+
};
|
|
1602
2119
|
}
|
|
1603
|
-
|
|
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 };
|
|
2125
|
+
}
|
|
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}`);
|
|
1604
2422
|
} catch (error) {
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
task.validateExports();
|
|
1611
|
-
return new Cron(
|
|
1612
|
-
pattern,
|
|
1613
|
-
CRON_OPTIONS,
|
|
1614
|
-
(job) => tick(task, onError, {
|
|
1615
|
-
scheduledAt: job.currentRun()?.getTime() ?? Date.now(),
|
|
1616
|
-
nextAt: job.nextRun()?.getTime() ?? null
|
|
1617
|
-
})
|
|
1618
|
-
);
|
|
2423
|
+
onProblem(
|
|
2424
|
+
`schedule of ${task.taskPath}: could not write ${change.event}: ${errorSummary(error)}`
|
|
2425
|
+
);
|
|
2426
|
+
}
|
|
2427
|
+
return null;
|
|
1619
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;
|
|
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
|
+
};
|
|
1620
2995
|
|
|
1621
2996
|
// src/runtime/taskLibraryScheduler.ts
|
|
1622
2997
|
var TaskLibraryScheduler = class {
|
|
1623
2998
|
taskLibrary;
|
|
1624
|
-
|
|
2999
|
+
options;
|
|
1625
3000
|
scheduledTasks = /* @__PURE__ */ new Map();
|
|
1626
3001
|
constructor(options) {
|
|
1627
3002
|
this.taskLibrary = options.taskLibrary;
|
|
1628
|
-
this.
|
|
3003
|
+
this.options = options;
|
|
1629
3004
|
}
|
|
1630
3005
|
async start() {
|
|
1631
3006
|
logTaskLibraryScheduler("Starting scheduler");
|
|
1632
3007
|
const report = { scheduled: [], failed: [] };
|
|
1633
3008
|
const scheduledTasks = this.taskLibrary.getTasks().map(async (task) => {
|
|
1634
3009
|
try {
|
|
1635
|
-
await this.scheduleTask(task);
|
|
1636
|
-
report.scheduled.push({ path: task.taskPath,
|
|
3010
|
+
const timing = await this.scheduleTask(task);
|
|
3011
|
+
report.scheduled.push({ path: task.taskPath, timing });
|
|
1637
3012
|
} catch (error) {
|
|
1638
3013
|
logTaskLibrarySchedulerError(`Error scheduling task: ${task.taskPath}`);
|
|
1639
3014
|
logTaskLibrarySchedulerError(error);
|
|
@@ -1646,88 +3021,106 @@ var TaskLibraryScheduler = class {
|
|
|
1646
3021
|
report.failed.sort(byPath);
|
|
1647
3022
|
return report;
|
|
1648
3023
|
}
|
|
1649
|
-
|
|
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 = {}) {
|
|
1650
3029
|
logTaskLibraryScheduler("Stopping scheduler");
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
3030
|
+
await Promise.all(
|
|
3031
|
+
[...this.scheduledTasks].map(([path, schedule]) => {
|
|
3032
|
+
logTaskLibraryScheduler(`Stopping scheduled task: ${path}`);
|
|
3033
|
+
return schedule.stop(options);
|
|
3034
|
+
})
|
|
3035
|
+
);
|
|
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();
|
|
1655
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
|
+
*/
|
|
1656
3046
|
async scheduleTask(task) {
|
|
1657
3047
|
logTaskLibraryScheduler(`Scheduling task: ${task.taskPath}`);
|
|
1658
3048
|
await task.loadEntry();
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
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`
|
|
3056
|
+
);
|
|
3057
|
+
const next = task.nextHook;
|
|
3058
|
+
invariant7(
|
|
3059
|
+
next == null || typeof next === "function",
|
|
3060
|
+
`next for ${task.taskPath}: expected a function`
|
|
1665
3061
|
);
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
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
|
+
}
|
|
1670
3083
|
});
|
|
1671
|
-
|
|
3084
|
+
await schedule.start();
|
|
3085
|
+
this.scheduledTasks.set(task.taskPath, schedule);
|
|
1672
3086
|
logTaskLibraryScheduler(`Scheduled: ${task.taskPath}`);
|
|
3087
|
+
return timing;
|
|
1673
3088
|
}
|
|
1674
3089
|
/**
|
|
1675
|
-
* Start one run of a scheduled task now, outside its schedule,
|
|
1676
|
-
*
|
|
1677
|
-
*
|
|
1678
|
-
* once the run is under way, not when it ends
|
|
1679
|
-
* anyone waits on a button.
|
|
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.
|
|
1680
3094
|
*
|
|
1681
|
-
* A run
|
|
1682
|
-
*
|
|
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.
|
|
1683
3097
|
*/
|
|
1684
|
-
runNow(taskPath) {
|
|
1685
|
-
const
|
|
1686
|
-
|
|
1687
|
-
if (!task || !cron) {
|
|
3098
|
+
async runNow(taskPath) {
|
|
3099
|
+
const schedule = this.scheduledTasks.get(taskPath);
|
|
3100
|
+
if (!schedule)
|
|
1688
3101
|
return { started: false, reason: `${taskPath} is not scheduled by this ngn run` };
|
|
1689
|
-
|
|
1690
|
-
if (task.isRunning && task.overlapPolicy === "skip") {
|
|
1691
|
-
return { started: false, reason: "a run of it is still going" };
|
|
1692
|
-
}
|
|
1693
|
-
void tick(
|
|
1694
|
-
task,
|
|
1695
|
-
(error) => {
|
|
1696
|
-
logTaskLibrarySchedulerError(`Run failed: ${task.taskPath}`);
|
|
1697
|
-
logTaskLibrarySchedulerError(error);
|
|
1698
|
-
this.onRunError?.(task.taskPath, error, failedRunId(error));
|
|
1699
|
-
},
|
|
1700
|
-
{ scheduledAt: null, nextAt: cron.nextRun()?.getTime() ?? null }
|
|
1701
|
-
);
|
|
1702
|
-
return { started: true };
|
|
3102
|
+
return schedule.runNow();
|
|
1703
3103
|
}
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
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
|
+
};
|
|
1708
3113
|
}
|
|
3114
|
+
return schedule.control(command);
|
|
3115
|
+
}
|
|
3116
|
+
async unscheduleTask(task) {
|
|
3117
|
+
const existing = this.scheduledTasks.get(task.taskPath);
|
|
3118
|
+
if (!existing) return;
|
|
1709
3119
|
logTaskLibraryScheduler(`Unscheduling: ${task.taskPath}`);
|
|
1710
|
-
existingTask.stop();
|
|
1711
3120
|
this.scheduledTasks.delete(task.taskPath);
|
|
3121
|
+
await existing.stop();
|
|
1712
3122
|
logTaskLibraryScheduler(`Unscheduled: ${task.taskPath}`);
|
|
1713
3123
|
}
|
|
1714
|
-
isCronPatternValid(cronPattern) {
|
|
1715
|
-
return isCronPatternValid(cronPattern);
|
|
1716
|
-
}
|
|
1717
|
-
// startScheduledTask(path: string) {
|
|
1718
|
-
// const task = this.scheduledTasks.get(path);
|
|
1719
|
-
// if (!task) {
|
|
1720
|
-
// return;
|
|
1721
|
-
// }
|
|
1722
|
-
// task.start();
|
|
1723
|
-
// }
|
|
1724
|
-
// stopScheduledTask(path: string) {
|
|
1725
|
-
// const task = this.scheduledTasks.get(path);
|
|
1726
|
-
// if (!task) {
|
|
1727
|
-
// return;
|
|
1728
|
-
// }
|
|
1729
|
-
// task.stop();
|
|
1730
|
-
// }
|
|
1731
3124
|
};
|
|
1732
3125
|
function byPath(a, b) {
|
|
1733
3126
|
return a.path.localeCompare(b.path);
|
|
@@ -1738,7 +3131,7 @@ function reasonOf(error, path) {
|
|
|
1738
3131
|
}
|
|
1739
3132
|
|
|
1740
3133
|
// src/runtime/taskOnceRunner.ts
|
|
1741
|
-
import
|
|
3134
|
+
import invariant8 from "tiny-invariant";
|
|
1742
3135
|
import { logTaskOnceRunner, logTaskOnceRunnerError } from "@apisurf/ngn-os";
|
|
1743
3136
|
var TaskOnceRunner = class {
|
|
1744
3137
|
task;
|
|
@@ -1749,7 +3142,7 @@ var TaskOnceRunner = class {
|
|
|
1749
3142
|
logTaskOnceRunner(`Preparing to run task: ${this.task.taskPath}`);
|
|
1750
3143
|
await this.task.loadEntry();
|
|
1751
3144
|
logTaskOnceRunner(`Loaded task code: ${this.task.taskPath}`);
|
|
1752
|
-
|
|
3145
|
+
invariant8(this.task.hasTaskExport(), `task function is missing for ${this.task.taskPath}`);
|
|
1753
3146
|
logTaskOnceRunner(`Running task: ${this.task.taskPath}`);
|
|
1754
3147
|
try {
|
|
1755
3148
|
await this.task.execute();
|
|
@@ -1768,10 +3161,10 @@ import {
|
|
|
1768
3161
|
initDbFileIfNotExists,
|
|
1769
3162
|
isDbPath
|
|
1770
3163
|
} from "@apisurf/ngn-persistence";
|
|
1771
|
-
import
|
|
3164
|
+
import invariant9 from "tiny-invariant";
|
|
1772
3165
|
var db;
|
|
1773
3166
|
async function setupDbClient(dbPath) {
|
|
1774
|
-
|
|
3167
|
+
invariant9(isDbPath(dbPath), "Invalid db path");
|
|
1775
3168
|
if (db) {
|
|
1776
3169
|
return db;
|
|
1777
3170
|
}
|
|
@@ -1779,7 +3172,7 @@ async function setupDbClient(dbPath) {
|
|
|
1779
3172
|
return db;
|
|
1780
3173
|
}
|
|
1781
3174
|
function getDbClient() {
|
|
1782
|
-
|
|
3175
|
+
invariant9(db, "DB client not initialized");
|
|
1783
3176
|
return db;
|
|
1784
3177
|
}
|
|
1785
3178
|
|
|
@@ -1790,8 +3183,8 @@ import {
|
|
|
1790
3183
|
logTaskConsoleInfo,
|
|
1791
3184
|
logTaskConsoleWarn
|
|
1792
3185
|
} from "@apisurf/ngn-os";
|
|
1793
|
-
import
|
|
1794
|
-
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";
|
|
1795
3188
|
|
|
1796
3189
|
// src/db/services/fileTask.ts
|
|
1797
3190
|
import { $fileTaskInsert } from "@apisurf/ngn-schema";
|
|
@@ -1980,7 +3373,7 @@ var TaskRunService = class {
|
|
|
1980
3373
|
}
|
|
1981
3374
|
async skipTaskRun(taskRunId, reason = null) {
|
|
1982
3375
|
await this.db.execute({
|
|
1983
|
-
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",
|
|
1984
3377
|
args: { id: taskRunId, ended_at: Date.now(), reason }
|
|
1985
3378
|
});
|
|
1986
3379
|
}
|
|
@@ -2015,16 +3408,20 @@ var TaskRunService = class {
|
|
|
2015
3408
|
}
|
|
2016
3409
|
async startTaskRun(taskRunId) {
|
|
2017
3410
|
await this.db.execute({
|
|
2018
|
-
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",
|
|
2019
3412
|
args: {
|
|
2020
3413
|
started_at: Date.now(),
|
|
2021
3414
|
id: taskRunId
|
|
2022
3415
|
}
|
|
2023
3416
|
});
|
|
2024
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
|
+
*/
|
|
2025
3422
|
async endTaskRun(taskRunId, status, result = null) {
|
|
2026
|
-
await this.db.execute({
|
|
2027
|
-
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",
|
|
2028
3425
|
args: {
|
|
2029
3426
|
ended_at: Date.now(),
|
|
2030
3427
|
status,
|
|
@@ -2032,6 +3429,7 @@ var TaskRunService = class {
|
|
|
2032
3429
|
id: taskRunId
|
|
2033
3430
|
}
|
|
2034
3431
|
});
|
|
3432
|
+
return rowsAffected > 0;
|
|
2035
3433
|
}
|
|
2036
3434
|
async getById(taskId) {
|
|
2037
3435
|
const { rows } = await this.db.execute({
|
|
@@ -2174,6 +3572,27 @@ var LockService = class {
|
|
|
2174
3572
|
});
|
|
2175
3573
|
return rowsAffected > 0;
|
|
2176
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
|
+
}
|
|
2177
3596
|
async release(name, holder) {
|
|
2178
3597
|
await this.db.execute({
|
|
2179
3598
|
sql: "DELETE FROM locks WHERE name = :name AND holder = :holder",
|
|
@@ -2187,9 +3606,10 @@ async function recordSafely(runId, write) {
|
|
|
2187
3606
|
try {
|
|
2188
3607
|
await write();
|
|
2189
3608
|
} catch (error) {
|
|
2190
|
-
logTaskCallback(`Could not record what run ${runId} threw: ${
|
|
3609
|
+
logTaskCallback(`Could not record what run ${runId} threw: ${errorSummary2(error)}`);
|
|
2191
3610
|
}
|
|
2192
3611
|
}
|
|
3612
|
+
var lateEnd = (outcome) => `finished after ngn closed it as interrupted: ${outcome}`;
|
|
2193
3613
|
function createControlsGenerator({
|
|
2194
3614
|
dbClient,
|
|
2195
3615
|
env,
|
|
@@ -2200,12 +3620,13 @@ function createControlsGenerator({
|
|
|
2200
3620
|
const kvService = new KVService(dbClient);
|
|
2201
3621
|
const timingService = new TimingService(dbClient);
|
|
2202
3622
|
const lockService = new LockService(dbClient);
|
|
3623
|
+
const scheduling = { store: new ScheduleStore(dbClient), locks: lockService };
|
|
2203
3624
|
const generateControls = ({ sourcePath, compiledPath }) => {
|
|
2204
3625
|
let fileTaskId;
|
|
2205
3626
|
let fileTaskVersionId;
|
|
2206
3627
|
const assertMandatoryIds = () => {
|
|
2207
|
-
|
|
2208
|
-
|
|
3628
|
+
invariant10(!!fileTaskId, "Task ID not found");
|
|
3629
|
+
invariant10(!!fileTaskVersionId, "Task version ID not found");
|
|
2209
3630
|
};
|
|
2210
3631
|
const taskCallbacks = {
|
|
2211
3632
|
async onTaskLoadComplete({ paths, md5Hash, compiledCode }) {
|
|
@@ -2249,8 +3670,12 @@ function createControlsGenerator({
|
|
|
2249
3670
|
},
|
|
2250
3671
|
async onFailure({ path, stage, error }) {
|
|
2251
3672
|
await recordSafely(runId, async () => {
|
|
2252
|
-
await taskRunService.endTaskRun(runId, "failure")
|
|
2253
|
-
|
|
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));
|
|
2254
3679
|
await logService.logError(taskId, runId, "Task failed");
|
|
2255
3680
|
});
|
|
2256
3681
|
logTaskCallback(`Task failed in ${stage}: ${taskId}, run ID: ${runId}`);
|
|
@@ -2259,13 +3684,16 @@ function createControlsGenerator({
|
|
|
2259
3684
|
async onHookError({ path, stage, error }) {
|
|
2260
3685
|
await recordSafely(
|
|
2261
3686
|
runId,
|
|
2262
|
-
() => logService.logError(taskId, runId,
|
|
3687
|
+
() => logService.logError(taskId, runId, failureLine2(stage, error))
|
|
2263
3688
|
);
|
|
2264
3689
|
logTaskCallback(`Hook ${stage} threw: ${taskId}, run ID: ${runId}`);
|
|
2265
3690
|
report?.({ path, runId, stage, error, fatal: false });
|
|
2266
3691
|
},
|
|
2267
3692
|
async onSuccess({ result }) {
|
|
2268
|
-
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
|
+
}
|
|
2269
3697
|
await logService.logInfo(taskId, runId, "Task succeeded");
|
|
2270
3698
|
logTaskCallback(`Task succeeded: ${taskId}, run ID: ${runId}`);
|
|
2271
3699
|
}
|
|
@@ -2310,6 +3738,7 @@ function createControlsGenerator({
|
|
|
2310
3738
|
return taskRunService.countSkipsSince(fileTaskId, reason, afterId);
|
|
2311
3739
|
}
|
|
2312
3740
|
},
|
|
3741
|
+
scheduling,
|
|
2313
3742
|
env: env ?? {},
|
|
2314
3743
|
// Scoped to this task's own folder — see createTaskSqlite
|
|
2315
3744
|
sqlite: createTaskSqlite(sourcePath),
|
|
@@ -2332,11 +3761,14 @@ export {
|
|
|
2332
3761
|
Compiler,
|
|
2333
3762
|
LockBusyError,
|
|
2334
3763
|
Queue,
|
|
3764
|
+
STOP_GRACE_MS,
|
|
3765
|
+
ScheduleStore,
|
|
2335
3766
|
Task,
|
|
2336
3767
|
TaskLibrary,
|
|
2337
3768
|
TaskLibraryScheduler,
|
|
2338
3769
|
TaskOnceRunner,
|
|
2339
3770
|
closeTaskDatabases,
|
|
3771
|
+
controlSchedule,
|
|
2340
3772
|
createControlsGenerator,
|
|
2341
3773
|
executeEsm,
|
|
2342
3774
|
failedRunId,
|
|
@@ -2344,7 +3776,10 @@ export {
|
|
|
2344
3776
|
initDbFileIfNotExists,
|
|
2345
3777
|
isCronPatternValid,
|
|
2346
3778
|
releaseTaskDatabases,
|
|
3779
|
+
resolveTiming,
|
|
2347
3780
|
runFailure,
|
|
2348
|
-
|
|
2349
|
-
setupDbClient
|
|
3781
|
+
runOnce,
|
|
3782
|
+
setupDbClient,
|
|
3783
|
+
systemClock,
|
|
3784
|
+
timingFromFlag
|
|
2350
3785
|
};
|