@happyvertical/smrt-jobs 0.30.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/AGENTS.md +71 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +151 -0
- package/dist/__smrt-register__.d.ts +2 -0
- package/dist/__smrt-register__.d.ts.map +1 -0
- package/dist/background-policy.d.ts +121 -0
- package/dist/background-policy.d.ts.map +1 -0
- package/dist/chunks/runner-DV8FBO0y.js +1642 -0
- package/dist/chunks/runner-DV8FBO0y.js.map +1 -0
- package/dist/chunks/worker-liveness-DOTjoIjr.js +65 -0
- package/dist/chunks/worker-liveness-DOTjoIjr.js.map +1 -0
- package/dist/error-redaction.d.ts +48 -0
- package/dist/error-redaction.d.ts.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +926 -0
- package/dist/index.js.map +1 -0
- package/dist/job-builder.d.ts +94 -0
- package/dist/job-builder.d.ts.map +1 -0
- package/dist/job-handle.d.ts +71 -0
- package/dist/job-handle.d.ts.map +1 -0
- package/dist/logger-extension.d.ts +58 -0
- package/dist/logger-extension.d.ts.map +1 -0
- package/dist/manifest.json +1327 -0
- package/dist/object-extension.d.ts +68 -0
- package/dist/object-extension.d.ts.map +1 -0
- package/dist/playground.d.ts +2 -0
- package/dist/playground.d.ts.map +1 -0
- package/dist/playground.js +179 -0
- package/dist/playground.js.map +1 -0
- package/dist/runner.d.ts +189 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +15 -0
- package/dist/runner.js.map +1 -0
- package/dist/schedule-runner.d.ts +151 -0
- package/dist/schedule-runner.d.ts.map +1 -0
- package/dist/smrt-job-event.d.ts +54 -0
- package/dist/smrt-job-event.d.ts.map +1 -0
- package/dist/smrt-job.d.ts +215 -0
- package/dist/smrt-job.d.ts.map +1 -0
- package/dist/smrt-knowledge.json +508 -0
- package/dist/smrt-worker.d.ts +72 -0
- package/dist/smrt-worker.d.ts.map +1 -0
- package/dist/stale-recovery.d.ts +34 -0
- package/dist/stale-recovery.d.ts.map +1 -0
- package/dist/svelte/components/JobActions.svelte +103 -0
- package/dist/svelte/components/JobActions.svelte.d.ts +23 -0
- package/dist/svelte/components/JobActions.svelte.d.ts.map +1 -0
- package/dist/svelte/components/JobDashboard.svelte +199 -0
- package/dist/svelte/components/JobDashboard.svelte.d.ts +27 -0
- package/dist/svelte/components/JobDashboard.svelte.d.ts.map +1 -0
- package/dist/svelte/components/JobDetail.svelte +256 -0
- package/dist/svelte/components/JobDetail.svelte.d.ts +17 -0
- package/dist/svelte/components/JobDetail.svelte.d.ts.map +1 -0
- package/dist/svelte/components/JobList.svelte +360 -0
- package/dist/svelte/components/JobList.svelte.d.ts +28 -0
- package/dist/svelte/components/JobList.svelte.d.ts.map +1 -0
- package/dist/svelte/components/JobStats.svelte +242 -0
- package/dist/svelte/components/JobStats.svelte.d.ts +15 -0
- package/dist/svelte/components/JobStats.svelte.d.ts.map +1 -0
- package/dist/svelte/components/JobStatusBadge.svelte +23 -0
- package/dist/svelte/components/JobStatusBadge.svelte.d.ts +9 -0
- package/dist/svelte/components/JobStatusBadge.svelte.d.ts.map +1 -0
- package/dist/svelte/components/types.d.ts +9 -0
- package/dist/svelte/components/types.d.ts.map +1 -0
- package/dist/svelte/components/types.js +8 -0
- package/dist/svelte/i18n.d.ts +22 -0
- package/dist/svelte/i18n.d.ts.map +1 -0
- package/dist/svelte/i18n.js +22 -0
- package/dist/svelte/index.d.ts +25 -0
- package/dist/svelte/index.d.ts.map +1 -0
- package/dist/svelte/index.js +28 -0
- package/dist/svelte/playground.d.ts +329 -0
- package/dist/svelte/playground.d.ts.map +1 -0
- package/dist/svelte/playground.js +174 -0
- package/dist/svelte/types.d.ts +191 -0
- package/dist/svelte/types.d.ts.map +1 -0
- package/dist/svelte/types.js +87 -0
- package/dist/ui.d.ts +10 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +69 -0
- package/dist/ui.js.map +1 -0
- package/dist/worker-liveness-thread.d.ts +2 -0
- package/dist/worker-liveness-thread.d.ts.map +1 -0
- package/dist/worker-liveness-thread.js +66 -0
- package/dist/worker-liveness-thread.js.map +1 -0
- package/dist/worker-liveness-ticker.d.ts +30 -0
- package/dist/worker-liveness-ticker.d.ts.map +1 -0
- package/dist/worker-liveness.d.ts +71 -0
- package/dist/worker-liveness.d.ts.map +1 -0
- package/package.json +93 -0
|
@@ -0,0 +1,1642 @@
|
|
|
1
|
+
import { ObjectRegistry, field, smrt, SmrtObject, SmrtCollection, ensureJobsSystemTableCompatibility, detectEngine, foreignKey, ensureJobEventsSystemTableCompatibility, getClassConfigResolvers, resolveLazyConfig } from "@happyvertical/smrt-core";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { Worker } from "node:worker_threads";
|
|
4
|
+
import { fromConfig } from "@happyvertical/jobs";
|
|
5
|
+
import { createLogger } from "@happyvertical/logger";
|
|
6
|
+
import { tenantId, TenantScoped, getTenantId, TenantContext } from "@happyvertical/smrt-tenancy";
|
|
7
|
+
import { createId } from "@happyvertical/utils";
|
|
8
|
+
import { c as createWorkerKey, a as resolveEngine, t as tuneSqliteForConcurrency, r as registerLiveWorker, o as offLoopEligible, u as unregisterLiveWorker, i as isWorkerAlive, b as resolveUrl } from "./worker-liveness-DOTjoIjr.js";
|
|
9
|
+
ObjectRegistry.registerPackageManifest(
|
|
10
|
+
new URL("./manifest.json", import.meta.url)
|
|
11
|
+
);
|
|
12
|
+
const MAX_JOB_RETRIES = 25;
|
|
13
|
+
const DEFAULT_TENANT_JOB_CAP = 1e4;
|
|
14
|
+
function clampRetries(requested) {
|
|
15
|
+
if (Number.isNaN(requested) || requested < 0) {
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
if (requested === Number.POSITIVE_INFINITY) {
|
|
19
|
+
return MAX_JOB_RETRIES;
|
|
20
|
+
}
|
|
21
|
+
return Math.min(Math.floor(requested), MAX_JOB_RETRIES);
|
|
22
|
+
}
|
|
23
|
+
class TenantJobCapExceededError extends Error {
|
|
24
|
+
constructor(tenantId2, cap, current) {
|
|
25
|
+
super(
|
|
26
|
+
`Tenant "${tenantId2}" has reached its background-job cap (${current}/${cap} in-flight). Refusing to enqueue another job.`
|
|
27
|
+
);
|
|
28
|
+
this.tenantId = tenantId2;
|
|
29
|
+
this.cap = cap;
|
|
30
|
+
this.current = current;
|
|
31
|
+
this.name = "TenantJobCapExceededError";
|
|
32
|
+
}
|
|
33
|
+
tenantId;
|
|
34
|
+
cap;
|
|
35
|
+
current;
|
|
36
|
+
}
|
|
37
|
+
function assertWithinTenantCreationCap(tenantId2, current, cap) {
|
|
38
|
+
if (!tenantId2 || cap <= 0) return;
|
|
39
|
+
if (current >= cap) {
|
|
40
|
+
throw new TenantJobCapExceededError(tenantId2, cap, current);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function markBackgroundEligible(ctor, ...methods) {
|
|
44
|
+
const target = ctor;
|
|
45
|
+
const existing = target.backgroundEligibleMethods;
|
|
46
|
+
const set = existing instanceof Set ? new Set(existing) : new Set(existing ?? []);
|
|
47
|
+
for (const method of methods) set.add(method);
|
|
48
|
+
target.backgroundEligibleMethods = set;
|
|
49
|
+
}
|
|
50
|
+
function backgroundEligible() {
|
|
51
|
+
return (target, propertyKey, descriptor) => {
|
|
52
|
+
const ctor = target.constructor;
|
|
53
|
+
markBackgroundEligible(ctor, String(propertyKey));
|
|
54
|
+
return descriptor;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function getBackgroundEligibleMethods(ctor) {
|
|
58
|
+
const declared = ctor?.backgroundEligibleMethods;
|
|
59
|
+
if (declared == null) return null;
|
|
60
|
+
return declared instanceof Set ? declared : new Set(declared);
|
|
61
|
+
}
|
|
62
|
+
function isBackgroundEligibleMethod(ctor, method) {
|
|
63
|
+
const allow = getBackgroundEligibleMethods(ctor);
|
|
64
|
+
if (allow == null) return true;
|
|
65
|
+
return allow.has(method);
|
|
66
|
+
}
|
|
67
|
+
const REDACTED = "***REDACTED***";
|
|
68
|
+
const CREDENTIAL_URL_RE = /([a-z][a-z0-9+.-]*:\/\/)[^/?#@\s]+@/gi;
|
|
69
|
+
const BEARER_TOKEN_RE = /\b(bearer|token)\s+[A-Za-z0-9._\-+/=]{8,}/gi;
|
|
70
|
+
const AUTHORIZATION_HEADER_RE = /\bauthorization\s*[:=]\s*(?:[A-Za-z]+\s+)?[^\s,;)]+/gi;
|
|
71
|
+
const SECRET_KEY_VALUE_RE = /\b([A-Za-z0-9_-]*(?:password|passwd|pwd|secret|api[_-]?key|access[_-]?key|secret[_-]?key|private[_-]?key|client[_-]?secret|token|credential|auth)[A-Za-z0-9_-]*)\s*([:=])\s*("[^"]*"|'[^']*'|[^\s,;)]+)/gi;
|
|
72
|
+
const JSON_SECRET_KEY_VALUE_RE = /("(?:[A-Za-z0-9_-]*(?:password|passwd|pwd|secret|api[_-]?key|access[_-]?key|secret[_-]?key|private[_-]?key|client[_-]?secret|token|credential|auth)[A-Za-z0-9_-]*)")\s*:\s*"[^"]*"/gi;
|
|
73
|
+
const STANDALONE_SECRET_RE = /\b(sk-[A-Za-z0-9_-]{14,}[A-Za-z0-9]|sk_(?:live|test)_[A-Za-z0-9_-]{14,}[A-Za-z0-9]|AKIA[0-9A-Z]{12,}|gh[pousr]_[A-Za-z0-9]{20,}|AIza[A-Za-z0-9_-]{20,}|xox[bp]-[A-Za-z0-9-]{10,})\b/g;
|
|
74
|
+
function redactErrorMessage(message) {
|
|
75
|
+
if (typeof message !== "string" || message.length === 0) {
|
|
76
|
+
return message;
|
|
77
|
+
}
|
|
78
|
+
return message.replace(CREDENTIAL_URL_RE, `$1${REDACTED}@`).replace(AUTHORIZATION_HEADER_RE, `authorization=${REDACTED}`).replace(BEARER_TOKEN_RE, `$1 ${REDACTED}`).replace(JSON_SECRET_KEY_VALUE_RE, `$1:"${REDACTED}"`).replace(SECRET_KEY_VALUE_RE, `$1$2${REDACTED}`).replace(STANDALONE_SECRET_RE, REDACTED);
|
|
79
|
+
}
|
|
80
|
+
function redactErrorForPersistence(error) {
|
|
81
|
+
if (error instanceof Error) {
|
|
82
|
+
return redactErrorMessage(error.message);
|
|
83
|
+
}
|
|
84
|
+
return redactErrorMessage(String(error));
|
|
85
|
+
}
|
|
86
|
+
class JobContextLogger {
|
|
87
|
+
constructor(baseLogger, jobContext) {
|
|
88
|
+
this.baseLogger = baseLogger;
|
|
89
|
+
this.jobContext = jobContext;
|
|
90
|
+
}
|
|
91
|
+
baseLogger;
|
|
92
|
+
jobContext;
|
|
93
|
+
addContext(data) {
|
|
94
|
+
return {
|
|
95
|
+
...data,
|
|
96
|
+
_job: {
|
|
97
|
+
id: this.jobContext.jobId,
|
|
98
|
+
attempt: this.jobContext.attempt,
|
|
99
|
+
queue: this.jobContext.queue,
|
|
100
|
+
objectType: this.jobContext.objectType,
|
|
101
|
+
method: this.jobContext.method
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
debug(message, data) {
|
|
106
|
+
this.baseLogger.debug(message, this.addContext(data));
|
|
107
|
+
}
|
|
108
|
+
info(message, data) {
|
|
109
|
+
this.baseLogger.info(message, this.addContext(data));
|
|
110
|
+
}
|
|
111
|
+
warn(message, data) {
|
|
112
|
+
this.baseLogger.warn(message, this.addContext(data));
|
|
113
|
+
}
|
|
114
|
+
error(message, data) {
|
|
115
|
+
this.baseLogger.error(message, this.addContext(data));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
var __defProp$2 = Object.defineProperty;
|
|
119
|
+
var __getOwnPropDesc$2 = Object.getOwnPropertyDescriptor;
|
|
120
|
+
var __decorateClass$2 = (decorators, target, key, kind) => {
|
|
121
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$2(target, key) : target;
|
|
122
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
123
|
+
if (decorator = decorators[i])
|
|
124
|
+
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
125
|
+
if (kind && result) __defProp$2(target, key, result);
|
|
126
|
+
return result;
|
|
127
|
+
};
|
|
128
|
+
let SmrtJob = class extends SmrtObject {
|
|
129
|
+
tenantId = void 0;
|
|
130
|
+
queue = "default";
|
|
131
|
+
objectType = "";
|
|
132
|
+
objectId = null;
|
|
133
|
+
method = "";
|
|
134
|
+
args = {};
|
|
135
|
+
runAt = /* @__PURE__ */ new Date();
|
|
136
|
+
priority = 50;
|
|
137
|
+
status = "pending";
|
|
138
|
+
attempts = 0;
|
|
139
|
+
maxAttempts = 3;
|
|
140
|
+
timeout = 3e5;
|
|
141
|
+
timeoutBehavior = "fail";
|
|
142
|
+
startedAt = null;
|
|
143
|
+
completedAt = null;
|
|
144
|
+
lastError = null;
|
|
145
|
+
resultPointer = null;
|
|
146
|
+
retryStrategy = {
|
|
147
|
+
type: "exponential",
|
|
148
|
+
config: { initialDelay: 1e3, multiplier: 2, maxDelay: 3e5 }
|
|
149
|
+
};
|
|
150
|
+
workerId = null;
|
|
151
|
+
workerHeartbeat = null;
|
|
152
|
+
/**
|
|
153
|
+
* Capture ambient tenant context when a job is saved inside withTenant().
|
|
154
|
+
*
|
|
155
|
+
* Scheduled jobs can also set this explicitly from their owning schedule.
|
|
156
|
+
*/
|
|
157
|
+
async save() {
|
|
158
|
+
if (this.tenantId === void 0) {
|
|
159
|
+
const contextTenantId = getTenantId();
|
|
160
|
+
if (contextTenantId) {
|
|
161
|
+
this.tenantId = contextTenantId;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return super.save();
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Mark the job for retry
|
|
168
|
+
*/
|
|
169
|
+
async retry() {
|
|
170
|
+
if (this.status === "completed") {
|
|
171
|
+
throw new Error("Cannot retry a completed job");
|
|
172
|
+
}
|
|
173
|
+
this.status = "pending";
|
|
174
|
+
this.attempts = 0;
|
|
175
|
+
this.lastError = null;
|
|
176
|
+
this.startedAt = null;
|
|
177
|
+
this.completedAt = null;
|
|
178
|
+
this.workerId = null;
|
|
179
|
+
this.workerHeartbeat = null;
|
|
180
|
+
await this.save();
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Cancel the job
|
|
184
|
+
*/
|
|
185
|
+
async cancel() {
|
|
186
|
+
if (this.status === "completed" || this.status === "cancelled") {
|
|
187
|
+
throw new Error(`Cannot cancel job with status: ${this.status}`);
|
|
188
|
+
}
|
|
189
|
+
this.status = "cancelled";
|
|
190
|
+
this.completedAt = /* @__PURE__ */ new Date();
|
|
191
|
+
await this.save();
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Get a human-readable description of the job
|
|
195
|
+
*/
|
|
196
|
+
getDescription() {
|
|
197
|
+
const target = this.objectId ? `${this.objectType}#${this.objectId}` : this.objectType;
|
|
198
|
+
return `${target}.${this.method}()`;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
__decorateClass$2([
|
|
202
|
+
tenantId({ nullable: true })
|
|
203
|
+
], SmrtJob.prototype, "tenantId", 2);
|
|
204
|
+
__decorateClass$2([
|
|
205
|
+
field({ type: "text", required: true, default: "default" })
|
|
206
|
+
], SmrtJob.prototype, "queue", 2);
|
|
207
|
+
__decorateClass$2([
|
|
208
|
+
field({ type: "text", required: true })
|
|
209
|
+
], SmrtJob.prototype, "objectType", 2);
|
|
210
|
+
__decorateClass$2([
|
|
211
|
+
field({ type: "text", nullable: true })
|
|
212
|
+
], SmrtJob.prototype, "objectId", 2);
|
|
213
|
+
__decorateClass$2([
|
|
214
|
+
field({ type: "text", required: true })
|
|
215
|
+
], SmrtJob.prototype, "method", 2);
|
|
216
|
+
__decorateClass$2([
|
|
217
|
+
field({ type: "json" })
|
|
218
|
+
], SmrtJob.prototype, "args", 2);
|
|
219
|
+
__decorateClass$2([
|
|
220
|
+
field({ type: "datetime", required: true })
|
|
221
|
+
], SmrtJob.prototype, "runAt", 2);
|
|
222
|
+
__decorateClass$2([
|
|
223
|
+
field({ type: "integer", required: true, default: 50 })
|
|
224
|
+
], SmrtJob.prototype, "priority", 2);
|
|
225
|
+
__decorateClass$2([
|
|
226
|
+
field({ type: "text", required: true, default: "pending" })
|
|
227
|
+
], SmrtJob.prototype, "status", 2);
|
|
228
|
+
__decorateClass$2([
|
|
229
|
+
field({ type: "integer", required: true, default: 0 })
|
|
230
|
+
], SmrtJob.prototype, "attempts", 2);
|
|
231
|
+
__decorateClass$2([
|
|
232
|
+
field({ type: "integer", required: true, default: 3 })
|
|
233
|
+
], SmrtJob.prototype, "maxAttempts", 2);
|
|
234
|
+
__decorateClass$2([
|
|
235
|
+
field({ type: "integer", required: true, default: 3e5 })
|
|
236
|
+
], SmrtJob.prototype, "timeout", 2);
|
|
237
|
+
__decorateClass$2([
|
|
238
|
+
field({ type: "text", required: true, default: "fail" })
|
|
239
|
+
], SmrtJob.prototype, "timeoutBehavior", 2);
|
|
240
|
+
__decorateClass$2([
|
|
241
|
+
field({ type: "datetime", nullable: true })
|
|
242
|
+
], SmrtJob.prototype, "startedAt", 2);
|
|
243
|
+
__decorateClass$2([
|
|
244
|
+
field({ type: "datetime", nullable: true })
|
|
245
|
+
], SmrtJob.prototype, "completedAt", 2);
|
|
246
|
+
__decorateClass$2([
|
|
247
|
+
field({ type: "text", nullable: true })
|
|
248
|
+
], SmrtJob.prototype, "lastError", 2);
|
|
249
|
+
__decorateClass$2([
|
|
250
|
+
field({ type: "text", nullable: true })
|
|
251
|
+
], SmrtJob.prototype, "resultPointer", 2);
|
|
252
|
+
__decorateClass$2([
|
|
253
|
+
field({ type: "json" })
|
|
254
|
+
], SmrtJob.prototype, "retryStrategy", 2);
|
|
255
|
+
__decorateClass$2([
|
|
256
|
+
field({ type: "text", nullable: true })
|
|
257
|
+
], SmrtJob.prototype, "workerId", 2);
|
|
258
|
+
__decorateClass$2([
|
|
259
|
+
field({ type: "datetime", nullable: true })
|
|
260
|
+
], SmrtJob.prototype, "workerHeartbeat", 2);
|
|
261
|
+
SmrtJob = __decorateClass$2([
|
|
262
|
+
smrt({
|
|
263
|
+
tableName: "_smrt_jobs",
|
|
264
|
+
// Fail closed: `_smrt_jobs` is an internal operational queue table. Generated
|
|
265
|
+
// REST/MCP list/get only filter by tenant when a tenant context is active;
|
|
266
|
+
// reached without context (a tenant-less/admin principal, or any non-SvelteKit
|
|
267
|
+
// surface) an `optional`-mode class returns UNFILTERED rows, leaking every
|
|
268
|
+
// tenant's jobs. Workers read this table via the collection directly
|
|
269
|
+
// (allowRawOnTenantScoped), never through generated routes, so nothing
|
|
270
|
+
// internal needs the read surface — so we do not generate one (S5 audit #1402).
|
|
271
|
+
api: false,
|
|
272
|
+
// retry/cancel are operator commands invoked in-process via the CLI;
|
|
273
|
+
// they intentionally aren't exposed over HTTP.
|
|
274
|
+
cli: {
|
|
275
|
+
include: ["list", "get", "retry", "cancel"],
|
|
276
|
+
skipApiCheck: true,
|
|
277
|
+
http: false
|
|
278
|
+
},
|
|
279
|
+
mcp: false
|
|
280
|
+
}),
|
|
281
|
+
TenantScoped({ mode: "optional" })
|
|
282
|
+
], SmrtJob);
|
|
283
|
+
class SmrtJobCollection extends SmrtCollection {
|
|
284
|
+
static _itemClass = SmrtJob;
|
|
285
|
+
async initialize() {
|
|
286
|
+
await super.initialize();
|
|
287
|
+
await ensureJobsSystemTableCompatibility(this.db);
|
|
288
|
+
return this;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* List jobs by status
|
|
292
|
+
*/
|
|
293
|
+
async listByStatus(status, options = {}) {
|
|
294
|
+
const where = {
|
|
295
|
+
status: Array.isArray(status) ? status : [status]
|
|
296
|
+
};
|
|
297
|
+
if (options.queue) {
|
|
298
|
+
where.queue = options.queue;
|
|
299
|
+
}
|
|
300
|
+
return this.list({
|
|
301
|
+
where,
|
|
302
|
+
orderBy: ["priority DESC", "run_at ASC"],
|
|
303
|
+
limit: options.limit
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* List pending jobs ready to run
|
|
308
|
+
*/
|
|
309
|
+
async listReady(options = {}) {
|
|
310
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
311
|
+
const whereConditions = ["status = 'pending'", "run_at <= ?"];
|
|
312
|
+
const params = [now];
|
|
313
|
+
if (options.queues?.length) {
|
|
314
|
+
const placeholders = options.queues.map(() => "?").join(", ");
|
|
315
|
+
whereConditions.push(`queue IN (${placeholders})`);
|
|
316
|
+
params.push(...options.queues);
|
|
317
|
+
}
|
|
318
|
+
params.push(options.limit || 100);
|
|
319
|
+
return this.query(
|
|
320
|
+
`SELECT * FROM _smrt_jobs WHERE ${whereConditions.join(" AND ")} ORDER BY priority DESC, run_at ASC LIMIT ?`,
|
|
321
|
+
params,
|
|
322
|
+
{ allowRawOnTenantScoped: true }
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Atomically claim pending jobs ready to run for a worker.
|
|
327
|
+
*
|
|
328
|
+
* The claim is performed as one conditional UPDATE so concurrent workers
|
|
329
|
+
* cannot receive the same pending row. PostgreSQL additionally skips rows
|
|
330
|
+
* locked by other workers instead of waiting behind them.
|
|
331
|
+
*/
|
|
332
|
+
async claimReady(options) {
|
|
333
|
+
const limit = options.limit ?? 100;
|
|
334
|
+
if (limit <= 0) return [];
|
|
335
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
336
|
+
const nowIso = now.toISOString();
|
|
337
|
+
const whereConditions = ["status = 'pending'", "run_at <= ?"];
|
|
338
|
+
const whereParams = [nowIso];
|
|
339
|
+
if (options.queues?.length) {
|
|
340
|
+
const placeholders = options.queues.map(() => "?").join(", ");
|
|
341
|
+
whereConditions.push(`queue IN (${placeholders})`);
|
|
342
|
+
whereParams.push(...options.queues);
|
|
343
|
+
}
|
|
344
|
+
const lockClause = getDatabaseEngine(this.db) === "postgres" ? " FOR UPDATE SKIP LOCKED" : "";
|
|
345
|
+
const candidateSelect = `
|
|
346
|
+
SELECT id
|
|
347
|
+
FROM _smrt_jobs
|
|
348
|
+
WHERE ${whereConditions.join(" AND ")}
|
|
349
|
+
ORDER BY priority DESC, run_at ASC, created_at ASC, id ASC
|
|
350
|
+
LIMIT ?${lockClause}
|
|
351
|
+
`;
|
|
352
|
+
const claimed = await this.query(
|
|
353
|
+
`UPDATE _smrt_jobs
|
|
354
|
+
SET status = 'running',
|
|
355
|
+
worker_id = ?,
|
|
356
|
+
worker_heartbeat = ?,
|
|
357
|
+
started_at = ?,
|
|
358
|
+
attempts = attempts + 1,
|
|
359
|
+
updated_at = ?
|
|
360
|
+
WHERE id IN (${candidateSelect})
|
|
361
|
+
AND status = 'pending'
|
|
362
|
+
RETURNING *`,
|
|
363
|
+
[options.workerId, nowIso, nowIso, nowIso, ...whereParams, limit],
|
|
364
|
+
// Worker-internal cross-tenant claim; tenant context is restored
|
|
365
|
+
// per-job at execution (SmrtJob is now @TenantScoped, S5 #1402).
|
|
366
|
+
{ allowRawOnTenantScoped: true }
|
|
367
|
+
);
|
|
368
|
+
return claimed.toSorted(compareClaimOrder);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Count non-terminal (pending/running) jobs owned by a tenant.
|
|
372
|
+
*
|
|
373
|
+
* Used to enforce the per-tenant creation cap so one tenant cannot exhaust
|
|
374
|
+
* the shared worker pool (S5 audit #1402). Reads `_smrt_jobs` directly so it
|
|
375
|
+
* works regardless of ambient tenant context.
|
|
376
|
+
*
|
|
377
|
+
* @param tenantId - Tenant to count for. `null` counts global (NULL-tenant)
|
|
378
|
+
* jobs.
|
|
379
|
+
*/
|
|
380
|
+
async countInFlightForTenant(tenantId2) {
|
|
381
|
+
const predicate = tenantId2 === null ? "tenant_id IS NULL" : "tenant_id = ?";
|
|
382
|
+
const params = tenantId2 === null ? [] : [tenantId2];
|
|
383
|
+
const result = await this.db.query(
|
|
384
|
+
`SELECT COUNT(*) AS count
|
|
385
|
+
FROM _smrt_jobs
|
|
386
|
+
WHERE status IN ('pending', 'running')
|
|
387
|
+
AND ${predicate}`,
|
|
388
|
+
...params
|
|
389
|
+
);
|
|
390
|
+
const row = result.rows[0];
|
|
391
|
+
return Number(row?.count ?? 0);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* The single creation path for queued jobs.
|
|
395
|
+
*
|
|
396
|
+
* Centralizes the two creation-time security guards from the S5 audit (#1402)
|
|
397
|
+
* so every enqueue — the fluent {@link "./job-builder".JobBuilder} *and* the
|
|
398
|
+
* ScheduleRunner's cron-triggered jobs — goes through one place:
|
|
399
|
+
*
|
|
400
|
+
* 1. `maxAttempts` is clamped to {@link MAX_JOB_RETRIES} so a misconfigured
|
|
401
|
+
* caller cannot pin a worker on a poison job indefinitely.
|
|
402
|
+
* 2. A per-tenant in-flight cap bounds how many non-terminal jobs one tenant
|
|
403
|
+
* may hold, so one tenant cannot exhaust the shared worker pool
|
|
404
|
+
* (cross-tenant denial of service). The cap applies to the row's effective
|
|
405
|
+
* tenant (explicit `data.tenantId` or, when absent, the ambient context);
|
|
406
|
+
* global (null-tenant) jobs are exempt.
|
|
407
|
+
*
|
|
408
|
+
* Atomicity note (best-effort soft cap, by design): the cap is a
|
|
409
|
+
* count-then-insert, NOT a hard transactional invariant. It is intentionally
|
|
410
|
+
* left non-atomic. A plain transaction would not help — under the adapters'
|
|
411
|
+
* default isolation two concurrent same-tenant enqueues would each read the
|
|
412
|
+
* same COUNT and both insert, so serializing them would require either a
|
|
413
|
+
* per-tenant lock row (`SELECT ... FOR UPDATE`) or SERIALIZABLE-isolation
|
|
414
|
+
* retry loops. That cross-process locking is fragile (lock-row contention,
|
|
415
|
+
* adapter-specific isolation behavior, the `transaction` adapter method being
|
|
416
|
+
* optional) and out of proportion to the threat: this cap is defense in depth
|
|
417
|
+
* against runaway/accidental creation exhausting the shared worker pool, not a
|
|
418
|
+
* billing/quota boundary. So under truly simultaneous enqueues a tenant may
|
|
419
|
+
* momentarily overshoot by the number of in-flight creators; the bound still
|
|
420
|
+
* prevents unbounded growth and closes the prior ScheduleRunner bypass. If a
|
|
421
|
+
* hard guarantee is ever needed, enforce it with a DB CHECK/trigger or a
|
|
422
|
+
* dedicated counter row, not an application-level lock.
|
|
423
|
+
*/
|
|
424
|
+
async enqueueJob(data, options = {}) {
|
|
425
|
+
const cap = options.tenantJobCap ?? DEFAULT_TENANT_JOB_CAP;
|
|
426
|
+
const explicitTenant = typeof data.tenantId === "string" && data.tenantId.length > 0 ? data.tenantId : data.tenantId === null ? null : void 0;
|
|
427
|
+
const effectiveTenant = explicitTenant !== void 0 ? explicitTenant : getTenantId() ?? null;
|
|
428
|
+
if (effectiveTenant && cap > 0) {
|
|
429
|
+
const current = await this.countInFlightForTenant(effectiveTenant);
|
|
430
|
+
assertWithinTenantCreationCap(effectiveTenant, current, cap);
|
|
431
|
+
}
|
|
432
|
+
const job = await this.create({
|
|
433
|
+
...data,
|
|
434
|
+
// Clamp here so neither the builder nor the schedule runner can bypass the
|
|
435
|
+
// retry ceiling (S5 audit #1402).
|
|
436
|
+
maxAttempts: clampRetries(data.maxAttempts ?? 3)
|
|
437
|
+
});
|
|
438
|
+
await job.save();
|
|
439
|
+
return job;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Get job statistics
|
|
443
|
+
*/
|
|
444
|
+
async stats(queue) {
|
|
445
|
+
const query = queue ? "SELECT status, COUNT(*) as count FROM _smrt_jobs WHERE queue = ? GROUP BY status" : "SELECT status, COUNT(*) as count FROM _smrt_jobs GROUP BY status";
|
|
446
|
+
const params = queue ? [queue] : [];
|
|
447
|
+
const result = await this._db.query(query, ...params);
|
|
448
|
+
const counts = {};
|
|
449
|
+
for (const row of result.rows) {
|
|
450
|
+
counts[row.status] = row.count;
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
pending: counts.pending ?? 0,
|
|
454
|
+
running: counts.running ?? 0,
|
|
455
|
+
completed: counts.completed ?? 0,
|
|
456
|
+
failed: counts.failed ?? 0,
|
|
457
|
+
cancelled: counts.cancelled ?? 0
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Cleanup old completed/failed jobs
|
|
462
|
+
*/
|
|
463
|
+
async cleanup(options) {
|
|
464
|
+
const conditions = [];
|
|
465
|
+
const params = [];
|
|
466
|
+
if (options.completedBefore) {
|
|
467
|
+
conditions.push("(status = 'completed' AND completed_at < ?)");
|
|
468
|
+
params.push(options.completedBefore.toISOString());
|
|
469
|
+
}
|
|
470
|
+
if (options.failedBefore) {
|
|
471
|
+
conditions.push("(status = 'failed' AND completed_at < ?)");
|
|
472
|
+
params.push(options.failedBefore.toISOString());
|
|
473
|
+
}
|
|
474
|
+
if (options.cancelledBefore) {
|
|
475
|
+
conditions.push("(status = 'cancelled' AND completed_at < ?)");
|
|
476
|
+
params.push(options.cancelledBefore.toISOString());
|
|
477
|
+
}
|
|
478
|
+
if (conditions.length === 0) return 0;
|
|
479
|
+
let query = `DELETE FROM _smrt_jobs WHERE (${conditions.join(" OR ")})`;
|
|
480
|
+
if (options.limit) {
|
|
481
|
+
query = `
|
|
482
|
+
DELETE FROM _smrt_jobs
|
|
483
|
+
WHERE id IN (
|
|
484
|
+
SELECT id FROM _smrt_jobs
|
|
485
|
+
WHERE (${conditions.join(" OR ")})
|
|
486
|
+
LIMIT ?
|
|
487
|
+
)
|
|
488
|
+
`;
|
|
489
|
+
params.push(options.limit);
|
|
490
|
+
}
|
|
491
|
+
const result = await this._db.query(query, ...params);
|
|
492
|
+
return result.rowCount ?? 0;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function getDatabaseEngine(db) {
|
|
496
|
+
const dbWithConfig = db;
|
|
497
|
+
return detectEngine(
|
|
498
|
+
db.url || dbWithConfig.config?.url || "",
|
|
499
|
+
dbWithConfig.type || dbWithConfig.config?.type
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
function compareClaimOrder(left, right) {
|
|
503
|
+
const priority = right.priority - left.priority;
|
|
504
|
+
if (priority !== 0) return priority;
|
|
505
|
+
const runAt = left.runAt.getTime() - right.runAt.getTime();
|
|
506
|
+
if (runAt !== 0) return runAt;
|
|
507
|
+
const createdAt = timestamp(left.created_at) - timestamp(right.created_at);
|
|
508
|
+
if (createdAt !== 0) return createdAt;
|
|
509
|
+
return (left.id ?? "").localeCompare(right.id ?? "");
|
|
510
|
+
}
|
|
511
|
+
function timestamp(value) {
|
|
512
|
+
return value?.getTime() ?? 0;
|
|
513
|
+
}
|
|
514
|
+
var __defProp$1 = Object.defineProperty;
|
|
515
|
+
var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
|
|
516
|
+
var __decorateClass$1 = (decorators, target, key, kind) => {
|
|
517
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
|
|
518
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
519
|
+
if (decorator = decorators[i])
|
|
520
|
+
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
521
|
+
if (kind && result) __defProp$1(target, key, result);
|
|
522
|
+
return result;
|
|
523
|
+
};
|
|
524
|
+
const JOB_EVENT_STORAGE_COLUMNS = [
|
|
525
|
+
"id",
|
|
526
|
+
"slug",
|
|
527
|
+
"context",
|
|
528
|
+
"created_at",
|
|
529
|
+
"updated_at",
|
|
530
|
+
"tenant_id",
|
|
531
|
+
"job_id",
|
|
532
|
+
"type",
|
|
533
|
+
"level",
|
|
534
|
+
"stage",
|
|
535
|
+
"progress",
|
|
536
|
+
"message",
|
|
537
|
+
"data"
|
|
538
|
+
].join(", ");
|
|
539
|
+
let SmrtJobEvent = class extends SmrtObject {
|
|
540
|
+
tenantId = void 0;
|
|
541
|
+
jobId = "";
|
|
542
|
+
type = "log";
|
|
543
|
+
level = "info";
|
|
544
|
+
stage = null;
|
|
545
|
+
progress = null;
|
|
546
|
+
message = "";
|
|
547
|
+
data = {};
|
|
548
|
+
createdAt = /* @__PURE__ */ new Date();
|
|
549
|
+
toCursor() {
|
|
550
|
+
const createdAt = this.createdAt instanceof Date ? this.createdAt.toISOString() : String(this.createdAt);
|
|
551
|
+
return `${createdAt}|${this.id ?? ""}`;
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
__decorateClass$1([
|
|
555
|
+
tenantId({ nullable: true })
|
|
556
|
+
], SmrtJobEvent.prototype, "tenantId", 2);
|
|
557
|
+
__decorateClass$1([
|
|
558
|
+
foreignKey("SmrtJob", { required: true })
|
|
559
|
+
], SmrtJobEvent.prototype, "jobId", 2);
|
|
560
|
+
__decorateClass$1([
|
|
561
|
+
field({ type: "text", required: true, default: "log" })
|
|
562
|
+
], SmrtJobEvent.prototype, "type", 2);
|
|
563
|
+
__decorateClass$1([
|
|
564
|
+
field({ type: "text", required: true, default: "info" })
|
|
565
|
+
], SmrtJobEvent.prototype, "level", 2);
|
|
566
|
+
__decorateClass$1([
|
|
567
|
+
field({ type: "text", nullable: true })
|
|
568
|
+
], SmrtJobEvent.prototype, "stage", 2);
|
|
569
|
+
__decorateClass$1([
|
|
570
|
+
field({ type: "integer", nullable: true })
|
|
571
|
+
], SmrtJobEvent.prototype, "progress", 2);
|
|
572
|
+
__decorateClass$1([
|
|
573
|
+
field({ type: "text", required: true, default: "" })
|
|
574
|
+
], SmrtJobEvent.prototype, "message", 2);
|
|
575
|
+
__decorateClass$1([
|
|
576
|
+
field({ type: "json" })
|
|
577
|
+
], SmrtJobEvent.prototype, "data", 2);
|
|
578
|
+
__decorateClass$1([
|
|
579
|
+
field({ type: "datetime", required: true })
|
|
580
|
+
], SmrtJobEvent.prototype, "createdAt", 2);
|
|
581
|
+
SmrtJobEvent = __decorateClass$1([
|
|
582
|
+
smrt({
|
|
583
|
+
tableName: "_smrt_job_events",
|
|
584
|
+
// Fail closed: same reasoning as SmrtJob. `_smrt_job_events` carries job
|
|
585
|
+
// progress/log/error payloads for every tenant; an `optional`-mode generated
|
|
586
|
+
// read reached without tenant context returns UNFILTERED rows. Consumers read
|
|
587
|
+
// events through the collection's tenant-aware methods (listByJob /
|
|
588
|
+
// listSinceCursor, which require an explicit tenantId or ambient context), not
|
|
589
|
+
// through generated routes — so we do not generate a read surface here
|
|
590
|
+
// (S5 audit #1402).
|
|
591
|
+
api: false,
|
|
592
|
+
// In-process operator commands only (http: false). skipApiCheck acknowledges
|
|
593
|
+
// that these CLI reads intentionally have no HTTP/API route now that api is
|
|
594
|
+
// disabled (S5 audit #1402).
|
|
595
|
+
cli: { include: ["list", "get"], http: false, skipApiCheck: true },
|
|
596
|
+
mcp: false
|
|
597
|
+
}),
|
|
598
|
+
TenantScoped({ mode: "optional" })
|
|
599
|
+
], SmrtJobEvent);
|
|
600
|
+
function normalizeLimit(limit) {
|
|
601
|
+
const numeric = typeof limit === "number" && Number.isFinite(limit) ? limit : 250;
|
|
602
|
+
return Math.max(1, Math.min(1e3, Math.floor(numeric)));
|
|
603
|
+
}
|
|
604
|
+
function normalizeProgress(progress) {
|
|
605
|
+
if (typeof progress !== "number" || !Number.isFinite(progress)) {
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
return Math.max(0, Math.min(100, Math.round(progress)));
|
|
609
|
+
}
|
|
610
|
+
function parseCursor(cursor) {
|
|
611
|
+
if (typeof cursor !== "string") return cursor;
|
|
612
|
+
const separator = cursor.lastIndexOf("|");
|
|
613
|
+
if (separator === -1) {
|
|
614
|
+
return { createdAt: cursor, id: "" };
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
createdAt: cursor.slice(0, separator),
|
|
618
|
+
id: cursor.slice(separator + 1)
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
function normalizeCursorDate(value) {
|
|
622
|
+
if (value instanceof Date) {
|
|
623
|
+
return value.toISOString();
|
|
624
|
+
}
|
|
625
|
+
const parsed = new Date(value);
|
|
626
|
+
if (!Number.isNaN(parsed.getTime())) {
|
|
627
|
+
return parsed.toISOString();
|
|
628
|
+
}
|
|
629
|
+
return value;
|
|
630
|
+
}
|
|
631
|
+
function usesSqliteDateFunctions(dbUrl) {
|
|
632
|
+
const normalized = dbUrl.toLowerCase();
|
|
633
|
+
return !(normalized.startsWith("postgres:") || normalized.startsWith("postgresql:"));
|
|
634
|
+
}
|
|
635
|
+
function getQueryRows(result) {
|
|
636
|
+
if (Array.isArray(result)) {
|
|
637
|
+
return result;
|
|
638
|
+
}
|
|
639
|
+
return result.rows ?? [];
|
|
640
|
+
}
|
|
641
|
+
class SmrtJobEventCollection extends SmrtCollection {
|
|
642
|
+
static _itemClass = SmrtJobEvent;
|
|
643
|
+
async initialize() {
|
|
644
|
+
await super.initialize();
|
|
645
|
+
await ensureJobEventsSystemTableCompatibility(this.db);
|
|
646
|
+
return this;
|
|
647
|
+
}
|
|
648
|
+
async append(input) {
|
|
649
|
+
return this.create({
|
|
650
|
+
tenantId: input.tenantId,
|
|
651
|
+
jobId: input.jobId,
|
|
652
|
+
type: input.type ?? "log",
|
|
653
|
+
level: input.level ?? "info",
|
|
654
|
+
stage: input.stage ?? null,
|
|
655
|
+
progress: normalizeProgress(input.progress),
|
|
656
|
+
message: input.message ?? "",
|
|
657
|
+
data: input.data ?? {},
|
|
658
|
+
createdAt: input.createdAt ?? /* @__PURE__ */ new Date()
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
async listByJob(jobId, options = {}) {
|
|
662
|
+
return this.listSinceCursor({
|
|
663
|
+
...options,
|
|
664
|
+
jobId
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
async listSinceCursor(options = {}) {
|
|
668
|
+
const where = [];
|
|
669
|
+
const params = [];
|
|
670
|
+
if (options.jobId) {
|
|
671
|
+
where.push("job_id = ?");
|
|
672
|
+
params.push(options.jobId);
|
|
673
|
+
}
|
|
674
|
+
this.addTenantPredicate(where, params, options);
|
|
675
|
+
if (options.cursor) {
|
|
676
|
+
const cursor = parseCursor(options.cursor);
|
|
677
|
+
const createdAt = await this.resolveCursorCreatedAt(cursor, options);
|
|
678
|
+
const createdAtExpression = this.createdAtComparableExpression();
|
|
679
|
+
where.push(
|
|
680
|
+
`(${createdAtExpression} > ? OR (${createdAtExpression} = ? AND id > ?))`
|
|
681
|
+
);
|
|
682
|
+
params.push(createdAt, createdAt, cursor.id);
|
|
683
|
+
} else if (options.since) {
|
|
684
|
+
where.push(`${this.createdAtComparableExpression()} > ?`);
|
|
685
|
+
params.push(normalizeCursorDate(options.since));
|
|
686
|
+
}
|
|
687
|
+
if (options.afterId) {
|
|
688
|
+
where.push("id > ?");
|
|
689
|
+
params.push(options.afterId);
|
|
690
|
+
}
|
|
691
|
+
params.push(normalizeLimit(options.limit));
|
|
692
|
+
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
|
693
|
+
return this.query(
|
|
694
|
+
`SELECT ${JOB_EVENT_STORAGE_COLUMNS}
|
|
695
|
+
FROM _smrt_job_events
|
|
696
|
+
${whereSql}
|
|
697
|
+
ORDER BY ${this.createdAtComparableExpression()} ASC, id ASC
|
|
698
|
+
LIMIT ?`,
|
|
699
|
+
params,
|
|
700
|
+
{ allowRawOnTenantScoped: true }
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
async latestProgressByJobIds(jobIds, options = {}) {
|
|
704
|
+
const uniqueJobIds = [...new Set(jobIds.filter(Boolean))];
|
|
705
|
+
const latestByJobId = /* @__PURE__ */ new Map();
|
|
706
|
+
if (uniqueJobIds.length === 0) return latestByJobId;
|
|
707
|
+
const placeholders = uniqueJobIds.map(() => "?").join(", ");
|
|
708
|
+
const where = [
|
|
709
|
+
`job_id IN (${placeholders})`,
|
|
710
|
+
"type = 'progress'"
|
|
711
|
+
];
|
|
712
|
+
const params = [...uniqueJobIds];
|
|
713
|
+
this.addTenantPredicate(where, params, options);
|
|
714
|
+
const createdAtExpression = this.createdAtComparableExpression();
|
|
715
|
+
const events = await this.query(
|
|
716
|
+
`SELECT ${JOB_EVENT_STORAGE_COLUMNS}
|
|
717
|
+
FROM (
|
|
718
|
+
SELECT ${JOB_EVENT_STORAGE_COLUMNS},
|
|
719
|
+
${createdAtExpression} AS smrt_created_at_sort,
|
|
720
|
+
ROW_NUMBER() OVER (
|
|
721
|
+
PARTITION BY job_id
|
|
722
|
+
ORDER BY ${createdAtExpression} DESC, id DESC
|
|
723
|
+
) AS smrt_rank
|
|
724
|
+
FROM _smrt_job_events
|
|
725
|
+
WHERE ${where.join(" AND ")}
|
|
726
|
+
) ranked
|
|
727
|
+
WHERE smrt_rank = 1
|
|
728
|
+
ORDER BY smrt_created_at_sort DESC, id DESC`,
|
|
729
|
+
params,
|
|
730
|
+
{ allowRawOnTenantScoped: true }
|
|
731
|
+
);
|
|
732
|
+
for (const event of events) {
|
|
733
|
+
latestByJobId.set(event.jobId, event);
|
|
734
|
+
}
|
|
735
|
+
return latestByJobId;
|
|
736
|
+
}
|
|
737
|
+
addTenantPredicate(where, params, options) {
|
|
738
|
+
if (options.tenantId === null) {
|
|
739
|
+
where.push("tenant_id IS NULL");
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
const tenantId2 = typeof options.tenantId === "string" ? options.tenantId : getTenantId();
|
|
743
|
+
if (tenantId2) {
|
|
744
|
+
where.push("tenant_id = ?");
|
|
745
|
+
params.push(tenantId2);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
throw new Error(
|
|
749
|
+
"Tenant-scoped job event queries require tenantId, tenantId: null, or an ambient tenant context."
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
createdAtComparableExpression() {
|
|
753
|
+
if (usesSqliteDateFunctions(this.db.url)) {
|
|
754
|
+
return "strftime('%Y-%m-%dT%H:%M:%fZ', created_at)";
|
|
755
|
+
}
|
|
756
|
+
return "created_at";
|
|
757
|
+
}
|
|
758
|
+
async resolveCursorCreatedAt(cursor, options) {
|
|
759
|
+
if (!cursor.id) {
|
|
760
|
+
return normalizeCursorDate(cursor.createdAt);
|
|
761
|
+
}
|
|
762
|
+
const where = ["id = ?"];
|
|
763
|
+
const params = [cursor.id];
|
|
764
|
+
if (options.jobId) {
|
|
765
|
+
where.push("job_id = ?");
|
|
766
|
+
params.push(options.jobId);
|
|
767
|
+
}
|
|
768
|
+
this.addTenantPredicate(where, params, options);
|
|
769
|
+
const result = await this.db.query(
|
|
770
|
+
`SELECT ${this.createdAtComparableExpression()} AS cursor_created_at
|
|
771
|
+
FROM _smrt_job_events
|
|
772
|
+
WHERE ${where.join(" AND ")}
|
|
773
|
+
LIMIT 1`,
|
|
774
|
+
...params
|
|
775
|
+
);
|
|
776
|
+
const cursorCreatedAt = getQueryRows(result)[0]?.cursor_created_at;
|
|
777
|
+
return typeof cursorCreatedAt === "string" && cursorCreatedAt.trim() ? cursorCreatedAt : normalizeCursorDate(cursor.createdAt);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
var __defProp = Object.defineProperty;
|
|
781
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
782
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
783
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
784
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
785
|
+
if (decorator = decorators[i])
|
|
786
|
+
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
787
|
+
if (kind && result) __defProp(target, key, result);
|
|
788
|
+
return result;
|
|
789
|
+
};
|
|
790
|
+
let SmrtWorker = class extends SmrtObject {
|
|
791
|
+
workerId = "";
|
|
792
|
+
pid = null;
|
|
793
|
+
hostname = null;
|
|
794
|
+
startedAt = null;
|
|
795
|
+
heartbeatAt = null;
|
|
796
|
+
leaseExpiresAt = null;
|
|
797
|
+
status = "running";
|
|
798
|
+
};
|
|
799
|
+
__decorateClass([
|
|
800
|
+
field({ type: "text", required: true })
|
|
801
|
+
], SmrtWorker.prototype, "workerId", 2);
|
|
802
|
+
__decorateClass([
|
|
803
|
+
field({ type: "integer", nullable: true })
|
|
804
|
+
], SmrtWorker.prototype, "pid", 2);
|
|
805
|
+
__decorateClass([
|
|
806
|
+
field({ type: "text", nullable: true })
|
|
807
|
+
], SmrtWorker.prototype, "hostname", 2);
|
|
808
|
+
__decorateClass([
|
|
809
|
+
field({ type: "datetime", nullable: true })
|
|
810
|
+
], SmrtWorker.prototype, "startedAt", 2);
|
|
811
|
+
__decorateClass([
|
|
812
|
+
field({ type: "datetime", nullable: true })
|
|
813
|
+
], SmrtWorker.prototype, "heartbeatAt", 2);
|
|
814
|
+
__decorateClass([
|
|
815
|
+
field({ type: "datetime", nullable: true })
|
|
816
|
+
], SmrtWorker.prototype, "leaseExpiresAt", 2);
|
|
817
|
+
__decorateClass([
|
|
818
|
+
field({ type: "text", required: true, default: "running" })
|
|
819
|
+
], SmrtWorker.prototype, "status", 2);
|
|
820
|
+
SmrtWorker = __decorateClass([
|
|
821
|
+
smrt({
|
|
822
|
+
tableName: "_smrt_workers",
|
|
823
|
+
conflictColumns: ["worker_id"],
|
|
824
|
+
api: false,
|
|
825
|
+
cli: false,
|
|
826
|
+
mcp: false
|
|
827
|
+
})
|
|
828
|
+
], SmrtWorker);
|
|
829
|
+
class SmrtWorkerCollection extends SmrtCollection {
|
|
830
|
+
static _itemClass = SmrtWorker;
|
|
831
|
+
/**
|
|
832
|
+
* Fail fast if the `_smrt_workers` table has not been migrated.
|
|
833
|
+
*
|
|
834
|
+
* The framework never creates application/system tables at runtime; the
|
|
835
|
+
* table is created by `smrt db:migrate` (or `getTestDatabase`). A consumer
|
|
836
|
+
* that upgrades smrt-jobs without migrating must get a clear, actionable
|
|
837
|
+
* error at `start()` rather than a confusing recovery failure later.
|
|
838
|
+
*/
|
|
839
|
+
async assertReady() {
|
|
840
|
+
try {
|
|
841
|
+
await this.db.query("SELECT 1 FROM _smrt_workers LIMIT 1");
|
|
842
|
+
} catch (error) {
|
|
843
|
+
throw new Error(
|
|
844
|
+
`The _smrt_workers table is missing. Run \`smrt db:migrate\` to create job-system tables before starting a TaskRunner/ScheduleRunner. (underlying error: ${error.message})`
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
/** Whether the `_smrt_workers` table exists (recovery skips lease checks if not). */
|
|
849
|
+
async tableReady() {
|
|
850
|
+
try {
|
|
851
|
+
await this.db.query("SELECT 1 FROM _smrt_workers LIMIT 1");
|
|
852
|
+
return true;
|
|
853
|
+
} catch {
|
|
854
|
+
return false;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
/** Register a worker incarnation with its lease seeded to `now + ttl`. */
|
|
858
|
+
async registerWorker(input) {
|
|
859
|
+
const now = /* @__PURE__ */ new Date();
|
|
860
|
+
await this.create({
|
|
861
|
+
workerId: input.workerKey,
|
|
862
|
+
pid: input.pid ?? null,
|
|
863
|
+
hostname: input.hostname ?? null,
|
|
864
|
+
startedAt: now,
|
|
865
|
+
heartbeatAt: now,
|
|
866
|
+
// Seed the lease in the same write so the worker is immediately "alive",
|
|
867
|
+
// closing the window between registration and the first claimReady().
|
|
868
|
+
leaseExpiresAt: new Date(now.getTime() + input.leaseTtlMs),
|
|
869
|
+
status: "running"
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
/** Renew a worker's lease to `now + ttl`. */
|
|
873
|
+
async renewLease(workerKey, leaseTtlMs) {
|
|
874
|
+
const now = /* @__PURE__ */ new Date();
|
|
875
|
+
await this.db.query(
|
|
876
|
+
`UPDATE _smrt_workers
|
|
877
|
+
SET lease_expires_at = ?,
|
|
878
|
+
heartbeat_at = ?
|
|
879
|
+
WHERE worker_id = ?`,
|
|
880
|
+
new Date(now.getTime() + leaseTtlMs).toISOString(),
|
|
881
|
+
now.toISOString(),
|
|
882
|
+
workerKey
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
/** Remove a worker incarnation (graceful shutdown). */
|
|
886
|
+
async expireWorker(workerKey) {
|
|
887
|
+
await this.db.query(
|
|
888
|
+
"DELETE FROM _smrt_workers WHERE worker_id = ?",
|
|
889
|
+
workerKey
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
/** Worker keys whose database lease is still fresh (alive cross-process). */
|
|
893
|
+
async freshLeaseWorkerKeys() {
|
|
894
|
+
const result = await this.db.query(
|
|
895
|
+
`SELECT worker_id
|
|
896
|
+
FROM _smrt_workers
|
|
897
|
+
WHERE lease_expires_at IS NOT NULL
|
|
898
|
+
AND lease_expires_at >= ?`,
|
|
899
|
+
(/* @__PURE__ */ new Date()).toISOString()
|
|
900
|
+
);
|
|
901
|
+
const keys = /* @__PURE__ */ new Set();
|
|
902
|
+
for (const row of result.rows) {
|
|
903
|
+
if (typeof row.worker_id === "string") keys.add(row.worker_id);
|
|
904
|
+
}
|
|
905
|
+
return keys;
|
|
906
|
+
}
|
|
907
|
+
/** Delete worker rows whose lease expired more than `graceMs` ago. */
|
|
908
|
+
async pruneExpired(graceMs) {
|
|
909
|
+
const cutoff = new Date(Date.now() - Math.max(0, graceMs)).toISOString();
|
|
910
|
+
await this.db.query(
|
|
911
|
+
`DELETE FROM _smrt_workers
|
|
912
|
+
WHERE lease_expires_at IS NOT NULL
|
|
913
|
+
AND lease_expires_at < ?`,
|
|
914
|
+
cutoff
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
const DEFAULT_TASK_HEARTBEAT_INTERVAL_MS = 3e4;
|
|
919
|
+
const DEFAULT_LEASE_TICK_MS = 1e4;
|
|
920
|
+
const DEFAULT_LEASE_TTL_MS = 3e4;
|
|
921
|
+
const LEASE_TTL_GRACE_MULTIPLIER = 3;
|
|
922
|
+
function getEffectiveLeaseTtlMs(leaseTtlMs, leaseTickMs) {
|
|
923
|
+
return Math.max(leaseTtlMs, leaseTickMs * LEASE_TTL_GRACE_MULTIPLIER);
|
|
924
|
+
}
|
|
925
|
+
const LIVENESS_THREAD_START_TIMEOUT_MS = 1e4;
|
|
926
|
+
const DEFAULT_CONFIG = {
|
|
927
|
+
id: "",
|
|
928
|
+
concurrency: 5,
|
|
929
|
+
queues: ["default"],
|
|
930
|
+
pollInterval: 1e3,
|
|
931
|
+
heartbeatInterval: DEFAULT_TASK_HEARTBEAT_INTERVAL_MS,
|
|
932
|
+
shutdownTimeout: 3e4,
|
|
933
|
+
staleJobThresholdMs: 9e4,
|
|
934
|
+
leaseTtlMs: DEFAULT_LEASE_TTL_MS,
|
|
935
|
+
leaseTickMs: DEFAULT_LEASE_TICK_MS
|
|
936
|
+
};
|
|
937
|
+
class TaskRunner extends EventEmitter {
|
|
938
|
+
id;
|
|
939
|
+
/**
|
|
940
|
+
* Per-incarnation-unique worker key. Stored as the `worker_id` on claimed
|
|
941
|
+
* jobs and in `_smrt_workers`, so a restart of a runner sharing the same
|
|
942
|
+
* configured `id` does not look like it still owns the previous
|
|
943
|
+
* incarnation's orphaned jobs. The human-facing {@link id} stays stable for
|
|
944
|
+
* events/logs.
|
|
945
|
+
*/
|
|
946
|
+
workerKey;
|
|
947
|
+
config;
|
|
948
|
+
effectiveLeaseTtlMs;
|
|
949
|
+
collection = null;
|
|
950
|
+
eventCollection = null;
|
|
951
|
+
workerCollection = null;
|
|
952
|
+
workersTableVerified = false;
|
|
953
|
+
lastRecoverySweepAt = 0;
|
|
954
|
+
running = false;
|
|
955
|
+
activeJobs = /* @__PURE__ */ new Map();
|
|
956
|
+
pollTimer = null;
|
|
957
|
+
heartbeatTimer = null;
|
|
958
|
+
leaseTimer = null;
|
|
959
|
+
livenessWorker = null;
|
|
960
|
+
shutdownPromise = null;
|
|
961
|
+
db = null;
|
|
962
|
+
logger = createLogger(true);
|
|
963
|
+
constructor(config = {}) {
|
|
964
|
+
super();
|
|
965
|
+
this.config = {
|
|
966
|
+
...DEFAULT_CONFIG,
|
|
967
|
+
...config,
|
|
968
|
+
id: config.id || `runner_${createId().slice(0, 8)}`
|
|
969
|
+
};
|
|
970
|
+
this.id = this.config.id;
|
|
971
|
+
this.workerKey = createWorkerKey(this.id);
|
|
972
|
+
this.effectiveLeaseTtlMs = getEffectiveLeaseTtlMs(
|
|
973
|
+
this.config.leaseTtlMs,
|
|
974
|
+
this.config.leaseTickMs
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Initialize the runner with database connection
|
|
979
|
+
*/
|
|
980
|
+
async initialize(db) {
|
|
981
|
+
this.db = db;
|
|
982
|
+
this.collection = await SmrtJobCollection.create({ db });
|
|
983
|
+
this.eventCollection = await SmrtJobEventCollection.create({ db });
|
|
984
|
+
this.workerCollection = await SmrtWorkerCollection.create({ db });
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Start processing jobs
|
|
988
|
+
*/
|
|
989
|
+
async start() {
|
|
990
|
+
if (this.running) return;
|
|
991
|
+
if (!this.collection || !this.workerCollection) {
|
|
992
|
+
throw new Error("TaskRunner not initialized. Call initialize() first.");
|
|
993
|
+
}
|
|
994
|
+
await this.workerCollection.assertReady();
|
|
995
|
+
if (this.db && resolveEngine(this.db) === "sqlite") {
|
|
996
|
+
await tuneSqliteForConcurrency(this.db);
|
|
997
|
+
}
|
|
998
|
+
await this.workerCollection.registerWorker({
|
|
999
|
+
workerKey: this.workerKey,
|
|
1000
|
+
pid: typeof process !== "undefined" ? process.pid : null,
|
|
1001
|
+
hostname: typeof process !== "undefined" ? process.env.HOSTNAME ?? null : null,
|
|
1002
|
+
leaseTtlMs: this.effectiveLeaseTtlMs
|
|
1003
|
+
});
|
|
1004
|
+
registerLiveWorker(this.workerKey);
|
|
1005
|
+
this.running = true;
|
|
1006
|
+
if (offLoopEligible(this.db)) {
|
|
1007
|
+
const threadStarted = await this.startLivenessThread();
|
|
1008
|
+
if (!threadStarted) this.startLeaseRenewal();
|
|
1009
|
+
} else {
|
|
1010
|
+
this.startLeaseRenewal();
|
|
1011
|
+
}
|
|
1012
|
+
this.startPolling();
|
|
1013
|
+
this.startHeartbeat();
|
|
1014
|
+
this.emit("runner:started");
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Stop processing jobs (graceful shutdown)
|
|
1018
|
+
*/
|
|
1019
|
+
async stop() {
|
|
1020
|
+
if (!this.running) return;
|
|
1021
|
+
if (this.shutdownPromise) return this.shutdownPromise;
|
|
1022
|
+
this.running = false;
|
|
1023
|
+
if (this.pollTimer) {
|
|
1024
|
+
clearTimeout(this.pollTimer);
|
|
1025
|
+
this.pollTimer = null;
|
|
1026
|
+
}
|
|
1027
|
+
if (this.heartbeatTimer) {
|
|
1028
|
+
clearInterval(this.heartbeatTimer);
|
|
1029
|
+
this.heartbeatTimer = null;
|
|
1030
|
+
}
|
|
1031
|
+
this.shutdownPromise = this.waitForActiveJobs();
|
|
1032
|
+
try {
|
|
1033
|
+
await this.shutdownPromise;
|
|
1034
|
+
} finally {
|
|
1035
|
+
this.shutdownPromise = null;
|
|
1036
|
+
await this.stopLivenessThread();
|
|
1037
|
+
if (this.leaseTimer) {
|
|
1038
|
+
clearInterval(this.leaseTimer);
|
|
1039
|
+
this.leaseTimer = null;
|
|
1040
|
+
}
|
|
1041
|
+
unregisterLiveWorker(this.workerKey);
|
|
1042
|
+
if (this.activeJobs.size === 0) {
|
|
1043
|
+
try {
|
|
1044
|
+
await this.workerCollection?.expireWorker(this.workerKey);
|
|
1045
|
+
} catch {
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
this.emit("runner:stopped");
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Check if runner is running
|
|
1053
|
+
*/
|
|
1054
|
+
isRunning() {
|
|
1055
|
+
return this.running;
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Get count of active jobs
|
|
1059
|
+
*/
|
|
1060
|
+
activeJobCount() {
|
|
1061
|
+
return this.activeJobs.size;
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Start the polling loop
|
|
1065
|
+
*/
|
|
1066
|
+
startPolling() {
|
|
1067
|
+
const poll = async () => {
|
|
1068
|
+
if (!this.running) return;
|
|
1069
|
+
try {
|
|
1070
|
+
await this.poll();
|
|
1071
|
+
} catch (error) {
|
|
1072
|
+
this.emit("runner:error", error);
|
|
1073
|
+
}
|
|
1074
|
+
if (this.running) {
|
|
1075
|
+
this.pollTimer = setTimeout(poll, this.config.pollInterval);
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
poll();
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Poll for and process jobs
|
|
1082
|
+
*/
|
|
1083
|
+
async poll() {
|
|
1084
|
+
if (!this.collection || !this.db) return;
|
|
1085
|
+
await this.recoverStaleJobs();
|
|
1086
|
+
const available = this.config.concurrency - this.activeJobs.size;
|
|
1087
|
+
if (available <= 0) return;
|
|
1088
|
+
const jobs = await this.collection.claimReady({
|
|
1089
|
+
workerId: this.workerKey,
|
|
1090
|
+
queues: this.config.queues,
|
|
1091
|
+
limit: available
|
|
1092
|
+
});
|
|
1093
|
+
for (const job of jobs) {
|
|
1094
|
+
const jobId = job.id;
|
|
1095
|
+
if (!jobId) continue;
|
|
1096
|
+
this.processJob(job);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Process a single job
|
|
1101
|
+
*/
|
|
1102
|
+
async processJob(job) {
|
|
1103
|
+
const jobId = job.id;
|
|
1104
|
+
if (!jobId) {
|
|
1105
|
+
this.emit("runner:error", new Error("Job has no ID"));
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
this.activeJobs.set(jobId, job);
|
|
1109
|
+
this.emit("job:started", job);
|
|
1110
|
+
await this.appendJobEvent(job, {
|
|
1111
|
+
type: "status",
|
|
1112
|
+
level: "info",
|
|
1113
|
+
stage: "started",
|
|
1114
|
+
progress: 0,
|
|
1115
|
+
message: `Started job: ${job.getDescription()}`
|
|
1116
|
+
});
|
|
1117
|
+
try {
|
|
1118
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
1119
|
+
setTimeout(() => {
|
|
1120
|
+
reject(new Error(`Job timeout after ${job.timeout}ms`));
|
|
1121
|
+
}, job.timeout);
|
|
1122
|
+
});
|
|
1123
|
+
const result = await Promise.race([this.executeJob(job), timeoutPromise]);
|
|
1124
|
+
const completedAt = /* @__PURE__ */ new Date();
|
|
1125
|
+
const applied = await this.writeOwnedJob(jobId, {
|
|
1126
|
+
status: "completed",
|
|
1127
|
+
completed_at: completedAt.toISOString(),
|
|
1128
|
+
result_pointer: result?.resultPointer ?? null,
|
|
1129
|
+
updated_at: completedAt.toISOString()
|
|
1130
|
+
});
|
|
1131
|
+
if (applied) {
|
|
1132
|
+
job.status = "completed";
|
|
1133
|
+
job.completedAt = completedAt;
|
|
1134
|
+
job.resultPointer = result?.resultPointer ?? null;
|
|
1135
|
+
await this.appendJobEvent(job, {
|
|
1136
|
+
type: "progress",
|
|
1137
|
+
level: "info",
|
|
1138
|
+
stage: "completed",
|
|
1139
|
+
progress: 100,
|
|
1140
|
+
message: `Completed job: ${job.getDescription()}`
|
|
1141
|
+
});
|
|
1142
|
+
this.emit("job:completed", job, result);
|
|
1143
|
+
}
|
|
1144
|
+
} catch (error) {
|
|
1145
|
+
await this.handleJobError(job, error);
|
|
1146
|
+
} finally {
|
|
1147
|
+
this.activeJobs.delete(jobId);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Apply a terminal/retry state transition to a job only if this worker still
|
|
1152
|
+
* owns it and it is still `running`. Returns whether the write applied.
|
|
1153
|
+
*
|
|
1154
|
+
* This closes the completion-vs-recovery race: if recovery already failed a
|
|
1155
|
+
* job out from under a finishing handler (a genuine zombie), the handler's
|
|
1156
|
+
* outcome is dropped rather than resurrecting the row.
|
|
1157
|
+
*/
|
|
1158
|
+
async writeOwnedJob(jobId, assignments) {
|
|
1159
|
+
if (!this.db) return false;
|
|
1160
|
+
const columns = Object.keys(assignments);
|
|
1161
|
+
const setSql = columns.map((column) => `${column} = ?`).join(", ");
|
|
1162
|
+
const values = columns.map((column) => assignments[column]);
|
|
1163
|
+
const result = await this.db.query(
|
|
1164
|
+
`UPDATE _smrt_jobs
|
|
1165
|
+
SET ${setSql}
|
|
1166
|
+
WHERE id = ? AND worker_id = ? AND status = 'running'
|
|
1167
|
+
RETURNING id`,
|
|
1168
|
+
...values,
|
|
1169
|
+
jobId,
|
|
1170
|
+
this.workerKey
|
|
1171
|
+
);
|
|
1172
|
+
return (result.rows?.length ?? 0) > 0;
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Execute a job by invoking the method on the SmrtObject
|
|
1176
|
+
*/
|
|
1177
|
+
async executeJob(job) {
|
|
1178
|
+
const runJob = async () => {
|
|
1179
|
+
const registeredClass = ObjectRegistry.getClass(job.objectType);
|
|
1180
|
+
if (!registeredClass) {
|
|
1181
|
+
throw new Error(`Unknown object type: ${job.objectType}`);
|
|
1182
|
+
}
|
|
1183
|
+
const ObjectClass = registeredClass.constructor;
|
|
1184
|
+
const rawArgs = job.args ?? {};
|
|
1185
|
+
const persistedAgentConfig = rawArgs._agentConfig ?? {};
|
|
1186
|
+
const { _agentConfig: _, _scheduleId: __, ...methodArgs } = rawArgs;
|
|
1187
|
+
const classResolvers = getClassConfigResolvers(ObjectClass);
|
|
1188
|
+
const agentConfig = await resolveLazyConfig(persistedAgentConfig, {
|
|
1189
|
+
classResolvers,
|
|
1190
|
+
onError: "throw"
|
|
1191
|
+
});
|
|
1192
|
+
let instance;
|
|
1193
|
+
if (job.objectId) {
|
|
1194
|
+
instance = new ObjectClass({ db: this.db, ...agentConfig });
|
|
1195
|
+
await instance.initialize();
|
|
1196
|
+
await instance.loadFromId(job.objectId);
|
|
1197
|
+
} else {
|
|
1198
|
+
instance = new ObjectClass({ db: this.db, ...agentConfig });
|
|
1199
|
+
await instance.initialize();
|
|
1200
|
+
}
|
|
1201
|
+
const jobId = job.id;
|
|
1202
|
+
if (!jobId) {
|
|
1203
|
+
throw new Error("Job has no ID");
|
|
1204
|
+
}
|
|
1205
|
+
const baseLogger = createLogger(true);
|
|
1206
|
+
const contextLogger = new JobContextLogger(baseLogger, {
|
|
1207
|
+
jobId,
|
|
1208
|
+
attempt: job.attempts,
|
|
1209
|
+
queue: job.queue,
|
|
1210
|
+
objectType: job.objectType,
|
|
1211
|
+
method: job.method
|
|
1212
|
+
});
|
|
1213
|
+
contextLogger.info(`Starting job: ${job.getDescription()}`);
|
|
1214
|
+
const executionContext = this.createExecutionContext(job, contextLogger);
|
|
1215
|
+
const method = instance[job.method];
|
|
1216
|
+
if (typeof method !== "function") {
|
|
1217
|
+
throw new Error(`Method not found: ${job.objectType}.${job.method}`);
|
|
1218
|
+
}
|
|
1219
|
+
if (!isBackgroundEligibleMethod(ObjectClass, job.method)) {
|
|
1220
|
+
throw new Error(
|
|
1221
|
+
`Method not background-eligible: ${job.objectType}.${job.method}`
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
const result = await method.call(instance, methodArgs, executionContext);
|
|
1225
|
+
return { result };
|
|
1226
|
+
};
|
|
1227
|
+
if (job.tenantId) {
|
|
1228
|
+
return TenantContext.runWithJobContext(
|
|
1229
|
+
{ tenantId: job.tenantId },
|
|
1230
|
+
runJob
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
return runJob();
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Handle job execution error
|
|
1237
|
+
*/
|
|
1238
|
+
async handleJobError(job, error) {
|
|
1239
|
+
const strategy = fromConfig(job.retryStrategy);
|
|
1240
|
+
const decision = strategy.shouldRetry(job.attempts, error);
|
|
1241
|
+
const jobId = job.id;
|
|
1242
|
+
if (!jobId) return;
|
|
1243
|
+
const safeMessage = redactErrorForPersistence(error);
|
|
1244
|
+
if (decision.shouldRetry && job.attempts < job.maxAttempts) {
|
|
1245
|
+
const nextRunAt = new Date(Date.now() + decision.delay);
|
|
1246
|
+
const applied = await this.writeOwnedJob(jobId, {
|
|
1247
|
+
status: "pending",
|
|
1248
|
+
last_error: safeMessage,
|
|
1249
|
+
run_at: nextRunAt.toISOString(),
|
|
1250
|
+
worker_id: null,
|
|
1251
|
+
worker_heartbeat: null,
|
|
1252
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1253
|
+
});
|
|
1254
|
+
if (!applied) return;
|
|
1255
|
+
job.status = "pending";
|
|
1256
|
+
job.lastError = safeMessage;
|
|
1257
|
+
job.runAt = nextRunAt;
|
|
1258
|
+
job.workerId = null;
|
|
1259
|
+
job.workerHeartbeat = null;
|
|
1260
|
+
await this.appendJobEvent(job, {
|
|
1261
|
+
type: "status",
|
|
1262
|
+
level: "warn",
|
|
1263
|
+
stage: "retrying",
|
|
1264
|
+
message: `Retrying job after failure: ${safeMessage}`,
|
|
1265
|
+
data: { delay: decision.delay, attempts: job.attempts }
|
|
1266
|
+
});
|
|
1267
|
+
this.emit("job:retrying", job, error, decision.delay);
|
|
1268
|
+
} else {
|
|
1269
|
+
const completedAt = /* @__PURE__ */ new Date();
|
|
1270
|
+
const applied = await this.writeOwnedJob(jobId, {
|
|
1271
|
+
status: "failed",
|
|
1272
|
+
completed_at: completedAt.toISOString(),
|
|
1273
|
+
last_error: safeMessage,
|
|
1274
|
+
updated_at: completedAt.toISOString()
|
|
1275
|
+
});
|
|
1276
|
+
if (!applied) return;
|
|
1277
|
+
job.status = "failed";
|
|
1278
|
+
job.completedAt = completedAt;
|
|
1279
|
+
job.lastError = safeMessage;
|
|
1280
|
+
await this.appendJobEvent(job, {
|
|
1281
|
+
type: "error",
|
|
1282
|
+
level: "error",
|
|
1283
|
+
stage: "failed",
|
|
1284
|
+
message: safeMessage,
|
|
1285
|
+
data: { attempts: job.attempts }
|
|
1286
|
+
});
|
|
1287
|
+
this.emit("job:failed", job, error);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
createExecutionContext(job, contextLogger) {
|
|
1291
|
+
const jobContext = {
|
|
1292
|
+
jobId: job.id ?? "",
|
|
1293
|
+
tenantId: job.tenantId ?? null,
|
|
1294
|
+
attempt: job.attempts,
|
|
1295
|
+
queue: job.queue,
|
|
1296
|
+
objectType: job.objectType,
|
|
1297
|
+
method: job.method
|
|
1298
|
+
};
|
|
1299
|
+
return {
|
|
1300
|
+
job: jobContext,
|
|
1301
|
+
logger: contextLogger,
|
|
1302
|
+
event: async (input) => {
|
|
1303
|
+
await this.appendJobEvent(job, input);
|
|
1304
|
+
},
|
|
1305
|
+
progress: async (input) => {
|
|
1306
|
+
const data = {
|
|
1307
|
+
...input.data ?? {},
|
|
1308
|
+
...input.detail ? { detail: input.detail } : {},
|
|
1309
|
+
...input.source ? { source: input.source } : {}
|
|
1310
|
+
};
|
|
1311
|
+
await this.appendJobEvent(job, {
|
|
1312
|
+
type: "progress",
|
|
1313
|
+
level: "info",
|
|
1314
|
+
stage: input.stage,
|
|
1315
|
+
progress: input.progress,
|
|
1316
|
+
message: input.message ?? input.detail ?? `${input.stage} ${Math.round(input.progress)}%`,
|
|
1317
|
+
data
|
|
1318
|
+
});
|
|
1319
|
+
},
|
|
1320
|
+
log: async (level, message, data) => {
|
|
1321
|
+
contextLogger[level](message, data);
|
|
1322
|
+
await this.appendJobEvent(job, {
|
|
1323
|
+
type: level === "error" ? "error" : "log",
|
|
1324
|
+
level,
|
|
1325
|
+
message,
|
|
1326
|
+
data
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
async appendJobEvent(job, input) {
|
|
1332
|
+
if (!this.eventCollection || !job.id) {
|
|
1333
|
+
return null;
|
|
1334
|
+
}
|
|
1335
|
+
try {
|
|
1336
|
+
const event = await this.eventCollection.append({
|
|
1337
|
+
tenantId: job.tenantId ?? null,
|
|
1338
|
+
jobId: job.id,
|
|
1339
|
+
type: input.type ?? "log",
|
|
1340
|
+
level: input.level ?? "info",
|
|
1341
|
+
stage: input.stage ?? null,
|
|
1342
|
+
progress: input.progress ?? null,
|
|
1343
|
+
message: input.message ?? "",
|
|
1344
|
+
data: input.data ?? {}
|
|
1345
|
+
});
|
|
1346
|
+
this.emit("job:event", job, event);
|
|
1347
|
+
if (event.type === "progress") {
|
|
1348
|
+
this.emit("job:progress", job, event);
|
|
1349
|
+
}
|
|
1350
|
+
return event;
|
|
1351
|
+
} catch (error) {
|
|
1352
|
+
const telemetryError = error instanceof Error ? error : new Error(`Failed to append job telemetry: ${String(error)}`);
|
|
1353
|
+
try {
|
|
1354
|
+
this.emit("runner:error", telemetryError);
|
|
1355
|
+
} catch {
|
|
1356
|
+
}
|
|
1357
|
+
return null;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Whether the `_smrt_workers` table exists. Cached once positive — the table
|
|
1362
|
+
* never disappears mid-run, so this avoids a probe query on every poll.
|
|
1363
|
+
*/
|
|
1364
|
+
async workersTableReady() {
|
|
1365
|
+
if (this.workersTableVerified) return true;
|
|
1366
|
+
const ready = await this.workerCollection?.tableReady() ?? false;
|
|
1367
|
+
if (ready) this.workersTableVerified = true;
|
|
1368
|
+
return ready;
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* Recover jobs orphaned by dead/restarted workers.
|
|
1372
|
+
*
|
|
1373
|
+
* A `running` job is recovered only when its owning worker is *not alive*
|
|
1374
|
+
* (issue #1474): not live in this process and holding no fresh lease in
|
|
1375
|
+
* `_smrt_workers`. This is independent of the handler event loop, so a worker
|
|
1376
|
+
* whose handler holds the loop synchronously keeps a fresh lease (renewed off
|
|
1377
|
+
* the loop by the liveness thread) or stays in this process's live set, and is
|
|
1378
|
+
* never false-recovered. The live set takes precedence over a stale lease, and
|
|
1379
|
+
* a runner never recovers its own active jobs.
|
|
1380
|
+
*
|
|
1381
|
+
* Recovery is swept at most once per lease tick (not every poll), since
|
|
1382
|
+
* detection is TTL-bound anyway — this bounds the per-poll database load.
|
|
1383
|
+
*/
|
|
1384
|
+
async recoverStaleJobs() {
|
|
1385
|
+
if (!this.db || !this.collection || !this.workerCollection) return;
|
|
1386
|
+
if (!await this.workersTableReady()) return;
|
|
1387
|
+
const now = Date.now();
|
|
1388
|
+
if (now - this.lastRecoverySweepAt < this.config.leaseTickMs) return;
|
|
1389
|
+
this.lastRecoverySweepAt = now;
|
|
1390
|
+
try {
|
|
1391
|
+
await this.workerCollection.pruneExpired(this.effectiveLeaseTtlMs * 10);
|
|
1392
|
+
} catch {
|
|
1393
|
+
}
|
|
1394
|
+
const freshLeaseKeys = await this.workerCollection.freshLeaseWorkerKeys();
|
|
1395
|
+
const running = await this.collection.query(
|
|
1396
|
+
`SELECT * FROM _smrt_jobs WHERE status = 'running'`,
|
|
1397
|
+
[],
|
|
1398
|
+
{ allowRawOnTenantScoped: true }
|
|
1399
|
+
);
|
|
1400
|
+
if (running.length === 0) return;
|
|
1401
|
+
const orphans = running.filter((job) => {
|
|
1402
|
+
const jobId = job.id;
|
|
1403
|
+
if (jobId && this.activeJobs.has(jobId)) return false;
|
|
1404
|
+
return !isWorkerAlive(job.workerId, freshLeaseKeys);
|
|
1405
|
+
});
|
|
1406
|
+
if (orphans.length === 0) return;
|
|
1407
|
+
const orphanIds = orphans.map((job) => job.id).filter((jobId) => typeof jobId === "string");
|
|
1408
|
+
if (orphanIds.length === 0) return;
|
|
1409
|
+
const placeholders = orphanIds.map(() => "?").join(", ");
|
|
1410
|
+
const recoveredAt = /* @__PURE__ */ new Date();
|
|
1411
|
+
const errorMessage = "Recovered orphaned running job: its owning worker is no longer alive (no fresh liveness lease in _smrt_workers and not running in this process).";
|
|
1412
|
+
const updated = await this.db.query(
|
|
1413
|
+
`UPDATE _smrt_jobs
|
|
1414
|
+
SET status = 'failed',
|
|
1415
|
+
completed_at = ?,
|
|
1416
|
+
last_error = ?,
|
|
1417
|
+
worker_id = NULL,
|
|
1418
|
+
worker_heartbeat = NULL
|
|
1419
|
+
WHERE status = 'running'
|
|
1420
|
+
AND id IN (${placeholders})
|
|
1421
|
+
RETURNING id`,
|
|
1422
|
+
recoveredAt.toISOString(),
|
|
1423
|
+
errorMessage,
|
|
1424
|
+
...orphanIds
|
|
1425
|
+
);
|
|
1426
|
+
const recoveredIds = new Set(
|
|
1427
|
+
updated.rows.map((row) => row.id).filter((id) => typeof id === "string")
|
|
1428
|
+
);
|
|
1429
|
+
if (recoveredIds.size === 0) return;
|
|
1430
|
+
for (const job of orphans) {
|
|
1431
|
+
if (!job.id || !recoveredIds.has(job.id)) continue;
|
|
1432
|
+
job.status = "failed";
|
|
1433
|
+
job.completedAt = recoveredAt;
|
|
1434
|
+
job.lastError = errorMessage;
|
|
1435
|
+
job.workerId = null;
|
|
1436
|
+
job.workerHeartbeat = null;
|
|
1437
|
+
const error = new Error(errorMessage);
|
|
1438
|
+
await this.appendJobEvent(job, {
|
|
1439
|
+
type: "error",
|
|
1440
|
+
level: "error",
|
|
1441
|
+
stage: "stale-recovery",
|
|
1442
|
+
message: errorMessage
|
|
1443
|
+
});
|
|
1444
|
+
this.emit("job:failed", job, error);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* Renew this worker's liveness lease.
|
|
1449
|
+
*
|
|
1450
|
+
* In Stage 1 this runs on the main event loop, so it provides cross-process
|
|
1451
|
+
* detection no weaker than the old per-job heartbeat. Stage 2 moves the
|
|
1452
|
+
* renewal to an off-loop worker thread so a synchronous handler can no longer
|
|
1453
|
+
* starve it. Same-process correctness never depends on this timer — the
|
|
1454
|
+
* in-memory live set covers it.
|
|
1455
|
+
*/
|
|
1456
|
+
startLeaseRenewal() {
|
|
1457
|
+
this.leaseTimer = setInterval(async () => {
|
|
1458
|
+
try {
|
|
1459
|
+
await this.workerCollection?.renewLease(
|
|
1460
|
+
this.workerKey,
|
|
1461
|
+
this.effectiveLeaseTtlMs
|
|
1462
|
+
);
|
|
1463
|
+
} catch {
|
|
1464
|
+
}
|
|
1465
|
+
}, this.config.leaseTickMs);
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Spawn the off-loop liveness thread. It opens its own connection and renews
|
|
1469
|
+
* this worker's lease on its own thread (unstarvable by handler CPU). Returns
|
|
1470
|
+
* false if the thread can't be resolved or fails to start, so the caller can
|
|
1471
|
+
* fall back to main-loop renewal.
|
|
1472
|
+
*/
|
|
1473
|
+
async startLivenessThread() {
|
|
1474
|
+
if (!this.db) return false;
|
|
1475
|
+
let entry;
|
|
1476
|
+
try {
|
|
1477
|
+
entry = import.meta.resolve("@happyvertical/smrt-jobs/worker-liveness-thread");
|
|
1478
|
+
} catch {
|
|
1479
|
+
return false;
|
|
1480
|
+
}
|
|
1481
|
+
let worker;
|
|
1482
|
+
try {
|
|
1483
|
+
worker = new Worker(new URL(entry), {
|
|
1484
|
+
workerData: {
|
|
1485
|
+
url: resolveUrl(this.db),
|
|
1486
|
+
type: resolveEngine(this.db),
|
|
1487
|
+
workerKey: this.workerKey,
|
|
1488
|
+
leaseTtlMs: this.effectiveLeaseTtlMs,
|
|
1489
|
+
leaseTickMs: this.config.leaseTickMs
|
|
1490
|
+
}
|
|
1491
|
+
});
|
|
1492
|
+
} catch {
|
|
1493
|
+
return false;
|
|
1494
|
+
}
|
|
1495
|
+
const ready = await new Promise((resolve) => {
|
|
1496
|
+
const onMessage = (message) => {
|
|
1497
|
+
if (message === "ready") {
|
|
1498
|
+
cleanup();
|
|
1499
|
+
resolve(true);
|
|
1500
|
+
} else if (message && typeof message === "object" && message.type === "error") {
|
|
1501
|
+
cleanup();
|
|
1502
|
+
resolve(false);
|
|
1503
|
+
}
|
|
1504
|
+
};
|
|
1505
|
+
const onFail = () => {
|
|
1506
|
+
cleanup();
|
|
1507
|
+
resolve(false);
|
|
1508
|
+
};
|
|
1509
|
+
const cleanup = () => {
|
|
1510
|
+
clearTimeout(timer);
|
|
1511
|
+
worker.off("message", onMessage);
|
|
1512
|
+
worker.off("error", onFail);
|
|
1513
|
+
worker.off("exit", onFail);
|
|
1514
|
+
};
|
|
1515
|
+
const timer = setTimeout(() => {
|
|
1516
|
+
cleanup();
|
|
1517
|
+
resolve(false);
|
|
1518
|
+
}, LIVENESS_THREAD_START_TIMEOUT_MS);
|
|
1519
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
1520
|
+
worker.on("message", onMessage);
|
|
1521
|
+
worker.once("error", onFail);
|
|
1522
|
+
worker.once("exit", onFail);
|
|
1523
|
+
});
|
|
1524
|
+
if (!ready) {
|
|
1525
|
+
await worker.terminate().catch(() => {
|
|
1526
|
+
});
|
|
1527
|
+
return false;
|
|
1528
|
+
}
|
|
1529
|
+
this.livenessWorker = worker;
|
|
1530
|
+
worker.unref();
|
|
1531
|
+
worker.once("error", () => this.handleLivenessThreadLoss(worker));
|
|
1532
|
+
worker.once("exit", () => this.handleLivenessThreadLoss(worker));
|
|
1533
|
+
return true;
|
|
1534
|
+
}
|
|
1535
|
+
handleLivenessThreadLoss(worker) {
|
|
1536
|
+
if (this.livenessWorker !== worker) return;
|
|
1537
|
+
this.livenessWorker = null;
|
|
1538
|
+
if (this.running && !this.leaseTimer) {
|
|
1539
|
+
this.startLeaseRenewal();
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
/** Stop the liveness thread (graceful, with a short bound), if running. */
|
|
1543
|
+
async stopLivenessThread() {
|
|
1544
|
+
const worker = this.livenessWorker;
|
|
1545
|
+
if (!worker) return;
|
|
1546
|
+
this.livenessWorker = null;
|
|
1547
|
+
const stopped = new Promise((resolve) => {
|
|
1548
|
+
const done = () => {
|
|
1549
|
+
clearTimeout(timer);
|
|
1550
|
+
worker.off("message", onMessage);
|
|
1551
|
+
resolve();
|
|
1552
|
+
};
|
|
1553
|
+
const onMessage = (message) => {
|
|
1554
|
+
if (message === "stopped") done();
|
|
1555
|
+
};
|
|
1556
|
+
worker.on("message", onMessage);
|
|
1557
|
+
worker.once("exit", done);
|
|
1558
|
+
const timer = setTimeout(done, 2e3);
|
|
1559
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
1560
|
+
});
|
|
1561
|
+
try {
|
|
1562
|
+
worker.postMessage("stop");
|
|
1563
|
+
} catch {
|
|
1564
|
+
}
|
|
1565
|
+
await stopped;
|
|
1566
|
+
await worker.terminate().catch(() => {
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* Per-job heartbeat loop — telemetry only ("last activity" for the UI). It no
|
|
1571
|
+
* longer gates recovery (that is the worker lease), so a blocked loop missing
|
|
1572
|
+
* a heartbeat is harmless.
|
|
1573
|
+
*/
|
|
1574
|
+
startHeartbeat() {
|
|
1575
|
+
this.heartbeatTimer = setInterval(async () => {
|
|
1576
|
+
if (!this.db) return;
|
|
1577
|
+
const jobIds = [...this.activeJobs.keys()];
|
|
1578
|
+
if (jobIds.length === 0) return;
|
|
1579
|
+
const placeholders = jobIds.map(() => "?").join(", ");
|
|
1580
|
+
try {
|
|
1581
|
+
await this.db.query(
|
|
1582
|
+
`UPDATE _smrt_jobs
|
|
1583
|
+
SET worker_heartbeat = ?
|
|
1584
|
+
WHERE status = 'running'
|
|
1585
|
+
AND id IN (${placeholders})`,
|
|
1586
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
1587
|
+
...jobIds
|
|
1588
|
+
);
|
|
1589
|
+
} catch {
|
|
1590
|
+
}
|
|
1591
|
+
}, this.config.heartbeatInterval);
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* Wait for active jobs to complete with timeout
|
|
1595
|
+
*/
|
|
1596
|
+
async waitForActiveJobs() {
|
|
1597
|
+
if (this.activeJobs.size === 0) return;
|
|
1598
|
+
return new Promise((resolve) => {
|
|
1599
|
+
const checkInterval = setInterval(() => {
|
|
1600
|
+
if (this.activeJobs.size === 0) {
|
|
1601
|
+
clearInterval(checkInterval);
|
|
1602
|
+
clearTimeout(timeout);
|
|
1603
|
+
resolve();
|
|
1604
|
+
}
|
|
1605
|
+
}, 100);
|
|
1606
|
+
const timeout = setTimeout(() => {
|
|
1607
|
+
clearInterval(checkInterval);
|
|
1608
|
+
this.logger.warn(
|
|
1609
|
+
`Shutdown timeout: ${this.activeJobs.size} jobs still active`
|
|
1610
|
+
);
|
|
1611
|
+
resolve();
|
|
1612
|
+
}, this.config.shutdownTimeout);
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
function createTaskRunner(config) {
|
|
1617
|
+
return new TaskRunner(config);
|
|
1618
|
+
}
|
|
1619
|
+
export {
|
|
1620
|
+
DEFAULT_TENANT_JOB_CAP as D,
|
|
1621
|
+
JobContextLogger as J,
|
|
1622
|
+
MAX_JOB_RETRIES as M,
|
|
1623
|
+
SmrtJobCollection as S,
|
|
1624
|
+
TaskRunner as T,
|
|
1625
|
+
DEFAULT_TASK_HEARTBEAT_INTERVAL_MS as a,
|
|
1626
|
+
SmrtWorkerCollection as b,
|
|
1627
|
+
clampRetries as c,
|
|
1628
|
+
redactErrorForPersistence as d,
|
|
1629
|
+
SmrtJob as e,
|
|
1630
|
+
SmrtJobEvent as f,
|
|
1631
|
+
SmrtJobEventCollection as g,
|
|
1632
|
+
SmrtWorker as h,
|
|
1633
|
+
TenantJobCapExceededError as i,
|
|
1634
|
+
assertWithinTenantCreationCap as j,
|
|
1635
|
+
backgroundEligible as k,
|
|
1636
|
+
createTaskRunner as l,
|
|
1637
|
+
getBackgroundEligibleMethods as m,
|
|
1638
|
+
isBackgroundEligibleMethod as n,
|
|
1639
|
+
markBackgroundEligible as o,
|
|
1640
|
+
redactErrorMessage as r
|
|
1641
|
+
};
|
|
1642
|
+
//# sourceMappingURL=runner-DV8FBO0y.js.map
|