@boringnode/queue 0.0.1-alpha.3 → 0.1.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/build/index.js CHANGED
@@ -1,116 +1,29 @@
1
1
  import {
2
- E_CONFIGURATION_ERROR,
2
+ parse
3
+ } from "./chunk-NPQKBCCY.js";
4
+ import {
5
+ Locator,
6
+ QueueManager,
7
+ debug_default
8
+ } from "./chunk-US7THLSZ.js";
9
+ import {
10
+ DEFAULT_ERROR_RETRY_DELAY,
11
+ DEFAULT_IDLE_DELAY,
12
+ DEFAULT_PRIORITY,
13
+ DEFAULT_STALLED_INTERVAL,
14
+ DEFAULT_STALLED_THRESHOLD,
3
15
  E_INVALID_BASE_DELAY,
4
- E_INVALID_DURATION_EXPRESSION,
16
+ E_INVALID_CRON_EXPRESSION,
5
17
  E_INVALID_MAX_DELAY,
6
18
  E_INVALID_MULTIPLIER,
19
+ E_INVALID_SCHEDULE_CONFIG,
7
20
  E_JOB_MAX_ATTEMPTS_REACHED,
8
21
  E_JOB_TIMEOUT,
9
- Locator,
10
- debug_default
11
- } from "./chunk-Y6KR3UIR.js";
22
+ exceptions_exports
23
+ } from "./chunk-SMOKFZ46.js";
12
24
 
13
25
  // src/job_dispatcher.ts
14
26
  import { randomUUID } from "crypto";
