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