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/CHANGELOG.md +42 -0
- package/README.md +18 -6
- package/dist/async-job-manager.d.ts +36 -1
- package/dist/async-job-manager.js +276 -20
- package/dist/config.d.ts +11 -0
- package/dist/config.js +37 -1
- package/dist/index.js +20 -3
- package/dist/job-store.d.ts +75 -5
- package/dist/job-store.js +248 -39
- package/dist/postgres-job-store-worker.js +124 -7
- package/dist/sqlite-driver.d.ts +1 -1
- package/dist/sqlite-driver.js +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
|
290
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
|
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, '
|
|
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
|
|
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;
|
package/dist/sqlite-driver.d.ts
CHANGED
|
@@ -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) =>
|
|
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;
|
package/dist/sqlite-driver.js
CHANGED