@isomorph.ai/app-sdk 1.4.0 → 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 +30 -7
- package/dist/runtime.js +114 -11
- package/package.json +1 -1
package/dist/runtime.d.ts
CHANGED
|
@@ -39,22 +39,25 @@ export declare class Runs {
|
|
|
39
39
|
private readonly client;
|
|
40
40
|
constructor(client: IsomorphClient);
|
|
41
41
|
private table;
|
|
42
|
+
/** The cheapest read the runs table answers; rejects while the gateway's data path is down. */
|
|
43
|
+
ready(): Promise<void>;
|
|
42
44
|
enqueue(job: string, input: unknown, owner: string): Promise<{
|
|
43
45
|
id: string;
|
|
44
46
|
}>;
|
|
45
47
|
get(id: string, owner: string): Promise<JobRun | null>;
|
|
46
48
|
cancel(id: string, owner: string): Promise<JobRun | null>;
|
|
47
|
-
/** Rows whose lease ran out go back to the queue while attempts remain, else fail. */
|
|
48
|
-
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>;
|
|
49
51
|
/** Claims the oldest queued run by a conditional update; null when the queue is empty or another worker won. */
|
|
50
52
|
claim(now: Date): Promise<JobRun | null>;
|
|
51
53
|
progress(id: string, percent: number, note?: string): Promise<void>;
|
|
52
54
|
cancelRequested(id: string): Promise<boolean>;
|
|
55
|
+
/** Records the outcome; the status written, `queued` when the failure left a retry for the next pass. */
|
|
53
56
|
finish(run: JobRun, outcome: {
|
|
54
57
|
result?: unknown;
|
|
55
58
|
error?: string;
|
|
56
59
|
cancelled?: boolean;
|
|
57
|
-
}): Promise<
|
|
60
|
+
}): Promise<JobRun['status']>;
|
|
58
61
|
}
|
|
59
62
|
/**
|
|
60
63
|
* What the process runs beside the action server:
|
|
@@ -70,6 +73,8 @@ export interface RuntimeOptions {
|
|
|
70
73
|
mode: RuntimeMode;
|
|
71
74
|
tokenFile?: string;
|
|
72
75
|
log?: (line: string) => void;
|
|
76
|
+
/** The pause between queue passes while work is found; the tests shorten it. */
|
|
77
|
+
queuePollMs?: number;
|
|
73
78
|
}
|
|
74
79
|
export declare class Runtime {
|
|
75
80
|
private readonly options;
|
|
@@ -77,27 +82,45 @@ export declare class Runtime {
|
|
|
77
82
|
private readonly running;
|
|
78
83
|
private readonly timers;
|
|
79
84
|
private stopped;
|
|
85
|
+
private queueLive;
|
|
86
|
+
private queueWake;
|
|
80
87
|
private readonly runs;
|
|
81
88
|
private readonly log;
|
|
82
89
|
readonly server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
83
90
|
constructor(options: RuntimeOptions);
|
|
84
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;
|
|
85
102
|
/**
|
|
86
103
|
* `--run-job <name>`: one scheduled run of one job, as a CronJob pod runs it.
|
|
87
104
|
* The run is the scheduler's own (`runScheduled`, the current minute as its
|
|
88
105
|
* tick), so it is loaded, run and recorded exactly as an in-process tick
|
|
89
|
-
* would have been. False when the app has no such job
|
|
90
|
-
* the log names which.
|
|
106
|
+
* would have been. False when the app has no such job, the gateway's data
|
|
107
|
+
* path never answered, or the handler threw; the log names which.
|
|
91
108
|
*/
|
|
92
109
|
runJobOnce(name: string, now?: Date): Promise<boolean>;
|
|
110
|
+
private awaitDataPath;
|
|
93
111
|
stop(): Promise<void>;
|
|
94
112
|
private every;
|
|
95
113
|
private refreshToken;
|
|
96
114
|
private scheduleTick;
|
|
97
115
|
/** One scheduled run; true when the handler returned, false when it threw or the last tick's run is still going. */
|
|
98
116
|
private runScheduled;
|
|
99
|
-
/**
|
|
100
|
-
|
|
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>;
|
|
101
124
|
private serve;
|
|
102
125
|
private reserved;
|
|
103
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
|
|
@@ -54,6 +68,10 @@ const LEASE_MS = 5 * 60_000;
|
|
|
54
68
|
const QUEUE_POLL_MS = 2_000;
|
|
55
69
|
const TOKEN_REFRESH_MS = 60_000;
|
|
56
70
|
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
71
|
+
// A CronJob pod may start while the app's database is still waking: --run-job
|
|
72
|
+
// waits this long for the gateway's data path, probing every retry interval.
|
|
73
|
+
const DATA_PATH_WAIT_MS = 90_000;
|
|
74
|
+
const DATA_PATH_RETRY_MS = 3_000;
|
|
57
75
|
/** Every `actions/<name>.ts` and `jobs/<name>.ts` the app carries, by logical name. */
|
|
58
76
|
export async function discover(root) {
|
|
59
77
|
const list = async (dir) => {
|
|
@@ -150,6 +168,10 @@ export class Runs {
|
|
|
150
168
|
this.client = client;
|
|
151
169
|
}
|
|
152
170
|
table() { return this.client.data.from(RUNS_TABLE); }
|
|
171
|
+
/** The cheapest read the runs table answers; rejects while the gateway's data path is down. */
|
|
172
|
+
async ready() {
|
|
173
|
+
await this.table().select('id').limit(1);
|
|
174
|
+
}
|
|
153
175
|
async enqueue(job, input, owner) {
|
|
154
176
|
const { data } = await this.table().insert({ job, input: input ?? null, status: 'queued', attempt: 0, max_attempts: DEFAULT_MAX_ATTEMPTS, progress: 0, owner_subject: owner, cancel_requested: false }).select('id').single();
|
|
155
177
|
return { id: data.id };
|
|
@@ -166,13 +188,14 @@ export class Runs {
|
|
|
166
188
|
}
|
|
167
189
|
return data;
|
|
168
190
|
}
|
|
169
|
-
/** 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. */
|
|
170
192
|
async reclaim(now) {
|
|
171
193
|
const { data: expired } = await this.table().select('*').eq('status', 'running').lt('lease_until', now.toISOString()).limit(20);
|
|
172
194
|
for (const run of expired) {
|
|
173
195
|
const exhausted = run.attempt >= run.max_attempts;
|
|
174
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');
|
|
175
197
|
}
|
|
198
|
+
return expired.length;
|
|
176
199
|
}
|
|
177
200
|
/** Claims the oldest queued run by a conditional update; null when the queue is empty or another worker won. */
|
|
178
201
|
async claim(now) {
|
|
@@ -190,10 +213,12 @@ export class Runs {
|
|
|
190
213
|
const { data } = await this.table().select('cancel_requested').eq('id', id).maybeSingle();
|
|
191
214
|
return Boolean(data?.cancel_requested);
|
|
192
215
|
}
|
|
216
|
+
/** Records the outcome; the status written, `queued` when the failure left a retry for the next pass. */
|
|
193
217
|
async finish(run, outcome) {
|
|
194
218
|
const retry = outcome.error !== undefined && !outcome.cancelled && run.attempt < run.max_attempts;
|
|
195
219
|
const status = outcome.cancelled ? 'cancelled' : outcome.error === undefined ? 'succeeded' : retry ? 'queued' : 'failed';
|
|
196
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;
|
|
197
222
|
}
|
|
198
223
|
}
|
|
199
224
|
export class Runtime {
|
|
@@ -202,6 +227,10 @@ export class Runtime {
|
|
|
202
227
|
running = new Set();
|
|
203
228
|
timers = [];
|
|
204
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;
|
|
205
234
|
runs;
|
|
206
235
|
log;
|
|
207
236
|
server = createServer((request, response) => { void this.serve(request, response); });
|
|
@@ -224,16 +253,58 @@ export class Runtime {
|
|
|
224
253
|
this.every(TOKEN_REFRESH_MS, () => this.refreshToken());
|
|
225
254
|
if (this.options.mode === 'full' && this.inventory.jobs.some((job) => job.schedule))
|
|
226
255
|
this.scheduleTick();
|
|
227
|
-
|
|
228
|
-
this.every(QUEUE_POLL_MS, () => this.work());
|
|
256
|
+
this.wakeQueue();
|
|
229
257
|
return port;
|
|
230
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
|
+
}
|
|
231
302
|
/**
|
|
232
303
|
* `--run-job <name>`: one scheduled run of one job, as a CronJob pod runs it.
|
|
233
304
|
* The run is the scheduler's own (`runScheduled`, the current minute as its
|
|
234
305
|
* tick), so it is loaded, run and recorded exactly as an in-process tick
|
|
235
|
-
* would have been. False when the app has no such job
|
|
236
|
-
* the log names which.
|
|
306
|
+
* would have been. False when the app has no such job, the gateway's data
|
|
307
|
+
* path never answered, or the handler threw; the log names which.
|
|
237
308
|
*/
|
|
238
309
|
async runJobOnce(name, now = new Date()) {
|
|
239
310
|
this.inventory = await discover(this.options.root);
|
|
@@ -242,8 +313,32 @@ export class Runtime {
|
|
|
242
313
|
this.log(`this app has no ${JOBS_DIR}/${name}.ts`);
|
|
243
314
|
return false;
|
|
244
315
|
}
|
|
316
|
+
if (this.options.gatewayUrl && !(await this.awaitDataPath()))
|
|
317
|
+
return false;
|
|
245
318
|
return this.runScheduled(name, new Date(Math.floor(now.getTime() / 60_000) * 60_000));
|
|
246
319
|
}
|
|
320
|
+
// The pod is scheduled by the cluster clock, not by the app's readiness: its
|
|
321
|
+
// database may be paused or waking (plan §5.3) and the gateway answers 5xx
|
|
322
|
+
// until it is up. The handler runs once the cheapest read answers; when it
|
|
323
|
+
// never does within the wait, the run fails with one line naming the last error.
|
|
324
|
+
async awaitDataPath() {
|
|
325
|
+
const deadline = Date.now() + DATA_PATH_WAIT_MS;
|
|
326
|
+
for (;;) {
|
|
327
|
+
try {
|
|
328
|
+
await this.runs.ready();
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
333
|
+
if (Date.now() + DATA_PATH_RETRY_MS > deadline) {
|
|
334
|
+
this.log(`the data path did not answer within ${DATA_PATH_WAIT_MS / 1000} s: ${reason}`);
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
this.log(`waiting for the data path: ${reason}`);
|
|
338
|
+
await new Promise((wake) => setTimeout(wake, DATA_PATH_RETRY_MS));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
247
342
|
async stop() {
|
|
248
343
|
this.stopped = true;
|
|
249
344
|
for (const timer of this.timers)
|
|
@@ -316,20 +411,25 @@ export class Runtime {
|
|
|
316
411
|
this.running.delete(key);
|
|
317
412
|
}
|
|
318
413
|
}
|
|
319
|
-
/**
|
|
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
|
+
*/
|
|
320
420
|
async work() {
|
|
321
421
|
if (this.running.has('queue'))
|
|
322
|
-
return;
|
|
422
|
+
return false;
|
|
323
423
|
this.running.add('queue');
|
|
324
424
|
try {
|
|
325
425
|
const now = new Date();
|
|
326
|
-
await this.runs.reclaim(now);
|
|
426
|
+
const reclaimed = await this.runs.reclaim(now);
|
|
327
427
|
const run = await this.runs.claim(now);
|
|
328
428
|
if (!run)
|
|
329
|
-
return;
|
|
429
|
+
return reclaimed > 0;
|
|
330
430
|
if (!this.inventory.jobs.some((job) => job.name === run.job)) {
|
|
331
431
|
await this.runs.finish({ ...run, attempt: run.max_attempts }, { error: `no jobs/${run.job}.ts in this app` });
|
|
332
|
-
return;
|
|
432
|
+
return true;
|
|
333
433
|
}
|
|
334
434
|
try {
|
|
335
435
|
const handler = await loadHandler(this.options.root, JOBS_DIR, run.job);
|
|
@@ -344,6 +444,7 @@ export class Runtime {
|
|
|
344
444
|
catch (error) {
|
|
345
445
|
await this.runs.finish(run, { error: error instanceof Error ? error.message : String(error) });
|
|
346
446
|
}
|
|
447
|
+
return true;
|
|
347
448
|
}
|
|
348
449
|
finally {
|
|
349
450
|
this.running.delete('queue');
|
|
@@ -399,7 +500,9 @@ export class Runtime {
|
|
|
399
500
|
throw new IsomorphError('VALIDATION_FAILED', `this app has no ${JOBS_DIR}/${job || '<name>'}.ts`, undefined, undefined, 400);
|
|
400
501
|
if (!owner)
|
|
401
502
|
throw new IsomorphError('AUTH_REQUIRED', 'a run is started by a signed-in person', undefined, undefined, 401);
|
|
402
|
-
|
|
503
|
+
const queued = await this.runs.enqueue(job, body.input, owner);
|
|
504
|
+
this.wakeQueue();
|
|
505
|
+
return queued;
|
|
403
506
|
}
|
|
404
507
|
case GET_RUN_ACTION: {
|
|
405
508
|
const run = typeof body.id === 'string' ? await this.runs.get(body.id, owner) : null;
|