diff --git a/.graphlemon/runs/911861fe-446a-4bab-8b69-b4872b92d616/prd.md b/.graphlemon/runs/911861fe-446a-4bab-8b69-b4872b92d616/prd.md new file mode 100644 index 0000000..83f835d --- /dev/null +++ b/.graphlemon/runs/911861fe-446a-4bab-8b69-b4872b92d616/prd.md @@ -0,0 +1,160 @@ +# 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 修复的范围。