← 返回博客

Agenda.js / node-agenda 定时任务事务邮件 + Rovela(2026):define + every 与按窗口幂等键

用 Agenda.js(MongoDB 后端的 Agenda 6.x define + every/schedule)调度,由任务处理器经 Rovela POST /emails 发送事务摘要:sending_access 仅服务端,并按日程窗口设置 Idempotency-Key。

更新于 2026年11月5日
Agenda.js / node-agenda 定时任务事务邮件 + Rovela(2026):define + every 与按窗口幂等键

Agenda.js 负责 MongoDB 定时任务 — Rovela 负责投递邮件

Agenda(常被搜作 Agenda.js / node-agenda)是面向 Node.js 的轻量任务调度库。Agenda 6.x 需要单独安装存储后端——常见是 @agendajs/mongo-backend(MongoDB)——然后用 define 注册处理器,再用 every、schedule 或 now 安排执行。周期性任务会落成一等的任务文档(every 生成 single 类型),因此 Web 进程重启后再次调用 every 也不会复制出多行 cron(Agenda 文档、npm agenda)。

这种形态适合摘要、运维报告、试用到期提醒等事务邮件:正文依赖触发时刻的实时应用 / 数据库状态(注册数、未付发票、失败任务),而不是可以提前几小时用 Rovela scheduled_at 冻住的内容。

当你的 Agenda 任务处理器调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(发送邮件 API、快速开始、鉴权)。Agenda(加上 MongoDB,或其他 Agenda 后端)负责日程、锁、并发与重试。你的处理器负责聚合、保管 sending_access 密钥,以及按日程窗口稳定的 Idempotency-Key。Rovela 只负责投递。

本指南串联 Agenda define + every / schedule → 任务处理器 → Rovela,实现:

  1. 用 agenda.define(name, handler, options?) 定义任务(Agenda v6 参数顺序:先 handler,再 options——见 v5→v6 迁移指南)
  2. 将 ROVELA_API_KEY(sending_access)、MAIL_FROM、Mongo URL 与收件人放在仅服务端环境——绝不放进浏览器、公开 Git,或会被广泛打印的 job payload
  3. 在处理器内按当前日程窗口聚合摘要 / 报告
  4. 经 Rovela 发送,并带上按窗口作用域的 Idempotency-Key
  5. 在 Agenda 锁重试、进程重启与重复触发下仍不重复发信

Rovela 不会创建 Agenda 集合、Mongo 索引或 cron 行。 Agenda 负责 schedule → lock → handler。你的处理器负责查询 → 映射字段 → POST /emails。Rovela 负责投递。

与下列文章区分:

也不同于没有周期性 every 的一次性 Agenda schedule / now——本文聚焦 Mongo 支撑的周期性 cron / 间隔路径(并对一次性 schedule 做简短说明)。

Agenda 调度什么,Rovela 发送什么

关注点责任方
任务名、every 间隔 / cron、timezone、skipImmediate、startDate / endDate / skipDaysAgenda every
任务文档持久化与加锁你的 MongoDB(或其他 Agenda 后端)
轮询(processEvery)、并发、lockLifetimeAgenda 实例 + define 选项
ROVELA_API_KEY(sending_access)服务端密钥Agenda worker 进程环境 / 密钥库
按窗口查询 DB / API你的 define 处理器
渲染 HTML + text你的处理器
POST /emails、DKIM、入队、退信Rovela
Idempotency-Key = agenda-cron-daily-digest:{yyyy-mm-dd}(或小时 / 租户桶)你的处理器

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

影响发信 worker 的 Agenda 约束

