@isomorph.ai/app-sdk 1.4.1 → 1.4.2
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 +25 -5
- package/dist/runtime.js +80 -9
- 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:
|
|
@@ -72,6 +73,8 @@ export interface RuntimeOptions {
|
|
|
72
73
|
mode: RuntimeMode;
|
|
73
74
|
tokenFile?: string;
|
|
74
75
|
log?: (line: string) => void;
|
|
76
|
+
/** The pause between queue passes while work is found; the tests shorten it. */
|
|
77
|
+
queuePollMs?: number;
|
|
75
78
|
}
|
|
76
79
|
export declare class Runtime {
|
|
77
80
|
private readonly options;
|
|
@@ -79,11 +82,23 @@ export declare class Runtime {
|
|
|
79
82
|
private readonly running;
|
|
80
83
|
private readonly timers;
|
|
81
84
|
private stopped;
|
|
85
|
+
private queueLive;
|
|
86
|
+
private queueWake;
|
|
82
87
|
private readonly runs;
|
|
83
88
|
private readonly log;
|
|
84
89
|
readonly server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
85
90
|
constructor(options: RuntimeOptions);
|
|
86
91
|
start(): Promise<number>;
|
|
92
|
+
/** Whether this process works the queue at all: not the gate, an app with jobs, a gateway to reach. */
|
|
93
|
+
private worksQueue;
|
|
94
|
+
/**
|
|
95
|
+
* Starts the queue loop unless it is already live. The first pass runs at
|
|
96
|
+
* once; each further pass follows QUEUE_POLL_MS after one that found work
|
|
97
|
+
* (or threw: a gateway that is waking answers 5xx, and the row that woke
|
|
98
|
+
* the loop is still there). A pass that finds nothing ends the loop with no
|
|
99
|
+
* timer armed, so an idle runtime makes no data calls at all.
|
|
100
|
+
*/
|
|
101
|
+
wakeQueue(): void;
|
|
87
102
|
/**
|
|
88
103
|
* `--run-job <name>`: one scheduled run of one job, as a CronJob pod runs it.
|
|
89
104
|
* The run is the scheduler's own (`runScheduled`, the current minute as its
|
|
@@ -99,8 +114,13 @@ export declare class Runtime {
|
|
|
99
114
|
private scheduleTick;
|
|
100
115
|
/** One scheduled run; true when the handler returned, false when it threw or the last tick's run is still going. */
|
|
101
116
|
private runScheduled;
|
|
102
|
-
/**
|
|
103
|
-
|
|
117
|
+
/**
|
|
118
|
+
* One pass of the queue loop: reclaim expired leases, claim one run, run it.
|
|
119
|
+
* True when the pass found work — a lease reclaimed, a run claimed, or the
|
|
120
|
+
* run it finished left a retry queued — so the loop should look again; false
|
|
121
|
+
* when the queue is empty (or another pass is already going).
|
|
122
|
+
*/
|
|
123
|
+
work(): Promise<boolean>;
|
|
104
124
|
private serve;
|
|
105
125
|
private reserved;
|
|
106
126
|
}
|
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
|
|
@@ -174,13 +188,14 @@ export class Runs {
|
|
|
174
188
|
}
|
|
175
189
|
return data;
|
|
176
190
|
}
|
|
177
|
-
/** Rows whose lease ran out go back to the queue while attempts remain, else fail. */
|
|
191
|
+
/** Rows whose lease ran out go back to the queue while attempts remain, else fail; how many there were. */
|
|
178
192
|
async reclaim(now) {
|
|
179
193
|
const { data: expired } = await this.table().select('*').eq('status', 'running').lt('lease_until', now.toISOString()).limit(20);
|
|
180
194
|
for (const run of expired) {
|
|
181
195
|
const exhausted = run.attempt >= run.max_attempts;
|
|
182
196
|
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
197
|
}
|
|
198
|
+
return expired.length;
|
|
184
199
|
}
|
|
185
200
|
/** Claims the oldest queued run by a conditional update; null when the queue is empty or another worker won. */
|
|
186
201
|
async claim(now) {
|
|
@@ -198,10 +213,12 @@ export class Runs {
|
|
|
198
213
|
const { data } = await this.table().select('cancel_requested').eq('id', id).maybeSingle();
|
|
199
214
|
return Boolean(data?.cancel_requested);
|
|
200
215
|
}
|
|
216
|
+
/** Records the outcome; the status written, `queued` when the failure left a retry for the next pass. */
|
|
201
217
|
async finish(run, outcome) {
|
|
202
218
|
const retry = outcome.error !== undefined && !outcome.cancelled && run.attempt < run.max_attempts;
|
|
203
219
|
const status = outcome.cancelled ? 'cancelled' : outcome.error === undefined ? 'succeeded' : retry ? 'queued' : 'failed';
|
|
204
220
|
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');
|
|
221
|
+
return status;
|
|
205
222
|
}
|
|
206
223
|
}
|
|
207
224
|
export class Runtime {
|
|
@@ -210,6 +227,10 @@ export class Runtime {
|
|
|
210
227
|
running = new Set();
|
|
211
228
|
timers = [];
|
|
212
229
|
stopped = false;
|
|
230
|
+
// The queue loop is live while a pass runs or its next pass is timed; a
|
|
231
|
+
// wake that arrives mid-pass is remembered so the pass after it looks again.
|
|
232
|
+
queueLive = false;
|
|
233
|
+
queueWake = false;
|
|
213
234
|
runs;
|
|
214
235
|
log;
|
|
215
236
|
server = createServer((request, response) => { void this.serve(request, response); });
|
|
@@ -232,10 +253,52 @@ export class Runtime {
|
|
|
232
253
|
this.every(TOKEN_REFRESH_MS, () => this.refreshToken());
|
|
233
254
|
if (this.options.mode === 'full' && this.inventory.jobs.some((job) => job.schedule))
|
|
234
255
|
this.scheduleTick();
|
|
235
|
-
|
|
236
|
-
this.every(QUEUE_POLL_MS, () => this.work());
|
|
256
|
+
this.wakeQueue();
|
|
237
257
|
return port;
|
|
238
258
|
}
|
|
259
|
+
/** Whether this process works the queue at all: not the gate, an app with jobs, a gateway to reach. */
|
|
260
|
+
worksQueue() {
|
|
261
|
+
return this.options.mode !== 'gate' && this.inventory.jobs.length > 0 && Boolean(this.options.gatewayUrl);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Starts the queue loop unless it is already live. The first pass runs at
|
|
265
|
+
* once; each further pass follows QUEUE_POLL_MS after one that found work
|
|
266
|
+
* (or threw: a gateway that is waking answers 5xx, and the row that woke
|
|
267
|
+
* the loop is still there). A pass that finds nothing ends the loop with no
|
|
268
|
+
* timer armed, so an idle runtime makes no data calls at all.
|
|
269
|
+
*/
|
|
270
|
+
wakeQueue() {
|
|
271
|
+
if (this.stopped || !this.worksQueue())
|
|
272
|
+
return;
|
|
273
|
+
if (this.queueLive) {
|
|
274
|
+
this.queueWake = true;
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
this.queueLive = true;
|
|
278
|
+
const pass = async () => {
|
|
279
|
+
if (this.stopped) {
|
|
280
|
+
this.queueLive = false;
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
this.queueWake = false;
|
|
284
|
+
let again = true;
|
|
285
|
+
try {
|
|
286
|
+
again = await this.work();
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
this.log(`queue pass failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
290
|
+
}
|
|
291
|
+
if (this.stopped) {
|
|
292
|
+
this.queueLive = false;
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (again || this.queueWake)
|
|
296
|
+
this.timers.push(setTimeout(() => void pass(), this.options.queuePollMs ?? QUEUE_POLL_MS));
|
|
297
|
+
else
|
|
298
|
+
this.queueLive = false;
|
|
299
|
+
};
|
|
300
|
+
void pass();
|
|
301
|
+
}
|
|
239
302
|
/**
|
|
240
303
|
* `--run-job <name>`: one scheduled run of one job, as a CronJob pod runs it.
|
|
241
304
|
* The run is the scheduler's own (`runScheduled`, the current minute as its
|
|
@@ -348,20 +411,25 @@ export class Runtime {
|
|
|
348
411
|
this.running.delete(key);
|
|
349
412
|
}
|
|
350
413
|
}
|
|
351
|
-
/**
|
|
414
|
+
/**
|
|
415
|
+
* One pass of the queue loop: reclaim expired leases, claim one run, run it.
|
|
416
|
+
* True when the pass found work — a lease reclaimed, a run claimed, or the
|
|
417
|
+
* run it finished left a retry queued — so the loop should look again; false
|
|
418
|
+
* when the queue is empty (or another pass is already going).
|
|
419
|
+
*/
|
|
352
420
|
async work() {
|
|
353
421
|
if (this.running.has('queue'))
|
|
354
|
-
return;
|
|
422
|
+
return false;
|
|
355
423
|
this.running.add('queue');
|
|
356
424
|
try {
|
|
357
425
|
const now = new Date();
|
|
358
|
-
await this.runs.reclaim(now);
|
|
426
|
+
const reclaimed = await this.runs.reclaim(now);
|
|
359
427
|
const run = await this.runs.claim(now);
|
|
360
428
|
if (!run)
|
|
361
|
-
return;
|
|
429
|
+
return reclaimed > 0;
|
|
362
430
|
if (!this.inventory.jobs.some((job) => job.name === run.job)) {
|
|
363
431
|
await this.runs.finish({ ...run, attempt: run.max_attempts }, { error: `no jobs/${run.job}.ts in this app` });
|
|
364
|
-
return;
|
|
432
|
+
return true;
|
|
365
433
|
}
|
|
366
434
|
try {
|
|
367
435
|
const handler = await loadHandler(this.options.root, JOBS_DIR, run.job);
|
|
@@ -376,6 +444,7 @@ export class Runtime {
|
|
|
376
444
|
catch (error) {
|
|
377
445
|
await this.runs.finish(run, { error: error instanceof Error ? error.message : String(error) });
|
|
378
446
|
}
|
|
447
|
+
return true;
|
|
379
448
|
}
|
|
380
449
|
finally {
|
|
381
450
|
this.running.delete('queue');
|
|
@@ -431,7 +500,9 @@ export class Runtime {
|
|
|
431
500
|
throw new IsomorphError('VALIDATION_FAILED', `this app has no ${JOBS_DIR}/${job || '<name>'}.ts`, undefined, undefined, 400);
|
|
432
501
|
if (!owner)
|
|
433
502
|
throw new IsomorphError('AUTH_REQUIRED', 'a run is started by a signed-in person', undefined, undefined, 401);
|
|
434
|
-
|
|
503
|
+
const queued = await this.runs.enqueue(job, body.input, owner);
|
|
504
|
+
this.wakeQueue();
|
|
505
|
+
return queued;
|
|
435
506
|
}
|
|
436
507
|
case GET_RUN_ACTION: {
|
|
437
508
|
const run = typeof body.id === 'string' ? await this.runs.get(body.id, owner) : null;
|