← 返回博客

BullMQ 可重复 / Cron 任务事务邮件 + Rovela(2026):upsertJobScheduler 与按窗口的幂等键

用 BullMQ Job Scheduler(upsertJobScheduler 的 cron pattern 或 every)在 Redis 上调度,由 Worker 经 Rovela POST /emails 发送事务摘要:sending_access 仅服务端,并按日程窗口设置 Idempotency-Key。

更新于 2026年11月5日
BullMQ 可重复 / Cron 任务事务邮件 + Rovela(2026):upsertJobScheduler 与按窗口的幂等键

BullMQ Job Scheduler 管 cron,Rovela 负责投递

BullMQ 是基于 Redis 的 Node.js 任务库。其 Job Scheduler(queue.upsertJobScheduler)像工厂一样工作:你用调度器 id 声明 repeat 设置(cron 的 pattern 和/或毫秒级的 every),BullMQ 按节奏入队 delayed job;Worker 像处理普通队列任务一样消费。文档里仍常把这类产出叫 “repeatable jobs”,但当前应使用 Job Scheduler API,而不是 v6 已移除的遗留 Queue.add(..., { repeat })(Job Schedulers、v5→v6 迁移)。

这种形态适合摘要、运维报告、试用到期提醒等 触发时才拼装 的事务邮件——注册数、未付账单、失败任务——而不是能提前用 Rovela scheduled_at 冻住正文的内容。

当 BullMQ Worker 调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(发送邮件 API、快速开始、鉴权)。BullMQ(加 Redis)负责日程、delayed job 产出、重试与并发;你的 Worker 负责聚合、保管 sending_access,以及按日程窗口稳定的 Idempotency-Key。Rovela 只做投递。

本指南串联 BullMQ Job Scheduler → Worker → Rovela:

  1. 用 upsertJobScheduler(schedulerId, { pattern | every, tz?, … }, { name, data, opts }) upsert 调度器
  2. 将 ROVELA_API_KEY(sending_access)、MAIL_FROM、Redis URL、收件人放在 仅服务端 环境变量——绝不进浏览器或公开仓库
  3. 在 Worker 处理器内按当前窗口聚合摘要 / 报告
  4. 经 Rovela 发送,并带上窗口作用域的 Idempotency-Key
  5. 在 BullMQ 重试、stall 恢复、重复 tick 下仍不重复发信

Rovela 不会创建 BullMQ 队列、Redis key 或 Job Scheduler。 BullMQ 负责 schedule → delayed job → worker;你的 Worker 负责查询 → 映射字段 → POST /emails;Rovela 负责投递。

与下列文章区分:

也不同于一次性 BullMQ delayed job(delay)或没有 Scheduler 的普通 queue.add——本文只覆盖 周期性 Job Scheduler / cron。

BullMQ 管什么,Rovela 发什么

事项负责方
调度器 id、pattern / every、tz、startDate / endDate / limitBullMQ upsertJobScheduler
调度元数据与 delayed job 的 Redis 持久化你的 Redis(队列前缀下的 BullMQ key)
上次任务开始处理后产出下一次 delayed jobBullMQ Job Scheduler
Worker 并发、attempts、backoffBullMQ Worker + 模板 opts
ROVELA_API_KEY(sending_access)密钥Worker 进程环境 / 密钥库
按窗口查库 / 调 API你的 Worker 处理器
渲染 HTML + text你的 Worker
POST /emails、DKIM、入队、退信Rovela
Idempotency-Key = bullmq-cron-daily-digest:{yyyy-mm-dd}(或小时 / 租户桶)你的 Worker

Rovela 返回 200 表示 已接受并入队,不等于「已进收件箱」(快速开始)。

影响发信 Worker 的 BullMQ 约束

按官方文档,发信日程请当作硬性要求:

  • 优先 Job Scheduler,不用遗留 repeat。 使用 upsertJobScheduler / getJobSchedulers / removeJobScheduler。自 5.16.0 起遗留 Queue.add(..., { repeat }) 等已弃用,v6 已删除(遗留 Repeatable、迁移)。
  • pattern 与 every 互斥。 要么 cron pattern(cron-parser,秒字段可选),要么固定间隔 every(毫秒)(Repeat strategies)。
  • Cron 可含可选秒字段。 秒 分 时 日 月 周。工作日 09:00 示例:0 0 9 * * 1-5。
  • 墙钟用 tz。 IANA 时区(如 Asia/Tokyo);UTC 请设 tz: 'UTC',不要依赖已移除的遗留 utc 标志(Repeat options)。
  • 按调度器 id upsert。 同 id 再次调用会更新 repeat 选项和/或任务模板,不会重复建调度器。
  • 产出速率跟处理走。 调度器在上一次任务 开始处理 后才生成下一次;队列拥堵或 Worker 不足时,实际频率可能低于名义间隔。
  • 调度存活期间通常有一条 Delayed 任务 对应下一次触发。
  • 不能给 Scheduler 产出的任务自定义 jobId。 BullMQ 用特殊 id 保证不会比 repeat 设置更频繁;用 job name / data 区分(Job Schedulers)。
  • 模板 opts 管重试。 在第三参数模板上设置 attempts、backoff、removeOnFail 等。
  • 可选边界: startDate、endDate、limit。
  • sending_access 仅服务端。 放在 Worker 环境,不要塞进可能被广读的 Redis job.data、客户端或 Git(鉴权)。
  • 优先 HTTPS POST /emails。 Worker 里走 SMTP 通常更差(Email API vs SMTP)。

