Commit 2a3b248dc3949c1f4e84a2d444a0abb5df9c2b6d
1 parent
d0b5d7ac
graphlemon: prd artifact for task 911861fe-446a-4bab-8b69-b4872b92d616
Showing
1 changed file
with
160 additions
and
0 deletions
.graphlemon/runs/911861fe-446a-4bab-8b69-b4872b92d616/prd.md
0 → 100644
| 1 | +# PRD: `supervisor/deploy_stage.py` 并发部署冲突的诊断化处理 | |
| 2 | + | |
| 3 | +## 1. 背景 | |
| 4 | + | |
| 5 | +`docs/todo/` 及既往 gap-analysis 曾提出 `stage_deployment()` "完全没有并发互斥检查" 的假设。经对当前代码 | |
| 6 | +(`supervisor/deploy_stage.py`、`src/schema.py`)核实,该假设不成立: | |
| 7 | + | |
| 8 | +- `src/schema.py` 的 `deployment` 表已有 `ux_deployment_active`(U-034 引入): | |
| 9 | + ```sql | |
| 10 | + CREATE UNIQUE INDEX ux_deployment_active ON deployment((1)) | |
| 11 | + WHERE status NOT IN ('finalized', 'rolled_back', 'blocked_pending_human'); | |
| 12 | + ``` | |
| 13 | + 即"同一时刻最多一条非终态部署记录",由数据库唯一约束在语句级别原子保证,不存在 TOCTOU 竞态。 | |
| 14 | +- `stage_deployment()`(`supervisor/deploy_stage.py:143`)在触发任何破坏性副作用(`git worktree add`、 | |
| 15 | + DB 拷贝、`Popen` 启动候选进程)之前,先 `INSERT INTO deployment ... VALUES (..., 'staged', ...)` | |
| 16 | + (165-171 行)并立即 `conn.commit()`。若此时已存在未到终态的部署记录,该 `INSERT` 会立刻触发 | |
| 17 | + `ux_deployment_active` 冲突并抛出异常,函数在任何副作用发生前就已终止——不会产生"第二个冲突的候选实例"。 | |
| 18 | +- `supervisor/test_deploy_stage.py::StageDeploymentHappyPathTest.test_only_one_non_terminal_deployment_allowed` | |
| 19 | + (171-180 行)已经断言并发调用被拒绝,用 `self.assertRaises(Exception)` 承接。 | |
| 20 | + | |
| 21 | +因此原 BRD 的核心担忧("静默继续、产生第二个冲突的候选")已被现有实现和现有测试覆盖,不是当前的真实缺口。 | |
| 22 | +本 PRD 基于重新核实后的两处真实、边界清楚的剩余差距: | |
| 23 | + | |
| 24 | +- **G1**:冲突发生时,`stage_deployment()` 让底层 `sqlite3.IntegrityError` 原样向上抛出——调用方拿到的 | |
| 25 | + 是一个不携带 `deployment_id`/当前占用者状态等诊断信息的通用异常,且与本模块其余异常 | |
| 26 | + (`CheckoutFailedError`,以及同包 `deploy_cutover.CutoverFailedError`、`deploy_rollback.RollbackFailedError`) | |
| 27 | + "具名、附带诊断字段"的约定不一致。 | |
| 28 | +- **G2**:现有测试只覆盖了 `finalized` 这一个终态放行路径(`BootstrapPreviousTest` | |
| 29 | + ``test_prior_finalized_deployment_supplies_previous_fields``间接覆盖),`ux_deployment_active` 索引 | |
| 30 | + 排除的另外两个终态——`rolled_back`、`blocked_pending_human`——没有对应的放行回归测试。这两个状态在 | |
| 31 | + 索引 `WHERE` 子句里被显式列为"允许发起新部署"的终态,缺测试意味着未来如果有人不小心改动该 `WHERE` | |
| 32 | + 子句或调用逻辑,不会被现有测试套件发现。 | |
| 33 | + | |
| 34 | +以及贯穿全程的门禁: | |
| 35 | + | |
| 36 | +- **G3**:`src/` 与 `supervisor/` 两个独立 `unittest discover` 套件必须保持全绿(新增/修改代码不得破坏 | |
| 37 | + 既有 560+ 测试)。 | |
| 38 | + | |
| 39 | +## 2. 目标(Goals) | |
| 40 | + | |
| 41 | +1. 当 `stage_deployment()` 因 `ux_deployment_active` 冲突而无法发起新部署时,调用方(Deploy Supervisor | |
| 42 | + 轮询循环、未来的手工调用者、测试代码)能拿到一个具名、语义明确的异常,并能从异常上直接读出: | |
| 43 | + - 本次被拒绝的 `git_commit_hash`; | |
| 44 | + - 当前正占用中的 `deployment_id` 与其 `status`(若能查到)。 | |
| 45 | +2. 补齐 `rolled_back`、`blocked_pending_human` 两个终态的放行回归测试,使"这三个终态都允许发起新部署" | |
| 46 | + 这一约束被测试显式锁定,而不是隐式依赖 SQL `WHERE` 子句不被改错。 | |
| 47 | +3. 两个 `unittest discover` 套件(`src`、`supervisor`)全部通过。 | |
| 48 | + | |
| 49 | +## 3. 非目标(Non-Goals) | |
| 50 | + | |
| 51 | +- 不改变 `ux_deployment_active` 索引本身的终态集合定义(`finalized`/`rolled_back`/`blocked_pending_human`) | |
| 52 | + ——这是 01-M 1.15 既定约束,本次不涉及业务语义变更。 | |
| 53 | +- 不引入应用层的额外锁(如文件锁、内存锁、乐观锁重试)——数据库唯一约束已经是并发正确的单一事实来源, | |
| 54 | + 重复加锁属于不必要的复杂度。 | |
| 55 | +- 不改变 `stage_deployment()` 现有的返回值结构、`CheckoutFailedError` 的行为、或本函数对 | |
| 56 | + `deploy_supervisor.py` 轮询循环的调用契约中除"冲突时抛什么异常"之外的任何其他部分。 | |
| 57 | +- 不处理 `health_check_failed`、`rollback_failed` 是否也应被视为终态——这两个状态目前不在 | |
| 58 | + `ux_deployment_active` 的排除列表里(即它们仍被视为"非终态、阻止新部署发起"),BRD 原文本对这两个 | |
| 59 | + 状态归类的描述与当前 schema 不一致,本 PRD 按当前 schema 的真实定义为准,不在本次范围内重新讨论 | |
| 60 | + 该分类是否合理。 | |
| 61 | + | |
| 62 | +## 4. 需求(Functional Requirements) | |
| 63 | + | |
| 64 | +### FR-1:新增具名异常 `ConcurrentDeploymentError` | |
| 65 | + | |
| 66 | +在 `supervisor/deploy_stage.py` 中新增一个继承自 `Exception` 的具名异常类,命名与放置方式与模块内既有 | |
| 67 | +`CheckoutFailedError` 一致(模块顶部,类旁附简短 docstring 说明触发场景与语义)。异常须携带足够诊断信息, | |
| 68 | +至少包括: | |
| 69 | + | |
| 70 | +- 本次被拒绝、尝试发起的 `git_commit_hash`; | |
| 71 | +- 若能通过一次只读查询定位到当前占用中的部署记录,附带其 `deployment_id` 与 `status`(查不到——理论上 | |
| 72 | + 不应发生,但不应因为查询本身失败而掩盖原始冲突——则异常仍需正常抛出,只是不携带这两个诊断字段)。 | |
| 73 | + | |
| 74 | +这些诊断信息应可通过异常实例的属性访问(例如 `err.git_commit_hash`、`err.blocking_deployment_id`、 | |
| 75 | +`err.blocking_status`),而不仅仅是拼进异常消息字符串——便于调用方做程序化处理,而不是只能解析文本。 | |
| 76 | + | |
| 77 | +### FR-2:`stage_deployment()` 捕获底层冲突并转译 | |
| 78 | + | |
| 79 | +`stage_deployment()` 中触发 `INSERT INTO deployment ...` 的语句需要能感知到"这次插入是因为 | |
| 80 | +`ux_deployment_active` 冲突而失败",并将其转译为 FR-1 定义的 `ConcurrentDeploymentError` 后抛出,而不是 | |
| 81 | +让原始的 `sqlite3.IntegrityError`(或其他底层驱动异常)原样传播出模块边界。 | |
| 82 | + | |
| 83 | +行为约束: | |
| 84 | + | |
| 85 | +- 转译只针对"因并发唯一约束冲突导致的插入失败"这一种情况;不得把其他类型的数据库错误(例如 schema 不 | |
| 86 | + 匹配、连接已关闭等意外错误)也错误地归类成并发冲突并吞掉真实错误信息——转译逻辑需要能区分这两类失败 | |
| 87 | + (例如通过检查错误信息是否指向 `ux_deployment_active`,或等价的可靠判别方式)。 | |
| 88 | +- 冲突发生时不应该产生任何除本次失败的 `INSERT` 之外的副作用——即 FR-2 的行为必须发生在 | |
| 89 | + `_checkout_candidate`/`_copy_prod_db`/`_launch_candidate` 等有实际副作用的调用之前(这一点当前代码结构 | |
| 90 | + 已经满足,属于保持不回归的约束,不是新增的顺序要求)。 | |
| 91 | +- `CheckoutFailedError` 的既有触发路径与语义不受影响。 | |
| 92 | + | |
| 93 | +### FR-3:测试覆盖——冲突路径 | |
| 94 | + | |
| 95 | +补充或改造现有 `test_only_one_non_terminal_deployment_allowed`(`supervisor/test_deploy_stage.py`),使其: | |
| 96 | + | |
| 97 | +- 断言第二次调用抛出的是 FR-1 定义的 `ConcurrentDeploymentError`(而不再是笼统的 `Exception`); | |
| 98 | +- 断言异常实例携带正确的诊断字段(第二次调用尝试的 `git_commit_hash`,以及能定位到第一次调用产生的 | |
| 99 | + `deployment_id`/`status`)。 | |
| 100 | + | |
| 101 | +### FR-4:测试覆盖——终态放行路径补齐 | |
| 102 | + | |
| 103 | +新增两个独立测试用例(或参数化为同一测试类下的两个方法),分别验证: | |
| 104 | + | |
| 105 | +- 数据库中存在一条 `status = 'rolled_back'` 的部署记录时,`stage_deployment()` 能正常发起新部署(不抛 | |
| 106 | + `ConcurrentDeploymentError`); | |
| 107 | +- 数据库中存在一条 `status = 'blocked_pending_human'` 的部署记录时,`stage_deployment()` 同样能正常发起 | |
| 108 | + 新部署。 | |
| 109 | + | |
| 110 | +两个用例都应验证"正常发起"这件事本身可观察的结果(例如返回值 `status` 为 `candidate_starting`,或新 | |
| 111 | +`deployment` 行确实被插入),而不仅仅是"没有抛异常"。 | |
| 112 | + | |
| 113 | +### FR-5:回归门禁 | |
| 114 | + | |
| 115 | +`python3 -m unittest discover -s src` 与 `python3 -m unittest discover -s supervisor -p "test_*.py"` | |
| 116 | +两个命令都必须以退出码 0 完成,不产生新的失败或错误用例。 | |
| 117 | + | |
| 118 | +## 5. 验收标准(Acceptance Criteria,可观察、可验证) | |
| 119 | + | |
| 120 | +| # | 场景 | 期望结果 | | |
| 121 | +|---|------|----------| | |
| 122 | +| AC-1 | 存在一条非终态(如 `staged`/`candidate_starting`/`health_check_running` 等)部署记录时调用 `stage_deployment()` | 抛出 `deploy_stage.ConcurrentDeploymentError`,异常实例可读出本次尝试的 `git_commit_hash` 与当前占用部署的 `deployment_id`/`status`;数据库中不新增第二条部署记录,不产生 worktree/候选进程等副作用 | | |
| 123 | +| AC-2 | 数据库中唯一一条部署记录状态为 `finalized` 时调用 `stage_deployment()` | 正常返回,新记录状态为 `candidate_starting`(既有行为,不得回归) | | |
| 124 | +| AC-3 | 数据库中唯一一条部署记录状态为 `rolled_back` 时调用 `stage_deployment()` | 正常返回,新记录状态为 `candidate_starting` | | |
| 125 | +| AC-4 | 数据库中唯一一条部署记录状态为 `blocked_pending_human` 时调用 `stage_deployment()` | 正常返回,新记录状态为 `candidate_starting` | | |
| 126 | +| AC-5 | `CheckoutFailedError` 既有触发路径(如提交不存在导致 `git worktree add` 失败) | 行为与当前实现完全一致,不受本次改动影响 | | |
| 127 | +| AC-6 | 全量测试 | `python3 -m unittest discover -s src` 与 `python3 -m unittest discover -s supervisor -p "test_*.py"` 均全绿 | | |
| 128 | + | |
| 129 | +## 6. 已知事实 / 设计决策 / 待验证假设 | |
| 130 | + | |
| 131 | +**已知事实**(来自代码核实,非猜测): | |
| 132 | +- `ux_deployment_active` 唯一索引已存在并已在插入语句级别原子化生效(`src/schema.py:332-333`)。 | |
| 133 | +- `stage_deployment()` 的 `INSERT`(165-171 行)先于所有副作用调用(174 行起)执行。 | |
| 134 | +- 当前测试 `test_only_one_non_terminal_deployment_allowed` 用 `assertRaises(Exception)` 断言,未指定异常 | |
| 135 | + 类型/诊断字段——这正是 G1/G3 要收紧的点。 | |
| 136 | +- 索引排除的终态集合是 `{'finalized', 'rolled_back', 'blocked_pending_human'}`,`health_check_failed` | |
| 137 | + 与 `rollback_failed` **不在**该集合中(即仍被视为阻止新部署发起的非终态)。 | |
| 138 | + | |
| 139 | +**设计决策**(PL 基于既有约定作出,非产品/业务层面的新决策): | |
| 140 | +- 新异常沿用模块内 `CheckoutFailedError` 的命名与文档风格(具名、docstring 说明触发场景,见 FR-1)。 | |
| 141 | +- 通过检查底层错误是否指向 `ux_deployment_active` 来区分"并发冲突"与"其他数据库错误",而不是笼统捕获 | |
| 142 | + 所有 `sqlite3.IntegrityError` 并全部归类为并发冲突——这是为了不掩盖其他可能的真实 bug。具体判别手段 | |
| 143 | + (字符串匹配 `sqlite3.IntegrityError.args`、或其他等价方式)留给 SDD/实现阶段选择,只要满足"不误判"这 | |
| 144 | + 一约束。 | |
| 145 | + | |
| 146 | +**待验证假设**:无(本次范围内的行为均可通过 FR-3/FR-4 的单元测试直接验证,不依赖运行期观测)。 | |
| 147 | + | |
| 148 | +**阻塞性未知**:无。 | |
| 149 | + | |
| 150 | +## 7. 风险与依赖 | |
| 151 | + | |
| 152 | +- 风险低:改动范围局限在单一模块(`supervisor/deploy_stage.py`)新增一个异常类 + 一处 `try/except` 转译 | |
| 153 | + + 测试文件补充用例,不触及 schema、不触及其他模块对 `stage_deployment()` 的调用签名或返回值结构。 | |
| 154 | +- 依赖:无新增外部依赖,沿用 `sqlite3` 标准库异常体系。 | |
| 155 | +- 不涉及权限、鉴权、支付、不可逆数据变更等高风险类别,无需额外审批门槛。 | |
| 156 | + | |
| 157 | +## 8. 范围外后续事项(供后续任务参考,不在本次交付范围内) | |
| 158 | + | |
| 159 | +- `health_check_failed`/`rollback_failed` 是否也应纳入终态集合,允许发起新部署——需要业务侧判断这两个 | |
| 160 | + 状态下是否真的应该阻塞后续部署,涉及 01-M/04-P 状态机语义变更,不是本次 bug 修复的范围。 | ... | ... |