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这一个终态放行路径(BootstrapPreviousTesttest_prior_finalized_deployment_supplies_previous_fields间接覆盖),ux_deployment_active索引 排除的另外两个终态——rolled_back、blocked_pending_human——没有对应的放行回归测试。这两个状态在 索引WHERE子句里被显式列为"允许发起新部署"的终态,缺测试意味着未来如果有人不小心改动该WHERE子句或调用逻辑,不会被现有测试套件发现。
以及贯穿全程的门禁:
- G3:
src/与supervisor/两个独立unittest discover套件必须保持全绿(新增/修改代码不得破坏 既有 560+ 测试)。
2. 目标(Goals)
- 当
stage_deployment()因ux_deployment_active冲突而无法发起新部署时,调用方(Deploy Supervisor 轮询循环、未来的手工调用者、测试代码)能拿到一个具名、语义明确的异常,并能从异常上直接读出:- 本次被拒绝的
git_commit_hash; - 当前正占用中的
deployment_id与其status(若能查到)。
- 本次被拒绝的
- 补齐
rolled_back、blocked_pending_human两个终态的放行回归测试,使"这三个终态都允许发起新部署" 这一约束被测试显式锁定,而不是隐式依赖 SQLWHERE子句不被改错。 - 两个
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()的调用签名或返回值结构。
- 测试文件补充用例,不触及 schema、不触及其他模块对
- 依赖:无新增外部依赖,沿用
sqlite3标准库异常体系。 - 不涉及权限、鉴权、支付、不可逆数据变更等高风险类别,无需额外审批门槛。
8. 范围外后续事项(供后续任务参考,不在本次交付范围内)
health_check_failed/rollback_failed是否也应纳入终态集合,允许发起新部署——需要业务侧判断这两个 状态下是否真的应该阻塞后续部署,涉及 01-M/04-P 状态机语义变更,不是本次 bug 修复的范围。