全部笔记

定时 Worker 的幂等性:触发两次也只能发布一次

用发布身份、持久状态和事务边界控制定时任务的重复执行与部分失败。

每天上午九点发布两篇文章,不能只依靠定时触发“通常只来一次”。重试、人工补跑和部署切换都可能重复执行同一天的任务。发布系统要保证同一个批次被执行多次,也不会多发文章或改变顺序。

本文讨论有私有草稿源和公开构建的发布流程。Cron Trigger 负责唤醒,不负责提供完整业务事务;具体服务的执行保证和传播时间应以当前文档为准。

先把触发事件与发布计划分开

调度事件带来一次运行机会,发布计划决定哪天、哪两篇及哪份内容可以公开。一个触发可能被重跑,一个计划不能因重跑而变化。假设第一篇已经转换,第二篇复制失败;下一次应该继续同一计划,不从剩余队列取新的两篇。计划保存稳定稿件标识与审核内容指纹,编辑者修改后需要重新批准,而不是继续使用旧计划公开新内容。

计划还应绑定发布政策版本。日发布数量、起始日期和类别比例变化,不应重新解释已经完成批次。保留旧计划事实,未来批次使用新政策,才能对账。私人稿件标题和正文不进入公共日志,调度记录可使用内部标识。计划数据本身也需要保护,因为它可能透露未公开内容安排。

function batchId(instant) {
  const parts = new Intl.DateTimeFormat('en-CA', {
    timeZone: 'Asia/Hong_Kong',
    calendar: 'gregory', numberingSystem: 'latn',
    year: 'numeric', month: '2-digit', day: '2-digit',
  }).formatToParts(instant);
  const field = (type) => parts.find((part) => part.type === type)?.value;
  const year = field('year');
  const month = field('month');
  const day = field('day');
  if (!/^\d{4}$/.test(year || '') || !/^\d{2}$/.test(month || '')
    || !/^\d{2}$/.test(day || '')) {
    throw new Error('Unsupported business date');
  }
  return `daily-publication:${year}-${month}-${day}`;
}

不要使用构建机器本地日期,也不要把每次运行时间当成批次 ID。调度表达式通常使用 UTC,香港上午九点对应的表达式要按明确时区换算。其他有夏令时地区则需要更谨慎的日历调度设计。

format 的本地化展示顺序和分隔符不是持久身份协议,不能依赖 en-CA 恰好显示 YYYY-MM-DD。这里固定 Gregorian 日历与 Latin 数字,从 formatToParts 取年月日后显式序列化,并将业务年份限制为四位支持范围;非法时刻或不支持范围直接失败。它计算首次计划的日期身份,跨日恢复仍读取已经保存的业务日期,不重新使用当前时刻挑选批次。

业务日期按编辑时区计算,不能直接用 UTC 日期作为香港计划身份。跨午夜触发与重跑仍关联正确计划,夏令时地区还需定义重复时段政策。调度器提供的时间只帮助定位触发,编辑计划才决定哪一天应该公开。

一条状态机表达每个阶段的恢复动作

可以将批次建模为计划已生成、已认领、公开文件已准备、构建已验证、版本已部署、结果已确认。每一步记录输入指纹与产物版本,失败保留最后完成阶段。状态迁移不能仅由“命令退出零”决定,例如部署上传成功后还需确认目标域名与具体文章。成功定义与用户可见结果一致,才不会把本地准备称为发布完成。

状态应允许恢复但不允许倒退解释。准备完成后稿件变化,应停止并要求重新审核,而不是静默换正文继续;部署确认后重复执行应返回已完成结果,不生成新发布日期。状态字段可以比示例更多或更少,关键是每个外部副作用都有可判断证据。只有一个 done 布尔值无法表达部分成功。

租约用于处理并发认领,期限用于恢复崩溃实例。租约到期不证明旧实例已停止,需要栅栏版本或明确协调机制避免两个执行者继续写同一结果。数据库唯一约束、事务或 Durable Object 等工具各有适用范围,先选择可靠一致性基础,再写业务状态。普通“先读后写”不具有互斥保证。

