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。
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:
- 用
client.schedule.create({ scheduleId, spec, action, policies })创建 Schedule,Action 启动摘要 Workflow - 将
ROVELA_API_KEY(sending_access)、MAIL_FROM、Temporal 连接与收件人放在 仅服务端 Worker 环境 —— 不要放进浏览器、会广泛落日志的 Workflow 参数,或公开 Git 仓库 - 在 Activity 内按当前日程窗口聚合摘要 / 报表
- 经 Rovela 发送,并使用窗口作用域的
Idempotency-Key - 在 Activity 重试、Worker 重启与重复 Schedule tick 下避免重复发信
Rovela 不会创建 Temporal Schedule、Namespace 或 Task Queue。 Temporal 负责 schedule → Workflow → Activity;Activity 负责查询 → 映射字段 → POST /emails;Rovela 负责投递。
与下列文章区分开:
- BullMQ 可重复 / Cron 任务事务邮件 —— Redis BullMQ
upsertJobScheduler(非 Temporal) - Trigger.dev Cron 事务邮件 —— Trigger.dev
schedules.task - Inngest Cron 事务邮件 —— Inngest
cron+step.run - Upstash QStash Schedules 邮件 —— QStash HTTPS 日程 +
Upstash-Signature - Supabase pg_cron 邮件 —— Postgres
pg_cron+pg_net - Kubernetes CronJob 邮件 ——
batch/v1CronJob → Job Pod - DigitalOcean App Platform Jobs 邮件 —— App Platform
kind: SCHEDULED - Heroku Scheduler 邮件 —— 粗粒度 Scheduler dyno
- Cloudflare Workers Cron Triggers 邮件 —— Workers
triggers.crons - Azure Functions Timer 邮件 —— Azure NCRONTAB
- GCP Cloud Scheduler 邮件 —— Cloud Scheduler → Cloud Functions / Pub/Sub
- AWS EventBridge Scheduler 邮件 —— EventBridge Scheduler → Lambda
- Netlify Scheduled Functions 邮件 —— Netlify UTC cron
- Fly Scheduled Machines 邮件 —— Fly Machine schedules
- Render Cron 事务邮件 —— Render Cron Job
- Railway Cron 事务邮件 —— Railway cron service
- Vercel Cron Digest 邮件 —— 平台 cron
GET生产路由 - GitHub Actions 事务邮件 —— workflow / CI 通知
- 定时发送邮件 —— 正文已知时用 Rovela
scheduled_at
也不同于 Temporal Cron Jobs(cronSchedule)与一次性 startDelay —— 本文只讲 Schedule 路径(TypeScript Schedules)。
Temporal 调度什么,Rovela 发送什么
| 关注点 | 归属 |
|---|---|
Schedule id、Spec(calendars / intervals / cronExpressions)、timezone、startAt / endAt、skip、jitter | Temporal 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、读密钥环境变量,或拿非确定性时钟做业务。把 RovelaPOST /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 文。
前置条件
- Rovela 上已验证的发信域名。未验证
from会400(Quick Start)。 - 形如
re_…的 API key。Activity Worker 优先用sending_access(Authentication)。 - Temporal 服务(自建或 Temporal Cloud)与 TypeScript SDK(
@temporalio/client、worker、workflow、activity)。 - Worker 环境:
ROVELA_API_KEY、MAIL_FROM、DIGEST_TO、Temporal 地址 / namespace / API key,以及 Activity 需要的 DB URL。 - 至少一个长期运行的 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)。
经验法则:
- 按日程窗口做键 ——
temporal-schedule-daily-digest:2026-11-05,或加上小时 / 租户。 - 不要每次 Activity attempt 用随机 UUID —— 会毁掉重试下的幂等。
- 保留 Activity 重试处理传输 / 5xx / 抛错 —— Temporal 恢复任务;Rovela 折叠同一键的重复接受。
- 单日摘要优先
Skipoverlap,避免慢任务叠并发发送。 - 把
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 投递事务邮件。