llm-cli-gateway 2.14.0-rc.1 → 2.14.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/job-store.js CHANGED
@@ -6,6 +6,7 @@ import { Worker } from "worker_threads";
6
6
  import { fileURLToPath } from "url";
7
7
  import { openDatabase } from "./sqlite-driver.js";
8
8
  import { noopLogger } from "./logger.js";
9
+ import { DEFAULT_INSTANCE_LEASE_TTL_MS, DEFAULT_HTTP_JOB_GRACE_MS } from "./config.js";
9
10
  export function resolveJobStoreDbPath() {
10
11
  const configured = process.env.LLM_GATEWAY_JOBS_DB ?? process.env.LLM_GATEWAY_LOGS_DB;
11
12
  if (configured !== undefined) {
@@ -19,6 +20,7 @@ export function resolveJobStoreDbPath() {
19
20
  }
20
21
  const DEFAULT_RETENTION_DAYS = 30;
21
22
  const FAR_FUTURE_ISO = "9999-12-31T23:59:59.999Z";
23
+ const SQLITE_NOW_MS = "CAST(ROUND((julianday('now') - 2440587.5) * 86400000.0) AS INTEGER)";
22
24
  export function resolveJobRetentionMs() {
23
25
  const raw = process.env.LLM_GATEWAY_JOB_RETENTION_DAYS;
24
26
  const days = raw ? Number(raw) : DEFAULT_RETENTION_DAYS;
@@ -63,6 +65,8 @@ function rowToRecord(row) {
63
65
  transport: row.transport ?? "process",
64
66
  httpStatus: row.http_status ?? null,
65
67
  payloadJson: row.payload_json ?? null,
68
+ ownerInstance: row.owner_instance ?? null,
69
+ leaseDeadline: row.lease_deadline == null ? null : Number(row.lease_deadline),
66
70
  };
67
71
  }
68
72
  function ensureJobsOwnerColumn(db) {
@@ -85,6 +89,16 @@ function ensureJobsTransportColumns(db) {
85
89
  db.exec("ALTER TABLE jobs ADD COLUMN payload_json TEXT");
86
90
  }
87
91
  }
92
+ function ensureJobsLeaseColumns(db) {
93
+ const cols = db.prepare("PRAGMA table_info(jobs)").all();
94
+ const names = new Set(cols.map(col => col?.name));
95
+ if (!names.has("owner_instance")) {
96
+ db.exec("ALTER TABLE jobs ADD COLUMN owner_instance TEXT");
97
+ }
98
+ if (!names.has("lease_deadline")) {
99
+ db.exec("ALTER TABLE jobs ADD COLUMN lease_deadline INTEGER");
100
+ }
101
+ }
88
102
  export function isValidationRunStore(store) {
89
103
  return (typeof store === "object" &&
90
104
  store !== null &&
@@ -97,6 +111,7 @@ export class SqliteJobStore {
97
111
  db;
98
112
  retentionMs;
99
113
  dedupWindowMs;
114
+ leaseTtlMs;
100
115
  insertStmt;
101
116
  updateOutputStmt;
102
117
  updateCompleteStmt;
@@ -105,11 +120,21 @@ export class SqliteJobStore {
105
120
  selectRunningOrphansStmt;
106
121
  markOrphanedStmt;
107
122
  deleteExpiredStmt;
123
+ markRunningStmt;
124
+ registerInstanceStmt;
125
+ heartbeatInstanceStmt;
126
+ heartbeatJobsStmt;
127
+ deregisterInstanceStmt;
128
+ selectStaleCandidatesStmt;
129
+ orphanExpiredStmt;
130
+ advanceLeaseStmt;
131
+ gcInstancesStmt;
108
132
  constructor(dbPath, logger = noopLogger, options = {}) {
109
133
  this.logger = logger;
110
134
  this.db = openDatabase(dbPath);
111
135
  this.db.exec("PRAGMA journal_mode = WAL");
112
136
  this.db.exec("PRAGMA synchronous = NORMAL");
137
+ this.db.exec("PRAGMA busy_timeout = 5000");
113
138
  this.db.exec(`
114
139
  CREATE TABLE IF NOT EXISTS jobs (
115
140
  id TEXT PRIMARY KEY,
@@ -131,12 +156,25 @@ export class SqliteJobStore {
131
156
  owner_principal TEXT,
132
157
  transport TEXT NOT NULL DEFAULT 'process',
133
158
  http_status INTEGER,
134
- payload_json TEXT
159
+ payload_json TEXT,
160
+ owner_instance TEXT,
161
+ lease_deadline INTEGER
135
162
  );
136
163
  CREATE INDEX IF NOT EXISTS idx_jobs_request_key ON jobs(request_key);
137
164
  CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
138
165
  CREATE INDEX IF NOT EXISTS idx_jobs_expires_at ON jobs(expires_at);
139
166
  CREATE INDEX IF NOT EXISTS idx_jobs_request_key_finished ON jobs(request_key, finished_at);
167
+
168
+ CREATE TABLE IF NOT EXISTS gateway_instances (
169
+ instance_id TEXT PRIMARY KEY,
170
+ role TEXT,
171
+ hostname TEXT,
172
+ pid INTEGER,
173
+ started_at INTEGER NOT NULL,
174
+ last_heartbeat INTEGER NOT NULL
175
+ );
176
+ CREATE INDEX IF NOT EXISTS idx_gateway_instances_heartbeat
177
+ ON gateway_instances(last_heartbeat);
140
178
  `);
141
179
  this.db.exec(`
142
180
  CREATE TABLE IF NOT EXISTS validation_runs (
@@ -176,6 +214,8 @@ export class SqliteJobStore {
176
214
  `);
177
215
  ensureJobsOwnerColumn(this.db);
178
216
  ensureJobsTransportColumns(this.db);
217
+ ensureJobsLeaseColumns(this.db);
218
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_jobs_owner_status ON jobs(owner_instance, status)");
179
219
  if (process.platform !== "win32") {
180
220
  try {
181
221
  chmodSync(dbPath, 0o600);
@@ -185,15 +225,17 @@ export class SqliteJobStore {
185
225
  }
186
226
  this.retentionMs = options.retentionMs ?? resolveJobRetentionMs();
187
227
  this.dedupWindowMs = options.dedupWindowMs ?? resolveDedupWindowMs();
228
+ this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_INSTANCE_LEASE_TTL_MS;
188
229
  this.insertStmt = this.db.prepare(`
189
230
  INSERT INTO jobs (id, correlation_id, request_key, cli, args_json, output_format,
190
231
  status, exit_code, stdout, stderr, output_truncated, error,
191
232
  started_at, finished_at, pid, expires_at, owner_principal,
192
- transport, http_status, payload_json)
233
+ transport, http_status, payload_json, owner_instance, lease_deadline)
193
234
  VALUES (@id, @correlation_id, @request_key, @cli, @args_json, @output_format,
194
- @status, @exit_code, @stdout, @stderr, @output_truncated, @error,
235
+ 'queued', @exit_code, @stdout, @stderr, @output_truncated, @error,
195
236
  @started_at, @finished_at, @pid, @expires_at, @owner_principal,
196
- @transport, @http_status, @payload_json)
237
+ @transport, @http_status, @payload_json, @owner_instance,
238
+ ${SQLITE_NOW_MS} + @lease_ttl_ms)
197
239
  `);
198
240
  this.updateOutputStmt = this.db.prepare(`
199
241
  UPDATE jobs SET stdout = @stdout, stderr = @stderr, output_truncated = @output_truncated
@@ -203,15 +245,18 @@ export class SqliteJobStore {
203
245
  UPDATE jobs SET status = @status, exit_code = @exit_code, stdout = @stdout, stderr = @stderr,
204
246
  output_truncated = @output_truncated, error = @error,
205
247
  finished_at = @finished_at, expires_at = @expires_at,
206
- http_status = @http_status
207
- WHERE id = @id
248
+ http_status = @http_status, lease_deadline = NULL
249
+ WHERE id = @id AND status IN ('queued', 'running', 'orphaned')
208
250
  `);
209
251
  this.getByIdStmt = this.db.prepare(`SELECT * FROM jobs WHERE id = ?`);
210
252
  this.findByRequestKeyStmt = this.db.prepare(`
211
253
  SELECT * FROM jobs
212
254
  WHERE request_key = ?
213
255
  AND started_at >= ?
214
- AND status IN ('running', 'completed')
256
+ AND (
257
+ status IN ('running', 'completed')
258
+ OR (status = 'queued' AND lease_deadline IS NOT NULL AND lease_deadline >= ${SQLITE_NOW_MS})
259
+ )
215
260
  ORDER BY started_at DESC
216
261
  LIMIT 1
217
262
  `);
@@ -228,6 +273,56 @@ export class SqliteJobStore {
228
273
  WHERE status = 'running'
229
274
  `);
230
275
  this.deleteExpiredStmt = this.db.prepare(`DELETE FROM jobs WHERE expires_at < ?`);
276
+ this.markRunningStmt = this.db.prepare(`
277
+ UPDATE jobs
278
+ SET status = 'running', pid = @pid, lease_deadline = ${SQLITE_NOW_MS} + @lease_ttl_ms
279
+ WHERE id = @id AND status = 'queued'
280
+ `);
281
+ this.registerInstanceStmt = this.db.prepare(`
282
+ INSERT INTO gateway_instances (instance_id, role, hostname, pid, started_at, last_heartbeat)
283
+ VALUES (@instance_id, @role, @hostname, @pid, ${SQLITE_NOW_MS}, ${SQLITE_NOW_MS})
284
+ ON CONFLICT(instance_id) DO UPDATE SET
285
+ role = excluded.role, hostname = excluded.hostname, pid = excluded.pid,
286
+ last_heartbeat = excluded.last_heartbeat
287
+ `);
288
+ this.heartbeatInstanceStmt = this.db.prepare(`
289
+ UPDATE gateway_instances SET last_heartbeat = ${SQLITE_NOW_MS} WHERE instance_id = @instance_id
290
+ `);
291
+ this.heartbeatJobsStmt = this.db.prepare(`
292
+ UPDATE jobs SET lease_deadline = ${SQLITE_NOW_MS} + @lease_ttl_ms
293
+ WHERE owner_instance = @instance_id AND status IN ('queued', 'running')
294
+ `);
295
+ this.deregisterInstanceStmt = this.db.prepare(`DELETE FROM gateway_instances WHERE instance_id = @instance_id`);
296
+ this.selectStaleCandidatesStmt = this.db.prepare(`
297
+ SELECT j.id AS id, j.pid AS pid, j.transport AS transport,
298
+ j.owner_instance AS owner_instance, gi.hostname AS hostname
299
+ FROM jobs j
300
+ LEFT JOIN gateway_instances gi ON gi.instance_id = j.owner_instance
301
+ WHERE j.status IN ('queued', 'running')
302
+ AND j.transport = 'process'
303
+ AND j.pid IS NOT NULL
304
+ AND (j.lease_deadline IS NULL OR j.lease_deadline < ${SQLITE_NOW_MS})
305
+ `);
306
+ this.orphanExpiredStmt = this.db.prepare(`
307
+ UPDATE jobs
308
+ SET status = 'orphaned',
309
+ error = COALESCE(error, 'owning gateway instance is no longer alive'),
310
+ finished_at = COALESCE(finished_at, @now_iso),
311
+ expires_at = @expires_iso,
312
+ lease_deadline = NULL
313
+ WHERE status IN ('queued', 'running')
314
+ AND (lease_deadline IS NULL OR lease_deadline < ${SQLITE_NOW_MS})
315
+ AND (transport <> 'http'
316
+ OR started_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', @http_grace_modifier))
317
+ AND id NOT IN (SELECT value FROM json_each(@exclude_json))
318
+ RETURNING id, correlation_id, started_at, stdout, stderr, exit_code, transport, http_status
319
+ `);
320
+ this.advanceLeaseStmt = this.db.prepare(`
321
+ UPDATE jobs SET lease_deadline = ${SQLITE_NOW_MS} + @lease_ttl_ms, pid = NULL
322
+ WHERE status IN ('queued', 'running')
323
+ AND id IN (SELECT value FROM json_each(@ids_json))
324
+ `);
325
+ this.gcInstancesStmt = this.db.prepare(`DELETE FROM gateway_instances WHERE last_heartbeat < ${SQLITE_NOW_MS} - @gc_ms`);
231
326
  }
232
327
  recordStart(input) {
233
328
  this.insertStmt.run({
@@ -237,7 +332,6 @@ export class SqliteJobStore {
237
332
  cli: input.cli,
238
333
  args_json: JSON.stringify(input.args),
239
334
  output_format: input.outputFormat ?? null,
240
- status: "running",
241
335
  exit_code: null,
242
336
  stdout: "",
243
337
  stderr: "",
@@ -251,7 +345,74 @@ export class SqliteJobStore {
251
345
  transport: input.transport ?? "process",
252
346
  http_status: null,
253
347
  payload_json: input.payloadJson ?? null,
348
+ owner_instance: input.ownerInstance ?? null,
349
+ lease_ttl_ms: this.leaseTtlMs,
350
+ });
351
+ }
352
+ markRunning(id, opts) {
353
+ const result = this.markRunningStmt.run({
354
+ id,
355
+ pid: opts.pid,
356
+ lease_ttl_ms: this.leaseTtlMs,
357
+ });
358
+ return Number(result.changes) > 0;
359
+ }
360
+ registerInstance(meta) {
361
+ this.registerInstanceStmt.run({
362
+ instance_id: meta.instanceId,
363
+ role: meta.role ?? null,
364
+ hostname: meta.hostname ?? null,
365
+ pid: meta.pid ?? null,
366
+ });
367
+ }
368
+ heartbeat(instanceId) {
369
+ this.heartbeatInstanceStmt.run({ instance_id: instanceId });
370
+ this.heartbeatJobsStmt.run({ instance_id: instanceId, lease_ttl_ms: this.leaseTtlMs });
371
+ }
372
+ deregisterInstance(instanceId) {
373
+ this.deregisterInstanceStmt.run({ instance_id: instanceId });
374
+ }
375
+ selectStaleProcessCandidates(_leaseTtlMs, _httpJobGraceMs) {
376
+ const rows = this.selectStaleCandidatesStmt.all();
377
+ return rows.map(r => ({
378
+ id: r.id,
379
+ pid: r.pid,
380
+ transport: r.transport ?? "process",
381
+ ownerInstance: r.owner_instance ?? null,
382
+ hostname: r.hostname ?? null,
383
+ }));
384
+ }
385
+ recoverStaleJobs(leaseTtlMs, httpJobGraceMs, liveConfirmedIds = []) {
386
+ const excludeJson = JSON.stringify(liveConfirmedIds);
387
+ const httpGraceModifier = `-${httpJobGraceMs / 1000} seconds`;
388
+ const run = this.db.withTransaction(() => {
389
+ if (liveConfirmedIds.length > 0) {
390
+ this.advanceLeaseStmt.run({ ids_json: excludeJson, lease_ttl_ms: leaseTtlMs });
391
+ }
392
+ const nowIso = new Date().toISOString();
393
+ const expiresAt = new Date(Date.now() + this.retentionMs).toISOString();
394
+ const rows = this.orphanExpiredStmt.all({
395
+ now_iso: nowIso,
396
+ expires_iso: expiresAt,
397
+ http_grace_modifier: httpGraceModifier,
398
+ exclude_json: excludeJson,
399
+ });
400
+ return rows.map(r => ({
401
+ id: r.id,
402
+ correlationId: r.correlation_id,
403
+ startedAt: r.started_at,
404
+ stdout: r.stdout ?? "",
405
+ stderr: r.stderr ?? "",
406
+ exitCode: r.exit_code,
407
+ transport: r.transport ?? "process",
408
+ httpStatus: r.http_status ?? null,
409
+ }));
254
410
  });
411
+ return run();
412
+ }
413
+ gcInstances(instanceGcMs) {
414
+ const result = this.gcInstancesStmt.run({ gc_ms: instanceGcMs });
415
+ return Number(result.changes);
255
416
  }
256
417
  recordOutput(id, stdout, stderr, outputTruncated) {
257
418
  this.updateOutputStmt.run({
@@ -286,21 +447,8 @@ export class SqliteJobStore {
286
447
  return row ? rowToRecord(row) : null;
287
448
  }
288
449
  markOrphanedOnStartup() {
289
- const now = new Date().toISOString();
290
- const expiresAt = new Date(Date.now() + this.retentionMs).toISOString();
291
- const rows = this.selectRunningOrphansStmt.all();
292
- const orphaned = rows.map(row => ({
293
- id: row.id,
294
- correlationId: row.correlation_id,
295
- startedAt: row.started_at,
296
- stdout: row.stdout ?? "",
297
- stderr: row.stderr ?? "",
298
- exitCode: row.exit_code,
299
- transport: row.transport ?? "process",
300
- httpStatus: row.http_status ?? null,
301
- }));
302
- const result = this.markOrphanedStmt.run(now, expiresAt);
303
- return { count: Number(result.changes), orphaned };
450
+ const orphaned = this.recoverStaleJobs(this.leaseTtlMs, DEFAULT_HTTP_JOB_GRACE_MS);
451
+ return { count: orphaned.length, orphaned };
304
452
  }
305
453
  evictExpired() {
306
454
  const now = new Date().toISOString();
@@ -462,9 +610,11 @@ export class MemoryJobStore {
462
610
  rows = new Map();
463
611
  retentionMs;
464
612
  dedupWindowMs;
613
+ leaseTtlMs;
465
614
  constructor(options = {}) {
466
615
  this.retentionMs = options.retentionMs ?? resolveJobRetentionMs();
467
616
  this.dedupWindowMs = options.dedupWindowMs ?? resolveDedupWindowMs();
617
+ this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_INSTANCE_LEASE_TTL_MS;
468
618
  }
469
619
  recordStart(input) {
470
620
  this.rows.set(input.id, {
@@ -474,7 +624,7 @@ export class MemoryJobStore {
474
624
  cli: input.cli,
475
625
  argsJson: JSON.stringify(input.args),
476
626
  outputFormat: input.outputFormat ?? null,
477
- status: "running",
627
+ status: "queued",
478
628
  exitCode: null,
479
629
  stdout: "",
480
630
  stderr: "",
@@ -488,8 +638,39 @@ export class MemoryJobStore {
488
638
  transport: input.transport ?? "process",
489
639
  httpStatus: null,
490
640
  payloadJson: input.payloadJson ?? null,
641
+ ownerInstance: input.ownerInstance ?? null,
642
+ leaseDeadline: Date.now() + this.leaseTtlMs,
491
643
  });
492
644
  }
645
+ markRunning(id, opts) {
646
+ const row = this.rows.get(id);
647
+ if (!row || row.status !== "queued")
648
+ return false;
649
+ row.status = "running";
650
+ row.pid = opts.pid;
651
+ row.leaseDeadline = Date.now() + this.leaseTtlMs;
652
+ return true;
653
+ }
654
+ registerInstance(_meta) { }
655
+ heartbeat(instanceId) {
656
+ const deadline = Date.now() + this.leaseTtlMs;
657
+ for (const row of this.rows.values()) {
658
+ if (row.ownerInstance === instanceId &&
659
+ (row.status === "queued" || row.status === "running")) {
660
+ row.leaseDeadline = deadline;
661
+ }
662
+ }
663
+ }
664
+ deregisterInstance(_instanceId) { }
665
+ selectStaleProcessCandidates(_leaseTtlMs, _httpJobGraceMs) {
666
+ return [];
667
+ }
668
+ recoverStaleJobs(_leaseTtlMs, _httpJobGraceMs, _liveConfirmedIds) {
669
+ return [];
670
+ }
671
+ gcInstances(_instanceGcMs) {
672
+ return 0;
673
+ }
493
674
  recordOutput(id, stdout, stderr, outputTruncated) {
494
675
  const row = this.rows.get(id);
495
676
  if (!row)
@@ -502,6 +683,8 @@ export class MemoryJobStore {
502
683
  const row = this.rows.get(input.id);
503
684
  if (!row)
504
685
  return;
686
+ if (row.status !== "queued" && row.status !== "running" && row.status !== "orphaned")
687
+ return;
505
688
  row.status = input.status;
506
689
  row.exitCode = input.exitCode;
507
690
  row.stdout = input.stdout;
@@ -510,6 +693,7 @@ export class MemoryJobStore {
510
693
  row.error = input.error;
511
694
  row.finishedAt = input.finishedAt;
512
695
  row.expiresAt = new Date(Date.parse(input.finishedAt) + this.retentionMs).toISOString();
696
+ row.leaseDeadline = null;
513
697
  if (input.httpStatus !== undefined)
514
698
  row.httpStatus = input.httpStatus;
515
699
  }
@@ -519,11 +703,15 @@ export class MemoryJobStore {
519
703
  }
520
704
  findByRequestKey(requestKey) {
521
705
  const cutoffMs = Date.now() - this.dedupWindowMs;
706
+ const nowMs = Date.now();
522
707
  let best = null;
523
708
  for (const row of this.rows.values()) {
524
709
  if (row.requestKey !== requestKey)
525
710
  continue;
526
- if (row.status !== "running" && row.status !== "completed")
711
+ const reusable = row.status === "running" ||
712
+ row.status === "completed" ||
713
+ (row.status === "queued" && row.leaseDeadline !== null && row.leaseDeadline >= nowMs);
714
+ if (!reusable)
527
715
  continue;
528
716
  if (Date.parse(row.startedAt) < cutoffMs)
529
717
  continue;
@@ -569,6 +757,7 @@ export class PostgresJobStore {
569
757
  dsn,
570
758
  retentionMs: options.retentionMs ?? resolveJobRetentionMs(),
571
759
  dedupWindowMs: options.dedupWindowMs ?? resolveDedupWindowMs(),
760
+ leaseTtlMs: options.leaseTtlMs ?? DEFAULT_INSTANCE_LEASE_TTL_MS,
572
761
  farFutureIso: FAR_FUTURE_ISO,
573
762
  connectionTimeoutMillis: 5000,
574
763
  },
@@ -616,6 +805,37 @@ export class PostgresJobStore {
616
805
  recordStart(input) {
617
806
  this.syncCall("recordStart", input);
618
807
  }
808
+ markRunning(id, opts) {
809
+ return this.syncCall("markRunning", id, opts);
810
+ }
811
+ registerInstance(meta) {
812
+ this.syncCall("registerInstance", meta);
813
+ }
814
+ heartbeat(instanceId) {
815
+ this.syncCall("heartbeat", instanceId);
816
+ }
817
+ deregisterInstance(instanceId) {
818
+ this.syncCall("deregisterInstance", instanceId);
819
+ }
820
+ selectStaleProcessCandidates(leaseTtlMs, httpJobGraceMs) {
821
+ return this.syncCall("selectStaleProcessCandidates", leaseTtlMs, httpJobGraceMs);
822
+ }
823
+ recoverStaleJobs(leaseTtlMs, httpJobGraceMs, liveConfirmedIds = []) {
824
+ const result = this.syncCall("recoverStaleJobs", leaseTtlMs, httpJobGraceMs, liveConfirmedIds);
825
+ return result.orphaned.map(row => ({
826
+ id: row.id,
827
+ correlationId: row.correlation_id,
828
+ startedAt: row.started_at,
829
+ stdout: row.stdout ?? "",
830
+ stderr: row.stderr ?? "",
831
+ exitCode: row.exit_code,
832
+ transport: row.transport ?? "process",
833
+ httpStatus: row.http_status ?? null,
834
+ }));
835
+ }
836
+ gcInstances(instanceGcMs) {
837
+ return this.syncCall("gcInstances", instanceGcMs);
838
+ }
619
839
  recordOutput(id, stdout, stderr, outputTruncated) {
620
840
  this.syncCall("recordOutput", id, stdout, stderr, outputTruncated);
621
841
  }
@@ -631,20 +851,8 @@ export class PostgresJobStore {
631
851
  return row ? rowToRecord(row) : null;
632
852
  }
633
853
  markOrphanedOnStartup() {
634
- const result = this.syncCall("markOrphanedOnStartup");
635
- return {
636
- count: result.count,
637
- orphaned: result.orphaned.map(row => ({
638
- id: row.id,
639
- correlationId: row.correlation_id,
640
- startedAt: row.started_at,
641
- stdout: row.stdout ?? "",
642
- stderr: row.stderr ?? "",
643
- exitCode: row.exit_code,
644
- transport: row.transport ?? "process",
645
- httpStatus: row.http_status ?? null,
646
- })),
647
- };
854
+ const orphaned = this.recoverStaleJobs(DEFAULT_INSTANCE_LEASE_TTL_MS, DEFAULT_HTTP_JOB_GRACE_MS);
855
+ return { count: orphaned.length, orphaned };
648
856
  }
649
857
  evictExpired() {
650
858
  return this.syncCall("evictExpired");
@@ -703,6 +911,7 @@ export function createJobStore(config, logger = noopLogger) {
703
911
  const opts = {
704
912
  retentionMs: config.retentionDays * 24 * 60 * 60 * 1000,
705
913
  dedupWindowMs: config.dedupWindowMs,
914
+ leaseTtlMs: config.instanceLeaseTtlMs,
706
915
  };
707
916
  switch (config.backend) {
708
917
  case "none":
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { parentPort, workerData } from "node:worker_threads";
3
3
  let pool = null;
4
+ const PG_NOW_MS = "(EXTRACT(EPOCH FROM now()) * 1000)::bigint";
4
5
  function signal(shared, status) {
5
6
  const view = new Int32Array(shared);
6
7
  Atomics.store(view, 0, status);
@@ -70,13 +71,26 @@ async function init() {
70
71
  owner_principal TEXT,
71
72
  transport TEXT NOT NULL DEFAULT 'process',
72
73
  http_status INTEGER,
73
- payload_json TEXT
74
+ payload_json TEXT,
75
+ owner_instance TEXT,
76
+ lease_deadline BIGINT
74
77
  );
75
78
  CREATE INDEX IF NOT EXISTS idx_jobs_request_key ON jobs(request_key);
76
79
  CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
77
80
  CREATE INDEX IF NOT EXISTS idx_jobs_expires_at ON jobs(expires_at);
78
81
  CREATE INDEX IF NOT EXISTS idx_jobs_request_key_finished ON jobs(request_key, finished_at);
79
82
 
83
+ CREATE TABLE IF NOT EXISTS gateway_instances (
84
+ instance_id TEXT PRIMARY KEY,
85
+ role TEXT,
86
+ hostname TEXT,
87
+ pid INTEGER,
88
+ started_at BIGINT NOT NULL,
89
+ last_heartbeat BIGINT NOT NULL
90
+ );
91
+ CREATE INDEX IF NOT EXISTS idx_gateway_instances_heartbeat
92
+ ON gateway_instances(last_heartbeat);
93
+
80
94
  CREATE TABLE IF NOT EXISTS validation_runs (
81
95
  validation_id TEXT PRIMARY KEY,
82
96
  owner_principal TEXT NOT NULL,
@@ -116,6 +130,9 @@ async function init() {
116
130
  await getPool().query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS transport TEXT NOT NULL DEFAULT 'process'");
117
131
  await getPool().query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS http_status INTEGER");
118
132
  await getPool().query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS payload_json TEXT");
133
+ await getPool().query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS owner_instance TEXT");
134
+ await getPool().query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS lease_deadline BIGINT");
135
+ await getPool().query("CREATE INDEX IF NOT EXISTS idx_jobs_owner_status ON jobs(owner_instance, status)");
119
136
  }
120
137
  async function op(method, args) {
121
138
  switch (method) {
@@ -127,9 +144,9 @@ async function op(method, args) {
127
144
  await getPool().query(`INSERT INTO jobs (id, correlation_id, request_key, cli, args_json, output_format,
128
145
  status, exit_code, stdout, stderr, output_truncated, error,
129
146
  started_at, finished_at, pid, expires_at, owner_principal,
130
- transport, http_status, payload_json)
131
- VALUES ($1, $2, $3, $4, $5, $6, 'running', NULL, '', '', FALSE, NULL,
132
- $7, NULL, $8, $9, $10, $11, NULL, $12)`, [
147
+ transport, http_status, payload_json, owner_instance, lease_deadline)
148
+ VALUES ($1, $2, $3, $4, $5, $6, 'queued', NULL, '', '', FALSE, NULL,
149
+ $7, NULL, $8, $9, $10, $11, NULL, $12, $13, ${PG_NOW_MS} + $14)`, [
133
150
  input.id,
134
151
  input.correlationId,
135
152
  input.requestKey,
@@ -142,9 +159,106 @@ async function op(method, args) {
142
159
  input.ownerPrincipal ?? null,
143
160
  input.transport ?? "process",
144
161
  input.payloadJson ?? null,
162
+ input.ownerInstance ?? null,
163
+ workerData.leaseTtlMs,
145
164
  ]);
146
165
  return null;
147
166
  }
167
+ case "markRunning": {
168
+ const [id, opts] = args;
169
+ const result = await getPool().query(`UPDATE jobs
170
+ SET status = 'running', pid = $2, lease_deadline = ${PG_NOW_MS} + $3::bigint
171
+ WHERE id = $1 AND status = 'queued'`, [id, opts?.pid ?? null, workerData.leaseTtlMs]);
172
+ return (result.rowCount ?? 0) > 0;
173
+ }
174
+ case "registerInstance": {
175
+ const meta = args[0];
176
+ await getPool().query(`INSERT INTO gateway_instances (instance_id, role, hostname, pid, started_at, last_heartbeat)
177
+ VALUES ($1, $2, $3, $4, ${PG_NOW_MS}, ${PG_NOW_MS})
178
+ ON CONFLICT (instance_id) DO UPDATE SET
179
+ role = EXCLUDED.role, hostname = EXCLUDED.hostname, pid = EXCLUDED.pid,
180
+ last_heartbeat = EXCLUDED.last_heartbeat`, [meta.instanceId, meta.role ?? null, meta.hostname ?? null, meta.pid ?? null]);
181
+ return null;
182
+ }
183
+ case "heartbeat": {
184
+ const instanceId = args[0];
185
+ await withClient(async (client) => {
186
+ await client.query("BEGIN");
187
+ try {
188
+ await client.query(`UPDATE gateway_instances SET last_heartbeat = ${PG_NOW_MS} WHERE instance_id = $1`, [instanceId]);
189
+ await client.query(`UPDATE jobs SET lease_deadline = ${PG_NOW_MS} + $2
190
+ WHERE owner_instance = $1 AND status IN ('queued', 'running')`, [instanceId, workerData.leaseTtlMs]);
191
+ await client.query("COMMIT");
192
+ }
193
+ catch (error) {
194
+ await client.query("ROLLBACK");
195
+ throw error;
196
+ }
197
+ });
198
+ return null;
199
+ }
200
+ case "deregisterInstance": {
201
+ await getPool().query("DELETE FROM gateway_instances WHERE instance_id = $1", [args[0]]);
202
+ return null;
203
+ }
204
+ case "selectStaleProcessCandidates": {
205
+ const result = await getPool().query(`SELECT j.id AS id, j.pid AS pid, j.transport AS transport,
206
+ j.owner_instance AS owner_instance, gi.hostname AS hostname
207
+ FROM jobs j
208
+ LEFT JOIN gateway_instances gi ON gi.instance_id = j.owner_instance
209
+ WHERE j.status IN ('queued', 'running')
210
+ AND j.transport = 'process'
211
+ AND j.pid IS NOT NULL
212
+ AND (j.lease_deadline IS NULL OR j.lease_deadline < ${PG_NOW_MS})`);
213
+ return result.rows.map(r => ({
214
+ id: r.id,
215
+ pid: r.pid,
216
+ transport: r.transport ?? "process",
217
+ ownerInstance: r.owner_instance ?? null,
218
+ hostname: r.hostname ?? null,
219
+ }));
220
+ }
221
+ case "recoverStaleJobs": {
222
+ const [leaseTtlMs, httpJobGraceMs, liveConfirmedIds] = args;
223
+ const excludeIds = liveConfirmedIds ?? [];
224
+ const httpGraceCutoffMs = "(" + PG_NOW_MS + " - $1::bigint)";
225
+ return await withClient(async (client) => {
226
+ await client.query("BEGIN");
227
+ try {
228
+ if (excludeIds.length > 0) {
229
+ await client.query(`UPDATE jobs SET lease_deadline = ${PG_NOW_MS} + $1::bigint, pid = NULL
230
+ WHERE status IN ('queued', 'running') AND id = ANY($2::text[])`, [leaseTtlMs, excludeIds]);
231
+ }
232
+ const orphan = await client.query(`UPDATE jobs
233
+ SET status = 'orphaned',
234
+ error = COALESCE(error, 'owning gateway instance is no longer alive'),
235
+ finished_at = COALESCE(finished_at, $2),
236
+ expires_at = $3,
237
+ lease_deadline = NULL
238
+ WHERE status IN ('queued', 'running')
239
+ AND (lease_deadline IS NULL OR lease_deadline < ${PG_NOW_MS})
240
+ AND (transport <> 'http'
241
+ OR (EXTRACT(EPOCH FROM started_at::timestamptz) * 1000)::bigint < ${httpGraceCutoffMs})
242
+ AND NOT (id = ANY($4::text[]))
243
+ RETURNING id, correlation_id, started_at, stdout, stderr, exit_code, transport, http_status`, [
244
+ httpJobGraceMs,
245
+ new Date().toISOString(),
246
+ new Date(Date.now() + workerData.retentionMs).toISOString(),
247
+ excludeIds,
248
+ ]);
249
+ await client.query("COMMIT");
250
+ return { orphaned: orphan.rows };
251
+ }
252
+ catch (error) {
253
+ await client.query("ROLLBACK");
254
+ throw error;
255
+ }
256
+ });
257
+ }
258
+ case "gcInstances": {
259
+ const result = await getPool().query(`DELETE FROM gateway_instances WHERE last_heartbeat < ${PG_NOW_MS} - $1::bigint`, [args[0]]);
260
+ return result.rowCount ?? 0;
261
+ }
148
262
  case "recordOutput":
149
263
  await getPool().query("UPDATE jobs SET stdout = $2, stderr = $3, output_truncated = $4 WHERE id = $1", args);
150
264
  return null;
@@ -154,8 +268,8 @@ async function op(method, args) {
154
268
  await getPool().query(`UPDATE jobs
155
269
  SET status = $2, exit_code = $3, stdout = $4, stderr = $5,
156
270
  output_truncated = $6, error = $7, finished_at = $8,
157
- expires_at = $9, http_status = $10
158
- WHERE id = $1`, [
271
+ expires_at = $9, http_status = $10, lease_deadline = NULL
272
+ WHERE id = $1 AND status IN ('queued', 'running', 'orphaned')`, [
159
273
  input.id,
160
274
  input.status,
161
275
  input.exitCode,
@@ -178,7 +292,10 @@ async function op(method, args) {
178
292
  const result = await getPool().query(`SELECT * FROM jobs
179
293
  WHERE request_key = $1
180
294
  AND started_at >= $2
181
- AND status IN ('running', 'completed')
295
+ AND (
296
+ status IN ('running', 'completed')
297
+ OR (status = 'queued' AND lease_deadline IS NOT NULL AND lease_deadline >= ${PG_NOW_MS})
298
+ )
182
299
  ORDER BY started_at DESC
183
300
  LIMIT 1`, [args[0], cutoff]);
184
301
  return result.rows[0] ?? null;
@@ -9,7 +9,7 @@ export interface GatewayStatement {
9
9
  export interface GatewayDatabase {
10
10
  exec(sql: string): void;
11
11
  prepare(sql: string): GatewayStatement;
12
- withTransaction<A extends unknown[]>(fn: (...args: A) => void): (...args: A) => void;
12
+ withTransaction<A extends unknown[], R = void>(fn: (...args: A) => R): (...args: A) => R;
13
13
  close(): void;
14
14
  }
15
15
  export declare function openDatabase(dbPath: string): GatewayDatabase;
@@ -115,8 +115,9 @@ class GatewayDatabaseImpl {
115
115
  this.db.exec("BEGIN");
116
116
  this.inTransaction = true;
117
117
  try {
118
- fn(...args);
118
+ const result = fn(...args);
119
119
  this.db.exec("COMMIT");
120
+ return result;
120
121
  }
121
122
  catch (error) {
122
123
  try {