@clipboard-health/mongo-jobs 0.3.1 → 0.3.3

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.
Files changed (2) hide show
  1. package/README.md +555 -0
  2. package/package.json +2 -2
package/README.md CHANGED
@@ -107,6 +107,561 @@ await backgroundJobs.start(["emails"], {
107
107
 
108
108
  </embedex>
109
109
 
110
+ ## Usage
111
+
112
+ ### Creating a job
113
+
114
+ Jobs are defined as classes that implement the `HandlerInterface`:
115
+
116
+ <embedex source="packages/mongo-jobs/examples/usage/myJob.ts">
117
+
118
+ ```ts
119
+ import type { BackgroundJobType, HandlerInterface } from "@clipboard-health/mongo-jobs";
120
+
121
+ export interface MyJobData {
122
+ userId: string;
123
+ action: string;
124
+ }
125
+
126
+ export class MyJob implements HandlerInterface<MyJobData> {
127
+ // Required: unique name for this job type
128
+ public name = "MyJob";
129
+
130
+ // Optional: max retry attempts (default: 10)
131
+ public maxAttempts = 5;
132
+
133
+ // Required: the actual job logic
134
+ async perform(data: MyJobData, job?: BackgroundJobType<MyJobData>) {
135
+ // Job implementation
136
+ console.log(`Processing ${data.action} for user ${data.userId}`);
137
+
138
+ // Optional: access job metadata
139
+ if (job) {
140
+ console.log(`Job ID: ${job._id.toString()}`);
141
+ console.log(`Attempt: ${job.attemptsCount}`);
142
+ }
143
+ }
144
+ }
145
+ ```
146
+
147
+ </embedex>
148
+
149
+ #### Job handler options
150
+
151
+ - **`name`** (required): Unique identifier for the job type
152
+ - **`maxAttempts`** (optional): Maximum number of retry attempts before marking the job as failed. Default is 10. Uses exponential backoff: 2^attempt seconds between retries
153
+ - **`perform`** (required): Async function that executes the job logic
154
+ - `data`: The job payload passed when enqueueing
155
+ - `job`: Optional metadata about the job execution (id, attempts, timestamps, etc.)
156
+
157
+ ### Registering jobs
158
+
159
+ Register job handlers with the `BackgroundJobs` instance and assign them to processing groups:
160
+
161
+ <embedex source="packages/mongo-jobs/examples/usage/registerJobs.ts">
162
+
163
+ ```ts
164
+ import { BackgroundJobs } from "@clipboard-health/mongo-jobs";
165
+
166
+ import { CleanupJob } from "./jobs/cleanupJob";
167
+ import { EmailJob } from "./jobs/emailJob";
168
+ import { ReportJob } from "./jobs/reportJob";
169
+ import { SmsJob } from "./jobs/smsJob";
170
+
171
+ const backgroundJobs = new BackgroundJobs();
172
+
173
+ // Register jobs to groups
174
+ backgroundJobs.register(EmailJob, "notifications");
175
+ backgroundJobs.register(ReportJob, "reports");
176
+ backgroundJobs.register(CleanupJob, "maintenance");
177
+
178
+ // You can register multiple jobs to the same group
179
+ backgroundJobs.register(SmsJob, "notifications");
180
+ ```
181
+
182
+ </embedex>
183
+
184
+ Groups allow you to:
185
+
186
+ - Organize related jobs together
187
+ - Run dedicated workers for specific job types
188
+ - Control concurrency per group
189
+ - Scale different job types independently
190
+
191
+ #### Jobs with dependencies
192
+
193
+ If your job requires dependencies (like services, database connections, etc.) passed through the constructor, you must register an instance instead of the class:
194
+
195
+ <embedex source="packages/mongo-jobs/examples/usage/registerJobsWithDependencies.ts">
196
+
197
+ ```ts
198
+ import { BackgroundJobs } from "@clipboard-health/mongo-jobs";
199
+
200
+ import { EmailServiceJob } from "./jobs/emailServiceJob";
201
+
202
+ const backgroundJobs = new BackgroundJobs();
203
+
204
+ // For jobs with constructor dependencies, register an instance
205
+ const emailService = {
206
+ async send(to: string, subject: string, body: string) {
207
+ console.log(`Sending email to ${to}: ${subject} : ${body}`);
208
+ },
209
+ };
210
+
211
+ backgroundJobs.register(new EmailServiceJob(emailService), "notifications");
212
+ ```
213
+
214
+ </embedex>
215
+
216
+ Example job with dependencies:
217
+
218
+ <embedex source="packages/mongo-jobs/examples/usage/jobs/emailServiceJob.ts">
219
+
220
+ ```ts
221
+ import type { HandlerInterface } from "@clipboard-health/mongo-jobs";
222
+
223
+ interface EmailService {
224
+ send(to: string, subject: string, body: string): Promise<void>;
225
+ }
226
+
227
+ export interface EmailServiceJobData {
228
+ to: string;
229
+ subject: string;
230
+ body: string;
231
+ }
232
+
233
+ export class EmailServiceJob implements HandlerInterface<EmailServiceJobData> {
234
+ public name = "EmailServiceJob";
235
+ public maxAttempts = 3;
236
+
237
+ constructor(private readonly emailService: EmailService) {}
238
+
239
+ async perform({ to, subject, body }: EmailServiceJobData) {
240
+ await this.emailService.send(to, subject, body);
241
+ }
242
+ }
243
+ ```
244
+
245
+ </embedex>
246
+
247
+ **Important**: When registering job instances, the library will use the instance directly rather than instantiating the class. This means:
248
+
249
+ - The same instance is used for all job executions in this process
250
+ - Dependencies are shared across all executions
251
+ - Your job class should be stateless (all state should come from the `data` parameter)
252
+
253
+ **Note**: Even when registering an instance, you can still enqueue jobs using the class, instance, or handler name:
254
+
255
+ ```ts
256
+ // All of these work, regardless of whether you registered a class or instance
257
+ await backgroundJobs.enqueue(EmailServiceJob, data); // By class
258
+ await backgroundJobs.enqueue(emailServiceJobInstance, data); // By instance
259
+ await backgroundJobs.enqueue("EmailServiceJob", data); // By name
260
+ ```
261
+
262
+ The enqueued class/instance/name is only used to look up the registered handler. The **registered** instance is always used for execution, not the instance passed to `enqueue()`.
263
+
264
+ ### Enqueuing jobs
265
+
266
+ Add jobs to the queue for processing:
267
+
268
+ <embedex source="packages/mongo-jobs/examples/usage/enqueueBasic.ts">
269
+
270
+ ```ts
271
+ import { backgroundJobs } from "./jobsRegistry";
272
+ import { MyJob } from "./myJob";
273
+
274
+ // Basic enqueue
275
+ await backgroundJobs.enqueue(MyJob, {
276
+ userId: "123",
277
+ action: "process",
278
+ });
279
+ ```
280
+
281
+ </embedex>
282
+
283
+ <embedex source="packages/mongo-jobs/examples/usage/enqueueWithOptions.ts">
284
+
285
+ ```ts
286
+ import type { ClientSession } from "mongodb";
287
+
288
+ import { backgroundJobs } from "./jobsRegistry";
289
+ import { MyJob } from "./myJob";
290
+
291
+ declare const mongoSession: ClientSession;
292
+
293
+ // Enqueue with options
294
+ await backgroundJobs.enqueue(
295
+ MyJob,
296
+ { userId: "123", action: "process" },
297
+ {
298
+ // Schedule for later
299
+ startAt: new Date("2024-12-31T23:59:59Z"),
300
+
301
+ // Ensure uniqueness (see uniqueness section below)
302
+ unique: "user-123-process",
303
+
304
+ // Use within a MongoDB transaction
305
+ session: mongoSession,
306
+ },
307
+ );
308
+ ```
309
+
310
+ </embedex>
311
+
312
+ <embedex source="packages/mongo-jobs/examples/usage/enqueueByName.ts">
313
+
314
+ ```ts
315
+ import { backgroundJobs } from "./jobsRegistry";
316
+
317
+ // Enqueue by job name (when handler is already registered)
318
+ await backgroundJobs.enqueue("MyJob", { userId: "123", action: "process" });
319
+ ```
320
+
321
+ </embedex>
322
+
323
+ #### Enqueue options
324
+
325
+ - **`startAt`**: Schedule the job to run at a specific time. Default is immediate
326
+ - **`unique`**: Ensure only one instance of the job exists (see Job uniqueness section)
327
+ - **`session`**: MongoDB session for transactional job creation
328
+
329
+ ### Starting a worker
330
+
331
+ Start processing jobs from one or more groups:
332
+
333
+ <embedex source="packages/mongo-jobs/examples/usage/startWorkerBasic.ts">
334
+
335
+ ```ts
336
+ import { backgroundJobs } from "./jobsRegistry";
337
+
338
+ // Start a worker for specific groups
339
+ await backgroundJobs.start(["notifications", "reports"], {
340
+ maxConcurrency: 20,
341
+ });
342
+ ```
343
+
344
+ </embedex>
345
+
346
+ <embedex source="packages/mongo-jobs/examples/usage/startWorkerWithOptions.ts">
347
+
348
+ ```ts
349
+ import { backgroundJobs } from "./jobsRegistry";
350
+
351
+ // Start with all available options
352
+ await backgroundJobs.start(["notifications"], {
353
+ // Maximum concurrent jobs (default: 10)
354
+ maxConcurrency: 10,
355
+
356
+ // Time to wait when no jobs available, in ms (default: 10000)
357
+ newJobCheckWaitMS: 5000,
358
+
359
+ // Use MongoDB change streams for instant job detection (default: true)
360
+ useChangeStream: true,
361
+
362
+ // Lock timeout for stuck jobs, in ms (default: 600000 = 10 minutes)
363
+ lockTimeoutMS: 300_000,
364
+
365
+ // Interval to check for stuck jobs, in ms (default: 60000 = 1 minute)
366
+ unlockJobsIntervalMS: 30_000,
367
+
368
+ // Interval to refresh queue list, in ms (default: 30000 = 30 seconds)
369
+ refreshQueuesIntervalMS: 60_000,
370
+
371
+ // Exclude specific queues from processing
372
+ exclude: ["low-priority-queue"],
373
+ });
374
+ ```
375
+
376
+ </embedex>
377
+
378
+ <embedex source="packages/mongo-jobs/examples/usage/stopWorker.ts">
379
+
380
+ ```ts
381
+ import { backgroundJobs } from "./jobsRegistry";
382
+
383
+ // Graceful shutdown
384
+ await backgroundJobs.stop(30_000); // Wait up to 30 seconds for jobs to complete
385
+ ```
386
+
387
+ </embedex>
388
+
389
+ #### Worker options
390
+
391
+ - **`maxConcurrency`**: Number of jobs to process simultaneously
392
+ - **`useChangeStream`**: Enable instant job detection using MongoDB change streams. When `true`, workers are notified immediately when new jobs are added
393
+ - **`newJobCheckWaitMS`**: Fallback polling interval when no jobs are available
394
+ - **`lockTimeoutMS`**: Maximum time a job can be locked before being considered stuck
395
+ - **`unlockJobsIntervalMS`**: How often to check for and unlock stuck jobs
396
+ - **`refreshQueuesIntervalMS`**: How often to refresh the list of queues to consume
397
+ - **`exclude`**: Array of queue names to skip processing
398
+
399
+ ### Cron jobs
400
+
401
+ Schedule recurring jobs using cron expressions:
402
+
403
+ <embedex source="packages/mongo-jobs/examples/usage/cronRegister.ts">
404
+
405
+ ```ts
406
+ import { BackgroundJobs } from "@clipboard-health/mongo-jobs";
407
+
408
+ import { DailyReportJob } from "./jobs/dailyReportJob";
409
+
410
+ const backgroundJobs = new BackgroundJobs();
411
+
412
+ // Register a cron job
413
+ await backgroundJobs.registerCron(DailyReportJob, {
414
+ // Group assignment (same as regular registration)
415
+ group: "reports",
416
+
417
+ // Unique name for this schedule
418
+ scheduleName: "daily-report",
419
+
420
+ // Cron expression (standard 5-field format)
421
+ cronExpression: "0 9 * * *", // Every day at 9 AM
422
+
423
+ // Optional: timezone for cron evaluation (default: "utc")
424
+ timeZone: "America/New_York",
425
+
426
+ // Data to pass to each job execution
427
+ data: { reportType: "daily" },
428
+ });
429
+ ```
430
+
431
+ </embedex>
432
+
433
+ <embedex source="packages/mongo-jobs/examples/usage/cronRemove.ts">
434
+
435
+ ```ts
436
+ import { backgroundJobs } from "./jobsRegistry";
437
+
438
+ // Remove a cron schedule and its pending jobs
439
+ await backgroundJobs.removeCron("daily-report");
440
+ ```
441
+
442
+ </embedex>
443
+
444
+ #### Cron scheduling details
445
+
446
+ - Uses standard 5-field cron expressions: `minute hour day month weekday`
447
+ - Automatically enqueues the next job after the current one completes
448
+ - Updates to cron schedules automatically cancel pending jobs and reschedule
449
+ - Failed cron jobs are retried according to `maxAttempts`, but the next scheduled job will still be enqueued
450
+ - Each scheduled execution is a unique job instance
451
+
452
+ #### Removing cron schedules
453
+
454
+ **Important**: When you register a cron schedule, it is persisted in the database. Even if you remove the schedule registration from your code, it will continue executing. To stop a cron schedule, you must explicitly remove it using the `removeCron` API:
455
+
456
+ ```ts
457
+ await backgroundJobs.removeCron("daily-report");
458
+ ```
459
+
460
+ This will:
461
+
462
+ - Delete the schedule from the database
463
+ - Cancel all pending jobs that were created by this schedule
464
+ - Prevent future jobs from being scheduled
465
+
466
+ ### Job uniqueness
467
+
468
+ Prevent duplicate jobs from being enqueued or running simultaneously:
469
+
470
+ <embedex source="packages/mongo-jobs/examples/usage/uniqueSimple.ts">
471
+
472
+ ```ts
473
+ import { ProcessUserJob } from "./jobs/processUserJob";
474
+ import { backgroundJobs } from "./jobsRegistry";
475
+
476
+ // Simple uniqueness - single unique key for both enqueued and running
477
+ await backgroundJobs.enqueue(
478
+ ProcessUserJob,
479
+ { userId: "123" },
480
+ {
481
+ unique: "process-user-123",
482
+ },
483
+ );
484
+ ```
485
+
486
+ </embedex>
487
+
488
+ #### Advanced uniqueness
489
+
490
+ It's possible to have separate enqueued and running key. When the job is enqueued, the library will
491
+ ensure that we can't enqueue another one but once it starts running it switches to its running key so
492
+ we can enqueue another one that will wait to be executed until the first one finishes.
493
+
494
+ An example where this can be useful is recalculating some kind of a cache. We don't want to enqueue more
495
+ than one non-running job to not explode number of enqueued jobs. But once it starts running and there is another
496
+ trigger that may warrant cache recalculation we want to schedule another one to do another recalculation even
497
+ if there is one running, cause we don't know if the current recalculation will include the newest change.
498
+
499
+ <embedex source="packages/mongo-jobs/examples/usage/uniqueAdvanced.ts">
500
+
501
+ ```ts
502
+ import { ProcessUserJob } from "./jobs/processUserJob";
503
+ import { backgroundJobs } from "./jobsRegistry";
504
+
505
+ // Advanced uniqueness - separate keys for enqueued vs running states
506
+ await backgroundJobs.enqueue(
507
+ ProcessUserJob,
508
+ { userId: "123" },
509
+ {
510
+ unique: {
511
+ // Only one enqueued job per user
512
+ enqueuedKey: "process-user-123",
513
+
514
+ // Only one running job per user
515
+ runningKey: "process-user-123-running",
516
+ },
517
+ },
518
+ );
519
+ ```
520
+
521
+ </embedex>
522
+
523
+ <embedex source="packages/mongo-jobs/examples/usage/uniqueMultipleEnqueued.ts">
524
+
525
+ ```ts
526
+ import { SendEmailJob } from "./jobs/sendEmailJob";
527
+ import { backgroundJobs } from "./jobsRegistry";
528
+
529
+ // Example: Allow multiple enqueued but only one running
530
+ await backgroundJobs.enqueue(
531
+ SendEmailJob,
532
+ { userId: "123", emailType: "welcome" },
533
+ {
534
+ unique: {
535
+ enqueuedKey: undefined, // Allow multiple enqueued emails
536
+ runningKey: "send-email-123", // But only one sending at a time
537
+ },
538
+ },
539
+ );
540
+ ```
541
+
542
+ </embedex>
543
+
544
+ #### Uniqueness behavior
545
+
546
+ - **Enqueued uniqueness**: Prevents duplicate jobs from being added to the queue. If a job with the same `enqueuedKey` already exists and hasn't started, the new enqueue returns `undefined`
547
+ - **Running uniqueness**: When a job starts, its unique key transitions from `enqueuedKey` to `runningKey`. This prevents multiple instances from running simultaneously
548
+ - If a duplicate unique key is detected, the operation silently fails and returns `undefined`
549
+ - Uniqueness is enforced via MongoDB unique index on the `uniqueKey` field
550
+ - Cron jobs automatically use unique keys based on schedule name and timestamp
551
+
552
+ ## Observability
553
+
554
+ ### Metrics
555
+
556
+ The library automatically reports metrics using StatsD by default. Metrics are reported every 60 seconds for each queue and include:
557
+
558
+ - **`background_jobs.queue.scheduled`** - Number of jobs scheduled for future execution
559
+ - **`background_jobs.queue.pending`** - Number of jobs ready to be processed
560
+ - **`background_jobs.queue.created`** - Total jobs (scheduled + pending)
561
+ - **`background_jobs.queue.failed`** - Number of jobs that exhausted all retry attempts
562
+ - **`background_jobs.queue.retry`** - Counter incremented when a job is retried
563
+ - **`background_jobs.queue.expired`** - Counter incremented when a job lock expires (stuck jobs)
564
+ - **`background_jobs.queue.delay`** - Timing metric for execution delay (time between `nextRunAt` and actual execution)
565
+
566
+ All metrics are tagged with `queue` to identify which queue the metric belongs to.
567
+
568
+ #### Custom metrics reporter
569
+
570
+ You can provide a custom metrics reporter by implementing the `MetricsReporter` interface:
571
+
572
+ ```ts
573
+ import { BackgroundJobs, type MetricsReporter } from "@clipboard-health/mongo-jobs";
574
+
575
+ class CustomMetricsReporter implements MetricsReporter {
576
+ gauge(name: string, value: number, tags: Record<string, string>): void {
577
+ // Report gauge metric
578
+ console.log(`Gauge: ${name} = ${value}`, tags);
579
+ }
580
+
581
+ increment(name: string, tags: Record<string, string>): void {
582
+ // Report counter increment
583
+ console.log(`Increment: ${name}`, tags);
584
+ }
585
+
586
+ timing(name: string, value: number | Date, tags: Record<string, string>): void {
587
+ // Report timing metric
588
+ console.log(`Timing: ${name} = ${value}`, tags);
589
+ }
590
+ }
591
+
592
+ const backgroundJobs = new BackgroundJobs({
593
+ metricsReporter: new CustomMetricsReporter(),
594
+ });
595
+ ```
596
+
597
+ #### StatsD configuration
598
+
599
+ The default metrics reporter uses the `hot-shots` StatsD client. You can configure it by passing options:
600
+
601
+ ```ts
602
+ import { BackgroundJobs, defaultMetricsReporter } from "@clipboard-health/mongo-jobs";
603
+
604
+ const backgroundJobs = new BackgroundJobs({
605
+ metricsReporter: defaultMetricsReporter({
606
+ host: "localhost",
607
+ port: 8125,
608
+ globalTags: { env: "production" },
609
+ }),
610
+ });
611
+ ```
612
+
613
+ ### OpenTelemetry tracing
614
+
615
+ The library provides built-in OpenTelemetry distributed tracing support. Traces are automatically created for job enqueueing (producer) and execution (consumer), allowing you to track jobs across your distributed system.
616
+
617
+ #### Trace spans
618
+
619
+ Three types of spans are created:
620
+
621
+ 1. **Producer spans** (`background-jobs.producer`) - Created when a job is enqueued
622
+ - Kind: `PRODUCER`
623
+ - Attributes include: messaging system, operation, destination (handler name), queue name
624
+
625
+ 2. **Consumer spans** (`background-jobs.consumer`) - Created when a job is executed
626
+ - Kind: `CONSUMER`
627
+ - Linked to the producer span for distributed tracing
628
+ - Attributes include: message ID, handler name, queue, attempt count, timestamps
629
+
630
+ 3. **Internal spans** (`background-jobs.internals`) - Created for internal operations
631
+ - Kind: `INTERNAL`
632
+ - Used for operations like fetching jobs, reporting metrics, etc.
633
+
634
+ #### Setting up OpenTelemetry
635
+
636
+ To enable tracing, configure the OpenTelemetry SDK in your application:
637
+
638
+ ```ts
639
+ import { NodeSDK } from "@opentelemetry/sdk-node";
640
+ import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
641
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
642
+
643
+ const sdk = new NodeSDK({
644
+ traceExporter: new OTLPTraceExporter({
645
+ url: "http://localhost:4318/v1/traces",
646
+ }),
647
+ instrumentations: [getNodeAutoInstrumentations()],
648
+ });
649
+
650
+ sdk.start();
651
+ ```
652
+
653
+ #### Distributed tracing
654
+
655
+ When a job is enqueued, trace context is automatically injected into the job data via the `_traceHeaders` field. When the job is executed, this context is extracted to link the consumer span to the producer span, enabling end-to-end trace visibility.
656
+
657
+ ```text
658
+ HTTP Request → Enqueue Job (Producer Span)
659
+
660
+ [Job in Queue]
661
+
662
+ Execute Job (Consumer Span) → Your Handler
663
+ ```
664
+
110
665
  ## License
111
666
 
112
667
  MIT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@clipboard-health/mongo-jobs",
3
3
  "description": "MongoDB-powered background jobs.",
4
- "version": "0.3.1",
4
+ "version": "0.3.3",
5
5
  "bugs": "https://github.com/ClipboardHealth/core-utils/issues",
6
6
  "dependencies": {
7
7
  "cron-parser": "5.4.0",
@@ -9,7 +9,7 @@
9
9
  "tslib": "2.8.1"
10
10
  },
11
11
  "devDependencies": {
12
- "@clipboard-health/util-ts": "3.18.1",
12
+ "@clipboard-health/util-ts": "3.18.2",
13
13
  "@opentelemetry/api": "1.9.0",
14
14
  "mongodb": "6.18.0",
15
15
  "mongoose": "8.18.0"