@daloyjs/core 1.2.0 → 1.3.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/dist/jobs.js ADDED
@@ -0,0 +1,1102 @@
1
+ /**
2
+ * Queue-agnostic background jobs for DaloyJS.
3
+ *
4
+ * Where {@link Scheduler} answers *“run this work in this process, on this
5
+ * clock”*, this module answers *“run this work somewhere,
6
+ * eventually”* — the three things a production job interface needs:
7
+ *
8
+ * - **A durable record.** {@link JobStore.put} persists a named JSON payload
9
+ * so the work survives the HTTP request and the process that accepted it.
10
+ * {@link MemoryJobStore} is correct for tests and single-process apps;
11
+ * production supplies a shared adapter (Redis, Postgres, SQS) that
12
+ * implements the same SPI. Core stays zero-dependency.
13
+ * - **At-least-once execution.** {@link createJobWorker} claims a lease,
14
+ * runs an allowlisted handler, and completes or fails with bounded
15
+ * retries (exponential backoff, full jitter) and a dead-letter status.
16
+ * If the process dies after the side effect but before complete, the
17
+ * lease expires and another worker may run it again — handlers must be
18
+ * idempotent. {@link EnqueueOptions.idempotencyKey} collapses duplicate
19
+ * producers (same key + same payload) the way HTTP `idempotency()`
20
+ * collapses duplicate POSTs.
21
+ * - **A clean split from workflows.** This is not Temporal, Inngest, or
22
+ * Vercel Workflow: there is no replay of TypeScript, no “world”,
23
+ * and no `await sleep("7 days")` inside a function body. Delayed jobs and
24
+ * retries are *new attempts of the same record*, not continuations.
25
+ *
26
+ * Pair `App.useJobs` to attach a queue (and optionally a worker that
27
+ * drains on graceful shutdown) and `App.cronEnqueue` when a cron tick
28
+ * should create a job instead of running the side effect in-process.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * import { createJobQueue, createJobWorker, MemoryJobStore } from "@daloyjs/core/jobs";
33
+ *
34
+ * const store = new MemoryJobStore();
35
+ * const queue = createJobQueue({ store });
36
+ * const worker = createJobWorker({
37
+ * queue,
38
+ * handlers: {
39
+ * "email.welcome": async ({ job, signal }) => {
40
+ * await sendEmail(job.payload, { signal });
41
+ * },
42
+ * },
43
+ * });
44
+ * await queue.enqueue({ name: "email.welcome", payload: { to: "a@b.c" } });
45
+ * await worker.runOnce();
46
+ * ```
47
+ *
48
+ * @module
49
+ * @since 1.3.0
50
+ */
51
+ import { isForbiddenObjectKey, safeJsonParse } from "./security.js";
52
+ const enc = new TextEncoder();
53
+ /**
54
+ * Enqueue validation, unknown names at define-time, and bad options. An
55
+ * ordinary `Error` subclass (like `CronParseError`), not an `HttpError` —
56
+ * the jobs engine is a library, not a mounted route; map it to `400`/`422`
57
+ * in your own contract route if you expose enqueue over HTTP.
58
+ *
59
+ * @since 1.3.0
60
+ */
61
+ export class JobConfigError extends Error {
62
+ /** Machine-readable failure reason; see {@link JobConfigErrorCode}. */
63
+ code;
64
+ constructor(code, message) {
65
+ super(message);
66
+ this.name = "JobConfigError";
67
+ this.code = code;
68
+ }
69
+ }
70
+ /**
71
+ * Same idempotency key, different payload fingerprint. A key is permanently
72
+ * bound to the first payload it was enqueued with — mirroring the `422`
73
+ * key-reuse rule of the HTTP `idempotency()` middleware.
74
+ *
75
+ * @since 1.3.0
76
+ */
77
+ export class JobIdempotencyConflictError extends Error {
78
+ /** Machine-readable failure reason. */
79
+ code = "idempotency_conflict";
80
+ /** The idempotency key that was reused. */
81
+ key;
82
+ /** The id of the job that already holds the key. */
83
+ existingJobId;
84
+ constructor(key, existingJobId) {
85
+ super(`Job idempotency key "${key}" was already used with a different payload ` +
86
+ `(existing job "${existingJobId}").`);
87
+ this.name = "JobIdempotencyConflictError";
88
+ this.key = key;
89
+ this.existingJobId = existingJobId;
90
+ }
91
+ }
92
+ /**
93
+ * Thrown by a handler (or the worker, for an unknown job name) to signal
94
+ * “do not retry” — the job is dead-lettered on the spot, however
95
+ * many attempts remain. Analogous to a webhook SSRF / permanent-4xx failure.
96
+ *
97
+ * @since 1.3.0
98
+ */
99
+ export class JobFatalError extends Error {
100
+ /** Machine-readable failure reason. */
101
+ code = "fatal";
102
+ cause;
103
+ constructor(message, cause) {
104
+ super(message);
105
+ this.name = "JobFatalError";
106
+ if (cause !== undefined)
107
+ this.cause = cause;
108
+ }
109
+ }
110
+ /**
111
+ * A claimed job exceeded its `timeoutMs`; the worker aborted the handler's
112
+ * signal and failed the job as retryable (unless the attempt budget is
113
+ * already exhausted).
114
+ *
115
+ * @since 1.3.0
116
+ */
117
+ export class JobTimeoutError extends Error {
118
+ /** Machine-readable failure reason. */
119
+ code = "timeout";
120
+ /** The per-attempt timeout that elapsed, in ms. */
121
+ timeoutMs;
122
+ constructor(timeoutMs) {
123
+ super(`Job exceeded its timeoutMs of ${timeoutMs}ms and was aborted.`);
124
+ this.name = "JobTimeoutError";
125
+ this.timeoutMs = timeoutMs;
126
+ }
127
+ }
128
+ // ── internal helpers ────────────────────────────────────────────────
129
+ // Anchored, bounded character-class allowlists — linear-time, ReDoS-free.
130
+ const JOB_NAME_RE = /^[a-zA-Z][a-zA-Z0-9._:-]{0,127}$/;
131
+ const QUEUE_NAME_RE = /^[a-zA-Z0-9_:-]{1,64}$/;
132
+ // Printable ASCII only (no control chars / whitespace), 1–255 chars — the
133
+ // same grammar as the HTTP Idempotency-Key header.
134
+ const IDEMPOTENCY_KEY_RE = /^[\x21-\x7e]{1,255}$/;
135
+ // Tenant ids share the tenancy() default grammar: lowercase DNS-label-like,
136
+ // safe to embed in keys and log lines.
137
+ const TENANT_RE = /^[a-z0-9](?:[a-z0-9_-]{0,61}[a-z0-9])?$/;
138
+ function assertJobName(name) {
139
+ if (typeof name !== "string" || !JOB_NAME_RE.test(name)) {
140
+ throw new JobConfigError("invalid_name", `Invalid job name ${JSON.stringify(String(name))}: must match ^[a-zA-Z][a-zA-Z0-9._:-]{0,127}$.`);
141
+ }
142
+ return name;
143
+ }
144
+ function assertQueueName(queue) {
145
+ if (typeof queue !== "string" || !QUEUE_NAME_RE.test(queue)) {
146
+ throw new JobConfigError("invalid_name", `Invalid queue name ${JSON.stringify(String(queue))}: must match ^[a-zA-Z0-9_:-]{1,64}$.`);
147
+ }
148
+ return queue;
149
+ }
150
+ function assertIdempotencyKey(key) {
151
+ if (typeof key !== "string" || !IDEMPOTENCY_KEY_RE.test(key)) {
152
+ throw new JobConfigError("invalid_option", "Job idempotencyKey must be 1-255 printable ASCII characters (no whitespace or control characters).");
153
+ }
154
+ return key;
155
+ }
156
+ function assertTenant(tenant) {
157
+ if (typeof tenant !== "string" || !TENANT_RE.test(tenant)) {
158
+ throw new JobConfigError("invalid_option", `Invalid tenant ${JSON.stringify(String(tenant))}: must match the tenancy() id grammar ` +
159
+ `^[a-z0-9](?:[a-z0-9_-]{0,61}[a-z0-9])?$.`);
160
+ }
161
+ return tenant;
162
+ }
163
+ /**
164
+ * Serialize a payload (or completion result) to JSON under the same rules:
165
+ * plain JSON only, prototype-pollution keys rejected (never stripped —
166
+ * silent key dropping is how `__proto__` smuggles past a review), byte size
167
+ * capped. A `JSON.stringify` replacer throws on forbidden keys, so the
168
+ * check rides the one traversal the serialization already performs.
169
+ */
170
+ function serializePayload(payload, maxBytes, what) {
171
+ if (payload === undefined ||
172
+ typeof payload === "function" ||
173
+ typeof payload === "symbol" ||
174
+ typeof payload === "bigint") {
175
+ throw new JobConfigError("invalid_payload", `Job ${what} must be a JSON-serializable value; got ${payload === undefined ? "undefined" : typeof payload}.`);
176
+ }
177
+ if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) {
178
+ // Reject class instances at the top level (Date, Map, Uint8Array, …):
179
+ // they stringify by silently dropping or reshaping data. Nested values
180
+ // follow ordinary JSON.stringify semantics (Date -> ISO string, …).
181
+ const proto = Object.getPrototypeOf(payload);
182
+ if (proto !== Object.prototype && proto !== null) {
183
+ throw new JobConfigError("invalid_payload", `Job ${what} must be a plain JSON object, array, or primitive; got a non-plain object.`);
184
+ }
185
+ }
186
+ let json;
187
+ try {
188
+ json = JSON.stringify(payload, (key, value) => {
189
+ if (key !== "" && isForbiddenObjectKey(key)) {
190
+ throw new JobConfigError("invalid_payload", `Job ${what} contains forbidden key "${key}" (prototype pollution).`);
191
+ }
192
+ return value;
193
+ });
194
+ }
195
+ catch (error) {
196
+ if (error instanceof JobConfigError)
197
+ throw error;
198
+ throw new JobConfigError("invalid_payload", `Job ${what} is not JSON-serializable: ${error instanceof Error ? error.message : String(error)}`);
199
+ }
200
+ const bytes = enc.encode(json);
201
+ if (bytes.byteLength > maxBytes) {
202
+ throw new JobConfigError("payload_too_large", `Job ${what} is ${bytes.byteLength} bytes serialized; the limit is ${maxBytes} bytes. ` +
203
+ "Jobs are not a blob store — put large payloads in object storage and enqueue the URL.");
204
+ }
205
+ return { json, bytes };
206
+ }
207
+ function getSubtle() {
208
+ const c = globalThis.crypto;
209
+ if (!c?.subtle) {
210
+ throw new Error("jobs: Web Crypto (crypto.subtle) is required for idempotency fingerprints. " +
211
+ "Provide a polyfill in environments without it.");
212
+ }
213
+ return c.subtle;
214
+ }
215
+ const HEX = "0123456789abcdef";
216
+ function bytesToHex(bytes) {
217
+ let out = "";
218
+ for (let i = 0; i < bytes.length; i++) {
219
+ const b = bytes[i];
220
+ out += HEX[b >> 4] + HEX[b & 0x0f];
221
+ }
222
+ return out;
223
+ }
224
+ /** SHA-256 hex of already-serialized payload bytes (idempotency fingerprint). */
225
+ async function sha256Hex(bytes) {
226
+ const digest = new Uint8Array(await getSubtle().digest("SHA-256", bytes));
227
+ return bytesToHex(digest);
228
+ }
229
+ function isPromiseLike(value) {
230
+ return (value !== null &&
231
+ (typeof value === "object" || typeof value === "function") &&
232
+ typeof value.then === "function");
233
+ }
234
+ /** Await a store result that may be sync or async (the SPI allows both). */
235
+ async function settle(value) {
236
+ return isPromiseLike(value) ? await value : value;
237
+ }
238
+ function randomId() {
239
+ const c = globalThis.crypto;
240
+ if (c?.randomUUID)
241
+ return c.randomUUID();
242
+ // Web-Crypto is mandatory on every runtime Daloy supports; this is an
243
+ // unreachable last-resort guard so a missing global never throws.
244
+ throw new Error("WebCrypto unavailable: cannot generate a job id");
245
+ }
246
+ /** Cap persisted error strings so a huge stack cannot bloat the store. */
247
+ const MAX_ERROR_CHARS = 2_000;
248
+ function errorMessage(error) {
249
+ const raw = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
250
+ return raw.length > MAX_ERROR_CHARS ? `${raw.slice(0, MAX_ERROR_CHARS)}…` : raw;
251
+ }
252
+ // ── backoff ─────────────────────────────────────────────────────────
253
+ /**
254
+ * Exponential backoff with full jitter, in ms — the same math as webhook
255
+ * delivery: `min(max, base * 2^(attempt-1))` scaled by a uniform random
256
+ * factor in `[0, 1)`. Exported for deterministic tests (inject
257
+ * `random: () => 1` for the unfuzzed ceiling).
258
+ *
259
+ * @param attempt - The attempt number that just failed (1-based; attempts
260
+ * were already incremented at claim time).
261
+ * @param base - Base delay for the first retry, in ms.
262
+ * @param max - Upper bound on the un-jittered delay, in ms.
263
+ * @param random - Uniform source in `[0, 1)`.
264
+ * @returns The delay before the next attempt, in ms.
265
+ * @since 1.3.0
266
+ */
267
+ export function computeBackoffMs(attempt, base, max, random) {
268
+ const exp = Math.min(max, base * 2 ** Math.max(0, attempt - 1));
269
+ return Math.floor(random() * exp); // full jitter
270
+ }
271
+ // ── memory store ────────────────────────────────────────────────────
272
+ /** Default cap on jobs (all statuses) held by {@link MemoryJobStore}. */
273
+ const DEFAULT_CAPACITY = 10_000;
274
+ /** Default retention for `completed` / `cancelled` records: 24 hours. */
275
+ const DEFAULT_RETENTION_MS = 86_400_000;
276
+ /** Default retention for `dead` records (kept longer for inspection): 7 days. */
277
+ const DEFAULT_DEAD_RETENTION_MS = 604_800_000;
278
+ /** Higher priority first, then earliest runAt, then oldest, then id. */
279
+ function compareRunnable(a, b) {
280
+ if (a.priority !== b.priority)
281
+ return b.priority - a.priority;
282
+ if (a.runAt !== b.runAt)
283
+ return a.runAt - b.runAt;
284
+ if (a.createdAt !== b.createdAt)
285
+ return a.createdAt - b.createdAt;
286
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
287
+ }
288
+ /**
289
+ * In-memory {@link JobStore}. Correct for tests and single-process apps —
290
+ * a full implementation of the SPI, not a fake. **Not durable across
291
+ * processes** and invisible to other replicas: production deployments must
292
+ * supply a shared store (Redis, Postgres, SQS) through the same interface.
293
+ * `app.useJobs()` warns when it sees this store with production config.
294
+ *
295
+ * Payloads are held serialized and re-parsed with the prototype-pollution-safe
296
+ * `safeJsonParse` on every read, and every read returns a deep copy, so
297
+ * callers can never mutate store state.
298
+ *
299
+ * @since 1.3.0
300
+ */
301
+ export class MemoryJobStore {
302
+ #jobs = new Map();
303
+ #idem = new Map(); // `${queue}\0${idempotencyKey}` -> job id
304
+ #capacity;
305
+ #now;
306
+ #retentionMs;
307
+ #deadRetentionMs;
308
+ /**
309
+ * @param opts - Capacity, clock, and retention knobs.
310
+ * @throws {RangeError} when a numeric option is out of bounds.
311
+ */
312
+ constructor(opts = {}) {
313
+ const capacity = opts.capacity ?? DEFAULT_CAPACITY;
314
+ if (!Number.isInteger(capacity) || capacity < 1) {
315
+ throw new RangeError("MemoryJobStore: capacity must be a positive integer.");
316
+ }
317
+ const retentionMs = opts.retentionMs ?? DEFAULT_RETENTION_MS;
318
+ if (!Number.isInteger(retentionMs) || retentionMs < 0) {
319
+ throw new RangeError("MemoryJobStore: retentionMs must be a non-negative integer.");
320
+ }
321
+ const deadRetentionMs = opts.deadRetentionMs ?? DEFAULT_DEAD_RETENTION_MS;
322
+ if (!Number.isInteger(deadRetentionMs) || deadRetentionMs < 0) {
323
+ throw new RangeError("MemoryJobStore: deadRetentionMs must be a non-negative integer.");
324
+ }
325
+ this.#capacity = capacity;
326
+ this.#now = opts.now ?? Date.now;
327
+ this.#retentionMs = retentionMs;
328
+ this.#deadRetentionMs = deadRetentionMs;
329
+ }
330
+ /** The number of jobs currently held (all statuses). */
331
+ get size() {
332
+ return this.#jobs.size;
333
+ }
334
+ /**
335
+ * @inheritDoc
336
+ * `_fingerprint` is part of the {@link JobStore} contract but unused here:
337
+ * the in-memory store compares serialized payloads directly.
338
+ */
339
+ put(job, _fingerprint) {
340
+ const now = this.#now();
341
+ const payloadJson = JSON.stringify(job.payload);
342
+ if (job.idempotencyKey !== null) {
343
+ const idemKey = `${job.queue}\0${job.idempotencyKey}`;
344
+ const existingId = this.#idem.get(idemKey);
345
+ if (existingId !== undefined) {
346
+ const existing = this.#jobs.get(existingId);
347
+ if (existing !== undefined) {
348
+ if (existing.payloadJson !== payloadJson) {
349
+ throw new JobIdempotencyConflictError(job.idempotencyKey, existingId);
350
+ }
351
+ return { job: this.#clone(existing), duplicate: true };
352
+ }
353
+ // Stale index entry (record was swept): fall through and re-insert.
354
+ }
355
+ if (this.#jobs.size >= this.#capacity) {
356
+ this.#purgeExpiredTerminal(now);
357
+ if (this.#jobs.size >= this.#capacity) {
358
+ throw new JobConfigError("store_full", `MemoryJobStore is at capacity (${this.#capacity} jobs); refusing to drop queued work. ` +
359
+ "Supply a durable JobStore for production volumes.");
360
+ }
361
+ }
362
+ this.#insert(job, payloadJson, now);
363
+ this.#idem.set(idemKey, job.id);
364
+ return { job: this.#clone(this.#jobs.get(job.id)), duplicate: false };
365
+ }
366
+ if (this.#jobs.size >= this.#capacity) {
367
+ this.#purgeExpiredTerminal(now);
368
+ if (this.#jobs.size >= this.#capacity) {
369
+ throw new JobConfigError("store_full", `MemoryJobStore is at capacity (${this.#capacity} jobs); refusing to drop queued work. ` +
370
+ "Supply a durable JobStore for production volumes.");
371
+ }
372
+ }
373
+ this.#insert(job, payloadJson, now);
374
+ return { job: this.#clone(this.#jobs.get(job.id)), duplicate: false };
375
+ }
376
+ /** @inheritDoc */
377
+ claim(queue, workerId, now) {
378
+ this.#reapLeases(now);
379
+ let best;
380
+ for (const job of this.#jobs.values()) {
381
+ if (job.queue !== queue)
382
+ continue;
383
+ const runnable = job.status === "queued" || (job.status === "delayed" && job.runAt <= now);
384
+ if (!runnable)
385
+ continue;
386
+ if (best === undefined || compareRunnable(job, best) < 0)
387
+ best = job;
388
+ }
389
+ if (best === undefined)
390
+ return null;
391
+ best.status = "running";
392
+ best.lockedBy = workerId;
393
+ best.leaseUntil = now + best.leaseMs;
394
+ // Attempts increment at claim: a crashed worker that never reports back
395
+ // still spends one attempt, so a poison handler cannot loop forever.
396
+ best.attempts += 1;
397
+ best.updatedAt = now;
398
+ return this.#clone(best);
399
+ }
400
+ /** @inheritDoc */
401
+ heartbeat(id, workerId, leaseUntil, now) {
402
+ const job = this.#jobs.get(id);
403
+ if (job === undefined || job.status !== "running" || job.lockedBy !== workerId) {
404
+ return false;
405
+ }
406
+ if (job.leaseUntil !== null && job.leaseUntil < now)
407
+ return false; // already lost
408
+ job.leaseUntil = leaseUntil;
409
+ job.updatedAt = now;
410
+ return true;
411
+ }
412
+ /** @inheritDoc */
413
+ complete(id, workerId, now, result) {
414
+ const job = this.#jobs.get(id);
415
+ if (job === undefined || job.status !== "running" || job.lockedBy !== workerId) {
416
+ return false;
417
+ }
418
+ job.status = "completed";
419
+ job.completedAt = now;
420
+ job.resultJson = result === null || result === undefined ? null : JSON.stringify(result);
421
+ job.lockedBy = null;
422
+ job.leaseUntil = null;
423
+ job.updatedAt = now;
424
+ return true;
425
+ }
426
+ /** @inheritDoc */
427
+ fail(id, workerId, error, next, now) {
428
+ const job = this.#jobs.get(id);
429
+ if (job === undefined || job.status !== "running" || job.lockedBy !== workerId) {
430
+ return false;
431
+ }
432
+ job.lastError = error;
433
+ job.lockedBy = null;
434
+ job.leaseUntil = null;
435
+ if (job.attempts >= job.maxAttempts) {
436
+ job.status = "dead";
437
+ job.runAt = now;
438
+ }
439
+ else {
440
+ job.status = next.status;
441
+ job.runAt = next.status === "delayed" ? next.runAt : now;
442
+ }
443
+ job.updatedAt = now;
444
+ return true;
445
+ }
446
+ /** @inheritDoc */
447
+ cancel(id, now) {
448
+ const job = this.#jobs.get(id);
449
+ if (job === undefined)
450
+ return false;
451
+ if (job.status === "completed" || job.status === "dead" || job.status === "cancelled") {
452
+ return false;
453
+ }
454
+ job.status = "cancelled";
455
+ job.lockedBy = null;
456
+ job.leaseUntil = null;
457
+ job.updatedAt = now;
458
+ return true;
459
+ }
460
+ /** @inheritDoc */
461
+ get(id, now) {
462
+ this.#reapLeases(now);
463
+ const job = this.#jobs.get(id);
464
+ return job === undefined ? null : this.#clone(job);
465
+ }
466
+ /** @inheritDoc */
467
+ list(filter) {
468
+ this.#reapLeases(this.#now());
469
+ const statuses = filter?.status === undefined
470
+ ? undefined
471
+ : new Set(Array.isArray(filter.status) ? filter.status : [filter.status]);
472
+ const out = [];
473
+ for (const job of this.#jobs.values()) {
474
+ if (filter?.queue !== undefined && job.queue !== filter.queue)
475
+ continue;
476
+ if (statuses !== undefined && !statuses.has(job.status))
477
+ continue;
478
+ if (filter?.name !== undefined && job.name !== filter.name)
479
+ continue;
480
+ if (filter?.tenant !== undefined && job.tenant !== filter.tenant)
481
+ continue;
482
+ out.push(this.#clone(job));
483
+ }
484
+ // Deterministic inspection order: oldest first, id as tiebreak.
485
+ out.sort((a, b) => a.createdAt !== b.createdAt ? a.createdAt - b.createdAt : a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
486
+ return out;
487
+ }
488
+ /** Test helper: every job, including terminal records. */
489
+ dump() {
490
+ return this.list();
491
+ }
492
+ #insert(job, payloadJson, now) {
493
+ this.#jobs.set(job.id, {
494
+ id: job.id,
495
+ queue: job.queue,
496
+ name: job.name,
497
+ payloadJson,
498
+ // The store decides the initial status from its own clock: a job with
499
+ // a future runAt starts delayed, anything else is immediately claimable.
500
+ status: job.runAt > now ? "delayed" : "queued",
501
+ attempts: job.attempts,
502
+ maxAttempts: job.maxAttempts,
503
+ runAt: job.runAt,
504
+ createdAt: job.createdAt,
505
+ updatedAt: now,
506
+ leaseUntil: null,
507
+ lockedBy: null,
508
+ leaseMs: job.leaseMs,
509
+ idempotencyKey: job.idempotencyKey,
510
+ priority: job.priority,
511
+ timeoutMs: job.timeoutMs,
512
+ lastError: null,
513
+ completedAt: null,
514
+ resultJson: null,
515
+ tenant: job.tenant,
516
+ });
517
+ }
518
+ /** Expired leases return to `queued`; attempts stay spent. */
519
+ #reapLeases(now) {
520
+ for (const job of this.#jobs.values()) {
521
+ if (job.status === "running" && job.leaseUntil !== null && job.leaseUntil < now) {
522
+ job.status = "queued";
523
+ job.lockedBy = null;
524
+ job.leaseUntil = null;
525
+ job.updatedAt = now;
526
+ }
527
+ }
528
+ }
529
+ /** Sweep terminal records past their retention; also drops index entries. */
530
+ #purgeExpiredTerminal(now) {
531
+ for (const job of this.#jobs.values()) {
532
+ const retention = job.status === "dead"
533
+ ? this.#deadRetentionMs
534
+ : job.status === "completed" || job.status === "cancelled"
535
+ ? this.#retentionMs
536
+ : undefined;
537
+ if (retention === undefined)
538
+ continue;
539
+ if (now - job.updatedAt <= retention)
540
+ continue;
541
+ if (job.idempotencyKey !== null)
542
+ this.#idem.delete(`${job.queue}\0${job.idempotencyKey}`);
543
+ this.#jobs.delete(job.id);
544
+ }
545
+ }
546
+ /** Deep copy: callers can never mutate store state through a snapshot. */
547
+ #clone(job) {
548
+ return {
549
+ id: job.id,
550
+ queue: job.queue,
551
+ name: job.name,
552
+ payload: safeJsonParse(job.payloadJson),
553
+ status: job.status,
554
+ attempts: job.attempts,
555
+ maxAttempts: job.maxAttempts,
556
+ runAt: job.runAt,
557
+ createdAt: job.createdAt,
558
+ updatedAt: job.updatedAt,
559
+ leaseUntil: job.leaseUntil,
560
+ lockedBy: job.lockedBy,
561
+ leaseMs: job.leaseMs,
562
+ idempotencyKey: job.idempotencyKey,
563
+ priority: job.priority,
564
+ timeoutMs: job.timeoutMs,
565
+ lastError: job.lastError,
566
+ completedAt: job.completedAt,
567
+ result: job.resultJson === null ? null : safeJsonParse(job.resultJson),
568
+ tenant: job.tenant,
569
+ };
570
+ }
571
+ }
572
+ /**
573
+ * Create a {@link JobQueue} over a {@link JobStore}. Validates all options
574
+ * eagerly (fail-fast config): numeric bounds throw `RangeError`, bad names
575
+ * throw {@link JobConfigError}, a missing store throws
576
+ * {@link JobConfigError} (`store_required`).
577
+ *
578
+ * @param opts - Queue configuration; see {@link JobQueueOptions}.
579
+ * @returns The producer façade.
580
+ * @since 1.3.0
581
+ */
582
+ export function createJobQueue(opts) {
583
+ if (opts === undefined || opts.store === undefined || opts.store === null) {
584
+ throw new JobConfigError("store_required", "createJobQueue(): a JobStore is required (MemoryJobStore for tests, a durable adapter in production).");
585
+ }
586
+ const store = opts.store;
587
+ const logger = opts.logger;
588
+ const now = opts.now ?? Date.now;
589
+ const payloadMaxBytes = opts.payloadMaxBytes ?? 64 * 1024;
590
+ if (!Number.isInteger(payloadMaxBytes) || payloadMaxBytes < 1) {
591
+ throw new RangeError("createJobQueue(): payloadMaxBytes must be a positive integer.");
592
+ }
593
+ const defaultQueue = opts.defaultQueue === undefined ? "default" : assertQueueName(opts.defaultQueue);
594
+ const defaultMaxAttempts = opts.defaultMaxAttempts ?? 5;
595
+ if (!Number.isInteger(defaultMaxAttempts) || defaultMaxAttempts < 1) {
596
+ throw new RangeError("createJobQueue(): defaultMaxAttempts must be a positive integer.");
597
+ }
598
+ const defaultTimeoutMs = opts.defaultTimeoutMs ?? 30_000;
599
+ if (!Number.isInteger(defaultTimeoutMs) || defaultTimeoutMs < 0) {
600
+ throw new RangeError("createJobQueue(): defaultTimeoutMs must be a non-negative integer.");
601
+ }
602
+ const defaultLeaseMs = opts.defaultLeaseMs ?? 30_000;
603
+ if (!Number.isInteger(defaultLeaseMs) || defaultLeaseMs < 1) {
604
+ throw new RangeError("createJobQueue(): defaultLeaseMs must be a positive integer.");
605
+ }
606
+ const baseDelayMs = opts.backoff?.baseDelayMs ?? 200;
607
+ if (!Number.isInteger(baseDelayMs) || baseDelayMs < 0) {
608
+ throw new RangeError("createJobQueue(): backoff.baseDelayMs must be a non-negative integer.");
609
+ }
610
+ const maxDelayMs = opts.backoff?.maxDelayMs ?? 60_000;
611
+ if (!Number.isInteger(maxDelayMs) || maxDelayMs < baseDelayMs) {
612
+ throw new RangeError("createJobQueue(): backoff.maxDelayMs must be an integer >= backoff.baseDelayMs.");
613
+ }
614
+ const backoff = {
615
+ baseDelayMs,
616
+ maxDelayMs,
617
+ // Backoff jitter spreads load; it is not a security primitive.
618
+ random: opts.backoff?.random ?? Math.random, // daloy-allow-weak-random: backoff jitter is not a security primitive
619
+ };
620
+ return {
621
+ store,
622
+ defaultQueue,
623
+ payloadMaxBytes,
624
+ backoff,
625
+ async enqueue(enqueueOpts) {
626
+ const name = assertJobName(enqueueOpts.name);
627
+ const queueName = enqueueOpts.queue === undefined ? defaultQueue : assertQueueName(enqueueOpts.queue);
628
+ const idempotencyKey = enqueueOpts.idempotencyKey === undefined
629
+ ? null
630
+ : assertIdempotencyKey(enqueueOpts.idempotencyKey);
631
+ const tenant = enqueueOpts.tenant === undefined ? null : assertTenant(enqueueOpts.tenant);
632
+ const enqueueNow = now();
633
+ let runAt;
634
+ if (enqueueOpts.runAt !== undefined) {
635
+ runAt =
636
+ enqueueOpts.runAt instanceof Date
637
+ ? enqueueOpts.runAt.getTime()
638
+ : enqueueOpts.runAt;
639
+ if (!Number.isFinite(runAt)) {
640
+ throw new JobConfigError("invalid_option", "enqueue(): runAt must be a finite epoch ms or a Date.");
641
+ }
642
+ }
643
+ else {
644
+ const delayMs = enqueueOpts.delayMs ?? 0;
645
+ if (!Number.isInteger(delayMs) || delayMs < 0) {
646
+ throw new JobConfigError("invalid_option", "enqueue(): delayMs must be a non-negative integer.");
647
+ }
648
+ runAt = enqueueNow + delayMs;
649
+ }
650
+ const priority = enqueueOpts.priority ?? 0;
651
+ if (!Number.isInteger(priority)) {
652
+ throw new JobConfigError("invalid_option", "enqueue(): priority must be an integer.");
653
+ }
654
+ const maxAttempts = enqueueOpts.maxAttempts ?? defaultMaxAttempts;
655
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
656
+ throw new JobConfigError("invalid_option", "enqueue(): maxAttempts must be a positive integer.");
657
+ }
658
+ const timeoutMs = enqueueOpts.timeoutMs ?? defaultTimeoutMs;
659
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 0) {
660
+ throw new JobConfigError("invalid_option", "enqueue(): timeoutMs must be a non-negative integer (0 disables the per-attempt timeout).");
661
+ }
662
+ const leaseMs = enqueueOpts.leaseMs ?? defaultLeaseMs;
663
+ if (!Number.isInteger(leaseMs) || leaseMs < 1) {
664
+ throw new JobConfigError("invalid_option", "enqueue(): leaseMs must be a positive integer.");
665
+ }
666
+ const { json, bytes } = serializePayload(enqueueOpts.payload, payloadMaxBytes, "payload");
667
+ // The fingerprint rides only idempotent enqueues; without a key the
668
+ // store never compares payloads, so skip the hash.
669
+ const fingerprint = idempotencyKey === null ? null : await sha256Hex(bytes);
670
+ // A clean, plain-JSON clone: key order and values are exactly what was
671
+ // serialized, so two enqueues of the same logical payload compare equal.
672
+ const payload = safeJsonParse(json);
673
+ const job = {
674
+ id: randomId(),
675
+ queue: queueName,
676
+ name,
677
+ payload,
678
+ status: runAt > enqueueNow ? "delayed" : "queued",
679
+ attempts: 0,
680
+ maxAttempts,
681
+ runAt,
682
+ createdAt: enqueueNow,
683
+ updatedAt: enqueueNow,
684
+ leaseUntil: null,
685
+ lockedBy: null,
686
+ leaseMs,
687
+ idempotencyKey,
688
+ priority,
689
+ timeoutMs,
690
+ lastError: null,
691
+ completedAt: null,
692
+ result: null,
693
+ tenant,
694
+ };
695
+ logger?.debug({ event: "jobs.enqueue", queue: queueName, name, jobId: job.id, delayed: runAt > enqueueNow }, `Enqueued job "${name}" (${job.id})`);
696
+ const result = await settle(store.put(job, fingerprint));
697
+ return { job: result.job, duplicate: result.duplicate };
698
+ },
699
+ async get(id) {
700
+ return settle(store.get(id, now()));
701
+ },
702
+ async cancel(id) {
703
+ return settle(store.cancel(id, now()));
704
+ },
705
+ };
706
+ }
707
+ const defaultWorkerTimers = {
708
+ set(callback, delayMs) {
709
+ const handle = setTimeout(callback, delayMs);
710
+ handle.unref?.();
711
+ return handle;
712
+ },
713
+ clear(handle) {
714
+ clearTimeout(handle);
715
+ },
716
+ };
717
+ /**
718
+ * Create a {@link JobWorker}. The handler registry is frozen into a
719
+ * null-prototype map at construction: unknown job names dead-letter as
720
+ * poison pills instead of spinning, and no code path resolves a handler by
721
+ * dynamic lookup or import.
722
+ *
723
+ * @param opts - Worker configuration; see {@link JobWorkerOptions}.
724
+ * @returns The worker.
725
+ * @throws {RangeError} on out-of-bounds numeric options.
726
+ * @throws {@link JobConfigError} on a missing/empty registry or invalid
727
+ * handler names.
728
+ * @since 1.3.0
729
+ */
730
+ export function createJobWorker(opts) {
731
+ if (opts === undefined || opts.queue === undefined || opts.queue === null) {
732
+ throw new JobConfigError("store_required", "createJobWorker(): a JobQueue is required.");
733
+ }
734
+ const queue = opts.queue;
735
+ const store = queue.store;
736
+ const logger = opts.logger;
737
+ const now = opts.now ?? Date.now;
738
+ const timers = opts.timers ?? defaultWorkerTimers;
739
+ if (opts.handlers === undefined || opts.handlers === null || typeof opts.handlers !== "object") {
740
+ throw new JobConfigError("unknown_handler", "createJobWorker(): a handlers registry is required.");
741
+ }
742
+ const handlers = Object.create(null);
743
+ for (const [name, handler] of Object.entries(opts.handlers)) {
744
+ assertJobName(name);
745
+ if (typeof handler !== "function") {
746
+ throw new JobConfigError("invalid_option", `createJobWorker(): handler "${name}" must be a function.`);
747
+ }
748
+ handlers[name] = handler;
749
+ }
750
+ const handlerNames = Object.keys(handlers);
751
+ if (handlerNames.length === 0) {
752
+ throw new JobConfigError("unknown_handler", "createJobWorker(): the handlers registry is empty; every claimed job would dead-letter.");
753
+ }
754
+ Object.freeze(handlers);
755
+ const queues = (opts.queues ?? [queue.defaultQueue]).map(assertQueueName);
756
+ const concurrency = opts.concurrency ?? 1;
757
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
758
+ throw new RangeError("createJobWorker(): concurrency must be a positive integer.");
759
+ }
760
+ if (concurrency > 1000) {
761
+ throw new RangeError("createJobWorker(): concurrency above 1000 is not a supported posture.");
762
+ }
763
+ if (concurrency > 32) {
764
+ logger?.warn({ event: "jobs.worker.high_concurrency", concurrency }, `Job worker concurrency ${concurrency} is unusually high; bound it to downstream capacity.`);
765
+ }
766
+ const pollIntervalMs = opts.pollIntervalMs ?? 200;
767
+ if (!Number.isInteger(pollIntervalMs) || pollIntervalMs < 1) {
768
+ throw new RangeError("createJobWorker(): pollIntervalMs must be a positive integer.");
769
+ }
770
+ const workerId = opts.workerId ?? randomId();
771
+ const backoff = queue.backoff;
772
+ let started = false;
773
+ let stopped = false;
774
+ let pollTimer;
775
+ let draining = false;
776
+ let drainAgain = false;
777
+ const inFlight = new Set();
778
+ function arm() {
779
+ if (stopped || !started)
780
+ return;
781
+ pollTimer = timers.set(() => {
782
+ pollTimer = undefined;
783
+ void tick();
784
+ }, pollIntervalMs);
785
+ }
786
+ async function tick() {
787
+ if (stopped)
788
+ return;
789
+ // Re-arm first (fixed cadence), then claim. Overlapping drains are
790
+ // collapsed by the single-flight drain flag.
791
+ arm();
792
+ await drainAvailable();
793
+ }
794
+ async function drainAvailable() {
795
+ if (draining) {
796
+ drainAgain = true;
797
+ return;
798
+ }
799
+ draining = true;
800
+ try {
801
+ do {
802
+ drainAgain = false;
803
+ while (!stopped && inFlight.size < concurrency) {
804
+ let job;
805
+ try {
806
+ job = await claimNext();
807
+ }
808
+ catch (error) {
809
+ // A throwing store must not kill the loop; the next poll retries.
810
+ logger?.error({ event: "jobs.worker.claim_failed", err: errorMessage(error), workerId }, "Job worker: claim failed");
811
+ break;
812
+ }
813
+ if (job === null)
814
+ break;
815
+ launch(job);
816
+ }
817
+ } while (drainAgain && !stopped);
818
+ }
819
+ finally {
820
+ draining = false;
821
+ }
822
+ }
823
+ async function claimNext() {
824
+ for (const queueName of queues) {
825
+ const job = await settle(store.claim(queueName, workerId, now()));
826
+ if (job !== null) {
827
+ logger?.debug({ event: "jobs.claim", queue: job.queue, name: job.name, jobId: job.id, attempt: job.attempts, workerId }, `Claimed job "${job.name}" (${job.id})`);
828
+ return job;
829
+ }
830
+ }
831
+ return null;
832
+ }
833
+ /** Track and run a claimed job without awaiting it (concurrency > 1). */
834
+ function launch(job) {
835
+ const controller = new AbortController();
836
+ const entry = { controller, promise: Promise.resolve(), jobId: job.id };
837
+ entry.promise = execute(job, controller)
838
+ .catch((error) => {
839
+ // The run algorithm handles every expected failure; this catch is the
840
+ // last-resort guard that keeps a defect from crashing the loop.
841
+ logger?.error({ event: "jobs.worker.run_crashed", jobId: job.id, err: errorMessage(error), workerId }, "Job worker: unexpected runner error");
842
+ })
843
+ .finally(() => {
844
+ inFlight.delete(entry);
845
+ });
846
+ inFlight.add(entry);
847
+ return entry;
848
+ }
849
+ async function execute(job, controller) {
850
+ let timedOut = false;
851
+ let lostLease = false;
852
+ let settled = false;
853
+ let timeoutTimer;
854
+ let heartbeatTimer;
855
+ const clearJobTimers = () => {
856
+ if (timeoutTimer !== undefined) {
857
+ timers.clear(timeoutTimer);
858
+ timeoutTimer = undefined;
859
+ }
860
+ if (heartbeatTimer !== undefined) {
861
+ timers.clear(heartbeatTimer);
862
+ heartbeatTimer = undefined;
863
+ }
864
+ };
865
+ const beat = async () => {
866
+ if (settled || lostLease)
867
+ return;
868
+ const ok = await settle(store.heartbeat(job.id, workerId, now() + job.leaseMs, now()));
869
+ if (!ok && !settled) {
870
+ // Another worker owns the job now: unwind locally, touch nothing.
871
+ lostLease = true;
872
+ logger?.warn({ event: "jobs.lease_lost", queue: job.queue, name: job.name, jobId: job.id, workerId }, `Lost lease for job "${job.name}" (${job.id}); aborting local run`);
873
+ controller.abort();
874
+ }
875
+ };
876
+ const scheduleBeat = () => {
877
+ if (settled)
878
+ return;
879
+ heartbeatTimer = timers.set(() => {
880
+ heartbeatTimer = undefined;
881
+ void (async () => {
882
+ await beat();
883
+ if (!settled && !lostLease && !controller.signal.aborted)
884
+ scheduleBeat();
885
+ })();
886
+ }, Math.max(1, Math.floor(job.leaseMs / 3)));
887
+ };
888
+ if (job.timeoutMs > 0) {
889
+ timeoutTimer = timers.set(() => {
890
+ timedOut = true;
891
+ controller.abort();
892
+ }, job.timeoutMs);
893
+ }
894
+ // Auto-heartbeat so handlers that forget ctx.heartbeat() still hold the
895
+ // lease while they run; long jobs should also call ctx.heartbeat().
896
+ scheduleBeat();
897
+ const jobLog = {
898
+ debug: (obj, msg) => logger?.debug(typeof obj === "string"
899
+ ? obj
900
+ : { jobId: job.id, name: job.name, queue: job.queue, attempt: job.attempts, ...obj }, msg),
901
+ info: (obj, msg) => logger?.info(typeof obj === "string"
902
+ ? obj
903
+ : { jobId: job.id, name: job.name, queue: job.queue, attempt: job.attempts, ...obj }, msg),
904
+ warn: (obj, msg) => logger?.warn(typeof obj === "string"
905
+ ? obj
906
+ : { jobId: job.id, name: job.name, queue: job.queue, attempt: job.attempts, ...obj }, msg),
907
+ error: (obj, msg) => logger?.error(typeof obj === "string"
908
+ ? obj
909
+ : { jobId: job.id, name: job.name, queue: job.queue, attempt: job.attempts, ...obj }, msg),
910
+ };
911
+ try {
912
+ const handler = handlers[job.name];
913
+ if (handler === undefined) {
914
+ // Poison pill: no code is registered for this name. Dead-letter
915
+ // immediately instead of retrying a record that can never succeed.
916
+ throw new JobFatalError(`Unknown job handler: "${job.name}".`);
917
+ }
918
+ const returned = await handler({
919
+ job,
920
+ signal: controller.signal,
921
+ attempt: job.attempts,
922
+ heartbeat: beat,
923
+ log: jobLog,
924
+ });
925
+ if (controller.signal.aborted) {
926
+ if (lostLease)
927
+ return; // the other worker owns the outcome
928
+ if (timedOut)
929
+ throw new JobTimeoutError(job.timeoutMs);
930
+ throw new Error("Job aborted: worker is stopping.");
931
+ }
932
+ const resultValue = returned === undefined ? null : returned;
933
+ if (resultValue !== null) {
934
+ // Same rules as payloads: plain JSON, pollution keys rejected, size cap.
935
+ serializePayload(resultValue, queue.payloadMaxBytes, "result");
936
+ }
937
+ const ok = await settle(store.complete(job.id, workerId, now(), resultValue));
938
+ if (!ok) {
939
+ logger?.warn({ event: "jobs.complete_lost", queue: job.queue, name: job.name, jobId: job.id, workerId }, `Complete for job "${job.name}" (${job.id}) was rejected: lease lost`);
940
+ return;
941
+ }
942
+ logger?.debug({ event: "jobs.complete", queue: job.queue, name: job.name, jobId: job.id, attempt: job.attempts }, `Completed job "${job.name}" (${job.id})`);
943
+ await safeHook(() => opts.onComplete?.(job));
944
+ }
945
+ catch (error) {
946
+ if (lostLease)
947
+ return; // the other worker owns the outcome
948
+ // A handler that unwound because of the timeout abort reports the
949
+ // timeout, not whatever the abort made it throw.
950
+ const effectiveError = timedOut
951
+ ? error instanceof JobTimeoutError
952
+ ? error
953
+ : new JobTimeoutError(job.timeoutMs)
954
+ : error;
955
+ const fatal = effectiveError instanceof JobFatalError;
956
+ const message = errorMessage(effectiveError);
957
+ const willRetry = !fatal && job.attempts < job.maxAttempts;
958
+ const delayMs = willRetry
959
+ ? computeBackoffMs(job.attempts, backoff.baseDelayMs, backoff.maxDelayMs, backoff.random)
960
+ : 0;
961
+ const nextRunAt = now() + delayMs;
962
+ const ok = await settle(store.fail(job.id, workerId, message, willRetry
963
+ ? { status: "delayed", runAt: nextRunAt }
964
+ : { status: "dead", runAt: now() }, now()));
965
+ if (!ok) {
966
+ logger?.warn({ event: "jobs.fail_lost", queue: job.queue, name: job.name, jobId: job.id, workerId }, `Fail for job "${job.name}" (${job.id}) was rejected: lease lost`);
967
+ return;
968
+ }
969
+ if (willRetry) {
970
+ logger?.warn({
971
+ event: "jobs.retry",
972
+ queue: job.queue,
973
+ name: job.name,
974
+ jobId: job.id,
975
+ attempt: job.attempts,
976
+ delayMs,
977
+ err: message,
978
+ }, `Job "${job.name}" (${job.id}) failed; retrying in ${delayMs}ms`);
979
+ await safeHook(() => opts.onFail?.(job, message, true, delayMs));
980
+ }
981
+ else {
982
+ logger?.error({
983
+ event: "jobs.dead",
984
+ queue: job.queue,
985
+ name: job.name,
986
+ jobId: job.id,
987
+ attempt: job.attempts,
988
+ fatal,
989
+ err: message,
990
+ }, `Job "${job.name}" (${job.id}) dead-lettered`);
991
+ await safeHook(() => opts.onFail?.(job, message, false));
992
+ await safeHook(() => opts.onDead?.(job, message));
993
+ }
994
+ }
995
+ finally {
996
+ settled = true;
997
+ clearJobTimers();
998
+ }
999
+ }
1000
+ async function safeHook(hook) {
1001
+ try {
1002
+ await hook();
1003
+ }
1004
+ catch (error) {
1005
+ // Hooks observe; they must never change the job's outcome.
1006
+ logger?.error({ event: "jobs.hook_failed", err: errorMessage(error) }, "Job worker: lifecycle hook threw");
1007
+ }
1008
+ }
1009
+ return {
1010
+ start() {
1011
+ if (stopped) {
1012
+ throw new JobConfigError("invalid_option", "Job worker cannot be restarted after stop(); create a new worker.");
1013
+ }
1014
+ if (started) {
1015
+ throw new JobConfigError("invalid_option", "Job worker is already started.");
1016
+ }
1017
+ started = true;
1018
+ logger?.info({ event: "jobs.worker.started", workerId, queues, concurrency }, "Job worker started");
1019
+ arm();
1020
+ },
1021
+ async stop(graceMs = 5_000) {
1022
+ if (!Number.isInteger(graceMs) || graceMs < 0) {
1023
+ throw new RangeError("Job worker stop(): graceMs must be a non-negative integer.");
1024
+ }
1025
+ if (stopped)
1026
+ return;
1027
+ stopped = true;
1028
+ if (pollTimer !== undefined) {
1029
+ timers.clear(pollTimer);
1030
+ pollTimer = undefined;
1031
+ }
1032
+ if (inFlight.size === 0) {
1033
+ logger?.info({ event: "jobs.worker.stopped", workerId }, "Job worker stopped");
1034
+ return;
1035
+ }
1036
+ let timedOut = false;
1037
+ let deadlineTimer;
1038
+ const deadline = new Promise((resolve) => {
1039
+ deadlineTimer = timers.set(() => {
1040
+ timedOut = true;
1041
+ resolve();
1042
+ }, graceMs);
1043
+ });
1044
+ const settledAll = Promise.all([...inFlight].map((entry) => entry.promise)).then(() => undefined);
1045
+ await Promise.race([settledAll, deadline]);
1046
+ if (deadlineTimer !== undefined)
1047
+ timers.clear(deadlineTimer);
1048
+ if (timedOut && inFlight.size > 0) {
1049
+ logger?.warn({ event: "jobs.worker.stop_timeout", workerId, inFlight: inFlight.size }, `Job worker grace period elapsed; aborting ${inFlight.size} in-flight job(s)`);
1050
+ for (const entry of inFlight)
1051
+ entry.controller.abort();
1052
+ // Wait for the aborted runs to unwind (they fail back to the queue).
1053
+ await Promise.all([...inFlight].map((entry) => entry.promise)).catch(() => undefined);
1054
+ }
1055
+ logger?.info({ event: "jobs.worker.stopped", workerId }, "Job worker stopped");
1056
+ },
1057
+ getState() {
1058
+ return { running: started && !stopped, inFlight: inFlight.size, workerId };
1059
+ },
1060
+ async runOnce() {
1061
+ const job = await claimNext();
1062
+ if (job === null)
1063
+ return false;
1064
+ const entry = launch(job);
1065
+ await entry.promise;
1066
+ return true;
1067
+ },
1068
+ };
1069
+ }
1070
+ // ── tenancy helper ──────────────────────────────────────────────────
1071
+ /**
1072
+ * Build a tenant-safe idempotency key of the form `t/{tenant}/{name}/{key}`
1073
+ * (or `g/{name}/{key}` when no tenant applies). Segments are validated so a
1074
+ * key can never smuggle whitespace/control characters, newlines, or `..`
1075
+ * into store keys or log lines, and tenant ids follow the `tenancy()`
1076
+ * grammar — one tenant's keys can never collide with another's.
1077
+ *
1078
+ * @param parts - `name` is the job name; `key` is the caller's dedupe id
1079
+ * (user id, provider event id, order id, …); `tenant` scopes the key.
1080
+ * @returns The composed key, at most 255 chars.
1081
+ * @throws {@link JobConfigError} when a segment fails validation.
1082
+ * @since 1.3.0
1083
+ */
1084
+ export function jobIdempotencyKey(parts) {
1085
+ const name = assertJobName(parts.name);
1086
+ const key = parts.key;
1087
+ if (typeof key !== "string" || key.length === 0) {
1088
+ throw new JobConfigError("invalid_option", "jobIdempotencyKey(): key must be a non-empty string.");
1089
+ }
1090
+ if (!/^[\x21-\x7e]+$/.test(key)) {
1091
+ throw new JobConfigError("invalid_option", "jobIdempotencyKey(): key must be printable ASCII (no whitespace or control characters).");
1092
+ }
1093
+ if (key.includes("..")) {
1094
+ throw new JobConfigError("invalid_option", 'jobIdempotencyKey(): key must not contain "..".');
1095
+ }
1096
+ const scope = parts.tenant === undefined ? "g" : `t/${assertTenant(parts.tenant)}`;
1097
+ const out = `${scope}/${name}/${key}`;
1098
+ if (out.length > 255) {
1099
+ throw new JobConfigError("invalid_option", `jobIdempotencyKey(): composed key is ${out.length} chars; the limit is 255.`);
1100
+ }
1101
+ return out;
1102
+ }