两个实例同时检查“今天尚未发布”,都可能得到相同答案。检查后写入不是锁。可以使用数据库唯一约束、事务或适合的单一协调者,让批次认领只有一个成功者。

状态应区分计划、认领、公开构建、部署完成与失败。不能在复制文件前就永久标记成功,也不能只有一个布尔值而无法判断失败位置。锁或租约要有过期与恢复机制,避免进程异常后任务永远被占用。

幂等键应与相同业务输入绑定

相同批次键重复执行时,输入应该相同。如果键是同一天,稿件集合却改变,系统应拒绝并报告冲突,不把它当成普通重试。记录两篇标识、内容摘要与目标版本,能判断这是继续已知工作还是新的发布请求。日期作为身份只是起点,输入一致性才让“只发一次”有明确含义。

公开文件写入采用受控临时位置,校验后再进入构建源,不在失败中留下半个文件。若已有目标文件,比较内容与计划,完全相同可以继续,不同则停止,不能覆盖未知修改。静态产物可以重复部署,前提是重复的是同一已验证集合。额外生成随机部署标识不应改变文章身份与 RSS 标识。

本地锁文件可以保护同一机器进程,但不能协调多个机器;KV 或对象存储的普通读写也不应被假定具有事务锁语义。按工具的真实保证设计,避免把单机演示结果扩展成全球并发承诺。对于个人博客,用单一受控发布执行器可能已经足够,未必需要完整分布式调度平台。

外部部署无法随数据库一起回滚

数据库事务不能自动撤销 Cloudflare 部署或 Git 推送。发布流程属于跨系统操作,应记录已完成步骤,并使每一步可重试。生成产物采用同一计划,重复部署同一产物应保持内容集合不变。

如果部署成功而状态写入失败,下一次运行可以查询部署版本或公开清单进行对账,而不是直接再选两篇。若提交成功但推送失败,需要明确告警与补推,不应把“本地文件已写好”报告为发布完成。

部署调用超时可能是上传失败,也可能是平台完成但响应丢失。下一次读取部署记录与公开清单,确认同一产物是否已经上线,再决定重试。数据库事务无法撤回已被公开的正文,因此发布状态要表达外部事实,不通过回滚数据库假装事情没有发生。已公开敏感内容还需清理与风险评估,不能仅把批次状态改回草稿。

Git 提交与推送同样分开。若本地提交完成但远程不可达,保存提交身份并报警,后续补推该提交,不再选择文章。远程推送完成而部署失败,则可以继续部署相同公开输入。每一步都检查当前状态与计划,避免人工处理与自动恢复互相覆盖。用户看得到的最终结果才是完成标准。

日志记录批次、阶段、版本和错误类别,不记录全文或认证令牌。警报在需要人工动作、失败或有意义状态变化时通知,不每次定时醒来都制造相同消息。恢复完成给出已验证版本与公开文章,确保维护者能判断操作是否达到目标。

人工干预也遵守相同发布契约

维护者手工补跑不能绕过审核与批次身份,否则自动系统正确而人工入口仍可能多发。补跑命令应指定业务日期,先展示已保存计划与当前阶段,只有符合恢复条件才继续。需要替换稿件时采用明确计划修订并重新审核,不通过删除成功状态诱导系统重新挑选。人工操作与定时操作共用核心状态逻辑,可以减少两套行为漂移。

暂停自动发布也不等于取消计划。保留队列、起始规则与已发布事实,恢复时按明确政策决定是否补历史。优化文章期间,内容指纹变化应使旧批准失效,不能仅暂停调度而不检查恢复后的审核状态。调度开关负责是否唤醒,内容批准负责是否能公开,两个条件独立成立。

发布记录需要备份和恢复测试。若状态文件丢失而线上内容仍存在,系统不能把全部稿件当作未发布重新开始;先根据公开清单和远程提交对账,再重建状态。权威来源的优先级应写清楚,线上事实与本地队列冲突时需要人工判断,不自动覆盖。用唯一约束减少重复,并用对账处理外部不可回滚事实。

单日批次失败后的警报可以包含日期、阶段和恢复建议,但不包含私人正文。连续重试不通知相同无变化状态,真正完成或需要人工选择时再报告。发布系统目标是可靠完成计划,不是持续生成运行日志。自动化可以安静执行,却必须让失败可见,尤其远程推送和部署确认没有完成时。