何时用 BullMQ Job Scheduler + POST /emails(而不是 scheduled_at)

适合 BullMQ cron / every → 即时发送 的情况:

  • HTML 必须在触发时带上 最新 指标
  • 你已有 Redis + BullMQ Worker,希望日程与同一套队列共存
  • 需要 Redis 持久的周期任务,又不想另上一套 SaaS 调度器
  • 每次只给运维 / 创始人 / 运行时查到的客户地址发少量事务信

正文已正确、只需「稍后再投」且不必常驻 Worker 时,用 Rovela scheduled_at(定时发送)。两者问题不同;本文是 BullMQ 路径。

若更想用 Trigger.dev / Inngest / QStash 的调度运行时,或平台自带 cron,见上文区分链接。

前置条件

  1. Rovela 上 已验证发信域名;未验证的 from 会 400(快速开始)。
  2. re_… 形态 API key;Worker 优先 sending_access(鉴权)。
  3. 生产者与 Worker 可达的 Redis,以及项目中的 bullmq。
  4. Worker 环境:ROVELA_API_KEY、MAIL_FROM、DIGEST_TO、REDIS_URL(及处理器所需的数据库 URL)。
  5. 至少有一个常驻 Worker——只有 Scheduler 不会发信。

Upsert Job Scheduler 与 Worker(TypeScript)

每日 09:00 UTC 的 cron pattern + tz: 'UTC':

// src/queues/digest.ts
import { Queue, Worker, type ConnectionOptions } from "bullmq";

const ROVELA_URL = "https://api.rovela.dev/emails";
const QUEUE_NAME = "transactional-email";

function requireEnv(name: string) {
  const v = process.env[name];
  if (!v) throw new Error(`missing env ${name}`);
  return v;
}

function redisConnection(): ConnectionOptions {
  return { url: requireEnv("REDIS_URL") } as ConnectionOptions;
}

function utcDayKey(d = new Date()) {
  return d.toISOString().slice(0, 10); // YYYY-MM-DD
}

function utcHourKey(d = new Date()) {
  return d.toISOString().slice(0, 13); // YYYY-MM-DDTHH
}

async function buildDailyDigest(day: string) {
  // 换成你对 Postgres / Redis / API 的查询
  const stats = { signups: 12, paid: 3, incidents: 1, failedJobs: 0 };
  const subject = `Acme daily digest — ${day}`;
  const text = [
    `Signups: ${stats.signups}`,
    `Paid: ${stats.paid}`,
    `Open incidents: ${stats.incidents}`,
    `Failed jobs: ${stats.failedJobs}`,
  ].join("\n");
  const html = `
    <h1>Daily digest — ${day}</h1>
    <ul>
      <li>Signups: <b>${stats.signups}</b></li>
      <li>Paid: <b>${stats.paid}</b></li>
      <li>Open incidents: <b>${stats.incidents}</b></li>
      <li>Failed jobs: <b>${stats.failedJobs}</b></li>
    </ul>
    <p><a href="https://app.example.com/admin">Open admin</a></p>
  `;
  return { subject, html, text, stats };
}

