brd.md 11.6 KB

BRD:supervisor/deploy_stage.py 并发部署冲突的诊断化处理

1. 背景与问题定义

graphlemon 的 Deploy Supervisor(supervisor/)在自举(self-hosting)场景下,会把针对 graphlemon 自身仓库 的代码变更打包成一次部署(deployment 行),由 supervisor/deploy_stage.stage_deployment() 负责"拉起候选 实例"这一步:检出新提交到独立 worktree、拷贝生产数据库、在临时端口上启动候选进程。这是蓝绿部署链路 (stage → health-check → cutover → monitor → finalize/rollback)里唯一一个会创建新部署记录的入口,一旦并发 触发两次,会产生两个互相踩踏的候选实例,属于高风险类别(生产事故 / 不可逆的部署状态错乱)。

原始任务描述(来自 docs/todo/ 及既往 gap-analysis)断言"stage_deployment() 只检查上一次成功落地的部署, 完全不检查当前是否已有进行中的部署,因此会静默产生第二个冲突的候选实例"。经对当前代码逐行核实,这一断言 不成立,需要在此明确记录并纠偏(SOUL.md 原则 4:不得把猜测当事实,事实与预想冲突时以事实为准):

  • src/schema.py(deployment 表定义)已经存在 ux_deployment_active 部分唯一索引(U-034 引入, 2026-08-02,非本次新增):
  CREATE UNIQUE INDEX ux_deployment_active ON deployment((1))
      WHERE status NOT IN ('finalized', 'rolled_back', 'blocked_pending_human');

即数据库层面已经保证"同一时刻最多一条非终态部署记录",由 SQLite 在 INSERT 语句级别原子化生效,不存在 TOCTOU(检查后再写入之间的)竞态窗口。

  • stage_deployment()(supervisor/deploy_stage.py:148-176)把 INSERT INTO deployment ... 放在所有具有 真实副作用的操作(git worktree add、生产库拷贝、Popen 启动候选进程)之前执行并立即 commit()。 也就是说,如果此时已存在未到终态的部署记录,冲突会在任何副作用发生前就终止函数执行——不会产生"第二个 冲突的候选实例",这个具体后果本身已经被现有实现规避。
  • supervisor/test_deploy_stage.py::StageDeploymentHappyPathTest.test_only_one_non_terminal_deployment_allowed (171-180 行)已经用真实 sqlite 断言了"第二次调用会被拒绝"这一行为,不是完全没有测试覆盖。

因此,"完全没有互斥检查"这个业务问题已经不存在。但核实过程中发现了两处真实、边界清楚、仍值得作为独立 bug 修复交付的差距,二者都属于"并发冲突被正确拒绝,但拒绝的方式和覆盖不够可靠/可诊断"这一类问题,而不是 "未被拒绝":

  • G1(诊断信息缺失,可运维性问题):冲突发生时,stage_deployment() 让底层 sqlite3.IntegrityError 原样向上抛出。调用方(当前是测试代码,未来是 Deploy Supervisor 的轮询循环和任何手工触发路径)拿到的是一个 不携带 deployment_id、当前占用者状态等诊断信息的通用底层异常,且与同模块 CheckoutFailedError、同包 deploy_cutover.CutoverFailedError、deploy_rollback.RollbackFailedError 这类"具名异常 + 携带诊断字段" 的既有约定不一致。出了并发冲突之后,值班人员/日志只能看到一串 SQLite 内部错误文本,无法直接判断"是谁在 占用、占用到什么状态",需要额外查库才能定位——这是一个真实的可运维性缺口,而不是功能性缺口。
  • G2(回归测试覆盖不完整,防回归问题):ux_deployment_active 索引的 WHERE 子句显式把 finalized/rolled_back/blocked_pending_human 三个状态列为"允许发起新部署"的终态,但现有测试只间接 覆盖了 finalized 这一条路径(BootstrapPreviousTest.test_prior_finalized_deployment_supplies_previous_fields)。 rolled_back、blocked_pending_human 这两个同样被索引显式放行的终态没有对应的回归测试。这意味着如果未来 有人不小心改动了这条 WHERE 子句、或者在 stage_deployment() 里加了额外的状态判断逻辑,测试套件不会 发现"本该允许发起新部署的终态被误挡住了"这类回归。

以及贯穿全程的门禁:

  • G3:src/ 与 supervisor/ 两个独立 unittest discover 套件必须保持全绿——修复动作不得引入新的失败 或破坏既有 560+ 测试。

2. 干系人与使用场景

  • Deploy Supervisor 自身的轮询循环(supervisor/deploy_supervisor.py,未来接线 stage_deployment() 的调用方):需要能程序化区分"这次失败是因为已有部署在进行中"和"这次失败是其他真实错误(如 checkout 失败、 磁盘满)",以便做出不同的后续动作(等待重试 vs. 上报阻塞)。
  • 值班人 / 事后排查者:在自举流程出现异常时,需要从异常信息本身直接读出"是谁占用了这次部署",而不是 额外去查 deployment 表。
  • 未来维护 stage_deployment() 或 ux_deployment_active 索引定义的开发者(人类或 Coder Agent):需要 一组明确的回归测试作为"这三个终态必须放行新部署"这一约束的可执行文档,防止未来改动悄悄破坏该约束。

3. 目标(Goals)

  1. 当 stage_deployment() 因 ux_deployment_active 冲突而无法发起新部署时,调用方能拿到一个具名、语义 明确、携带诊断字段(本次被拒绝的 git_commit_hash、当前占用中的 deployment_id 与 status)的异常, 而不是原始的底层数据库异常。
  2. 把"finalized/rolled_back/blocked_pending_human 三个终态都允许发起新部署"这一约束,从"隐式依赖 SQL WHERE 子句不被改错"变成"由可执行的回归测试显式锁定"。
  3. 修复过程不破坏任何既有行为:CheckoutFailedError 的既有触发路径与语义、stage_deployment() 现有的 返回值结构和调用契约,均保持不变。
  4. src/ 与 supervisor/ 两个 unittest discover 套件全部通过,退出码为 0。

