Sidekiq-Cron 事务邮件 + Rovela(2026):schedule.yml / Job.create 与按窗口幂等键
用 Sidekiq-Cron 周期任务(YAML cron 或 Job.create)对接 Rovela POST /emails:服务端 sending_access,按调度窗口 Idempotency-Key,适合摘要与运维报告。
Sidekiq-Cron 管调度,Rovela 负责投递
Sidekiq-Cron 是 Sidekiq 的调度扩展。它在 Sidekiq worker 旁跑一条线程,按你声明的 cron(* * * * *)或 Fugit 自然语言(every day at five)把任务入队。轮询默认约每 30 秒检查一次到期任务;多进程 / 多节点下也安全——不会把同一个 cron 任务重复入队。
这种形态适合摘要、运维报告、试用到期提醒等事务邮件:正文依赖触发时刻的实时应用 / 数据库状态(注册数、未付发票、失败任务),而不是可以提前几小时用 Rovela scheduled_at 冻住的内容。
当你的 Sidekiq worker 调用 POST https://api.rovela.dev/emails 时,Rovela 负责排队、DKIM 签名与投递(Send Email API、Quick Start、Authentication)。Sidekiq-Cron 拥有节奏与“是否到期”的逻辑;worker 拥有聚合、sending_access 密钥,以及按调度窗口稳定的 Idempotency-Key。Rovela 只负责投递。
本指南串联 Sidekiq-Cron → Sidekiq worker → Rovela,做到:
- 在
config/schedule.yml(或Sidekiq::Cron::Job.create/load_from_hash!)声明周期任务 - 把
ROVELA_API_KEY(sending_access)、MAIL_FROM、Redis URL、收件人放在仅服务端环境变量——绝不进浏览器或公开 Git - 在
perform内聚合当前调度窗口的摘要 / 报告 - 用按窗口作用域的
Idempotency-Key调用 Rovela - 在 Sidekiq 重试、进程重启、多进程轮询下仍不重复发信
Rovela 不会创建 Sidekiq-Cron 计划、Redis 或 worker。 Sidekiq-Cron 管 schedule → 入队;你的 worker 管查询 → 映射字段 → POST /emails;Rovela 管投递。
与下列文章区分:
- Celery Beat 事务邮件 — Python Celery
beat_schedule/crontab - Agenda.js 定时任务邮件 — Mongo 上的
agendadefine/every(Node) - Temporal Schedule 邮件 — Temporal Schedule Spec + Activity
- BullMQ 可重复 / Cron 任务邮件 — Redis BullMQ
upsertJobScheduler - 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 邮件 — 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 服务
- Vercel Cron Digest 邮件 — 平台 cron
GET生产路由 - GitHub Actions 事务邮件 — workflow / CI 通知
- 定时发送邮件 — 正文已知时用 Rovela
scheduled_at
也与一次性的 Sidekiq perform_in / perform_at 延迟区分——本文只讲 Sidekiq-Cron 周期任务路径(schedule.yml / Sidekiq::Cron::Job)。Sidekiq Enterprise 自带的 Periodic Jobs 是另一套商业能力;本指南使用开源 sidekiq-cron gem。
Sidekiq-Cron 调度什么,Rovela 发送什么
| 关注点 | 归属 |
|---|---|
任务 name、cron、class、queue、args、namespace、status | Sidekiq-Cron(Job properties) |
计划文件 / hash 加载(config/schedule.yml、load_from_hash!) | Sidekiq server 启动时的 Sidekiq-Cron |
轮询间隔(cron_poll_interval,默认 30s) | Sidekiq::Cron.configure |
| cron 求值时区 | Rails 时区,或 cronline 后缀时区(0 9 * * * America/Chicago) |
| 到期时入队 worker | Sidekiq-Cron 轮询线程(多进程安全) |
| 任务重试、确认、并发 | Sidekiq worker + sidekiq_options retry: |
ROVELA_API_KEY(sending_access)服务端密钥 | 你的 Sidekiq 进程环境 / 密钥库 |
| 按窗口查询 DB / API | 你的 worker perform |
| 渲染 HTML + text | 你的 worker perform |
POST /emails、DKIM、排队、退信 | Rovela |
Idempotency-Key = sidekiq-cron-daily-digest:{yyyy-mm-dd}(或小时 / 租户桶) | 你的 worker |
Rovela 返回 200 表示已接受并入队,不代表“已进收件箱”(Quick Start)。
影响发信 worker 的 Sidekiq-Cron 约束
结合 Sidekiq-Cron README,发信调度请当作硬要求:
- 至少要有一个 Sidekiq 进程在跑。 只有 Sidekiq 起来时才会调度 cron;整机停掉等于计划静默。
- 多进程可用。 gem 文档写明多进程时不会把同一任务重复调度。仍要用 Rovela
Idempotency-Key防重试与崩溃重投。 - 默认轮询 30 秒。 若用亚分钟(6 字段)cron,需把
cron_poll_interval降到低于任务间隔。 - 默认自动加载
config/schedule.yml。 支持 ERB。可用cron_schedule_file覆盖;或设为nil后用load_from_hash!(…, source: "schedule")手动加载,以便 YAML 删除项被裁剪。 - 时区。 Rails 下按 Rails 时区求值,否则默认 UTC。墙钟邮件建议在 cronline 写明城市时区:
'0 9 * * * America/New_York'。 - Fugit 自然语言。
"every day at five"可用;natural_cron_parsing_mode设为:strict可拒绝歧义多行解析。 sending_access仅服务端。ROVELA_API_KEY放在 Sidekiq 进程环境——不要写进可能被 Redis 保留的 jobargs、客户端包或 Git(Authentication)。- 优先 HTTPS
POST /emails。 从 worker 直连出站 SMTP 通常不合适(Email API vs SMTP)。
何时用 Sidekiq-Cron + POST /emails(而不是 scheduled_at)
适合 Sidekiq-Cron → 现场发送 的情况:
- HTML 必须包含触发时的新鲜指标
- 你已在 Ruby / Rails 应用里跑 Sidekiq(Redis),希望调度与同一批 worker 放在一起
- 需要类似 cron 的摘要,但不想再上另一套 SaaS 调度器
- 一封(或少量)事务邮件发给运行时查到的运维 / 创始人 / 客户地址
正文已经正确、只想“稍后再投”、且不需要常驻 Sidekiq 时,用 Rovela scheduled_at(定时发送邮件)。Sidekiq-Cron 调度与 scheduled_at 解决不同问题;本文走 Sidekiq-Cron 路径。
若更想用 Celery Beat / BullMQ / Agenda / Temporal / Trigger.dev / Inngest / QStash,见上文区分链接。调度应落在某云主机算力旁时,用对应平台 cron 文。
前置条件
- Rovela 上已验证发信域名。未验证的
from会400(Quick Start)。 - 形如
re_…的 API key。worker 优先sending_access(Authentication)。 - 带 Sidekiq + Redis 的 Ruby 应用,且至少有一个 Sidekiq server 进程(cron 轮询才会跑)。
- Gemfile 加入
sidekiq-cron并bundle install。非 Rails 需在require 'sidekiq'之后require 'sidekiq-cron'。 - Worker 环境:
ROVELA_API_KEY、MAIL_FROM、DIGEST_TO,以及 Redis URL 与任务所需的 DB URL。
声明 schedule.yml 与 worker(Ruby)
每天 09:00 UTC 摘要,用 YAML(默认从 config/schedule.yml 自动加载):
# config/schedule.yml
daily_digest_email:
cron: "0 9 * * *" # 每天 09:00(Rails TZ,否则 UTC;也可追加时区)
class: "DailyDigestEmailWorker"
queue: mailers
description: "Ops daily digest via Rovela"
可选 initializer:
# config/initializers/sidekiq_cron.rb
Sidekiq::Cron.configure do |config|
# config.cron_schedule_file = "config/schedule.yml" # 默认
# config.cron_poll_interval = 30 # 默认秒
# config.cron_schedule_file = nil # 关闭自动加载;改为手动
end
聚合实时统计并调用 Rovela 的 worker(标准库 Net::HTTP,也可换成 Faraday):
# app/workers/daily_digest_email_worker.rb
require "json"
require "net/http"
require "uri"
require "time"
class DailyDigestEmailWorker
include Sidekiq::Worker
sidekiq_options queue: :mailers, retry: 5
def perform(*_args)
day = Time.now.utc.strftime("%Y-%m-%d")
idempotency_key = "sidekiq-cron-daily-digest:#{day}"
digest = build_daily_digest(day)
to = ENV.fetch("DIGEST_TO").split(",").map(&:strip).reject(&:empty?)
# 切勿打印 ROVELA_API_KEY 或 Redis 密码
Sidekiq.logger.info(
{
day: day,
idempotencyKey: idempotency_key,
jid: jid,
}.to_json
)
result = send_transactional(
to: to,
subject: digest[:subject],
html: digest[:html],
text: digest[:text],
idempotency_key: idempotency_key,
tags: {
kind: "sidekiq-cron-daily-digest",
day: day,
platform: "sidekiq-cron",
worker: self.class.name,
}
)
{ ok: true, emailId: result["id"], day: day }
end
private
def require_env(name)
ENV.fetch(name) { raise "missing env #{name}" }
end
def build_daily_digest(day)
# 换成你对 Postgres / Redis / API 的真实查询
stats = { signups: 12, paid: 3, incidents: 1, failed_jobs: 0 }
subject = "Acme daily digest — #{day}"
text = [
"Signups: #{stats[:signups]}",
"Paid: #{stats[:paid]}",
"Open incidents: #{stats[:incidents]}",
"Failed jobs: #{stats[:failed_jobs]}",
].join("\n")
html = <<~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[:failed_jobs]}</b></li>
</ul>
<p><a href="https://app.example.com/admin">Open admin</a></p>
HTML
{ subject: subject, html: html, text: text, stats: stats }
end
def send_transactional(to:, subject:, html:, text:, idempotency_key:, tags:)
uri = URI("https://api.rovela.dev/emails")
payload = {
from: require_env("MAIL_FROM"),
to: to,
subject: subject,
html: html,
text: text,
tags: tags,
}
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.open_timeout = 10
http.read_timeout = 30
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{require_env('ROVELA_API_KEY')}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idempotency_key
req.body = JSON.generate(payload)
res = http.request(req)
body = res.body.to_s.empty? ? {} : JSON.parse(res.body)
if res.code.to_i >= 400
raise "Rovela #{res.code}: #{body['message'] || 'send failed'}"
end
body
end
end
不用 YAML、用代码动态创建:
Sidekiq::Cron::Job.create(
name: "daily-digest-email",
cron: "0 9 * * *",
class: "DailyDigestEmailWorker",
queue: "mailers",
description: "Ops daily digest via Rovela"
)
# => true / false
用 bang 从 hash 加载,使 source: "schedule" 且不在 hash 中的任务被裁剪:
# 例如 Sidekiq.configure_server { |c| c.on(:startup) { ... } }
# 并先 Sidekiq::Cron.configure { |c| c.cron_schedule_file = nil }
schedule = YAML.load_file(Rails.root.join("config/schedule.yml"))
Sidekiq::Cron::Job.load_from_hash!(schedule, source: "schedule")
本地时区早晨摘要(时区写在 cronline):
# config/schedule.yml
tokyo_morning_ops_email:
cron: "0 5 * * * Asia/Tokyo"
class: "TokyoOpsEmailWorker"
queue: mailers
# perform 内按调度时区日切分幂等键:
require "tzinfo"
day = TZInfo::Timezone.get("Asia/Tokyo").now.strftime("%Y-%m-%d")
idempotency_key = "sidekiq-cron-tokyo-ops:#{day}"
每 15 分钟运维脉冲:
hourly_ops_pulse:
cron: "*/15 * * * *"
class: "OpsPulseEmailWorker"
queue: mailers
# idempotency_key = "sidekiq-cron-ops-pulse:#{...按一刻钟窗口分桶...}"
# 对 */15 应按窗口分桶,不要用墙钟秒级 now
常见发信节奏对照(crontab.guru / Fugit):
| Schedule | 含义 |
|---|---|
0 0 * * * | 每天午夜(求值时区) |
0 9 * * * | 每天 09:00 |
*/15 * * * * | 每 15 分钟 |
30 7 * * 1 | 周一 07:30 |
0 */3 * * * | 每三小时 |
0 9 * * * America/Chicago | America/Chicago 的 09:00 |
every day at five | Fugit 自然语言 → 0 5 * * * |
按窗口作用域的 Idempotency-Key
Rovela 的 Idempotency-Key(最长 256 字符)会让 24 小时内相同发送的重试返回原始响应,而不是再发一封(Send Email API)。这对 Sidekiq 很重要,因为:
- Sidekiq retry 会在 5xx / 超时 / 抛错后再次进入
perform - Rovela 已
200但 ack 前 worker 崩溃可能重投任务 - 队列延迟会让墙钟
Time.now漂移——应按业务调度窗口(UTC 日 / 时区日 / 小时)键控,而不是毫秒级 now - 多租户 args 需要把租户 id 写进键
推荐键:
| 键 | 适用 |
|---|---|
sidekiq-cron-daily-digest:{yyyy-mm-dd} | 每个 UTC 日一封运维摘要 |
sidekiq-cron-ops-pulse:{yyyy-mm-dd}T{HH}-{MM} | /15 或按小时 crontab(按窗口分桶) |
sidekiq-cron-tokyo-ops:{yyyy-mm-dd} | 本地时区早晨摘要(按 cronline 时区日) |
sidekiq-cron-weekly-report:{yyyy}-W{ww} | 每个 ISO 周一封创始人报告 |
sidekiq-cron-tenant-digest:{tenantId}:{yyyy-mm-dd} | 按租户 worker args |
若一个 job 里发很多个性化摘要,优先按收件人键控(或对最多 100 封非模板消息用 POST /emails/batch——batch 按设计拒绝 template / topic_id(Send Email API))。
挂载密钥并验证调度
- 在 Sidekiq 主机设置
ROVELA_API_KEY(sending_access)、MAIL_FROM、DIGEST_TO与 Redis URL(从不发信的 web 进程不必放 Rovela key)。 - 部署加载了
sidekiq-cron的 Sidekiq;在 Web UI / Redis 确认 cron 任务为 enabled。 - 确认
config/schedule.yml(或Sidekiq::Cron::Job.all)列出daily_digest_email/DailyDigestEmailWorker,cron 与时区符合预期。 - 等下一个 tick,或在非生产临时用更短 cron,同时保持窗口键稳定。
- 在 Sidekiq 日志中查看
idempotencyKey与 RovelaemailId。可选再用 webhook 关联。
生产发信优先 sending_access,不要用 full_access(Authentication)。切勿打印 API key。Rovela 轮换用先创建后吊销;Redis 凭据按你的主机指南轮换。
失败模式与处理
| 现象 | 可能原因 | 处理 |
|---|---|---|
| cron 从不触发 | 没有 Sidekiq 进程 / cron 被禁用 | 启动 Sidekiq;检查 enabled / 任务 status |
| 已入队但不执行 | 队列不对 / 无消费者 | 让 Sidekiq -q 监听 mailers(或你的队列) |
| 墙钟时间不对 | 误当 UTC / Rails 时区不一致 | 在 cronline 追加 IANA 时区,或对齐 config.time_zone |
| 重试后重复摘要 | 键不稳定 / 用毫秒 Time.now | 改用窗口键;24h 内 Idempotency-Key 会折叠重复 |
| 改 YAML 不生效 | 自动加载被覆盖 / Redis 残留旧任务 | 用 load_from_hash!(…, source: "schedule") bang 加载;检查 cron_schedule_file |
| 亚分钟 cron 漏跑 | 轮询 30s 大于节奏 | 把 cron_poll_interval 降到低于任务间隔 |
Rovela 401 | ROVELA_API_KEY 错误 / 缺失 | 修 Sidekiq 环境;确认 sending_access |
Rovela 400 提到域名 | MAIL_FROM 域名未验证 | 在 Rovela Domains 验证域名 |
Rovela 429 | 限流 | 抛错让 Sidekiq 重试;保持同一 Idempotency-Key |
| 密钥进 Git / job args | 密钥提交或塞进 args | 只放进程环境 / vault |
生产清单
- 已验证 Rovela 发信域名;
MAIL_FROM匹配该域名 ROVELA_API_KEY为sending_access,且仅在 Sidekiq 环境- 已加载
sidekiq-cron;至少一个 Sidekiq server 常开 - 文档化了 cron + 时区(YAML 或
Job.create);配置了 workerretry - Sidekiq 正在消费该任务队列
- Rovela 发送使用按窗口的
Idempotency-Key - 调度真相源是版本化 YAML(或受控 bang-load)
- 日志有成功
emailId且不打印密钥 - 连续失败 / Sidekiq 宕机有告警
- 可选:对摘要收件人配置
email.bounced/email.complainedwebhook
FAQ
Sidekiq-Cron 能不经过 worker 直接调 Rovela 吗?没有实际意义——轮询器只负责入队。聚合实时数据与保管 sending_access 必须在 worker。Rovela 不托管 Sidekiq 或 Redis。
worker 里该不该用 Rovela scheduled_at?通常不必。若 Sidekiq-Cron 已在发送时刻触发且正文是现场算的,直接 POST /emails。若现在受理、稍后再投且不需要再醒一次,再用 scheduled_at(定时发送邮件)。
Sidekiq-Cron vs Celery Beat vs BullMQ vs Agenda vs Temporal vs Trigger.dev vs 平台 cron?调度应贴着 Ruby Sidekiq worker 时用 Sidekiq-Cron。Python Celery 用 Celery Beat(Celery Beat)。Node Redis/Mongo 队列旁用 BullMQ / Agenda(BullMQ、Agenda)。耐久 Schedule Spec 用 Temporal(Temporal)。SaaS 运行时用 Trigger.dev / Inngest / QStash。调度应贴着某平台算力时用对应平台 cron。
YAML 还是 Job.create?跨环境晋升的摘要优先版本化 config/schedule.yml(或带 source: "schedule" 的 bang load_from_hash!)。控制台/代码临时创建的用动态 Job.create。
Sidekiq Enterprise Periodic Jobs 呢?那是另一套商业能力。本指南用开源 sidekiq-cron(schedule.yml / Sidekiq::Cron::Job)。若你从 Enterprise Periodic Jobs 入队等价 worker,Rovela 发送模式相同:服务端 sending_access + 窗口 Idempotency-Key。
sending_access 覆盖哪些能力?足以通过 POST /emails / batch 入队邮件。读消息、改期、取消需要 full_access 或仪表盘会话(Send Email API、Authentication)。worker 保持 sending_access。
200 是否表示创始人已收到摘要?否——只表示 Rovela 已接受并排队。投递状态看 GET /emails/:id、事件或 webhook(Quick Start)。
为何既要 Sidekiq 重试又要 Rovela Idempotency-Key?Sidekiq 在你的代码失败时重试任务;Rovela 的键在同一窗口内 HTTP 成功两次(或超时后其实已接受)时折叠重复发送。两层都要。
把 cron 调度发出去,投递交给 Rovela
在 YAML(或 Job.create)声明 Sidekiq-Cron 任务,把现场聚合与 POST /emails 放进 worker,把 Idempotency-Key 限定在调度窗口(及租户),并让 sending_access 仅留在服务端。Sidekiq-Cron 按时唤醒;Rovela 投递事务邮件。