Cuckoo

Monitor BullMQ Job Schedulers and Repeatable Jobs

Updated

BullMQ can produce jobs on a cron pattern or a fixed interval, and its workers retry failures with backoff. What it can't do is tell you that a scheduled job didn't happen. That can be because no worker is connected, Redis lost its data, the scheduler was removed by a deploy script, or the worker is stuck on a long job and the queue backs up. Failed jobs at least appear in the failed set. Missing jobs appear nowhere.

A heartbeat check adds that signal. The worker reports each scheduled run to Cuckoo, and a late or failed report triggers an alert.

How Cuckoo sees a BullMQ schedule

Create one check per job scheduler, with the same cron pattern and time zone. Each check has a URL:

  • https://cuckoo.metastic.ch/ping/YOUR-UUID/start when the worker picks up the job
  • https://cuckoo.metastic.ch/ping/YOUR-UUID when it completes
  • https://cuckoo.metastic.ch/ping/YOUR-UUID/fail when it fails for good (POST body up to 10 KB stored)

Step 1: Define the schedule

Since BullMQ 5.16, job schedulers are the recommended API. upsertJobScheduler is idempotent, so you can call it on every boot without creating duplicates:

import { Queue } from "bullmq";

const connection = { host: "127.0.0.1", port: 6379 };
const queue = new Queue("reports", { connection });

await queue.upsertJobScheduler(
  "nightly-report",                                // scheduler id
  { pattern: "0 3 * * *", tz: "Europe/Zurich" },   // or { every: 60_000 }
  {
    name: "nightly-report",
    data: {},
    opts: { attempts: 3, backoff: { type: "exponential", delay: 30_000 } },
  }
);

On older BullMQ versions the equivalent is a repeatable job: queue.add("nightly-report", {}, { repeat: { pattern: "0 3 * * *", tz: "Europe/Zurich" } }). BullMQ v6 removes the legacy repeatable-job APIs, so migrate to job schedulers before you upgrade.

Step 2: A ping helper

// heartbeat.js
export async function ping(url, suffix = "", body) {
  if (!url) return;
  try {
    await fetch(url + suffix, {
      method: body ? "POST" : "GET",
      body: body ? String(body).slice(0, 10_000) : undefined,
      signal: AbortSignal.timeout(10_000),
    });
  } catch (err) {
    console.warn(`heartbeat ping failed: ${err.message}`);
  }
}

Step 3: Ping from the worker

Map job names to ping URLs, send /start from the processor, and use the worker's completed and failed events for the result:

import { Worker } from "bullmq";
import { ping } from "./heartbeat.js";

const PINGS = {
  "nightly-report": process.env.CUCKOO_NIGHTLY_REPORT, // https://cuckoo.metastic.ch/ping/YOUR-UUID
};

const worker = new Worker(
  "reports",
  async (job) => {
    await ping(PINGS[job.name], "/start");
    return buildNightlyReport(job.data);
  },
  { connection: { host: "127.0.0.1", port: 6379 } }
);

worker.on("completed", (job) => {
  ping(PINGS[job.name]);
});

worker.on("failed", (job, err) => {
  if (!job) return;
  const maxAttempts = job.opts.attempts ?? 1;
  if (job.attemptsMade >= maxAttempts) {
    ping(PINGS[job.name], "/fail", `${err.name}: ${err.message}\n${err.stack ?? ""}`);
  }
});

You can also put all pings inside the processor with try/catch. The events version has one advantage: it can see whether BullMQ will retry the job.

Attempts, backoff and fail pings

With attempts: 3, a failing job runs up to three times. The failed event fires after every failed attempt, so the check above sends /fail only once attemptsMade has reached the limit. Attempts that later succeed simply end in a success ping.

Two things to size correctly:

  • Grace time vs. backoff. With attempts: 3, exponential backoff with a 30-second base waits about 30 s and then 60 s. The grace time on the Cuckoo check must be longer than the total retry window plus runtime. If it isn't, the check goes late before the final attempt can report.
  • Alert on every attempt instead. Drop the attemptsMade condition. Cuckoo then alerts on the first failure and sends a recovery message when a retry succeeds.

Why this also catches a dead worker

If no worker is running, the scheduler still produces jobs in Redis, but nobody processes them and no ping is sent. If Redis is down, nothing is produced at all. Either way the check goes late after the grace time. Several workers on the same queue are fine: each scheduled job is processed by one worker, so it pings once.

Schedule and grace advice

  • Mirror pattern and tz in Cuckoo as a cron expression with the same IANA time zone. For every, use a simple period of the same length.
  • BullMQ patterns may have a seconds field (0 15 3 * * *). Cuckoo cron expressions use five fields, so drop the leading seconds.
  • Set a max runtime if a job can stall. With the /start ping, Cuckoo flags runs that never finish.

Troubleshooting

  • No pings, no jobs: run queue.getJobSchedulers() to check that the scheduler exists in this Redis database.
  • Jobs waiting but not processed: the worker connects to a different queue name, prefix or Redis database.
  • /fail on every attempt: job.opts.attempts is not set on the scheduler's job template, so it defaults to one.
  • Duplicate schedulers after deploys: you are still on repeat options with changing settings. Move to upsertJobScheduler with a fixed id.

FAQ

Do the completed and failed events work across processes?

The Worker events used here fire only in the worker process that ran the job, which is the right place for pings. If you need events from elsewhere, BullMQ's QueueEvents class receives them through Redis.

Can one check cover several schedulers?

Technically yes, but then you can't tell which one stopped. Use one check per scheduler id.

Does a failing ping affect the job?

No. The helper catches every error and times out after 10 seconds, and the event handlers don't await anything that could throw back into BullMQ.

Related guides

All guides · Compare Cuckoo with other tools

Send feedback

Ideas, bugs, questions: we read every message. Or write to hello@metastic.ch.