BullMQ 可重复 / Cron 任务事务邮件 + Rovela(2026):upsertJobScheduler 与按窗口的幂等键
用 BullMQ Job Scheduler(upsertJobScheduler 的 cron pattern 或 every)在 Redis 上调度,由 Worker 经 Rovela POST /emails 发送事务摘要:sending_access 仅服务端,并按日程窗口设置 Idempotency-Key。
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:
- 用
upsertJobScheduler(schedulerId, { pattern | every, tz?, … }, { name, data, opts })upsert 调度器 - 将
ROVELA_API_KEY(sending_access)、MAIL_FROM、Redis URL、收件人放在 仅服务端 环境变量——绝不进浏览器或公开仓库 - 在 Worker 处理器内按当前窗口聚合摘要 / 报告
- 经 Rovela 发送,并带上窗口作用域的
Idempotency-Key - 在 BullMQ 重试、stall 恢复、重复 tick 下仍不重复发信
Rovela 不会创建 BullMQ 队列、Redis key 或 Job Scheduler。 BullMQ 负责 schedule → delayed job → worker;你的 Worker 负责查询 → 映射字段 → POST /emails;Rovela 负责投递。
与下列文章区分:
- Trigger.dev 定时任务事务邮件 — Trigger.dev
schedules.task(非 Redis BullMQ) - Inngest Cron 事务邮件 — Inngest
cron+step.run - Upstash QStash Schedules 邮件 — QStash HTTPS 日程 + 签名
- Supabase pg_cron 邮件 — Postgres
pg_cron+pg_net - Kubernetes CronJob 邮件 —
batch/v1CronJob - DigitalOcean App Platform Jobs 邮件 —
kind: SCHEDULED - Heroku Scheduler 邮件 — Scheduler dyno
- Cloudflare Workers Cron 邮件 — Workers
triggers.crons - Azure Functions Timer 邮件 — Azure NCRONTAB
- GCP Cloud Scheduler 邮件 — Cloud Scheduler
- AWS EventBridge Scheduler 邮件 — EventBridge → Lambda
- Netlify Scheduled Functions 邮件 — Netlify UTC cron
- Fly Scheduled Machines 邮件 — Fly Machine 日程
- Render Cron 事务邮件 — Render Cron Job
- Railway Cron 事务邮件 — Railway cron
- Vercel Cron Digest 邮件 — 平台 cron 打生产路由
- GitHub Actions 事务邮件 — workflow / CI
- 定时发送邮件 — 正文已知时用 Rovela
scheduled_at
也不同于一次性 BullMQ delayed job(delay)或没有 Scheduler 的普通 queue.add——本文只覆盖 周期性 Job Scheduler / cron。
BullMQ 管什么,Rovela 发什么
| 事项 | 负责方 |
|---|---|
调度器 id、pattern / every、tz、startDate / endDate / limit | BullMQ upsertJobScheduler |
| 调度元数据与 delayed job 的 Redis 持久化 | 你的 Redis(队列前缀下的 BullMQ key) |
| 上次任务开始处理后产出下一次 delayed job | BullMQ Job Scheduler |
| Worker 并发、attempts、backoff | BullMQ 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互斥。 要么 cronpattern(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 环境,不要塞进可能被广读的 Redisjob.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,见上文区分链接。
前置条件
- Rovela 上 已验证发信域名;未验证的
from会400(快速开始)。 re_…形态 API key;Worker 优先sending_access(鉴权)。- 生产者与 Worker 可达的 Redis,以及项目中的
bullmq。 - Worker 环境:
ROVELA_API_KEY、MAIL_FROM、DIGEST_TO、REDIS_URL(及处理器所需的数据库 URL)。 - 至少有一个常驻 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))。
挂密钥并验证日程
- 在 Worker(以及 upsert 调度器的生产者)上设置
ROVELA_API_KEY(sending_access)、MAIL_FROM、DIGEST_TO、REDIS_URL。 - 部署 / 重启 Worker;每个环境跑一次
ensureDailyDigestScheduler()。 - 用
queue.getJobSchedulers()确认daily-digest-email的pattern/tz(或every)。 - 等下一次 tick;非生产可用更短的
every验证,并保持窗口 key 稳定。 - 在 Worker 日志里看
idempotencyKey+ RovelaemailId;可用 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 401 | ROVELA_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.complainedwebhook
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 投递事务邮件。