@zmdb/jobs 1.0.0-beta.1

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.
@@ -0,0 +1,763 @@
1
+ // Portable queue and worker state machines; providers own persistence.
2
+ export interface Clock {
3
+ now(): number;
4
+ sleep(ms: number, signal: AbortSignal): Promise<void>;
5
+ }
6
+
7
+ export interface JobEnqueue {
8
+ readonly id: string;
9
+ readonly name: string;
10
+ readonly payload: string;
11
+ readonly enqueuedAt: Date;
12
+ readonly availableAt: Date;
13
+ readonly dedupeKey?: string;
14
+ }
15
+ export type JobEnqueueResult =
16
+ | { readonly kind: 'inserted'; readonly jobId: string }
17
+ | { readonly kind: 'duplicate'; readonly jobId: string };
18
+ export interface JobEnqueuer {
19
+ enqueue(job: JobEnqueue): Promise<JobEnqueueResult>;
20
+ }
21
+ export interface JobCandidate {
22
+ readonly id: string;
23
+ readonly name: string;
24
+ readonly enqueuedAt: Date;
25
+ }
26
+ export interface ClaimedJob extends JobCandidate {
27
+ readonly payload: string;
28
+ readonly attempts: number;
29
+ readonly dedupeKey?: string;
30
+ readonly holder: string;
31
+ }
32
+ export type JobSettlement =
33
+ | {
34
+ readonly kind: 'done';
35
+ readonly jobId: string;
36
+ readonly holder: string;
37
+ readonly idempotencyKey: string;
38
+ readonly completedAt: Date;
39
+ }
40
+ | {
41
+ readonly kind: 'retry';
42
+ readonly jobId: string;
43
+ readonly holder: string;
44
+ readonly attempts: number;
45
+ readonly availableAt: Date;
46
+ readonly detail: string;
47
+ }
48
+ | {
49
+ readonly kind: 'dead';
50
+ readonly jobId: string;
51
+ readonly holder: string;
52
+ readonly attempts: number;
53
+ readonly reason: DeadReason;
54
+ readonly detail: string;
55
+ readonly deadAt: Date;
56
+ }
57
+ | { readonly kind: 'release'; readonly jobId: string; readonly holder: string; readonly availableAt: Date };
58
+ export interface JobStore extends JobEnqueuer {
59
+ candidates(options: { readonly now: Date; readonly limit: number }): Promise<readonly JobCandidate[]>;
60
+ claim(options: {
61
+ readonly ids: readonly string[];
62
+ readonly holder: string;
63
+ readonly now: Date;
64
+ readonly leaseUntil: Date;
65
+ }): Promise<readonly ClaimedJob[]>;
66
+ completed(key: string): Promise<boolean>;
67
+ settle(settlement: JobSettlement): Promise<boolean>;
68
+ listDead(options: { readonly limit: number; readonly reason?: DeadReason }): Promise<readonly DeadJob[]>;
69
+ replay(jobId: string, availableAt: Date): Promise<boolean>;
70
+ }
71
+ export interface JobStoreResource {
72
+ close(options?: { readonly graceMs: number }): void | Promise<void>;
73
+ }
74
+ export interface JobStoreMigration {
75
+ readonly version: number;
76
+ readonly name: string;
77
+ readonly up: string;
78
+ readonly down: string;
79
+ }
80
+
81
+ export type Backoff =
82
+ | { readonly kind: 'fixed'; readonly delayMs: number }
83
+ | { readonly kind: 'exponential'; readonly baseMs: number; readonly ceilingMs: number };
84
+
85
+ export interface RetryPolicy {
86
+ readonly attempts: number;
87
+ readonly backoff: Backoff;
88
+ }
89
+
90
+ export type DeadReason = 'invalid-payload' | 'unknown-name' | 'attempts-exhausted';
91
+
92
+ export type JobOutcome =
93
+ | { readonly kind: 'done' }
94
+ | { readonly kind: 'retry'; readonly afterMs: number }
95
+ | { readonly kind: 'dead'; readonly reason: DeadReason; readonly detail: string };
96
+
97
+ export interface JobContext {
98
+ readonly jobId: string;
99
+ readonly name: string;
100
+ readonly attempt: number;
101
+ readonly enqueuedAt: Date;
102
+ readonly idempotencyKey: string;
103
+ readonly signal: AbortSignal;
104
+ }
105
+
106
+ export interface JobHandler<M, K extends keyof M & string> {
107
+ readonly name: K;
108
+ readonly validate: (raw: unknown) => M[K];
109
+ handle(payload: M[K], ctx: JobContext): Promise<void>;
110
+ readonly concurrency?: number;
111
+ readonly timeoutMs?: number;
112
+ readonly retries?: RetryPolicy;
113
+ }
114
+
115
+ export type AnyJobHandler<M> = { readonly [K in keyof M & string]: JobHandler<M, K> }[keyof M & string];
116
+
117
+ export interface EnqueueOptions {
118
+ readonly delayMs?: number;
119
+ readonly dedupeKey?: string;
120
+ }
121
+
122
+ export interface Queue<M> {
123
+ enqueue<K extends keyof M & string>(name: K, payload: M[K], opts?: EnqueueOptions): Promise<string>;
124
+ enqueueInTransaction<K extends keyof M & string>(
125
+ tx: JobEnqueuer,
126
+ name: K,
127
+ payload: M[K],
128
+ opts?: EnqueueOptions,
129
+ ): Promise<string>;
130
+ }
131
+
132
+ export interface QueueOptions {
133
+ readonly store: JobStore;
134
+ readonly clock: Clock;
135
+ }
136
+
137
+ export interface DeadJob {
138
+ readonly jobId: string;
139
+ readonly name: string;
140
+ readonly payload: string;
141
+ readonly attempts: number;
142
+ readonly reason: DeadReason;
143
+ readonly detail: string;
144
+ readonly enqueuedAt: Date;
145
+ readonly deadAt: Date;
146
+ }
147
+
148
+ export interface WorkerOptions<M> {
149
+ readonly handlers: readonly AnyJobHandler<M>[];
150
+ readonly store: JobStore;
151
+ readonly clock: Clock;
152
+ readonly concurrency: number;
153
+ readonly graceMs: number;
154
+ readonly leaseMs: number;
155
+ readonly onDead: (job: DeadJob) => void | Promise<void>;
156
+ readonly onHandlerError: (ctx: JobContext, error: unknown) => void;
157
+ readonly timeoutMs?: number;
158
+ readonly retries?: RetryPolicy;
159
+ readonly batch?: number;
160
+ readonly idleMs?: number;
161
+ readonly maxIdleMs?: number;
162
+ }
163
+
164
+ export interface Worker {
165
+ runOnce(): Promise<RunReport>;
166
+ start(): void;
167
+ onShutdown(options?: { readonly graceMs: number }): Promise<void>;
168
+ listDead(opts: { readonly limit: number; readonly reason?: DeadReason }): Promise<readonly DeadJob[]>;
169
+ replay(jobId: string): Promise<boolean>;
170
+ }
171
+
172
+ export interface RunReport {
173
+ readonly claimed: number;
174
+ readonly done: number;
175
+ readonly retried: number;
176
+ readonly dead: number;
177
+ readonly skipped: number;
178
+ }
179
+
180
+ interface MutableReport {
181
+ claimed: number;
182
+ done: number;
183
+ retried: number;
184
+ dead: number;
185
+ skipped: number;
186
+ }
187
+
188
+ interface RuntimeHandler {
189
+ readonly name: string;
190
+ readonly concurrency?: number;
191
+ readonly timeoutMs?: number;
192
+ readonly retries?: RetryPolicy;
193
+ prepare(raw: unknown): (ctx: JobContext) => Promise<void>;
194
+ }
195
+
196
+ interface ActiveJob {
197
+ readonly row: ClaimedJob;
198
+ readonly controller: AbortController;
199
+ abandoned: boolean;
200
+ }
201
+
202
+ type HandlerSettlement = { readonly kind: 'resolved' } | { readonly kind: 'rejected'; readonly error: unknown };
203
+
204
+ type TimeoutSettlement = { readonly kind: 'timeout' } | { readonly kind: 'cancelled' };
205
+
206
+ const DEFAULT_TIMEOUT_MS = 30_000;
207
+ const DEFAULT_RETRIES: RetryPolicy = {
208
+ attempts: 5,
209
+ backoff: { kind: 'exponential', baseMs: 1000, ceilingMs: 300_000 },
210
+ };
211
+ const DEFAULT_BATCH = 100;
212
+ const DEFAULT_IDLE_MS = 1000;
213
+ const DEFAULT_MAX_IDLE_MS = 30_000;
214
+
215
+ function emptyReport(): MutableReport {
216
+ return { claimed: 0, done: 0, retried: 0, dead: 0, skipped: 0 };
217
+ }
218
+
219
+ function addReport(target: MutableReport, source: RunReport): void {
220
+ target.claimed += source.claimed;
221
+ target.done += source.done;
222
+ target.retried += source.retried;
223
+ target.dead += source.dead;
224
+ target.skipped += source.skipped;
225
+ }
226
+
227
+ function integer(name: string, value: number, minimum: number): void {
228
+ if (!Number.isSafeInteger(value) || value < minimum) {
229
+ throw new RangeError(`${name} must be a safe integer greater than or equal to ${minimum}`);
230
+ }
231
+ }
232
+
233
+ function duration(name: string, value: number, allowZero = false): void {
234
+ if (!Number.isFinite(value) || (allowZero ? value < 0 : value <= 0)) {
235
+ throw new RangeError(`${name} must be ${allowZero ? 'non-negative' : 'positive'} and finite`);
236
+ }
237
+ }
238
+
239
+ function validateRetryPolicy(name: string, policy: RetryPolicy): void {
240
+ integer(`${name}.attempts`, policy.attempts, 1);
241
+ if (policy.backoff.kind === 'fixed') {
242
+ duration(`${name}.backoff.delayMs`, policy.backoff.delayMs);
243
+ return;
244
+ }
245
+ duration(`${name}.backoff.baseMs`, policy.backoff.baseMs);
246
+ duration(`${name}.backoff.ceilingMs`, policy.backoff.ceilingMs);
247
+ if (policy.backoff.ceilingMs < policy.backoff.baseMs) {
248
+ throw new RangeError(`${name}.backoff.ceilingMs must be greater than or equal to baseMs`);
249
+ }
250
+ }
251
+
252
+ function runtimeHandler<M>(handler: AnyJobHandler<M>): RuntimeHandler {
253
+ // A mapped-union member is safe to widen internally: its validator and method
254
+ // came from the same member before this startup-built dispatch entry existed.
255
+ const broad: JobHandler<M, keyof M & string> = handler;
256
+ const runtime: RuntimeHandler = {
257
+ name: broad.name,
258
+ prepare(raw) {
259
+ const payload = broad.validate(raw);
260
+ return ctx => broad.handle(payload, ctx);
261
+ },
262
+ };
263
+ if (broad.concurrency !== undefined) Object.assign(runtime, { concurrency: broad.concurrency });
264
+ if (broad.timeoutMs !== undefined) Object.assign(runtime, { timeoutMs: broad.timeoutMs });
265
+ if (broad.retries !== undefined) Object.assign(runtime, { retries: broad.retries });
266
+ return runtime;
267
+ }
268
+
269
+ function errorDetail(error: unknown): string {
270
+ return error instanceof Error ? error.message : String(error);
271
+ }
272
+
273
+ function parsePayload(payload: string): unknown {
274
+ return JSON.parse(payload);
275
+ }
276
+
277
+ function jitter(policy: RetryPolicy, attempt: number): number {
278
+ const nominal =
279
+ policy.backoff.kind === 'fixed'
280
+ ? policy.backoff.delayMs
281
+ : Math.min(policy.backoff.ceilingMs, policy.backoff.baseMs * 2 ** (attempt - 1));
282
+ return nominal * (0.75 + Math.random() * 0.5);
283
+ }
284
+
285
+ async function wait(clock: Clock, ms: number, signal: AbortSignal): Promise<'elapsed' | 'aborted'> {
286
+ try {
287
+ await clock.sleep(ms, signal);
288
+ return 'elapsed';
289
+ } catch (error) {
290
+ if (signal.aborted) return 'aborted';
291
+ throw error;
292
+ }
293
+ }
294
+
295
+ class JobWorker<M> implements Worker {
296
+ readonly #handlers = new Map<string, RuntimeHandler>();
297
+ readonly #activeByHandler = new Map<string, number>();
298
+ readonly #active = new Map<string, ActiveJob>();
299
+ readonly #inFlight = new Map<string, Promise<RunReport>>();
300
+ readonly #claims = new Set<Promise<readonly ClaimedJob[]>>();
301
+ readonly #claimRequeues = new Set<Promise<void>>();
302
+ readonly #keyTails = new Map<string, Promise<void>>();
303
+ readonly #store: JobStore;
304
+ readonly #clock: Clock;
305
+ readonly #concurrency: number;
306
+ readonly #graceMs: number;
307
+ readonly #leaseMs: number;
308
+ readonly #onDead: WorkerOptions<M>['onDead'];
309
+ readonly #onHandlerError: WorkerOptions<M>['onHandlerError'];
310
+ readonly #timeoutMs: number;
311
+ readonly #retries: RetryPolicy;
312
+ readonly #batch: number;
313
+ readonly #idleMs: number;
314
+ readonly #maxIdleMs: number;
315
+ #stopping = false;
316
+ #started = false;
317
+ #idleAbort: AbortController | undefined;
318
+ #pass: Promise<RunReport> | undefined;
319
+ #shutdown: Promise<void> | undefined;
320
+
321
+ constructor(opts: WorkerOptions<M>) {
322
+ integer('concurrency', opts.concurrency, 1);
323
+ duration('graceMs', opts.graceMs, true);
324
+ duration('leaseMs', opts.leaseMs);
325
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
326
+ const retries = opts.retries ?? DEFAULT_RETRIES;
327
+ const batch = opts.batch ?? DEFAULT_BATCH;
328
+ const idleMs = opts.idleMs ?? DEFAULT_IDLE_MS;
329
+ const maxIdleMs = opts.maxIdleMs ?? DEFAULT_MAX_IDLE_MS;
330
+ duration('timeoutMs', timeoutMs);
331
+ if (opts.leaseMs <= timeoutMs) {
332
+ throw new RangeError('leaseMs must be greater than timeoutMs');
333
+ }
334
+ validateRetryPolicy('retries', retries);
335
+ integer('batch', batch, 1);
336
+ duration('idleMs', idleMs);
337
+ duration('maxIdleMs', maxIdleMs);
338
+ if (maxIdleMs < idleMs) throw new RangeError('maxIdleMs must be greater than or equal to idleMs');
339
+
340
+ this.#store = opts.store;
341
+ this.#clock = opts.clock;
342
+ this.#concurrency = opts.concurrency;
343
+ this.#graceMs = opts.graceMs;
344
+ this.#leaseMs = opts.leaseMs;
345
+ this.#onDead = opts.onDead;
346
+ this.#onHandlerError = opts.onHandlerError;
347
+ this.#timeoutMs = timeoutMs;
348
+ this.#retries = retries;
349
+ this.#batch = batch;
350
+ this.#idleMs = idleMs;
351
+ this.#maxIdleMs = maxIdleMs;
352
+
353
+ for (const declared of opts.handlers) {
354
+ const handler = runtimeHandler(declared);
355
+ if (this.#handlers.has(handler.name)) throw new Error(`duplicate queue handler: ${handler.name}`);
356
+ if (handler.concurrency !== undefined) {
357
+ integer(`${handler.name}.concurrency`, handler.concurrency, 1);
358
+ if (handler.concurrency > this.#concurrency) {
359
+ throw new RangeError(`${handler.name}.concurrency cannot exceed worker concurrency`);
360
+ }
361
+ }
362
+ if (handler.timeoutMs !== undefined) {
363
+ duration(`${handler.name}.timeoutMs`, handler.timeoutMs);
364
+ if (opts.leaseMs <= handler.timeoutMs) {
365
+ throw new RangeError(`leaseMs must be greater than ${handler.name}.timeoutMs`);
366
+ }
367
+ }
368
+ if (handler.retries !== undefined) validateRetryPolicy(`${handler.name}.retries`, handler.retries);
369
+ this.#handlers.set(handler.name, handler);
370
+ }
371
+ }
372
+
373
+ async runOnce(): Promise<RunReport> {
374
+ if (this.#pass !== undefined) return this.#pass;
375
+ const pass = this.#runPass();
376
+ this.#pass = pass;
377
+ try {
378
+ return await pass;
379
+ } finally {
380
+ if (this.#pass === pass) this.#pass = undefined;
381
+ }
382
+ }
383
+
384
+ async #runPass(): Promise<RunReport> {
385
+ if (this.#stopping) return emptyReport();
386
+ const capacity = this.#concurrency - this.#inFlight.size;
387
+ if (capacity <= 0) return emptyReport();
388
+
389
+ const claim = this.#claim(Math.min(this.#batch, capacity));
390
+ this.#claims.add(claim);
391
+ let rows: readonly ClaimedJob[];
392
+ try {
393
+ rows = await claim;
394
+ } finally {
395
+ this.#claims.delete(claim);
396
+ }
397
+ if (this.#stopping) {
398
+ const requeue = Promise.allSettled(rows.map(row => this.#requeueClaim(row))).then(() => undefined);
399
+ this.#claimRequeues.add(requeue);
400
+ try {
401
+ await requeue;
402
+ } finally {
403
+ this.#claimRequeues.delete(requeue);
404
+ }
405
+ return { ...emptyReport(), claimed: rows.length };
406
+ }
407
+
408
+ const report = emptyReport();
409
+ report.claimed = rows.length;
410
+ const settled = await Promise.all(rows.map(row => this.#startClaimed(row)));
411
+ for (const outcome of settled) addReport(report, outcome);
412
+ return report;
413
+ }
414
+
415
+ start(): void {
416
+ if (this.#started || this.#stopping) return;
417
+ this.#started = true;
418
+ void this.#loop().catch(() => {
419
+ this.#stopping = true;
420
+ });
421
+ }
422
+
423
+ onShutdown(options?: { readonly graceMs: number }): Promise<void> {
424
+ if (this.#shutdown !== undefined) return this.#shutdown;
425
+ const cap = options?.graceMs;
426
+ if (cap !== undefined) duration('graceMs', cap, true);
427
+ const graceMs = Math.min(this.#graceMs, cap ?? this.#graceMs);
428
+ this.#shutdown = this.#drain(graceMs);
429
+ return this.#shutdown;
430
+ }
431
+
432
+ async listDead(opts: { readonly limit: number; readonly reason?: DeadReason }): Promise<readonly DeadJob[]> {
433
+ return this.#store.listDead(opts);
434
+ }
435
+
436
+ async replay(jobId: string): Promise<boolean> {
437
+ return this.#store.replay(jobId, new Date(this.#clock.now()));
438
+ }
439
+
440
+ async #loop(): Promise<void> {
441
+ let idleMs = this.#idleMs;
442
+ while (!this.#stopping) {
443
+ const report = await this.runOnce();
444
+ if (this.#stopping) return;
445
+ if (report.claimed > 0) {
446
+ idleMs = this.#idleMs;
447
+ continue;
448
+ }
449
+ const controller = new AbortController();
450
+ this.#idleAbort = controller;
451
+ await wait(this.#clock, idleMs, controller.signal);
452
+ if (this.#idleAbort === controller) this.#idleAbort = undefined;
453
+ idleMs = Math.min(this.#maxIdleMs, idleMs * 2);
454
+ }
455
+ }
456
+
457
+ async #drain(graceMs: number): Promise<void> {
458
+ this.#stopping = true;
459
+ this.#idleAbort?.abort();
460
+ const current = [this.#pass, ...this.#claims, ...this.#claimRequeues, ...this.#inFlight.values()];
461
+ const graceAbort = new AbortController();
462
+ const settled = Promise.allSettled(current).then(() => 'settled');
463
+ const grace = graceMs === 0 ? Promise.resolve('elapsed') : wait(this.#clock, graceMs, graceAbort.signal);
464
+ const outcome = await Promise.race([settled, grace]);
465
+ graceAbort.abort();
466
+ if (outcome === 'settled') return;
467
+ const unfinished = [...this.#active.values()];
468
+ for (const active of unfinished) {
469
+ active.abandoned = true;
470
+ active.controller.abort();
471
+ }
472
+ // Release is observed even when a provider cannot finish before this deadline.
473
+ void Promise.allSettled(unfinished.map(active => this.#requeueClaim(active.row)));
474
+ }
475
+
476
+ async #claim(limit: number): Promise<readonly ClaimedJob[]> {
477
+ const now = new Date(this.#clock.now());
478
+ // A capped handler must not hide other names behind its queued candidates.
479
+ const scanLimit = [...this.#handlers.values()].some(handler => handler.concurrency !== undefined)
480
+ ? this.#batch
481
+ : limit;
482
+ const candidates = await this.#store.candidates({ now, limit: scanLimit });
483
+ const selected = this.#selectCandidates(candidates, limit);
484
+ if (selected.length === 0) return [];
485
+ return this.#store.claim({
486
+ ids: selected.map(candidate => candidate.id),
487
+ holder: globalThis.crypto.randomUUID(),
488
+ now,
489
+ leaseUntil: new Date(now.getTime() + this.#leaseMs),
490
+ });
491
+ }
492
+
493
+ #selectCandidates(rows: readonly JobCandidate[], limit: number): readonly JobCandidate[] {
494
+ const selected: JobCandidate[] = [];
495
+ const reserved = new Map<string, number>();
496
+ for (const row of rows) {
497
+ if (selected.length >= limit) break;
498
+ const { id, name } = row;
499
+ if (this.#active.has(id)) continue;
500
+ const handler = this.#handlers.get(name);
501
+ if (handler?.concurrency !== undefined) {
502
+ const used = (this.#activeByHandler.get(name) ?? 0) + (reserved.get(name) ?? 0);
503
+ if (used >= handler.concurrency) continue;
504
+ reserved.set(name, (reserved.get(name) ?? 0) + 1);
505
+ }
506
+ selected.push(row);
507
+ }
508
+ return selected;
509
+ }
510
+
511
+ #startClaimed(row: ClaimedJob): Promise<RunReport> {
512
+ const handler = this.#handlers.get(row.name);
513
+ const active: ActiveJob = { row, controller: new AbortController(), abandoned: false };
514
+ this.#active.set(row.id, active);
515
+ if (handler !== undefined) {
516
+ this.#activeByHandler.set(row.name, (this.#activeByHandler.get(row.name) ?? 0) + 1);
517
+ }
518
+ const work = this.#process(active, handler).finally(() => {
519
+ this.#active.delete(row.id);
520
+ this.#inFlight.delete(row.id);
521
+ if (handler !== undefined) {
522
+ const remaining = (this.#activeByHandler.get(row.name) ?? 1) - 1;
523
+ if (remaining === 0) this.#activeByHandler.delete(row.name);
524
+ else this.#activeByHandler.set(row.name, remaining);
525
+ }
526
+ });
527
+ this.#inFlight.set(row.id, work);
528
+ return work;
529
+ }
530
+
531
+ async #process(active: ActiveJob, handler: RuntimeHandler | undefined): Promise<RunReport> {
532
+ const key = active.row.dedupeKey ?? active.row.id;
533
+ const release = await this.#lockKey(key);
534
+ try {
535
+ if (active.abandoned) return emptyReport();
536
+ const completed = await this.#markerExists(key);
537
+ if (active.abandoned) return emptyReport();
538
+ if (completed) {
539
+ const done = await this.#markDone(active);
540
+ return { ...emptyReport(), done: done ? 1 : 0, skipped: 1 };
541
+ }
542
+
543
+ let raw: unknown;
544
+ try {
545
+ raw = parsePayload(active.row.payload);
546
+ } catch (error) {
547
+ return this.#markDead(active, 'invalid-payload', `${errorDetail(error)}: ${active.row.payload.slice(0, 200)}`);
548
+ }
549
+
550
+ if (handler === undefined) {
551
+ return this.#settleFailure(
552
+ active,
553
+ this.#retries,
554
+ `no handler registered for ${active.row.name}`,
555
+ 'unknown-name',
556
+ );
557
+ }
558
+
559
+ let prepared: (ctx: JobContext) => Promise<void>;
560
+ try {
561
+ prepared = handler.prepare(raw);
562
+ } catch (error) {
563
+ return this.#markDead(active, 'invalid-payload', errorDetail(error));
564
+ }
565
+
566
+ const ctx: JobContext = {
567
+ jobId: active.row.id,
568
+ name: active.row.name,
569
+ attempt: active.row.attempts + 1,
570
+ enqueuedAt: active.row.enqueuedAt,
571
+ idempotencyKey: key,
572
+ signal: active.controller.signal,
573
+ };
574
+ const timeoutMs = handler.timeoutMs ?? this.#timeoutMs;
575
+ const policy = handler.retries ?? this.#retries;
576
+ return this.#runHandler(active, prepared, ctx, timeoutMs, policy);
577
+ } finally {
578
+ release();
579
+ }
580
+ }
581
+
582
+ async #runHandler(
583
+ active: ActiveJob,
584
+ prepared: (ctx: JobContext) => Promise<void>,
585
+ ctx: JobContext,
586
+ timeoutMs: number,
587
+ policy: RetryPolicy,
588
+ ): Promise<RunReport> {
589
+ const timerAbort = new AbortController();
590
+ const handler: Promise<HandlerSettlement> = Promise.resolve()
591
+ .then(() => (active.abandoned ? undefined : prepared(ctx)))
592
+ .then(
593
+ () => ({ kind: 'resolved' }),
594
+ (error): HandlerSettlement => ({ kind: 'rejected', error }),
595
+ );
596
+ const timeout: Promise<TimeoutSettlement> = wait(this.#clock, timeoutMs, timerAbort.signal).then(result =>
597
+ result === 'elapsed' ? { kind: 'timeout' } : { kind: 'cancelled' },
598
+ );
599
+ const first = await Promise.race([handler, timeout]);
600
+
601
+ if (first.kind === 'timeout') {
602
+ active.controller.abort();
603
+ const error = new Error(`job ${ctx.name} timed out after ${timeoutMs}ms`);
604
+ this.#reportHandlerError(ctx, error);
605
+ const outcome = await this.#settleFailure(active, policy, error.message);
606
+ await handler;
607
+ return outcome;
608
+ }
609
+
610
+ timerAbort.abort();
611
+ if (active.abandoned) return emptyReport();
612
+ if (first.kind === 'rejected') {
613
+ this.#reportHandlerError(ctx, first.error);
614
+ return this.#settleFailure(active, policy, errorDetail(first.error));
615
+ }
616
+ const done = await this.#markDone(active);
617
+ return { ...emptyReport(), done: done ? 1 : 0, skipped: done ? 0 : 1 };
618
+ }
619
+
620
+ #reportHandlerError(ctx: JobContext, error: unknown): void {
621
+ try {
622
+ this.#onHandlerError(ctx, error);
623
+ } catch {
624
+ // The error sink is observational; it cannot change queue settlement.
625
+ }
626
+ }
627
+
628
+ async #settleFailure(
629
+ active: ActiveJob,
630
+ policy: RetryPolicy,
631
+ detail: string,
632
+ terminalReason: DeadReason = 'attempts-exhausted',
633
+ ): Promise<RunReport> {
634
+ const attempt = active.row.attempts + 1;
635
+ if (attempt >= policy.attempts) return this.#markDead(active, terminalReason, detail);
636
+ if (active.abandoned) return emptyReport();
637
+
638
+ const settled = await this.#store.settle({
639
+ kind: 'retry',
640
+ jobId: active.row.id,
641
+ holder: active.row.holder,
642
+ attempts: attempt,
643
+ availableAt: new Date(this.#clock.now() + jitter(policy, attempt)),
644
+ detail,
645
+ });
646
+ return { ...emptyReport(), retried: settled ? 1 : 0, skipped: settled ? 0 : 1 };
647
+ }
648
+
649
+ async #markDone(active: ActiveJob): Promise<boolean> {
650
+ if (active.abandoned) return false;
651
+ return this.#store.settle({
652
+ kind: 'done',
653
+ jobId: active.row.id,
654
+ holder: active.row.holder,
655
+ idempotencyKey: active.row.dedupeKey ?? active.row.id,
656
+ completedAt: new Date(this.#clock.now()),
657
+ });
658
+ }
659
+
660
+ async #markDead(active: ActiveJob, reason: DeadReason, detail: string): Promise<RunReport> {
661
+ if (active.abandoned) return emptyReport();
662
+ const deadAt = new Date(this.#clock.now());
663
+ const attempts = active.row.attempts + 1;
664
+ const settled = await this.#store.settle({
665
+ kind: 'dead',
666
+ jobId: active.row.id,
667
+ holder: active.row.holder,
668
+ attempts,
669
+ reason,
670
+ detail,
671
+ deadAt,
672
+ });
673
+ if (!settled) return { ...emptyReport(), skipped: 1 };
674
+ await this.#onDead({
675
+ jobId: active.row.id,
676
+ name: active.row.name,
677
+ payload: active.row.payload,
678
+ attempts,
679
+ reason,
680
+ detail,
681
+ enqueuedAt: active.row.enqueuedAt,
682
+ deadAt,
683
+ });
684
+ return { ...emptyReport(), dead: 1 };
685
+ }
686
+
687
+ async #markerExists(key: string): Promise<boolean> {
688
+ return this.#store.completed(key);
689
+ }
690
+
691
+ async #lockKey(key: string): Promise<() => void> {
692
+ let release = (): void => undefined;
693
+ const current = new Promise<void>(resolve => {
694
+ release = resolve;
695
+ });
696
+ const previous = this.#keyTails.get(key);
697
+ this.#keyTails.set(key, current);
698
+ if (previous !== undefined) await previous;
699
+ return () => {
700
+ release();
701
+ if (this.#keyTails.get(key) === current) this.#keyTails.delete(key);
702
+ };
703
+ }
704
+
705
+ async #requeueClaim(row: ClaimedJob): Promise<void> {
706
+ await this.#store.settle({
707
+ kind: 'release',
708
+ jobId: row.id,
709
+ holder: row.holder,
710
+ availableAt: new Date(this.#clock.now()),
711
+ });
712
+ }
713
+ }
714
+
715
+ class JobQueue<M> implements Queue<M> {
716
+ readonly #store: JobStore;
717
+ readonly #clock: Clock;
718
+ constructor(opts: QueueOptions) {
719
+ this.#store = opts.store;
720
+ this.#clock = opts.clock;
721
+ }
722
+ enqueue<K extends keyof M & string>(name: K, payload: M[K], opts?: EnqueueOptions): Promise<string> {
723
+ return this.#enqueue(this.#store, name, payload, opts);
724
+ }
725
+ enqueueInTransaction<K extends keyof M & string>(
726
+ tx: JobEnqueuer,
727
+ name: K,
728
+ payload: M[K],
729
+ opts?: EnqueueOptions,
730
+ ): Promise<string> {
731
+ return this.#enqueue(tx, name, payload, opts);
732
+ }
733
+ async #enqueue<K extends keyof M & string>(
734
+ store: JobEnqueuer,
735
+ name: K,
736
+ payload: M[K],
737
+ opts?: EnqueueOptions,
738
+ ): Promise<string> {
739
+ const delayMs = opts?.delayMs ?? 0;
740
+ duration('delayMs', delayMs, true);
741
+ const encoded = JSON.stringify(payload);
742
+ if (encoded === undefined) throw new TypeError(`job ${name} payload is not JSON-serializable`);
743
+ const now = this.#clock.now();
744
+ const result = await store.enqueue({
745
+ id: globalThis.crypto.randomUUID(),
746
+ name,
747
+ payload: encoded,
748
+ enqueuedAt: new Date(now),
749
+ availableAt: new Date(now + delayMs),
750
+ ...(opts?.dedupeKey === undefined ? {} : { dedupeKey: opts.dedupeKey }),
751
+ });
752
+ return result.jobId;
753
+ }
754
+ }
755
+
756
+ export function createQueue<M>(opts: QueueOptions): Queue<M> {
757
+ if (opts?.store === undefined) throw new TypeError('@zmdb/jobs: a store is required');
758
+ return new JobQueue<M>(opts);
759
+ }
760
+ export function createWorker<M>(opts: WorkerOptions<M>): Worker {
761
+ if (opts?.store === undefined) throw new TypeError('@zmdb/jobs: a store is required');
762
+ return new JobWorker(opts);
763
+ }