@apisurf/ngn-core 0.3.0 → 0.4.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 +96 -9
- package/dist/core.js +194 -82
- package/package.json +4 -4
package/dist/core.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { RunStage } from '@apisurf/ngn-schema/describe';
|
|
2
|
+
export { RunStage } from '@apisurf/ngn-schema/describe';
|
|
1
3
|
import { FsNodeFileDescriptor } from '@apisurf/ngn-os';
|
|
2
4
|
import { Client, InArgs, ResultSet, InStatement, InValue } from '@apisurf/ngn-persistence';
|
|
3
5
|
export { initDbFileIfNotExists } from '@apisurf/ngn-persistence';
|
|
@@ -120,6 +122,13 @@ interface TaskSqlite {
|
|
|
120
122
|
* Close every database opened by a task. Called by the CLI on shutdown.
|
|
121
123
|
*/
|
|
122
124
|
declare function closeTaskDatabases(): void;
|
|
125
|
+
/**
|
|
126
|
+
* Let go of the databases one task file opened, closing those no other task
|
|
127
|
+
* holds. For a one-off run inside a process that keeps scheduling others (the
|
|
128
|
+
* editor's): `closeTaskDatabases` there would close the connections of
|
|
129
|
+
* scheduled runs still in progress, and their next query would fail.
|
|
130
|
+
*/
|
|
131
|
+
declare function releaseTaskDatabases(taskSourcePath: string): void;
|
|
123
132
|
|
|
124
133
|
/**
|
|
125
134
|
* A length of time as tasks write it: milliseconds, or a string such as
|
|
@@ -484,7 +493,8 @@ interface TaskContext {
|
|
|
484
493
|
file: FsNodeFileDescriptor;
|
|
485
494
|
tasksRootDir: string;
|
|
486
495
|
};
|
|
487
|
-
|
|
496
|
+
/** The variables from the config's `envFile`; empty when there is none. */
|
|
497
|
+
env: Record<string, string>;
|
|
488
498
|
kv: {
|
|
489
499
|
set(key: string, value: string): Promise<void>;
|
|
490
500
|
get(key: string): Promise<string | null>;
|
|
@@ -541,9 +551,6 @@ interface CompiledTaskCallbacks {
|
|
|
541
551
|
onTaskExportsLoaded?: () => Promise<void>;
|
|
542
552
|
}
|
|
543
553
|
interface TaskRuntimeCallbacks {
|
|
544
|
-
onNotFound?: (meta: {
|
|
545
|
-
path: string;
|
|
546
|
-
}) => Promise<void>;
|
|
547
554
|
/** `reason` is set when ngn skipped the run, rather than the task's `shouldSkip`. */
|
|
548
555
|
onSkip?: (meta: {
|
|
549
556
|
path: string;
|
|
@@ -556,9 +563,30 @@ interface TaskRuntimeCallbacks {
|
|
|
556
563
|
path: string;
|
|
557
564
|
result?: unknown;
|
|
558
565
|
}) => Promise<void>;
|
|
566
|
+
/** The run failed: `error` was thrown in `stage`. Never throws itself. */
|
|
559
567
|
onFailure?: (meta: {
|
|
560
568
|
path: string;
|
|
569
|
+
stage: RunStage;
|
|
570
|
+
error: unknown;
|
|
561
571
|
}) => Promise<void>;
|
|
572
|
+
/** A hook threw; the run's outcome stands. Never throws itself. */
|
|
573
|
+
onHookError?: (meta: {
|
|
574
|
+
path: string;
|
|
575
|
+
stage: RunStage;
|
|
576
|
+
error: unknown;
|
|
577
|
+
}) => Promise<void>;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Something a run threw, once it is recorded against the run: what the CLI
|
|
581
|
+
* prints. `fatal` is false for a hook's error, which leaves the run's outcome
|
|
582
|
+
* as it was.
|
|
583
|
+
*/
|
|
584
|
+
interface RunProblem {
|
|
585
|
+
path: string;
|
|
586
|
+
runId: number;
|
|
587
|
+
stage: RunStage;
|
|
588
|
+
error: unknown;
|
|
589
|
+
fatal: boolean;
|
|
562
590
|
}
|
|
563
591
|
/**
|
|
564
592
|
* One run's bookkeeping: its row in `task_runs`, and everything that writes
|
|
@@ -607,8 +635,17 @@ interface ExecuteSchedule {
|
|
|
607
635
|
/** When the next tick is due, epoch ms. */
|
|
608
636
|
nextAt?: number | null;
|
|
609
637
|
}
|
|
610
|
-
/** The `task_runs.id` of the run that threw `error`, when a
|
|
638
|
+
/** The `task_runs.id` of the run that threw `error`, when a run recorded it. */
|
|
611
639
|
declare function failedRunId(error: unknown): number | null;
|
|
640
|
+
/**
|
|
641
|
+
* The run that recorded `error` and the stage it was thrown in; null for an
|
|
642
|
+
* error no run recorded (a task file that did not compile, a database ngn
|
|
643
|
+
* could not open), which only its thrower knows about.
|
|
644
|
+
*/
|
|
645
|
+
declare function runFailure(error: unknown): {
|
|
646
|
+
runId: number;
|
|
647
|
+
stage: RunStage;
|
|
648
|
+
} | null;
|
|
612
649
|
declare class Task {
|
|
613
650
|
private taskCallbacks;
|
|
614
651
|
private startRun;
|
|
@@ -654,8 +691,26 @@ declare class Task {
|
|
|
654
691
|
hasTaskExport(): boolean;
|
|
655
692
|
hasTimingExport(): boolean;
|
|
656
693
|
loadEntry(): Promise<void>;
|
|
694
|
+
/**
|
|
695
|
+
* Load the task file's code and register its version. A failure here has no
|
|
696
|
+
* run to mark yet, so it is thrown to whoever called execute(). Evaluating
|
|
697
|
+
* the code comes after the run exists — see `readExports`.
|
|
698
|
+
*/
|
|
657
699
|
private loadAndRegister;
|
|
658
|
-
|
|
700
|
+
/**
|
|
701
|
+
* Evaluate the task file and check the exports a run depends on. Throws
|
|
702
|
+
* what the module threw, or what is wrong with an export, for the run to
|
|
703
|
+
* record as failed in `load`.
|
|
704
|
+
*/
|
|
705
|
+
private readExports;
|
|
706
|
+
/** Mark the run failed in `stage`, remembering where for whoever reports the error. */
|
|
707
|
+
private failRun;
|
|
708
|
+
/**
|
|
709
|
+
* Call one of the task's hooks. What it throws is recorded on the run and
|
|
710
|
+
* goes no further: the run's outcome was decided before the hook ran, and
|
|
711
|
+
* `onError`'s error must not replace the one it was called with.
|
|
712
|
+
*/
|
|
713
|
+
private runHook;
|
|
659
714
|
/**
|
|
660
715
|
* Run the task for one tick, retrying as its `retry` export allows. A
|
|
661
716
|
* scheduled tick passes when it fired and when the next one is due, which
|
|
@@ -682,6 +737,17 @@ declare class Task {
|
|
|
682
737
|
*/
|
|
683
738
|
private holdBack;
|
|
684
739
|
private buildContext;
|
|
740
|
+
/**
|
|
741
|
+
* Load the task file, open the attempt's `task_runs` row, then evaluate the
|
|
742
|
+
* file's code; what that throws is recorded on the row as failed in `load`.
|
|
743
|
+
*/
|
|
744
|
+
private openRun;
|
|
745
|
+
/**
|
|
746
|
+
* One attempt: a `task_runs` row, then the task, with every failure recorded
|
|
747
|
+
* against that row as the stage it was thrown in. Only a task file whose code
|
|
748
|
+
* cannot be loaded at all, or a run row that cannot be written, fails before
|
|
749
|
+
* there is a row to record it on.
|
|
750
|
+
*/
|
|
685
751
|
private executeOnce;
|
|
686
752
|
}
|
|
687
753
|
|
|
@@ -708,6 +774,13 @@ type Options$1 = {
|
|
|
708
774
|
/** Called when a scheduled run throws, with the run it was recorded as when there is one. */
|
|
709
775
|
onRunError?: (path: string, error: unknown, runId: number | null) => void;
|
|
710
776
|
};
|
|
777
|
+
/** What `runNow()` did: started a run, or why it did not. */
|
|
778
|
+
type RunNowOutcome = {
|
|
779
|
+
started: true;
|
|
780
|
+
} | {
|
|
781
|
+
started: false;
|
|
782
|
+
reason: string;
|
|
783
|
+
};
|
|
711
784
|
/** What `start()` did with each task: scheduled on its pattern, or left out and why. */
|
|
712
785
|
interface ScheduleReport {
|
|
713
786
|
scheduled: {
|
|
@@ -727,6 +800,17 @@ declare class TaskLibraryScheduler {
|
|
|
727
800
|
start(): Promise<ScheduleReport>;
|
|
728
801
|
stop(): Promise<void>;
|
|
729
802
|
scheduleTask(task: Task): Promise<void>;
|
|
803
|
+
/**
|
|
804
|
+
* Start one run of a scheduled task now, outside its schedule, the way a tick
|
|
805
|
+
* would: retries, budget, locks and hooks all apply, and errors go where a
|
|
806
|
+
* tick's go. It is not a tick, so the breaker does not hold it back. Resolves
|
|
807
|
+
* once the run is under way, not when it ends: a run can take longer than
|
|
808
|
+
* anyone waits on a button.
|
|
809
|
+
*
|
|
810
|
+
* A run still going is reported rather than recorded as a skip, since nothing
|
|
811
|
+
* was due: the person asking can simply wait.
|
|
812
|
+
*/
|
|
813
|
+
runNow(taskPath: string): RunNowOutcome;
|
|
730
814
|
unscheduleTask(task: Task): void;
|
|
731
815
|
isCronPatternValid(cronPattern: string): boolean;
|
|
732
816
|
}
|
|
@@ -750,9 +834,12 @@ declare function scheduleTask(task: Task, pattern: string, onError: (error: unkn
|
|
|
750
834
|
declare function setupDbClient(dbPath: string): Promise<Client>;
|
|
751
835
|
declare function getDbClient(): Client;
|
|
752
836
|
|
|
753
|
-
declare function createControlsGenerator({ dbClient, env, }: {
|
|
837
|
+
declare function createControlsGenerator({ dbClient, env, report, }: {
|
|
754
838
|
dbClient: Client;
|
|
755
|
-
env
|
|
839
|
+
/** A missing env file is null from the readers; tasks always get an object. */
|
|
840
|
+
env: TaskContext["env"] | null | undefined;
|
|
841
|
+
/** Told about everything a run throws, after it is recorded. The CLI prints it. */
|
|
842
|
+
report?: (problem: RunProblem) => void;
|
|
756
843
|
}): BuildCompiledTaskConfigFn;
|
|
757
844
|
|
|
758
|
-
export { type AddJob, type Backoff, type ClaimOptions, Compiler, type DBInstance, type Duration, type InitDBOptions, type Job, type JobFilter, type JobStatus, LockBusyError, type LockOptions, type Migration, type OverlapPolicy, Queue, type QueueOptions, type QueueStats, type ScheduleReport, Task, type TaskContext, TaskLibrary, TaskLibraryScheduler, TaskOnceRunner, type TaskRun, type TaskSqlite, type WorkHandler, type WorkOptions, type WorkSummary, type WorkerHandle, closeTaskDatabases, createControlsGenerator, executeEsm, failedRunId, getDbClient, isCronPatternValid, scheduleTask, setupDbClient };
|
|
845
|
+
export { type AddJob, type Backoff, type ClaimOptions, Compiler, type DBInstance, type Duration, type InitDBOptions, type Job, type JobFilter, type JobStatus, LockBusyError, type LockOptions, type Migration, type OverlapPolicy, Queue, type QueueOptions, type QueueStats, type RunNowOutcome, type RunProblem, type ScheduleReport, Task, type TaskContext, TaskLibrary, TaskLibraryScheduler, TaskOnceRunner, type TaskRun, type TaskSqlite, type WorkHandler, type WorkOptions, type WorkSummary, type WorkerHandle, closeTaskDatabases, createControlsGenerator, executeEsm, failedRunId, getDbClient, isCronPatternValid, releaseTaskDatabases, runFailure, scheduleTask, setupDbClient };
|
package/dist/core.js
CHANGED
|
@@ -297,36 +297,36 @@ var Entry = class {
|
|
|
297
297
|
isLoaded() {
|
|
298
298
|
return this.entry.isLoaded();
|
|
299
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* Evaluate the task file and read its exports. Whatever the module throws
|
|
302
|
+
* while it is evaluated is thrown from here, so the run it was for can
|
|
303
|
+
* record it.
|
|
304
|
+
*/
|
|
300
305
|
async getExports() {
|
|
301
306
|
invariant4(this.entry.code, "Entry code not loaded. Load entry first.");
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
taskFunction = taskExports.task;
|
|
312
|
-
}
|
|
313
|
-
return {
|
|
314
|
-
task: taskFunction,
|
|
315
|
-
timing: taskExports.timing,
|
|
316
|
-
overlap: taskExports.overlap,
|
|
317
|
-
budget: taskExports.budget,
|
|
318
|
-
exclusive: taskExports.exclusive,
|
|
319
|
-
retry: taskExports.retry,
|
|
320
|
-
shouldSkip: taskExports.shouldSkip,
|
|
321
|
-
shouldRetry: taskExports.shouldRetry,
|
|
322
|
-
onSuccess: taskExports.onSuccess,
|
|
323
|
-
onError: taskExports.onError,
|
|
324
|
-
onComplete: taskExports.onComplete
|
|
325
|
-
};
|
|
326
|
-
} catch (error) {
|
|
327
|
-
console.error("Error getting task exports:", error);
|
|
328
|
-
return null;
|
|
307
|
+
const taskExports = await executeEsm(this.entry.code);
|
|
308
|
+
this.callbacks.onTaskCodeLoaded?.(this.entry.codeHash);
|
|
309
|
+
let taskFunction;
|
|
310
|
+
if (typeof taskExports.default === "function") {
|
|
311
|
+
taskFunction = taskExports.default;
|
|
312
|
+
} else if (taskExports.default && typeof taskExports.default === "object" && "task" in taskExports.default) {
|
|
313
|
+
taskFunction = taskExports.default.task;
|
|
314
|
+
} else {
|
|
315
|
+
taskFunction = taskExports.task;
|
|
329
316
|
}
|
|
317
|
+
return {
|
|
318
|
+
task: taskFunction,
|
|
319
|
+
timing: taskExports.timing,
|
|
320
|
+
overlap: taskExports.overlap,
|
|
321
|
+
budget: taskExports.budget,
|
|
322
|
+
exclusive: taskExports.exclusive,
|
|
323
|
+
retry: taskExports.retry,
|
|
324
|
+
shouldSkip: taskExports.shouldSkip,
|
|
325
|
+
shouldRetry: taskExports.shouldRetry,
|
|
326
|
+
onSuccess: taskExports.onSuccess,
|
|
327
|
+
onError: taskExports.onError,
|
|
328
|
+
onComplete: taskExports.onComplete
|
|
329
|
+
};
|
|
330
330
|
}
|
|
331
331
|
get paths() {
|
|
332
332
|
return this.entry.path;
|
|
@@ -385,10 +385,17 @@ function deadlineSignal(deadline) {
|
|
|
385
385
|
return { signal: controller.signal, dispose: () => {
|
|
386
386
|
} };
|
|
387
387
|
}
|
|
388
|
-
|
|
389
|
-
|
|
388
|
+
let timer;
|
|
389
|
+
const arm = () => {
|
|
390
|
+
const left = deadline - Date.now();
|
|
391
|
+
if (left <= 0) return abort();
|
|
392
|
+
timer = setTimeout(arm, Math.min(left, MAX_TIMER_MS));
|
|
393
|
+
timer.unref();
|
|
394
|
+
};
|
|
395
|
+
arm();
|
|
390
396
|
return { signal: controller.signal, dispose: () => clearTimeout(timer) };
|
|
391
397
|
}
|
|
398
|
+
var MAX_TIMER_MS = 2 ** 31 - 1;
|
|
392
399
|
|
|
393
400
|
// src/runtime/locks.ts
|
|
394
401
|
var LockBusyError = class extends Error {
|
|
@@ -530,27 +537,28 @@ function resolveTaskDbPath(taskDir, file) {
|
|
|
530
537
|
}
|
|
531
538
|
return resolved;
|
|
532
539
|
}
|
|
533
|
-
function getClient(absolutePath) {
|
|
540
|
+
function getClient(absolutePath, owner) {
|
|
534
541
|
const existing = openClients.get(absolutePath);
|
|
535
542
|
if (existing) {
|
|
536
|
-
|
|
543
|
+
existing.owners.add(owner);
|
|
544
|
+
return existing.client;
|
|
537
545
|
}
|
|
538
546
|
mkdirSync(dirname(absolutePath), { recursive: true });
|
|
539
547
|
const client = openDbClient(`file:${absolutePath}`);
|
|
540
|
-
openClients.set(absolutePath, client);
|
|
548
|
+
openClients.set(absolutePath, { client, owners: /* @__PURE__ */ new Set([owner]) });
|
|
541
549
|
return client;
|
|
542
550
|
}
|
|
543
551
|
function closeClient(absolutePath) {
|
|
544
|
-
const
|
|
545
|
-
if (
|
|
546
|
-
client.close();
|
|
552
|
+
const open = openClients.get(absolutePath);
|
|
553
|
+
if (open) {
|
|
554
|
+
open.client.close();
|
|
547
555
|
openClients.delete(absolutePath);
|
|
548
556
|
}
|
|
549
557
|
}
|
|
550
558
|
function createTaskSqlite(taskSourcePath) {
|
|
551
559
|
const taskDir = dirname(taskSourcePath);
|
|
552
560
|
const defaultDbPath = join(taskDir, `${basename(taskSourcePath, extname(taskSourcePath))}.db`);
|
|
553
|
-
const defaultClient = () => getClient(defaultDbPath);
|
|
561
|
+
const defaultClient = () => getClient(defaultDbPath, taskSourcePath);
|
|
554
562
|
return {
|
|
555
563
|
get client() {
|
|
556
564
|
return defaultClient();
|
|
@@ -562,7 +570,7 @@ function createTaskSqlite(taskSourcePath) {
|
|
|
562
570
|
batch: (statements) => defaultClient().batch(statements),
|
|
563
571
|
async initDB(options) {
|
|
564
572
|
const dbPath = resolveTaskDbPath(taskDir, options.file);
|
|
565
|
-
const client = getClient(dbPath);
|
|
573
|
+
const client = getClient(dbPath, taskSourcePath);
|
|
566
574
|
if (options.migrations?.length) {
|
|
567
575
|
await runMigrations(client, options.migrations);
|
|
568
576
|
}
|
|
@@ -582,11 +590,18 @@ function createTaskSqlite(taskSourcePath) {
|
|
|
582
590
|
};
|
|
583
591
|
}
|
|
584
592
|
function closeTaskDatabases() {
|
|
585
|
-
for (const client of openClients.values()) {
|
|
593
|
+
for (const { client } of openClients.values()) {
|
|
586
594
|
client.close();
|
|
587
595
|
}
|
|
588
596
|
openClients.clear();
|
|
589
597
|
}
|
|
598
|
+
function releaseTaskDatabases(taskSourcePath) {
|
|
599
|
+
for (const [path, { client, owners }] of openClients) {
|
|
600
|
+
if (!owners.delete(taskSourcePath) || owners.size > 0) continue;
|
|
601
|
+
client.close();
|
|
602
|
+
openClients.delete(path);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
590
605
|
|
|
591
606
|
// src/runtime/queue/schema.ts
|
|
592
607
|
var QUEUE_MIGRATIONS = [
|
|
@@ -1172,6 +1187,9 @@ function breakerSkips(failedInARow, breaker) {
|
|
|
1172
1187
|
var sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1173
1188
|
var failedRuns = /* @__PURE__ */ new WeakMap();
|
|
1174
1189
|
function failedRunId(error) {
|
|
1190
|
+
return runFailure(error)?.runId ?? null;
|
|
1191
|
+
}
|
|
1192
|
+
function runFailure(error) {
|
|
1175
1193
|
return error && typeof error === "object" ? failedRuns.get(error) ?? null : null;
|
|
1176
1194
|
}
|
|
1177
1195
|
var Task = class {
|
|
@@ -1284,12 +1302,15 @@ var Task = class {
|
|
|
1284
1302
|
await this.entry.load();
|
|
1285
1303
|
this.entryExports = await this.entry.getExports();
|
|
1286
1304
|
}
|
|
1305
|
+
/**
|
|
1306
|
+
* Load the task file's code and register its version. A failure here has no
|
|
1307
|
+
* run to mark yet, so it is thrown to whoever called execute(). Evaluating
|
|
1308
|
+
* the code comes after the run exists — see `readExports`.
|
|
1309
|
+
*/
|
|
1287
1310
|
async loadAndRegister() {
|
|
1288
1311
|
invariant5(this.entry, "Entry not initialized.");
|
|
1289
|
-
await this.
|
|
1312
|
+
await this.entry.load();
|
|
1290
1313
|
invariant5(this.entry.isLoaded(), "Entry failed to load.");
|
|
1291
|
-
invariant5(this.entryExports, "Entry failed to get exports.");
|
|
1292
|
-
this.taskCallbacks?.onTaskExportsLoaded?.();
|
|
1293
1314
|
const loadResult = await this.taskCallbacks?.onTaskLoadComplete?.({
|
|
1294
1315
|
paths: this.entry.paths,
|
|
1295
1316
|
md5Hash: this.entry.codeHash,
|
|
@@ -1298,22 +1319,44 @@ var Task = class {
|
|
|
1298
1319
|
this.meta.fileTaskId = loadResult?.fileTaskId ?? -1;
|
|
1299
1320
|
this.meta.fileTaskVersionId = loadResult?.fileTaskVersionId ?? -1;
|
|
1300
1321
|
}
|
|
1301
|
-
|
|
1322
|
+
/**
|
|
1323
|
+
* Evaluate the task file and check the exports a run depends on. Throws
|
|
1324
|
+
* what the module threw, or what is wrong with an export, for the run to
|
|
1325
|
+
* record as failed in `load`.
|
|
1326
|
+
*/
|
|
1327
|
+
async readExports(path) {
|
|
1328
|
+
invariant5(this.entry, "Entry not initialized.");
|
|
1329
|
+
const exports = await this.entry.getExports();
|
|
1330
|
+
this.entryExports = exports;
|
|
1331
|
+
this.taskCallbacks?.onTaskExportsLoaded?.();
|
|
1332
|
+
if (typeof exports.task !== "function") {
|
|
1333
|
+
throw new Error(`${path} exports no task function`);
|
|
1334
|
+
}
|
|
1335
|
+
return {
|
|
1336
|
+
exports,
|
|
1337
|
+
task: exports.task,
|
|
1338
|
+
budgetMs: exports.budget == null ? null : toMs(exports.budget, `budget for ${path}`),
|
|
1339
|
+
exclusiveLocks: exclusiveNames(exports.exclusive, path),
|
|
1340
|
+
retry: resolveRetry(exports.retry, path)
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
/** Mark the run failed in `stage`, remembering where for whoever reports the error. */
|
|
1344
|
+
async failRun(run, stage, error) {
|
|
1345
|
+
invariant5(this.entry, "Entry not initialized.");
|
|
1346
|
+
if (error && typeof error === "object") failedRuns.set(error, { runId: run.id, stage });
|
|
1347
|
+
await run.callbacks.onFailure?.({ path: this.entry.paths.relativeEntry, stage, error });
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Call one of the task's hooks. What it throws is recorded on the run and
|
|
1351
|
+
* goes no further: the run's outcome was decided before the hook ran, and
|
|
1352
|
+
* `onError`'s error must not replace the one it was called with.
|
|
1353
|
+
*/
|
|
1354
|
+
async runHook(run, stage, hook) {
|
|
1302
1355
|
invariant5(this.entry, "Entry not initialized.");
|
|
1303
|
-
const path = this.entry.paths.relativeEntry;
|
|
1304
1356
|
try {
|
|
1305
|
-
await
|
|
1306
|
-
|
|
1307
|
-
await run.callbacks.
|
|
1308
|
-
await hooks.onSuccess?.(taskContext);
|
|
1309
|
-
return taskReturnValue;
|
|
1310
|
-
} catch (err) {
|
|
1311
|
-
if (err && typeof err === "object") failedRuns.set(err, run.id);
|
|
1312
|
-
await run.callbacks.onFailure?.({ path });
|
|
1313
|
-
await hooks.onError?.(err, taskContext);
|
|
1314
|
-
throw err;
|
|
1315
|
-
} finally {
|
|
1316
|
-
await hooks.onComplete?.(taskContext);
|
|
1357
|
+
await hook();
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
await run.callbacks.onHookError?.({ path: this.entry.paths.relativeEntry, stage, error });
|
|
1317
1360
|
}
|
|
1318
1361
|
}
|
|
1319
1362
|
/**
|
|
@@ -1403,27 +1446,38 @@ var Task = class {
|
|
|
1403
1446
|
queue: (name, options = {}) => openQueue(options.db?.client ?? this.sqlite.client, name, options, { nextAt, signal })
|
|
1404
1447
|
};
|
|
1405
1448
|
}
|
|
1406
|
-
|
|
1449
|
+
/**
|
|
1450
|
+
* Load the task file, open the attempt's `task_runs` row, then evaluate the
|
|
1451
|
+
* file's code; what that throws is recorded on the row as failed in `load`.
|
|
1452
|
+
*/
|
|
1453
|
+
async openRun(tick2) {
|
|
1407
1454
|
invariant5(this.entry, "Entry not initialized.");
|
|
1408
1455
|
await this.loadAndRegister();
|
|
1409
|
-
invariant5(this.entryExports, "Entry failed to get exports.");
|
|
1410
|
-
const exports = this.entryExports;
|
|
1411
1456
|
const meta = { ...this.meta };
|
|
1412
1457
|
const path = this.entry.paths.relativeEntry;
|
|
1413
|
-
const budgetMs = exports.budget == null ? null : toMs(exports.budget, `budget for ${path}`);
|
|
1414
|
-
const exclusiveLocks = exclusiveNames(exports.exclusive, path);
|
|
1415
|
-
const retry = resolveRetry(exports.retry, path);
|
|
1416
|
-
tick2.retry = retry;
|
|
1417
1458
|
const run = await this.startRun({ retryOf: tick2.firstRunId });
|
|
1418
1459
|
const firstAttempt = tick2.firstRunId == null;
|
|
1419
1460
|
tick2.firstRunId ??= run.id;
|
|
1420
|
-
|
|
1421
|
-
await
|
|
1422
|
-
|
|
1461
|
+
try {
|
|
1462
|
+
return { run, meta, path, firstAttempt, ...await this.readExports(path) };
|
|
1463
|
+
} catch (error) {
|
|
1464
|
+
await this.failRun(run, "load", error);
|
|
1465
|
+
throw error;
|
|
1423
1466
|
}
|
|
1467
|
+
}
|
|
1468
|
+
/**
|
|
1469
|
+
* One attempt: a `task_runs` row, then the task, with every failure recorded
|
|
1470
|
+
* against that row as the stage it was thrown in. Only a task file whose code
|
|
1471
|
+
* cannot be loaded at all, or a run row that cannot be written, fails before
|
|
1472
|
+
* there is a row to record it on.
|
|
1473
|
+
*/
|
|
1474
|
+
async executeOnce({ scheduledAt = null, nextAt = null }, tick2) {
|
|
1475
|
+
const { run, meta, path, firstAttempt, exports, task, budgetMs, exclusiveLocks, retry } = await this.openRun(tick2);
|
|
1476
|
+
tick2.retry = retry;
|
|
1424
1477
|
const deadline = runDeadline({ startedAt: Date.now(), scheduledAt, nextAt, budgetMs });
|
|
1425
1478
|
const { signal, dispose } = deadlineSignal(deadline);
|
|
1426
1479
|
const taskContext = this.buildContext(run, meta, { scheduledAt, nextAt, deadline }, signal);
|
|
1480
|
+
let stage = "start";
|
|
1427
1481
|
let exclusiveHeld = null;
|
|
1428
1482
|
try {
|
|
1429
1483
|
const gate = await this.holdBack(run, {
|
|
@@ -1437,20 +1491,29 @@ var Task = class {
|
|
|
1437
1491
|
return;
|
|
1438
1492
|
}
|
|
1439
1493
|
exclusiveHeld = gate.held;
|
|
1494
|
+
stage = "shouldSkip";
|
|
1440
1495
|
if (await exports.shouldSkip?.(taskContext)) {
|
|
1441
1496
|
await run.callbacks.onSkip?.({ path });
|
|
1442
1497
|
return;
|
|
1443
1498
|
}
|
|
1499
|
+
stage = "start";
|
|
1500
|
+
await run.callbacks.onStart?.({ path });
|
|
1501
|
+
stage = "task";
|
|
1444
1502
|
tick2.failedContext = taskContext;
|
|
1445
|
-
const
|
|
1446
|
-
const value = await this.runTask(
|
|
1447
|
-
exports.task,
|
|
1448
|
-
taskContext,
|
|
1449
|
-
{ onSuccess, onError, onComplete },
|
|
1450
|
-
run
|
|
1451
|
-
);
|
|
1503
|
+
const value = await task(taskContext);
|
|
1452
1504
|
tick2.failedContext = null;
|
|
1505
|
+
stage = "record";
|
|
1506
|
+
await run.callbacks.onSuccess?.({ path, result: value });
|
|
1507
|
+
await this.runHook(run, "onSuccess", () => exports.onSuccess?.(taskContext));
|
|
1508
|
+
await this.runHook(run, "onComplete", () => exports.onComplete?.(taskContext));
|
|
1453
1509
|
return value;
|
|
1510
|
+
} catch (error) {
|
|
1511
|
+
await this.failRun(run, stage, error);
|
|
1512
|
+
if (stage === "task" || stage === "record") {
|
|
1513
|
+
await this.runHook(run, "onError", () => exports.onError?.(error, taskContext));
|
|
1514
|
+
await this.runHook(run, "onComplete", () => exports.onComplete?.(taskContext));
|
|
1515
|
+
}
|
|
1516
|
+
throw error;
|
|
1454
1517
|
} finally {
|
|
1455
1518
|
dispose();
|
|
1456
1519
|
await exclusiveHeld?.release();
|
|
@@ -1608,6 +1671,36 @@ var TaskLibraryScheduler = class {
|
|
|
1608
1671
|
this.scheduledTasks.set(task.taskPath, scheduledTask);
|
|
1609
1672
|
logTaskLibraryScheduler(`Scheduled: ${task.taskPath}`);
|
|
1610
1673
|
}
|
|
1674
|
+
/**
|
|
1675
|
+
* Start one run of a scheduled task now, outside its schedule, the way a tick
|
|
1676
|
+
* would: retries, budget, locks and hooks all apply, and errors go where a
|
|
1677
|
+
* tick's go. It is not a tick, so the breaker does not hold it back. Resolves
|
|
1678
|
+
* once the run is under way, not when it ends: a run can take longer than
|
|
1679
|
+
* anyone waits on a button.
|
|
1680
|
+
*
|
|
1681
|
+
* A run still going is reported rather than recorded as a skip, since nothing
|
|
1682
|
+
* was due: the person asking can simply wait.
|
|
1683
|
+
*/
|
|
1684
|
+
runNow(taskPath) {
|
|
1685
|
+
const task = this.taskLibrary.getTasks().find((t) => t.taskPath === taskPath);
|
|
1686
|
+
const cron = this.scheduledTasks.get(taskPath);
|
|
1687
|
+
if (!task || !cron) {
|
|
1688
|
+
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 };
|
|
1703
|
+
}
|
|
1611
1704
|
unscheduleTask(task) {
|
|
1612
1705
|
const existingTask = this.scheduledTasks.get(task.taskPath);
|
|
1613
1706
|
if (!existingTask) {
|
|
@@ -1698,6 +1791,7 @@ import {
|
|
|
1698
1791
|
logTaskConsoleWarn
|
|
1699
1792
|
} from "@apisurf/ngn-os";
|
|
1700
1793
|
import invariant11 from "tiny-invariant";
|
|
1794
|
+
import { errorSummary, failureLine } from "@apisurf/ngn-schema/describe";
|
|
1701
1795
|
|
|
1702
1796
|
// src/db/services/fileTask.ts
|
|
1703
1797
|
import { $fileTaskInsert } from "@apisurf/ngn-schema";
|
|
@@ -2089,9 +2183,17 @@ var LockService = class {
|
|
|
2089
2183
|
};
|
|
2090
2184
|
|
|
2091
2185
|
// src/runtime/generateControls.ts
|
|
2186
|
+
async function recordSafely(runId, write) {
|
|
2187
|
+
try {
|
|
2188
|
+
await write();
|
|
2189
|
+
} catch (error) {
|
|
2190
|
+
logTaskCallback(`Could not record what run ${runId} threw: ${errorSummary(error)}`);
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2092
2193
|
function createControlsGenerator({
|
|
2093
2194
|
dbClient,
|
|
2094
|
-
env
|
|
2195
|
+
env,
|
|
2196
|
+
report
|
|
2095
2197
|
}) {
|
|
2096
2198
|
const taskRunService = new TaskRunService(dbClient);
|
|
2097
2199
|
const logService = new LogService(dbClient);
|
|
@@ -2134,10 +2236,6 @@ function createControlsGenerator({
|
|
|
2134
2236
|
retry_of: retryOf
|
|
2135
2237
|
});
|
|
2136
2238
|
const callbacks = {
|
|
2137
|
-
async onNotFound() {
|
|
2138
|
-
await logService.logError(taskId, runId, "Task not found");
|
|
2139
|
-
logTaskCallback(`Task not found: ${taskId}, run ID: ${runId}`);
|
|
2140
|
-
},
|
|
2141
2239
|
async onSkip({ reason }) {
|
|
2142
2240
|
await taskRunService.skipTaskRun(runId, reason ?? null);
|
|
2143
2241
|
await logService.logInfo(taskId, runId, "Task skipped");
|
|
@@ -2149,10 +2247,22 @@ function createControlsGenerator({
|
|
|
2149
2247
|
await logService.logInfo(taskId, runId, "Task started");
|
|
2150
2248
|
logTaskCallback(`Task started: ${taskId}, run ID: ${runId}`);
|
|
2151
2249
|
},
|
|
2152
|
-
async onFailure() {
|
|
2153
|
-
await
|
|
2154
|
-
|
|
2155
|
-
|
|
2250
|
+
async onFailure({ path, stage, error }) {
|
|
2251
|
+
await recordSafely(runId, async () => {
|
|
2252
|
+
await taskRunService.endTaskRun(runId, "failure");
|
|
2253
|
+
await logService.logError(taskId, runId, failureLine(stage, error));
|
|
2254
|
+
await logService.logError(taskId, runId, "Task failed");
|
|
2255
|
+
});
|
|
2256
|
+
logTaskCallback(`Task failed in ${stage}: ${taskId}, run ID: ${runId}`);
|
|
2257
|
+
report?.({ path, runId, stage, error, fatal: true });
|
|
2258
|
+
},
|
|
2259
|
+
async onHookError({ path, stage, error }) {
|
|
2260
|
+
await recordSafely(
|
|
2261
|
+
runId,
|
|
2262
|
+
() => logService.logError(taskId, runId, failureLine(stage, error))
|
|
2263
|
+
);
|
|
2264
|
+
logTaskCallback(`Hook ${stage} threw: ${taskId}, run ID: ${runId}`);
|
|
2265
|
+
report?.({ path, runId, stage, error, fatal: false });
|
|
2156
2266
|
},
|
|
2157
2267
|
async onSuccess({ result }) {
|
|
2158
2268
|
await taskRunService.endTaskRun(runId, "success", serializeResult(result));
|
|
@@ -2200,7 +2310,7 @@ function createControlsGenerator({
|
|
|
2200
2310
|
return taskRunService.countSkipsSince(fileTaskId, reason, afterId);
|
|
2201
2311
|
}
|
|
2202
2312
|
},
|
|
2203
|
-
env,
|
|
2313
|
+
env: env ?? {},
|
|
2204
2314
|
// Scoped to this task's own folder — see createTaskSqlite
|
|
2205
2315
|
sqlite: createTaskSqlite(sourcePath),
|
|
2206
2316
|
kv: {
|
|
@@ -2233,6 +2343,8 @@ export {
|
|
|
2233
2343
|
getDbClient,
|
|
2234
2344
|
initDbFileIfNotExists,
|
|
2235
2345
|
isCronPatternValid,
|
|
2346
|
+
releaseTaskDatabases,
|
|
2347
|
+
runFailure,
|
|
2236
2348
|
scheduleTask,
|
|
2237
2349
|
setupDbClient
|
|
2238
2350
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apisurf/ngn-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "ngn runtime: task compilation, scheduling and the run/log/timing services.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Luka Vidakovic",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
"esbuild": "^0.27.1",
|
|
39
39
|
"tiny-invariant": "^1.3.3",
|
|
40
40
|
"zod": "^3.24.2",
|
|
41
|
-
"@apisurf/ngn-os": "0.
|
|
42
|
-
"@apisurf/ngn-persistence": "0.
|
|
43
|
-
"@apisurf/ngn-schema": "0.
|
|
41
|
+
"@apisurf/ngn-os": "0.4.0",
|
|
42
|
+
"@apisurf/ngn-persistence": "0.4.0",
|
|
43
|
+
"@apisurf/ngn-schema": "0.4.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@types/node": "^22.8.1",
|