async function sendTransactional(opts: {
  to: string[];
  subject: string;
  html: string;
  text: string;
  idempotencyKey: string;
  tags: Record<string, string>;
}) {
  const res = await fetch(ROVELA_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${requireEnv("ROVELA_API_KEY")}`,
      "Content-Type": "application/json",
      "Idempotency-Key": opts.idempotencyKey,
    },
    body: JSON.stringify({
      from: requireEnv("MAIL_FROM"),
      to: opts.to,
      subject: opts.subject,
      html: opts.html,
      text: opts.text,
      tags: opts.tags,
    }),
  });
  const body = await res.json().catch(() => ({}));
  if (!res.ok) {
    throw new Error(`Rovela ${res.status}: ${(body as any).message ?? "send failed"}`);
  }
  return body as { object: string; id: string };
}

export async function ensureDailyDigestScheduler() {
  const queue = new Queue(QUEUE_NAME, { connection: redisConnection() });

  // Job Scheduler —— 推荐 API(不要用遗留 Queue.add + repeat)
  await queue.upsertJobScheduler(
    "daily-digest-email", // 稳定的调度器 id(upsert 键)
    {
      // 秒 分 时 日 月 周 —— 每天 09:00:00 UTC
      pattern: "0 0 9 * * *",
      tz: "UTC",
    },
    {
      name: "daily-digest-email",
      data: { kind: "daily-digest" },
      opts: {
        attempts: 5,
        backoff: { type: "exponential", delay: 3000 },
        removeOnComplete: 1000,
        removeOnFail: 5000,
      },
    }
  );

  await queue.close();
}

export function startDigestWorker() {
  const worker = new Worker(
    QUEUE_NAME,
    async (job) => {
      if (job.name !== "daily-digest-email") return;

      const when = job.timestamp ? new Date(job.timestamp) : new Date();
      const day = utcDayKey(when);
      const idempotencyKey = `bullmq-cron-daily-digest:${day}`;

      const digest = await buildDailyDigest(day);
      const to = requireEnv("DIGEST_TO")
        .split(",")
        .map((s) => s.trim())
        .filter(Boolean);

      // 切勿打印 ROVELA_API_KEY 或 Redis 密码
      console.log(
        JSON.stringify({
          day,
          idempotencyKey,
          jobId: job.id,
          schedulerKind: job.data?.kind,
        })
      );

      const result = await sendTransactional({
        to,
        subject: digest.subject,
        html: digest.html,
        text: digest.text,
        idempotencyKey,
        tags: {
          kind: "bullmq-cron-daily-digest",
          day,
          platform: "bullmq",
          jobName: job.name,
        },
      });

      return { ok: true, emailId: result.id, day };
    },
    { connection: redisConnection() }
  );

  worker.on("failed", (job, err) => {
    console.error(
      JSON.stringify({
        event: "digest_job_failed",
        jobId: job?.id,
        attemptsMade: job?.attemptsMade,
        message: err.message,
      })
    );
  });

  return worker;
}

在启动/迁移路径调用一次 ensureDailyDigestScheduler()(可重复,upsert)。在常驻进程中运行 startDigestWorker()。

本地时区早间摘要:

await queue.upsertJobScheduler(
  "tokyo-morning-ops-email",
  {
    pattern: "0 0 5 * * *", // 该 tz 下 05:00:00
    tz: "Asia/Tokyo",
  },
  {
    name: "tokyo-morning-ops-email",
    data: { kind: "tokyo-ops" },
    opts: { attempts: 5, backoff: { type: "exponential", delay: 3000 } },
  }
);

// Worker 内按日程时区的「日」做幂等键:
// const day = new Date(job.timestamp).toLocaleDateString("en-CA", { timeZone: "Asia/Tokyo" });
// const idempotencyKey = `bullmq-cron-tokyo-ops:${day}`;

固定间隔脉冲用 every(与 pattern 互斥):

await queue.upsertJobScheduler(
  "hourly-ops-email",
  { every: 60 * 60 * 1000 },
  {
    name: "hourly-ops-email",
    data: { kind: "hourly-ops" },
    opts: { attempts: 5, backoff: { type: "exponential", delay: 2000 } },
  }
);

// idempotencyKey = `bullmq-cron-hourly-ops:${utcHourKey(new Date(job.timestamp))}`

轮换时列出或删除:

const schedulers = await queue.getJobSchedulers();
await queue.removeJobScheduler("daily-digest-email");

窗口作用域的 Idempotency-Key

Rovela 的 Idempotency-Key(最长 256 字符)让 24 小时内相同请求的重试返回原响应,而不是再发一封(发送邮件 API)。对 BullMQ 很重要,因为:

  • Worker attempts / backoff 会在 5xx / 超时后再次进入处理器
  • Stall 恢复可能在已拿到 Rovela 200 但未 ack 时重跑
  • 队列延迟会让墙钟 Date.now() 漂移——应按任务日程窗口(job.timestamp / 业务日)键控
  • 多 Worker 共享同一 Redis 调度器——多租户时把租户 id 写进 key

推荐键:

Key适用
bullmq-cron-daily-digest:{yyyy-mm-dd}每个 UTC 日一封运维摘要
bullmq-cron-hourly-ops:{yyyy-mm-dd}T{HH}小时级 every / 小时 cron
bullmq-cron-tokyo-ops:{yyyy-mm-dd}本地时区早间摘要(按日程 tz 的日)
bullmq-cron-weekly-report:{yyyy}-W{ww}每周一封创始人报告
bullmq-cron-tenant-digest:{tenantId}:{yyyy-mm-dd}任务 data 按租户

一次 job 发很多个性化摘要时,优先每收件人一把 key(或对最多 100 封非模板消息用 POST /emails/batch——batch 会拒绝 template / topic_id(发送邮件 API))。

挂密钥并验证日程

  1. 在 Worker(以及 upsert 调度器的生产者)上设置 ROVELA_API_KEY(sending_access)、MAIL_FROM、DIGEST_TO、REDIS_URL。
  2. 部署 / 重启 Worker;每个环境跑一次 ensureDailyDigestScheduler()。
  3. 用 queue.getJobSchedulers() 确认 daily-digest-email 的 pattern / tz(或 every)。
  4. 等下一次 tick;非生产可用更短的 every 验证,并保持窗口 key 稳定。
  5. 在 Worker 日志里看 idempotencyKey + Rovela emailId;可用 webhook 事后对照。

生产发信优先 sending_access,不要用 full_access(鉴权)。切勿打印 API key。Rovela 轮换用先创建后吊销;Redis 凭据按托管方指引轮换。

故障与处理

现象可能原因处理
有 Scheduler 但不跑没有 Worker / 队列名不对在同一队列名 + Redis 上启动 Worker
比 cron 更稀积压 / 并发不足扩 Worker;记住下次 job 要等上次 开始处理
v6 上报遗留 repeat 错仍在用 Queue.add + repeat先在 v5 迁到 upsertJobScheduler(迁移)
墙钟不对缺 tz / 误当本地时设 tz: 'UTC' 或正确 IANA
Rovela 401ROVELA_API_KEY 错误 / 缺失修 Worker 环境;确认 sending_access
Rovela 400 提到域名MAIL_FROM 域名未验证在 Rovela Domains 验证
Rovela 429限流抛错让 BullMQ 重试;保持同一 Idempotency-Key
重复摘要重试 / 多 Worker 且 key 不稳用窗口 key;24h 内幂等会折叠重复
密钥进 Git / Redis payload提交了或塞进 job.data只放进程环境 / 密钥库
自定义 jobId 无效Scheduler 产出任务自带 id用 job name + data;唯一性放进 Idempotency-Key

生产检查清单

  • Rovela 发信域名已验证;MAIL_FROM 匹配
  • ROVELA_API_KEY 为 sending_access,仅在 Worker 环境
  • 使用 upsertJobScheduler(非遗留 repeat)
  • 文档化 pattern + tz 或 every;模板设好 attempts / backoff
  • 该队列至少有一个常驻 Worker
  • Rovela 发送使用窗口作用域 Idempotency-Key
  • Redis 高可用 / 持久化满足 RPO
  • 日志有 emailId 且不打印密钥
  • 连续 Worker 失败 / stall 有告警
  • 可选:对摘要收件人订阅 email.bounced / email.complained webhook

FAQ

BullMQ 能不经过我的 Worker 调 Rovela 吗?没有实质意义——Scheduler 只入队任务。处理器必须聚合实时数据并本地保管 sending_access。Rovela 不托管 BullMQ 或 Redis。

要不要在 Worker 里用 Rovela scheduled_at?通常不要。若 BullMQ 已在发送时刻带着实时正文触发,直接 POST /emails。若现在受理、稍后再投且不必再醒一次,才用 scheduled_at(定时发送)。

BullMQ vs Trigger.dev vs Inngest vs QStash vs 平台 cron?日程要跟自建 Redis 队列在一起时用 BullMQ;要 SaaS 任务运行时用 Trigger.dev / Inngest(Trigger.dev、Inngest);要 HTTP 原生带签名的日程用 QStash;日程应贴着某云平台算力时用该平台 cron。

pattern 还是 every?墙钟摘要(本地/UTC 09:00)优先 pattern + tz;不要求日历对齐的固定间隔优先 every。

遗留的 Queue.add + repeat 怎么办?升到 BullMQ v6 前先迁到 Job Scheduler——那里已无遗留 API(迁移)。

sending_access 能做什么?足以 POST /emails / batch 入队。读消息、改期、取消需要 full_access 或控制台会话(发送邮件 API、鉴权)。Worker 保持 sending_access。

200 表示创始人已经收到摘要了吗?否——表示 Rovela 已接受并入队。投递状态用 GET /emails/:id、events 或 webhook(快速开始)。

为什么既要 BullMQ attempts 又要 Rovela Idempotency-Key?BullMQ 在代码失败时重试 任务;Rovela 的 key 在同一窗口内 HTTP 成功两次(或接受后超时)时折叠重复 发送。两层都要。

上线 Scheduler,把投递交给 Rovela

用 cron pattern(或 every)upsert BullMQ Job Scheduler,在 Worker 里做实时聚合与 POST /emails,把 Idempotency-Key 限定在日程窗口(及租户),并保持 sending_access 仅服务端。Redis + BullMQ 准时唤醒;Rovela 投递事务邮件。