Benchmark:supervisor/deploy_stage.py 并发部署冲突诊断化处理
对应 PRD:《supervisor/deploy_stage.stage_deployment() 并发部署冲突的诊断化处理》(FR-1~FR-5, AC-1~AC-6)。
对应 SDD:《supervisor/deploy_stage.py 并发部署冲突诊断化处理》 §2~§6。
测试文件:supervisor/test_deploy_stage.py。所有用例使用真实 sqlite3 内存/临时文件数据库 +
真实 git worktree(沿用既有 DeployStageTestBase 约定),仅 mock deploy_stage._launch_candidate
(避免真的 fork 子进程监听端口)。禁止 mock sqlite3、conn.execute、_is_concurrent_deployment_conflict
本身——并发冲突必须由真实的 ux_deployment_active 唯一索引触发,不能靠 mock 伪造。
0. 公共前置(Test Fixture)
self.conn:真实 sqlite3 连接,已执行src/schema.py建表 DDL(含ux_deployment_active索引)。self.repo_dir:临时 git 仓库,至少有commit_v1(初始提交)与commit_v2(后续提交)两个真实 commit hash。self.prod_db_path:临时文件路径,指向一个已初始化 schema 的 sqlite 文件(_checkout_candidate/_launch_candidate需要它存在)。- 每个测试类结束时清理临时目录(
tearDown中shutil.rmtree),不留孤儿 worktree。
1. Happy Path 场景
T-01:无历史部署记录,首次 staging 正常放行
- 输入:
self.conn中deployment表为空(无任何行)。调用deploy_stage.stage_deployment(self.conn, self.commit_v2, repo_root=self.repo_dir, prod_db_path=self.prod_db_path, port=9001),_launch_candidate被 mock 为返回pid=111的对象,不真实启动子进程。 - 行为:
INSERT语句一次性成功提交(不进入except sqlite3.IntegrityError分支);_checkout_candidate使用真实git worktree add检出commit_v2;成功后该行UPDATE为status = 'candidate_starting'。 - 期望输出(可观察断言):
- 调用不抛出任何异常。
- 返回值是一个 dict/mapping,
result["status"] == "candidate_starting",result["git_commit_hash"] == self.commit_v2,result["deployment_id"]非空字符串。 SELECT COUNT(*) FROM deployment结果为1。SELECT status FROM deployment WHERE deployment_id = ?返回"candidate_starting"。
T-02:已存在一条 finalized 状态的历史部署,新 staging 正常放行(AC-2,既有回归测试,不改)
- 输入:先插入一行
deployment_id='dep-old', git_commit_hash='commit-old', status='finalized'(模拟上一次成功落地的部署),再调用stage_deployment(self.conn, self.commit_v2, ...)。 - 行为:
ux_deployment_active索引条件不包含finalized状态,INSERT不冲突,一次性成功。 - 期望输出:
- 不抛出异常;
result["status"] == "candidate_starting"。 SELECT COUNT(*) FROM deployment结果为2(旧的finalized行 + 新行都保留)。- 新行的
previous_commit_hash == 'commit-old'(既有 bootstrap-from-previous-finalized 行为不回归, 由既有测试BootstrapPreviousTest.test_prior_finalized_deployment_supplies_previous_fields覆盖, 此处仅确认不与本次改动冲突)。
- 不抛出异常;
T-03:已存在一条 rolled_back 状态的历史部署,新 staging 正常放行(AC-3,新增)
- 输入:
_seed_deployment("rolled_back")插入一行deployment_id='dep-prior', git_commit_hash='commit-prior', status='rolled_back'。 调用stage_deployment(self.conn, self.commit_v2, repo_root=self.repo_dir, prod_db_path=self.prod_db_path, port=9101),_launch_candidatemock 返回pid=111。 - 行为:
ux_deployment_active索引不约束rolled_back状态,INSERT一次性成功。 - 期望输出:
- 不抛出
ConcurrentDeploymentError,也不抛出其他异常。 result["status"] == "candidate_starting"。SELECT status FROM deployment WHERE deployment_id = ?(新插入的行)返回"candidate_starting"。SELECT COUNT(*) FROM deployment结果为2(两行并存,旧行状态仍是rolled_back,未被改动)。
- 不抛出
T-04:已存在一条 blocked_pending_human 状态的历史部署,新 staging 正常放行(AC-4,新增)
- 输入:
_seed_deployment("blocked_pending_human")插入一行status='blocked_pending_human'。 调用stage_deployment(self.conn, self.commit_v2, repo_root=self.repo_dir, prod_db_path=self.prod_db_path, port=9102)。 - 行为:同 T-03,
ux_deployment_active索引不约束该状态。 - 期望输出:
- 不抛出异常。
result["status"] == "candidate_starting"。SELECT status FROM deployment WHERE deployment_id = ?(新行)返回"candidate_starting"。SELECT COUNT(*) FROM deployment结果为2;旧行status仍为"blocked_pending_human"(未被覆盖或删除)。
2. 边界场景
T-05:并发冲突后,deployment 表行数不增长(AC-1 的行数不变式,附属于 T-06 但单独可断言)
- 输入:延续 T-06 的两次调用序列。
- 行为:第二次
INSERT触发IntegrityError,except分支执行conn.rollback(),未提交任何新行。 - 期望输出:冲突发生后立即执行
SELECT COUNT(*) FROM deployment,结果仍为1(只有第一次调用成功插入的那一行),不会因为失败的INSERT尝试而残留半提交的行或脏行。
T-06:非 ux_deployment_active 触发的 IntegrityError 不应被误判为并发冲突(_is_concurrent_deployment_conflict 判别边界)
- 输入:直接单元测试
deploy_stage._is_concurrent_deployment_conflict(...),不经过完整stage_deployment()流程:- 用例 A:构造一个
sqlite3.IntegrityError("UNIQUE constraint failed: index 'ux_deployment_active'"), 传入函数。 - 用例 B:构造一个
sqlite3.IntegrityError("NOT NULL constraint failed: deployment.git_commit_hash"), 传入函数。 - 用例 C:构造一个
sqlite3.IntegrityError("UNIQUE constraint failed: index 'ux_some_other_index'"), 传入函数(同为 UNIQUE 冲突,但索引名不同)。
- 用例 A:构造一个
- 期望输出:
- 用例 A:返回
True。 - 用例 B:返回
False。 - 用例 C:返回
False(精确匹配索引名字符串,不是宽泛匹配 "UNIQUE constraint failed")。
- 用例 A:返回
T-07:_find_blocking_deployment 在多条非终态行存在时返回最新一条
- 输入:连续插入两行非终态部署(绕过
stage_deployment()直接用conn.execute插入, 借此模拟索引本应阻止但测试内部直接构造历史数据的边界情况):deployment_id='dep-a', status='staged'(先插入),deployment_id='dep-b', status='health_check_running'(后插入,rowid 更大)。之后调用deploy_stage._find_blocking_deployment(self.conn)。 - 期望输出:返回
('dep-b', 'health_check_running')(按rowid DESC LIMIT 1,取最后插入的一条), 不是dep-a。
T-08:_find_blocking_deployment 在查询本身失败时返回 None 而不抛异常
- 输入:传入一个已被关闭(
conn.close())的 sqlite3 连接对象给_find_blocking_deployment。 - 期望输出:函数捕获内部
sqlite3.Error(连接已关闭时执行execute会抛出sqlite3.ProgrammingError,是sqlite3.Error子类),返回None,不向调用方传播异常。
3. 失败/异常场景(核心场景,对应 AC-1)
T-09:已有一条进行中(candidate_starting)部署时,第二次 staging 被拒绝,抛出具名异常且携带诊断字段(AC-1,改造后的 test_only_one_non_terminal_deployment_allowed)
- 输入:
- 第一次调用:
first = deploy_stage.stage_deployment(self.conn, self.commit_v2, repo_root=self.repo_dir, prod_db_path=self.prod_db_path, port=9001),_launch_candidatemock 返回pid=1。该调用成功,行状态被更新为"candidate_starting"。 - 第二次调用(同一个
self.conn,模拟并发触发):deploy_stage.stage_deployment(self.conn, self.commit_v1, repo_root=self.repo_dir, prod_db_path=self.prod_db_path, port=9002), 同样 mock_launch_candidate(不应被调用到,因为应在INSERT阶段就失败)。
- 第一次调用:
- 行为:第二次调用的
INSERT触发ux_deployment_active唯一索引冲突,进入except sqlite3.IntegrityError分支,_is_concurrent_deployment_conflict返回True,_find_blocking_deployment查到第一次调用留下的那一行,最终raise ConcurrentDeploymentError(...)。 - 期望输出(用
assertRaises+ 异常实例属性断言,不能只断言异常类型):- 抛出的异常类型是
deploy_stage.ConcurrentDeploymentError(不是裸sqlite3.IntegrityError)。 err.git_commit_hash == self.commit_v1(本次被拒绝调用的 commit,不是占用中的那个)。err.blocking_deployment_id == first["deployment_id"](第一次调用返回的 deployment_id)。err.blocking_status == "candidate_starting"。str(err)包含子串self.commit_v1、first["deployment_id"]、"candidate_starting"三者 (消息可读性检查,确认三个字段确实被拼进了错误消息,不只是存在于属性上)。- 第二次调用中
mock_launch(_launch_candidate)未被调用(mock_launch.assert_not_called())—— 证明失败发生在INSERT阶段,尚未进入 checkout/launch 流程,避免产生孤儿 worktree 或孤儿子进程。 SELECT COUNT(*) FROM deployment结果为1(不新增冲突的第二行)。
- 抛出的异常类型是
T-10:已有一条 staged(尚未进入 checkout)状态的部署时,第二次 staging 同样被拒绝
- 输入:绕过完整
stage_deployment(),直接conn.execute插入一行deployment_id='dep-x', git_commit_hash='commit-x', status='staged'并commit()(模拟第一次调用 在INSERT成功、但尚未走到后续UPDATE ... candidate_starting之前发生的时间窗口)。 之后调用deploy_stage.stage_deployment(self.conn, self.commit_v2, repo_root=self.repo_dir, prod_db_path=self.prod_db_path, port=9003)。 - 行为:同 T-09,
ux_deployment_active索引同样约束staged状态(不仅约束candidate_starting),INSERT冲突。 - 期望输出:
- 抛出
deploy_stage.ConcurrentDeploymentError。 err.blocking_deployment_id == "dep-x",err.blocking_status == "staged"。SELECT COUNT(*) FROM deployment结果为1。
- 抛出
T-11:并发冲突后,self.conn 处于干净可用状态,可以立即执行后续只读查询(conn.rollback() 效果验证)
- 输入:紧接 T-09 的第二次调用(捕获异常后),在同一个
except/with self.assertRaises块外, 立即对self.conn执行一次普通只读查询:self.conn.execute("SELECT 1").fetchone()。 - 期望输出:查询正常返回
(1,),不抛出sqlite3.OperationalError(例如 "cannot start a transaction within a transaction" 或连接被挂起的错误),证明except分支里的conn.rollback()确实清理了因IntegrityError而悬挂的隐式事务。
T-12:CheckoutFailedError 路径不受本次改动影响(AC-5,既有回归测试,不改,仅确认覆盖不丢失)
- 输入:
INSERT成功(无并发冲突),_checkout_candidate内部触发的git worktree add通过 mocksubprocess.run(或既有测试里对_checkout_candidate依赖的 subprocess 调用) 使其抛出subprocess.CalledProcessError。 - 行为:
stage_deployment()捕获该错误,转译为既有的deploy_stage.CheckoutFailedError; 本次改动新增的try/except sqlite3.IntegrityError只包裹INSERT语句本身,不包裹_checkout_candidate调用,因此该分支的行为应与改动前逐字节一致。 - 期望输出:
- 抛出的异常类型是
deploy_stage.CheckoutFailedError(不是ConcurrentDeploymentError, 两者互不干扰)。 - 对应的
deployment行status被更新为"blocked_pending_human"(既有行为,不回归)。
- 抛出的异常类型是
4. 覆盖矩阵(PRD 验收标准 -> 本文档场景)
| PRD 验收标准 | 覆盖场景 |
|---|---|
| AC-1(并发冲突:具名异常 + 诊断字段 + 不新增行 + 未进入 launch) | T-09、T-10、T-05 |
AC-2(finalized 放行,既有行为不回归) |
T-02 |
AC-3(rolled_back 放行) |
T-03 |
AC-4(blocked_pending_human 放行) |
T-04 |
AC-5(CheckoutFailedError 路径不回归) |
T-12 |
| AC-6(全量测试全绿) | 见下方「全量回归」 |
| (SDD 补充)判别函数正确性 | T-06 |
| (SDD 补充)诊断查询健壮性 | T-07、T-08 |
| (SDD 补充)连接状态清理 | T-11 |
| (Happy path 基线,不属于本次改动但必须不回归) | T-01 |
5. 全量回归(AC-6)
本次改动完成后,以下两条命令必须均以退出码 0 结束,不允许有任何 FAIL/ERROR:
python3 -m unittest discover -s src
python3 -m unittest discover -s supervisor -p "test_*.py"
第二条命令必须完整跑完 supervisor/test_deploy_stage.py 中本文档列出的全部 12 个场景
(T-01~T-12),以及 supervisor/ 下其余既有测试文件(test_deploy_cutover.py、
test_deploy_health_check.py、test_deploy_monitor.py、test_deploy_rollback.py、
test_self_hosting_e2e.py 等)——本次改动不修改这些文件,但它们间接依赖 deploy_stage
模块的公开接口(如 stage_deployment 的返回值结构、CheckoutFailedError 的存在),
必须确认未被意外破坏。