WeKan 持久化作业设计:检查点、租约与幂等重放的重启安全作业契约
2026/9/14 19:56:28 网站建设 项目流程

WeKan 持久化作业设计:检查点、租约与幂等重放的重启安全作业契约

【免费下载链接】wekanThe Open Source kanban, built with Meteor. GitHub issues/PRs are only for FLOSS Developers, not for support, support is at https://wekan.fi/commercial-support/ . PR source translation to imports/i18n/data/en.i18n.json, other translations at https://app.transifex.com/wekan/wekan项目地址: https://gitcode.com/GitHub_Trending/we/wekan

本文基于 WeKan 仓库中 Admin Panel → Problems 下的设计文档 Durable-Operations.md,系统讲解 WeKan 如何保证跨进程存活的后台作业——导入、附件迁移、数据库迁移、备份、完整性扫描、定时规则、Webhook 与邮件——在服务端重启、进程崩溃或外部服务限流时"从检查点续跑而不是从头再来"。读完后你能掌握该契约的完整要素:持久化作业记录字段、原子 claim 与可续租的 lease、五步幂等执行顺序、外部服务重试/退避策略,以及仓库中已经落地的实现证据(Trello 导入作业、SQLite 忙写重试、Recovery 事件审计)。

一、适用范围与设计目标

该设计适用于 WeKan 启动的任何"可能比一次 HTTP 请求或一个服务端进程活得更久"的工作,文档明确列出的范围包括:

  • 各类导入(Trello 及其他看板导入、ZIP/JSON/CSV/Jira/Kanboard/ICS 导入);
  • 附件/头像迁移、数据库文本迁移、备份/恢复;
  • 完整性/恢复扫描、定时规则、Webhook、邮件及其他外部服务调用。

核心恢复规则一句话概括:不是"从头再跑一遍",而是每完成一个幂等单元就持久化一个检查点(checkpoint),非正常停止后从检查点继续。这个规则与 Recovery.md 中的失败覆盖表直接联动:当后台作业进程退出时,过期的持久化 lease 会被回收,执行从"最后已验证的单元"继续,并在 Recovery 报告中留下job-reclaimed-after-restart事件及后续结果。

文档当前状态为Implementation in progress(负责人 xet7)。也就是说这是一份"契约式设计":它先定义了所有持久化作业必须遵守的接口和不变量,各具体作业按契约逐步落地——Trello 导入作业与数据库忙写重试已经可见实现(见第四节),而统一的租约/检查点设施仍在推进中。

二、持久化作业契约(Durable Job Contract)

2.1 每条作业必须有完整的数据库记录

每个后台作业都对应一条数据库记录,至少包含:

字段说明
类型 / 属主 / 租户作业类型、发起者(owner)、所属租户(tenant)
脱敏的输入引用(sanitized input reference)输入只以脱敏引用形式保存
状态(state)例如runningpausedfailed
当前检查点该作业已验证完成的位置
尝试计数器与nextAttemptAt自动尝试已用次数、下一次允许尝试的时间
有界的错误历史(bounded error history)不无限膨胀的错误记录
时间戳创建、更新等关键时间点
可续租的 lease带属主与过期的执行租约

一条关键安全不变量:秘密永远只通过服务端配置或加密凭证记录来引用,明文 token 绝不复制进作业记录。仓库中已经落地的 models/trelloImportJobs.js 在注释里明确写死了这一点:Trello API 的 key/token "NEVER stored in this document",只保留在运行循环的服务端内存中,服务端重启后需要客户端重新提供(文件第 12–14 行)。这份作业文档保存的是队列、进度、逐板结果和错误日志,因此用户可以在 Trello 导入页离开后回来查看进度、在致命 API 错误后恢复,或取消并删除已导入的看板重新开始。

2.2 原子 claim 与可续租的 lease

  • Worker 用一次原子的条件更新(conditional update)来 claim 一条到期的作业;
  • lease 带属主 ID 与过期时间,工作期间持续续租,干净暂停或完成时释放;
  • 过期 lease 可被替换进程(replacement process)回收;
  • 由此保证:多个 WeKan 副本(replica)不可能并发执行同一个单元

