prd.md 11.6 KB

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

1. 背景

docs/todo/ 及既往 gap-analysis 曾提出 stage_deployment() "完全没有并发互斥检查" 的假设。经对当前代码 (supervisor/deploy_stage.py、src/schema.py)核实,该假设不成立:

  • src/schema.py 的 deployment 表已有 ux_deployment_active(U-034 引入): sql CREATE UNIQUE INDEX ux_deployment_active ON deployment((1)) WHERE status NOT IN ('finalized', 'rolled_back', 'blocked_pending_human'); 即"同一时刻最多一条非终态部署记录",由数据库唯一约束在语句级别原子保证,不存在 TOCTOU 竞态。
  • stage_deployment()(supervisor/deploy_stage.py:143)在触发任何破坏性副作用(git worktree add、 DB 拷贝、Popen 启动候选进程)之前,先 INSERT INTO deployment ... VALUES (..., 'staged', ...) (165-171 行)并立即 conn.commit()。若此时已存在未到终态的部署记录,该 INSERT 会立刻触发 ux_deployment_active 冲突并抛出异常,函数在任何副作用发生前就已终止——不会产生"第二个冲突的候选实例"。
  • supervisor/test_deploy_stage.py::StageDeploymentHappyPathTest.test_only_one_non_terminal_deployment_allowed (171-180 行)已经断言并发调用被拒绝,用 self.assertRaises(Exception) 承接。

因此原 BRD 的核心担忧("静默继续、产生第二个冲突的候选")已被现有实现和现有测试覆盖,不是当前的真实缺口。 本 PRD 基于重新核实后的两处真实、边界清楚的剩余差距:

  • G1:冲突发生时,stage_deployment() 让底层 sqlite3.IntegrityError 原样向上抛出——调用方拿到的 是一个不携带 deployment_id/当前占用者状态等诊断信息的通用异常,且与本模块其余异常 (CheckoutFailedError,以及同包 deploy_cutover.CutoverFailedError、deploy_rollback.RollbackFailedError) "具名、附带诊断字段"的约定不一致。
  • G2:现有测试只覆盖了 finalized 这一个终态放行路径(BootstrapPreviousTest test_prior_finalized_deployment_supplies_previous_fields间接覆盖),ux_deployment_active 索引 排除的另外两个终态——rolled_back、blocked_pending_human——没有对应的放行回归测试。这两个状态在 索引 WHERE 子句里被显式列为"允许发起新部署"的终态,缺测试意味着未来如果有人不小心改动该 WHERE 子句或调用逻辑,不会被现有测试套件发现。

以及贯穿全程的门禁:

  • G3:src/ 与 supervisor/ 两个独立 unittest discover 套件必须保持全绿(新增/修改代码不得破坏 既有 560+ 测试)。

2. 目标(Goals)

  1. 当 stage_deployment() 因 ux_deployment_active 冲突而无法发起新部署时,调用方(Deploy Supervisor 轮询循环、未来的手工调用者、测试代码)能拿到一个具名、语义明确的异常,并能从异常上直接读出:
    • 本次被拒绝的 git_commit_hash;
    • 当前正占用中的 deployment_id 与其 status(若能查到)。
  2. 补齐 rolled_back、blocked_pending_human 两个终态的放行回归测试,使"这三个终态都允许发起新部署" 这一约束被测试显式锁定,而不是隐式依赖 SQL WHERE 子句不被改错。
  3. 两个 unittest discover 套件(src、supervisor)全部通过。

3. 非目标(Non-Goals)

  • 不改变 ux_deployment_active 索引本身的终态集合定义(finalized/rolled_back/blocked_pending_human) ——这是 01-M 1.15 既定约束,本次不涉及业务语义变更。
  • 不引入应用层的额外锁(如文件锁、内存锁、乐观锁重试)——数据库唯一约束已经是并发正确的单一事实来源, 重复加锁属于不必要的复杂度。
  • 不改变 stage_deployment() 现有的返回值结构、CheckoutFailedError 的行为、或本函数对 deploy_supervisor.py 轮询循环的调用契约中除"冲突时抛什么异常"之外的任何其他部分。
  • 不处理 health_check_failed、rollback_failed 是否也应被视为终态——这两个状态目前不在 ux_deployment_active 的排除列表里(即它们仍被视为"非终态、阻止新部署发起"),BRD 原文本对这两个 状态归类的描述与当前 schema 不一致,本 PRD 按当前 schema 的真实定义为准,不在本次范围内重新讨论 该分类是否合理。

4. 需求(Functional Requirements)

FR-1:新增具名异常 ConcurrentDeploymentError

在 supervisor/deploy_stage.py 中新增一个继承自 Exception 的具名异常类,命名与放置方式与模块内既有 CheckoutFailedError 一致(模块顶部,类旁附简短 docstring 说明触发场景与语义)。异常须携带足够诊断信息, 至少包括:

  • 本次被拒绝、尝试发起的 git_commit_hash;
  • 若能通过一次只读查询定位到当前占用中的部署记录,附带其 deployment_id 与 status(查不到——理论上 不应发生,但不应因为查询本身失败而掩盖原始冲突——则异常仍需正常抛出,只是不携带这两个诊断字段)。

这些诊断信息应可通过异常实例的属性访问(例如 err.git_commit_hash、err.blocking_deployment_id、 err.blocking_status),而不仅仅是拼进异常消息字符串——便于调用方做程序化处理,而不是只能解析文本。

FR-2:stage_deployment() 捕获底层冲突并转译

stage_deployment() 中触发 INSERT INTO deployment ... 的语句需要能感知到"这次插入是因为 ux_deployment_active 冲突而失败",并将其转译为 FR-1 定义的 ConcurrentDeploymentError 后抛出,而不是 让原始的 sqlite3.IntegrityError(或其他底层驱动异常)原样传播出模块边界。

