@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 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<void>;
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<void>;
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 or the handler threw;
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
- /** One pass of the queue loop: reclaim expired leases, claim one run, run it. */
100
- work(): Promise<void>;
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
- if (this.options.mode !== 'gate' && this.inventory.jobs.length > 0 && this.options.gatewayUrl)
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 or the handler threw;
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
- /** One pass of the queue loop: reclaim expired leases, claim one run, run it. */
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
- return this.runs.enqueue(job, body.input, owner);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",