这与文档中 Startup 一节的呼应关系是:正常 shutdown 主动释放 lease,而 SIGKILL、掉电、进程崩溃这类无法主动释放的场景,则依赖 lease 过期 + 幂等重放来兜底。

2.3 五步幂等单元执行顺序

每个执行单元使用一个稳定的幂等键(stable idempotency key),并严格按以下顺序执行:

  1. Claim 或回收作业,读取其持久化检查点;
  2. 检查该单元的结果是否已经以它的幂等键存在(已存在则跳过执行);
  3. 执行一个有界的工作单元(bounded unit of work);
  4. 验证结果,然后原子地推进检查点
  5. 续租、让出(yield)、claim 下一个单元。

崩溃语义由此清晰:在第 4 步之前崩溃,只会重复执行同一个单元,而不是重跑整个作业。而重复执行必须本身是安全的,文档给出了四类保证:

  • 数据库复制按_idupsert;
  • 导入的看板保留其来源/作业身份(source/job identity),重复导入不会产生第二份;
  • 文件传输在校验目标字节一致后才删除源
  • 外部请求在服务商支持时携带 idempotency key。

2.4 自动尝试耗尽之后

达到自动尝试上限后,作业保持持久化pausedfailed,绝不被丢弃。Admin Panel → Problems → Recovery 记录该作业的操作、检查点、尝试次数、下次重试时间、有界的失败原因,以及"重启恢复是否回收了它"。安全敏感型的拒绝(例如 SSRF 拦截)则留在 Problems → Security 流中。

三、仓库中可对照的实现证据

3.1 Trello 导入:持久化作业 + 重试策略的完整落地

server/trelloApiImport.js 是外部服务策略的一份具体实现,其常量与文档逐条对应:

  • 有界超时REQUEST_TIMEOUT_MS = 30000,API 调用用AbortSignal.timeout施加总超时(L74、L140);
  • 可重试状态4084254295xx(L157),以及网络错误进入同一退避路径;
  • 非重试的拒绝:SSRF guard 的拒绝是"裁决而不是小故障"——SSRF_GUARD:前缀的错误直接抛出trello-blocked-url,不重试(L143-L147);
  • 重试上限MAX_RETRIES = 5,耗尽后抛出trello-api-rate-limited(L170-L175);
  • 最小间隔门MIN_REQUEST_GAP_MS = 120,串行请求之间保持最小间隔,使批量导入"从一开始就不接近限额",这正是文档中"provider 级最小间隔门在本实例所有作业上生效"的一个实例(L62-L86)。

Retry-After的处理实现见 retryDelayMs:优先解析头部的秒数 delta,其次解析HTTP date,两者都取有效值;都不可用才回落到封顶指数退避BASE_BACKOFF_MS(1000) * 2^attempt,上限MAX_BACKOFF_MS(30000)。注意实现里对Retry-After也套了Math.min(…, MAX_BACKOFF_MS)封顶,这是"provider 可延长但不得缩短"之外、单实例层面的安全上限。

3.2 SQLite 忙写重试:带 jitter 的封顶退避

server/00retryBusyWrites.js 展示了文档"重试使用带 jitter 的指数退避且有配置上限"在数据库侧的形态:所有集合写(insertAsync/updateAsync/upsertAsync/removeAsync)经过一个有界重试包装,全部参数可用环境变量配置:

环境变量默认值含义
WEKAN_DB_RETRY_ATTEMPTS5含首次在内的总尝试次数
WEKAN_DB_RETRY_BASE_MS25退避基数
WEKAN_DB_RETRY_MAX_MS400单步延迟上限
WEKAN_DB_RETRY_TOTAL_MS2000含首试的总等待上限
WEKAN_DB_RETRY_LOG_MS60000重试统计日志的最短周期

