llm-cli-gateway 2.14.0-rc.1 → 2.14.1
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/.agents/skills/retrospective-walk/SKILL.md +294 -0
- package/CHANGELOG.md +52 -0
- package/README.md +19 -7
- 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 +2 -1
package/dist/job-store.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Logger } from "./logger.js";
|
|
2
2
|
import type { PersistenceConfig } from "./config.js";
|
|
3
|
-
export type JobStoreStatus = "running" | "completed" | "failed" | "canceled" | "orphaned";
|
|
3
|
+
export type JobStoreStatus = "queued" | "running" | "completed" | "failed" | "canceled" | "orphaned";
|
|
4
|
+
export type JobStoreActiveStatus = Extract<JobStoreStatus, "queued" | "running">;
|
|
4
5
|
export type JobTransport = "process" | "http";
|
|
5
6
|
export interface JobRecord {
|
|
6
7
|
id: string;
|
|
@@ -23,11 +24,26 @@ export interface JobRecord {
|
|
|
23
24
|
transport: JobTransport;
|
|
24
25
|
httpStatus: number | null;
|
|
25
26
|
payloadJson: string | null;
|
|
27
|
+
ownerInstance: string | null;
|
|
28
|
+
leaseDeadline: number | null;
|
|
26
29
|
}
|
|
27
30
|
export declare function resolveJobStoreDbPath(): string | null;
|
|
28
31
|
export declare function resolveJobRetentionMs(): number;
|
|
29
32
|
export declare function resolveDedupWindowMs(): number;
|
|
30
33
|
export declare function computeRequestKey(cli: string, args: string[], extra?: string): string;
|
|
34
|
+
export interface GatewayInstanceMeta {
|
|
35
|
+
instanceId: string;
|
|
36
|
+
role: string | null;
|
|
37
|
+
hostname: string | null;
|
|
38
|
+
pid: number | null;
|
|
39
|
+
}
|
|
40
|
+
export interface SweepCandidate {
|
|
41
|
+
id: string;
|
|
42
|
+
pid: number | null;
|
|
43
|
+
transport: JobTransport;
|
|
44
|
+
ownerInstance: string | null;
|
|
45
|
+
hostname: string | null;
|
|
46
|
+
}
|
|
31
47
|
export interface JobStore {
|
|
32
48
|
recordStart(input: {
|
|
33
49
|
id: string;
|
|
@@ -39,13 +55,23 @@ export interface JobStore {
|
|
|
39
55
|
startedAt: string;
|
|
40
56
|
pid: number | null;
|
|
41
57
|
ownerPrincipal?: string | null;
|
|
58
|
+
ownerInstance?: string | null;
|
|
42
59
|
transport?: JobTransport;
|
|
43
60
|
payloadJson?: string | null;
|
|
44
61
|
}): void;
|
|
62
|
+
markRunning(id: string, opts: {
|
|
63
|
+
pid: number | null;
|
|
64
|
+
}): boolean;
|
|
65
|
+
registerInstance(meta: GatewayInstanceMeta): void;
|
|
66
|
+
heartbeat(instanceId: string): void;
|
|
67
|
+
deregisterInstance(instanceId: string): void;
|
|
68
|
+
selectStaleProcessCandidates(leaseTtlMs: number, httpJobGraceMs: number): SweepCandidate[];
|
|
69
|
+
recoverStaleJobs(leaseTtlMs: number, httpJobGraceMs: number, liveConfirmedIds?: string[]): OrphanedJobSnapshot[];
|
|
70
|
+
gcInstances(instanceGcMs: number): number;
|
|
45
71
|
recordOutput(id: string, stdout: string, stderr: string, outputTruncated: boolean): void;
|
|
46
72
|
recordComplete(input: {
|
|
47
73
|
id: string;
|
|
48
|
-
status: Exclude<JobStoreStatus, "running">;
|
|
74
|
+
status: Exclude<JobStoreStatus, "running" | "queued">;
|
|
49
75
|
exitCode: number | null;
|
|
50
76
|
stdout: string;
|
|
51
77
|
stderr: string;
|
|
@@ -117,6 +143,7 @@ export declare class SqliteJobStore implements JobStore, ValidationRunStore {
|
|
|
117
143
|
private db;
|
|
118
144
|
private retentionMs;
|
|
119
145
|
private dedupWindowMs;
|
|
146
|
+
private leaseTtlMs;
|
|
120
147
|
private insertStmt;
|
|
121
148
|
private updateOutputStmt;
|
|
122
149
|
private updateCompleteStmt;
|
|
@@ -125,9 +152,19 @@ export declare class SqliteJobStore implements JobStore, ValidationRunStore {
|
|
|
125
152
|
private selectRunningOrphansStmt;
|
|
126
153
|
private markOrphanedStmt;
|
|
127
154
|
private deleteExpiredStmt;
|
|
155
|
+
private markRunningStmt;
|
|
156
|
+
private registerInstanceStmt;
|
|
157
|
+
private heartbeatInstanceStmt;
|
|
158
|
+
private heartbeatJobsStmt;
|
|
159
|
+
private deregisterInstanceStmt;
|
|
160
|
+
private selectStaleCandidatesStmt;
|
|
161
|
+
private orphanExpiredStmt;
|
|
162
|
+
private advanceLeaseStmt;
|
|
163
|
+
private gcInstancesStmt;
|
|
128
164
|
constructor(dbPath: string, logger?: Logger, options?: {
|
|
129
165
|
retentionMs?: number;
|
|
130
166
|
dedupWindowMs?: number;
|
|
167
|
+
leaseTtlMs?: number;
|
|
131
168
|
});
|
|
132
169
|
recordStart(input: {
|
|
133
170
|
id: string;
|
|
@@ -139,13 +176,23 @@ export declare class SqliteJobStore implements JobStore, ValidationRunStore {
|
|
|
139
176
|
startedAt: string;
|
|
140
177
|
pid: number | null;
|
|
141
178
|
ownerPrincipal?: string | null;
|
|
179
|
+
ownerInstance?: string | null;
|
|
142
180
|
transport?: JobTransport;
|
|
143
181
|
payloadJson?: string | null;
|
|
144
182
|
}): void;
|
|
183
|
+
markRunning(id: string, opts: {
|
|
184
|
+
pid: number | null;
|
|
185
|
+
}): boolean;
|
|
186
|
+
registerInstance(meta: GatewayInstanceMeta): void;
|
|
187
|
+
heartbeat(instanceId: string): void;
|
|
188
|
+
deregisterInstance(instanceId: string): void;
|
|
189
|
+
selectStaleProcessCandidates(_leaseTtlMs: number, _httpJobGraceMs: number): SweepCandidate[];
|
|
190
|
+
recoverStaleJobs(leaseTtlMs: number, httpJobGraceMs: number, liveConfirmedIds?: string[]): OrphanedJobSnapshot[];
|
|
191
|
+
gcInstances(instanceGcMs: number): number;
|
|
145
192
|
recordOutput(id: string, stdout: string, stderr: string, outputTruncated: boolean): void;
|
|
146
193
|
recordComplete(input: {
|
|
147
194
|
id: string;
|
|
148
|
-
status: Exclude<JobStoreStatus, "running">;
|
|
195
|
+
status: Exclude<JobStoreStatus, "running" | "queued">;
|
|
149
196
|
exitCode: number | null;
|
|
150
197
|
stdout: string;
|
|
151
198
|
stderr: string;
|
|
@@ -176,9 +223,11 @@ export declare class MemoryJobStore implements JobStore {
|
|
|
176
223
|
private rows;
|
|
177
224
|
private retentionMs;
|
|
178
225
|
private dedupWindowMs;
|
|
226
|
+
private leaseTtlMs;
|
|
179
227
|
constructor(options?: {
|
|
180
228
|
retentionMs?: number;
|
|
181
229
|
dedupWindowMs?: number;
|
|
230
|
+
leaseTtlMs?: number;
|
|
182
231
|
});
|
|
183
232
|
recordStart(input: {
|
|
184
233
|
id: string;
|
|
@@ -190,13 +239,23 @@ export declare class MemoryJobStore implements JobStore {
|
|
|
190
239
|
startedAt: string;
|
|
191
240
|
pid: number | null;
|
|
192
241
|
ownerPrincipal?: string | null;
|
|
242
|
+
ownerInstance?: string | null;
|
|
193
243
|
transport?: JobTransport;
|
|
194
244
|
payloadJson?: string | null;
|
|
195
245
|
}): void;
|
|
246
|
+
markRunning(id: string, opts: {
|
|
247
|
+
pid: number | null;
|
|
248
|
+
}): boolean;
|
|
249
|
+
registerInstance(_meta: GatewayInstanceMeta): void;
|
|
250
|
+
heartbeat(instanceId: string): void;
|
|
251
|
+
deregisterInstance(_instanceId: string): void;
|
|
252
|
+
selectStaleProcessCandidates(_leaseTtlMs: number, _httpJobGraceMs: number): SweepCandidate[];
|
|
253
|
+
recoverStaleJobs(_leaseTtlMs: number, _httpJobGraceMs: number, _liveConfirmedIds?: string[]): OrphanedJobSnapshot[];
|
|
254
|
+
gcInstances(_instanceGcMs: number): number;
|
|
196
255
|
recordOutput(id: string, stdout: string, stderr: string, outputTruncated: boolean): void;
|
|
197
256
|
recordComplete(input: {
|
|
198
257
|
id: string;
|
|
199
|
-
status: Exclude<JobStoreStatus, "running">;
|
|
258
|
+
status: Exclude<JobStoreStatus, "running" | "queued">;
|
|
200
259
|
exitCode: number | null;
|
|
201
260
|
stdout: string;
|
|
202
261
|
stderr: string;
|
|
@@ -223,6 +282,7 @@ export declare class PostgresJobStore implements JobStore, ValidationRunStore {
|
|
|
223
282
|
constructor(dsn: string, logger?: Logger, options?: {
|
|
224
283
|
retentionMs?: number;
|
|
225
284
|
dedupWindowMs?: number;
|
|
285
|
+
leaseTtlMs?: number;
|
|
226
286
|
});
|
|
227
287
|
private syncCall;
|
|
228
288
|
recordStart(input: {
|
|
@@ -235,13 +295,23 @@ export declare class PostgresJobStore implements JobStore, ValidationRunStore {
|
|
|
235
295
|
startedAt: string;
|
|
236
296
|
pid: number | null;
|
|
237
297
|
ownerPrincipal?: string | null;
|
|
298
|
+
ownerInstance?: string | null;
|
|
238
299
|
transport?: JobTransport;
|
|
239
300
|
payloadJson?: string | null;
|
|
240
301
|
}): void;
|
|
302
|
+
markRunning(id: string, opts: {
|
|
303
|
+
pid: number | null;
|
|
304
|
+
}): boolean;
|
|
305
|
+
registerInstance(meta: GatewayInstanceMeta): void;
|
|
306
|
+
heartbeat(instanceId: string): void;
|
|
307
|
+
deregisterInstance(instanceId: string): void;
|
|
308
|
+
selectStaleProcessCandidates(leaseTtlMs: number, httpJobGraceMs: number): SweepCandidate[];
|
|
309
|
+
recoverStaleJobs(leaseTtlMs: number, httpJobGraceMs: number, liveConfirmedIds?: string[]): OrphanedJobSnapshot[];
|
|
310
|
+
gcInstances(instanceGcMs: number): number;
|
|
241
311
|
recordOutput(id: string, stdout: string, stderr: string, outputTruncated: boolean): void;
|
|
242
312
|
recordComplete(input: {
|
|
243
313
|
id: string;
|
|
244
|
-
status: Exclude<JobStoreStatus, "running">;
|
|
314
|
+
status: Exclude<JobStoreStatus, "running" | "queued">;
|
|
245
315
|
exitCode: number | null;
|
|
246
316
|
stdout: string;
|
|
247
317
|
stderr: string;
|
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":
|