@q32/core 0.1.19 → 0.1.21
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/README.md +2 -2
- package/dist/jobs.d.ts +394 -6
- package/dist/jobs.d.ts.map +1 -1
- package/dist/jobs.js +1134 -62
- package/dist/jobs.js.map +1 -1
- package/dist/oauth.d.ts +3 -0
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +6 -3
- package/dist/oauth.js.map +1 -1
- package/docs/jobs.md +49 -0
- package/package.json +1 -1
- package/src/jobs.ts +1665 -71
- package/src/oauth.ts +15 -4
package/dist/jobs.js
CHANGED
|
@@ -1,6 +1,157 @@
|
|
|
1
1
|
import { parseJsonColumn, stringifyJsonColumn } from "./d1.js";
|
|
2
2
|
import { createId } from "./ids.js";
|
|
3
3
|
import { isFutureIso, nowIso } from "./time.js";
|
|
4
|
+
export class JobDeadline {
|
|
5
|
+
budgetMs;
|
|
6
|
+
startedAt = Date.now();
|
|
7
|
+
constructor(budgetMs = 50_000) {
|
|
8
|
+
this.budgetMs = budgetMs;
|
|
9
|
+
}
|
|
10
|
+
shouldYield() {
|
|
11
|
+
return Date.now() - this.startedAt >= this.budgetMs;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class DurableJobDispatcher {
|
|
15
|
+
options;
|
|
16
|
+
constructor(options) {
|
|
17
|
+
this.options = options;
|
|
18
|
+
}
|
|
19
|
+
async run(jobId) {
|
|
20
|
+
const job = await this.options.jobs.claim(jobId);
|
|
21
|
+
if (!job)
|
|
22
|
+
return { kind: "done", result: null };
|
|
23
|
+
const handler = this.options.handlers[job.jobType];
|
|
24
|
+
if (!handler) {
|
|
25
|
+
await this.options.jobs.fail(job.jobId, `No handler registered for job type: ${job.jobType}`);
|
|
26
|
+
throw new Error(`No handler registered for job type: ${job.jobType}`);
|
|
27
|
+
}
|
|
28
|
+
const events = new DurableJobEvents(job, this.options.events);
|
|
29
|
+
await events.info({ eventName: "job.started", status: "started", message: "Job started." });
|
|
30
|
+
try {
|
|
31
|
+
if (await this.shouldStop(job.jobId)) {
|
|
32
|
+
await this.options.jobs.stop(job.jobId);
|
|
33
|
+
await events.warn({ eventName: "job.stopped", status: "skipped", message: "Job stopped before handler ran." });
|
|
34
|
+
return { kind: "stopped", result: null };
|
|
35
|
+
}
|
|
36
|
+
const output = await handler({
|
|
37
|
+
job,
|
|
38
|
+
jobs: this.options.jobs,
|
|
39
|
+
services: this.options.services,
|
|
40
|
+
events,
|
|
41
|
+
shouldStop: () => this.shouldStop(job.jobId),
|
|
42
|
+
});
|
|
43
|
+
const normalized = normalizeJobResult(output);
|
|
44
|
+
if (normalized.kind === "requeue") {
|
|
45
|
+
await this.options.jobs.requeue(job.jobId, {
|
|
46
|
+
payload: normalized.payload,
|
|
47
|
+
result: normalized.result ?? null,
|
|
48
|
+
availableAt: normalized.availableAt ?? null,
|
|
49
|
+
});
|
|
50
|
+
await events.info({ eventName: "job.requeued", status: "started", message: "Job requeued." });
|
|
51
|
+
}
|
|
52
|
+
else if (normalized.kind === "stopped") {
|
|
53
|
+
await this.options.jobs.stop(job.jobId, normalized.result ?? null);
|
|
54
|
+
await events.warn({ eventName: "job.stopped", status: "skipped", message: "Job stopped." });
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
await this.options.jobs.succeed(job.jobId, normalized.result ?? null);
|
|
58
|
+
await events.info({ eventName: "job.succeeded", status: "ok", message: "Job succeeded." });
|
|
59
|
+
}
|
|
60
|
+
return normalized;
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
const latest = await this.options.jobs.get(job.jobId);
|
|
64
|
+
if (latest && latest.attemptCount < latest.maxAttempts) {
|
|
65
|
+
await this.options.jobs.requeue(job.jobId, {
|
|
66
|
+
result: { retryError: errorMessage(error) },
|
|
67
|
+
availableAt: this.options.retryDelay?.({ job: latest, error }) ?? null,
|
|
68
|
+
lastError: errorMessage(error),
|
|
69
|
+
});
|
|
70
|
+
await events.warn({ eventName: "job.retrying", status: "warning", message: "Job failed and will retry.", error });
|
|
71
|
+
return { kind: "requeue", result: null };
|
|
72
|
+
}
|
|
73
|
+
await this.options.jobs.fail(job.jobId, error);
|
|
74
|
+
await events.error({ eventName: "job.failed", status: "error", message: "Job failed.", error });
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async shouldStop(jobId) {
|
|
79
|
+
const latest = await this.options.jobs.get(jobId);
|
|
80
|
+
return latest?.status === "stopping" || latest?.status === "stopped";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export async function runParentJobOrchestration(context, input) {
|
|
84
|
+
const existing = await context.jobs.listChildren(context.job.jobId);
|
|
85
|
+
const failureMode = input.failureMode ?? "fail_fast";
|
|
86
|
+
const queuedSiblingPolicy = input.queuedSiblingPolicy ?? "fail";
|
|
87
|
+
if (existing.length === 0) {
|
|
88
|
+
if (input.children.length === 0) {
|
|
89
|
+
return {
|
|
90
|
+
kind: "done",
|
|
91
|
+
result: {
|
|
92
|
+
stage: "complete",
|
|
93
|
+
failureMode,
|
|
94
|
+
queuedSiblingPolicy,
|
|
95
|
+
childJobIds: [],
|
|
96
|
+
summary: emptyChildrenSummary(context.job.jobId),
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const children = [];
|
|
101
|
+
for (const child of input.children) {
|
|
102
|
+
const queued = await context.jobs.enqueue({
|
|
103
|
+
...child,
|
|
104
|
+
parentJobId: child.parentJobId ?? context.job.jobId,
|
|
105
|
+
});
|
|
106
|
+
children.push(queued);
|
|
107
|
+
await input.onChildQueued?.(queued);
|
|
108
|
+
}
|
|
109
|
+
await context.events.info({
|
|
110
|
+
eventName: "job.children_queued",
|
|
111
|
+
status: "started",
|
|
112
|
+
message: "Child jobs queued.",
|
|
113
|
+
metrics: { queued: input.children.length },
|
|
114
|
+
});
|
|
115
|
+
return requeueParent(context.job.payload, children, summarizeJobs(context.job.jobId, children), input);
|
|
116
|
+
}
|
|
117
|
+
const summary = await context.jobs.summarizeChildren(context.job.jobId);
|
|
118
|
+
if (failureMode === "fail_fast" && summary.failed > 0) {
|
|
119
|
+
const failedReason = (await context.jobs.listChildren(context.job.jobId)).find((child) => child.status === "failed" && child.lastError)
|
|
120
|
+
?.lastError ?? `Child job failed for parent ${context.job.jobId}.`;
|
|
121
|
+
if (queuedSiblingPolicy === "stop") {
|
|
122
|
+
await context.jobs.markQueuedChildrenTerminal(context.job.jobId, "stopped", failedReason);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
await context.jobs.markQueuedChildrenTerminal(context.job.jobId, "failed", failedReason);
|
|
126
|
+
}
|
|
127
|
+
await context.jobs.requestStopActiveChildren(context.job.jobId);
|
|
128
|
+
await context.events.warn({
|
|
129
|
+
eventName: "job.children_failed",
|
|
130
|
+
status: "warning",
|
|
131
|
+
message: "One or more child jobs failed.",
|
|
132
|
+
metrics: summary,
|
|
133
|
+
});
|
|
134
|
+
const updated = await context.jobs.summarizeChildren(context.job.jobId);
|
|
135
|
+
if (!updated.allTerminal) {
|
|
136
|
+
const children = await context.jobs.listChildren(context.job.jobId);
|
|
137
|
+
return requeueParent(context.job.payload, children, updated, input);
|
|
138
|
+
}
|
|
139
|
+
throw new Error(failedReason);
|
|
140
|
+
}
|
|
141
|
+
const children = await context.jobs.listChildren(context.job.jobId);
|
|
142
|
+
if (!summary.allTerminal)
|
|
143
|
+
return requeueParent(context.job.payload, children, summary, input);
|
|
144
|
+
return {
|
|
145
|
+
kind: "done",
|
|
146
|
+
result: {
|
|
147
|
+
stage: "complete",
|
|
148
|
+
failureMode,
|
|
149
|
+
queuedSiblingPolicy,
|
|
150
|
+
childJobIds: children.map((child) => child.jobId),
|
|
151
|
+
summary,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
4
155
|
export class D1JobStore {
|
|
5
156
|
db;
|
|
6
157
|
options;
|
|
@@ -14,51 +165,49 @@ export class D1JobStore {
|
|
|
14
165
|
async enqueue(input) {
|
|
15
166
|
const jobId = createId(this.options.idPrefix ?? "job");
|
|
16
167
|
const now = nowIso();
|
|
168
|
+
const enqueuePolicy = input.enqueuePolicy ?? "enqueue";
|
|
169
|
+
if (enqueuePolicy === "dedupe_active" && input.lockKey) {
|
|
170
|
+
const existing = await this.getActiveForLockKey(input.lockKey);
|
|
171
|
+
if (existing)
|
|
172
|
+
return existing;
|
|
173
|
+
}
|
|
17
174
|
await this.db
|
|
18
175
|
.prepare(`INSERT INTO ${this.tableName} (
|
|
19
|
-
job_id, job_type, status, payload_json, result_json,
|
|
176
|
+
job_id, job_type, status, org_id, site_id, payload_json, result_json, metadata_json,
|
|
177
|
+
parent_job_id, lock_key, concurrency_key, concurrency_limit, enqueue_policy,
|
|
20
178
|
attempt_count, max_attempts, available_at, created_at, updated_at
|
|
21
|
-
) VALUES (?, ?, 'queued', ?, NULL, ?, 0, ?, ?, ?, ?)`)
|
|
22
|
-
.bind(jobId, input.jobType, stringifyJsonColumn(input.payload ?? {}), input.lockKey ?? null, Math.max(1, Math.floor(input.maxAttempts ?? 3)), input.availableAt ?? null, now, now)
|
|
179
|
+
) VALUES (?, ?, 'queued', ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`)
|
|
180
|
+
.bind(jobId, input.jobType, input.orgId ?? null, input.siteId ?? null, stringifyJsonColumn(input.payload ?? {}), stringifyJsonColumn(input.metadata ?? {}), input.parentJobId ?? null, input.lockKey ?? null, input.concurrencyKey ?? null, normalizeConcurrencyLimit(input.concurrencyLimit), enqueuePolicy, Math.max(1, Math.floor(input.maxAttempts ?? this.options.defaultMaxAttempts ?? 3)), input.availableAt ?? null, now, now)
|
|
23
181
|
.run();
|
|
24
182
|
const job = await this.get(jobId);
|
|
25
183
|
if (!job)
|
|
26
184
|
throw new Error(`Failed to load enqueued job: ${jobId}`);
|
|
185
|
+
await this.emitStatusEvent(job, "queued", "Job queued.");
|
|
27
186
|
return job;
|
|
28
187
|
}
|
|
29
188
|
async get(jobId) {
|
|
30
189
|
const row = await this.db
|
|
31
|
-
.prepare(`
|
|
32
|
-
job_id AS jobId,
|
|
33
|
-
job_type AS jobType,
|
|
34
|
-
status,
|
|
35
|
-
payload_json AS payloadJson,
|
|
36
|
-
result_json AS resultJson,
|
|
37
|
-
lock_key AS lockKey,
|
|
38
|
-
attempt_count AS attemptCount,
|
|
39
|
-
max_attempts AS maxAttempts,
|
|
40
|
-
available_at AS availableAt,
|
|
41
|
-
started_at AS startedAt,
|
|
42
|
-
completed_at AS completedAt,
|
|
43
|
-
last_error AS lastError,
|
|
44
|
-
created_at AS createdAt,
|
|
45
|
-
updated_at AS updatedAt
|
|
46
|
-
FROM ${this.tableName}
|
|
47
|
-
WHERE job_id = ?
|
|
48
|
-
LIMIT 1`)
|
|
190
|
+
.prepare(`${this.selectSql()} WHERE job_id = ? LIMIT 1`)
|
|
49
191
|
.bind(jobId)
|
|
50
192
|
.first();
|
|
51
193
|
return row ? rowToJob(row) : null;
|
|
52
194
|
}
|
|
195
|
+
async getJob(jobId) {
|
|
196
|
+
return this.get(jobId);
|
|
197
|
+
}
|
|
198
|
+
async enqueueChild(parentJobId, input) {
|
|
199
|
+
return this.enqueue({ ...input, parentJobId });
|
|
200
|
+
}
|
|
201
|
+
async enqueueChildren(parentJobId, inputs) {
|
|
202
|
+
const children = [];
|
|
203
|
+
for (const input of inputs)
|
|
204
|
+
children.push(await this.enqueueChild(parentJobId, input));
|
|
205
|
+
return children;
|
|
206
|
+
}
|
|
53
207
|
async listQueued(limit = 25) {
|
|
54
208
|
const now = nowIso();
|
|
55
209
|
const rows = await this.db
|
|
56
|
-
.prepare(
|
|
57
|
-
job_id AS jobId, job_type AS jobType, status, payload_json AS payloadJson,
|
|
58
|
-
result_json AS resultJson, lock_key AS lockKey, attempt_count AS attemptCount,
|
|
59
|
-
max_attempts AS maxAttempts, available_at AS availableAt, started_at AS startedAt,
|
|
60
|
-
completed_at AS completedAt, last_error AS lastError, created_at AS createdAt, updated_at AS updatedAt
|
|
61
|
-
FROM ${this.tableName}
|
|
210
|
+
.prepare(`${this.selectSql()}
|
|
62
211
|
WHERE status = 'queued' AND (available_at IS NULL OR available_at <= ?)
|
|
63
212
|
ORDER BY created_at ASC
|
|
64
213
|
LIMIT ?`)
|
|
@@ -66,60 +215,75 @@ export class D1JobStore {
|
|
|
66
215
|
.all();
|
|
67
216
|
return (rows.results ?? []).map(rowToJob);
|
|
68
217
|
}
|
|
218
|
+
async getLatestForSite(input) {
|
|
219
|
+
const row = await this.db
|
|
220
|
+
.prepare(`${this.selectSql()}
|
|
221
|
+
WHERE site_id = ?
|
|
222
|
+
${input.jobType ? "AND job_type = ?" : ""}
|
|
223
|
+
ORDER BY created_at DESC
|
|
224
|
+
LIMIT 1`)
|
|
225
|
+
.bind(...[input.siteId, ...(input.jobType ? [input.jobType] : [])])
|
|
226
|
+
.first();
|
|
227
|
+
return row ? rowToJob(row) : null;
|
|
228
|
+
}
|
|
229
|
+
async getActiveForSite(input) {
|
|
230
|
+
const row = await this.db
|
|
231
|
+
.prepare(`${this.selectSql()}
|
|
232
|
+
WHERE site_id = ?
|
|
233
|
+
AND status IN ('queued', 'running', 'stopping')
|
|
234
|
+
${input.jobType ? "AND job_type = ?" : ""}
|
|
235
|
+
ORDER BY created_at DESC
|
|
236
|
+
LIMIT 1`)
|
|
237
|
+
.bind(...[input.siteId, ...(input.jobType ? [input.jobType] : [])])
|
|
238
|
+
.first();
|
|
239
|
+
return row ? rowToJob(row) : null;
|
|
240
|
+
}
|
|
241
|
+
async getLatestForSiteByPayload(input) {
|
|
242
|
+
return this.getForSiteByPayload({ ...input, activeOnly: false });
|
|
243
|
+
}
|
|
244
|
+
async getActiveForSiteByPayload(input) {
|
|
245
|
+
return this.getForSiteByPayload({ ...input, activeOnly: true });
|
|
246
|
+
}
|
|
69
247
|
async claim(jobId) {
|
|
70
248
|
const current = await this.get(jobId);
|
|
71
|
-
if (!current || current.status
|
|
249
|
+
if (!current || isTerminalJobStatus(current.status) || current.status === "stopping")
|
|
72
250
|
return null;
|
|
251
|
+
if (current.status === "running") {
|
|
252
|
+
if (!isStaleRunningJob(current, this.options.staleRunningAfterSeconds))
|
|
253
|
+
return null;
|
|
254
|
+
await this.markStaleRunningQueued(jobId);
|
|
255
|
+
}
|
|
73
256
|
if (current.status === "queued" && isFutureIso(current.availableAt))
|
|
74
257
|
return null;
|
|
75
258
|
if (current.attemptCount >= current.maxAttempts) {
|
|
76
259
|
await this.fail(jobId, "Job exhausted all attempts.");
|
|
77
260
|
return null;
|
|
78
261
|
}
|
|
262
|
+
if (current.concurrencyKey && current.status === "queued") {
|
|
263
|
+
const limit = current.concurrencyLimit ?? 1;
|
|
264
|
+
const running = await this.countRunningForConcurrencyKey(current.concurrencyKey);
|
|
265
|
+
if (running >= limit)
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
79
268
|
const now = nowIso();
|
|
80
269
|
const result = await this.db
|
|
81
270
|
.prepare(`UPDATE ${this.tableName}
|
|
82
271
|
SET status = 'running',
|
|
83
272
|
attempt_count = attempt_count + 1,
|
|
84
|
-
started_at =
|
|
273
|
+
started_at = ?,
|
|
85
274
|
updated_at = ?,
|
|
86
275
|
last_error = NULL
|
|
87
276
|
WHERE job_id = ?
|
|
88
|
-
AND status
|
|
277
|
+
AND status = 'queued'
|
|
89
278
|
AND (available_at IS NULL OR available_at <= ?)`)
|
|
90
279
|
.bind(now, now, jobId, now)
|
|
91
280
|
.run();
|
|
92
281
|
if (!Number(result.meta.changes ?? 0))
|
|
93
282
|
return null;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
await this.db
|
|
99
|
-
.prepare(`UPDATE ${this.tableName}
|
|
100
|
-
SET status = 'succeeded', result_json = ?, completed_at = ?, updated_at = ?
|
|
101
|
-
WHERE job_id = ?`)
|
|
102
|
-
.bind(stringifyJsonColumn(result), now, now, jobId)
|
|
103
|
-
.run();
|
|
104
|
-
}
|
|
105
|
-
async requeue(jobId, result = null, availableAt = null) {
|
|
106
|
-
const now = nowIso();
|
|
107
|
-
await this.db
|
|
108
|
-
.prepare(`UPDATE ${this.tableName}
|
|
109
|
-
SET status = 'queued', result_json = ?, available_at = ?, updated_at = ?
|
|
110
|
-
WHERE job_id = ?`)
|
|
111
|
-
.bind(stringifyJsonColumn(result), availableAt, now, jobId)
|
|
112
|
-
.run();
|
|
113
|
-
}
|
|
114
|
-
async fail(jobId, error) {
|
|
115
|
-
const now = nowIso();
|
|
116
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
117
|
-
await this.db
|
|
118
|
-
.prepare(`UPDATE ${this.tableName}
|
|
119
|
-
SET status = 'failed', last_error = ?, completed_at = ?, updated_at = ?
|
|
120
|
-
WHERE job_id = ?`)
|
|
121
|
-
.bind(message, now, now, jobId)
|
|
122
|
-
.run();
|
|
283
|
+
const job = await this.get(jobId);
|
|
284
|
+
if (job)
|
|
285
|
+
await this.emitStatusEvent(job, "running", "Job started.");
|
|
286
|
+
return job;
|
|
123
287
|
}
|
|
124
288
|
async run(jobId, handler) {
|
|
125
289
|
const job = await this.claim(jobId);
|
|
@@ -129,7 +293,14 @@ export class D1JobStore {
|
|
|
129
293
|
const output = await handler(job);
|
|
130
294
|
const normalized = normalizeJobResult(output);
|
|
131
295
|
if (normalized.kind === "requeue") {
|
|
132
|
-
await this.requeue(jobId,
|
|
296
|
+
await this.requeue(jobId, {
|
|
297
|
+
payload: normalized.payload,
|
|
298
|
+
result: normalized.result ?? null,
|
|
299
|
+
availableAt: normalized.availableAt ?? null,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
else if (normalized.kind === "stopped") {
|
|
303
|
+
await this.stop(jobId, normalized.result ?? null);
|
|
133
304
|
}
|
|
134
305
|
else {
|
|
135
306
|
await this.succeed(jobId, normalized.result ?? null);
|
|
@@ -139,13 +310,699 @@ export class D1JobStore {
|
|
|
139
310
|
catch (error) {
|
|
140
311
|
const latest = await this.get(jobId);
|
|
141
312
|
if (latest && latest.attemptCount < latest.maxAttempts) {
|
|
142
|
-
await this.requeue(jobId, { retryError: error
|
|
313
|
+
await this.requeue(jobId, { result: { retryError: errorMessage(error) }, lastError: errorMessage(error) });
|
|
143
314
|
return { kind: "requeue", result: null };
|
|
144
315
|
}
|
|
145
316
|
await this.fail(jobId, error);
|
|
146
317
|
throw error;
|
|
147
318
|
}
|
|
148
319
|
}
|
|
320
|
+
async succeed(jobId, result = null) {
|
|
321
|
+
await this.complete(jobId, "succeeded", result, null);
|
|
322
|
+
}
|
|
323
|
+
async stop(jobId, result = null) {
|
|
324
|
+
await this.complete(jobId, "stopped", result, null);
|
|
325
|
+
}
|
|
326
|
+
async requeue(jobId, inputOrResult = {}, legacyAvailableAt) {
|
|
327
|
+
const input = isRequeueInput(inputOrResult)
|
|
328
|
+
? inputOrResult
|
|
329
|
+
: { result: inputOrResult, availableAt: legacyAvailableAt ?? null };
|
|
330
|
+
const now = nowIso();
|
|
331
|
+
const payloadSql = input.payload === undefined ? "" : "payload_json = ?,";
|
|
332
|
+
const bindings = input.payload === undefined ? [] : [stringifyJsonColumn(input.payload)];
|
|
333
|
+
await this.db
|
|
334
|
+
.prepare(`UPDATE ${this.tableName}
|
|
335
|
+
SET status = 'queued',
|
|
336
|
+
${payloadSql}
|
|
337
|
+
result_json = ?,
|
|
338
|
+
available_at = ?,
|
|
339
|
+
last_error = ?,
|
|
340
|
+
updated_at = ?
|
|
341
|
+
WHERE job_id = ?`)
|
|
342
|
+
.bind(...bindings, stringifyJsonColumn(input.result ?? null), input.availableAt ?? null, input.lastError ?? null, now, jobId)
|
|
343
|
+
.run();
|
|
344
|
+
const job = await this.get(jobId);
|
|
345
|
+
if (job) {
|
|
346
|
+
await this.emitStatusEvent(job, "queued", "Job requeued.", {
|
|
347
|
+
availableAt: input.availableAt ?? null,
|
|
348
|
+
...(input.lastError ? { lastError: input.lastError } : {}),
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async fail(jobId, error) {
|
|
353
|
+
await this.complete(jobId, "failed", null, errorMessage(error));
|
|
354
|
+
}
|
|
355
|
+
async requestStop(jobId) {
|
|
356
|
+
const now = nowIso();
|
|
357
|
+
const queuedStopStatus = this.options.queuedStopStatus ?? "stopped";
|
|
358
|
+
await this.db
|
|
359
|
+
.prepare(`UPDATE ${this.tableName}
|
|
360
|
+
SET status = CASE WHEN status = 'queued' THEN ? ELSE 'stopping' END,
|
|
361
|
+
completed_at = CASE WHEN status = 'queued' AND ? = 'stopped' THEN ? ELSE completed_at END,
|
|
362
|
+
updated_at = ?
|
|
363
|
+
WHERE job_id = ? AND status IN ('queued', 'running')`)
|
|
364
|
+
.bind(queuedStopStatus, queuedStopStatus, now, now, jobId)
|
|
365
|
+
.run();
|
|
366
|
+
const job = await this.get(jobId);
|
|
367
|
+
if (job)
|
|
368
|
+
await this.emitStatusEvent(job, job.status, "Job stop requested.");
|
|
369
|
+
}
|
|
370
|
+
async listChildren(parentJobId) {
|
|
371
|
+
const rows = await this.db
|
|
372
|
+
.prepare(`${this.selectSql()} WHERE parent_job_id = ? ORDER BY created_at ASC`)
|
|
373
|
+
.bind(parentJobId)
|
|
374
|
+
.all();
|
|
375
|
+
return (rows.results ?? []).map(rowToJob);
|
|
376
|
+
}
|
|
377
|
+
async getJobTree(jobId) {
|
|
378
|
+
const root = await this.get(jobId);
|
|
379
|
+
if (!root)
|
|
380
|
+
return null;
|
|
381
|
+
const rows = await this.db
|
|
382
|
+
.prepare(`WITH RECURSIVE tree(job_id, depth) AS (
|
|
383
|
+
SELECT job_id, 0 FROM ${this.tableName} WHERE job_id = ?
|
|
384
|
+
UNION ALL
|
|
385
|
+
SELECT jobs.job_id, tree.depth + 1
|
|
386
|
+
FROM ${this.tableName} jobs
|
|
387
|
+
JOIN tree ON jobs.parent_job_id = tree.job_id
|
|
388
|
+
)
|
|
389
|
+
SELECT
|
|
390
|
+
jobs.job_id AS jobId,
|
|
391
|
+
jobs.job_type AS jobType,
|
|
392
|
+
jobs.status,
|
|
393
|
+
jobs.org_id AS orgId,
|
|
394
|
+
jobs.site_id AS siteId,
|
|
395
|
+
jobs.payload_json AS payloadJson,
|
|
396
|
+
jobs.result_json AS resultJson,
|
|
397
|
+
jobs.metadata_json AS metadataJson,
|
|
398
|
+
jobs.parent_job_id AS parentJobId,
|
|
399
|
+
jobs.lock_key AS lockKey,
|
|
400
|
+
jobs.concurrency_key AS concurrencyKey,
|
|
401
|
+
jobs.concurrency_limit AS concurrencyLimit,
|
|
402
|
+
jobs.enqueue_policy AS enqueuePolicy,
|
|
403
|
+
jobs.attempt_count AS attemptCount,
|
|
404
|
+
jobs.max_attempts AS maxAttempts,
|
|
405
|
+
jobs.available_at AS availableAt,
|
|
406
|
+
jobs.started_at AS startedAt,
|
|
407
|
+
jobs.completed_at AS completedAt,
|
|
408
|
+
jobs.last_error AS lastError,
|
|
409
|
+
jobs.created_at AS createdAt,
|
|
410
|
+
jobs.updated_at AS updatedAt,
|
|
411
|
+
tree.depth AS depth
|
|
412
|
+
FROM ${this.tableName} jobs
|
|
413
|
+
JOIN tree ON jobs.job_id = tree.job_id
|
|
414
|
+
WHERE jobs.job_id <> ?
|
|
415
|
+
ORDER BY tree.depth ASC, jobs.created_at ASC`)
|
|
416
|
+
.bind(jobId, jobId)
|
|
417
|
+
.all();
|
|
418
|
+
const children = (rows.results ?? []).map((row) => ({ ...rowToJob(row), depth: Number(row.depth) }));
|
|
419
|
+
const allJobs = [root, ...children];
|
|
420
|
+
return {
|
|
421
|
+
root,
|
|
422
|
+
children,
|
|
423
|
+
summary: summarizeJobs(root.jobId, children),
|
|
424
|
+
rollupStatus: rollupJobTreeStatus(allJobs),
|
|
425
|
+
rowStates: [
|
|
426
|
+
jobEntityState(root, 0),
|
|
427
|
+
...children.map((child) => jobEntityState(child, child.depth)),
|
|
428
|
+
],
|
|
429
|
+
activityEvents: await this.listJobActivityEvents(allJobs.map((job) => job.jobId)),
|
|
430
|
+
latestError: latestJobError(allJobs),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
async summarizeChildren(parentJobId) {
|
|
434
|
+
return summarizeJobs(parentJobId, await this.listChildren(parentJobId));
|
|
435
|
+
}
|
|
436
|
+
async markQueuedChildrenTerminal(parentJobId, status, error) {
|
|
437
|
+
const now = nowIso();
|
|
438
|
+
const result = await this.db
|
|
439
|
+
.prepare(`UPDATE ${this.tableName}
|
|
440
|
+
SET status = ?, last_error = ?, completed_at = ?, updated_at = ?
|
|
441
|
+
WHERE parent_job_id = ? AND status = 'queued'`)
|
|
442
|
+
.bind(status, status === "failed" && error ? errorMessage(error) : null, now, now, parentJobId)
|
|
443
|
+
.run();
|
|
444
|
+
const changed = Number(result.meta.changes ?? 0);
|
|
445
|
+
if (changed > 0 && this.options.onStatusEvent) {
|
|
446
|
+
for (const child of await this.listChildren(parentJobId)) {
|
|
447
|
+
if (child.status === status) {
|
|
448
|
+
await this.emitStatusEvent(child, status, status === "failed" ? errorMessage(error ?? "Child job failed.") : "Job stopped.", error ? { reason: errorMessage(error) } : null);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return changed;
|
|
453
|
+
}
|
|
454
|
+
async requestStopActiveChildren(parentJobId) {
|
|
455
|
+
const now = nowIso();
|
|
456
|
+
const statuses = this.options.requestStopQueuedChildren ? "'queued', 'running'" : "'running'";
|
|
457
|
+
const result = await this.db
|
|
458
|
+
.prepare(`UPDATE ${this.tableName}
|
|
459
|
+
SET status = 'stopping', updated_at = ?
|
|
460
|
+
WHERE parent_job_id = ? AND status IN (${statuses})`)
|
|
461
|
+
.bind(now, parentJobId)
|
|
462
|
+
.run();
|
|
463
|
+
const changed = Number(result.meta.changes ?? 0);
|
|
464
|
+
if (changed > 0 && this.options.onStatusEvent) {
|
|
465
|
+
for (const child of await this.listChildren(parentJobId)) {
|
|
466
|
+
if (child.status === "stopping")
|
|
467
|
+
await this.emitStatusEvent(child, "stopping", "Job stop requested.");
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return changed;
|
|
471
|
+
}
|
|
472
|
+
async failQueuedChildren(input) {
|
|
473
|
+
return this.markQueuedChildrenTerminal(input.parentJobId, "failed", input.reason ?? "Child job failed.");
|
|
474
|
+
}
|
|
475
|
+
async stopQueuedChildren(input) {
|
|
476
|
+
return this.markQueuedChildrenTerminal(input.parentJobId, "stopped", input.reason ?? "Child job stopped.");
|
|
477
|
+
}
|
|
478
|
+
async getActiveForLockKey(lockKey) {
|
|
479
|
+
const row = await this.db
|
|
480
|
+
.prepare(`${this.selectSql()} WHERE lock_key = ? AND status IN ('queued', 'running', 'stopping') ORDER BY created_at ASC LIMIT 1`)
|
|
481
|
+
.bind(lockKey)
|
|
482
|
+
.first();
|
|
483
|
+
return row ? rowToJob(row) : null;
|
|
484
|
+
}
|
|
485
|
+
async countRunningForConcurrencyKey(concurrencyKey) {
|
|
486
|
+
const row = await this.db
|
|
487
|
+
.prepare(`SELECT COUNT(*) AS count
|
|
488
|
+
FROM ${this.tableName}
|
|
489
|
+
WHERE concurrency_key = ? AND status IN ('running', 'stopping')`)
|
|
490
|
+
.bind(concurrencyKey)
|
|
491
|
+
.first();
|
|
492
|
+
return Number(row?.count ?? 0);
|
|
493
|
+
}
|
|
494
|
+
async getForSiteByPayload(input) {
|
|
495
|
+
const row = await this.db
|
|
496
|
+
.prepare(`${this.selectSql()}
|
|
497
|
+
WHERE site_id = ?
|
|
498
|
+
${input.jobType ? "AND job_type = ?" : ""}
|
|
499
|
+
${input.activeOnly ? "AND status IN ('queued', 'running', 'stopping')" : ""}
|
|
500
|
+
AND json_extract(payload_json, ?) = ?
|
|
501
|
+
ORDER BY created_at DESC
|
|
502
|
+
LIMIT 1`)
|
|
503
|
+
.bind(...[
|
|
504
|
+
input.siteId,
|
|
505
|
+
...(input.jobType ? [input.jobType] : []),
|
|
506
|
+
`$.${input.key}`,
|
|
507
|
+
input.value,
|
|
508
|
+
])
|
|
509
|
+
.first();
|
|
510
|
+
return row ? rowToJob(row) : null;
|
|
511
|
+
}
|
|
512
|
+
async listJobActivityEvents(jobIds) {
|
|
513
|
+
if (jobIds.length === 0)
|
|
514
|
+
return [];
|
|
515
|
+
const placeholders = jobIds.map(() => "?").join(", ");
|
|
516
|
+
try {
|
|
517
|
+
const rows = await this.db
|
|
518
|
+
.prepare(`SELECT
|
|
519
|
+
ops_event_id AS opsEventId,
|
|
520
|
+
job_id AS jobId,
|
|
521
|
+
level,
|
|
522
|
+
event_type AS eventType,
|
|
523
|
+
message,
|
|
524
|
+
metadata_json AS metadataJson,
|
|
525
|
+
created_at AS createdAt
|
|
526
|
+
FROM ${this.options.opsEventsTableName ?? "ops_events"}
|
|
527
|
+
WHERE job_id IN (${placeholders})
|
|
528
|
+
ORDER BY created_at ASC
|
|
529
|
+
LIMIT 200`)
|
|
530
|
+
.bind(...jobIds)
|
|
531
|
+
.all();
|
|
532
|
+
return (rows.results ?? []).map(rowToActivityEvent);
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
return [];
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
async complete(jobId, status, result, lastError) {
|
|
539
|
+
const now = nowIso();
|
|
540
|
+
await this.db
|
|
541
|
+
.prepare(`UPDATE ${this.tableName}
|
|
542
|
+
SET status = ?, result_json = ?, last_error = ?, completed_at = ?, updated_at = ?
|
|
543
|
+
WHERE job_id = ?`)
|
|
544
|
+
.bind(status, stringifyJsonColumn(result), lastError, now, now, jobId)
|
|
545
|
+
.run();
|
|
546
|
+
const job = await this.get(jobId);
|
|
547
|
+
if (job) {
|
|
548
|
+
await this.emitStatusEvent(job, status, status === "succeeded" ? "Job succeeded." : status === "stopped" ? "Job stopped." : (lastError ?? "Job failed."));
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
async markStaleRunningQueued(jobId) {
|
|
552
|
+
const now = nowIso();
|
|
553
|
+
await this.db
|
|
554
|
+
.prepare(`UPDATE ${this.tableName}
|
|
555
|
+
SET status = 'queued',
|
|
556
|
+
available_at = ?,
|
|
557
|
+
last_error = ?,
|
|
558
|
+
updated_at = ?
|
|
559
|
+
WHERE job_id = ? AND status = 'running'`)
|
|
560
|
+
.bind(now, "Job reclaimed after stale running lease.", now, jobId)
|
|
561
|
+
.run();
|
|
562
|
+
const job = await this.get(jobId);
|
|
563
|
+
if (job) {
|
|
564
|
+
await this.emitStatusEvent(job, "queued", "Stale running job requeued.", {
|
|
565
|
+
previousStatus: "running",
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
async emitStatusEvent(job, status, message, metadata, error) {
|
|
570
|
+
await this.options.onStatusEvent?.({ job, status, message, metadata: metadata ?? null, error });
|
|
571
|
+
}
|
|
572
|
+
selectSql() {
|
|
573
|
+
return `SELECT
|
|
574
|
+
job_id AS jobId,
|
|
575
|
+
job_type AS jobType,
|
|
576
|
+
status,
|
|
577
|
+
org_id AS orgId,
|
|
578
|
+
site_id AS siteId,
|
|
579
|
+
payload_json AS payloadJson,
|
|
580
|
+
result_json AS resultJson,
|
|
581
|
+
metadata_json AS metadataJson,
|
|
582
|
+
parent_job_id AS parentJobId,
|
|
583
|
+
lock_key AS lockKey,
|
|
584
|
+
concurrency_key AS concurrencyKey,
|
|
585
|
+
concurrency_limit AS concurrencyLimit,
|
|
586
|
+
enqueue_policy AS enqueuePolicy,
|
|
587
|
+
attempt_count AS attemptCount,
|
|
588
|
+
max_attempts AS maxAttempts,
|
|
589
|
+
available_at AS availableAt,
|
|
590
|
+
started_at AS startedAt,
|
|
591
|
+
completed_at AS completedAt,
|
|
592
|
+
last_error AS lastError,
|
|
593
|
+
created_at AS createdAt,
|
|
594
|
+
updated_at AS updatedAt
|
|
595
|
+
FROM ${this.tableName}`;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const DEFAULT_POSTGRES_JOB_COLUMNS = {
|
|
599
|
+
jobId: "job_id",
|
|
600
|
+
orgId: "org_id",
|
|
601
|
+
siteId: "site_id",
|
|
602
|
+
queueName: "queue_name",
|
|
603
|
+
jobType: "job_type",
|
|
604
|
+
status: "status",
|
|
605
|
+
payloadJson: "payload_json",
|
|
606
|
+
resultJson: "result_json",
|
|
607
|
+
metadataJson: "metadata_json",
|
|
608
|
+
parentJobId: "parent_job_id",
|
|
609
|
+
lockKey: "lock_key",
|
|
610
|
+
concurrencyKey: "concurrency_key",
|
|
611
|
+
concurrencyLimit: "concurrency_limit",
|
|
612
|
+
enqueuePolicy: "enqueue_policy",
|
|
613
|
+
attemptCount: "attempt_count",
|
|
614
|
+
maxAttempts: "max_attempts",
|
|
615
|
+
availableAt: "available_at",
|
|
616
|
+
startedAt: "started_at",
|
|
617
|
+
completedAt: "completed_at",
|
|
618
|
+
lastError: "last_error",
|
|
619
|
+
createdAt: "created_at",
|
|
620
|
+
updatedAt: "updated_at",
|
|
621
|
+
};
|
|
622
|
+
export class PostgresJobStore {
|
|
623
|
+
sql;
|
|
624
|
+
options;
|
|
625
|
+
tableName;
|
|
626
|
+
columns;
|
|
627
|
+
constructor(sql, options = {}) {
|
|
628
|
+
this.sql = sql;
|
|
629
|
+
this.options = options;
|
|
630
|
+
this.tableName = quotePgIdent(options.tableName ?? "jobs");
|
|
631
|
+
this.columns = { ...DEFAULT_POSTGRES_JOB_COLUMNS, ...(options.columns ?? {}) };
|
|
632
|
+
}
|
|
633
|
+
async enqueue(input) {
|
|
634
|
+
if (input.enqueuePolicy === "dedupe_active" && input.lockKey) {
|
|
635
|
+
const existing = await this.getActiveForLockKey(input.lockKey);
|
|
636
|
+
if (existing)
|
|
637
|
+
return existing;
|
|
638
|
+
}
|
|
639
|
+
const jobId = createId(this.options.idPrefix ?? "job");
|
|
640
|
+
const queueName = stringValue(input.metadata?.queueName) ?? queueNameFromPayload(input.payload) ?? this.options.queueName ?? null;
|
|
641
|
+
const insertColumnKeys = [
|
|
642
|
+
"jobId",
|
|
643
|
+
"orgId",
|
|
644
|
+
"siteId",
|
|
645
|
+
"queueName",
|
|
646
|
+
"jobType",
|
|
647
|
+
"status",
|
|
648
|
+
"payloadJson",
|
|
649
|
+
"resultJson",
|
|
650
|
+
"metadataJson",
|
|
651
|
+
"parentJobId",
|
|
652
|
+
"lockKey",
|
|
653
|
+
"concurrencyKey",
|
|
654
|
+
"concurrencyLimit",
|
|
655
|
+
"enqueuePolicy",
|
|
656
|
+
"maxAttempts",
|
|
657
|
+
"availableAt",
|
|
658
|
+
];
|
|
659
|
+
const insertColumns = insertColumnKeys.filter((key) => this.columns[key] !== null);
|
|
660
|
+
const insertValues = {
|
|
661
|
+
jobId,
|
|
662
|
+
orgId: input.orgId ?? null,
|
|
663
|
+
siteId: input.siteId ?? null,
|
|
664
|
+
queueName,
|
|
665
|
+
jobType: input.jobType,
|
|
666
|
+
status: this.toDbStatus("queued"),
|
|
667
|
+
payloadJson: jsonParam(input.payload ?? {}),
|
|
668
|
+
resultJson: jsonParam(this.options.initialResult ?? null),
|
|
669
|
+
metadataJson: jsonParam(input.metadata ?? {}),
|
|
670
|
+
parentJobId: input.parentJobId ?? null,
|
|
671
|
+
lockKey: input.lockKey ?? null,
|
|
672
|
+
concurrencyKey: input.concurrencyKey ?? null,
|
|
673
|
+
concurrencyLimit: normalizeConcurrencyLimit(input.concurrencyLimit),
|
|
674
|
+
enqueuePolicy: input.enqueuePolicy ?? "enqueue",
|
|
675
|
+
attemptCount: 0,
|
|
676
|
+
maxAttempts: Math.max(1, Math.floor(input.maxAttempts ?? this.options.defaultMaxAttempts ?? 3)),
|
|
677
|
+
availableAt: input.availableAt ?? null,
|
|
678
|
+
startedAt: null,
|
|
679
|
+
completedAt: null,
|
|
680
|
+
lastError: null,
|
|
681
|
+
createdAt: null,
|
|
682
|
+
updatedAt: null,
|
|
683
|
+
};
|
|
684
|
+
const values = insertColumns.map((key) => insertValues[key]);
|
|
685
|
+
await this.sql.unsafe(`INSERT INTO ${this.tableName} (
|
|
686
|
+
${insertColumns.map((key) => this.col(key)).join(", ")}
|
|
687
|
+
) VALUES (${insertColumns.map((key, index) => this.insertPlaceholder(key, index + 1)).join(", ")})`, values);
|
|
688
|
+
const job = await this.get(jobId);
|
|
689
|
+
if (!job)
|
|
690
|
+
throw new Error(`Failed to load enqueued job: ${jobId}`);
|
|
691
|
+
return job;
|
|
692
|
+
}
|
|
693
|
+
async get(jobId) {
|
|
694
|
+
const rows = await this.sql.unsafe(`${this.selectSql()} WHERE ${this.col("jobId")} = $1 LIMIT 1`, [jobId]);
|
|
695
|
+
return rows[0] ? this.rowToJob(rows[0]) : null;
|
|
696
|
+
}
|
|
697
|
+
async claim(jobId) {
|
|
698
|
+
const queueClause = this.options.queueName ? `AND ${this.col("queueName")} = $2` : "";
|
|
699
|
+
const values = this.options.queueName ? [jobId, this.options.queueName] : [jobId];
|
|
700
|
+
const rows = await this.sql.unsafe(`UPDATE ${this.tableName}
|
|
701
|
+
SET ${this.col("status")} = '${this.toDbStatus("running")}',
|
|
702
|
+
${this.col("attemptCount")} = ${this.col("attemptCount")} + 1,
|
|
703
|
+
${this.setSql("startedAt", "now(),")}
|
|
704
|
+
${this.col("updatedAt")} = now(),
|
|
705
|
+
${this.setSql("lastError", "NULL")}
|
|
706
|
+
WHERE ${this.col("jobId")} = $1
|
|
707
|
+
${queueClause}
|
|
708
|
+
AND ${this.col("status")} = '${this.toDbStatus("queued")}'
|
|
709
|
+
AND ${this.col("availableAt")} <= now()
|
|
710
|
+
AND (
|
|
711
|
+
${this.nullableColSql("concurrencyKey", "NULL")} IS NULL
|
|
712
|
+
OR (
|
|
713
|
+
SELECT COUNT(*)
|
|
714
|
+
FROM ${this.tableName} active
|
|
715
|
+
WHERE active.${this.colRaw("concurrencyKey")} = ${this.tableName}.${this.colRaw("concurrencyKey")}
|
|
716
|
+
AND active.${this.colRaw("status")} IN ('${this.toDbStatus("running")}', '${this.toDbStatus("stopping")}')
|
|
717
|
+
AND active.${this.colRaw("jobId")} <> ${this.tableName}.${this.colRaw("jobId")}
|
|
718
|
+
) < COALESCE(${this.nullableColSql("concurrencyLimit", "1")}, 1)
|
|
719
|
+
)
|
|
720
|
+
RETURNING ${this.returningSql()}`, values);
|
|
721
|
+
return rows[0] ? this.rowToJob(rows[0]) : null;
|
|
722
|
+
}
|
|
723
|
+
async succeed(jobId, result = null) {
|
|
724
|
+
await this.complete(jobId, "succeeded", result, null);
|
|
725
|
+
}
|
|
726
|
+
async requeue(jobId, input = {}) {
|
|
727
|
+
const payloadSet = input.payload === undefined ? "" : `${this.col("payloadJson")} = $5::jsonb,`;
|
|
728
|
+
const values = [
|
|
729
|
+
jsonParam(input.result ?? null),
|
|
730
|
+
input.availableAt ?? null,
|
|
731
|
+
input.lastError ?? null,
|
|
732
|
+
jobId,
|
|
733
|
+
...(input.payload === undefined ? [] : [jsonParam(input.payload)]),
|
|
734
|
+
];
|
|
735
|
+
await this.sql.unsafe(`UPDATE ${this.tableName}
|
|
736
|
+
SET ${this.col("status")} = '${this.toDbStatus("queued")}',
|
|
737
|
+
${payloadSet}
|
|
738
|
+
${this.col("resultJson")} = $1::jsonb,
|
|
739
|
+
${this.col("availableAt")} = COALESCE($2::timestamptz, now()),
|
|
740
|
+
${this.setSql("lastError", "$3,")}
|
|
741
|
+
${this.col("updatedAt")} = now()
|
|
742
|
+
WHERE ${this.col("jobId")} = $4`, values);
|
|
743
|
+
}
|
|
744
|
+
async fail(jobId, error) {
|
|
745
|
+
await this.complete(jobId, "failed", null, errorMessage(error));
|
|
746
|
+
}
|
|
747
|
+
async stop(jobId, result = null) {
|
|
748
|
+
await this.complete(jobId, "stopped", result, null);
|
|
749
|
+
}
|
|
750
|
+
async requestStop(jobId) {
|
|
751
|
+
await this.sql.unsafe(`UPDATE ${this.tableName}
|
|
752
|
+
SET ${this.col("status")} = CASE
|
|
753
|
+
WHEN ${this.col("status")} = '${this.toDbStatus("queued")}' THEN '${this.toDbStatus("stopped")}'
|
|
754
|
+
ELSE '${this.toDbStatus("stopping")}'
|
|
755
|
+
END,
|
|
756
|
+
${this.setSql("completedAt", `CASE
|
|
757
|
+
WHEN ${this.col("status")} = '${this.toDbStatus("queued")}' THEN now()
|
|
758
|
+
ELSE ${this.col("completedAt")}
|
|
759
|
+
END,`)}
|
|
760
|
+
${this.col("updatedAt")} = now()
|
|
761
|
+
WHERE ${this.col("jobId")} = $1
|
|
762
|
+
AND ${this.col("status")} IN ('${this.toDbStatus("queued")}', '${this.toDbStatus("running")}')`, [jobId]);
|
|
763
|
+
}
|
|
764
|
+
async listChildren(parentJobId) {
|
|
765
|
+
const rows = await this.sql.unsafe(`${this.selectSql()} WHERE ${this.col("parentJobId")} = $1 ORDER BY ${this.col("createdAt")} ASC`, [parentJobId]);
|
|
766
|
+
return rows.map((row) => this.rowToJob(row));
|
|
767
|
+
}
|
|
768
|
+
async summarizeChildren(parentJobId) {
|
|
769
|
+
return summarizeJobs(parentJobId, await this.listChildren(parentJobId));
|
|
770
|
+
}
|
|
771
|
+
async markQueuedChildrenTerminal(parentJobId, status, error) {
|
|
772
|
+
const rows = await this.sql.unsafe(`WITH updated AS (
|
|
773
|
+
UPDATE ${this.tableName}
|
|
774
|
+
SET ${this.col("status")} = $1,
|
|
775
|
+
${this.setSql("lastError", "$2,")}
|
|
776
|
+
${this.setSql("completedAt", "now(),")}
|
|
777
|
+
${this.col("updatedAt")} = now()
|
|
778
|
+
WHERE ${this.col("parentJobId")} = $3
|
|
779
|
+
AND ${this.col("status")} = '${this.toDbStatus("queued")}'
|
|
780
|
+
RETURNING ${this.col("jobId")}
|
|
781
|
+
)
|
|
782
|
+
SELECT COUNT(*)::int AS changed FROM updated`, [this.toDbStatus(status), status === "failed" && error ? errorMessage(error) : null, parentJobId]);
|
|
783
|
+
return Number(rows[0]?.changed ?? 0);
|
|
784
|
+
}
|
|
785
|
+
async requestStopActiveChildren(parentJobId) {
|
|
786
|
+
const rows = await this.sql.unsafe(`WITH updated AS (
|
|
787
|
+
UPDATE ${this.tableName}
|
|
788
|
+
SET ${this.col("status")} = '${this.toDbStatus("stopping")}',
|
|
789
|
+
${this.col("updatedAt")} = now()
|
|
790
|
+
WHERE ${this.col("parentJobId")} = $1
|
|
791
|
+
AND ${this.col("status")} = '${this.toDbStatus("running")}'
|
|
792
|
+
RETURNING ${this.col("jobId")}
|
|
793
|
+
)
|
|
794
|
+
SELECT COUNT(*)::int AS changed FROM updated`, [parentJobId]);
|
|
795
|
+
return Number(rows[0]?.changed ?? 0);
|
|
796
|
+
}
|
|
797
|
+
async getActiveForLockKey(lockKey) {
|
|
798
|
+
const rows = await this.sql.unsafe(`${this.selectSql()}
|
|
799
|
+
WHERE ${this.col("lockKey")} = $1
|
|
800
|
+
AND ${this.col("status")} IN ('${this.toDbStatus("queued")}', '${this.toDbStatus("running")}', '${this.toDbStatus("stopping")}')
|
|
801
|
+
ORDER BY ${this.col("createdAt")} DESC
|
|
802
|
+
LIMIT 1`, [lockKey]);
|
|
803
|
+
return rows[0] ? this.rowToJob(rows[0]) : null;
|
|
804
|
+
}
|
|
805
|
+
async complete(jobId, status, result, lastError) {
|
|
806
|
+
await this.sql.unsafe(`UPDATE ${this.tableName}
|
|
807
|
+
SET ${this.col("status")} = $1,
|
|
808
|
+
${this.col("resultJson")} = $2::jsonb,
|
|
809
|
+
${this.setSql("lastError", "$3,")}
|
|
810
|
+
${this.setSql("completedAt", "now(),")}
|
|
811
|
+
${this.col("updatedAt")} = now()
|
|
812
|
+
WHERE ${this.col("jobId")} = $4`, [this.toDbStatus(status), jsonParam(result), lastError, jobId]);
|
|
813
|
+
}
|
|
814
|
+
selectSql() {
|
|
815
|
+
return `SELECT ${this.returningSql()} FROM ${this.tableName}`;
|
|
816
|
+
}
|
|
817
|
+
returningSql() {
|
|
818
|
+
return [
|
|
819
|
+
["jobId", "jobId"],
|
|
820
|
+
["jobType", "jobType"],
|
|
821
|
+
["status", "status"],
|
|
822
|
+
["orgId", "orgId"],
|
|
823
|
+
["siteId", "siteId"],
|
|
824
|
+
["payloadJson", "payloadJson"],
|
|
825
|
+
["resultJson", "resultJson"],
|
|
826
|
+
["metadataJson", "metadataJson"],
|
|
827
|
+
["parentJobId", "parentJobId"],
|
|
828
|
+
["lockKey", "lockKey"],
|
|
829
|
+
["concurrencyKey", "concurrencyKey"],
|
|
830
|
+
["concurrencyLimit", "concurrencyLimit"],
|
|
831
|
+
["enqueuePolicy", "enqueuePolicy"],
|
|
832
|
+
["attemptCount", "attemptCount"],
|
|
833
|
+
["maxAttempts", "maxAttempts"],
|
|
834
|
+
["availableAt", "availableAt"],
|
|
835
|
+
["startedAt", "startedAt"],
|
|
836
|
+
["completedAt", "completedAt"],
|
|
837
|
+
["lastError", "lastError"],
|
|
838
|
+
["createdAt", "createdAt"],
|
|
839
|
+
["updatedAt", "updatedAt"],
|
|
840
|
+
]
|
|
841
|
+
.map(([key, alias]) => `${this.selectExpr(key)} AS "${alias}"`)
|
|
842
|
+
.join(", ");
|
|
843
|
+
}
|
|
844
|
+
rowToJob(row) {
|
|
845
|
+
return rowToJob({
|
|
846
|
+
...row,
|
|
847
|
+
status: this.fromDbStatus(String(row.status)),
|
|
848
|
+
payloadJson: jsonText(row.payloadJson) ?? "{}",
|
|
849
|
+
resultJson: jsonText(row.resultJson),
|
|
850
|
+
metadataJson: jsonText(row.metadataJson),
|
|
851
|
+
availableAt: dateText(row.availableAt),
|
|
852
|
+
startedAt: dateText(row.startedAt),
|
|
853
|
+
completedAt: dateText(row.completedAt),
|
|
854
|
+
createdAt: dateText(row.createdAt) ?? "",
|
|
855
|
+
updatedAt: dateText(row.updatedAt) ?? "",
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
toDbStatus(status) {
|
|
859
|
+
return this.options.statusMap?.[status] ?? status;
|
|
860
|
+
}
|
|
861
|
+
fromDbStatus(status) {
|
|
862
|
+
return this.options.reverseStatusMap?.[status] ?? status;
|
|
863
|
+
}
|
|
864
|
+
col(key) {
|
|
865
|
+
const column = this.columns[key];
|
|
866
|
+
if (!column)
|
|
867
|
+
throw new Error(`Postgres job column is not configured: ${String(key)}`);
|
|
868
|
+
return quotePgIdent(column);
|
|
869
|
+
}
|
|
870
|
+
colRaw(key) {
|
|
871
|
+
const column = this.columns[key];
|
|
872
|
+
if (!column)
|
|
873
|
+
throw new Error(`Postgres job column is not configured: ${String(key)}`);
|
|
874
|
+
return column;
|
|
875
|
+
}
|
|
876
|
+
insertPlaceholder(key, index) {
|
|
877
|
+
if (key === "payloadJson" || key === "resultJson" || key === "metadataJson")
|
|
878
|
+
return `$${index}::jsonb`;
|
|
879
|
+
if (key === "availableAt" || key === "startedAt" || key === "completedAt")
|
|
880
|
+
return `COALESCE($${index}::timestamptz, now())`;
|
|
881
|
+
return `$${index}`;
|
|
882
|
+
}
|
|
883
|
+
nullableColSql(key, fallback) {
|
|
884
|
+
return this.columns[key] ? this.col(key) : fallback;
|
|
885
|
+
}
|
|
886
|
+
selectExpr(key) {
|
|
887
|
+
if (this.columns[key])
|
|
888
|
+
return this.col(key);
|
|
889
|
+
if (key === "metadataJson")
|
|
890
|
+
return `'{}'::jsonb`;
|
|
891
|
+
if (key === "resultJson")
|
|
892
|
+
return `'null'::jsonb`;
|
|
893
|
+
if (key === "enqueuePolicy")
|
|
894
|
+
return `'enqueue'`;
|
|
895
|
+
if (key === "concurrencyLimit" || key === "attemptCount" || key === "maxAttempts")
|
|
896
|
+
return "0";
|
|
897
|
+
return "NULL";
|
|
898
|
+
}
|
|
899
|
+
setSql(key, assignment) {
|
|
900
|
+
return this.columns[key] ? `${this.col(key)} = ${assignment}` : "";
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
export class PipelineManager {
|
|
904
|
+
options;
|
|
905
|
+
constructor(options) {
|
|
906
|
+
this.options = options;
|
|
907
|
+
}
|
|
908
|
+
getNextStep(step) {
|
|
909
|
+
const index = this.options.steps.indexOf(step);
|
|
910
|
+
if (index === -1)
|
|
911
|
+
throw new Error(`Unknown pipeline step: ${step}`);
|
|
912
|
+
return this.options.steps[index + 1] ?? null;
|
|
913
|
+
}
|
|
914
|
+
async run(job) {
|
|
915
|
+
if (job.pipeline !== this.options.pipeline)
|
|
916
|
+
throw new Error(`Unexpected pipeline: ${job.pipeline}`);
|
|
917
|
+
const stepIndex = this.options.steps.indexOf(job.step);
|
|
918
|
+
if (stepIndex === -1)
|
|
919
|
+
throw new Error(`Unknown pipeline step: ${job.step}`);
|
|
920
|
+
const defaultNextStep = this.options.steps[stepIndex + 1] ?? null;
|
|
921
|
+
const result = await this.options.handlers[job.step]({
|
|
922
|
+
job,
|
|
923
|
+
stepIndex,
|
|
924
|
+
isFinalStep: defaultNextStep === null,
|
|
925
|
+
});
|
|
926
|
+
const nextState = result?.state ?? job.state;
|
|
927
|
+
const explicitNextStep = result?.nextStep;
|
|
928
|
+
const nextStep = explicitNextStep === undefined
|
|
929
|
+
? defaultNextStep
|
|
930
|
+
: explicitNextStep === null
|
|
931
|
+
? null
|
|
932
|
+
: explicitNextStep.step;
|
|
933
|
+
const enqueuedState = explicitNextStep && explicitNextStep !== null ? (explicitNextStep.state ?? nextState) : nextState;
|
|
934
|
+
if (nextStep) {
|
|
935
|
+
const continuationId = this.options.makeContinuationId?.();
|
|
936
|
+
const nextJob = {
|
|
937
|
+
pipeline: job.pipeline,
|
|
938
|
+
runId: job.runId,
|
|
939
|
+
step: nextStep,
|
|
940
|
+
...(continuationId ? { continuationId } : {}),
|
|
941
|
+
state: enqueuedState,
|
|
942
|
+
};
|
|
943
|
+
if (result?.nextStepDelaySeconds === undefined)
|
|
944
|
+
await this.options.enqueue(nextJob);
|
|
945
|
+
else
|
|
946
|
+
await this.options.enqueue(nextJob, { delaySeconds: result.nextStepDelaySeconds });
|
|
947
|
+
}
|
|
948
|
+
const completion = result?.completion ??
|
|
949
|
+
(nextStep === null && defaultNextStep === null
|
|
950
|
+
? {
|
|
951
|
+
status: "completed",
|
|
952
|
+
targetsTotal: 0,
|
|
953
|
+
targetsSucceeded: 0,
|
|
954
|
+
targetsFailed: 0,
|
|
955
|
+
message: `${job.pipeline} pipeline completed`,
|
|
956
|
+
}
|
|
957
|
+
: null);
|
|
958
|
+
return { nextStep, state: enqueuedState, completion };
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
export function createJobPublisher(options) {
|
|
962
|
+
return {
|
|
963
|
+
async put(message, sendOptions = {}) {
|
|
964
|
+
if (options.inline) {
|
|
965
|
+
if (!options.handleInline)
|
|
966
|
+
throw new Error("Inline job publisher requires handleInline.");
|
|
967
|
+
await options.onQueued?.(message, { ...sendOptions, mode: "inline" });
|
|
968
|
+
await options.handleInline(message);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
if (!options.queue)
|
|
972
|
+
throw new Error("Job queue is not configured.");
|
|
973
|
+
await options.onQueued?.(message, { ...sendOptions, mode: "queue" });
|
|
974
|
+
await options.queue.send(message, sendOptions);
|
|
975
|
+
},
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
export async function enqueueJobOperation(jobs, input) {
|
|
979
|
+
const keys = jobOperationKeys(input);
|
|
980
|
+
return jobs.enqueue({
|
|
981
|
+
jobType: input.jobType,
|
|
982
|
+
orgId: input.orgId ?? null,
|
|
983
|
+
siteId: input.siteId ?? null,
|
|
984
|
+
parentJobId: input.parentJobId ?? null,
|
|
985
|
+
payload: jobOperationPayload(input),
|
|
986
|
+
lockKey: keys.lockKey,
|
|
987
|
+
concurrencyKey: keys.concurrencyKey,
|
|
988
|
+
concurrencyLimit: keys.concurrencyLimit,
|
|
989
|
+
enqueuePolicy: input.dedupeActive === false ? "enqueue" : "dedupe_active",
|
|
990
|
+
maxAttempts: input.maxAttempts ?? undefined,
|
|
991
|
+
availableAt: input.availableAt ?? null,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
export function jobOperationKeys(input) {
|
|
995
|
+
const scope = keyPart(input.siteId ? `site:${input.siteId}` : "global");
|
|
996
|
+
const operation = keyPart(input.operationKey);
|
|
997
|
+
const entityKey = input.entity ? jobOperationEntityKey(input.entity) : null;
|
|
998
|
+
const concurrencyScope = keyPart(input.concurrencyScope ?? input.operationKey);
|
|
999
|
+
return {
|
|
1000
|
+
lockKey: input.dedupeActive === false
|
|
1001
|
+
? null
|
|
1002
|
+
: ["jobop", scope, operation, entityKey ? keyPart(entityKey) : "root"].join(":"),
|
|
1003
|
+
concurrencyKey: ["jobop", scope, concurrencyScope].join(":"),
|
|
1004
|
+
concurrencyLimit: normalizeOperationConcurrencyLimit(input.concurrencyLimit),
|
|
1005
|
+
};
|
|
149
1006
|
}
|
|
150
1007
|
export const D1_JOBS_SCHEMA = `
|
|
151
1008
|
CREATE TABLE IF NOT EXISTS jobs (
|
|
@@ -154,7 +1011,14 @@ CREATE TABLE IF NOT EXISTS jobs (
|
|
|
154
1011
|
status TEXT NOT NULL,
|
|
155
1012
|
payload_json TEXT NOT NULL DEFAULT '{}',
|
|
156
1013
|
result_json TEXT,
|
|
1014
|
+
org_id TEXT,
|
|
1015
|
+
site_id TEXT,
|
|
1016
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
1017
|
+
parent_job_id TEXT,
|
|
157
1018
|
lock_key TEXT,
|
|
1019
|
+
concurrency_key TEXT,
|
|
1020
|
+
concurrency_limit INTEGER,
|
|
1021
|
+
enqueue_policy TEXT NOT NULL DEFAULT 'enqueue',
|
|
158
1022
|
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
159
1023
|
max_attempts INTEGER NOT NULL DEFAULT 3,
|
|
160
1024
|
available_at TEXT,
|
|
@@ -166,8 +1030,13 @@ CREATE TABLE IF NOT EXISTS jobs (
|
|
|
166
1030
|
);
|
|
167
1031
|
CREATE INDEX IF NOT EXISTS jobs_status_available_idx ON jobs(status, available_at, created_at);
|
|
168
1032
|
CREATE INDEX IF NOT EXISTS jobs_type_status_idx ON jobs(job_type, status, created_at);
|
|
1033
|
+
CREATE INDEX IF NOT EXISTS jobs_org_status_idx ON jobs(org_id, status, created_at);
|
|
1034
|
+
CREATE INDEX IF NOT EXISTS jobs_site_status_idx ON jobs(site_id, status, created_at);
|
|
1035
|
+
CREATE INDEX IF NOT EXISTS jobs_parent_status_idx ON jobs(parent_job_id, status, created_at);
|
|
1036
|
+
CREATE INDEX IF NOT EXISTS jobs_concurrency_active_idx ON jobs(concurrency_key, status)
|
|
1037
|
+
WHERE concurrency_key IS NOT NULL AND status IN ('running', 'stopping');
|
|
169
1038
|
CREATE UNIQUE INDEX IF NOT EXISTS jobs_active_lock_key_idx ON jobs(lock_key)
|
|
170
|
-
WHERE lock_key IS NOT NULL AND status IN ('queued', 'running');
|
|
1039
|
+
WHERE lock_key IS NOT NULL AND status IN ('queued', 'running', 'stopping');
|
|
171
1040
|
`;
|
|
172
1041
|
function rowToJob(row) {
|
|
173
1042
|
return {
|
|
@@ -176,7 +1045,14 @@ function rowToJob(row) {
|
|
|
176
1045
|
status: row.status,
|
|
177
1046
|
payload: parseJsonColumn(row.payloadJson, {}),
|
|
178
1047
|
result: parseJsonColumn(row.resultJson, null),
|
|
1048
|
+
orgId: row.orgId ?? null,
|
|
1049
|
+
siteId: row.siteId ?? null,
|
|
1050
|
+
parentJobId: row.parentJobId ?? null,
|
|
179
1051
|
lockKey: row.lockKey,
|
|
1052
|
+
concurrencyKey: row.concurrencyKey ?? null,
|
|
1053
|
+
concurrencyLimit: row.concurrencyLimit === undefined || row.concurrencyLimit === null ? null : Number(row.concurrencyLimit),
|
|
1054
|
+
enqueuePolicy: row.enqueuePolicy ?? "enqueue",
|
|
1055
|
+
metadata: parseJsonColumn(row.metadataJson, {}),
|
|
180
1056
|
attemptCount: Number(row.attemptCount),
|
|
181
1057
|
maxAttempts: Number(row.maxAttempts),
|
|
182
1058
|
availableAt: row.availableAt,
|
|
@@ -192,4 +1068,200 @@ function normalizeJobResult(output) {
|
|
|
192
1068
|
return output;
|
|
193
1069
|
return { kind: "done", result: (output ?? null) };
|
|
194
1070
|
}
|
|
1071
|
+
function quotePgIdent(value) {
|
|
1072
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value))
|
|
1073
|
+
throw new Error(`Invalid Postgres identifier: ${value}`);
|
|
1074
|
+
return `"${value}"`;
|
|
1075
|
+
}
|
|
1076
|
+
function jsonText(value) {
|
|
1077
|
+
if (value === null || value === undefined)
|
|
1078
|
+
return null;
|
|
1079
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
1080
|
+
}
|
|
1081
|
+
function jsonParam(value) {
|
|
1082
|
+
return JSON.stringify(value ?? null);
|
|
1083
|
+
}
|
|
1084
|
+
function queueNameFromPayload(value) {
|
|
1085
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1086
|
+
return null;
|
|
1087
|
+
return stringValue(value.queueName);
|
|
1088
|
+
}
|
|
1089
|
+
function dateText(value) {
|
|
1090
|
+
if (value === null || value === undefined)
|
|
1091
|
+
return null;
|
|
1092
|
+
if (value instanceof Date)
|
|
1093
|
+
return value.toISOString();
|
|
1094
|
+
if (typeof value === "string")
|
|
1095
|
+
return value;
|
|
1096
|
+
return String(value);
|
|
1097
|
+
}
|
|
1098
|
+
function rowToActivityEvent(row) {
|
|
1099
|
+
const level = row.level === "warn" || row.level === "error" ? row.level : "info";
|
|
1100
|
+
return {
|
|
1101
|
+
opsEventId: row.opsEventId,
|
|
1102
|
+
jobId: row.jobId,
|
|
1103
|
+
level,
|
|
1104
|
+
eventType: row.eventType,
|
|
1105
|
+
message: row.message,
|
|
1106
|
+
metadata: parseJsonColumn(row.metadataJson, null),
|
|
1107
|
+
createdAt: row.createdAt,
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
function summarizeJobs(parentJobId, jobs) {
|
|
1111
|
+
const summary = {
|
|
1112
|
+
parentJobId,
|
|
1113
|
+
total: jobs.length,
|
|
1114
|
+
queued: 0,
|
|
1115
|
+
running: 0,
|
|
1116
|
+
stopping: 0,
|
|
1117
|
+
succeeded: 0,
|
|
1118
|
+
failed: 0,
|
|
1119
|
+
stopped: 0,
|
|
1120
|
+
active: 0,
|
|
1121
|
+
terminal: 0,
|
|
1122
|
+
allTerminal: jobs.length > 0,
|
|
1123
|
+
};
|
|
1124
|
+
for (const job of jobs) {
|
|
1125
|
+
summary[job.status] += 1;
|
|
1126
|
+
if (isTerminalJobStatus(job.status))
|
|
1127
|
+
summary.terminal += 1;
|
|
1128
|
+
else
|
|
1129
|
+
summary.active += 1;
|
|
1130
|
+
}
|
|
1131
|
+
summary.allTerminal = summary.total > 0 && summary.terminal === summary.total;
|
|
1132
|
+
return summary;
|
|
1133
|
+
}
|
|
1134
|
+
function rollupJobTreeStatus(jobs) {
|
|
1135
|
+
if (jobs.some((job) => job.status === "failed"))
|
|
1136
|
+
return "failed";
|
|
1137
|
+
if (jobs.some((job) => job.status === "stopped"))
|
|
1138
|
+
return "stopped";
|
|
1139
|
+
if (jobs.some((job) => job.status === "running" || job.status === "stopping"))
|
|
1140
|
+
return "running";
|
|
1141
|
+
if (jobs.some((job) => job.status === "queued"))
|
|
1142
|
+
return "queued";
|
|
1143
|
+
return "succeeded";
|
|
1144
|
+
}
|
|
1145
|
+
function latestJobError(jobs) {
|
|
1146
|
+
return ([...jobs]
|
|
1147
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
|
1148
|
+
.find((job) => job.lastError && job.lastError.trim().length > 0)?.lastError ?? null);
|
|
1149
|
+
}
|
|
1150
|
+
function jobEntityState(job, depth) {
|
|
1151
|
+
const payload = isRecord(job.payload) ? job.payload : {};
|
|
1152
|
+
const entityType = stringValue(payload.entityType);
|
|
1153
|
+
const entityId = stringValue(payload.entityId);
|
|
1154
|
+
const entityKey = stringValue(payload.entityKey) ?? (entityType && entityId ? `${entityType}:${entityId}` : job.jobId);
|
|
1155
|
+
return {
|
|
1156
|
+
jobId: job.jobId,
|
|
1157
|
+
jobType: job.jobType,
|
|
1158
|
+
status: job.status,
|
|
1159
|
+
entityKey,
|
|
1160
|
+
entityType,
|
|
1161
|
+
entityId,
|
|
1162
|
+
label: stringValue(payload.entityLabel),
|
|
1163
|
+
lastError: job.lastError,
|
|
1164
|
+
result: job.result,
|
|
1165
|
+
depth,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
function emptyChildrenSummary(parentJobId) {
|
|
1169
|
+
return summarizeJobs(parentJobId, []);
|
|
1170
|
+
}
|
|
1171
|
+
function requeueParent(payload, children, summary, input) {
|
|
1172
|
+
const pollSeconds = Math.max(1, Math.floor(input.pollSeconds ?? 10));
|
|
1173
|
+
const now = input.now?.() ?? new Date();
|
|
1174
|
+
return {
|
|
1175
|
+
kind: "requeue",
|
|
1176
|
+
payload,
|
|
1177
|
+
result: {
|
|
1178
|
+
stage: "waiting_children",
|
|
1179
|
+
failureMode: input.failureMode ?? "fail_fast",
|
|
1180
|
+
queuedSiblingPolicy: input.queuedSiblingPolicy ?? "fail",
|
|
1181
|
+
childJobIds: children.map((child) => child.jobId),
|
|
1182
|
+
summary,
|
|
1183
|
+
},
|
|
1184
|
+
availableAt: new Date(now.getTime() + pollSeconds * 1000).toISOString(),
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
function isTerminalJobStatus(status) {
|
|
1188
|
+
return status === "succeeded" || status === "failed" || status === "stopped";
|
|
1189
|
+
}
|
|
1190
|
+
function normalizeConcurrencyLimit(value) {
|
|
1191
|
+
if (value === null || value === undefined)
|
|
1192
|
+
return null;
|
|
1193
|
+
return Math.max(1, Math.floor(value));
|
|
1194
|
+
}
|
|
1195
|
+
function jobOperationPayload(input) {
|
|
1196
|
+
return {
|
|
1197
|
+
...(input.payload ?? {}),
|
|
1198
|
+
operationKey: input.operationKey,
|
|
1199
|
+
...(input.entity
|
|
1200
|
+
? {
|
|
1201
|
+
entityType: input.entity.entityType,
|
|
1202
|
+
entityId: input.entity.entityId,
|
|
1203
|
+
entityKey: jobOperationEntityKey(input.entity),
|
|
1204
|
+
...(input.entity.entityLabel ? { entityLabel: input.entity.entityLabel } : {}),
|
|
1205
|
+
}
|
|
1206
|
+
: {}),
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
function jobOperationEntityKey(entity) {
|
|
1210
|
+
return entity.entityKey?.trim() || `${entity.entityType}:${entity.entityId}`;
|
|
1211
|
+
}
|
|
1212
|
+
function keyPart(value) {
|
|
1213
|
+
return value
|
|
1214
|
+
.trim()
|
|
1215
|
+
.toLowerCase()
|
|
1216
|
+
.replace(/[^a-z0-9:_-]+/g, "-")
|
|
1217
|
+
.replace(/-+/g, "-")
|
|
1218
|
+
.replace(/^-|-$/g, "");
|
|
1219
|
+
}
|
|
1220
|
+
function normalizeOperationConcurrencyLimit(value) {
|
|
1221
|
+
if (!Number.isFinite(value) || !value)
|
|
1222
|
+
return 1;
|
|
1223
|
+
return Math.max(1, Math.floor(value));
|
|
1224
|
+
}
|
|
1225
|
+
function isRecord(value) {
|
|
1226
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
1227
|
+
}
|
|
1228
|
+
function stringValue(value) {
|
|
1229
|
+
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
1230
|
+
}
|
|
1231
|
+
function isRequeueInput(value) {
|
|
1232
|
+
return (!!value &&
|
|
1233
|
+
typeof value === "object" &&
|
|
1234
|
+
("payload" in value || "result" in value || "availableAt" in value || "lastError" in value));
|
|
1235
|
+
}
|
|
1236
|
+
function isStaleRunningJob(job, staleRunningAfterSeconds) {
|
|
1237
|
+
if (!staleRunningAfterSeconds || staleRunningAfterSeconds <= 0 || !job.startedAt)
|
|
1238
|
+
return false;
|
|
1239
|
+
const startedAt = Date.parse(job.startedAt);
|
|
1240
|
+
if (!Number.isFinite(startedAt))
|
|
1241
|
+
return false;
|
|
1242
|
+
return Date.now() - startedAt >= staleRunningAfterSeconds * 1000;
|
|
1243
|
+
}
|
|
1244
|
+
function errorMessage(error) {
|
|
1245
|
+
return error instanceof Error ? error.message : String(error);
|
|
1246
|
+
}
|
|
1247
|
+
class DurableJobEvents {
|
|
1248
|
+
job;
|
|
1249
|
+
sink;
|
|
1250
|
+
constructor(job, sink) {
|
|
1251
|
+
this.job = job;
|
|
1252
|
+
this.sink = sink;
|
|
1253
|
+
}
|
|
1254
|
+
async info(input) {
|
|
1255
|
+
await this.emit({ ...input, severity: "info" });
|
|
1256
|
+
}
|
|
1257
|
+
async warn(input) {
|
|
1258
|
+
await this.emit({ ...input, severity: "warn" });
|
|
1259
|
+
}
|
|
1260
|
+
async error(input) {
|
|
1261
|
+
await this.emit({ ...input, severity: "error" });
|
|
1262
|
+
}
|
|
1263
|
+
async emit(input) {
|
|
1264
|
+
await this.sink?.(this.job, input);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
195
1267
|
//# sourceMappingURL=jobs.js.map
|