根据 Agenda 公开文档(v6.x),把这些当作发信日程的硬要求:

  • 安装核心 + 后端。 npm install agenda @agendajs/mongo-backend(Node.js 18+,MongoDB 4+)。v6 核心包不再内置 MongoDB(迁移指南、mongo-backend README)。
  • 用 backend: new MongoBackend(...) 构造。 传 address 或已有 mongo Db;可选 collection(默认 agendaJobs)、ensureIndex、sort(Agenda 文档)。
  • define(name, fn, options?) — v6 中 handler 在 options 之前。 选项含 concurrency、lockLimit、lockLifetime、priority、backoff、removeOnComplete(define)。
  • every(interval, name, data?, options?) 创建 single 任务。 每次启动再跑同一行 every 仍只有一条周期任务——专为经常重启的文件设计(every)。
  • 间隔形式。 人类可读('15 minutes'、'1 hour')、cron 字符串('0 * * * *')或 Number(毫秒)(every)。
  • every 的时区与边界。 需要 timezone、skipImmediate、forkMode、startDate、endDate、skipDays(0=周日 … 6=周六)时,必须同时传 data(every)。
  • 一次性用 schedule(when, name, data?)。 人类可读('tomorrow at noon'、'in 5 minutes')或 Date(schedule)。
  • 先 start();关停用 drain() / stop()。 有关停时限的平台优先 drain(timeoutMs),让进行中的 Rovela 调用有机会结束(Stopping / Draining)。
  • sending_access 仅服务端。 把 ROVELA_API_KEY 放在 Agenda 进程环境——不要放进运维会浏览的 Mongo job data、客户端包或 Git(鉴权)。
  • 优先 HTTPS POST /emails。 从 job worker 直接出站 SMTP 通常不合适(Email API vs SMTP)。

何时用 Agenda + POST /emails(而不是 scheduled_at)

在这些情况下用 Agenda every / cron → 实时发送:

  • HTML 必须包含触发时的新鲜指标
  • 你已有 MongoDB(或其他 Agenda 后端),希望周期任务与同一 Node 应用放在一起
  • 需要自托管、可持久的 cron,又不想上 Redis BullMQ、Temporal 或 SaaS 调度器
  • 向运维 / 创始人 / 运行时查到的客户地址发送一封(或少量)事务邮件

当正文已经正确、只需要「稍后再投」且不必常驻 Agenda worker 时,用 Rovela scheduled_at(定时发送邮件)。Agenda 日程与 scheduled_at 解决不同问题;本文是 Agenda 路径。

若你更想用 BullMQ / Temporal / Trigger.dev / Inngest / QStash,见上文链接的对应文章。若日程应紧挨某云平台算力,则用该平台的 cron 文。

前置条件

  1. Rovela 中已验证的发信域名。未验证的 from 会以 400 拒绝(快速开始)。
  2. 形如 re_… 的 API 密钥。Agenda worker 优先 sending_access(鉴权)。
  3. Agenda 进程可访问的 MongoDB,并已安装 agenda 与 @agendajs/mongo-backend。
  4. 进程环境:ROVELA_API_KEY、MAIL_FROM、DIGEST_TO、MONGODB_URI(以及处理器需要的其他 DB URL)。
  5. 至少一个长期运行并调用 agenda.start() 的进程——只 define 而不启动 Agenda 不会发信。

define + every + Rovela(TypeScript,Agenda 6.x)

每天 09:00 UTC 的摘要,使用 cron 字符串与 timezone: 'UTC':

// src/jobs/digest-agenda.ts
import { Agenda } from "agenda";
import { MongoBackend } from "@agendajs/mongo-backend";

const ROVELA_URL = "https://api.rovela.dev/emails";
const JOB_NAME = "daily-digest-email";

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

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

async function buildDailyDigest(day: string) {
  // 换成你对 Mongo / Postgres / 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 startDigestAgenda() {
  const agenda = new Agenda({
    backend: new MongoBackend({
      address: requireEnv("MONGODB_URI"),
      collection: "agendaJobs",
    }),
    processEvery: "30 seconds",
    maxConcurrency: 20,
  });

  // v6: define(name, handler, options?) — options 在后
  agenda.define(
    JOB_NAME,
    async (job) => {
      const when =
        (job.attrs.lastRunAt as Date | undefined) ??
        (job.attrs.nextRunAt as Date | undefined) ??
        new Date();
      const day = utcDayKey(when instanceof Date ? when : new Date(when));
      const idempotencyKey = `agenda-cron-daily-digest:${day}`;

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

      // 切勿打印 ROVELA_API_KEY 或 Mongo 密码
      console.log(
        JSON.stringify({
          day,
          idempotencyKey,
          jobName: job.attrs.name,
          jobId: String(job.attrs._id ?? ""),
          kind: job.attrs.data?.kind,
        })
      );

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

      return { ok: true, emailId: result.id, day };
    },
    {
      concurrency: 1, // 每个实例同一时间只发一封摘要
      lockLifetime: 10 * 60 * 1000,
      priority: "normal",
    }
  );

  await agenda.start();

  // every 创建单条周期任务 — 每次启动调用都安全
  await agenda.every(
    "0 9 * * *", // 09:00 UTC(cron)
    JOB_NAME,
    { kind: "daily-digest" },
    { timezone: "UTC", skipImmediate: true }
  );

  agenda.on("fail", (err, job) => {
    console.error(
      JSON.stringify({
        event: "agenda_job_failed",
        jobName: job?.attrs?.name,
        message: err.message,
      })
    );
  });

  const shutdown = async () => {
    // 等待进行中的 Rovela POST(云平台常见约 30s)
    await agenda.drain(30_000);
    process.exit(0);
  };
  process.on("SIGTERM", shutdown);
  process.on("SIGINT", shutdown);

  return agenda;
}