最终验收需要保存每个失败注入点的恢复证据。当天只有两篇不够,还要是预定两篇、内容与审核相符、时间正确且其他草稿不在公开产物。幂等性不是重复操作“没有报错”,而是重复执行后业务事实保持相同。这个定义能够同时约束文件转换、Git、构建和部署。

预览计划不能悄悄执行认领。维护者运行 dry-run 想查看今天两篇,若预览函数顺便写入已认领状态,真正调度器可能以为有人正在执行。将计划计算、批准保存与执行认领分成明确入口;只读预览可以读取既有计划,但不推进状态、不生成 Git 提交,也不调用部署。名称叫 dry-run 不足以证明无副作用。

测试保存合成状态快照,运行预览后比较状态、文件和外部调用计数,三者都应不变。再运行批准动作,仅出现规定的计划记录;最后执行才允许公开转换。若预览需要临时渲染,写入独立临时位置并与正式构建输入隔离,不让一次查看成为未来发布的隐式来源。

计划展示与执行之间内容可能改变,所以 dry-run 输出也不是长期批准证据。报告保存当时的内容指纹与政策版本,执行时重新对照;差异应要求再次确认,而不是认领“看起来同名”的文章。这样作者能安全检查下一步,又不会因查看操作消耗当天名额或公开尚在编辑的正文。

一次认领超时以后谁还能写入

执行者甲认领批次,正在准备产物,租约随后到期;执行者乙取得新租约开始恢复。甲并不一定已经停止,它可能只是暂时无法联系协调服务。若两个执行者都能写公开文件或切换部署,租约本身不能避免双重操作。需要将认领代数作为栅栏条件,在关键持久状态更新时拒绝旧代执行者,或选择具有明确串行语义的协调架构。

外部部署接口是否支持条件切换,是这份设计的重要限制。如果栅栏只能阻止数据库写入,却不能阻止甲继续调用平台,仍可能出现旧版本覆盖。可以让执行者仅准备不可变产物,由单一受控发布者完成切换,或在切换前后核对可靠协调状态。具体方案取决于平台能力,不能声称给记录加一个整数就自动保护所有外部副作用。

演练使用两个受控执行者,暂停甲直到租约失效,再让乙推进,最后恢复甲。检查旧代不能改变最终公开事实,同时日志保留拒绝原因。这个样本区别于两个实例同时开始的普通测试:后者只验证认领冲突,前者验证已经持有过权利的执行者不会继续破坏结果。并发安全要覆盖陈旧执行者,而不是只覆盖新竞争者。

日期身份需要与恢复目标一致

任务在某业务日被选中,第二天才重跑,不能用重跑时刻重新计算批次 ID。保存的业务日期与计划是恢复输入,当前时间只用于判断是否允许继续。人工补跑显式指定日期,先显示已有计划与阶段,再执行批准动作。误传未来日期或跨日边界应有校验,不让任意参数提前公开未来稿件。

暂停期间不自动补历史,是一种编辑政策,不是调度器漏执行的技术错误。恢复时应从已确认起点生成未来计划,保留未发布历史记录或明确标记跳过。不能把未发布直接改成完成来让状态数字好看,也不能一次性多发几十篇作为隐式补偿。读者节奏、审核状态和文章身份都应遵守已批准政策。

计划修订与重试也要分开。同日甲乙改为甲丙,输入集合已经改变,应生成明确修订并重新批准,检查乙是否曾被公开。若乙已经上线,回滚本地计划不能删除外部事实。修订记录解释实际发生的内容变化,而不是让一份“当天两篇”计划在历史上拥有多个矛盾版本。

部署超时后的对账需要独立证据

部署调用返回超时,先根据平台部署记录、产物版本和公开清单判断同一批是否已经完成。不要只请求首页,因为首页可能缓存旧版,也不能只看后台有一个成功上传,因为域名可能绑定另一项目。以具体两篇公开地址、RSS 条目和版本清单相互核对,记录已确认与尚未确认事实。

