← 返回博客

Sidekiq-Cron 事务邮件 + Rovela(2026):schedule.yml / Job.create 与按窗口幂等键

用 Sidekiq-Cron 周期任务(YAML cron 或 Job.create)对接 Rovela POST /emails:服务端 sending_access,按调度窗口 Idempotency-Key,适合摘要与运维报告。

更新于 2026年11月5日
Sidekiq-Cron 事务邮件 + Rovela(2026):schedule.yml / Job.create 与按窗口幂等键

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,做到:

  1. 在 config/schedule.yml(或 Sidekiq::Cron::Job.create / load_from_hash!)声明周期任务
  2. 把 ROVELA_API_KEY(sending_access)、MAIL_FROM、Redis URL、收件人放在仅服务端环境变量——绝不进浏览器或公开 Git
  3. 在 perform 内聚合当前调度窗口的摘要 / 报告
  4. 用按窗口作用域的 Idempotency-Key 调用 Rovela
  5. 在 Sidekiq 重试、进程重启、多进程轮询下仍不重复发信

Rovela 不会创建 Sidekiq-Cron 计划、Redis 或 worker。 Sidekiq-Cron 管 schedule → 入队;你的 worker 管查询 → 映射字段 → POST /emails;Rovela 管投递。

与下列文章区分:

也与一次性的 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、statusSidekiq-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)
到期时入队 workerSidekiq-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 保留的 job args、客户端包或 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 文。

前置条件

  1. Rovela 上已验证发信域名。未验证的 from 会 400(Quick Start)。
  2. 形如 re_… 的 API key。worker 优先 sending_access(Authentication)。
  3. 带 Sidekiq + Redis 的 Ruby 应用,且至少有一个 Sidekiq server 进程(cron 轮询才会跑)。
  4. Gemfile 加入 sidekiq-cron 并 bundle install。非 Rails 需在 require 'sidekiq' 之后 require 'sidekiq-cron'。
  5. 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/ChicagoAmerica/Chicago 的 09:00
every day at fiveFugit 自然语言 → 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))。

挂载密钥并验证调度

  1. 在 Sidekiq 主机设置 ROVELA_API_KEY(sending_access)、MAIL_FROM、DIGEST_TO 与 Redis URL(从不发信的 web 进程不必放 Rovela key)。
  2. 部署加载了 sidekiq-cron 的 Sidekiq;在 Web UI / Redis 确认 cron 任务为 enabled。
  3. 确认 config/schedule.yml(或 Sidekiq::Cron::Job.all)列出 daily_digest_email / DailyDigestEmailWorker,cron 与时区符合预期。
  4. 等下一个 tick,或在非生产临时用更短 cron,同时保持窗口键稳定。
  5. 在 Sidekiq 日志中查看 idempotencyKey 与 Rovela emailId。可选再用 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 401ROVELA_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);配置了 worker retry
  • Sidekiq 正在消费该任务队列
  • Rovela 发送使用按窗口的 Idempotency-Key
  • 调度真相源是版本化 YAML(或受控 bang-load)
  • 日志有成功 emailId 且不打印密钥
  • 连续失败 / Sidekiq 宕机有告警
  • 可选:对摘要收件人配置 email.bounced / email.complained webhook

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 投递事务邮件。