benchmark.md 13.6 KB

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'。
  • 期望输出(可观察断言):
    1. 调用不抛出任何异常。
    2. 返回值是一个 dict/mapping,result["status"] == "candidate_starting", result["git_commit_hash"] == self.commit_v2,result["deployment_id"] 非空字符串。
    3. SELECT COUNT(*) FROM deployment 结果为 1。
    4. 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 不冲突,一次性成功。
  • 期望输出:
    1. 不抛出异常;result["status"] == "candidate_starting"。
    2. SELECT COUNT(*) FROM deployment 结果为 2(旧的 finalized 行 + 新行都保留)。
    3. 新行的 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_candidate mock 返回 pid=111。
  • 行为:ux_deployment_active 索引不约束 rolled_back 状态,INSERT 一次性成功。
  • 期望输出:
    1. 不抛出 ConcurrentDeploymentError,也不抛出其他异常。
    2. result["status"] == "candidate_starting"。
    3. SELECT status FROM deployment WHERE deployment_id = ? (新插入的行)返回 "candidate_starting"。
    4. 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 索引不约束该状态。
  • 期望输出:
    1. 不抛出异常。
    2. result["status"] == "candidate_starting"。
    3. SELECT status FROM deployment WHERE deployment_id = ?(新行)返回 "candidate_starting"。
    4. 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:返回 True。
    • 用例 B:返回 False。
    • 用例 C:返回 False(精确匹配索引名字符串,不是宽泛匹配 "UNIQUE constraint failed")。

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)

  • 输入:
    1. 第一次调用: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_candidate mock 返回 pid=1。该调用成功,行状态被更新为 "candidate_starting"。
    2. 第二次调用(同一个 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 + 异常实例属性断言,不能只断言异常类型):
    1. 抛出的异常类型是 deploy_stage.ConcurrentDeploymentError(不是裸 sqlite3.IntegrityError)。
    2. err.git_commit_hash == self.commit_v1(本次被拒绝调用的 commit,不是占用中的那个)。
    3. err.blocking_deployment_id == first["deployment_id"](第一次调用返回的 deployment_id)。
    4. err.blocking_status == "candidate_starting"。
    5. str(err) 包含子串 self.commit_v1、first["deployment_id"]、"candidate_starting" 三者 (消息可读性检查,确认三个字段确实被拼进了错误消息,不只是存在于属性上)。
    6. 第二次调用中 mock_launch(_launch_candidate)未被调用(mock_launch.assert_not_called())—— 证明失败发生在 INSERT 阶段,尚未进入 checkout/launch 流程,避免产生孤儿 worktree 或孤儿子进程。
    7. 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 冲突。
  • 期望输出:
    1. 抛出 deploy_stage.ConcurrentDeploymentError。
    2. err.blocking_deployment_id == "dep-x",err.blocking_status == "staged"。
    3. 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 通过 mock subprocess.run(或既有测试里对 _checkout_candidate 依赖的 subprocess 调用) 使其抛出 subprocess.CalledProcessError。
  • 行为:stage_deployment() 捕获该错误,转译为既有的 deploy_stage.CheckoutFailedError; 本次改动新增的 try/except sqlite3.IntegrityError 只包裹 INSERT 语句本身,不包裹 _checkout_candidate 调用,因此该分支的行为应与改动前逐字节一致。
  • 期望输出:
    1. 抛出的异常类型是 deploy_stage.CheckoutFailedError(不是 ConcurrentDeploymentError, 两者互不干扰)。
    2. 对应的 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 的存在), 必须确认未被意外破坏。