#TypeScript#DevOps#独立开发

定时自动化运行账本:时区实例、DST 与漏跑恢复

Cron 表达式只描述重复模式,不能回答夏令时跳时、平台停机补跑和响应丢失后哪一次已经执行。本文为每个计划实例建立稳定身份、运行账本和可回滚切换协议。

本文要点

  • 主题聚焦:TypeScript、DevOps、独立开发
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年1月26日·更新于 2026年7月20日

“每天早上 9 点发送经营摘要”看似是一行 cron,到了生产却至少有四种时间:操作者理解的本地时间、UTC 瞬间、调度器实际发现任务的时间、worker 真正开始的时间。夏令时开始会让某个本地时刻不存在,结束又会让同一时刻出现两次;平台停机两小时后恢复,还要决定漏掉的两次是否补跑。若重试键取当前时间,每次恢复都会制造一封新邮件。

后台任务队列解释 worker、重试和死信队列,多 Agent DAG 调度恢复处理有依赖的智能任务。本篇只定义周期计划如何物化为唯一 occurrence,以及它是否已经产生业务效果;不负责通用队列选型,也不把模型规划当成时钟。

冻结时间合同,而不是只保存 cron 字符串

发布 scheduleSpec.v1 时固定 scheduleId、revision、IANA timeZone、tzdbVersion、本地重复规则、开始与结束边界、gapPolicy、foldPolicy、misfirePolicy、最大补跑数和允许迟到窗口。RFC 5545 的 recurrence 规则可以表达周期,IANA 时区库负责本地时间与 UTC offset 的历史映射;两者都不能替产品决定跳时和叠时怎么处理,因此策略必须成为版本字段。

yaml
schema: scheduleSpec.v1
scheduleId: daily-ops-summary
revision: 7
timeZone: America/New_York
tzdbVersion: 2026a
rule: "FREQ=DAILY;BYHOUR=2;BYMINUTE=30"
gapPolicy: SKIP
foldPolicy: FIRST_ONLY
misfirePolicy: CATCH_UP_BOUNDED
maxCatchUp: 2
cutoverAt: 2026-11-01T06:00:00Z

春季的 02:30 不存在时,SKIP 要产生带原因的 SKIPPED_GAP 记录,而不是把它悄悄挪到 03:00;若业务选择 SHIFT_FORWARD,挪动后的瞬间也必须记录。秋季两个 01:30 由 FIRST_ONLY、SECOND_ONLY 或 BOTH 明确选择。occurrenceId 根据 schedule ID、revision、名义本地日期时间及 fold ordinal 计算;另以不含 revision 的 logicalSlotId 绑定 schedule ID 与策略解析后的 UTC 瞬间,跨版本防重。两者都不能使用发现或启动时间。

计划改版采用 UTC cutoverAt 的半开区间:旧 revision 只物化解析瞬间 < cutoverAt 的实例,新 revision 只物化 >= cutoverAt 的实例。cutover ledger 对 (scheduleId, logicalSlotId) 做唯一约束和 CAS,不能让两个 revision 同时领取边界槽。已经物化的历史不能因 tzdb 更新重新编号;若法律变化影响未来实例,发布新 revision 并保留旧计算证据。数据库迁移关注表结构演进,这里保护的是时间解释本身。

occurrence 与 attempt 分层记账

物化器同时受 occurrence 明细键和跨 revision 的 logical slot 唯一约束,并在同一事务写 outbox。状态限定为 PLANNED -> DUE -> DISPATCHING -> RUNNING -> SUCCEEDED,失败可进入 RETRY_WAIT,无法确认外部结果进入 UNKNOWN_EFFECT;SKIPPED_GAP、MISFIRE_EXPIRED 和 CANCELLED 都是终态事实,不应被当成“没有记录”。

每次执行另建 attempt,包含 claim generation、worker、started/finished 时间、输入摘要和错误类。retry 只增加 attempt,不创建新 occurrence;投递邮件或生成报表时向下游传 occurrence ID 作为业务幂等键。下游若不支持幂等和结果查询,超时后不能盲目再发,只能隔离并对账。MCP 超时、重试与幂等提供一般调用语义,本账本把稳定键限定到一次日历实例。

物化范围只前看有限 horizon,例如 14 天;多个调度器并发扫描时由唯一约束选出一个事实,不靠主机锁假设。平台恢复后先计算遗漏集合,再按 misfire 策略处理。CATCH_UP_BOUNDED 最多补规定数量,其余写 MISFIRE_EXPIRED,避免停机一周后同时发送七封摘要。外部 I/O 前,worker 以 CAS 将 DUE/RETRY_WAIT 变为 DISPATCHING,同时匹配 active revision、cutover gate、disableGeneration 和 execution epoch;停用、改版与 dispatch 更新同一 gate,只能一方胜。已赢得 dispatch 的效果进入对账,不能假装被取消。

用时间故障证明不会重复

测试矩阵至少覆盖:spring-forward 缺口、fall-back 两个 offset、tzdb 版本更新、闰日、月末、调度器停机后跨多个周期恢复、两个 materializer 同时发现、worker 成功后丢 ACK、重试晚于下一周期、旧 revision 在 cutover 后苏醒,以及停用与开始竞争。断言每个策略预期的 occurrence 集合完全相同,同一 ID 一百次投递最多形成一个已确认业务效果,所有无法确认项保持 UNKNOWN。

验收门要求支持时区的黄金用例 100% 一致;随机生成十万组边界时间时重复 occurrence 为 0;故障恢复后五分钟内完成物化,超出补跑上限的执行为 0;UNKNOWN_EFFECT 必须在 30 分钟内对账或进入人工队列;所有执行接受点校验 revision 与 disable generation 的覆盖率为 100%。用 Prometheus 与 Grafana监控物化延迟、misfire、attempt 数和未知效果,但指标不能代替账本事实。

上线先让新物化器 shadow 计算四周,只比较 occurrence ID 和策略原因,不投递任务;随后接管一个无外部效果的内部计划,再扩到 5%。回滚时原子把 scheduler implementation alias 切回上一版实现、提升执行 epoch 并停止新 claim;旧实现必须继续读取同一 occurrence 账本和当前 scheduleSpec,绝不能恢复已经越过 cutover 的旧计划范围。已经成功的 occurrence 不能删除后让旧系统补发;只有未开始且由旧实现能识别的实例才可重新排队。

Related

相关推荐