From febab504baad159a0506b556e5ecd8e40569e409 Mon Sep 17 00:00:00 2001 From: graphlemon Date: Thu, 13 Aug 2026 17:46:45 +0800 Subject: [PATCH] graphlemon: benchmark artifact for task 911861fe-446a-4bab-8b69-b4872b92d616 --- .graphlemon/runs/911861fe-446a-4bab-8b69-b4872b92d616/benchmark.md | 200 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 195 insertions(+), 5 deletions(-) diff --git a/.graphlemon/runs/911861fe-446a-4bab-8b69-b4872b92d616/benchmark.md b/.graphlemon/runs/911861fe-446a-4bab-8b69-b4872b92d616/benchmark.md index 309219a..6decf76 100644 --- a/.graphlemon/runs/911861fe-446a-4bab-8b69-b4872b92d616/benchmark.md +++ b/.graphlemon/runs/911861fe-446a-4bab-8b69-b4872b92d616/benchmark.md @@ -1,7 +1,197 @@ -The graphlemon Work Node (planner) workflow completed. It produced a Benchmark artifact for the goal "修复 supervisor/deploy_stage.py 并发部署互斥锁问题" (fix missing concurrency mutex in stage_deployment()): +# Benchmark:`supervisor/deploy_stage.py` 并发部署冲突诊断化处理 -- Scope: new `ConcurrentDeploymentError` exception + try/except around the existing `INSERT INTO deployment` in supervisor/deploy_stage.py; corresponding test tightening/additions in supervisor/test_deploy_stage.py. No schema change, no new dependency. -- Key finding: ux_deployment_active's actual exclusion set is only finalized/rolled_back/blocked_pending_human — health_check_failed and rollback_failed are NOT excluded, so those must still block a new deployment. -- 9 test scenarios defined: happy path (no prior row), three terminal-state pass-throughs, the conflicting in-flight case (with exception attribute assertions), two failure-boundary statuses, a no-side-effects check, and a full regression run of both src and supervisor test suites. +对应 PRD:《`supervisor/deploy_stage.stage_deployment()` 并发部署冲突的诊断化处理》(FR-1~FR-5, AC-1~AC-6)。 +对应 SDD:《`supervisor/deploy_stage.py` 并发部署冲突诊断化处理》 §2~§6。 -No repo files changed (git status clean) — this was a pure Planner artifact-generation step; the Benchmark content is returned as this node's structured output for the next stage (Coder) to consume. \ No newline at end of file +测试文件:`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`: + +```bash +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` 的存在), +必须确认未被意外破坏。 \ No newline at end of file -- libgit2 0.21.4