← 返回博客

Temporal Schedule 事务邮件 + Rovela(2026):Calendar / Interval / Cron Spec 与 Activity 幂等键

用 Temporal Schedule(calendars / intervals / cronExpressions)调度,由 Workflow Activity 经 Rovela POST /emails 发送事务摘要:sending_access 仅服务端,并按日程窗口设置 Idempotency-Key。有别于 BullMQ、Trigger.dev、Inngest 与 QStash。

更新于 2026年11月5日
Temporal Schedule 事务邮件 + Rovela(2026):Calendar / Interval / Cron Spec 与 Activity 幂等键

Temporal Schedule 管时钟 —— Rovela 负责投递

Temporal Schedules 会按 Schedule Spec 在指定时间启动 Workflow Execution:interval(every + 可选 phase)、calendar 表达式,和/或 cron 字符串,再减去 exclusions。它和 Temporal Cron Job(在 workflow.start 上挂 cronSchedule)不同:Schedule 是一等身份,可暂停、更新、回填、手动触发,而不必改写 Workflow。Temporal 官方对新项目更推荐 Schedules,而不是遗留 Cron Jobs(Schedules、TypeScript Schedules)。

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

当 Temporal Activity(绝不要在 Workflow 里直接发 HTTP)调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(Send Email API、Quick Start、Authentication)。Temporal 拥有 Spec、overlap 策略、catchup 窗口、Workflow 持久化与 Activity 重试;你的 Activity 拥有聚合逻辑、sending_access 密钥,以及按日程窗口稳定的 Idempotency-Key;Rovela 只负责投递。

本文接线 Temporal Schedule → Workflow → Activity → Rovela:

  1. 用 client.schedule.create({ scheduleId, spec, action, policies }) 创建 Schedule,Action 启动摘要 Workflow
  2. 将 ROVELA_API_KEY(sending_access)、MAIL_FROM、Temporal 连接与收件人放在 仅服务端 Worker 环境 —— 不要放进浏览器、会广泛落日志的 Workflow 参数,或公开 Git 仓库
  3. 在 Activity 内按当前日程窗口聚合摘要 / 报表
  4. 经 Rovela 发送,并使用窗口作用域的 Idempotency-Key
  5. 在 Activity 重试、Worker 重启与重复 Schedule tick 下避免重复发信

Rovela 不会创建 Temporal Schedule、Namespace 或 Task Queue。 Temporal 负责 schedule → Workflow → Activity;Activity 负责查询 → 映射字段 → POST /emails;Rovela 负责投递。

与下列文章区分开:

也不同于 Temporal Cron Jobs(cronSchedule)与一次性 startDelay —— 本文只讲 Schedule 路径(TypeScript Schedules)。

Temporal 调度什么,Rovela 发送什么

关注点归属
Schedule id、Spec(calendars / intervals / cronExpressions)、timezone、startAt / endAt、skip、jitterTemporal ScheduleSpec / Schedule
Overlap、catchup、失败暂停、回填、暂停 / 触发 / 更新Temporal Schedule 策略与 handle API
启动 Workflow Action(类型、task queue、参数、超时)Temporal Schedule Action
Workflow 编排(确定性;Workflow 内不做 HTTP / 读密钥)你的 Workflow
发送用的 Activity 重试 / 超时Temporal Activity options
ROVELA_API_KEY(sending_access)服务端密钥Worker 进程环境 / 密钥库
按窗口查询 DB / API你的 Activity
渲染 HTML + text你的 Activity
POST /emails、DKIM、入队、退信Rovela
Idempotency-Key = temporal-schedule-daily-digest:{yyyy-mm-dd}(或小时 / 租户桶)你的 Activity

Rovela 返回 200 表示已接受并入队,不等于「已经进收件箱」(Quick Start)。

影响发信 Activity 的 Temporal Schedule 约束

根据 Temporal 文档,发信 Schedule 请当作硬性要求:

  • 优先用 Schedules,而不是 Cron Jobs。 Schedule 可暂停、更新、回填,Spec 更丰富。Cron Jobs 把 cronSchedule 挂在 Workflow 启动上,不适合新用例(Schedule、TypeScript Schedules)。
  • Spec 是并集。 触发时刻 = calendars ∪ intervals ∪ cronExpressions,再减去 skip(ScheduleSpec)。
  • Interval 要注意 phase。 Interval 默认对齐 Unix epoch;需要人类墙钟时刻时用 calendar,或计算 phase offset(Schedule Spec)。
  • Schedule 上的 cron 偏迁移。 TypeScript cronExpressions 方便从遗留 Cron Workflow 迁过来;新 Spec 更推荐 calendars 或 intervals(ScheduleSpec)。cron 可为 5 / 6 / 7 段;支持 @daily、@hourly、@every 45m 等。
  • 默认时区 UTC。 需要本地墙钟时设置 IANA timezone,并注意夏令时缺口 / 重叠的行为说明(ScheduleSpec)。
  • Overlap 对邮件很重要。 默认 Skip 可避免上一轮未结束时再叠一封摘要;AllowAll 会并发启动多个 Workflow —— 务必配合窗口级幂等键(Overlap Policy)。
  • Catchup 窗口限制迟到的自动 Action。 故障恢复后,超过 catchup 的自动触发可能被跳过(默认很长;时间敏感摘要应收紧)(Catchup Window)。
  • HTTP 放在 Activity。 Workflow 必须确定性 —— 不要在 Workflow 里 fetch、读密钥环境变量,或拿非确定性时钟做业务。把 Rovela POST /emails 放进带 startToCloseTimeout / retry 的 Activity。
  • Schedule 启动的 Workflow Id 会追加时间戳后缀,并带上 TemporalScheduledStartTime / TemporalScheduledById(Action)。
  • sending_access 仅服务端。 ROVELA_API_KEY 放在 Worker 环境,不要进 Workflow memo、前端或 Git(Authentication)。
  • 优先 HTTPS POST /emails。 Worker 里跑出站 SMTP 通常不合适(Email API vs SMTP)。

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

