# 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 修复的范围。