@isomorph.ai/app-sdk 1.4.1 → 1.4.3
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/runtime.d.ts +27 -5
- package/dist/runtime.js +86 -10
- package/package.json +1 -1
package/dist/runtime.d.ts
CHANGED
|
@@ -46,17 +46,18 @@ export declare class Runs {
|
|
|
46
46
|
}>;
|
|
47
47
|
get(id: string, owner: string): Promise<JobRun | null>;
|
|
48
48
|
cancel(id: string, owner: string): Promise<JobRun | null>;
|
|
49
|
-
/** Rows whose lease ran out go back to the queue while attempts remain, else fail. */
|
|
50
|
-
reclaim(now: Date): Promise<
|
|
49
|
+
/** Rows whose lease ran out go back to the queue while attempts remain, else fail; how many there were. */
|
|
50
|
+
reclaim(now: Date): Promise<number>;
|
|
51
51
|
/** Claims the oldest queued run by a conditional update; null when the queue is empty or another worker won. */
|
|
52
52
|
claim(now: Date): Promise<JobRun | null>;
|
|
53
53
|
progress(id: string, percent: number, note?: string): Promise<void>;
|
|
54
54
|
cancelRequested(id: string): Promise<boolean>;
|
|
55
|
+
/** Records the outcome; the status written, `queued` when the failure left a retry for the next pass. */
|
|
55
56
|
finish(run: JobRun, outcome: {
|
|
56
57
|
result?: unknown;
|
|
57
58
|
error?: string;
|
|
58
59
|
cancelled?: boolean;
|
|
59
|
-
}): Promise<
|
|
60
|
+
}): Promise<JobRun['status']>;
|
|
60
61
|
}
|
|
61
62
|
/**
|
|
62
63
|
* What the process runs beside the action server:
|
|
@@ -71,7 +72,11 @@ export interface RuntimeOptions {
|
|
|
71
72
|
gatewayUrl: string;
|
|
72
73
|
mode: RuntimeMode;
|
|
73
74
|
tokenFile?: string;
|
|
75
|
+
/** False when the app has no runs table (ISOMORPH_JOB_RUNS=none): nothing reads or works it. */
|
|
76
|
+
jobRuns?: boolean;
|
|
74
77
|
log?: (line: string) => void;
|
|
78
|
+
/** The pause between queue passes while work is found; the tests shorten it. */
|
|
79
|
+
queuePollMs?: number;
|
|
75
80
|
}
|
|
76
81
|
export declare class Runtime {
|
|
77
82
|
private readonly options;
|
|
@@ -79,11 +84,23 @@ export declare class Runtime {
|
|
|
79
84
|
private readonly running;
|
|
80
85
|
private readonly timers;
|
|
81
86
|
private stopped;
|
|
87
|
+
private queueLive;
|
|
88
|
+
private queueWake;
|
|
82
89
|
private readonly runs;
|
|
83
90
|
private readonly log;
|
|
84
91
|
readonly server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
85
92
|
constructor(options: RuntimeOptions);
|
|
86
93
|
start(): Promise<number>;
|
|
94
|
+
/** Whether this process works the queue at all: not the gate, a runs table, an app with jobs, a gateway to reach. */
|
|
95
|
+
private worksQueue;
|
|
96
|
+
/**
|
|
97
|
+
* Starts the queue loop unless it is already live. The first pass runs at
|
|
98
|
+
* once; each further pass follows QUEUE_POLL_MS after one that found work
|
|
99
|
+
* (or threw: a gateway that is waking answers 5xx, and the row that woke
|
|
100
|
+
* the loop is still there). A pass that finds nothing ends the loop with no
|
|
101
|
+
* timer armed, so an idle runtime makes no data calls at all.
|
|
102
|
+
*/
|
|
103
|
+
wakeQueue(): void;
|
|
87
104
|
/**
|
|
88
105
|
* `--run-job <name>`: one scheduled run of one job, as a CronJob pod runs it.
|
|
89
106
|
* The run is the scheduler's own (`runScheduled`, the current minute as its
|
|
@@ -99,8 +116,13 @@ export declare class Runtime {
|
|
|
99
116
|
private scheduleTick;
|
|
100
117
|
/** One scheduled run; true when the handler returned, false when it threw or the last tick's run is still going. */
|
|
101
118
|
private runScheduled;
|
|
102
|
-
/**
|
|
103
|
-
|
|
119
|
+
/**
|
|
120
|
+
* One pass of the queue loop: reclaim expired leases, claim one run, run it.
|
|
121
|
+
* True when the pass found work — a lease reclaimed, a run claimed, or the
|
|
122
|
+
* run it finished left a retry queued — so the loop should look again; false
|
|
123
|
+
* when the queue is empty (or another pass is already going).
|
|
124
|
+
*/
|
|
125
|
+
work(): Promise<boolean>;
|
|
104
126
|
private serve;
|
|
105
127
|
private reserved;
|
|
106
128
|
}
|
package/dist/runtime.js
CHANGED
|
@@ -16,6 +16,20 @@
|
|
|
16
16
|
// when it threw or the app has no such job. No port is opened, no scheduler
|
|
17
17
|
// or queue loop starts.
|
|
18
18
|
//
|
|
19
|
+
// The queue is worked on demand, not on a clock. An idle kit app makes no
|
|
20
|
+
// database calls: the runtime is the process that serves the enqueue action
|
|
21
|
+
// and the one that finishes every run, so it knows when work exists. It runs
|
|
22
|
+
// one pass at start (reclaiming whatever a crashed predecessor left running
|
|
23
|
+
// and draining any backlog), then loops every QUEUE_POLL_MS only while a pass
|
|
24
|
+
// found something — a claimed run, a reclaimed lease, a run that finished with
|
|
25
|
+
// retries queued — and stops with no timer armed once the queue is empty. An
|
|
26
|
+
// enqueue starts the loop again. This holds because kit-runtime is pinned to
|
|
27
|
+
// one replica; a multi-replica runtime would need a slow periodic poll as well,
|
|
28
|
+
// since a row inserted through another replica is invisible to this one until
|
|
29
|
+
// it looks (out of scope here). The V2 idle controller reads pg_stat_database
|
|
30
|
+
// counters to decide a database is quiet, so a poll that issued a transaction
|
|
31
|
+
// every 2 s kept the shared Postgres awake for every app that had a job.
|
|
32
|
+
//
|
|
19
33
|
// Environment:
|
|
20
34
|
// PORT the action server's port (default 8080)
|
|
21
35
|
// ISOMORPH_GATEWAY_URL the gateway the SDK calls; HARBOUR_GATEWAY_URL
|
|
@@ -30,6 +44,10 @@
|
|
|
30
44
|
// in-process scheduler. Set by Provision on the
|
|
31
45
|
// hosted kit-runtime; `isomorph dev` leaves it
|
|
32
46
|
// unset and keeps cron in-process
|
|
47
|
+
// ISOMORPH_JOB_RUNS=none the app has no runs table (it never calls
|
|
48
|
+
// isomorph.jobs): no queue loop, and --run-job
|
|
49
|
+
// runs without waiting on the data path. Set by
|
|
50
|
+
// Provision; the table exists otherwise
|
|
33
51
|
// ISOMORPH_APP_ROOT the app root (default: the working directory)
|
|
34
52
|
import { createServer } from 'node:http';
|
|
35
53
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
@@ -174,13 +192,14 @@ export class Runs {
|
|
|
174
192
|
}
|
|
175
193
|
return data;
|
|
176
194
|
}
|
|
177
|
-
/** Rows whose lease ran out go back to the queue while attempts remain, else fail. */
|
|
195
|
+
/** Rows whose lease ran out go back to the queue while attempts remain, else fail; how many there were. */
|
|
178
196
|
async reclaim(now) {
|
|
179
197
|
const { data: expired } = await this.table().select('*').eq('status', 'running').lt('lease_until', now.toISOString()).limit(20);
|
|
180
198
|
for (const run of expired) {
|
|
181
199
|
const exhausted = run.attempt >= run.max_attempts;
|
|
182
200
|
await this.table().update({ status: exhausted ? 'failed' : 'queued', error: exhausted ? 'the worker was interrupted and no attempts remain' : run.error, lease_until: null, updated_at: now.toISOString() }).eq('id', run.id).eq('status', 'running');
|
|
183
201
|
}
|
|
202
|
+
return expired.length;
|
|
184
203
|
}
|
|
185
204
|
/** Claims the oldest queued run by a conditional update; null when the queue is empty or another worker won. */
|
|
186
205
|
async claim(now) {
|
|
@@ -198,10 +217,12 @@ export class Runs {
|
|
|
198
217
|
const { data } = await this.table().select('cancel_requested').eq('id', id).maybeSingle();
|
|
199
218
|
return Boolean(data?.cancel_requested);
|
|
200
219
|
}
|
|
220
|
+
/** Records the outcome; the status written, `queued` when the failure left a retry for the next pass. */
|
|
201
221
|
async finish(run, outcome) {
|
|
202
222
|
const retry = outcome.error !== undefined && !outcome.cancelled && run.attempt < run.max_attempts;
|
|
203
223
|
const status = outcome.cancelled ? 'cancelled' : outcome.error === undefined ? 'succeeded' : retry ? 'queued' : 'failed';
|
|
204
224
|
await this.table().update({ status, result: outcome.result ?? null, error: outcome.error ?? null, progress: status === 'succeeded' ? 100 : run.progress, lease_until: null, updated_at: new Date().toISOString() }).eq('id', run.id).eq('status', 'running');
|
|
225
|
+
return status;
|
|
205
226
|
}
|
|
206
227
|
}
|
|
207
228
|
export class Runtime {
|
|
@@ -210,6 +231,10 @@ export class Runtime {
|
|
|
210
231
|
running = new Set();
|
|
211
232
|
timers = [];
|
|
212
233
|
stopped = false;
|
|
234
|
+
// The queue loop is live while a pass runs or its next pass is timed; a
|
|
235
|
+
// wake that arrives mid-pass is remembered so the pass after it looks again.
|
|
236
|
+
queueLive = false;
|
|
237
|
+
queueWake = false;
|
|
213
238
|
runs;
|
|
214
239
|
log;
|
|
215
240
|
server = createServer((request, response) => { void this.serve(request, response); });
|
|
@@ -232,10 +257,52 @@ export class Runtime {
|
|
|
232
257
|
this.every(TOKEN_REFRESH_MS, () => this.refreshToken());
|
|
233
258
|
if (this.options.mode === 'full' && this.inventory.jobs.some((job) => job.schedule))
|
|
234
259
|
this.scheduleTick();
|
|
235
|
-
|
|
236
|
-
this.every(QUEUE_POLL_MS, () => this.work());
|
|
260
|
+
this.wakeQueue();
|
|
237
261
|
return port;
|
|
238
262
|
}
|
|
263
|
+
/** Whether this process works the queue at all: not the gate, a runs table, an app with jobs, a gateway to reach. */
|
|
264
|
+
worksQueue() {
|
|
265
|
+
return this.options.mode !== 'gate' && this.options.jobRuns !== false && this.inventory.jobs.length > 0 && Boolean(this.options.gatewayUrl);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Starts the queue loop unless it is already live. The first pass runs at
|
|
269
|
+
* once; each further pass follows QUEUE_POLL_MS after one that found work
|
|
270
|
+
* (or threw: a gateway that is waking answers 5xx, and the row that woke
|
|
271
|
+
* the loop is still there). A pass that finds nothing ends the loop with no
|
|
272
|
+
* timer armed, so an idle runtime makes no data calls at all.
|
|
273
|
+
*/
|
|
274
|
+
wakeQueue() {
|
|
275
|
+
if (this.stopped || !this.worksQueue())
|
|
276
|
+
return;
|
|
277
|
+
if (this.queueLive) {
|
|
278
|
+
this.queueWake = true;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
this.queueLive = true;
|
|
282
|
+
const pass = async () => {
|
|
283
|
+
if (this.stopped) {
|
|
284
|
+
this.queueLive = false;
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
this.queueWake = false;
|
|
288
|
+
let again = true;
|
|
289
|
+
try {
|
|
290
|
+
again = await this.work();
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
this.log(`queue pass failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
294
|
+
}
|
|
295
|
+
if (this.stopped) {
|
|
296
|
+
this.queueLive = false;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (again || this.queueWake)
|
|
300
|
+
this.timers.push(setTimeout(() => void pass(), this.options.queuePollMs ?? QUEUE_POLL_MS));
|
|
301
|
+
else
|
|
302
|
+
this.queueLive = false;
|
|
303
|
+
};
|
|
304
|
+
void pass();
|
|
305
|
+
}
|
|
239
306
|
/**
|
|
240
307
|
* `--run-job <name>`: one scheduled run of one job, as a CronJob pod runs it.
|
|
241
308
|
* The run is the scheduler's own (`runScheduled`, the current minute as its
|
|
@@ -250,7 +317,7 @@ export class Runtime {
|
|
|
250
317
|
this.log(`this app has no ${JOBS_DIR}/${name}.ts`);
|
|
251
318
|
return false;
|
|
252
319
|
}
|
|
253
|
-
if (this.options.gatewayUrl && !(await this.awaitDataPath()))
|
|
320
|
+
if (this.options.gatewayUrl && this.options.jobRuns !== false && !(await this.awaitDataPath()))
|
|
254
321
|
return false;
|
|
255
322
|
return this.runScheduled(name, new Date(Math.floor(now.getTime() / 60_000) * 60_000));
|
|
256
323
|
}
|
|
@@ -348,20 +415,25 @@ export class Runtime {
|
|
|
348
415
|
this.running.delete(key);
|
|
349
416
|
}
|
|
350
417
|
}
|
|
351
|
-
/**
|
|
418
|
+
/**
|
|
419
|
+
* One pass of the queue loop: reclaim expired leases, claim one run, run it.
|
|
420
|
+
* True when the pass found work — a lease reclaimed, a run claimed, or the
|
|
421
|
+
* run it finished left a retry queued — so the loop should look again; false
|
|
422
|
+
* when the queue is empty (or another pass is already going).
|
|
423
|
+
*/
|
|
352
424
|
async work() {
|
|
353
425
|
if (this.running.has('queue'))
|
|
354
|
-
return;
|
|
426
|
+
return false;
|
|
355
427
|
this.running.add('queue');
|
|
356
428
|
try {
|
|
357
429
|
const now = new Date();
|
|
358
|
-
await this.runs.reclaim(now);
|
|
430
|
+
const reclaimed = await this.runs.reclaim(now);
|
|
359
431
|
const run = await this.runs.claim(now);
|
|
360
432
|
if (!run)
|
|
361
|
-
return;
|
|
433
|
+
return reclaimed > 0;
|
|
362
434
|
if (!this.inventory.jobs.some((job) => job.name === run.job)) {
|
|
363
435
|
await this.runs.finish({ ...run, attempt: run.max_attempts }, { error: `no jobs/${run.job}.ts in this app` });
|
|
364
|
-
return;
|
|
436
|
+
return true;
|
|
365
437
|
}
|
|
366
438
|
try {
|
|
367
439
|
const handler = await loadHandler(this.options.root, JOBS_DIR, run.job);
|
|
@@ -376,6 +448,7 @@ export class Runtime {
|
|
|
376
448
|
catch (error) {
|
|
377
449
|
await this.runs.finish(run, { error: error instanceof Error ? error.message : String(error) });
|
|
378
450
|
}
|
|
451
|
+
return true;
|
|
379
452
|
}
|
|
380
453
|
finally {
|
|
381
454
|
this.running.delete('queue');
|
|
@@ -431,7 +504,9 @@ export class Runtime {
|
|
|
431
504
|
throw new IsomorphError('VALIDATION_FAILED', `this app has no ${JOBS_DIR}/${job || '<name>'}.ts`, undefined, undefined, 400);
|
|
432
505
|
if (!owner)
|
|
433
506
|
throw new IsomorphError('AUTH_REQUIRED', 'a run is started by a signed-in person', undefined, undefined, 401);
|
|
434
|
-
|
|
507
|
+
const queued = await this.runs.enqueue(job, body.input, owner);
|
|
508
|
+
this.wakeQueue();
|
|
509
|
+
return queued;
|
|
435
510
|
}
|
|
436
511
|
case GET_RUN_ACTION: {
|
|
437
512
|
const run = typeof body.id === 'string' ? await this.runs.get(body.id, owner) : null;
|
|
@@ -458,6 +533,7 @@ export function optionsFromEnvironment(env = process.env) {
|
|
|
458
533
|
gatewayUrl: (env.ISOMORPH_GATEWAY_URL || env.HARBOUR_GATEWAY_URL || '').replace(/\/$/, ''),
|
|
459
534
|
mode: env.ISOMORPH_RUNTIME_MODE === 'gate' ? 'gate' : env.ISOMORPH_JOBS_SCHEDULER === 'platform' ? 'actions' : 'full',
|
|
460
535
|
tokenFile: env.HARBOUR_WORKLOAD_TOKEN_FILE || undefined,
|
|
536
|
+
jobRuns: env.ISOMORPH_JOB_RUNS !== 'none',
|
|
461
537
|
};
|
|
462
538
|
}
|
|
463
539
|
/** The job named by `--run-job <name>` or `--run-job=<name>`; undefined when the runtime is a server. */
|