行为约束:

  • 转译只针对"因并发唯一约束冲突导致的插入失败"这一种情况;不得把其他类型的数据库错误(例如 schema 不 匹配、连接已关闭等意外错误)也错误地归类成并发冲突并吞掉真实错误信息——转译逻辑需要能区分这两类失败 (例如通过检查错误信息是否指向 ux_deployment_active,或等价的可靠判别方式)。
  • 冲突发生时不应该产生任何除本次失败的 INSERT 之外的副作用——即 FR-2 的行为必须发生在 _checkout_candidate/_copy_prod_db/_launch_candidate 等有实际副作用的调用之前(这一点当前代码结构 已经满足,属于保持不回归的约束,不是新增的顺序要求)。
  • CheckoutFailedError 的既有触发路径与语义不受影响。

FR-3:测试覆盖——冲突路径

补充或改造现有 test_only_one_non_terminal_deployment_allowed(supervisor/test_deploy_stage.py),使其:

  • 断言第二次调用抛出的是 FR-1 定义的 ConcurrentDeploymentError(而不再是笼统的 Exception);
  • 断言异常实例携带正确的诊断字段(第二次调用尝试的 git_commit_hash,以及能定位到第一次调用产生的 deployment_id/status)。

FR-4:测试覆盖——终态放行路径补齐

新增两个独立测试用例(或参数化为同一测试类下的两个方法),分别验证:

  • 数据库中存在一条 status = 'rolled_back' 的部署记录时,stage_deployment() 能正常发起新部署(不抛 ConcurrentDeploymentError);
  • 数据库中存在一条 status = 'blocked_pending_human' 的部署记录时,stage_deployment() 同样能正常发起 新部署。

两个用例都应验证"正常发起"这件事本身可观察的结果(例如返回值 status 为 candidate_starting,或新 deployment 行确实被插入),而不仅仅是"没有抛异常"。

FR-5:回归门禁

python3 -m unittest discover -s src 与 python3 -m unittest discover -s supervisor -p "test_*.py" 两个命令都必须以退出码 0 完成,不产生新的失败或错误用例。

5. 验收标准(Acceptance Criteria,可观察、可验证)

# 场景 期望结果
AC-1 存在一条非终态(如 staged/candidate_starting/health_check_running 等)部署记录时调用 stage_deployment() 抛出 deploy_stage.ConcurrentDeploymentError,异常实例可读出本次尝试的 git_commit_hash 与当前占用部署的 deployment_id/status;数据库中不新增第二条部署记录,不产生 worktree/候选进程等副作用
AC-2 数据库中唯一一条部署记录状态为 finalized 时调用 stage_deployment() 正常返回,新记录状态为 candidate_starting(既有行为,不得回归)
AC-3 数据库中唯一一条部署记录状态为 rolled_back 时调用 stage_deployment() 正常返回,新记录状态为 candidate_starting
AC-4 数据库中唯一一条部署记录状态为 blocked_pending_human 时调用 stage_deployment() 正常返回,新记录状态为 candidate_starting
AC-5 CheckoutFailedError 既有触发路径(如提交不存在导致 git worktree add 失败) 行为与当前实现完全一致,不受本次改动影响
AC-6 全量测试 python3 -m unittest discover -s src 与 python3 -m unittest discover -s supervisor -p "test_*.py" 均全绿

6. 已知事实 / 设计决策 / 待验证假设

已知事实(来自代码核实,非猜测):

  • ux_deployment_active 唯一索引已存在并已在插入语句级别原子化生效(src/schema.py:332-333)。
  • stage_deployment() 的 INSERT(165-171 行)先于所有副作用调用(174 行起)执行。
  • 当前测试 test_only_one_non_terminal_deployment_allowed 用 assertRaises(Exception) 断言,未指定异常 类型/诊断字段——这正是 G1/G3 要收紧的点。
  • 索引排除的终态集合是 {'finalized', 'rolled_back', 'blocked_pending_human'},health_check_failed 与 rollback_failed 不在该集合中(即仍被视为阻止新部署发起的非终态)。

设计决策(PL 基于既有约定作出,非产品/业务层面的新决策):

  • 新异常沿用模块内 CheckoutFailedError 的命名与文档风格(具名、docstring 说明触发场景,见 FR-1)。
  • 通过检查底层错误是否指向 ux_deployment_active 来区分"并发冲突"与"其他数据库错误",而不是笼统捕获 所有 sqlite3.IntegrityError 并全部归类为并发冲突——这是为了不掩盖其他可能的真实 bug。具体判别手段 (字符串匹配 sqlite3.IntegrityError.args、或其他等价方式)留给 SDD/实现阶段选择,只要满足"不误判"这 一约束。

待验证假设:无(本次范围内的行为均可通过 FR-3/FR-4 的单元测试直接验证,不依赖运行期观测)。

阻塞性未知:无。

7. 风险与依赖

  • 风险低:改动范围局限在单一模块(supervisor/deploy_stage.py)新增一个异常类 + 一处 try/except 转译
    • 测试文件补充用例,不触及 schema、不触及其他模块对 stage_deployment() 的调用签名或返回值结构。
  • 依赖:无新增外部依赖,沿用 sqlite3 标准库异常体系。
  • 不涉及权限、鉴权、支付、不可逆数据变更等高风险类别,无需额外审批门槛。

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

  • health_check_failed/rollback_failed 是否也应纳入终态集合,允许发起新部署——需要业务侧判断这两个 状态下是否真的应该阻塞后续部署,涉及 01-M/04-P 状态机语义变更,不是本次 bug 修复的范围。