在下列情况用 Temporal Schedule → 实时发送:

  • HTML 必须包含触发时刻的新鲜指标
  • 你已在跑 Temporal,希望日程与同一批 Worker 放在一起
  • 需要 pause / backfill / overlap 等平台 cron 很少干净提供的控制
  • 一两封事务邮件发给运行时查出的运维 / 创始人 / 客户地址

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

若你更想用 BullMQ / Trigger.dev / Inngest / QStash,见上文互链。若日程应贴着某云主机算力,用对应平台 cron 文。

前置条件

  1. Rovela 上已验证的发信域名。未验证 from 会 400(Quick Start)。
  2. 形如 re_… 的 API key。Activity Worker 优先用 sending_access(Authentication)。
  3. Temporal 服务(自建或 Temporal Cloud)与 TypeScript SDK(@temporalio/client、worker、workflow、activity)。
  4. Worker 环境:ROVELA_API_KEY、MAIL_FROM、DIGEST_TO、Temporal 地址 / namespace / API key,以及 Activity 需要的 DB URL。
  5. 至少一个长期运行的 Worker 轮询 Schedule 的 task queue —— 只有 Schedule 不会发信。

创建 Schedule、Workflow 与 Activity(TypeScript)

每天 09:00 UTC 的日历 Spec(新 Spec 优先于 cron):

// src/workflows/dailyDigest.ts
import { proxyActivities } from "@temporalio/workflow";
import type * as activities from "../activities/rovelaDigest";

const { sendDailyDigestEmail } = proxyActivities<typeof activities>({
  startToCloseTimeout: "2 minutes",
  retry: {
    initialInterval: "5s",
    backoffCoefficient: 2,
    maximumAttempts: 5,
  },
});

/** 确定性 Workflow:只编排 —— HTTP 在 Activity。 */
export async function dailyDigestWorkflow(): Promise<{ emailId: string }> {
  return await sendDailyDigestEmail();
}
// src/activities/rovelaDigest.ts
const ROVELA_URL = "https://api.rovela.dev/emails";

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
}

export async function sendDailyDigestEmail(): Promise<{ emailId: string }> {
  const apiKey = requireEnv("ROVELA_API_KEY"); // sending_access
  const from = requireEnv("MAIL_FROM"); // e.g. Acme <hello@mail.acme.com>
  const to = requireEnv("DIGEST_TO");

  const day = utcDayKey();
  const signups = await countSignupsSince(day);
  const unpaid = await countUnpaidInvoices();
  const subject = `Daily digest ${day}: ${signups} signups, ${unpaid} unpaid`;
  const text = `Signups today: ${signups}\nUnpaid invoices: ${unpaid}\n`;
  const html = `<h1>Daily digest ${day}</h1><p>Signups: <b>${signups}</b></p><p>Unpaid: <b>${unpaid}</b></p>`;

  const idempotencyKey = `temporal-schedule-daily-digest:${day}`;

  const res = await fetch(ROVELA_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify({ from, to, subject, html, text, tags: { kind: "daily-digest", day } }),
    signal: AbortSignal.timeout(15_000),
  });

  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(`Rovela ${res.status}: ${(err as any).message ?? "send failed"}`);
  }

  const body = (await res.json()) as { id: string };
  return { emailId: body.id };
}

async function countSignupsSince(_day: string) {
  return 0;
}
async function countUnpaidInvoices() {
  return 0;
}
// src/schedules/createDailyDigest.ts
import { Connection, Client, ScheduleOverlapPolicy } from "@temporalio/client";
import { dailyDigestWorkflow } from "../workflows/dailyDigest";

export async function createDailyDigestSchedule() {
  const connection = await Connection.connect({
    // address / tls / apiKey —— 按 Temporal Cloud 或自建运维配置
  });
  const client = new Client({ connection, namespace: process.env.TEMPORAL_NAMESPACE ?? "default" });

  const schedule = await client.schedule.create({
    scheduleId: "daily-digest-email",
    action: {
      type: "startWorkflow",
      workflowType: dailyDigestWorkflow,
      taskQueue: "transactional-email",
    },
    policies: {
      overlap: ScheduleOverlapPolicy.SKIP,
      catchupWindow: "1 day",
    },
    spec: {
      calendars: [
        {
          comment: "every day at 09:00 UTC",
          hour: 9,
          minute: 0,
        },
      ],
      timezone: "UTC",
    },
  });

  return schedule.scheduleId;
}