4. 非目标(Non-Goals)

  • 不重新设计或改变 ux_deployment_active 索引本身的终态集合定义(finalized/rolled_back/ blocked_pending_human)——这是 01-M 1.15 既定约束,本次不涉及业务语义变更。
  • 不引入应用层的额外锁(文件锁、进程内内存锁、乐观锁重试等)——数据库唯一约束已经是并发正确性的单一事实 来源且已经过原子化验证,重复加锁只会增加不必要的复杂度和新的一致性风险。
  • 不改变 stage_deployment() 现有的返回值结构({deployment_id, status, candidate_port, candidate_pid, candidate_instance_dir})。
  • 不处理 health_check_failed、rollback_failed 是否也应该被视为终态、允许发起新部署——这两个状态目前 仍在 ux_deployment_active 的"非终态"一侧(即仍会阻止新部署发起),这是否合理需要业务侧判断,属于状态 机语义变更,不在本次 bug 修复范围内(详见第 7 节范围外事项)。
  • 不改动 deploy_supervisor.py 轮询循环本身如何消费这个异常——本次只交付异常的产生方(deploy_stage.py) 和对应测试;调用方是否/如何区分处理该异常留给后续任务。

5. 范围(Scope)

仅涉及两个文件:

  • supervisor/deploy_stage.py:新增一个具名异常类(携带诊断字段),并在触发 ux_deployment_active 冲突的那一次 INSERT 周围新增 try/except,把底层数据库异常转译为该具名异常后抛出。
  • supervisor/test_deploy_stage.py:收紧现有并发冲突测试的断言(从笼统的 assertRaises(Exception) 改为 断言具名异常类型及其诊断字段),并新增 rolled_back、blocked_pending_human 两条终态放行路径的回归 测试。

不涉及 src/schema.py(不改 schema)、不涉及 deploy_cutover.py/deploy_rollback.py/deploy_health_check.py 等同包其他模块、不涉及新增外部依赖。

6. 已知事实 / 设计决策 / 待验证假设 / 阻塞性未知

已知事实(均来自对当前代码的直接核实,非猜测):

  • ux_deployment_active 唯一索引已存在并在 INSERT 语句级别原子化生效(src/schema.py:357-362)。
  • stage_deployment() 的 INSERT(supervisor/deploy_stage.py:170-176)先于所有具有真实副作用的调用 (_checkout_candidate/_copy_prod_db/_launch_candidate,178 行起)执行;冲突发生时不会产生候选 worktree、候选数据库拷贝或候选进程等任何副作用。
  • 现有测试 test_only_one_non_terminal_deployment_allowed(supervisor/test_deploy_stage.py:171-180)用 self.assertRaises(Exception) 承接冲突异常,未指定异常类型也未校验诊断字段——这正是 G1 要收紧的具体 测试点。
  • 索引 WHERE 子句排除(即"允许发起新部署")的终态集合是 {'finalized', 'rolled_back', 'blocked_pending_human'};health_check_failed 与 rollback_failed 不在该集合中,即当前设计下仍被 视为"非终态、会继续阻止新部署发起"的状态。

设计决策(PL 基于本仓库既有约定作出,非新的产品/业务决策,供 SDD 阶段延续):

  • 新异常应沿用模块内 CheckoutFailedError 的命名与文档风格:具名、放在模块顶部、docstring 说明触发场景 与语义,与同包 CutoverFailedError/RollbackFailedError 的既有约定保持一致。
  • 转译逻辑必须能区分"因 ux_deployment_active 冲突导致的插入失败"与"其他类型的数据库错误"(如 schema 不匹配、连接已关闭等意外情况),不能把所有 sqlite3.IntegrityError 一律归类为并发冲突而掩盖其他真实 错误。具体判别手段(例如检查错误信息是否指向 ux_deployment_active,或其他等价可靠方式)留给 SDD/实现 阶段选择,唯一的硬约束是"不得误判"。

待验证假设:无——本次范围内的全部行为均可通过单元测试直接、确定性地验证,不依赖运行期观测或线上数据。

阻塞性未知:无——不需要产品、业务或外部决策即可完成本次修复。

7. 风险与依赖

  • 风险等级:低。 改动范围局限在单一模块新增一个异常类 + 一处 try/except 转译逻辑,加上测试文件补充 用例;不触及 schema、不触及部署状态机的语义定义、不改变任何模块对外的调用签名或返回值结构。
  • 依赖:无新增外部依赖,转译逻辑沿用 Python 标准库 sqlite3 的异常体系。
  • 不涉及权限、鉴权、支付、不可逆数据变更等更高风险类别本身(部署系统虽然高风险,但本次改动不触碰其状态 机语义或副作用触发顺序,只改"冲突发生后异常长什么样"这一件事),无需额外审批门槛。

8. 范围外后续事项(供后续任务参考,不在本次交付范围内)

  • health_check_failed/rollback_failed 是否也应该被纳入 ux_deployment_active 的终态集合、允许在这两 个状态下发起新部署——需要业务侧判断"健康检查失败/回滚失败之后,是否应该允许立即重新尝试部署",涉及 01-M/04-P 状态机语义变更,不是本次 bug 修复的范围,留待独立任务讨论。
  • deploy_supervisor.py 轮询循环如何消费本次新增的具名异常(例如是否需要针对 ConcurrentDeploymentError 做专门的等待重试逻辑)——本次只保证异常"存在且可诊断",调用方的消费策略留给后续任务。