对账结果冲突时停止自动推进。例如平台显示部署成功,源包含甲乙但详情乙缺失,不能自动选择丙补齐数量。首先修复产物或发布一致性,保留同一批恢复身份。证据可以有传播延迟,因此采用有界重读并明确期限;超过后报告需要人工判断,不把无限轮询当作完成。

Git 远程状态也是独立事实。提交身份已保存时,推送失败只补推这份公开变更,不能重新转换另一组稿件。远程已经包含提交而本地状态未更新,先验证远程引用再记录完成。避免用一个 done 字段同时代表文件、Git 和部署,分阶段才能在部分成功后有准确下一步。

私有来源的读权限不延伸为上传权限

发布执行器读取全部草稿是选稿需要,上传范围却只包含批准批次的公开转换。构建输入、Git 暂存清单和部署目录分别检查,私人索引与备份不在任何公开范围。目录名称叫 drafts 或 private 不提供自动保护,工具实际枚举哪些文件才决定风险。以独特哨兵验证未选稿件在产物和待提交内容中不存在。

审核指纹覆盖标题、摘要、代码与引用等公开影响项,不因“只是优化文本”跳过批准。一个字符可以改变命令风险,批次中的旧指纹不许可当前新正文。重新审核以后更新该计划或未来排期的批准记录,未排期稿件不因审核通过自动进入每日选择。批准与排期是不同权限,状态模型应保持区别。

队列备份恢复也需要业务对账。备份停在部署之前,线上却已经公开甲乙,不能恢复后将批次当作未执行并重新选稿。先读取可靠外部版本与已保存计划,确定哪个事实较新,再修复内部状态。备份保护文件不等于恢复流程自动知道外部副作用。

演练可以将内部状态回到一份较旧合成快照,而保留模拟部署完成记录,验证恢复程序提示或识别既有公开事实。不要删除真实发布状态做测试。记录中只包含必要 ID 与版本,私人全文不为恢复便利复制到公开存储。能够处理进程崩溃与能够处理状态回退,是两个不同恢复场景。

上线完成与订阅通知完成分别记账

两篇文章已经上线,通知邮件发送失败,不应把发布批次恢复成未选稿状态。通知关联明确的公开版本与接收人身份,保留待交付、已确认和结果未知,而不以一次通知异常否定已经存在的公开事实。通知执行器也不能因重试重新选择两篇正文,否则恢复的是另一项业务。

例如三位合成订阅者中两位确认、一位超时,测试只对未完成项采取批准恢复策略。下游不支持幂等或结果核查时,重复通知风险如实记录,不能因为文章发布本身幂等就承诺邮件也只收到一次。尚未上线的私人正文不放进通知任务,任务只引用已确认的公开地址;通知系统的读取权限不延伸到全部草稿源。

完成记录说明具体业务不变量

重复执行最终应满足同一两篇、同一批准内容、稳定发布时间、没有其他私人内容与可核对公开版本。单纯总数增加二不能证明选稿正确,运行没有抛错也不能证明部署完成。把这些不变量作为验收清单,失败时给出日期、阶段和下一动作。自动化的可靠性来自可恢复事实,不来自触发次数看起来规律。

演练矩阵覆盖时间、并发与私密边界

同一业务日连续运行、两个实例同时认领、认领后崩溃、构建后崩溃、部署后状态写入失败,都应恢复为同一两篇文章。分别比较公开文件、RSS、状态记录与线上版本,不只数列表卡片。失败后稿件被编辑的场景应阻止继续发布,证明旧审核不能批准新内容。

再测试业务日期前后、调度延迟与人工补跑,确认政策明确选择目标日期。时区转换可以用固定瞬间测试,不依赖开发机器当前时间。香港没有日光节约时间,不代表所有未来地区都能使用固定 UTC 偏移。扩展地域时重新审查日历规则。

草稿哨兵扫描覆盖整个公开产物与 Git 待提交内容。调度执行器有权读取私人目录,不代表它可以上传整个目录。发布只转换计划中审核通过的两篇,其他稿件保持不可访问。可靠定时发布同时需要幂等状态机、外部对账和内容隔离,任意一项缺失都可能让“每天两篇”变成不可控公开。

参考:Workers Cron Triggers、Durable Objects。