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) | 例如running、paused、failed等 |
| 当前检查点 | 该作业已验证完成的位置 |
尝试计数器与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),并严格按以下顺序执行:
- Claim 或回收作业,读取其持久化检查点;
- 检查该单元的结果是否已经以它的幂等键存在(已存在则跳过执行);
- 执行一个有界的工作单元(bounded unit of work);
- 验证结果,然后原子地推进检查点;
- 续租、让出(yield)、claim 下一个单元。
崩溃语义由此清晰:在第 4 步之前崩溃,只会重复执行同一个单元,而不是重跑整个作业。而重复执行必须本身是安全的,文档给出了四类保证:
- 数据库复制按
_idupsert; - 导入的看板保留其来源/作业身份(source/job identity),重复导入不会产生第二份;
- 文件传输在校验目标字节一致后才删除源;
- 外部请求在服务商支持时携带 idempotency key。
2.4 自动尝试耗尽之后
达到自动尝试上限后,作业保持持久化为paused或failed,绝不被丢弃。Admin Panel → Problems → Recovery 记录该作业的操作、检查点、尝试次数、下次重试时间、有界的失败原因,以及"重启恢复是否回收了它"。安全敏感型的拒绝(例如 SSRF 拦截)则留在 Problems → Security 流中。
三、仓库中可对照的实现证据
3.1 Trello 导入:持久化作业 + 重试策略的完整落地
server/trelloApiImport.js 是外部服务策略的一份具体实现,其常量与文档逐条对应:
- 有界超时:
REQUEST_TIMEOUT_MS = 30000,API 调用用AbortSignal.timeout施加总超时(L74、L140); - 可重试状态:
408、425、429与5xx(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_ATTEMPTS | 5 | 含首次在内的总尝试次数 |
WEKAN_DB_RETRY_BASE_MS | 25 | 退避基数 |
WEKAN_DB_RETRY_MAX_MS | 400 | 单步延迟上限 |
WEKAN_DB_RETRY_TOTAL_MS | 2000 | 含首试的总等待上限 |
WEKAN_DB_RETRY_LOG_MS | 60000 | 重试统计日志的最短周期 |
其中 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…"的要求:
- 启动脚本(snap
ferretdb-control、发布包start-wekan.sh、Docker entrypoint)每执行一个动作向 SQLite 数据目录的recovery-events.jsonl追加一行; - server/recovery.js 在
Meteor.startup时把"比上次导入更新"的行批量插入recoveryEvents集合(用集合 meta 里的最后导入时间做增量,重启不会重复导入整个文件); - models/recoveryEvents.js 定义了事件类型常量(
corruption-detected、restore-backup、remigrate、bloat-repaired、manual-required等),Recovery 报告 面向管理员按最新优先展示,支持搜索与分页。
recoveryEvents的 schema(L34-L79)包含severity(info/warning/error)、source(server/startup/ferretdb/manual)、done布尔列、操作者userId/username与ipv4/ipv6——这些字段支撑了 Recovery 表中"绿色对勾 / 红色三角 / 黄色垃圾桶"的 Done 状态渲染。
四、外部服务策略(External-Service Policy)
文档对每一个出站操作定义了统一策略,可以归纳为五条:
4.1 超时与有界响应
每个出站操作都有连接超时和总超时,且响应体有界。这防止慢连接、挂起的 DNS 或超大响应拖死 worker,也防止响应体被意外持久化进作业记录(文档 Tests 一节要求负面测试证明"无界响应体不会持久化到作业或报告中")。
4.2 可重试状态的分类
- 可重试:HTTP
408、425、429、瞬时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 启动时
- 先把过期的
runninglease 标记为可回收; - 记录一条
job-reclaimed-after-restart的 Recovery 事件; - 然后带 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),仅供参考