在轮询 transactional-email 的 Worker 上注册 dailyDigestWorkflow 与 sendDailyDigestEmail,再用安全的运维脚本或迁移创建一次 Schedule。

Interval 与 cron Spec 变体

spec: {
  intervals: [{ every: "6h" }],
}

spec: {
  cronExpressions: ["0 9 * * MON-FRI"],
  timezone: "America/New_York",
}

运维可用的 CLI 等价写法(temporal schedule create):

temporal schedule create \
  --schedule-id daily-digest-email \
  --calendar '{"hour":"9","minute":"0"}' \
  --timezone UTC \
  --overlap-policy Skip \
  --catchup-window 1d \
  --workflow-id daily-digest \
  --task-queue transactional-email \
  --type dailyDigestWorkflow

幂等:Schedule tick、Activity 重试、一封邮件

Worker 崩溃、网络抖动或抛错后,Temporal 可能重试 Activity;AllowAll、手动 trigger / backfill 也可能为相近窗口启动多个 Workflow。Rovela 的 Idempotency-Key(最长 256 字符,约 24 小时内记住)会返回原先的接受响应,而不是再发一封(Send Email API)。

经验法则:

  1. 按日程窗口做键 —— temporal-schedule-daily-digest:2026-11-05,或加上小时 / 租户。
  2. 不要每次 Activity attempt 用随机 UUID —— 会毁掉重试下的幂等。
  3. 保留 Activity 重试处理传输 / 5xx / 抛错 —— Temporal 恢复任务;Rovela 折叠同一键的重复接受。
  4. 单日摘要优先 Skip overlap,避免慢任务叠并发发送。
  5. 把 scheduled_at 当作另一工具 —— 正文已知时再冻住投递;不要把「实时聚合 → 发送」路径和它混在一起,除非你明确要 Rovela 持有已完成正文。

安全清单

  • Worker 使用 sending_access。它能入队发信,但不能读信、改期或取消(Authentication、Send Email API)。
  • 切勿把 ROVELA_API_KEY 放进 Workflow 代码、Schedule memo、前端包或公开仓库。
  • 生产前验证 from 域名(未验证会 400)。
  • 单封邮件收件人合计 ≤ 50(to / cc / bcc)。
  • 日志记 Rovela id 与 Temporal Workflow / Schedule id —— 不要记 API key。

运维:暂停、触发、回填

const handle = client.schedule.getHandle("daily-digest-email");

await handle.pause("deploy freeze");
await handle.unpause("resume after deploy");

await handle.trigger();

await handle.backfill({
  start: new Date("2026-11-01T00:00:00Z"),
  end: new Date("2026-11-02T00:00:00Z"),
  overlap: ScheduleOverlapPolicy.BUFFER_ONE,
});

Backfill 与 trigger 可能为相近窗口产生多次 Workflow 启动 —— 保持窗口级 Idempotency-Key,让 Rovela 仍折叠同一天的重复摘要(Backfill、TypeScript Schedules)。

常见问题

Schedules 和 Temporal Cron Jobs?新的周期任务用 Schedules:可暂停、更新、回填、overlap,以及组合 calendar / interval / cron 的 Spec。Cron Jobs 是启动时的 cronSchedule,不适合新用例。

Calendars、cronExpressions、intervals?新 Spec 优先 calendars 与 intervals。cronExpressions 便于从遗留 Cron Workflow 迁移。触发时刻是三者并集再减 skip。

为什么不在 Workflow 里调 Rovela?Workflow 必须确定性。网络 I/O、密钥与墙钟聚合应在带超时与重试的 Activity 中。

sending_access 能做什么?足够经 POST /emails / batch 入队。读信、改期、取消需要 full_access 或控制台会话。Worker 保持 sending_access。

200 表示创始人已经收到摘要?否 —— 只表示 Rovela 已接受并入队。投递状态请用 GET /emails/:id、事件或 webhook。

为何既要 Temporal Activity 重试又要 Rovela Idempotency-Key?Temporal 在代码失败时重试 Activity;Rovela 的键在同一窗口内 HTTP 成功两次(或超时后 Rovela 已接受)时折叠重复 发送。两层都要。

和 BullMQ / Inngest / Trigger.dev / QStash 有何不同?那些文章用 Redis 任务调度或 SaaS 日程运行时。本文用 Temporal Schedule Spec(calendar / interval / cron)启动持久 Workflow,由其 Activity 调用 Rovela。

上线 Schedule,投递给 Rovela

用 calendar(或 interval / cron)Spec 创建 Temporal Schedule,把实时聚合与 POST /emails 放进 Activity,按日程窗口(与租户)设置 Idempotency-Key,并保持 sending_access 仅服务端。Temporal 按时唤醒 Workflow;Rovela 投递事务邮件。