15
-
16
- // src/queue_manager.ts
17
- var QueueManagerSingleton = class {
18
- #defaultAdapter;
19
- #adapters = {};
20
- #adapterInstances = /* @__PURE__ */ new Map();
21
- #globalRetryConfig;
22
- #queueConfigs = /* @__PURE__ */ new Map();
23
- async init(config) {
24
- debug_default("initializing queue manager with config: %O", config);
25
- this.#validateConfig(config);
26
- this.#adapterInstances.clear();
27
- this.#defaultAdapter = config.default;
28
- this.#adapters = config.adapters;
29
- this.#globalRetryConfig = config.retry;
30
- if (config.queues) {
31
- for (const [queue, queueConfig] of Object.entries(config.queues)) {
32
- this.#queueConfigs.set(queue, queueConfig);
33
- }
34
- }
35
- await Locator.registerFromGlob(config.locations);
36
- return this;
37
- }
38
- use(adapter) {
39
- if (!adapter) {
40
- adapter = this.#defaultAdapter;
41
- }
42
- const cached = this.#adapterInstances.get(adapter);
43
- if (cached) {
44
- return cached;
45
- }
46
- const adapterFactory = this.#adapters[adapter];
47
- if (!adapterFactory) {
48
- throw new E_CONFIGURATION_ERROR([`Adapter "${adapter}" is not registered`]);
49
- }
50
- debug_default('using adapter "%s"', adapter);
51
- try {
52
- const instance = adapterFactory();
53
- this.#adapterInstances.set(adapter, instance);
54
- return instance;
55
- } catch (error) {
56
- throw new Error();
57
- }
58
- }
59
- /**
60
- * Priority: job > queue > global
61
- */
62
- getMergedRetryConfig(queue, jobRetryConfig) {
63
- const queueConfig = this.#queueConfigs.get(queue);
64
- const queueRetryConfig = queueConfig?.retry || {};
65
- let maxRetries = jobRetryConfig?.maxRetries || queueRetryConfig.maxRetries || this.#globalRetryConfig?.maxRetries || 0;
66
- let backoff = jobRetryConfig?.backoff || queueRetryConfig.backoff || this.#globalRetryConfig?.backoff;
67
- return { maxRetries, backoff };
68
- }
69
- #validateConfig(config) {
70
- if (!config.adapters || Object.keys(config.adapters).length === 0) {
71
- throw new E_CONFIGURATION_ERROR(["At least one adapter must be configured"]);
72
- }
73
- if (!config.default) {
74
- throw new E_CONFIGURATION_ERROR(["Default adapter must be specified"]);
75
- }
76
- if (!config.locations || config.locations.length === 0) {
77
- throw new E_CONFIGURATION_ERROR(["Job locations must be specified"]);
78
- }
79
- if (!config.adapters[config.default]) {
80
- throw new E_CONFIGURATION_ERROR([
81
- `Default adapter "${config.default}" not found in adapters configuration`
82
- ]);
83
- }
84
- for (const [name, factory] of Object.entries(config.adapters)) {
85
- if (typeof factory !== "function") {
86
- throw new E_CONFIGURATION_ERROR([`Adapter "${name}" must be a factory function`]);
87
- }
88
- }
89
- }
90
- async destroy() {
91
- for (const [name, adapter] of this.#adapterInstances) {
92
- debug_default('destroying adapter "%s"', name);
93
- await adapter.destroy();
94
- }
95
- this.#adapterInstances.clear();
96
- }
97
- };
98
- var QueueManager = new QueueManagerSingleton();
99
-
100
- // src/utils.ts
101
- import { parse as parseDuration } from "@lukeed/ms";
102
- function parse(duration) {
103
- if (typeof duration === "number") {
104
- return duration;
105
- }
106
- const milliseconds = parseDuration(duration);
107
- if (typeof milliseconds === "undefined") {
108
- throw new E_INVALID_DURATION_EXPRESSION([duration]);
109
- }
110
- return milliseconds;
111
- }
112
-
113
- // src/job_dispatcher.ts
114
27
  var JobDispatcher = class {
115
28
  #name;
116
29
  #payload;
@@ -118,26 +31,105 @@ var JobDispatcher = class {
118
31
  #adapter;
119
32
  #delay;
120
33
  #priority;
34
+ /**
35
+ * Create a new job dispatcher.
36
+ *
37
+ * @param name - The job class name (used to locate the class at runtime)
38
+ * @param payload - The data to pass to the job
39
+ */
121
40
  constructor(name, payload) {
122
41
  this.#name = name;
123
42
  this.#payload = payload;
124
43
  }
44
+ /**
45
+ * Set the target queue for this job.
46
+ *
47
+ * @param queue - Queue name (default: 'default')
48
+ * @returns This dispatcher for chaining
49
+ *
50
+ * @example
51
+ * ```typescript
52
+ * await SendEmailJob.dispatch(payload).toQueue('emails')
53
+ * ```
54
+ */
125
55
  toQueue(queue) {
126
56
  this.#queue = queue;
127
57
  return this;
128
58
  }
59
+ /**
60
+ * Delay the job execution.
61
+ *
62
+ * The job will be stored in a delayed state and moved to pending
63
+ * after the delay expires.
64
+ *
65
+ * @param delay - Delay as milliseconds or duration string ('5s', '1h', '7d')
66
+ * @returns This dispatcher for chaining
67
+ *
68
+ * @example
69
+ * ```typescript
70
+ * // Send reminder in 24 hours
71
+ * await ReminderJob.dispatch(payload).in('24h')
72
+ *
73
+ * // Process in 5 minutes
74
+ * await CleanupJob.dispatch(payload).in('5m')
75
+ * ```
76
+ */
129
77
  in(delay) {
130
78
  this.#delay = delay;
131
79
  return this;
132
80
  }
81
+ /**
82
+ * Set the job priority.
83
+ *
84
+ * Lower numbers = higher priority. Jobs with lower priority values
85
+ * are processed before jobs with higher values.
86
+ *
87
+ * @param priority - Priority level (1-10, default: 5)
88
+ * @returns This dispatcher for chaining
89
+ *
90
+ * @example
91
+ * ```typescript
92
+ * // High priority job
93
+ * await UrgentJob.dispatch(payload).priority(1)
94
+ *
95
+ * // Low priority job
96
+ * await BackgroundJob.dispatch(payload).priority(10)
97
+ * ```
98
+ */
133
99
  priority(priority) {
134
100
  this.#priority = priority;
135
101
  return this;
136
102
  }
103
+ /**
104
+ * Use a specific adapter for this job.
105
+ *
106
+ * @param adapter - Adapter name or factory function
107
+ * @returns This dispatcher for chaining
108
+ *
109
+ * @example
110
+ * ```typescript
111
+ * // Use named adapter
112
+ * await Job.dispatch(payload).with('redis')
113
+ *
114
+ * // Use custom adapter instance
115
+ * await Job.dispatch(payload).with(() => new CustomAdapter())
116
+ * ```
117
+ */
137
118
  with(adapter) {
138
119
  this.#adapter = adapter;
139
120
  return this;
140
121
  }
122
+ /**
123
+ * Dispatch the job to the queue.
124
+ *
125
+ * @returns A DispatchResult containing the jobId
126
+ *
127
+ * @example
128
+ * ```typescript
129
+ * const { jobId } = await SendEmailJob.dispatch(payload).run()
130
+ * console.log(`Dispatched job: ${jobId}`)
131
+ * ```
132
+ */
141
133
  async run() {
142
134
  const id = randomUUID();
143
135
  debug_default("dispatching job %s with id %s using payload %s", this.#name, id, this.#payload);
@@ -155,8 +147,19 @@ var JobDispatcher = class {
155
147
  } else {
156
148
  await adapter.pushOn(this.#queue, payload);
157
149
  }
158
- return id;
150
+ return {
151
+ jobId: id
152
+ };
159
153
  }
154
+ /**
155
+ * Thenable implementation for auto-dispatch when awaited.
156
+ *
157
+ * Allows `await Job.dispatch(payload)` without explicit `.run()`.
158
+ *
159
+ * @param onFulfilled - Success callback
160
+ * @param onRejected - Error callback
161
+ * @returns Promise resolving to the DispatchResult
162
+ */
160
163
  then(onFulfilled, onRejected) {
161
164
  return this.run().then(onFulfilled, onRejected);
162
165
  }
@@ -171,16 +174,221 @@ var JobDispatcher = class {
171
174
  }
172
175
  };
173
176
 
177
+ // src/schedule_builder.ts
178
+ import { CronExpressionParser } from "cron-parser";
179
+ var ScheduleBuilder = class {
180
+ #jobName;
181
+ #payload;
182
+ #id;
183
+ #cronExpression;
184
+ #everyMs;
185
+ #timezone = "UTC";
186
+ #from;
187
+ #to;
188
+ #limit;
189
+ constructor(jobName, payload) {
190
+ this.#jobName = jobName;
191
+ this.#payload = payload;
192
+ }
193
+ /**
194
+ * Set a custom schedule ID.
195
+ * If not specified, defaults to the job name.
196
+ * If a schedule with this ID exists, it will be updated (upsert).
197
+ */
198
+ id(scheduleId) {
199
+ this.#id = scheduleId;
200
+ return this;
201
+ }
202
+ /**
203
+ * Set a cron expression for the schedule.
204
+ * Mutually exclusive with `every()`.
205
+ */
206
+ cron(expression) {
207
+ this.#cronExpression = expression;
208
+ return this;
209
+ }
210
+ /**
211
+ * Set a repeating interval for the schedule.
212
+ * Mutually exclusive with `cron()`.
213
+ */
214
+ every(interval) {
215
+ this.#everyMs = parse(interval);
216
+ return this;
217
+ }
218
+ /**
219
+ * Set the timezone for cron evaluation.
220
+ * @default 'UTC'
221
+ */
222
+ timezone(tz) {
223
+ this.#timezone = tz;
224
+ return this;
225
+ }
226
+ /**
227
+ * Set the start boundary for the schedule.
228
+ * No jobs will be dispatched before this date.
229
+ */
230
+ from(date) {
231
+ this.#from = date;
232
+ return this;
233
+ }
234
+ /**
235
+ * Set the end boundary for the schedule.
236
+ * No jobs will be dispatched after this date.
237
+ */
238
+ to(date) {
239
+ this.#to = date;
240
+ return this;
241
+ }
242
+ /**
243
+ * Set both start and end boundaries for the schedule.
244
+ * Shorthand for `.from(start).to(end)`.
245
+ */
246
+ between(from, to) {
247
+ return this.from(from).to(to);
248
+ }
249
+ /**
250
+ * Set the maximum number of runs for this schedule.
251
+ */
252
+ limit(maxRuns) {
253
+ this.#limit = maxRuns;
254
+ return this;
255
+ }
256
+ /**
257
+ * Create the schedule and return the schedule ID.
258
+ */
259
+ async run() {
260
+ if (!this.#cronExpression && !this.#everyMs) {
261
+ throw new E_INVALID_SCHEDULE_CONFIG([
262
+ "Schedule must have either a cron expression or an interval"
263
+ ]);
264
+ }
265
+ if (this.#cronExpression && this.#everyMs) {
266
+ throw new E_INVALID_SCHEDULE_CONFIG([
267
+ "Schedule cannot have both a cron expression and an interval"
268
+ ]);
269
+ }
270
+ if (this.#cronExpression) {
271
+ try {
272
+ CronExpressionParser.parse(this.#cronExpression, { tz: this.#timezone });
273
+ } catch (error) {
274
+ throw new E_INVALID_CRON_EXPRESSION([this.#cronExpression, error.message]);
275
+ }
276
+ }
277
+ const config = {
278
+ id: this.#id ?? this.#jobName,
279
+ jobName: this.#jobName,
280
+ payload: this.#payload,
281
+ cronExpression: this.#cronExpression,
282
+ everyMs: this.#everyMs,
283
+ timezone: this.#timezone,
284
+ from: this.#from,
285
+ to: this.#to,
286
+ limit: this.#limit
287
+ };
288
+ const adapter = QueueManager.use();
289
+ const scheduleId = await adapter.createSchedule(config);
290
+ const nextRunAt = this.#calculateNextRunAt();
291
+ await adapter.updateSchedule(scheduleId, { nextRunAt });
292
+ return { scheduleId };
293
+ }
294
+ /**
295
+ * Calculate the next run time based on cron or interval.
296
+ */
297
+ #calculateNextRunAt() {
298
+ const now = /* @__PURE__ */ new Date();
299
+ let nextRun;
300
+ if (this.#cronExpression) {
301
+ const cron = CronExpressionParser.parse(this.#cronExpression, {
302
+ currentDate: now,
303
+ tz: this.#timezone
304
+ });
305
+ nextRun = cron.next().toDate();
306
+ } else {
307
+ nextRun = new Date(now.getTime() + this.#everyMs);
308
+ }
309
+ if (this.#from && nextRun < this.#from) {
310
+ if (this.#cronExpression) {
311
+ const cron = CronExpressionParser.parse(this.#cronExpression, {
312
+ currentDate: this.#from,
313
+ tz: this.#timezone
314
+ });
315
+ nextRun = cron.next().toDate();
316
+ } else {
317
+ nextRun = this.#from;
318
+ }
319
+ }
320
+ return nextRun;
321
+ }
322
+ /**
323
+ * Implement PromiseLike to allow `await builder.every('5m')` syntax.
324
+ */
325
+ then(onfulfilled, onrejected) {
326
+ return this.run().then(onfulfilled, onrejected);
327
+ }
328
+ };
329
+
174
330
  // src/job.ts
175
331
  var Job = class {
176
332
  #payload;
333
+ #context;
334
+ /** Static options for this job class (queue, retries, timeout, etc.) */
177
335
  static options = {};
336
+ /** The payload data passed to this job instance */
178
337
  get payload() {
179
338
  return this.#payload;
180
339
  }
181
- constructor(payload) {
340
+ /**
341
+ * Context information for the current job execution.
342
+ *
343
+ * Provides metadata such as job ID, current attempt number,
344
+ * queue name, priority, and timing information.
345
+ *
346
+ * @example
347
+ * ```typescript
348
+ * async execute() {
349
+ * if (this.context.attempt > 1) {
350
+ * console.log(`Retry attempt ${this.context.attempt}`)
351
+ * }
352
+ * console.log(`Processing job ${this.context.jobId} on queue ${this.context.queue}`)
353
+ * }
354
+ * ```
355
+ */
356
+ get context() {
357
+ return this.#context;
358
+ }
359
+ /**
360
+ * Create a new job instance.
361
+ *
362
+ * @param payload - The data to be processed by this job
363
+ * @param context - The job execution context (provided by the worker)
364
+ */
365
+ constructor(payload, context) {
182
366
  this.#payload = payload;
367
+ this.#context = Object.freeze(context);
183
368
  }
369
+ /**
370
+ * Dispatch this job to the queue.
371
+ *
372
+ * Returns a JobDispatcher for fluent configuration before dispatching.
373
+ * The job is not actually dispatched until `.run()` is called or the
374
+ * dispatcher is awaited.
375
+ *
376
+ * @param payload - The data to pass to the job
377
+ * @returns A JobDispatcher for fluent configuration
378
+ *
379
+ * @example
380
+ * ```typescript
381
+ * // Simple dispatch
382
+ * await SendEmailJob.dispatch({ to: 'user@example.com', subject: 'Hello' })
383
+ *
384
+ * // With options
385
+ * await SendEmailJob.dispatch({ to: 'user@example.com' })
386
+ * .toQueue('high-priority')
387
+ * .priority(1)
388
+ * .in('5m')
389
+ * .run()
390
+ * ```
391
+ */
184
392
  static dispatch(payload) {
185
393
  const dispatcher = new JobDispatcher(
186
394
  this.jobName,
@@ -197,6 +405,34 @@ var Job = class {
197
405
  }
198
406
  return dispatcher;
199
407
  }
408
+ /**
409
+ * Create a schedule for this job.
410
+ *
411
+ * Returns a ScheduleBuilder for fluent configuration before creating the schedule.
412
+ * The schedule is not actually created until `.run()` is called or the
413
+ * builder is awaited.
414
+ *
415
+ * @param payload - The data to pass to the job on each run
416
+ * @returns A ScheduleBuilder for fluent configuration
417
+ *
418
+ * @example
419
+ * ```typescript
420
+ * // Cron schedule
421
+ * await CleanupJob.schedule({ days: 30 })
422
+ * .id('cleanup-daily')
423
+ * .cron('0 0 * * *')
424
+ * .timezone('Europe/Paris')
425
+ * .run()
426
+ *
427
+ * // Interval schedule
428
+ * await SyncJob.schedule({ source: 'api' })
429
+ * .every('5m')
430
+ * .run()
431
+ * ```
432
+ */
433
+ static schedule(payload) {
434
+ return new ScheduleBuilder(this.jobName, payload);
435
+ }
200
436
  };
201
437
 
202
438
  // src/worker.ts
@@ -206,18 +442,45 @@ import { setTimeout } from "timers/promises";
206
442
  // src/job_pool.ts
207
443
  var JobPool = class {
208
444
  #activeJobs = /* @__PURE__ */ new Map();
445
+ /** Number of currently running jobs */
209
446
  get size() {
210
447
  return this.#activeJobs.size;
211
448
  }
449
+ /**
450
+ * Check if the pool has no running jobs.
451
+ *
452
+ * @returns True if no jobs are running
453
+ */
212
454
  isEmpty() {
213
455
  return this.#activeJobs.size === 0;
214
456
  }
457
+ /**
458
+ * Check if the pool can accept more jobs.
459
+ *
460
+ * @param concurrency - Maximum number of concurrent jobs
461
+ * @returns True if there's room for more jobs
462
+ */
215
463
  hasCapacity(concurrency) {
216
464
  return this.#activeJobs.size < concurrency;
217
465
  }
466
+ /**
467
+ * Add a job to the pool.
468
+ *
469
+ * @param job - The acquired job data
470
+ * @param queue - The queue the job came from
471
+ * @param promise - Promise that resolves when the job completes
472
+ */
218
473
  add(job, queue, promise) {
219
474
  this.#activeJobs.set(job.id, { promise, job, queue });
220
475
  }
476
+ /**
477
+ * Wait for the next job to complete and return it.
478
+ *
479
+ * Uses `Promise.race()` internally, so the fastest job wins.
480
+ * The completed job is removed from the pool.
481
+ *
482
+ * @returns The first job to complete (success or failure)
483
+ */
221
484
  async waitForNextCompletion() {
222
485
  const completedJobId = await Promise.race(
223
486
  [...this.#activeJobs.entries()].map(async ([id, { promise }]) => {
@@ -232,6 +495,12 @@ var JobPool = class {
232
495
  this.#activeJobs.delete(completedJobId);
233
496
  return completed;
234
497
  }
498
+ /**
499
+ * Wait for all running jobs to complete.
500
+ *
501
+ * Used during graceful shutdown to ensure no jobs are abandoned.
502
+ * Clears the pool after all jobs finish.
503
+ */
235
504
  async drain() {
236
505
  const promises = [...this.#activeJobs.values()].map(async ({ promise }) => {
237
506
  try {
@@ -248,19 +517,46 @@ var JobPool = class {
248
517
  var Worker = class {
249
518
  #id;
250
519
  #config;
520
+ #idleDelay;
521
+ #stalledInterval;
522
+ #stalledThreshold;
523
+ #maxStalledCount;
524
+ #concurrency;
525
+ #gracefulShutdown;
526
+ #onShutdownSignal;
251
527
  #adapter;
252
528
  #running = false;
253
529
  #initialized = false;
254
530
  #generator;
255
531
  #pool;
532
+ #lastStalledCheck = 0;
533
+ #shutdownHandler;
534
+ /** Unique identifier for this worker instance */
256
535
  get id() {
257
536
  return this.#id;
258
537
  }
538
+ /**
539
+ * Create a new worker instance.
540
+ *
541
+ * @param config - Queue configuration including adapter and worker settings
542
+ */
259
543
  constructor(config) {
260
544
  this.#config = config;
261
545
  this.#id = randomUUID2();
546
+ this.#idleDelay = parse(config.worker?.idleDelay ?? DEFAULT_IDLE_DELAY);
547
+ this.#stalledInterval = parse(config.worker?.stalledInterval ?? DEFAULT_STALLED_INTERVAL);
548
+ this.#stalledThreshold = parse(config.worker?.stalledThreshold ?? DEFAULT_STALLED_THRESHOLD);
549
+ this.#maxStalledCount = config.worker?.maxStalledCount ?? 1;
550
+ this.#concurrency = config.worker?.concurrency ?? 1;
551
+ this.#gracefulShutdown = config.worker?.gracefulShutdown ?? true;
552
+ this.#onShutdownSignal = config.worker?.onShutdownSignal;
262
553
  debug_default("created worker with id %s and config %O", this.#id, config);
263
554
  }
555
+ /**
556
+ * Initialize the worker (called automatically by `start()`).
557
+ *
558
+ * Sets up the QueueManager and adapter connection.
559
+ */
264
560
  async init() {
265
561
  if (this.#initialized) {
266
562
  return;
@@ -272,6 +568,23 @@ var Worker = class {
272
568
  this.#initialized = true;
273
569
  debug_default("worker %s initialized", this.#id);
274
570
  }
571
+ /**
572
+ * Start processing jobs from the specified queues.
573
+ *
574
+ * This method blocks until the worker is stopped (via `stop()` or signal).
575
+ * Jobs are processed concurrently up to the configured concurrency limit.
576
+ *
577
+ * @param queues - Queue names to process (default: ['default'])
578
+ *
579
+ * @example
580
+ * ```typescript
581
+ * // Process single queue
582
+ * await worker.start()
583
+ *
584
+ * // Process multiple queues (priority order)
585
+ * await worker.start(['high-priority', 'default', 'low-priority'])
586
+ * ```
587
+ */
275
588
  async start(queues = ["default"]) {
276
589
  await this.init();
277
590
  if (this.#running) {
@@ -280,7 +593,7 @@ var Worker = class {
280
593
  }
281
594
  this.#running = true;
282
595
  debug_default("starting worker %s on queues: %O", this.#id, queues);
283
- await this.#setupGracefulShutdown();
596
+ this.#setupGracefulShutdown();
284
597
  for await (const cycle of this.process(queues)) {
285
598
  if (["started", "completed"].includes(cycle.type)) {
286
599
  continue;
@@ -296,6 +609,12 @@ var Worker = class {
296
609
  }
297
610
  }
298
611
  }
612
+ /**
613
+ * Stop the worker gracefully.
614
+ *
615
+ * Waits for all running jobs to complete before shutting down.
616
+ * Called automatically on SIGINT/SIGTERM if gracefulShutdown is enabled.
617
+ */
299
618
  async stop() {
300
619
  debug_default("stopping worker %s", this.#id);
301
620
  this.#running = false;
@@ -306,7 +625,29 @@ var Worker = class {
306
625
  if (this.#adapter) {
307
626
  await this.#adapter.destroy();
308
627
  }
628
+ this.#removeShutdownHandlers();
309
629
  }
630
+ /**
631
+ * Process a single cycle and return the result.
632
+ *
633
+ * Useful for testing or when you need fine-grained control.
634
+ * Each cycle may start new jobs, complete a job, or return idle.
635
+ *
636
+ * @param queues - Queue names to process
637
+ * @returns The cycle result, or null if the worker was stopped
638
+ *
639
+ * @example
640
+ * ```typescript
641
+ * const worker = new Worker(config)
642
+ *
643
+ * // Process cycles manually
644
+ * let cycle = await worker.processCycle(['default'])
645
+ * while (cycle) {
646
+ * console.log('Cycle:', cycle.type)
647
+ * cycle = await worker.processCycle(['default'])
648
+ * }
649
+ * ```
650
+ */
310
651
  async processCycle(queues) {
311
652
  await this.init();
312
653
  this.#running = true;
@@ -320,26 +661,59 @@ var Worker = class {
320
661
  }
321
662
  return result.value;
322
663
  }
664
+ /**
665
+ * Generator that yields worker cycle events.
666
+ *
667
+ * Low-level API for processing jobs. Yields events for:
668
+ * - `started`: A new job began execution
669
+ * - `completed`: A job finished (success or failure)
670
+ * - `idle`: No jobs available, suggest waiting
671
+ * - `error`: An error occurred during processing
672
+ *
673
+ * @param queues - Queue names to process
674
+ * @yields WorkerCycle events
675
+ *
676
+ * @example
677
+ * ```typescript
678
+ * for await (const cycle of worker.process(['default'])) {
679
+ * switch (cycle.type) {
680
+ * case 'started':
681
+ * console.log(`Started job ${cycle.job.id}`)
682
+ * break
683
+ * case 'completed':
684
+ * console.log(`Completed job ${cycle.job.id}`)
685
+ * break
686
+ * case 'idle':
687
+ * await sleep(cycle.suggestedDelay)
688
+ * break
689
+ * }
690
+ * }
691
+ * ```
692
+ */
323
693
  async *process(queues) {
324
- const pollingInterval = parse(this.#config.worker?.pollingInterval || "2s");
325
694
  this.#pool = new JobPool();
326
695
  while (this.#running) {
327
696
  try {
697
+ await this.#checkStalledJobs(queues);
698
+ await this.#dispatchDueSchedules();
328
699
  yield* this.#fillPool(queues);
329
700
  if (this.#pool.isEmpty()) {
330
- yield { type: "idle", suggestedDelay: pollingInterval };
701
+ yield { type: "idle", suggestedDelay: this.#idleDelay };
331
702
  continue;
332
703
  }
333
704
  const completed = await this.#pool.waitForNextCompletion();
334
705
  yield { type: "completed", queue: completed.queue, job: completed.job };
335
706
  } catch (error) {
336
- yield { type: "error", error, suggestedDelay: parse("5s") };
707
+ yield {
708
+ type: "error",
709
+ error,
710
+ suggestedDelay: parse(DEFAULT_ERROR_RETRY_DELAY)
711
+ };
337
712
  }
338
713
  }
339
714
  }
340
715
  async *#fillPool(queues) {
341
- const concurrency = this.#config.worker?.concurrency || 1;
342
- const slotsAvailable = concurrency - this.#pool.size;
716
+ const slotsAvailable = this.#concurrency - this.#pool.size;
343
717
  if (slotsAvailable <= 0) return;
344
718
  const popPromises = Array.from({ length: slotsAvailable }, () => this.#acquireNextJob(queues));
345
719
  const results = await Promise.all(popPromises);
@@ -400,7 +774,17 @@ var Worker = class {
400
774
  async #initJob(job, queue) {
401
775
  try {
402
776
  const JobClass = Locator.getOrThrow(job.name);
403
- const instance = new JobClass(job.payload);
777
+ const context = Object.freeze({
778
+ jobId: job.id,
779
+ name: job.name,
780
+ attempt: job.attempts + 1,
781
+ queue,
782
+ priority: job.priority ?? DEFAULT_PRIORITY,
783
+ acquiredAt: new Date(job.acquiredAt),
784
+ stalledCount: job.stalledCount ?? 0
785
+ });
786
+ const jobFactory = QueueManager.getJobFactory();
787
+ const instance = jobFactory ? await jobFactory(JobClass, job.payload, context) : new JobClass(job.payload, context);
404
788
  const options = JobClass.options || {};
405
789
  const timeout = this.#getJobTimeout(options);
406
790
  return { instance, options, timeout };
@@ -442,14 +826,193 @@ var Worker = class {
442
826
  }
443
827
  return null;
444
828
  }
445
- async #setupGracefulShutdown() {
446
- const shutdown = async () => {
829
+ async #checkStalledJobs(queues) {
830
+ const now = Date.now();
831
+ if (now - this.#lastStalledCheck < this.#stalledInterval) {
832
+ return;
833
+ }
834
+ this.#lastStalledCheck = now;
835
+ for (const queue of queues) {
836
+ const recovered = await this.#adapter.recoverStalledJobs(
837
+ queue,
838
+ this.#stalledThreshold,
839
+ this.#maxStalledCount
840
+ );
841
+ if (recovered > 0) {
842
+ debug_default("worker %s: recovered %d stalled jobs from queue %s", this.#id, recovered, queue);
843
+ }
844
+ }
845
+ }
846
+ #setupGracefulShutdown() {
847
+ if (!this.#gracefulShutdown) {
848
+ return;
849
+ }
850
+ this.#shutdownHandler = async () => {
447
851
  debug_default("received shutdown signal, stopping worker...");
852
+ if (this.#onShutdownSignal) {
853
+ await this.#onShutdownSignal();
854
+ }
448
855
  await this.stop();
449
- process.exit(0);
450
856
  };
451
- process.on("SIGINT", shutdown);
452
- process.on("SIGTERM", shutdown);
857
+ process.on("SIGINT", this.#shutdownHandler);
858
+ process.on("SIGTERM", this.#shutdownHandler);
859
+ }
860
+ #removeShutdownHandlers() {
861
+ if (this.#shutdownHandler) {
862
+ process.off("SIGINT", this.#shutdownHandler);
863
+ process.off("SIGTERM", this.#shutdownHandler);
864
+ this.#shutdownHandler = void 0;
865
+ }
866
+ }
867
+ /**
868
+ * Dispatch any due scheduled jobs.
869
+ *
870
+ * Claims due schedules from the adapter and dispatches the corresponding
871
+ * jobs to their configured queues.
872
+ */
873
+ async #dispatchDueSchedules() {
874
+ while (true) {
875
+ const schedule = await this.#adapter.claimDueSchedule();
876
+ if (!schedule) {
877
+ break;
878
+ }
879
+ debug_default(
880
+ "worker %s: dispatching scheduled job %s (schedule: %s, runCount: %d)",
881
+ this.#id,
882
+ schedule.jobName,
883
+ schedule.id,
884
+ schedule.runCount + 1
885
+ );
886
+ const JobClass = Locator.get(schedule.jobName);
887
+ const queue = JobClass?.options?.queue ?? "default";
888
+ await this.#adapter.pushOn(queue, {
889
+ id: randomUUID2(),
890
+ name: schedule.jobName,
891
+ payload: schedule.payload,
892
+ attempts: 0,
893
+ priority: JobClass?.options?.priority
894
+ });
895
+ }
896
+ }
897
+ };
898
+
899
+ // src/schedule.ts
900
+ var Schedule = class _Schedule {
901
+ #data;
902
+ constructor(data) {
903
+ this.#data = data;
904
+ }
905
+ get id() {
906
+ return this.#data.id;
907
+ }
908
+ get jobName() {
909
+ return this.#data.jobName;
910
+ }
911
+ get payload() {
912
+ return this.#data.payload;
913
+ }
914
+ get cronExpression() {
915
+ return this.#data.cronExpression;
916
+ }
917
+ get everyMs() {
918
+ return this.#data.everyMs;
919
+ }
920
+ get timezone() {
921
+ return this.#data.timezone;
922
+ }
923
+ get from() {
924
+ return this.#data.from;
925
+ }
926
+ get to() {
927
+ return this.#data.to;
928
+ }
929
+ get limit() {
930
+ return this.#data.limit;
931
+ }
932
+ get runCount() {
933
+ return this.#data.runCount;
934
+ }
935
+ get nextRunAt() {
936
+ return this.#data.nextRunAt;
937
+ }
938
+ get lastRunAt() {
939
+ return this.#data.lastRunAt;
940
+ }
941
+ get status() {
942
+ return this.#data.status;
943
+ }
944
+ get createdAt() {
945
+ return this.#data.createdAt;
946
+ }
947
+ /**
948
+ * Find a schedule by ID.
949
+ *
950
+ * @param id - The schedule ID
951
+ * @returns The schedule instance, or null if not found
952
+ */
953
+ static async find(id) {
954
+ const adapter = QueueManager.use();
955
+ const data = await adapter.getSchedule(id);
956
+ if (!data) return null;
957
+ return new _Schedule(data);
958
+ }
959
+ /**
960
+ * List all schedules matching the given options.
961
+ *
962
+ * @param options - Optional filters for listing
963
+ * @returns Array of schedule instances
964
+ */
965
+ static async list(options) {
966
+ const adapter = QueueManager.use();
967
+ const schedules = await adapter.listSchedules(options);
968
+ return schedules.map((data) => new _Schedule(data));
969
+ }
970
+ /**
971
+ * Pause this schedule.
972
+ * No jobs will be dispatched while paused.
973
+ */
974
+ async pause() {
975
+ const adapter = QueueManager.use();
976
+ await adapter.updateSchedule(this.#data.id, { status: "paused" });
977
+ this.#data.status = "paused";
978
+ }
979
+ /**
980
+ * Resume this schedule.
981
+ * Jobs will be dispatched according to the schedule.
982
+ */
983
+ async resume() {
984
+ const adapter = QueueManager.use();
985
+ await adapter.updateSchedule(this.#data.id, { status: "active" });
986
+ this.#data.status = "active";
987
+ }
988
+ /**
989
+ * Delete this schedule permanently.
990
+ */
991
+ async delete() {
992
+ const adapter = QueueManager.use();
993
+ await adapter.deleteSchedule(this.#data.id);
994
+ }
995
+ /**
996
+ * Trigger immediate execution of this schedule's job.
997
+ * Also updates runCount and lastRunAt.
998
+ *
999
+ * If the schedule has reached its limit, the job will not be dispatched.
1000
+ */
1001
+ async trigger() {
1002
+ if (this.#data.limit !== null && this.#data.runCount >= this.#data.limit) {
1003
+ return;
1004
+ }
1005
+ const adapter = QueueManager.use();
1006
+ const dispatcher = new JobDispatcher(this.#data.jobName, this.#data.payload);
1007
+ await dispatcher.run();
1008
+ const now = /* @__PURE__ */ new Date();
1009
+ const newRunCount = this.#data.runCount + 1;
1010
+ await adapter.updateSchedule(this.#data.id, {
1011
+ runCount: newRunCount,
1012
+ lastRunAt: now
1013
+ });
1014
+ this.#data.runCount = newRunCount;
1015
+ this.#data.lastRunAt = now;
453
1016
  }
454
1017
  };
455
1018
 
@@ -458,10 +1021,33 @@ import { RuntimeException } from "@poppinss/utils";
458
1021
  import { assertUnreachable } from "@poppinss/utils/assert";
459
1022
  var BackoffStrategy = class {
460
1023
  #config;
1024
+ /**
1025
+ * Create a new backoff strategy.
1026
+ *
1027
+ * @param config - Backoff configuration
1028
+ * @throws {E_INVALID_BASE_DELAY} If baseDelay is not positive
1029
+ * @throws {E_INVALID_MAX_DELAY} If maxDelay is invalid
1030
+ * @throws {E_INVALID_MULTIPLIER} If multiplier is invalid
1031
+ */
461
1032
  constructor(config) {
462
1033
  this.#config = config;
463
1034
  this.#validateConfig();
464
1035
  }
1036
+ /**
1037
+ * Calculate the delay for a given attempt number.
1038
+ *
1039
+ * @param attempt - The attempt number (1-based)
1040
+ * @returns Delay in milliseconds
1041
+ * @throws {RuntimeException} If attempt is less than 1
1042
+ *
1043
+ * @example
1044
+ * ```typescript
1045
+ * // Exponential: 1s, 2s, 4s, 8s, 16s, ...
1046
+ * strategy.calculateDelay(1) // 1000
1047
+ * strategy.calculateDelay(2) // 2000
1048
+ * strategy.calculateDelay(3) // 4000
1049
+ * ```
1050
+ */
465
1051
  calculateDelay(attempt) {
466
1052
  if (attempt < 1) {
467
1053
  throw new RuntimeException("Attempt number must be >= 1");
@@ -489,10 +1075,27 @@ var BackoffStrategy = class {
489
1075
  }
490
1076
  return Math.floor(delay);
491
1077
  }
1078
+ /**
1079
+ * Get the Date when the next retry should occur.
1080
+ *
1081
+ * @param attempt - The attempt number (1-based)
1082
+ * @returns Date for the next retry
1083
+ *
1084
+ * @example
1085
+ * ```typescript
1086
+ * const nextRetry = strategy.getNextRetryAt(3)
1087
+ * console.log(`Retry at: ${nextRetry.toISOString()}`)
1088
+ * ```
1089
+ */
492
1090
  getNextRetryAt(attempt) {
493
1091
  const delay = this.calculateDelay(attempt);
494
1092
  return new Date(Date.now() + delay);
495
1093
  }
1094
+ /**
1095
+ * Get a frozen copy of the configuration.
1096
+ *
1097
+ * @returns Readonly configuration object
1098
+ */
496
1099
  getConfig() {
497
1100
  return Object.freeze({ ...this.#config });
498
1101
  }
@@ -560,9 +1163,13 @@ function customBackoff(config) {
560
1163
  }
561
1164
  export {
562
1165
  Job,
1166
+ Locator,
563
1167
  QueueManager,
1168
+ Schedule,
1169
+ ScheduleBuilder,
564
1170
  Worker,
565
1171
  customBackoff,
1172
+ exceptions_exports as errors,
566
1173
  exponentialBackoff,
567
1174
  fixedBackoff,
568
1175
  linearBackoff