其中 delayFor 使用"完整 jitter"(步长的 0.5–1.5 倍随机),目的是"同一把锁交接释放的 N 个写者不会全部同时回来"。文件头部注释还解释了为什么重复写是安全的:SQLite 单写者模型下,SQLITE_BUSY意味着该写根本没有发生,而 Meteor 已选好_id,撞了唯一索引的重复尝试会被拒绝而非插入两次——这正是文档"数据库复制按_idupsert、重放必须安全"要求的实例化。

3.3 Recovery 证据链:JSONL → 集合 → 管理面板

作业被回收、恢复动作发生时,证据通过三层流转,对应文档"Problems → Recovery records the operation, checkpoint, attempt, next retry…"的要求:

  1. 启动脚本(snapferretdb-control、发布包start-wekan.sh、Docker entrypoint)每执行一个动作向 SQLite 数据目录的recovery-events.jsonl追加一行;
  2. server/recovery.js 在Meteor.startup时把"比上次导入更新"的行批量插入recoveryEvents集合(用集合 meta 里的最后导入时间做增量,重启不会重复导入整个文件);
  3. models/recoveryEvents.js 定义了事件类型常量(corruption-detectedrestore-backupremigratebloat-repairedmanual-required等),Recovery 报告 面向管理员按最新优先展示,支持搜索与分页。

recoveryEvents的 schema(L34-L79)包含severity(info/warning/error)、source(server/startup/ferretdb/manual)、done布尔列、操作者userId/usernameipv4/ipv6——这些字段支撑了 Recovery 表中"绿色对勾 / 红色三角 / 黄色垃圾桶"的 Done 状态渲染。

四、外部服务策略(External-Service Policy)

文档对每一个出站操作定义了统一策略,可以归纳为五条:

4.1 超时与有界响应

每个出站操作都有连接超时和总超时,且响应体有界。这防止慢连接、挂起的 DNS 或超大响应拖死 worker,也防止响应体被意外持久化进作业记录(文档 Tests 一节要求负面测试证明"无界响应体不会持久化到作业或报告中")。

4.2 可重试状态的分类

  • 可重试:HTTP408425429、瞬时5xx、DNS/连接重置与超时;
  • 不可重试:认证、鉴权、校验失败,以及 SSRF 拒绝。

分类的意义在于:把"权限不足""URL 被安全守卫拦截"这类裁决(verdict)与"网络抖动"区分开。重试一个被拒绝的 SSRF 请求只是把同一个被拦截的请求多执行五次,正如 trelloFetch 的注释所述。

4.3Retry-After是权威信号

Retry-After是合法的 delta(秒数)或 HTTP date 时,它说了算;provider 的速率限制重置头(如X-RateLimit-Reset一类)只能延长、绝不能缩短该延迟。Trello 导入的 retryDelayMs 实现了 delta 与 HTTP date 两种形态的解析。

4.4 先持久化nextAttemptAt,再睡眠

文档特别强调:下一次尝试时间在睡眠之前就已持久化。因此重启不会重置速率限制、不会造成重试风暴(retry storm)——进程在退避期间死掉,醒来时按库里存的nextAttemptAt判断是否到期,而不是把计数器清零。此外,provider 级的并发门与最小间隔门作用于本实例内的所有作业,而不仅仅是单个作业内部(Trello 导入里MIN_REQUEST_GAP_MS = 120的最小间隔就是这种门的一个已落地实例)。

五、按作业类型的检查点(Operation-Specific Checkpoints)

文档给出了一张各作业的"持久化单元 + 完成证据"表,完整继承如下:

