@apisurf/ngn-core 0.2.7 → 0.2.8
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 +459 -11
- package/dist/core.js +1244 -238
- package/package.json +8 -7
package/dist/core.js
CHANGED
|
@@ -133,7 +133,6 @@ __exports;`);
|
|
|
133
133
|
...buildGlobals(),
|
|
134
134
|
require: createRequire(requireResolutionRoot),
|
|
135
135
|
// Provide CommonJS-style globals for bundled dependencies that expect them
|
|
136
|
-
// (e.g., node-cron uses __dirname for its daemon path)
|
|
137
136
|
__dirname: process.cwd(),
|
|
138
137
|
__filename: `${process.cwd()}/vm-script.js`
|
|
139
138
|
});
|
|
@@ -314,6 +313,10 @@ var Entry = class {
|
|
|
314
313
|
return {
|
|
315
314
|
task: taskFunction,
|
|
316
315
|
timing: taskExports.timing,
|
|
316
|
+
overlap: taskExports.overlap,
|
|
317
|
+
budget: taskExports.budget,
|
|
318
|
+
exclusive: taskExports.exclusive,
|
|
319
|
+
retry: taskExports.retry,
|
|
317
320
|
shouldSkip: taskExports.shouldSkip,
|
|
318
321
|
shouldRetry: taskExports.shouldRetry,
|
|
319
322
|
onSuccess: taskExports.onSuccess,
|
|
@@ -342,18 +345,843 @@ var Entry = class {
|
|
|
342
345
|
}
|
|
343
346
|
};
|
|
344
347
|
|
|
348
|
+
// src/runtime/duration.ts
|
|
349
|
+
var UNIT_MS = { ms: 1, s: 1e3, m: 6e4, h: 36e5, d: 864e5 };
|
|
350
|
+
function toMs(value, what = "duration") {
|
|
351
|
+
if (typeof value === "number") {
|
|
352
|
+
if (Number.isFinite(value) && value >= 0) return value;
|
|
353
|
+
} else {
|
|
354
|
+
const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/.exec(value);
|
|
355
|
+
if (match) return Number(match[1]) * UNIT_MS[match[2]];
|
|
356
|
+
}
|
|
357
|
+
throw new Error(
|
|
358
|
+
`Invalid ${what} ${JSON.stringify(value)}: expected milliseconds or a string like "30s", "4m"`
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// src/runtime/runWindow.ts
|
|
363
|
+
function runDeadline({
|
|
364
|
+
startedAt,
|
|
365
|
+
scheduledAt,
|
|
366
|
+
nextAt,
|
|
367
|
+
budgetMs
|
|
368
|
+
}) {
|
|
369
|
+
const candidates = [];
|
|
370
|
+
if (nextAt != null) {
|
|
371
|
+
const period = nextAt - (scheduledAt ?? startedAt);
|
|
372
|
+
candidates.push(nextAt - Math.min(5e3, Math.max(0, period) * 0.1));
|
|
373
|
+
}
|
|
374
|
+
if (budgetMs != null) candidates.push(startedAt + budgetMs);
|
|
375
|
+
return candidates.length ? Math.floor(Math.min(...candidates)) : null;
|
|
376
|
+
}
|
|
377
|
+
function deadlineSignal(deadline) {
|
|
378
|
+
const controller = new AbortController();
|
|
379
|
+
if (deadline == null) return { signal: controller.signal, dispose: () => {
|
|
380
|
+
} };
|
|
381
|
+
const abort = () => controller.abort(new Error("The run reached its deadline"));
|
|
382
|
+
const delay = deadline - Date.now();
|
|
383
|
+
if (delay <= 0) {
|
|
384
|
+
abort();
|
|
385
|
+
return { signal: controller.signal, dispose: () => {
|
|
386
|
+
} };
|
|
387
|
+
}
|
|
388
|
+
const timer = setTimeout(abort, delay);
|
|
389
|
+
timer.unref();
|
|
390
|
+
return { signal: controller.signal, dispose: () => clearTimeout(timer) };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// src/runtime/locks.ts
|
|
394
|
+
var LockBusyError = class extends Error {
|
|
395
|
+
constructor(lockName) {
|
|
396
|
+
super(`Lock "${lockName}" is held by another run`);
|
|
397
|
+
this.lockName = lockName;
|
|
398
|
+
}
|
|
399
|
+
name = "LockBusyError";
|
|
400
|
+
};
|
|
401
|
+
var DEFAULT_TTL_MS = 6e4;
|
|
402
|
+
var MAX_POLL_MS = 1e3;
|
|
403
|
+
var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
404
|
+
function assertLockName(name) {
|
|
405
|
+
if (typeof name !== "string" || !name.trim()) {
|
|
406
|
+
throw new Error(`A lock name must be a non-empty string, got ${JSON.stringify(name)}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function createRunLocks(service, taskRunId) {
|
|
410
|
+
async function tryHold(name, ttlMs) {
|
|
411
|
+
const holder = await service.tryAcquire(name, ttlMs, taskRunId);
|
|
412
|
+
if (!holder) return null;
|
|
413
|
+
const renewal = setInterval(() => {
|
|
414
|
+
service.renew(name, holder, ttlMs).catch(() => {
|
|
415
|
+
});
|
|
416
|
+
}, ttlMs / 3);
|
|
417
|
+
renewal.unref();
|
|
418
|
+
return {
|
|
419
|
+
async release() {
|
|
420
|
+
clearInterval(renewal);
|
|
421
|
+
await service.release(name, holder);
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
async function hold(name, ttlMs, waitUntil) {
|
|
426
|
+
for (let pause = 25; ; pause = Math.min(pause * 2, MAX_POLL_MS)) {
|
|
427
|
+
const held = await tryHold(name, ttlMs);
|
|
428
|
+
if (held) return held;
|
|
429
|
+
const left = waitUntil - Date.now();
|
|
430
|
+
if (left <= 0) return null;
|
|
431
|
+
await sleep(Math.min(pause, left));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
/** `ctx.lock`: run `fn` holding `name`, released however `fn` ends. */
|
|
436
|
+
async lock(name, fn, options, deadline) {
|
|
437
|
+
assertLockName(name);
|
|
438
|
+
const ttlMs = options.ttl == null ? DEFAULT_TTL_MS : toMs(options.ttl, "lock ttl");
|
|
439
|
+
const { wait = true } = options;
|
|
440
|
+
const waitUntil = wait === true ? deadline ?? Number.POSITIVE_INFINITY : wait === false ? Date.now() : Date.now() + toMs(wait, "lock wait");
|
|
441
|
+
const held = await hold(name, ttlMs, waitUntil);
|
|
442
|
+
if (!held) throw new LockBusyError(name);
|
|
443
|
+
try {
|
|
444
|
+
return await fn();
|
|
445
|
+
} finally {
|
|
446
|
+
await held.release();
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
/**
|
|
450
|
+
* Take every lock in `names` without waiting, for `exclusive`. Names are
|
|
451
|
+
* taken in sorted order so two runs asking for overlapping sets cannot
|
|
452
|
+
* each hold half. If any is busy, the ones already taken are given back.
|
|
453
|
+
*/
|
|
454
|
+
async holdAll(names) {
|
|
455
|
+
const taken = [];
|
|
456
|
+
const releaseAll = async () => {
|
|
457
|
+
await Promise.all(taken.map((held) => held.release()));
|
|
458
|
+
};
|
|
459
|
+
for (const name of [...new Set(names)].toSorted()) {
|
|
460
|
+
const held = await tryHold(name, DEFAULT_TTL_MS);
|
|
461
|
+
if (!held) {
|
|
462
|
+
await releaseAll();
|
|
463
|
+
return { busy: name };
|
|
464
|
+
}
|
|
465
|
+
taken.push(held);
|
|
466
|
+
}
|
|
467
|
+
return { release: releaseAll };
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
function exclusiveNames(value, taskPath) {
|
|
472
|
+
if (value == null) return [];
|
|
473
|
+
const names = Array.isArray(value) ? value : [value];
|
|
474
|
+
for (const name of names) {
|
|
475
|
+
if (typeof name !== "string" || !name.trim()) {
|
|
476
|
+
throw new Error(
|
|
477
|
+
`Invalid exclusive for ${taskPath}: expected a lock name or a list of them, got ${JSON.stringify(value)}`
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return names;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// src/runtime/taskSqlite.ts
|
|
485
|
+
import { mkdirSync } from "node:fs";
|
|
486
|
+
import { unlink } from "node:fs/promises";
|
|
487
|
+
import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
|
|
488
|
+
import {
|
|
489
|
+
openDbClient
|
|
490
|
+
} from "@apisurf/ngn-persistence";
|
|
491
|
+
var openClients = /* @__PURE__ */ new Map();
|
|
492
|
+
async function runMigrations(client, migrations) {
|
|
493
|
+
if (!migrations.length) return;
|
|
494
|
+
await client.execute(`
|
|
495
|
+
CREATE TABLE IF NOT EXISTS _ngn_migrations (
|
|
496
|
+
id TEXT PRIMARY KEY,
|
|
497
|
+
applied_at TEXT NOT NULL
|
|
498
|
+
)
|
|
499
|
+
`);
|
|
500
|
+
const applied = await client.execute("SELECT id FROM _ngn_migrations");
|
|
501
|
+
const appliedIds = new Set(applied.rows.map((r) => r.id));
|
|
502
|
+
for (const migration of migrations) {
|
|
503
|
+
if (appliedIds.has(migration.id)) continue;
|
|
504
|
+
await client.execute(migration.up);
|
|
505
|
+
await client.execute({
|
|
506
|
+
sql: "INSERT INTO _ngn_migrations (id, applied_at) VALUES (?, ?)",
|
|
507
|
+
args: [migration.id, (/* @__PURE__ */ new Date()).toISOString()]
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
function resolveTaskDbPath(taskDir, file) {
|
|
512
|
+
if (typeof file !== "string" || !file.trim()) {
|
|
513
|
+
throw new Error("A database file name is required");
|
|
514
|
+
}
|
|
515
|
+
const relativePath = file.startsWith("file:") ? file.slice(5) : file;
|
|
516
|
+
if (!relativePath) {
|
|
517
|
+
throw new Error("A database file name is required");
|
|
518
|
+
}
|
|
519
|
+
if (isAbsolute(relativePath)) {
|
|
520
|
+
throw new Error(
|
|
521
|
+
`Database file must be relative to the task folder, got an absolute path: "${file}"`
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
const resolved = resolve(taskDir, relativePath);
|
|
525
|
+
const taskDirRoot = taskDir.endsWith(sep) ? taskDir : `${taskDir}${sep}`;
|
|
526
|
+
if (!resolved.startsWith(taskDirRoot)) {
|
|
527
|
+
throw new Error(
|
|
528
|
+
`Database file "${file}" resolves outside the task folder (${taskDir}). A task can only manage databases in its own folder.`
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
return resolved;
|
|
532
|
+
}
|
|
533
|
+
function getClient(absolutePath) {
|
|
534
|
+
const existing = openClients.get(absolutePath);
|
|
535
|
+
if (existing) {
|
|
536
|
+
return existing;
|
|
537
|
+
}
|
|
538
|
+
mkdirSync(dirname(absolutePath), { recursive: true });
|
|
539
|
+
const client = openDbClient(`file:${absolutePath}`);
|
|
540
|
+
openClients.set(absolutePath, client);
|
|
541
|
+
return client;
|
|
542
|
+
}
|
|
543
|
+
function closeClient(absolutePath) {
|
|
544
|
+
const client = openClients.get(absolutePath);
|
|
545
|
+
if (client) {
|
|
546
|
+
client.close();
|
|
547
|
+
openClients.delete(absolutePath);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
function createTaskSqlite(taskSourcePath) {
|
|
551
|
+
const taskDir = dirname(taskSourcePath);
|
|
552
|
+
const defaultDbPath = join(taskDir, `${basename(taskSourcePath, extname(taskSourcePath))}.db`);
|
|
553
|
+
const defaultClient = () => getClient(defaultDbPath);
|
|
554
|
+
return {
|
|
555
|
+
get client() {
|
|
556
|
+
return defaultClient();
|
|
557
|
+
},
|
|
558
|
+
get path() {
|
|
559
|
+
return defaultDbPath;
|
|
560
|
+
},
|
|
561
|
+
execute: (sql, args) => defaultClient().execute(args ? { sql, args } : sql),
|
|
562
|
+
batch: (statements) => defaultClient().batch(statements),
|
|
563
|
+
async initDB(options) {
|
|
564
|
+
const dbPath = resolveTaskDbPath(taskDir, options.file);
|
|
565
|
+
const client = getClient(dbPath);
|
|
566
|
+
if (options.migrations?.length) {
|
|
567
|
+
await runMigrations(client, options.migrations);
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
client,
|
|
571
|
+
path: dbPath,
|
|
572
|
+
execute: (sql, args) => client.execute(args ? { sql, args } : sql),
|
|
573
|
+
batch: (statements) => client.batch(statements),
|
|
574
|
+
close: () => closeClient(dbPath)
|
|
575
|
+
};
|
|
576
|
+
},
|
|
577
|
+
async destroyDB(file) {
|
|
578
|
+
const dbPath = resolveTaskDbPath(taskDir, file);
|
|
579
|
+
closeClient(dbPath);
|
|
580
|
+
await unlink(dbPath);
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
function closeTaskDatabases() {
|
|
585
|
+
for (const client of openClients.values()) {
|
|
586
|
+
client.close();
|
|
587
|
+
}
|
|
588
|
+
openClients.clear();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// src/runtime/queue/schema.ts
|
|
592
|
+
var QUEUE_MIGRATIONS = [
|
|
593
|
+
{
|
|
594
|
+
id: "ngn-queue-0001",
|
|
595
|
+
up: `CREATE TABLE IF NOT EXISTS _ngn_jobs (
|
|
596
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
597
|
+
queue TEXT NOT NULL,
|
|
598
|
+
key TEXT,
|
|
599
|
+
grp TEXT,
|
|
600
|
+
payload TEXT,
|
|
601
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
602
|
+
status TEXT NOT NULL DEFAULT 'ready',
|
|
603
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
604
|
+
max_attempts INTEGER NOT NULL,
|
|
605
|
+
run_after INTEGER NOT NULL,
|
|
606
|
+
lease_until INTEGER,
|
|
607
|
+
last_error TEXT,
|
|
608
|
+
created_at INTEGER NOT NULL,
|
|
609
|
+
updated_at INTEGER NOT NULL,
|
|
610
|
+
UNIQUE (queue, key)
|
|
611
|
+
)`
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
id: "ngn-queue-0002",
|
|
615
|
+
up: "CREATE INDEX IF NOT EXISTS _ngn_jobs_claim_idx ON _ngn_jobs (queue, status, run_after)"
|
|
616
|
+
}
|
|
617
|
+
];
|
|
618
|
+
|
|
619
|
+
// src/runtime/queue/work.ts
|
|
620
|
+
function untilSignal(until) {
|
|
621
|
+
if (until === void 0) return new AbortController().signal;
|
|
622
|
+
if (typeof until !== "number") return until;
|
|
623
|
+
const left = until - Date.now();
|
|
624
|
+
return left <= 0 ? AbortSignal.abort() : AbortSignal.timeout(left);
|
|
625
|
+
}
|
|
626
|
+
var WorkRun = class {
|
|
627
|
+
constructor(queue, handler, options) {
|
|
628
|
+
this.queue = queue;
|
|
629
|
+
this.handler = handler;
|
|
630
|
+
this.options = options;
|
|
631
|
+
this.signal = untilSignal(options.until);
|
|
632
|
+
this.concurrency = Math.max(1, Math.floor(options.concurrency ?? 1));
|
|
633
|
+
this.max = options.max ?? Number.POSITIVE_INFINITY;
|
|
634
|
+
}
|
|
635
|
+
buffer = [];
|
|
636
|
+
/** Groups with a job buffered or being worked. */
|
|
637
|
+
busyGroups = /* @__PURE__ */ new Set();
|
|
638
|
+
stopped = /* @__PURE__ */ new Set();
|
|
639
|
+
perGroupCount = /* @__PURE__ */ new Map();
|
|
640
|
+
capped = /* @__PURE__ */ new Set();
|
|
641
|
+
/**
|
|
642
|
+
* Every job this call has claimed. Never claimed twice: without a next tick
|
|
643
|
+
* to back off to (run:once), a failed or released job is due again at once,
|
|
644
|
+
* and the call would spend all its attempts in one go.
|
|
645
|
+
*/
|
|
646
|
+
seen = /* @__PURE__ */ new Set();
|
|
647
|
+
signal;
|
|
648
|
+
concurrency;
|
|
649
|
+
max;
|
|
650
|
+
claiming = null;
|
|
651
|
+
claimed = 0;
|
|
652
|
+
working = 0;
|
|
653
|
+
/** Workers waiting for a job in hand to finish and free its group. */
|
|
654
|
+
waiting = [];
|
|
655
|
+
stoppedBy = "empty";
|
|
656
|
+
/** Set when a worker breaks: the others finish what they hold and stop. */
|
|
657
|
+
broken = false;
|
|
658
|
+
tally = { done: 0, failed: 0, dead: 0, released: 0 };
|
|
659
|
+
async run() {
|
|
660
|
+
const workers = Array.from({ length: this.concurrency }, (_, index) => this.worker(index));
|
|
661
|
+
try {
|
|
662
|
+
const results = await Promise.allSettled(workers);
|
|
663
|
+
const failure = results.find((result) => result.status === "rejected");
|
|
664
|
+
if (failure) throw failure.reason;
|
|
665
|
+
} finally {
|
|
666
|
+
await this.releaseJobs(this.buffer.splice(0), { kind: "release" });
|
|
667
|
+
}
|
|
668
|
+
const stats = await this.queue.stats();
|
|
669
|
+
return {
|
|
670
|
+
...this.tally,
|
|
671
|
+
remaining: stats.due,
|
|
672
|
+
oldestAgeMs: stats.oldestReadyAgeMs,
|
|
673
|
+
stoppedGroups: [...this.stopped],
|
|
674
|
+
stoppedBy: this.stoppedBy
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
shouldStop() {
|
|
678
|
+
if (this.broken) return true;
|
|
679
|
+
if (this.signal.aborted) this.stoppedBy = "deadline";
|
|
680
|
+
return this.signal.aborted;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* The next job for a worker: one from the buffer, refilled when empty.
|
|
684
|
+
* `"empty"` when nothing is claimable and no job in hand could change that;
|
|
685
|
+
* null to ask again (after a refill, a wait, or a job put back).
|
|
686
|
+
*/
|
|
687
|
+
async nextJob() {
|
|
688
|
+
const job = this.buffer.shift();
|
|
689
|
+
if (job && this.atCap(job.group)) {
|
|
690
|
+
this.capped.add(job.group);
|
|
691
|
+
await this.releaseJobs([job], { kind: "release" });
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
if (job) return job;
|
|
695
|
+
if (await this.refill()) return null;
|
|
696
|
+
if (this.working === 0) return "empty";
|
|
697
|
+
await new Promise((resolve2) => this.waiting.push(resolve2));
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
async worker(index) {
|
|
701
|
+
let resource = null;
|
|
702
|
+
try {
|
|
703
|
+
for (; ; ) {
|
|
704
|
+
if (this.shouldStop()) return;
|
|
705
|
+
const job = await this.nextJob();
|
|
706
|
+
if (job === "empty") return;
|
|
707
|
+
if (!job) continue;
|
|
708
|
+
try {
|
|
709
|
+
resource ??= { value: await this.options.worker?.setup?.(index) };
|
|
710
|
+
} catch (error) {
|
|
711
|
+
this.buffer.unshift(job);
|
|
712
|
+
throw error;
|
|
713
|
+
}
|
|
714
|
+
await this.work(job, index, resource.value);
|
|
715
|
+
}
|
|
716
|
+
} catch (error) {
|
|
717
|
+
this.broken = true;
|
|
718
|
+
throw error;
|
|
719
|
+
} finally {
|
|
720
|
+
if (resource) await this.options.worker?.teardown?.(resource.value, index);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/** Claim up to one job per free worker. Shared by workers that ask at once. */
|
|
724
|
+
refill() {
|
|
725
|
+
this.claiming ??= this.claim().finally(() => this.claiming = null);
|
|
726
|
+
return this.claiming;
|
|
727
|
+
}
|
|
728
|
+
async claim() {
|
|
729
|
+
const room = Math.min(this.concurrency, this.max - this.claimed);
|
|
730
|
+
if (room <= 0) {
|
|
731
|
+
this.stoppedBy = "max";
|
|
732
|
+
return 0;
|
|
733
|
+
}
|
|
734
|
+
const jobs = await this.queue.claim({
|
|
735
|
+
limit: room,
|
|
736
|
+
perGroup: 1,
|
|
737
|
+
skipGroups: [...this.busyGroups, ...this.stopped, ...this.capped],
|
|
738
|
+
skipIds: [...this.seen]
|
|
739
|
+
});
|
|
740
|
+
this.claimed += jobs.length;
|
|
741
|
+
for (const job of jobs) {
|
|
742
|
+
this.seen.add(job.id);
|
|
743
|
+
if (job.group != null) this.busyGroups.add(job.group);
|
|
744
|
+
this.buffer.push(job);
|
|
745
|
+
}
|
|
746
|
+
if (this.claimed >= this.max) this.stoppedBy = "max";
|
|
747
|
+
return jobs.length;
|
|
748
|
+
}
|
|
749
|
+
async work(job, index, resource) {
|
|
750
|
+
this.working++;
|
|
751
|
+
this.countForGroup(job.group);
|
|
752
|
+
let settle = null;
|
|
753
|
+
const handle = {
|
|
754
|
+
index,
|
|
755
|
+
resource,
|
|
756
|
+
signal: this.signal,
|
|
757
|
+
release: (options = {}) => {
|
|
758
|
+
settle ??= { kind: "release", ...options };
|
|
759
|
+
},
|
|
760
|
+
stopGroup: (options = {}) => {
|
|
761
|
+
settle ??= { kind: "stopGroup", ...options };
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
try {
|
|
765
|
+
try {
|
|
766
|
+
await this.handler(job, handle);
|
|
767
|
+
const asked = settle;
|
|
768
|
+
if (asked) await this.settleAsked(job, asked);
|
|
769
|
+
else if (await this.queue.complete(job)) this.tally.done++;
|
|
770
|
+
} catch (error) {
|
|
771
|
+
await this.failJob(job, error);
|
|
772
|
+
}
|
|
773
|
+
} finally {
|
|
774
|
+
if (job.group != null) this.busyGroups.delete(job.group);
|
|
775
|
+
this.working--;
|
|
776
|
+
for (const resolve2 of this.waiting.splice(0)) resolve2();
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
async failJob(job, error) {
|
|
780
|
+
if (!await this.queue.fail(job, error)) return;
|
|
781
|
+
this.tally.failed++;
|
|
782
|
+
if (job.attempts >= job.maxAttempts) this.tally.dead++;
|
|
783
|
+
}
|
|
784
|
+
async settleAsked(job, settle) {
|
|
785
|
+
if (settle.kind === "stopGroup" && job.group != null) {
|
|
786
|
+
this.stopped.add(job.group);
|
|
787
|
+
const rest = this.buffer.filter((other) => other.group === job.group);
|
|
788
|
+
const keep = this.buffer.filter((other) => other.group !== job.group);
|
|
789
|
+
this.buffer.splice(0, this.buffer.length, ...keep);
|
|
790
|
+
await this.releaseJobs(rest, settle);
|
|
791
|
+
}
|
|
792
|
+
await this.releaseJobs([job], settle);
|
|
793
|
+
}
|
|
794
|
+
async releaseJobs(jobs, { delay, refund }) {
|
|
795
|
+
for (const job of jobs) {
|
|
796
|
+
if (await this.queue.release(job, { delay, refund })) this.tally.released++;
|
|
797
|
+
if (job.group != null) this.busyGroups.delete(job.group);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
groupLimit(group) {
|
|
801
|
+
const { perGroup } = this.options;
|
|
802
|
+
if (perGroup == null) return Number.POSITIVE_INFINITY;
|
|
803
|
+
return typeof perGroup === "function" ? perGroup(group) : perGroup;
|
|
804
|
+
}
|
|
805
|
+
atCap(group) {
|
|
806
|
+
return group != null && (this.perGroupCount.get(group) ?? 0) >= this.groupLimit(group);
|
|
807
|
+
}
|
|
808
|
+
countForGroup(group) {
|
|
809
|
+
if (group == null) return;
|
|
810
|
+
const count = (this.perGroupCount.get(group) ?? 0) + 1;
|
|
811
|
+
this.perGroupCount.set(group, count);
|
|
812
|
+
if (count >= this.groupLimit(group)) this.capped.add(group);
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
function work(queue, handler, options) {
|
|
816
|
+
return new WorkRun(queue, handler, options).run();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// src/runtime/queue/queue.ts
|
|
820
|
+
var DEFAULT_MAX_ATTEMPTS = 3;
|
|
821
|
+
var DEFAULT_LEASE_MS = 10 * 6e4;
|
|
822
|
+
var MAX_ERROR_LENGTH = 2e3;
|
|
823
|
+
var placeholders = (n) => Array.from({ length: n }, () => "?").join(", ");
|
|
824
|
+
var asList = (value) => typeof value === "string" ? [value] : value;
|
|
825
|
+
function toJob(row) {
|
|
826
|
+
return {
|
|
827
|
+
id: row.id,
|
|
828
|
+
key: row.key,
|
|
829
|
+
group: row.grp,
|
|
830
|
+
payload: row.payload == null ? void 0 : JSON.parse(row.payload),
|
|
831
|
+
priority: row.priority,
|
|
832
|
+
attempts: row.attempts,
|
|
833
|
+
maxAttempts: row.max_attempts,
|
|
834
|
+
lastError: row.last_error,
|
|
835
|
+
createdAt: row.created_at
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
function errorText(error) {
|
|
839
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
840
|
+
return text.slice(0, MAX_ERROR_LENGTH);
|
|
841
|
+
}
|
|
842
|
+
function filterSql(filter) {
|
|
843
|
+
const parts = [];
|
|
844
|
+
const args = [];
|
|
845
|
+
if (filter.group != null) {
|
|
846
|
+
const groups = asList(filter.group);
|
|
847
|
+
parts.push(groups.length ? `grp IN (${placeholders(groups.length)})` : "0");
|
|
848
|
+
args.push(...groups);
|
|
849
|
+
}
|
|
850
|
+
if (filter.groupNotIn?.length) {
|
|
851
|
+
parts.push(`(grp IS NULL OR grp NOT IN (${placeholders(filter.groupNotIn.length)}))`);
|
|
852
|
+
args.push(...filter.groupNotIn);
|
|
853
|
+
}
|
|
854
|
+
if (filter.key != null) {
|
|
855
|
+
const keys = asList(filter.key);
|
|
856
|
+
parts.push(keys.length ? `key IN (${placeholders(keys.length)})` : "0");
|
|
857
|
+
args.push(...keys);
|
|
858
|
+
}
|
|
859
|
+
return { sql: parts.length ? ` AND ${parts.join(" AND ")}` : "", args };
|
|
860
|
+
}
|
|
861
|
+
var Queue = class {
|
|
862
|
+
constructor(client, name, options, run) {
|
|
863
|
+
this.client = client;
|
|
864
|
+
this.name = name;
|
|
865
|
+
this.options = options;
|
|
866
|
+
this.run = run;
|
|
867
|
+
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
868
|
+
this.leaseMs = options.lease == null ? DEFAULT_LEASE_MS : toMs(options.lease, "queue lease");
|
|
869
|
+
if (!Number.isInteger(this.maxAttempts) || this.maxAttempts < 1) {
|
|
870
|
+
throw new Error(`Invalid maxAttempts for queue "${name}": expected a whole number above 0`);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
maxAttempts;
|
|
874
|
+
leaseMs;
|
|
875
|
+
/**
|
|
876
|
+
* The INSERT for one job, to run in your own `batch` beside the row the job
|
|
877
|
+
* is about. A key already in the queue makes it a no-op.
|
|
878
|
+
*/
|
|
879
|
+
addStatement(job) {
|
|
880
|
+
const now = Date.now();
|
|
881
|
+
return {
|
|
882
|
+
sql: `INSERT INTO _ngn_jobs
|
|
883
|
+
(queue, key, grp, payload, priority, status, attempts, max_attempts, run_after, created_at, updated_at)
|
|
884
|
+
VALUES (?, ?, ?, ?, ?, 'ready', 0, ?, ?, ?, ?)
|
|
885
|
+
ON CONFLICT (queue, key) DO NOTHING`,
|
|
886
|
+
args: [
|
|
887
|
+
this.name,
|
|
888
|
+
job.key ?? null,
|
|
889
|
+
job.group ?? null,
|
|
890
|
+
job.payload === void 0 ? null : JSON.stringify(job.payload),
|
|
891
|
+
job.priority ?? 0,
|
|
892
|
+
job.maxAttempts ?? this.maxAttempts,
|
|
893
|
+
now + (job.delay == null ? 0 : toMs(job.delay, "job delay")),
|
|
894
|
+
now,
|
|
895
|
+
now
|
|
896
|
+
]
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
/** Add one job or several. Returns how many were new; the rest had keys already queued. */
|
|
900
|
+
async add(jobs) {
|
|
901
|
+
const list = Array.isArray(jobs) ? jobs : [jobs];
|
|
902
|
+
if (list.length === 0) return 0;
|
|
903
|
+
const results = await this.client.batch(
|
|
904
|
+
list.map((job) => this.addStatement(job)),
|
|
905
|
+
"write"
|
|
906
|
+
);
|
|
907
|
+
return results.reduce((sum, result) => sum + result.rowsAffected, 0);
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Take up to `limit` jobs that are due, counting an attempt on each and
|
|
911
|
+
* leasing them for the queue's `lease`.
|
|
912
|
+
*
|
|
913
|
+
* With groups, the jobs are drawn a group at a time: every group's first job
|
|
914
|
+
* before any group's second, groups in a fresh random order each claim. So
|
|
915
|
+
* a claim spreads over as many groups as it can, and when `limit` cuts a
|
|
916
|
+
* round short it is not always the same groups left out.
|
|
917
|
+
*/
|
|
918
|
+
async claim({ limit, perGroup, skipGroups = [], skipIds = [] }) {
|
|
919
|
+
if (limit <= 0) return [];
|
|
920
|
+
const now = Date.now();
|
|
921
|
+
const skip = skipGroups.length ? `AND (grp IS NULL OR grp NOT IN (${placeholders(skipGroups.length)}))` : "";
|
|
922
|
+
const [, claimed] = await this.client.batch(
|
|
923
|
+
[
|
|
924
|
+
this.retireStatement(now),
|
|
925
|
+
{
|
|
926
|
+
sql: `UPDATE _ngn_jobs
|
|
927
|
+
SET status = 'leased', lease_until = ?, attempts = attempts + 1, updated_at = ?
|
|
928
|
+
WHERE id IN (
|
|
929
|
+
SELECT id FROM (
|
|
930
|
+
SELECT id, grp, priority, run_after,
|
|
931
|
+
ROW_NUMBER() OVER (
|
|
932
|
+
PARTITION BY COALESCE(grp, 'id:' || id)
|
|
933
|
+
ORDER BY priority DESC, run_after, id
|
|
934
|
+
) AS turn
|
|
935
|
+
FROM _ngn_jobs
|
|
936
|
+
WHERE queue = ?
|
|
937
|
+
AND attempts < max_attempts
|
|
938
|
+
AND ((status = 'ready' AND run_after <= ?)
|
|
939
|
+
OR (status = 'leased' AND lease_until <= ?))
|
|
940
|
+
${skip}
|
|
941
|
+
AND id NOT IN (SELECT value FROM json_each(?))
|
|
942
|
+
)
|
|
943
|
+
WHERE turn <= ?
|
|
944
|
+
ORDER BY turn, CASE WHEN grp IS NULL THEN NULL ELSE random() END,
|
|
945
|
+
priority DESC, run_after, id
|
|
946
|
+
LIMIT ?
|
|
947
|
+
)
|
|
948
|
+
RETURNING *`,
|
|
949
|
+
args: [
|
|
950
|
+
now + this.leaseMs,
|
|
951
|
+
now,
|
|
952
|
+
this.name,
|
|
953
|
+
now,
|
|
954
|
+
now,
|
|
955
|
+
...skipGroups,
|
|
956
|
+
// One JSON parameter however many ids, rather than one per id.
|
|
957
|
+
JSON.stringify(skipIds),
|
|
958
|
+
perGroup ?? Number.MAX_SAFE_INTEGER,
|
|
959
|
+
limit
|
|
960
|
+
]
|
|
961
|
+
}
|
|
962
|
+
],
|
|
963
|
+
"write"
|
|
964
|
+
);
|
|
965
|
+
return claimed.rows.toSorted((a, b) => b.priority - a.priority || a.run_after - b.run_after || a.id - b.id).map((row) => toJob(row));
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Jobs whose lease ran out on their last attempt: no claim will take them
|
|
969
|
+
* again, so they are marked `dead` rather than left looking leased forever.
|
|
970
|
+
*/
|
|
971
|
+
retireStatement(now) {
|
|
972
|
+
return {
|
|
973
|
+
sql: `UPDATE _ngn_jobs
|
|
974
|
+
SET status = 'dead', lease_until = NULL, updated_at = ?,
|
|
975
|
+
last_error = COALESCE(last_error, 'lease expired')
|
|
976
|
+
WHERE queue = ? AND status = 'leased' AND lease_until <= ? AND attempts >= max_attempts`,
|
|
977
|
+
args: [now, this.name, now]
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Settle a claimed job, if the claim is still ours: same job, same attempt,
|
|
982
|
+
* still leased. False when the lease expired and the job moved on.
|
|
983
|
+
*/
|
|
984
|
+
async settle(job, set, args) {
|
|
985
|
+
const { rowsAffected } = await this.client.execute({
|
|
986
|
+
sql: `UPDATE _ngn_jobs SET ${set}, updated_at = ?
|
|
987
|
+
WHERE id = ? AND queue = ? AND status = 'leased' AND attempts = ?`,
|
|
988
|
+
args: [...args, Date.now(), job.id, this.name, job.attempts]
|
|
989
|
+
});
|
|
990
|
+
return rowsAffected > 0;
|
|
991
|
+
}
|
|
992
|
+
/** The job is finished. */
|
|
993
|
+
complete(job) {
|
|
994
|
+
return this.settle(job, "status = 'done', lease_until = NULL, last_error = NULL", []);
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* The attempt failed. The job backs off and is tried again, or is `dead`
|
|
998
|
+
* once it has had all its attempts. `delay` overrides the backoff.
|
|
999
|
+
*/
|
|
1000
|
+
fail(job, error, { delay } = {}) {
|
|
1001
|
+
const runAfter = Date.now() + (delay == null ? this.backoffMs(job.attempts) : toMs(delay));
|
|
1002
|
+
return this.settle(
|
|
1003
|
+
job,
|
|
1004
|
+
`status = CASE WHEN attempts >= max_attempts THEN 'dead' ELSE 'ready' END,
|
|
1005
|
+
lease_until = NULL, last_error = ?, run_after = ?`,
|
|
1006
|
+
[errorText(error), runAfter]
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Hand a claimed job back unworked. By default the attempt is refunded: for
|
|
1011
|
+
* a job that never got a fair try, such as a site that walled the run.
|
|
1012
|
+
*/
|
|
1013
|
+
release(job, { delay, refund = true } = {}) {
|
|
1014
|
+
const runAfter = Date.now() + (delay == null ? 0 : toMs(delay));
|
|
1015
|
+
return this.settle(
|
|
1016
|
+
job,
|
|
1017
|
+
`status = 'ready', lease_until = NULL, run_after = ?,
|
|
1018
|
+
attempts = ${refund ? "MAX(attempts - 1, 0)" : "attempts"}`,
|
|
1019
|
+
[runAfter]
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
/** How long a job that just failed its `attempts`th attempt waits. */
|
|
1023
|
+
backoffMs(attempts) {
|
|
1024
|
+
const { backoff = "next-tick" } = this.options;
|
|
1025
|
+
if (backoff === "next-tick") {
|
|
1026
|
+
const { nextAt } = this.run;
|
|
1027
|
+
return nextAt == null ? 0 : Math.max(0, nextAt - Date.now());
|
|
1028
|
+
}
|
|
1029
|
+
if (typeof backoff === "object") {
|
|
1030
|
+
const base = toMs(backoff.base, "backoff base");
|
|
1031
|
+
const max = backoff.max == null ? Number.POSITIVE_INFINITY : toMs(backoff.max, "backoff max");
|
|
1032
|
+
return Math.min(max, base * 2 ** Math.max(0, attempts - 1));
|
|
1033
|
+
}
|
|
1034
|
+
return toMs(backoff, "backoff");
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Set ready jobs aside, e.g. ones no handler can read yet. Parked jobs are
|
|
1038
|
+
* never claimed until `reopen`. Returns how many were parked.
|
|
1039
|
+
*/
|
|
1040
|
+
async park(filter, reason) {
|
|
1041
|
+
const where = filterSql(filter);
|
|
1042
|
+
const { rowsAffected } = await this.client.execute({
|
|
1043
|
+
sql: `UPDATE _ngn_jobs SET status = 'parked', last_error = COALESCE(?, last_error), updated_at = ?
|
|
1044
|
+
WHERE queue = ? AND status = 'ready'${where.sql}`,
|
|
1045
|
+
args: [reason ?? null, Date.now(), this.name, ...where.args]
|
|
1046
|
+
});
|
|
1047
|
+
return rowsAffected;
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Make jobs ready again with their attempts reset: parked ones by default,
|
|
1051
|
+
* or any of `from`, e.g. `["dead"]` to retry what gave up.
|
|
1052
|
+
*/
|
|
1053
|
+
async reopen(filter = {}, { from = ["parked"] } = {}) {
|
|
1054
|
+
if (from.length === 0) return 0;
|
|
1055
|
+
const where = filterSql(filter);
|
|
1056
|
+
const now = Date.now();
|
|
1057
|
+
const { rowsAffected } = await this.client.execute({
|
|
1058
|
+
sql: `UPDATE _ngn_jobs SET status = 'ready', attempts = 0, run_after = ?, updated_at = ?
|
|
1059
|
+
WHERE queue = ? AND status IN (${placeholders(from.length)})${where.sql}`,
|
|
1060
|
+
args: [now, now, this.name, ...from, ...where.args]
|
|
1061
|
+
});
|
|
1062
|
+
return rowsAffected;
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Delete finished jobs last touched more than `olderThan` ago, `done` ones by
|
|
1066
|
+
* default. A pruned key can be added again.
|
|
1067
|
+
*/
|
|
1068
|
+
async prune({
|
|
1069
|
+
olderThan,
|
|
1070
|
+
status = ["done"]
|
|
1071
|
+
}) {
|
|
1072
|
+
if (status.length === 0) return 0;
|
|
1073
|
+
const { rowsAffected } = await this.client.execute({
|
|
1074
|
+
sql: `DELETE FROM _ngn_jobs
|
|
1075
|
+
WHERE queue = ? AND status IN (${placeholders(status.length)}) AND updated_at < ?`,
|
|
1076
|
+
args: [this.name, ...status, Date.now() - toMs(olderThan, "prune age")]
|
|
1077
|
+
});
|
|
1078
|
+
return rowsAffected;
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Work through the queue until it is empty, `max` is reached or the run's
|
|
1082
|
+
* deadline passes, `concurrency` jobs at a time and never two of one group
|
|
1083
|
+
* at once. A handler that returns completes its job; one that throws fails
|
|
1084
|
+
* it. Whatever is left waits for the next run.
|
|
1085
|
+
*/
|
|
1086
|
+
// NoInfer: the resource type comes from `worker.setup`, not from the handler's parameter.
|
|
1087
|
+
work(handler, options = {}) {
|
|
1088
|
+
return work(this, handler, { until: this.run.signal, ...options });
|
|
1089
|
+
}
|
|
1090
|
+
/** The job with `key`, in whatever state it is in. */
|
|
1091
|
+
async get(key) {
|
|
1092
|
+
const { rows } = await this.client.execute({
|
|
1093
|
+
sql: "SELECT * FROM _ngn_jobs WHERE queue = ? AND key = ?",
|
|
1094
|
+
args: [this.name, key]
|
|
1095
|
+
});
|
|
1096
|
+
const row = rows[0];
|
|
1097
|
+
return row ? { ...toJob(row), status: row.status } : null;
|
|
1098
|
+
}
|
|
1099
|
+
async stats() {
|
|
1100
|
+
const now = Date.now();
|
|
1101
|
+
const { rows } = await this.client.execute({
|
|
1102
|
+
sql: `SELECT
|
|
1103
|
+
COUNT(*) FILTER (WHERE status = 'ready') AS ready,
|
|
1104
|
+
COUNT(*) FILTER (WHERE status = 'ready' AND run_after <= ?) AS due,
|
|
1105
|
+
COUNT(*) FILTER (WHERE status = 'leased') AS leased,
|
|
1106
|
+
COUNT(*) FILTER (WHERE status = 'done') AS done,
|
|
1107
|
+
COUNT(*) FILTER (WHERE status = 'dead') AS dead,
|
|
1108
|
+
COUNT(*) FILTER (WHERE status = 'parked') AS parked,
|
|
1109
|
+
MIN(created_at) FILTER (WHERE status = 'ready') AS oldest
|
|
1110
|
+
FROM _ngn_jobs WHERE queue = ?`,
|
|
1111
|
+
args: [now, this.name]
|
|
1112
|
+
});
|
|
1113
|
+
const row = rows[0];
|
|
1114
|
+
return {
|
|
1115
|
+
ready: Number(row.ready),
|
|
1116
|
+
due: Number(row.due),
|
|
1117
|
+
leased: Number(row.leased),
|
|
1118
|
+
done: Number(row.done),
|
|
1119
|
+
dead: Number(row.dead),
|
|
1120
|
+
parked: Number(row.parked),
|
|
1121
|
+
oldestReadyAgeMs: row.oldest == null ? null : now - Number(row.oldest)
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
async function openQueue(client, name, options, run) {
|
|
1126
|
+
if (typeof name !== "string" || !name.trim()) {
|
|
1127
|
+
throw new Error(`A queue name must be a non-empty string, got ${JSON.stringify(name)}`);
|
|
1128
|
+
}
|
|
1129
|
+
await runMigrations(client, QUEUE_MIGRATIONS);
|
|
1130
|
+
return new Queue(client, name, options, run);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// src/runtime/retry.ts
|
|
1134
|
+
var DEFAULT_MAX_SKIP = 32;
|
|
1135
|
+
var isCount = (value) => typeof value === "number" && Number.isInteger(value) && value >= 1;
|
|
1136
|
+
function resolveRetry(value, taskPath) {
|
|
1137
|
+
if (value == null) return { attempts: 1, delayMs: 0, breaker: null };
|
|
1138
|
+
const fail = (what) => {
|
|
1139
|
+
throw new Error(`Invalid retry for ${taskPath}: ${what}`);
|
|
1140
|
+
};
|
|
1141
|
+
if (typeof value !== "object") fail('expected an object like { attempts: 3, delay: "30s" }');
|
|
1142
|
+
const { attempts = 1, delay = 0, breaker } = value;
|
|
1143
|
+
if (!isCount(attempts)) fail("attempts must be a whole number above 0");
|
|
1144
|
+
if (breaker != null) {
|
|
1145
|
+
if (!isCount(breaker.after)) fail("breaker.after must be a whole number above 0");
|
|
1146
|
+
if (breaker.maxSkip != null && !isCount(breaker.maxSkip)) {
|
|
1147
|
+
fail("breaker.maxSkip must be a whole number above 0");
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
return {
|
|
1151
|
+
attempts,
|
|
1152
|
+
delayMs: toMs(delay, `retry delay for ${taskPath}`),
|
|
1153
|
+
breaker: breaker ? { after: breaker.after, maxSkip: breaker.maxSkip ?? DEFAULT_MAX_SKIP } : null
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
function retryFits({
|
|
1157
|
+
attempt,
|
|
1158
|
+
retry,
|
|
1159
|
+
nextAt,
|
|
1160
|
+
now = Date.now()
|
|
1161
|
+
}) {
|
|
1162
|
+
if (attempt >= retry.attempts) return false;
|
|
1163
|
+
return nextAt == null || now + retry.delayMs < nextAt;
|
|
1164
|
+
}
|
|
1165
|
+
var BREAKER_SKIP_REASON = "circuit open after repeated failures";
|
|
1166
|
+
function breakerSkips(failedInARow, breaker) {
|
|
1167
|
+
if (!breaker || failedInARow < breaker.after) return 0;
|
|
1168
|
+
return Math.min(breaker.maxSkip, 2 ** (failedInARow - breaker.after));
|
|
1169
|
+
}
|
|
1170
|
+
|
|
345
1171
|
// src/runtime/task.ts
|
|
1172
|
+
var sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
346
1173
|
var Task = class {
|
|
347
1174
|
taskCallbacks;
|
|
348
|
-
|
|
1175
|
+
startRun;
|
|
1176
|
+
history;
|
|
349
1177
|
env;
|
|
350
1178
|
kv;
|
|
351
|
-
log;
|
|
352
|
-
timing;
|
|
353
1179
|
sqlite;
|
|
354
1180
|
meta;
|
|
355
1181
|
entry = null;
|
|
356
1182
|
entryExports = null;
|
|
1183
|
+
/** Executions started and not yet settled. */
|
|
1184
|
+
inFlight = 0;
|
|
357
1185
|
constructor({
|
|
358
1186
|
entryParams,
|
|
359
1187
|
buildConfigFn
|
|
@@ -362,12 +1190,13 @@ var Task = class {
|
|
|
362
1190
|
this.entry = new Entry(entryParams, {
|
|
363
1191
|
onTaskCodeLoaded: this.taskCallbacks?.onTaskCodeLoaded
|
|
364
1192
|
});
|
|
365
|
-
const { taskCallbacks,
|
|
1193
|
+
const { taskCallbacks, startRun, history, env, kv, sqlite } = buildConfigFn({
|
|
366
1194
|
sourcePath: this.entry.paths.source,
|
|
367
1195
|
compiledPath: this.entry.paths.compiled
|
|
368
1196
|
});
|
|
369
1197
|
this.taskCallbacks = taskCallbacks;
|
|
370
|
-
this.
|
|
1198
|
+
this.startRun = startRun;
|
|
1199
|
+
this.history = history;
|
|
371
1200
|
this.sqlite = sqlite;
|
|
372
1201
|
this.meta = {
|
|
373
1202
|
fileTaskId: -1,
|
|
@@ -377,8 +1206,6 @@ var Task = class {
|
|
|
377
1206
|
};
|
|
378
1207
|
this.env = env;
|
|
379
1208
|
this.kv = kv;
|
|
380
|
-
this.log = log;
|
|
381
|
-
this.timing = timing;
|
|
382
1209
|
}
|
|
383
1210
|
get sourcePath() {
|
|
384
1211
|
invariant5(this.entry, "Entry not initialized.");
|
|
@@ -403,6 +1230,45 @@ var Task = class {
|
|
|
403
1230
|
invariant5(this.entryExports, "Entry exports not initialized.");
|
|
404
1231
|
return this.entryExports.timing;
|
|
405
1232
|
}
|
|
1233
|
+
/**
|
|
1234
|
+
* The task's `overlap` export, `"skip"` when it has none. Throws on anything
|
|
1235
|
+
* else, naming the task, so a typo fails at schedule time.
|
|
1236
|
+
*/
|
|
1237
|
+
get overlapPolicy() {
|
|
1238
|
+
invariant5(this.entryExports, "Entry exports not initialized.");
|
|
1239
|
+
const { overlap = "skip" } = this.entryExports;
|
|
1240
|
+
invariant5(
|
|
1241
|
+
overlap === "skip" || overlap === "allow",
|
|
1242
|
+
`Invalid overlap "${String(overlap)}" for ${this.taskPath}: expected "skip" or "allow"`
|
|
1243
|
+
);
|
|
1244
|
+
return overlap;
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Check the exports that shape how the task is scheduled, so a bad value
|
|
1248
|
+
* fails when the task is scheduled rather than on its first tick.
|
|
1249
|
+
*/
|
|
1250
|
+
validateExports() {
|
|
1251
|
+
invariant5(this.entryExports, "Entry exports not initialized.");
|
|
1252
|
+
void this.overlapPolicy;
|
|
1253
|
+
const { budget, exclusive, retry } = this.entryExports;
|
|
1254
|
+
if (budget != null) toMs(budget, `budget for ${this.taskPath}`);
|
|
1255
|
+
exclusiveNames(exclusive, this.taskPath);
|
|
1256
|
+
resolveRetry(retry, this.taskPath);
|
|
1257
|
+
}
|
|
1258
|
+
/** Whether an execution of this task is in progress. */
|
|
1259
|
+
get isRunning() {
|
|
1260
|
+
return this.inFlight > 0;
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Record a run that ngn decided not to start, without loading or calling any
|
|
1264
|
+
* of the task's code. Needs the task to have been registered by an earlier
|
|
1265
|
+
* execution, which is always so for a run skipped because one is in progress.
|
|
1266
|
+
*/
|
|
1267
|
+
async skip(reason) {
|
|
1268
|
+
invariant5(this.entry, "Entry not initialized.");
|
|
1269
|
+
const run = await this.startRun();
|
|
1270
|
+
await run.callbacks.onSkip?.({ path: this.entry.paths.relativeEntry, reason });
|
|
1271
|
+
}
|
|
406
1272
|
hasTaskExport() {
|
|
407
1273
|
return this.entryExports?.task !== void 0;
|
|
408
1274
|
}
|
|
@@ -416,68 +1282,174 @@ var Task = class {
|
|
|
416
1282
|
}
|
|
417
1283
|
async loadAndRegister() {
|
|
418
1284
|
invariant5(this.entry, "Entry not initialized.");
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
this.meta.fileTaskVersionId = loadResult?.fileTaskVersionId ?? -1;
|
|
431
|
-
} catch (error) {
|
|
432
|
-
if (this.entry.paths.relativeEntry) {
|
|
433
|
-
await this.runtimeCallbacks?.onFailure?.({
|
|
434
|
-
path: this.entry.paths.relativeEntry
|
|
435
|
-
});
|
|
436
|
-
}
|
|
437
|
-
throw error;
|
|
438
|
-
}
|
|
1285
|
+
await this.loadEntry();
|
|
1286
|
+
invariant5(this.entry.isLoaded(), "Entry failed to load.");
|
|
1287
|
+
invariant5(this.entryExports, "Entry failed to get exports.");
|
|
1288
|
+
this.taskCallbacks?.onTaskExportsLoaded?.();
|
|
1289
|
+
const loadResult = await this.taskCallbacks?.onTaskLoadComplete?.({
|
|
1290
|
+
paths: this.entry.paths,
|
|
1291
|
+
md5Hash: this.entry.codeHash,
|
|
1292
|
+
compiledCode: this.entry.code
|
|
1293
|
+
});
|
|
1294
|
+
this.meta.fileTaskId = loadResult?.fileTaskId ?? -1;
|
|
1295
|
+
this.meta.fileTaskVersionId = loadResult?.fileTaskVersionId ?? -1;
|
|
439
1296
|
}
|
|
440
|
-
async runTask(task, taskContext, hooks) {
|
|
1297
|
+
async runTask(task, taskContext, hooks, run) {
|
|
441
1298
|
invariant5(this.entry, "Entry not initialized.");
|
|
442
1299
|
const path = this.entry.paths.relativeEntry;
|
|
443
1300
|
try {
|
|
444
|
-
await
|
|
1301
|
+
await run.callbacks.onStart?.({ path });
|
|
445
1302
|
const taskReturnValue = await task(taskContext);
|
|
446
|
-
await
|
|
1303
|
+
await run.callbacks.onSuccess?.({ path, result: taskReturnValue });
|
|
447
1304
|
await hooks.onSuccess?.(taskContext);
|
|
448
1305
|
return taskReturnValue;
|
|
449
1306
|
} catch (err) {
|
|
450
|
-
await
|
|
1307
|
+
await run.callbacks.onFailure?.({ path });
|
|
451
1308
|
await hooks.onError?.(err, taskContext);
|
|
452
1309
|
throw err;
|
|
453
1310
|
} finally {
|
|
454
1311
|
await hooks.onComplete?.(taskContext);
|
|
455
1312
|
}
|
|
456
1313
|
}
|
|
457
|
-
|
|
1314
|
+
/**
|
|
1315
|
+
* Run the task for one tick, retrying as its `retry` export allows. A
|
|
1316
|
+
* scheduled tick passes when it fired and when the next one is due, which
|
|
1317
|
+
* `ctx.run.deadline` and the retry window are worked out from.
|
|
1318
|
+
*/
|
|
1319
|
+
async execute(schedule = {}) {
|
|
1320
|
+
this.inFlight++;
|
|
1321
|
+
try {
|
|
1322
|
+
return await this.executeWithRetries(schedule);
|
|
1323
|
+
} finally {
|
|
1324
|
+
this.inFlight--;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
async executeWithRetries(schedule) {
|
|
1328
|
+
const tick2 = {
|
|
1329
|
+
firstRunId: null,
|
|
1330
|
+
retry: resolveRetry(void 0, this.taskPath),
|
|
1331
|
+
failedContext: null
|
|
1332
|
+
};
|
|
1333
|
+
for (let attempt = 1; ; attempt++) {
|
|
1334
|
+
tick2.failedContext = null;
|
|
1335
|
+
try {
|
|
1336
|
+
return await this.executeOnce(schedule, tick2);
|
|
1337
|
+
} catch (error) {
|
|
1338
|
+
if (!await this.shouldRetry(error, attempt, schedule, tick2)) throw error;
|
|
1339
|
+
await sleep2(tick2.retry.delayMs);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Retry only a run that got as far as running: a module that will not load
|
|
1345
|
+
* fails the same way every time. Then it has to fit before the next tick,
|
|
1346
|
+
* and the task's `shouldRetry` gets the last word.
|
|
1347
|
+
*/
|
|
1348
|
+
async shouldRetry(error, attempt, { nextAt = null }, tick2) {
|
|
1349
|
+
if (!tick2.failedContext || !retryFits({ attempt, retry: tick2.retry, nextAt })) return false;
|
|
1350
|
+
const { shouldRetry } = this.entryExports ?? {};
|
|
1351
|
+
return shouldRetry ? await shouldRetry(error, tick2.failedContext) !== false : true;
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* Whether the breaker holds this tick back: after `after` failed ticks in a
|
|
1355
|
+
* row it skips 1, 2, 4… ticks (counted since the latest failure), letting
|
|
1356
|
+
* one through in between to see whether things have recovered.
|
|
1357
|
+
*/
|
|
1358
|
+
async breakerOpen(retry) {
|
|
1359
|
+
if (!retry.breaker) return false;
|
|
1360
|
+
const outcomes = await this.history.recentTickOutcomes(retry.breaker.after + 64);
|
|
1361
|
+
const failedInARow = outcomes.findIndex((tick2) => tick2.outcome !== "failure");
|
|
1362
|
+
const wanted = breakerSkips(
|
|
1363
|
+
failedInARow === -1 ? outcomes.length : failedInARow,
|
|
1364
|
+
retry.breaker
|
|
1365
|
+
);
|
|
1366
|
+
if (wanted === 0) return false;
|
|
1367
|
+
return await this.history.countSkipsSince(BREAKER_SKIP_REASON, outcomes[0].id) < wanted;
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1370
|
+
* Why ngn will not start this run, or null. Checked in order: the breaker
|
|
1371
|
+
* (scheduled first attempts only), then the `exclusive` locks, which are
|
|
1372
|
+
* left held on success.
|
|
1373
|
+
*/
|
|
1374
|
+
async holdBack(run, {
|
|
1375
|
+
firstAttempt,
|
|
1376
|
+
scheduled,
|
|
1377
|
+
exclusiveLocks,
|
|
1378
|
+
retry
|
|
1379
|
+
}) {
|
|
1380
|
+
if (firstAttempt && scheduled && await this.breakerOpen(retry)) {
|
|
1381
|
+
return { reason: BREAKER_SKIP_REASON };
|
|
1382
|
+
}
|
|
1383
|
+
if (!exclusiveLocks.length) return { held: null };
|
|
1384
|
+
const held = await run.locks.holdAll(exclusiveLocks);
|
|
1385
|
+
return "busy" in held ? { reason: `lock "${held.busy}" is held` } : { held };
|
|
1386
|
+
}
|
|
1387
|
+
buildContext(run, meta, window, signal) {
|
|
1388
|
+
const { scheduledAt, nextAt, deadline } = window;
|
|
1389
|
+
return {
|
|
1390
|
+
env: this.env,
|
|
1391
|
+
meta,
|
|
1392
|
+
kv: this.kv,
|
|
1393
|
+
log: run.log,
|
|
1394
|
+
timing: run.timing,
|
|
1395
|
+
sqlite: this.sqlite,
|
|
1396
|
+
run: { id: run.id, scheduledAt, nextAt, deadline, signal },
|
|
1397
|
+
lock: (name, fn, options = {}) => run.locks.lock(name, fn, options, deadline),
|
|
1398
|
+
queue: (name, options = {}) => openQueue(options.db?.client ?? this.sqlite.client, name, options, { nextAt, signal })
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
async executeOnce({ scheduledAt = null, nextAt = null }, tick2) {
|
|
458
1402
|
invariant5(this.entry, "Entry not initialized.");
|
|
459
1403
|
await this.loadAndRegister();
|
|
460
1404
|
invariant5(this.entryExports, "Entry failed to get exports.");
|
|
461
|
-
|
|
462
|
-
const
|
|
1405
|
+
const exports = this.entryExports;
|
|
1406
|
+
const meta = { ...this.meta };
|
|
463
1407
|
const path = this.entry.paths.relativeEntry;
|
|
464
|
-
|
|
465
|
-
|
|
1408
|
+
const budgetMs = exports.budget == null ? null : toMs(exports.budget, `budget for ${path}`);
|
|
1409
|
+
const exclusiveLocks = exclusiveNames(exports.exclusive, path);
|
|
1410
|
+
const retry = resolveRetry(exports.retry, path);
|
|
1411
|
+
tick2.retry = retry;
|
|
1412
|
+
const run = await this.startRun({ retryOf: tick2.firstRunId });
|
|
1413
|
+
const firstAttempt = tick2.firstRunId == null;
|
|
1414
|
+
tick2.firstRunId ??= run.id;
|
|
1415
|
+
if (!exports.task) {
|
|
1416
|
+
await run.callbacks.onNotFound?.({ path });
|
|
466
1417
|
throw new Error("Task function not found");
|
|
467
1418
|
}
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
1419
|
+
const deadline = runDeadline({ startedAt: Date.now(), scheduledAt, nextAt, budgetMs });
|
|
1420
|
+
const { signal, dispose } = deadlineSignal(deadline);
|
|
1421
|
+
const taskContext = this.buildContext(run, meta, { scheduledAt, nextAt, deadline }, signal);
|
|
1422
|
+
let exclusiveHeld = null;
|
|
1423
|
+
try {
|
|
1424
|
+
const gate = await this.holdBack(run, {
|
|
1425
|
+
firstAttempt,
|
|
1426
|
+
scheduled: scheduledAt != null,
|
|
1427
|
+
exclusiveLocks,
|
|
1428
|
+
retry
|
|
1429
|
+
});
|
|
1430
|
+
if ("reason" in gate) {
|
|
1431
|
+
await run.callbacks.onSkip?.({ path, reason: gate.reason });
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
exclusiveHeld = gate.held;
|
|
1435
|
+
if (await exports.shouldSkip?.(taskContext)) {
|
|
1436
|
+
await run.callbacks.onSkip?.({ path });
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
tick2.failedContext = taskContext;
|
|
1440
|
+
const { onSuccess, onError, onComplete } = exports;
|
|
1441
|
+
const value = await this.runTask(
|
|
1442
|
+
exports.task,
|
|
1443
|
+
taskContext,
|
|
1444
|
+
{ onSuccess, onError, onComplete },
|
|
1445
|
+
run
|
|
1446
|
+
);
|
|
1447
|
+
tick2.failedContext = null;
|
|
1448
|
+
return value;
|
|
1449
|
+
} finally {
|
|
1450
|
+
dispose();
|
|
1451
|
+
await exclusiveHeld?.release();
|
|
479
1452
|
}
|
|
480
|
-
return this.runTask(task, taskContext, { onSuccess, onError, onComplete });
|
|
481
1453
|
}
|
|
482
1454
|
};
|
|
483
1455
|
|
|
@@ -539,9 +1511,46 @@ var TaskLibrary = class {
|
|
|
539
1511
|
};
|
|
540
1512
|
|
|
541
1513
|
// src/runtime/taskLibraryScheduler.ts
|
|
542
|
-
import
|
|
543
|
-
import invariant7 from "tiny-invariant";
|
|
1514
|
+
import invariant8 from "tiny-invariant";
|
|
544
1515
|
import { logTaskLibraryScheduler, logTaskLibrarySchedulerError } from "@apisurf/ngn-os";
|
|
1516
|
+
|
|
1517
|
+
// src/runtime/schedule.ts
|
|
1518
|
+
import { Cron } from "croner";
|
|
1519
|
+
import invariant7 from "tiny-invariant";
|
|
1520
|
+
var CRON_OPTIONS = { mode: "5-or-6-parts" };
|
|
1521
|
+
function isCronPatternValid(pattern) {
|
|
1522
|
+
try {
|
|
1523
|
+
new Cron(pattern, { ...CRON_OPTIONS, paused: true }).stop();
|
|
1524
|
+
return true;
|
|
1525
|
+
} catch {
|
|
1526
|
+
return false;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
async function tick(task, onError, schedule = {}) {
|
|
1530
|
+
try {
|
|
1531
|
+
if (task.isRunning && task.overlapPolicy === "skip") {
|
|
1532
|
+
await task.skip("previous run still in progress");
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
await task.execute(schedule);
|
|
1536
|
+
} catch (error) {
|
|
1537
|
+
onError(error);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
function scheduleTask(task, pattern, onError) {
|
|
1541
|
+
invariant7(isCronPatternValid(pattern), `Invalid cron pattern: ${pattern} for ${task.taskPath}`);
|
|
1542
|
+
task.validateExports();
|
|
1543
|
+
return new Cron(
|
|
1544
|
+
pattern,
|
|
1545
|
+
CRON_OPTIONS,
|
|
1546
|
+
(job) => tick(task, onError, {
|
|
1547
|
+
scheduledAt: job.currentRun()?.getTime() ?? Date.now(),
|
|
1548
|
+
nextAt: job.nextRun()?.getTime() ?? null
|
|
1549
|
+
})
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// src/runtime/taskLibraryScheduler.ts
|
|
545
1554
|
var TaskLibraryScheduler = class {
|
|
546
1555
|
taskLibrary;
|
|
547
1556
|
scheduledTasks = /* @__PURE__ */ new Map();
|
|
@@ -573,14 +1582,16 @@ var TaskLibraryScheduler = class {
|
|
|
573
1582
|
logTaskLibraryScheduler(`Scheduling task: ${task.taskPath}`);
|
|
574
1583
|
await task.loadEntry();
|
|
575
1584
|
logTaskLibraryScheduler(`Scheduling: ${task.taskPath} with pattern: ${task.timingPattern}`);
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
1585
|
+
invariant8(task.hasTimingExport(), `timing pattern is missing for ${task.taskPath}`);
|
|
1586
|
+
invariant8(task.hasTaskExport(), `task function is missing for ${task.taskPath}`);
|
|
1587
|
+
invariant8(
|
|
579
1588
|
task.timingPattern && this.isCronPatternValid(task.timingPattern),
|
|
580
1589
|
`Invalid cron pattern: ${task.timingPattern} for ${task.taskPath}`
|
|
581
1590
|
);
|
|
582
|
-
const
|
|
583
|
-
|
|
1591
|
+
const scheduledTask = scheduleTask(task, task.timingPattern, (error) => {
|
|
1592
|
+
logTaskLibrarySchedulerError(`Run failed: ${task.taskPath}`);
|
|
1593
|
+
logTaskLibrarySchedulerError(error);
|
|
1594
|
+
});
|
|
584
1595
|
this.scheduledTasks.set(task.taskPath, scheduledTask);
|
|
585
1596
|
logTaskLibraryScheduler(`Scheduled: ${task.taskPath}`);
|
|
586
1597
|
}
|
|
@@ -595,7 +1606,7 @@ var TaskLibraryScheduler = class {
|
|
|
595
1606
|
logTaskLibraryScheduler(`Unscheduled: ${task.taskPath}`);
|
|
596
1607
|
}
|
|
597
1608
|
isCronPatternValid(cronPattern) {
|
|
598
|
-
return
|
|
1609
|
+
return isCronPatternValid(cronPattern);
|
|
599
1610
|
}
|
|
600
1611
|
// startScheduledTask(path: string) {
|
|
601
1612
|
// const task = this.scheduledTasks.get(path);
|
|
@@ -614,7 +1625,7 @@ var TaskLibraryScheduler = class {
|
|
|
614
1625
|
};
|
|
615
1626
|
|
|
616
1627
|
// src/runtime/taskOnceRunner.ts
|
|
617
|
-
import
|
|
1628
|
+
import invariant9 from "tiny-invariant";
|
|
618
1629
|
import { logTaskOnceRunner, logTaskOnceRunnerError } from "@apisurf/ngn-os";
|
|
619
1630
|
var TaskOnceRunner = class {
|
|
620
1631
|
task;
|
|
@@ -625,7 +1636,7 @@ var TaskOnceRunner = class {
|
|
|
625
1636
|
logTaskOnceRunner(`Preparing to run task: ${this.task.taskPath}`);
|
|
626
1637
|
await this.task.loadEntry();
|
|
627
1638
|
logTaskOnceRunner(`Loaded task code: ${this.task.taskPath}`);
|
|
628
|
-
|
|
1639
|
+
invariant9(this.task.hasTaskExport(), `task function is missing for ${this.task.taskPath}`);
|
|
629
1640
|
logTaskOnceRunner(`Running task: ${this.task.taskPath}`);
|
|
630
1641
|
try {
|
|
631
1642
|
await this.task.execute();
|
|
@@ -644,10 +1655,10 @@ import {
|
|
|
644
1655
|
initDbFileIfNotExists,
|
|
645
1656
|
isDbPath
|
|
646
1657
|
} from "@apisurf/ngn-persistence";
|
|
647
|
-
import
|
|
1658
|
+
import invariant10 from "tiny-invariant";
|
|
648
1659
|
var db;
|
|
649
1660
|
async function setupDbClient(dbPath) {
|
|
650
|
-
|
|
1661
|
+
invariant10(isDbPath(dbPath), "Invalid db path");
|
|
651
1662
|
if (db) {
|
|
652
1663
|
return db;
|
|
653
1664
|
}
|
|
@@ -655,7 +1666,7 @@ async function setupDbClient(dbPath) {
|
|
|
655
1666
|
return db;
|
|
656
1667
|
}
|
|
657
1668
|
function getDbClient() {
|
|
658
|
-
|
|
1669
|
+
invariant10(db, "DB client not initialized");
|
|
659
1670
|
return db;
|
|
660
1671
|
}
|
|
661
1672
|
|
|
@@ -666,114 +1677,7 @@ import {
|
|
|
666
1677
|
logTaskConsoleInfo,
|
|
667
1678
|
logTaskConsoleWarn
|
|
668
1679
|
} from "@apisurf/ngn-os";
|
|
669
|
-
import
|
|
670
|
-
|
|
671
|
-
// src/runtime/taskSqlite.ts
|
|
672
|
-
import { mkdirSync } from "node:fs";
|
|
673
|
-
import { unlink } from "node:fs/promises";
|
|
674
|
-
import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
|
|
675
|
-
import {
|
|
676
|
-
openDbClient
|
|
677
|
-
} from "@apisurf/ngn-persistence";
|
|
678
|
-
var openClients = /* @__PURE__ */ new Map();
|
|
679
|
-
async function runMigrations(client, migrations) {
|
|
680
|
-
if (!migrations.length) return;
|
|
681
|
-
await client.execute(`
|
|
682
|
-
CREATE TABLE IF NOT EXISTS _ngn_migrations (
|
|
683
|
-
id TEXT PRIMARY KEY,
|
|
684
|
-
applied_at TEXT NOT NULL
|
|
685
|
-
)
|
|
686
|
-
`);
|
|
687
|
-
const applied = await client.execute("SELECT id FROM _ngn_migrations");
|
|
688
|
-
const appliedIds = new Set(applied.rows.map((r) => r.id));
|
|
689
|
-
for (const migration of migrations) {
|
|
690
|
-
if (appliedIds.has(migration.id)) continue;
|
|
691
|
-
await client.execute(migration.up);
|
|
692
|
-
await client.execute({
|
|
693
|
-
sql: "INSERT INTO _ngn_migrations (id, applied_at) VALUES (?, ?)",
|
|
694
|
-
args: [migration.id, (/* @__PURE__ */ new Date()).toISOString()]
|
|
695
|
-
});
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
function resolveTaskDbPath(taskDir, file) {
|
|
699
|
-
if (typeof file !== "string" || !file.trim()) {
|
|
700
|
-
throw new Error("A database file name is required");
|
|
701
|
-
}
|
|
702
|
-
const relativePath = file.startsWith("file:") ? file.slice(5) : file;
|
|
703
|
-
if (!relativePath) {
|
|
704
|
-
throw new Error("A database file name is required");
|
|
705
|
-
}
|
|
706
|
-
if (isAbsolute(relativePath)) {
|
|
707
|
-
throw new Error(
|
|
708
|
-
`Database file must be relative to the task folder, got an absolute path: "${file}"`
|
|
709
|
-
);
|
|
710
|
-
}
|
|
711
|
-
const resolved = resolve(taskDir, relativePath);
|
|
712
|
-
const taskDirRoot = taskDir.endsWith(sep) ? taskDir : `${taskDir}${sep}`;
|
|
713
|
-
if (!resolved.startsWith(taskDirRoot)) {
|
|
714
|
-
throw new Error(
|
|
715
|
-
`Database file "${file}" resolves outside the task folder (${taskDir}). A task can only manage databases in its own folder.`
|
|
716
|
-
);
|
|
717
|
-
}
|
|
718
|
-
return resolved;
|
|
719
|
-
}
|
|
720
|
-
function getClient(absolutePath) {
|
|
721
|
-
const existing = openClients.get(absolutePath);
|
|
722
|
-
if (existing) {
|
|
723
|
-
return existing;
|
|
724
|
-
}
|
|
725
|
-
mkdirSync(dirname(absolutePath), { recursive: true });
|
|
726
|
-
const client = openDbClient(`file:${absolutePath}`);
|
|
727
|
-
openClients.set(absolutePath, client);
|
|
728
|
-
return client;
|
|
729
|
-
}
|
|
730
|
-
function closeClient(absolutePath) {
|
|
731
|
-
const client = openClients.get(absolutePath);
|
|
732
|
-
if (client) {
|
|
733
|
-
client.close();
|
|
734
|
-
openClients.delete(absolutePath);
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
function createTaskSqlite(taskSourcePath) {
|
|
738
|
-
const taskDir = dirname(taskSourcePath);
|
|
739
|
-
const defaultDbPath = join(taskDir, `${basename(taskSourcePath, extname(taskSourcePath))}.db`);
|
|
740
|
-
const defaultClient = () => getClient(defaultDbPath);
|
|
741
|
-
return {
|
|
742
|
-
get client() {
|
|
743
|
-
return defaultClient();
|
|
744
|
-
},
|
|
745
|
-
get path() {
|
|
746
|
-
return defaultDbPath;
|
|
747
|
-
},
|
|
748
|
-
execute: (sql, args) => defaultClient().execute(args ? { sql, args } : sql),
|
|
749
|
-
batch: (statements) => defaultClient().batch(statements),
|
|
750
|
-
async initDB(options) {
|
|
751
|
-
const dbPath = resolveTaskDbPath(taskDir, options.file);
|
|
752
|
-
const client = getClient(dbPath);
|
|
753
|
-
if (options.migrations?.length) {
|
|
754
|
-
await runMigrations(client, options.migrations);
|
|
755
|
-
}
|
|
756
|
-
return {
|
|
757
|
-
client,
|
|
758
|
-
path: dbPath,
|
|
759
|
-
execute: (sql, args) => client.execute(args ? { sql, args } : sql),
|
|
760
|
-
batch: (statements) => client.batch(statements),
|
|
761
|
-
close: () => closeClient(dbPath)
|
|
762
|
-
};
|
|
763
|
-
},
|
|
764
|
-
async destroyDB(file) {
|
|
765
|
-
const dbPath = resolveTaskDbPath(taskDir, file);
|
|
766
|
-
closeClient(dbPath);
|
|
767
|
-
await unlink(dbPath);
|
|
768
|
-
}
|
|
769
|
-
};
|
|
770
|
-
}
|
|
771
|
-
function closeTaskDatabases() {
|
|
772
|
-
for (const client of openClients.values()) {
|
|
773
|
-
client.close();
|
|
774
|
-
}
|
|
775
|
-
openClients.clear();
|
|
776
|
-
}
|
|
1680
|
+
import invariant11 from "tiny-invariant";
|
|
777
1681
|
|
|
778
1682
|
// src/db/services/fileTask.ts
|
|
779
1683
|
import { $fileTaskInsert } from "@apisurf/ngn-schema";
|
|
@@ -933,25 +1837,67 @@ async function registerFileTask(dbClient, md5Hash, compiledCode, paths) {
|
|
|
933
1837
|
}
|
|
934
1838
|
|
|
935
1839
|
// src/db/services/taskRun.ts
|
|
1840
|
+
function serializeResult(value) {
|
|
1841
|
+
if (value === void 0) return null;
|
|
1842
|
+
try {
|
|
1843
|
+
return JSON.stringify(value) ?? null;
|
|
1844
|
+
} catch {
|
|
1845
|
+
try {
|
|
1846
|
+
return String(value);
|
|
1847
|
+
} catch {
|
|
1848
|
+
return null;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
936
1852
|
var TaskRunService = class {
|
|
937
1853
|
constructor(db2) {
|
|
938
1854
|
this.db = db2;
|
|
939
1855
|
}
|
|
940
1856
|
async initTaskRun(data) {
|
|
941
1857
|
const { rows } = await this.db.execute({
|
|
942
|
-
sql: "INSERT INTO task_runs (file_task_id, file_task_version_id, status) VALUES (:file_task_id, :file_task_version_id, 'pending') RETURNING id",
|
|
1858
|
+
sql: "INSERT INTO task_runs (file_task_id, file_task_version_id, status, retry_of) VALUES (:file_task_id, :file_task_version_id, 'pending', :retry_of) RETURNING id",
|
|
943
1859
|
args: {
|
|
944
1860
|
file_task_id: data.file_task_id,
|
|
945
|
-
file_task_version_id: data.file_task_version_id
|
|
1861
|
+
file_task_version_id: data.file_task_version_id,
|
|
1862
|
+
retry_of: data.retry_of ?? null
|
|
946
1863
|
}
|
|
947
1864
|
});
|
|
948
1865
|
return rows[0].id;
|
|
949
1866
|
}
|
|
950
|
-
async skipTaskRun(taskRunId) {
|
|
1867
|
+
async skipTaskRun(taskRunId, reason = null) {
|
|
951
1868
|
await this.db.execute({
|
|
952
|
-
sql: "UPDATE task_runs SET status = 'skipped', ended_at = :ended_at WHERE id = :id",
|
|
953
|
-
args: { id: taskRunId, ended_at: Date.now() }
|
|
1869
|
+
sql: "UPDATE task_runs SET status = 'skipped', ended_at = :ended_at, skip_reason = :reason WHERE id = :id",
|
|
1870
|
+
args: { id: taskRunId, ended_at: Date.now(), reason }
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
/**
|
|
1874
|
+
* How the task's latest ticks ended, newest first: `failure` or `success`
|
|
1875
|
+
* per tick, a tick's retries folded into it (its last attempt decides).
|
|
1876
|
+
* Skipped ticks are left out; they neither break nor extend a streak.
|
|
1877
|
+
*/
|
|
1878
|
+
async recentTickOutcomes(fileTaskId, limit) {
|
|
1879
|
+
const { rows } = await this.db.execute({
|
|
1880
|
+
sql: `SELECT r.id,
|
|
1881
|
+
COALESCE(
|
|
1882
|
+
(SELECT a.status FROM task_runs a WHERE a.retry_of = r.id ORDER BY a.id DESC LIMIT 1),
|
|
1883
|
+
r.status
|
|
1884
|
+
) AS outcome
|
|
1885
|
+
FROM task_runs r
|
|
1886
|
+
WHERE r.file_task_id = :fileTaskId AND r.retry_of IS NULL
|
|
1887
|
+
AND r.status IN ('success', 'failure')
|
|
1888
|
+
ORDER BY r.id DESC LIMIT :limit`,
|
|
1889
|
+
args: { fileTaskId, limit }
|
|
954
1890
|
});
|
|
1891
|
+
return rows;
|
|
1892
|
+
}
|
|
1893
|
+
/** Runs skipped with `reason` after run `afterId`. */
|
|
1894
|
+
async countSkipsSince(fileTaskId, reason, afterId) {
|
|
1895
|
+
const { rows } = await this.db.execute({
|
|
1896
|
+
sql: `SELECT COUNT(*) AS n FROM task_runs
|
|
1897
|
+
WHERE file_task_id = :fileTaskId AND status = 'skipped' AND skip_reason = :reason AND id > :afterId`,
|
|
1898
|
+
args: { fileTaskId, reason, afterId }
|
|
1899
|
+
});
|
|
1900
|
+
return Number(rows[0].n);
|
|
955
1901
|
}
|
|
956
1902
|
async startTaskRun(taskRunId) {
|
|
957
1903
|
await this.db.execute({
|
|
@@ -962,12 +1908,13 @@ var TaskRunService = class {
|
|
|
962
1908
|
}
|
|
963
1909
|
});
|
|
964
1910
|
}
|
|
965
|
-
async endTaskRun(taskRunId, status) {
|
|
1911
|
+
async endTaskRun(taskRunId, status, result = null) {
|
|
966
1912
|
await this.db.execute({
|
|
967
|
-
sql: "UPDATE task_runs SET ended_at = :ended_at, status = :status WHERE id = :id",
|
|
1913
|
+
sql: "UPDATE task_runs SET ended_at = :ended_at, status = :status, result = :result WHERE id = :id",
|
|
968
1914
|
args: {
|
|
969
1915
|
ended_at: Date.now(),
|
|
970
1916
|
status,
|
|
1917
|
+
result,
|
|
971
1918
|
id: taskRunId
|
|
972
1919
|
}
|
|
973
1920
|
});
|
|
@@ -1081,6 +2028,46 @@ var TimingService = class {
|
|
|
1081
2028
|
}
|
|
1082
2029
|
};
|
|
1083
2030
|
|
|
2031
|
+
// src/db/services/lock.ts
|
|
2032
|
+
import { randomUUID } from "node:crypto";
|
|
2033
|
+
var LockService = class {
|
|
2034
|
+
constructor(db2) {
|
|
2035
|
+
this.db = db2;
|
|
2036
|
+
}
|
|
2037
|
+
/** The holder token on success, null when someone else holds an unexpired lease. */
|
|
2038
|
+
async tryAcquire(name, ttlMs, taskRunId) {
|
|
2039
|
+
const holder = `${process.pid}:${randomUUID()}`;
|
|
2040
|
+
const now = Date.now();
|
|
2041
|
+
const { rows } = await this.db.execute({
|
|
2042
|
+
sql: `INSERT INTO locks (name, holder, task_run_id, acquired_at, expires_at)
|
|
2043
|
+
VALUES (:name, :holder, :taskRunId, :now, :expiresAt)
|
|
2044
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
2045
|
+
holder = excluded.holder,
|
|
2046
|
+
task_run_id = excluded.task_run_id,
|
|
2047
|
+
acquired_at = excluded.acquired_at,
|
|
2048
|
+
expires_at = excluded.expires_at
|
|
2049
|
+
WHERE locks.expires_at <= excluded.acquired_at
|
|
2050
|
+
RETURNING holder`,
|
|
2051
|
+
args: { name, holder, taskRunId, now, expiresAt: now + ttlMs }
|
|
2052
|
+
});
|
|
2053
|
+
return rows.length ? holder : null;
|
|
2054
|
+
}
|
|
2055
|
+
/** Push the lease out; false when it was lost (it expired and someone took it). */
|
|
2056
|
+
async renew(name, holder, ttlMs) {
|
|
2057
|
+
const { rowsAffected } = await this.db.execute({
|
|
2058
|
+
sql: "UPDATE locks SET expires_at = :expiresAt WHERE name = :name AND holder = :holder",
|
|
2059
|
+
args: { name, holder, expiresAt: Date.now() + ttlMs }
|
|
2060
|
+
});
|
|
2061
|
+
return rowsAffected > 0;
|
|
2062
|
+
}
|
|
2063
|
+
async release(name, holder) {
|
|
2064
|
+
await this.db.execute({
|
|
2065
|
+
sql: "DELETE FROM locks WHERE name = :name AND holder = :holder",
|
|
2066
|
+
args: { name, holder }
|
|
2067
|
+
});
|
|
2068
|
+
}
|
|
2069
|
+
};
|
|
2070
|
+
|
|
1084
2071
|
// src/runtime/generateControls.ts
|
|
1085
2072
|
function createControlsGenerator({
|
|
1086
2073
|
dbClient,
|
|
@@ -1090,13 +2077,13 @@ function createControlsGenerator({
|
|
|
1090
2077
|
const logService = new LogService(dbClient);
|
|
1091
2078
|
const kvService = new KVService(dbClient);
|
|
1092
2079
|
const timingService = new TimingService(dbClient);
|
|
2080
|
+
const lockService = new LockService(dbClient);
|
|
1093
2081
|
const generateControls = ({ sourcePath, compiledPath }) => {
|
|
1094
2082
|
let fileTaskId;
|
|
1095
2083
|
let fileTaskVersionId;
|
|
1096
|
-
let fileTaskRunId;
|
|
1097
2084
|
const assertMandatoryIds = () => {
|
|
1098
|
-
|
|
1099
|
-
|
|
2085
|
+
invariant11(!!fileTaskId, "Task ID not found");
|
|
2086
|
+
invariant11(!!fileTaskVersionId, "Task version ID not found");
|
|
1100
2087
|
};
|
|
1101
2088
|
const taskCallbacks = {
|
|
1102
2089
|
async onTaskLoadComplete({ paths, md5Hash, compiledCode }) {
|
|
@@ -1111,54 +2098,88 @@ function createControlsGenerator({
|
|
|
1111
2098
|
fileTaskVersionId
|
|
1112
2099
|
};
|
|
1113
2100
|
},
|
|
1114
|
-
async onTaskExecutionStart() {
|
|
1115
|
-
assertMandatoryIds();
|
|
1116
|
-
fileTaskRunId = await taskRunService.initTaskRun({
|
|
1117
|
-
file_task_id: fileTaskId,
|
|
1118
|
-
file_task_version_id: fileTaskVersionId
|
|
1119
|
-
});
|
|
1120
|
-
},
|
|
1121
2101
|
async onTaskCodeLoaded() {
|
|
1122
2102
|
},
|
|
1123
2103
|
async onTaskExportsLoaded() {
|
|
1124
2104
|
}
|
|
1125
2105
|
};
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
2106
|
+
async function startRun({
|
|
2107
|
+
retryOf = null
|
|
2108
|
+
} = {}) {
|
|
2109
|
+
assertMandatoryIds();
|
|
2110
|
+
const taskId = fileTaskId;
|
|
2111
|
+
const runId = await taskRunService.initTaskRun({
|
|
2112
|
+
file_task_id: taskId,
|
|
2113
|
+
file_task_version_id: fileTaskVersionId,
|
|
2114
|
+
retry_of: retryOf
|
|
2115
|
+
});
|
|
2116
|
+
const callbacks = {
|
|
2117
|
+
async onNotFound() {
|
|
2118
|
+
await logService.logError(taskId, runId, "Task not found");
|
|
2119
|
+
logTaskCallback(`Task not found: ${taskId}, run ID: ${runId}`);
|
|
2120
|
+
},
|
|
2121
|
+
async onSkip({ reason }) {
|
|
2122
|
+
await taskRunService.skipTaskRun(runId, reason ?? null);
|
|
2123
|
+
await logService.logInfo(taskId, runId, "Task skipped");
|
|
2124
|
+
if (reason) await logService.logInfo(taskId, runId, `Skipped: ${reason}`);
|
|
2125
|
+
logTaskCallback(`Task skipped: ${taskId}, run ID: ${runId}`);
|
|
2126
|
+
},
|
|
2127
|
+
async onStart() {
|
|
2128
|
+
await taskRunService.startTaskRun(runId);
|
|
2129
|
+
await logService.logInfo(taskId, runId, "Task started");
|
|
2130
|
+
logTaskCallback(`Task started: ${taskId}, run ID: ${runId}`);
|
|
2131
|
+
},
|
|
2132
|
+
async onFailure() {
|
|
2133
|
+
await taskRunService.endTaskRun(runId, "failure");
|
|
2134
|
+
await logService.logError(taskId, runId, "Task failed");
|
|
2135
|
+
logTaskCallback(`Task failed: ${taskId}, run ID: ${runId}`);
|
|
2136
|
+
},
|
|
2137
|
+
async onSuccess({ result }) {
|
|
2138
|
+
await taskRunService.endTaskRun(runId, "success", serializeResult(result));
|
|
2139
|
+
await logService.logInfo(taskId, runId, "Task succeeded");
|
|
2140
|
+
logTaskCallback(`Task succeeded: ${taskId}, run ID: ${runId}`);
|
|
2141
|
+
}
|
|
2142
|
+
};
|
|
2143
|
+
return {
|
|
2144
|
+
id: runId,
|
|
2145
|
+
callbacks,
|
|
2146
|
+
locks: createRunLocks(lockService, runId),
|
|
2147
|
+
log: {
|
|
2148
|
+
async info(value) {
|
|
2149
|
+
logTaskConsoleInfo(value);
|
|
2150
|
+
await logService.logInfo(taskId, runId, value);
|
|
2151
|
+
},
|
|
2152
|
+
async error(value) {
|
|
2153
|
+
logTaskConsoleError(value);
|
|
2154
|
+
await logService.logError(taskId, runId, value);
|
|
2155
|
+
},
|
|
2156
|
+
async warning(value) {
|
|
2157
|
+
logTaskConsoleWarn(value);
|
|
2158
|
+
await logService.logWarning(taskId, runId, value);
|
|
2159
|
+
}
|
|
2160
|
+
},
|
|
2161
|
+
timing: {
|
|
2162
|
+
start(label) {
|
|
2163
|
+
return timingService.start(taskId, runId, label);
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
};
|
|
2167
|
+
}
|
|
1157
2168
|
return {
|
|
1158
2169
|
compiledPath,
|
|
1159
2170
|
sourcePath,
|
|
1160
2171
|
taskCallbacks,
|
|
1161
|
-
|
|
2172
|
+
startRun,
|
|
2173
|
+
history: {
|
|
2174
|
+
recentTickOutcomes(limit) {
|
|
2175
|
+
assertMandatoryIds();
|
|
2176
|
+
return taskRunService.recentTickOutcomes(fileTaskId, limit);
|
|
2177
|
+
},
|
|
2178
|
+
countSkipsSince(reason, afterId) {
|
|
2179
|
+
assertMandatoryIds();
|
|
2180
|
+
return taskRunService.countSkipsSince(fileTaskId, reason, afterId);
|
|
2181
|
+
}
|
|
2182
|
+
},
|
|
1162
2183
|
env,
|
|
1163
2184
|
// Scoped to this task's own folder — see createTaskSqlite
|
|
1164
2185
|
sqlite: createTaskSqlite(sourcePath),
|
|
@@ -1172,25 +2193,6 @@ function createControlsGenerator({
|
|
|
1172
2193
|
async delete(key) {
|
|
1173
2194
|
await kvService.deleteValue(fileTaskId, key);
|
|
1174
2195
|
}
|
|
1175
|
-
},
|
|
1176
|
-
log: {
|
|
1177
|
-
async info(value) {
|
|
1178
|
-
logTaskConsoleInfo(value);
|
|
1179
|
-
await logService.logInfo(fileTaskId, fileTaskRunId, value);
|
|
1180
|
-
},
|
|
1181
|
-
async error(value) {
|
|
1182
|
-
logTaskConsoleError(value);
|
|
1183
|
-
await logService.logError(fileTaskId, fileTaskRunId, value);
|
|
1184
|
-
},
|
|
1185
|
-
async warning(value) {
|
|
1186
|
-
logTaskConsoleWarn(value);
|
|
1187
|
-
await logService.logWarning(fileTaskId, fileTaskRunId, value);
|
|
1188
|
-
}
|
|
1189
|
-
},
|
|
1190
|
-
timing: {
|
|
1191
|
-
start(label) {
|
|
1192
|
-
return timingService.start(fileTaskId, fileTaskRunId, label);
|
|
1193
|
-
}
|
|
1194
2196
|
}
|
|
1195
2197
|
};
|
|
1196
2198
|
};
|
|
@@ -1198,6 +2200,8 @@ function createControlsGenerator({
|
|
|
1198
2200
|
}
|
|
1199
2201
|
export {
|
|
1200
2202
|
Compiler,
|
|
2203
|
+
LockBusyError,
|
|
2204
|
+
Queue,
|
|
1201
2205
|
Task,
|
|
1202
2206
|
TaskLibrary,
|
|
1203
2207
|
TaskLibraryScheduler,
|
|
@@ -1207,5 +2211,7 @@ export {
|
|
|
1207
2211
|
executeEsm,
|
|
1208
2212
|
getDbClient,
|
|
1209
2213
|
initDbFileIfNotExists,
|
|
2214
|
+
isCronPatternValid,
|
|
2215
|
+
scheduleTask,
|
|
1210
2216
|
setupDbClient
|
|
1211
2217
|
};
|