@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/README.md +3 -2
- package/dist/adapters/cloudflare.d.ts +8 -1
- package/dist/adapters/cloudflare.js +15 -1
- package/dist/adapters/lambda.js +15 -5
- package/dist/adapters/vercel.d.ts +5 -0
- package/dist/adapters/vercel.js +27 -2
- package/dist/app.d.ts +136 -10
- package/dist/app.js +382 -69
- package/dist/combine.js +14 -8
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/jobs.d.ts +665 -0
- package/dist/jobs.js +1102 -0
- package/dist/metrics.d.ts +23 -9
- package/dist/metrics.js +33 -10
- package/dist/otlp.d.ts +31 -5
- package/dist/otlp.js +99 -37
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/package.json +7 -1
package/dist/jobs.d.ts
ADDED
|
@@ -0,0 +1,665 @@
|
|
|
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 type { SchedulerLogger, TimerFns } from "./scheduler.js";
|
|
52
|
+
/**
|
|
53
|
+
* Machine-readable reason carried by a {@link JobConfigError}.
|
|
54
|
+
*
|
|
55
|
+
* - `invalid_name` — job or queue name failed the charset allowlist.
|
|
56
|
+
* - `invalid_payload` — payload (or result) is not plain JSON, or carries a
|
|
57
|
+
* prototype-pollution key.
|
|
58
|
+
* - `payload_too_large` — serialized payload (or result) exceeds
|
|
59
|
+
* {@link JobQueueOptions.payloadMaxBytes}.
|
|
60
|
+
* - `invalid_option` — a numeric/enum option failed validation, or a
|
|
61
|
+
* lifecycle call was duplicated (`useJobs` twice, worker `start()` twice).
|
|
62
|
+
* - `unknown_handler` — no handler was registered for a claimed job's name.
|
|
63
|
+
* - `store_required` — an operation needs a queue that was never configured
|
|
64
|
+
* (for example `app.cronEnqueue()` before `app.useJobs()`).
|
|
65
|
+
* - `store_full` — a bounded store rejected a new job because it is at
|
|
66
|
+
* capacity.
|
|
67
|
+
*
|
|
68
|
+
* @since 1.3.0
|
|
69
|
+
*/
|
|
70
|
+
export type JobConfigErrorCode = "invalid_name" | "invalid_payload" | "payload_too_large" | "invalid_option" | "unknown_handler" | "store_required" | "store_full";
|
|
71
|
+
/**
|
|
72
|
+
* Enqueue validation, unknown names at define-time, and bad options. An
|
|
73
|
+
* ordinary `Error` subclass (like `CronParseError`), not an `HttpError` —
|
|
74
|
+
* the jobs engine is a library, not a mounted route; map it to `400`/`422`
|
|
75
|
+
* in your own contract route if you expose enqueue over HTTP.
|
|
76
|
+
*
|
|
77
|
+
* @since 1.3.0
|
|
78
|
+
*/
|
|
79
|
+
export declare class JobConfigError extends Error {
|
|
80
|
+
/** Machine-readable failure reason; see {@link JobConfigErrorCode}. */
|
|
81
|
+
readonly code: JobConfigErrorCode;
|
|
82
|
+
constructor(code: JobConfigErrorCode, message: string);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Same idempotency key, different payload fingerprint. A key is permanently
|
|
86
|
+
* bound to the first payload it was enqueued with — mirroring the `422`
|
|
87
|
+
* key-reuse rule of the HTTP `idempotency()` middleware.
|
|
88
|
+
*
|
|
89
|
+
* @since 1.3.0
|
|
90
|
+
*/
|
|
91
|
+
export declare class JobIdempotencyConflictError extends Error {
|
|
92
|
+
/** Machine-readable failure reason. */
|
|
93
|
+
readonly code: "idempotency_conflict";
|
|
94
|
+
/** The idempotency key that was reused. */
|
|
95
|
+
readonly key: string;
|
|
96
|
+
/** The id of the job that already holds the key. */
|
|
97
|
+
readonly existingJobId: string;
|
|
98
|
+
constructor(key: string, existingJobId: string);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Thrown by a handler (or the worker, for an unknown job name) to signal
|
|
102
|
+
* “do not retry” — the job is dead-lettered on the spot, however
|
|
103
|
+
* many attempts remain. Analogous to a webhook SSRF / permanent-4xx failure.
|
|
104
|
+
*
|
|
105
|
+
* @since 1.3.0
|
|
106
|
+
*/
|
|
107
|
+
export declare class JobFatalError extends Error {
|
|
108
|
+
/** Machine-readable failure reason. */
|
|
109
|
+
readonly code: "fatal";
|
|
110
|
+
readonly cause?: unknown;
|
|
111
|
+
constructor(message: string, cause?: unknown);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* A claimed job exceeded its `timeoutMs`; the worker aborted the handler's
|
|
115
|
+
* signal and failed the job as retryable (unless the attempt budget is
|
|
116
|
+
* already exhausted).
|
|
117
|
+
*
|
|
118
|
+
* @since 1.3.0
|
|
119
|
+
*/
|
|
120
|
+
export declare class JobTimeoutError extends Error {
|
|
121
|
+
/** Machine-readable failure reason. */
|
|
122
|
+
readonly code: "timeout";
|
|
123
|
+
/** The per-attempt timeout that elapsed, in ms. */
|
|
124
|
+
readonly timeoutMs: number;
|
|
125
|
+
constructor(timeoutMs: number);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Lifecycle states of a {@link Job}. Terminal states (`completed`, `dead`,
|
|
129
|
+
* `cancelled`) have no outgoing transitions.
|
|
130
|
+
*
|
|
131
|
+
* ```text
|
|
132
|
+
* delayed --(runAt <= now)--> queued
|
|
133
|
+
* queued --claim--> running
|
|
134
|
+
* running --complete--> completed (terminal)
|
|
135
|
+
* running --fail, attempts < max--> delayed (runAt = now + backoff)
|
|
136
|
+
* running --fail, attempts >= max--> dead (terminal)
|
|
137
|
+
* running --JobFatalError--> dead (terminal)
|
|
138
|
+
* running --lease expired--> queued (attempts already counted)
|
|
139
|
+
* queued/delayed/running --cancel--> cancelled (terminal)
|
|
140
|
+
* ```
|
|
141
|
+
*
|
|
142
|
+
* @since 1.3.0
|
|
143
|
+
*/
|
|
144
|
+
export type JobStatus = "queued" | "delayed" | "running" | "completed" | "dead" | "cancelled";
|
|
145
|
+
/**
|
|
146
|
+
* A serializable snapshot of one unit of background work: an opaque named
|
|
147
|
+
* handler plus a plain-JSON payload. All fields are readonly snapshots —
|
|
148
|
+
* mutate nothing; stores return deep copies.
|
|
149
|
+
*
|
|
150
|
+
* @since 1.3.0
|
|
151
|
+
*/
|
|
152
|
+
export interface Job<P = unknown> {
|
|
153
|
+
/** Unique job id (`crypto.randomUUID()`). */
|
|
154
|
+
readonly id: string;
|
|
155
|
+
/** Named partition inside the store (`default`, `mail`, …). */
|
|
156
|
+
readonly queue: string;
|
|
157
|
+
/** Handler registry key. */
|
|
158
|
+
readonly name: string;
|
|
159
|
+
/** Plain-JSON handler input, validated at enqueue. */
|
|
160
|
+
readonly payload: P;
|
|
161
|
+
/** Current lifecycle state. */
|
|
162
|
+
readonly status: JobStatus;
|
|
163
|
+
/** How many times execution has started, including the current run. */
|
|
164
|
+
readonly attempts: number;
|
|
165
|
+
/** Maximum starts before the job dead-letters. */
|
|
166
|
+
readonly maxAttempts: number;
|
|
167
|
+
/** Earliest epoch ms the job may be claimed. */
|
|
168
|
+
readonly runAt: number;
|
|
169
|
+
/** Epoch ms the record was created. */
|
|
170
|
+
readonly createdAt: number;
|
|
171
|
+
/** Epoch ms the record last changed. */
|
|
172
|
+
readonly updatedAt: number;
|
|
173
|
+
/** Epoch ms the current lease ends, or `null` when not claimed. */
|
|
174
|
+
readonly leaseUntil: number | null;
|
|
175
|
+
/** Worker holding the lease, or `null` when not claimed. */
|
|
176
|
+
readonly lockedBy: string | null;
|
|
177
|
+
/** Lease duration granted per claim, in ms. */
|
|
178
|
+
readonly leaseMs: number;
|
|
179
|
+
/** Producer-supplied dedupe key, unique per queue, or `null`. */
|
|
180
|
+
readonly idempotencyKey: string | null;
|
|
181
|
+
/** Claim ordering: higher first. */
|
|
182
|
+
readonly priority: number;
|
|
183
|
+
/** Per-attempt timeout in ms; `0` disables. */
|
|
184
|
+
readonly timeoutMs: number;
|
|
185
|
+
/** Message of the most recent failure, or `null`. */
|
|
186
|
+
readonly lastError: string | null;
|
|
187
|
+
/** Epoch ms the job completed, or `null`. */
|
|
188
|
+
readonly completedAt: number | null;
|
|
189
|
+
/** Optional value stored at completion (the handler's return value). */
|
|
190
|
+
readonly result: unknown | null;
|
|
191
|
+
/** Optional tenant discriminator copied from enqueue (data, not authz). */
|
|
192
|
+
readonly tenant: string | null;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Input to {@link JobQueue.enqueue}. Only `name` and `payload` are required.
|
|
196
|
+
*
|
|
197
|
+
* @since 1.3.0
|
|
198
|
+
*/
|
|
199
|
+
export interface EnqueueOptions<P = unknown> {
|
|
200
|
+
/** Handler registry key: `^[a-zA-Z][a-zA-Z0-9._:-]{0,127}$`. */
|
|
201
|
+
name: string;
|
|
202
|
+
/** Plain-JSON handler input. Not a blob store — see `payloadMaxBytes`. */
|
|
203
|
+
payload: P;
|
|
204
|
+
/** Named partition. Default `"default"`. Charset `[a-zA-Z0-9_:-]{1,64}`. */
|
|
205
|
+
queue?: string;
|
|
206
|
+
/**
|
|
207
|
+
* Unique-per-queue dedupe key. A second enqueue with the same key and a
|
|
208
|
+
* deep-equal payload returns the existing job with `duplicate: true`; the
|
|
209
|
+
* same key with a different payload throws {@link JobIdempotencyConflictError}.
|
|
210
|
+
* Printable ASCII, 1–255 chars — build tenant-safe keys with
|
|
211
|
+
* {@link jobIdempotencyKey}.
|
|
212
|
+
*/
|
|
213
|
+
idempotencyKey?: string;
|
|
214
|
+
/** Absolute earliest claim time. If in the future, status starts `delayed`. */
|
|
215
|
+
runAt?: number | Date;
|
|
216
|
+
/** Relative delay in ms. Ignored when {@link runAt} is set. */
|
|
217
|
+
delayMs?: number;
|
|
218
|
+
/** Claim ordering: higher first. Default `0`. */
|
|
219
|
+
priority?: number;
|
|
220
|
+
/** Maximum starts before dead-letter. Default `5`. */
|
|
221
|
+
maxAttempts?: number;
|
|
222
|
+
/** Per-attempt timeout in ms; `0` disables (dangerous). Default `30_000`. */
|
|
223
|
+
timeoutMs?: number;
|
|
224
|
+
/** Lease duration granted per claim in ms. Default `30_000`. */
|
|
225
|
+
leaseMs?: number;
|
|
226
|
+
/**
|
|
227
|
+
* Optional tenant discriminator copied onto the record for store key
|
|
228
|
+
* partitioning and logs. Validated against the same `[a-z0-9_-]` grammar
|
|
229
|
+
* as `tenancy()` ids. Jobs are not HTTP — pass `ctx.state.tenant`
|
|
230
|
+
* explicitly; nothing reads it for you.
|
|
231
|
+
*/
|
|
232
|
+
tenant?: string;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Outcome of {@link JobQueue.enqueue} / {@link JobStore.put}.
|
|
236
|
+
*
|
|
237
|
+
* @since 1.3.0
|
|
238
|
+
*/
|
|
239
|
+
export interface EnqueueResult<P = unknown> {
|
|
240
|
+
/** The created job, or the existing one on an idempotency hit. */
|
|
241
|
+
job: Job<P>;
|
|
242
|
+
/** `true` when an existing job was returned due to an idempotency-key hit. */
|
|
243
|
+
duplicate: boolean;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Persistence SPI — the whole point of “queue-agnostic”. All
|
|
247
|
+
* durability lives behind this interface; durable backends (Redis, Postgres,
|
|
248
|
+
* SQS) are user-supplied adapters, never core dependencies. Every method may
|
|
249
|
+
* be synchronous or asynchronous, mirroring `IdempotencyStore`.
|
|
250
|
+
*
|
|
251
|
+
* Implementations must treat {@link JobStore.claim} and {@link JobStore.put}
|
|
252
|
+
* as atomic: two concurrent claims must never hand the same job to two
|
|
253
|
+
* workers, and two concurrent puts with the same idempotency key must never
|
|
254
|
+
* create two records.
|
|
255
|
+
*
|
|
256
|
+
* @since 1.3.0
|
|
257
|
+
*/
|
|
258
|
+
export interface JobStore {
|
|
259
|
+
/**
|
|
260
|
+
* Insert a new job. If `idempotencyKey` is set and a job already exists in
|
|
261
|
+
* this queue with that key, return the existing job with `duplicate: true`
|
|
262
|
+
* WITHOUT modifying it — even if it is terminal (first-writer-wins). If the
|
|
263
|
+
* existing payload fingerprint differs, throw
|
|
264
|
+
* {@link JobIdempotencyConflictError}.
|
|
265
|
+
*
|
|
266
|
+
* @param job - The fully-formed record to persist.
|
|
267
|
+
* @param fingerprint - SHA-256 hex of the serialized payload, or `null`
|
|
268
|
+
* when no idempotency key is set. Provided so adapters can compare
|
|
269
|
+
* payloads without re-hashing.
|
|
270
|
+
*/
|
|
271
|
+
put(job: Job, fingerprint: string | null): Promise<EnqueueResult> | EnqueueResult;
|
|
272
|
+
/**
|
|
273
|
+
* Atomically select the next runnable job in `queue` (`queued`, or
|
|
274
|
+
* `delayed` with `runAt <= now`, or `running` with an expired lease),
|
|
275
|
+
* highest `priority` first, then oldest `runAt` / `createdAt`. Set
|
|
276
|
+
* `status = "running"`, `leaseUntil = now + job.leaseMs`,
|
|
277
|
+
* `lockedBy = workerId`, increment `attempts`, and return the claimed
|
|
278
|
+
* record. Return `null` when nothing is runnable.
|
|
279
|
+
*
|
|
280
|
+
* @param queue - The partition to claim from (never cross-queue).
|
|
281
|
+
* @param workerId - Identity written to `lockedBy` for fencing.
|
|
282
|
+
* @param now - Epoch ms, injected for deterministic leases.
|
|
283
|
+
*/
|
|
284
|
+
claim(queue: string, workerId: string, now: number): Promise<Job | null> | Job | null;
|
|
285
|
+
/**
|
|
286
|
+
* Extend the lease of a running job still owned by `workerId`.
|
|
287
|
+
*
|
|
288
|
+
* @returns `false` when the lease was lost (not running, wrong owner, or
|
|
289
|
+
* already expired) — the caller must stop touching the job.
|
|
290
|
+
*/
|
|
291
|
+
heartbeat(id: string, workerId: string, leaseUntil: number, now: number): Promise<boolean> | boolean;
|
|
292
|
+
/**
|
|
293
|
+
* Mark a running job owned by `workerId` as `completed` (terminal),
|
|
294
|
+
* storing an optional result value.
|
|
295
|
+
*
|
|
296
|
+
* @returns `false` when the lease was lost — the worker must treat the
|
|
297
|
+
* completion as not persisted.
|
|
298
|
+
*/
|
|
299
|
+
complete(id: string, workerId: string, now: number, result: unknown | null): Promise<boolean> | boolean;
|
|
300
|
+
/**
|
|
301
|
+
* Record a failure for a running job owned by `workerId`. Attempts were
|
|
302
|
+
* already incremented at claim time: when `attempts >= maxAttempts` the
|
|
303
|
+
* store marks the job `dead`; otherwise it applies `next` (requeue
|
|
304
|
+
* `delayed` until `runAt`, or straight back to `queued`).
|
|
305
|
+
*
|
|
306
|
+
* @returns `false` when the lease was lost — another worker owns the job.
|
|
307
|
+
*/
|
|
308
|
+
fail(id: string, workerId: string, error: string, next: {
|
|
309
|
+
status: "delayed" | "queued" | "dead";
|
|
310
|
+
runAt: number;
|
|
311
|
+
}, now: number): Promise<boolean> | boolean;
|
|
312
|
+
/**
|
|
313
|
+
* Cancel a non-terminal job. Terminal jobs return `false`.
|
|
314
|
+
*/
|
|
315
|
+
cancel(id: string, now: number): Promise<boolean> | boolean;
|
|
316
|
+
/**
|
|
317
|
+
* Read one job by id, or `null` when unknown. Expired leases are reaped
|
|
318
|
+
* lazily before the snapshot is taken.
|
|
319
|
+
*/
|
|
320
|
+
get(id: string, now: number): Promise<Job | null> | Job | null;
|
|
321
|
+
/**
|
|
322
|
+
* Optional filtered listing — required on {@link MemoryJobStore} for
|
|
323
|
+
* tests and inspection; production adapters may omit it.
|
|
324
|
+
*/
|
|
325
|
+
list?(filter?: {
|
|
326
|
+
queue?: string;
|
|
327
|
+
status?: JobStatus | JobStatus[];
|
|
328
|
+
name?: string;
|
|
329
|
+
tenant?: string;
|
|
330
|
+
}): Promise<readonly Job[]> | readonly Job[];
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Exponential backoff with full jitter, in ms — the same math as webhook
|
|
334
|
+
* delivery: `min(max, base * 2^(attempt-1))` scaled by a uniform random
|
|
335
|
+
* factor in `[0, 1)`. Exported for deterministic tests (inject
|
|
336
|
+
* `random: () => 1` for the unfuzzed ceiling).
|
|
337
|
+
*
|
|
338
|
+
* @param attempt - The attempt number that just failed (1-based; attempts
|
|
339
|
+
* were already incremented at claim time).
|
|
340
|
+
* @param base - Base delay for the first retry, in ms.
|
|
341
|
+
* @param max - Upper bound on the un-jittered delay, in ms.
|
|
342
|
+
* @param random - Uniform source in `[0, 1)`.
|
|
343
|
+
* @returns The delay before the next attempt, in ms.
|
|
344
|
+
* @since 1.3.0
|
|
345
|
+
*/
|
|
346
|
+
export declare function computeBackoffMs(attempt: number, base: number, max: number, random: () => number): number;
|
|
347
|
+
/**
|
|
348
|
+
* Options for {@link MemoryJobStore}.
|
|
349
|
+
*
|
|
350
|
+
* @since 1.3.0
|
|
351
|
+
*/
|
|
352
|
+
export interface MemoryJobStoreOptions {
|
|
353
|
+
/**
|
|
354
|
+
* Maximum jobs held (all statuses). Default `10_000`. On overflow of new
|
|
355
|
+
* puts the store first sweeps terminal records past their retention, then
|
|
356
|
+
* throws {@link JobConfigError} (`store_full`) — evicting queued work
|
|
357
|
+
* would be silent data loss.
|
|
358
|
+
*/
|
|
359
|
+
capacity?: number;
|
|
360
|
+
/** Injectable clock (ms since epoch). Default {@link Date.now}. */
|
|
361
|
+
now?: () => number;
|
|
362
|
+
/**
|
|
363
|
+
* How long `completed` / `cancelled` records are retained after their last
|
|
364
|
+
* update, in ms. Default 24h. Terminal records are swept lazily on
|
|
365
|
+
* mutating operations.
|
|
366
|
+
*/
|
|
367
|
+
retentionMs?: number;
|
|
368
|
+
/** How long `dead` records are retained for inspection, in ms. Default 7d. */
|
|
369
|
+
deadRetentionMs?: number;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* In-memory {@link JobStore}. Correct for tests and single-process apps —
|
|
373
|
+
* a full implementation of the SPI, not a fake. **Not durable across
|
|
374
|
+
* processes** and invisible to other replicas: production deployments must
|
|
375
|
+
* supply a shared store (Redis, Postgres, SQS) through the same interface.
|
|
376
|
+
* `app.useJobs()` warns when it sees this store with production config.
|
|
377
|
+
*
|
|
378
|
+
* Payloads are held serialized and re-parsed with the prototype-pollution-safe
|
|
379
|
+
* `safeJsonParse` on every read, and every read returns a deep copy, so
|
|
380
|
+
* callers can never mutate store state.
|
|
381
|
+
*
|
|
382
|
+
* @since 1.3.0
|
|
383
|
+
*/
|
|
384
|
+
export declare class MemoryJobStore implements JobStore {
|
|
385
|
+
#private;
|
|
386
|
+
/**
|
|
387
|
+
* @param opts - Capacity, clock, and retention knobs.
|
|
388
|
+
* @throws {RangeError} when a numeric option is out of bounds.
|
|
389
|
+
*/
|
|
390
|
+
constructor(opts?: MemoryJobStoreOptions);
|
|
391
|
+
/** The number of jobs currently held (all statuses). */
|
|
392
|
+
get size(): number;
|
|
393
|
+
/**
|
|
394
|
+
* @inheritDoc
|
|
395
|
+
* `_fingerprint` is part of the {@link JobStore} contract but unused here:
|
|
396
|
+
* the in-memory store compares serialized payloads directly.
|
|
397
|
+
*/
|
|
398
|
+
put(job: Job, _fingerprint: string | null): EnqueueResult;
|
|
399
|
+
/** @inheritDoc */
|
|
400
|
+
claim(queue: string, workerId: string, now: number): Job | null;
|
|
401
|
+
/** @inheritDoc */
|
|
402
|
+
heartbeat(id: string, workerId: string, leaseUntil: number, now: number): boolean;
|
|
403
|
+
/** @inheritDoc */
|
|
404
|
+
complete(id: string, workerId: string, now: number, result: unknown | null): boolean;
|
|
405
|
+
/** @inheritDoc */
|
|
406
|
+
fail(id: string, workerId: string, error: string, next: {
|
|
407
|
+
status: "delayed" | "queued" | "dead";
|
|
408
|
+
runAt: number;
|
|
409
|
+
}, now: number): boolean;
|
|
410
|
+
/** @inheritDoc */
|
|
411
|
+
cancel(id: string, now: number): boolean;
|
|
412
|
+
/** @inheritDoc */
|
|
413
|
+
get(id: string, now: number): Job | null;
|
|
414
|
+
/** @inheritDoc */
|
|
415
|
+
list(filter?: {
|
|
416
|
+
queue?: string;
|
|
417
|
+
status?: JobStatus | JobStatus[];
|
|
418
|
+
name?: string;
|
|
419
|
+
tenant?: string;
|
|
420
|
+
}): Job[];
|
|
421
|
+
/** Test helper: every job, including terminal records. */
|
|
422
|
+
dump(): Job[];
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Context handed to a {@link JobHandler} on each attempt.
|
|
426
|
+
*
|
|
427
|
+
* @since 1.3.0
|
|
428
|
+
*/
|
|
429
|
+
export interface JobContext<P = unknown> {
|
|
430
|
+
/** The claimed job snapshot (status `running`, `attempts` already incremented). */
|
|
431
|
+
readonly job: Job<P>;
|
|
432
|
+
/**
|
|
433
|
+
* Aborted when the per-attempt `timeoutMs` elapses, when the worker stops
|
|
434
|
+
* out of grace, or when the lease is lost to another worker. Forward it to
|
|
435
|
+
* every I/O call the handler makes.
|
|
436
|
+
*/
|
|
437
|
+
readonly signal: AbortSignal;
|
|
438
|
+
/** 1-based attempt number (=== `job.attempts`). */
|
|
439
|
+
readonly attempt: number;
|
|
440
|
+
/**
|
|
441
|
+
* Extend the lease by another `leaseMs`. Long-running handlers should call
|
|
442
|
+
* this (or rely on the worker's automatic heartbeat every `leaseMs / 3`).
|
|
443
|
+
* If the lease is already lost, the handler's signal is aborted instead.
|
|
444
|
+
*/
|
|
445
|
+
heartbeat(): Promise<void>;
|
|
446
|
+
/** Structured logger pre-bound with job id / name / queue / attempt. */
|
|
447
|
+
readonly log: SchedulerLogger;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* A registered unit of work. Throw {@link JobFatalError} for permanent
|
|
451
|
+
* failures (no retry); any other throw — or a timeout — is retried with
|
|
452
|
+
* backoff until the attempt budget is spent, then the job is dead-lettered.
|
|
453
|
+
* The return value, when not `undefined`, is stored as {@link Job.result}
|
|
454
|
+
* under the same plain-JSON rules and size cap as payloads.
|
|
455
|
+
*
|
|
456
|
+
* Handlers must be idempotent: delivery is at-least-once. Pass an
|
|
457
|
+
* idempotency key through to downstream APIs (e.g. Stripe's
|
|
458
|
+
* `Idempotency-Key`) when a duplicate run would move money or send email.
|
|
459
|
+
*
|
|
460
|
+
* @since 1.3.0
|
|
461
|
+
*/
|
|
462
|
+
export type JobHandler<P = unknown> = (ctx: JobContext<P>) => unknown | Promise<unknown>;
|
|
463
|
+
/**
|
|
464
|
+
* A handler registry: job name to handler. Closed at
|
|
465
|
+
* {@link createJobWorker} time — there is deliberately no dynamic
|
|
466
|
+
* registration and no `import(job.name)`, so a store record can never pick
|
|
467
|
+
* the code that runs it. Typed as `unknown` payload per entry; a
|
|
468
|
+
* heterogeneous map cannot unify payload types, so annotate each handler's
|
|
469
|
+
* `ctx` as {@link JobContext}`<YourPayload>` for narrowing.
|
|
470
|
+
*
|
|
471
|
+
* @since 1.3.0
|
|
472
|
+
*/
|
|
473
|
+
export type JobHandlerMap = {
|
|
474
|
+
[name: string]: JobHandler<unknown>;
|
|
475
|
+
};
|
|
476
|
+
/**
|
|
477
|
+
* Backoff policy shared by queue and worker.
|
|
478
|
+
*
|
|
479
|
+
* @since 1.3.0
|
|
480
|
+
*/
|
|
481
|
+
export interface JobBackoffOptions {
|
|
482
|
+
/** Base delay for the first retry, in ms. Default `200`. */
|
|
483
|
+
baseDelayMs: number;
|
|
484
|
+
/** Upper bound on the un-jittered delay, in ms. Default `60_000`. */
|
|
485
|
+
maxDelayMs: number;
|
|
486
|
+
/** Uniform jitter source in `[0, 1)`. Default {@link Math.random}. */
|
|
487
|
+
random: () => number;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Options for {@link createJobQueue}. Only `store` is required.
|
|
491
|
+
*
|
|
492
|
+
* @since 1.3.0
|
|
493
|
+
*/
|
|
494
|
+
export interface JobQueueOptions {
|
|
495
|
+
/** Persistence backend. {@link MemoryJobStore} for tests / single process. */
|
|
496
|
+
store: JobStore;
|
|
497
|
+
/** Structured logger for queue events. */
|
|
498
|
+
logger?: SchedulerLogger;
|
|
499
|
+
/** Injectable clock (ms since epoch). Default {@link Date.now}. */
|
|
500
|
+
now?: () => number;
|
|
501
|
+
/**
|
|
502
|
+
* Maximum UTF-8 bytes of the serialized payload (and of a completion
|
|
503
|
+
* result). Default `65536` (64 KiB) — jobs are not a blob store.
|
|
504
|
+
*/
|
|
505
|
+
payloadMaxBytes?: number;
|
|
506
|
+
/** Partition used when {@link EnqueueOptions.queue} is omitted. Default `"default"`. */
|
|
507
|
+
defaultQueue?: string;
|
|
508
|
+
/** Default for {@link EnqueueOptions.maxAttempts}. Default `5`. */
|
|
509
|
+
defaultMaxAttempts?: number;
|
|
510
|
+
/** Default for {@link EnqueueOptions.timeoutMs}. Default `30_000`. */
|
|
511
|
+
defaultTimeoutMs?: number;
|
|
512
|
+
/** Default for {@link EnqueueOptions.leaseMs}. Default `30_000`. */
|
|
513
|
+
defaultLeaseMs?: number;
|
|
514
|
+
/** Retry backoff policy; see {@link JobBackoffOptions}. */
|
|
515
|
+
backoff?: {
|
|
516
|
+
baseDelayMs?: number;
|
|
517
|
+
maxDelayMs?: number;
|
|
518
|
+
random?: () => number;
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* The producer façade: validate, serialize, fingerprint, enqueue, inspect,
|
|
523
|
+
* cancel. Split from {@link JobWorker} so HTTP replicas never poll. Safe to
|
|
524
|
+
* share across an entire process.
|
|
525
|
+
*
|
|
526
|
+
* @since 1.3.0
|
|
527
|
+
*/
|
|
528
|
+
export interface JobQueue {
|
|
529
|
+
/**
|
|
530
|
+
* Persist a job. Resolves with `duplicate: true` and the existing job when
|
|
531
|
+
* the idempotency key matches a prior enqueue of the same payload.
|
|
532
|
+
*
|
|
533
|
+
* @param opts - The job to create; see {@link EnqueueOptions}.
|
|
534
|
+
* @returns The created (or deduplicated) record.
|
|
535
|
+
* @throws {@link JobConfigError} on invalid names, options, non-JSON
|
|
536
|
+
* payloads, oversized payloads, or a full bounded store.
|
|
537
|
+
* @throws {@link JobIdempotencyConflictError} when the key was already used
|
|
538
|
+
* with a different payload.
|
|
539
|
+
*/
|
|
540
|
+
enqueue<P>(opts: EnqueueOptions<P>): Promise<EnqueueResult<P>>;
|
|
541
|
+
/** Read one job by id, or `null`. */
|
|
542
|
+
get(id: string): Promise<Job | null>;
|
|
543
|
+
/** Cancel a non-terminal job. Resolves `false` for terminal/unknown ids. */
|
|
544
|
+
cancel(id: string): Promise<boolean>;
|
|
545
|
+
/** The underlying store, exposed for adapters and test inspection. */
|
|
546
|
+
readonly store: JobStore;
|
|
547
|
+
/** Partition used when enqueue omits `queue`. */
|
|
548
|
+
readonly defaultQueue: string;
|
|
549
|
+
/** Serialized-payload (and result) byte cap. */
|
|
550
|
+
readonly payloadMaxBytes: number;
|
|
551
|
+
/** Resolved retry backoff policy (used by workers on failure). */
|
|
552
|
+
readonly backoff: JobBackoffOptions;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Create a {@link JobQueue} over a {@link JobStore}. Validates all options
|
|
556
|
+
* eagerly (fail-fast config): numeric bounds throw `RangeError`, bad names
|
|
557
|
+
* throw {@link JobConfigError}, a missing store throws
|
|
558
|
+
* {@link JobConfigError} (`store_required`).
|
|
559
|
+
*
|
|
560
|
+
* @param opts - Queue configuration; see {@link JobQueueOptions}.
|
|
561
|
+
* @returns The producer façade.
|
|
562
|
+
* @since 1.3.0
|
|
563
|
+
*/
|
|
564
|
+
export declare function createJobQueue(opts: JobQueueOptions): JobQueue;
|
|
565
|
+
/**
|
|
566
|
+
* Options for {@link createJobWorker}.
|
|
567
|
+
*
|
|
568
|
+
* @since 1.3.0
|
|
569
|
+
*/
|
|
570
|
+
export interface JobWorkerOptions {
|
|
571
|
+
/** The queue to claim from (provides the store and backoff policy). */
|
|
572
|
+
queue: JobQueue;
|
|
573
|
+
/**
|
|
574
|
+
* Handler registry, closed at construction. Keys must satisfy the job-name
|
|
575
|
+
* grammar; values are `JobHandler<any>` because a heterogeneous map cannot
|
|
576
|
+
* unify payload types — annotate each handler's ctx for narrowing.
|
|
577
|
+
*/
|
|
578
|
+
handlers: Record<string, JobHandler<any>>;
|
|
579
|
+
/** Partitions to claim from, in order. Default `[queue.defaultQueue]`. */
|
|
580
|
+
queues?: string[];
|
|
581
|
+
/** Maximum jobs run concurrently. Default `1`. */
|
|
582
|
+
concurrency?: number;
|
|
583
|
+
/** Idle poll cadence in ms. Default `200`. */
|
|
584
|
+
pollIntervalMs?: number;
|
|
585
|
+
/** Fencing identity written to `lockedBy`. Default `crypto.randomUUID()`. */
|
|
586
|
+
workerId?: string;
|
|
587
|
+
/** Structured logger for worker lifecycle and failure events. */
|
|
588
|
+
logger?: SchedulerLogger;
|
|
589
|
+
/** Injectable timer primitives (shared shape with the Scheduler). */
|
|
590
|
+
timers?: TimerFns;
|
|
591
|
+
/** Injectable clock (ms since epoch). Default {@link Date.now}. */
|
|
592
|
+
now?: () => number;
|
|
593
|
+
/** Called when a job dead-letters (fatal, or budget exhausted). */
|
|
594
|
+
onDead?: (job: Job, error: string) => void | Promise<void>;
|
|
595
|
+
/** Called when a job completes. */
|
|
596
|
+
onComplete?: (job: Job) => void | Promise<void>;
|
|
597
|
+
/** Called on every non-fatal failure, before the next state is applied. */
|
|
598
|
+
onFail?: (job: Job, error: string, willRetry: boolean, delayMs?: number) => void | Promise<void>;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* A claimed-job consumer: poll/claim/run/complete with leases, heartbeats,
|
|
602
|
+
* bounded retries, and graceful drain. Create it where a long-lived process
|
|
603
|
+
* exists (Node/Bun/Deno); on serverless isolates, enqueue only.
|
|
604
|
+
*
|
|
605
|
+
* @since 1.3.0
|
|
606
|
+
*/
|
|
607
|
+
export interface JobWorker {
|
|
608
|
+
/**
|
|
609
|
+
* Start the poll loop.
|
|
610
|
+
*
|
|
611
|
+
* @throws {@link JobConfigError} when the worker was already started (or
|
|
612
|
+
* was started and then stopped — a stopped worker is spent).
|
|
613
|
+
*/
|
|
614
|
+
start(): void;
|
|
615
|
+
/**
|
|
616
|
+
* Stop polling, wait up to `graceMs` for in-flight jobs to settle, then
|
|
617
|
+
* abort their signals and wait for them to unwind. Jobs aborted this way
|
|
618
|
+
* are failed back to the queue so another worker can pick them up.
|
|
619
|
+
* Idempotent.
|
|
620
|
+
*/
|
|
621
|
+
stop(graceMs?: number): Promise<void>;
|
|
622
|
+
/** Point-in-time worker state. */
|
|
623
|
+
getState(): {
|
|
624
|
+
running: boolean;
|
|
625
|
+
inFlight: number;
|
|
626
|
+
workerId: string;
|
|
627
|
+
};
|
|
628
|
+
/**
|
|
629
|
+
* Test helper: claim and run one available job to settlement, whether or
|
|
630
|
+
* not the loop is started. Resolves `false` when nothing was claimable.
|
|
631
|
+
*/
|
|
632
|
+
runOnce(): Promise<boolean>;
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Create a {@link JobWorker}. The handler registry is frozen into a
|
|
636
|
+
* null-prototype map at construction: unknown job names dead-letter as
|
|
637
|
+
* poison pills instead of spinning, and no code path resolves a handler by
|
|
638
|
+
* dynamic lookup or import.
|
|
639
|
+
*
|
|
640
|
+
* @param opts - Worker configuration; see {@link JobWorkerOptions}.
|
|
641
|
+
* @returns The worker.
|
|
642
|
+
* @throws {RangeError} on out-of-bounds numeric options.
|
|
643
|
+
* @throws {@link JobConfigError} on a missing/empty registry or invalid
|
|
644
|
+
* handler names.
|
|
645
|
+
* @since 1.3.0
|
|
646
|
+
*/
|
|
647
|
+
export declare function createJobWorker(opts: JobWorkerOptions): JobWorker;
|
|
648
|
+
/**
|
|
649
|
+
* Build a tenant-safe idempotency key of the form `t/{tenant}/{name}/{key}`
|
|
650
|
+
* (or `g/{name}/{key}` when no tenant applies). Segments are validated so a
|
|
651
|
+
* key can never smuggle whitespace/control characters, newlines, or `..`
|
|
652
|
+
* into store keys or log lines, and tenant ids follow the `tenancy()`
|
|
653
|
+
* grammar — one tenant's keys can never collide with another's.
|
|
654
|
+
*
|
|
655
|
+
* @param parts - `name` is the job name; `key` is the caller's dedupe id
|
|
656
|
+
* (user id, provider event id, order id, …); `tenant` scopes the key.
|
|
657
|
+
* @returns The composed key, at most 255 chars.
|
|
658
|
+
* @throws {@link JobConfigError} when a segment fails validation.
|
|
659
|
+
* @since 1.3.0
|
|
660
|
+
*/
|
|
661
|
+
export declare function jobIdempotencyKey(parts: {
|
|
662
|
+
tenant?: string;
|
|
663
|
+
name: string;
|
|
664
|
+
key: string;
|
|
665
|
+
}): string;
|