作业持久化单元与完成证据
Trello 及其他看板导入一个源看板;源 ID 在队列索引前进之前,必须恰好映射到一个已导入看板
ZIP/JSON/CSV/Jira/Kanboard/ICS 导入已解析的源 + 一个看板/卡片批次;创建出的记录携带作业/来源键(job/source key)
附件/头像迁移一个文件版本;删除源之前,目标的大小/校验和与元数据必须一致
文本数据库迁移_id排序的一个集合批次;目标端 upsert 且证据覆盖检查点
备份/恢复一个集合/文件条目;暂存的归档/对象与校验和清单最后才发布
完整性/恢复扫描一个有界清单批次;最后一条稳定的对象键被持久化
定时规则触发事件 ID(occurrence ID);已记录的事件不会被应用两次
Webhook/邮件每个事件每个目的地一条投递记录;provider 幂等键,或显式的 at-least-once 状态

请求/响应式导出文档单独做了边界说明:它们重启后不会恢复 HTTP socket,而是走快照读取(snapshot read)并显式失败;将来若增加可下载的异步导出,必须遵守本契约。客户端可以安全地重试同步的幂等读取。

六、启动与关闭行为

6.1 启动时

  1. 先把过期的runninglease 标记为可回收;
  2. 记录一条job-reclaimed-after-restart的 Recovery 事件;
  3. 然后带 jitter 地渐进启动到期作业(避免所有副本同时抢作业造成惊群)。

对于"所需凭证或存储不可用"的作业:置为paused并给出可操作的(actionable)原因——文档明确它们不得被错误地标记为 failed 或 completed

6.2 关闭时

  • SIGTERM:停止 claim 新工作,把检查点写回当前安全边界,并在 shutdown 宽限期内释放 lease;
  • SIGKILL、掉电、进程崩溃:不依赖任何主动清理,依靠 lease 过期 + 幂等重放恢复。

七、测试要求

文档规定每个持久化作业必须具备以下测试,这是验收一份实现是否"真的持久化"的清单:

  • 通用:正常完成(positive completion)、检查点前崩溃、副作用后崩溃、过期 lease 回收、并发 worker 互斥、暂停与取消;
  • 外部作业附加:超时、网络重置、每种可重试状态、非重试的 4xx、合法/非法Retry-After、jitter 边界、尝试耗尽、退避期间重启;
  • 负面测试:证明秘密与无界响应体不会被持久化进作业或报告。

仓库中与之配套的现有测试文件可作为参照(例如 tests/recoveryPlan.test.cjs、tests/recoveryReportQuery.test.cjs 在 Recovery.md 的 Tests 一节中列出的决策逻辑与报告查询测试);而 docs/Features/Admin-Panel/Problems/README.md 也已在"Detailed pages"表中把本设计标注为 Problems 面板的组成部分:"Restart-safe jobs, leases, checkpoints, idempotency, external-service retries and recovery reporting"。

八、小结:如何阅读这套契约

  • 如果你是运维:关注的是恢复语义——重启不会丢工作,作业要么从检查点续跑,要么以paused/failed留在库里并带 actionable 原因,Recovery 面板能看到job-reclaimed-after-restart与每次恢复动作的记录(server/recovery.js 的 JSONL 导入、RECOVERY_IN_PROGRESS标记与数据库健康探针共同支撑这一观察面)。
  • 如果你是开发者:新增任何后台作业时,照第二节五步顺序实现幂等单元、第四节策略实现外部调用,并对照第七节清单补测试;已有的 models/trelloImportJobs.js(作业文档不存秘密)与 server/00retryBusyWrites.js(可配置的 jitter 退避、总时长封顶、统计上报)是两份可直接比对的实现范本。
  • 该设计处于Implementation in progress状态:统一的 lease/checkpoint 设施仍在推进中,引用本契约实现新作业时,请以 Durable-Operations.md 当前版本为准,并结合 Recovery.md 的失败覆盖表核对证据链是否完整。

【免费下载链接】wekanThe Open Source kanban, built with Meteor. GitHub issues/PRs are only for FLOSS Developers, not for support, support is at https://wekan.fi/commercial-support/ . PR source translation to imports/i18n/data/en.i18n.json, other translations at https://app.transifex.com/wekan/wekan项目地址: https://gitcode.com/GitHub_Trending/we/wekan

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询