@platformatic/job-queue

repository·main·Indexed 18 days ago

https://github.com/platformatic/job-queue

A reliable, type-safe job queue for Node.js featuring deduplication, request/response support, and pluggable storage backends including Redis, Filesystem, and Memory. It supports at-least-once delivery guarantees, graceful shutdown, and a Reaper for recovering stalled jobs. The library provides both fire-and-forget (enqueue) and synchronous (enqueueAndWait) invocation modes, with built-in support for custom serialization via the Serde interface.

Tokens
26.7K
Snippets
82
Records
100
Agent score
63%

What's inside @platformatic/job-queue

  1. How job cancellation works

    main

    You can attempt to cancel a job using cancel(id). The behavior depends on the current state of the job:

    • Not Found: Returns { status: 'not_found' }.
    • Completed: Returns { status: 'completed' } (cannot cancel a finished job).
    • Processing: Returns { status: 'processing' }. Jobs currently being handled by a worker cannot be cancelled mid-processing.
    • Queued: The job is cancelled. The library performs an atomic MULTI block to:
      1. Delete the job entry from the {prefix}:jobs hash using HDEL.
      2. Publish a {id}:cancelled event to {prefix}:events.

    Note on Implementation: The library does not perform an $O(n)$ removal from the Redis LIST directly. Instead, it deletes the job from the jobs hash. When a worker eventually picks up the job from the queue, it checks the jobs hash; if the entry is missing, the worker skips the job and removes it from its processing queue.

    // Example cancellation
    const status = await cancel('job-id');
    if (status.status === 'cancelled') {
      console.log('Job was successfully cancelled');
    }
  2. Compare Fire-and-Forget vs Request/Response modes

    main

    Choose the invocation mode based on your requirements:

    AspectFire-and-ForgetRequest/Response
    Methodenqueue()enqueueAndWait()
    ReturnsImmediatelyWhen job completes
    ResultVia getResult() laterDirectly returned
    Use caseBackground tasksRPC-style calls
    TimeoutN/AConfigurable
    NotificationEventsPub/sub (instant)
  3. Recover stalled jobs with the `Reaper`

    main

    The Reaper monitors for jobs that have been marked as 'processing' for longer than the visibilityTimeout (indicating a worker crash) and automatically requeues them. This is essential when running multiple workers.

    Leader Election: To prevent multiple Reapers from competing, you can enable leader election (supported only by RedisStorage). Only the leader will actively monitor and requeue jobs.

    import { Reaper } from '@platformatic/job-queue'
    
    const reaper = new Reaper({
      storage,
      visibilityTimeout: 30000
    })
    
    await reaper.start()
    
    reaper.on('stalled', (id) => {
      console.log(`Job ${id} was stalled and requeued`})
    })
    
    // On shutdown
    await reaper.stop()
  4. Understand the reliable queue pattern and idempotency requirements

    main

    The @platformatic/job-queue library implements a reliable queue pattern with at-least-once delivery guarantees. Because jobs may be executed multiple times in failure scenarios (e.g., worker crashes, network partitions, or lease expirations), all job handlers must be idempotent.

    Idempotent operations (Safe):

    • Setting a value in a database (not incrementing).
    • Sending an email with a unique message ID (relying on provider deduplication).
    • Processing an image and storing it by content hash.
    • HTTP PUT requests.

    Non-idempotent operations (Require external safeguards):

    • Incrementing a counter.
    • Charging a credit card (use idempotency keys).
    • Sending notifications without deduplication.
  5. How the Request/Response pattern (enqueueAndWait) works

    main

    The enqueueAndWait(id, payload, { timeout, resultTTL? }) method is an optimized pattern for waiting on a job result without polling. It uses Redis Pub/Sub for minimal latency.

    The Workflow:

    1. Subscription: The client subscribes to {prefix}:notifications:{id} before enqueuing to prevent race conditions.
    2. Enqueue: The client calls enqueue(id, payload).
    3. Wait: The client waits for a notification on the subscription.
      • If the job is already completed, it returns the result immediately.
      • If the job is a duplicate, it waits on the subscription.
      • If the job is queued, it blocks until a notification is received or the timeout is reached.
    4. Resolution:
      • 'completed' notification: The client performs a GET to fetch the result and returns.
      • 'failed' notification: The client fetches the error and throws it.
      • timeout: The client throws a TimeoutError.

    Key Optimization: The Pub/Sub mechanism only carries the notification (not the full payload), allowing the client to fetch the result via a single GET after the notification arrives, which handles large payloads efficiently.

    // Example Request/Response usage
    try {
      const result = await enqueueAndWait('job-id', { data: 'foo' }, { 
        timeout: 5000, 
        resultTTL: 30000 
      });
      console.log('Job finished:', result);
    } catch (err) {
      if (err instanceof TimeoutError) {
        console.error('Job timed out');
      } else {
        console.error('Job failed:', err);
      }
    }
  6. How stalled job recovery works

    main

    The library uses an event-driven architecture and a background Reaper to recover jobs that were interrupted (e.g., due to a worker crash).

    Event-Based Detection

    All state changes are published to {prefix}:events. The Reaper subscribes to these events and maintains timers for jobs in the processing state.

    The Reaper Process

    1. Registration: When a worker starts, it registers itself in the {prefix}:workers set.
    2. Monitoring: The Reaper monitors {id}:processing events.
    3. Timer: For every processing event, the Reaper starts a timer based on the visibilityTimeout.
    4. Requeue: If the timer fires and the job is still marked as processing in the {prefix}:jobs hash, the Reaper triggers recoverStalledJob(id, workerId).

    Atomic Recovery

    The recoverStalledJob function uses a Lua script to ensure atomicity:

    • It checks if the job state in {prefix}:jobs starts with processing:.
    • If yes, it moves the message from the worker's processing queue back to the main queue (LREM + LPUSH).
    • It updates the job state to queued.
    • It publishes an {id}:stalled event.

    Recovery Timing

    • Stalled Jobs: Recovery occurs approximately after the visibilityTimeout expires.
    • Graceful Shutdown: If a worker shuts down gracefully, it is responsible for requeueing its own jobs immediately.
  7. Design constraints and limitations of the job queue

    main

    When building with @platformatic/job-queue, be aware of the following architectural constraints and requirements:

    • Idempotency is mandatory: The library provides at-least-once delivery. Jobs may be re-processed due to crashes, lease expirations, or network issues. You must ensure your job handlers are idempotent (processing the same job twice results in the same state as processing it once).
    • FIFO only: The queue does not support priority levels; all jobs are processed in First-In-First-Out order. For priority support, you must implement multiple queues.
    • No delayed jobs: Jobs are processed immediately upon being added. If you need delayed execution, you must use an external scheduler.
    • Single Redis instance: The library does not have built-in support for Redis clustering. To use Redis Cluster, you must use a proxy or a compatible client.
    • Cancellation mechanism: Job removal is handled via cancellation marking rather than direct list removal to avoid $O(n)$ performance penalties.
  8. How job enqueuing works

    main

    When you call enqueue(id, payload, { resultTTL? }), the library performs an atomic operation to ensure reliability:

    1. Deduplication Check: It checks if the job ID already exists in the {prefix}:jobs hash.
      • If the state is completed, it returns the cached result.
      • If the state is queued or processing, it returns a duplicate status.
      • If the job does not exist or is failed, it proceeds to enqueue.
    2. Atomic Enqueue: Using a Redis MULTI block, it:
      • Sets the job state in {prefix}:jobs to queued:{timestamp}.
      • Pushes the message to the {prefix}:queue list.
      • Publishes a {id}:queued event to {prefix}:events.

    TTL Resolution: The resultTTL (retention time for the result) is resolved as options.resultTTL ?? config.resultTTL. This value is serialized into the message so that workers use the producer's preferred TTL.

    Validation: resultTTL must be an integer representing milliseconds and must be > 0. Invalid values will trigger a validation error.

    // Example enqueue call
    enqueue('job-123', { task: 'process-image' }, { resultTTL: 60000 });
    // resultTTL is 60,000ms (1 minute)
  9. Use Fire-and-Forget invocation mode

    main

    Use the enqueue() method to submit a job and return immediately without waiting for the result. This is ideal for background tasks like sending emails or processing uploads where high throughput and low latency are priorities and the caller does not need the result.

    // Returns immediately after job is queued
    await queue.enqueue('job-123', { email: 'user@example.com' });
  10. Perform a graceful shutdown

    main

    When shutting down your application, call queue.stop(). This method waits for in-flight jobs to complete (or until a timeout is reached) before closing the connection to the storage backend.

    import closeWithGrace from 'close-with-grace';
    
    const queue = new Queue(config);
    
    queue.execute(handler);
    await queue.start();
    
    closeWithGrace({ delay: 10000 }, async ({ signal }) => {
      console.log(`${signal} received, stopping queue...`);
    
      // stop() waits for in-flight jobs to complete
      // or until delay timeout
      await queue.stop();
    });
  11. Choose a Worker ID strategy

    main

    Each worker needs a unique ID to track in-flight jobs. If workerId is omitted in the QueueConfig, a random UUID is generated. For better observability or metrics grouping, you can provide a stable ID.

    import { randomUUID } from 'node:crypto';
    import { hostname } from 'node:os';
    
    // Option 1: Random UUID (safe - storage handles cleanup)
    const workerId = randomUUID();
    
    // Option 2: Hostname (good for VMs, bare metal)
    const workerId = hostname();
    
    // Option 3: Kubernetes pod name
    const workerId = process.env.HOSTNAME ?? hostname();
    
    // Option 4: Process-scoped
    const workerId = `${hostname()}-${process.pid}`;
  12. Implement content-based deduplication

    main

    To prevent duplicate jobs based on their content rather than a manual ID, you can generate a hash of the payload and use it as the job ID. This ensures that identical payloads are not queued multiple times.

    import { createHash } from 'node:crypto';
    
    function contentId(payload: unknown): string {
      return createHash('sha256')
        .update(JSON.stringify(payload))
        .digest('hex')
        .slice(0, 16);
    }
    
    await queue.enqueue(contentId(job), job);