在长期运行的专用 worker(或你有意把 Agenda 与 API 同进程部署时的 API 进程)中调用 startDigestAgenda()。

本地时区早晨摘要:

await agenda.every(
  "0 5 * * *", // 该时区 05:00
  "tokyo-morning-ops-email",
  { kind: "tokyo-ops" },
  { timezone: "Asia/Tokyo", skipImmediate: true }
);

// 在处理器中按日程时区的「日」构造幂等键:
// const day = new Date().toLocaleDateString("en-CA", { timeZone: "Asia/Tokyo" });
// const idempotencyKey = `agenda-cron-tokyo-ops:${day}`;

人类可读间隔(every 同样支持):

await agenda.every("1 hour", "hourly-ops-email", { kind: "hourly-ops" }, {
  skipImmediate: true,
});

// 小时级幂等键示例:
// const hour = new Date().toISOString().slice(0, 13); // YYYY-MM-DDTHH
// const idempotencyKey = `agenda-cron-hourly-ops:${hour}`;

用 schedule 做一次性延迟事务发送(不是周期 cron):

await agenda.schedule("in 15 minutes", "trial-expiry-nudge", {
  userId: "u_123",
  kind: "trial-nudge",
});
// 处理器应使用类似 `agenda-schedule-trial-nudge:u_123:2026-11-05` 的 Idempotency-Key,
// 避免 Agenda 重试 / 锁重跑导致双发。

按窗口的 Idempotency-Key(必需)

Agenda 可能在崩溃、锁过期或 handler 抛错后重跑任务。Rovela 的 Idempotency-Key(最长 256 字符,24 小时重放窗口)会返回原响应而不是再发一封(发送邮件 API)。

节奏示例键
日摘要agenda-cron-daily-digest:2026-11-05
小时运维agenda-cron-hourly-ops:2026-11-05T09
按租户日摘要agenda-cron-tenant-digest:tenant_42:2026-11-05
一次性 scheduleagenda-schedule-trial-nudge:u_123:2026-11-05

不要每次尝试用随机 UUID——那会毁掉幂等。不要把 API 密钥写进键或日志。

密钥与运维清单

  • Agenda 进程环境 / 密钥管理中仅使用 sending_access 的 ROVELA_API_KEY(鉴权)
  • MAIL_FROM 落在已验证域名
  • MONGODB_URI 对 Agenda 集合最小权限
  • 收件人来自环境或服务端查询——不要硬编码进公开仓库
  • SIGTERM 时优雅 drain(),让进行中的 POST /emails 有机会完成
  • 监控 Agenda fail 事件,以及 Rovela webhook / GET /emails/:id 投递状态(快速开始)

排障

现象可能原因
任务从不跑忘了 await agenda.start(),或没有进程让 Agenda 常驻
升级后出现重复 cron 行混用 v5 的 db / mongo 构造与 v6 的 backend——按迁移指南改
every 的 options 被忽略使用 options 参数时必须同时传 data(every)
Rovela 401Bearer 密钥缺失/无效,或密钥类型不匹配该端点
Rovela 对 from 返回 400域名未验证(快速开始)
重试时双发缺少或不稳定的按窗口 Idempotency-Key
负载下 handler 超时提高 lockLifetime、降低 concurrency,或把重聚合移出关键路径

小结

Agenda.js 用 define、every 与 schedule,在 MongoDB(或 v6 的 Postgres / Redis 后端)上调度周期与一次性工作。你的任务处理器聚合实时状态,并用仅服务端的 sending_access 密钥与按窗口的 Idempotency-Key 调用 Rovela POST /emails。Rovela 负责投递;Agenda 不会变成 ESP,Rovela 也不会变成你的 cron。

下一步:验证域名,创建 sending_access 密钥,在预发环境跑上述 worker,再用返回的邮件 id 与事件 / webhook 确认投递。