Fix: fail closed after project reconcile errors - #655
Conversation
|
PR Title: Fix: fail closed after project reconcile errors Commit: 本次变更在 applyProject 的调度器协调失败路径上新增了 fail-closed 补偿机制:当 ReconcileSchedulers 返回错误时,调用新函数 disableProjectSchedulersAfterReconcileFailure,禁用该项目全部已启用调度器并刷新调度器控制器,避免调度器在未完整协调的新 revision 上继续运行;补偿错误通过 errors.Join 合并进原错误。新增测试文件用 mock store/validator 覆盖了辅助函数的禁用行为与错误合并。 整体评估:辅助函数本身的实现与错误处理正确,单元测试覆盖了主要正常/错误分支。但存在一个可用性回归风险——补偿触发条件过宽:applyProject 对 ReconcileSchedulers 的任何错误(包括第一个调度器写入前即失败的瞬态错误,以及最终控制器 Refresh 失败但 store 已完整落库的情况)都无条件禁用全部调度器,可能把瞬态故障放大为整个项目调度器停机。详见所提交的 finding。 |
|
已按最新评论修订 Apply 状态机:
本地验证: |
|
PR Title: Fix: fail closed after project reconcile errors Commit: 本次 PR 针对已确认的历史 finding(无条件 fail-closed 导致瞬态/未变更失败引发全项目调度器非必要停机)进行修复:将 controller.applyProject 中 reconcile 失败后的补偿从「任何错误都禁用项目全部调度器」收紧为「仅当 store 已发生实际变更且失败非 refresh 阶段时才 fail-closed」。 实现方式:ReconcileSchedulers 内部用 mutationTrackingSchedulerStore 包装 store,追踪三个写方法(UpsertProjectScheduler / SetProjectSchedulerEnabled / ReplaceSchedulerTriggers)的成功调用得到 storeChanged;新增 schedulerReconcileError 携带 err/storeChanged/refreshOnly 并在所有错误出口包装;新增 schedulerReconcileNeedsFailClosed 判定(errors.As 命中且 storeChanged && !refreshOnly);controller.go 仅在该判定为真时调用 disableProjectSchedulersAfterReconcileFailure。 正确性分析:changed=false 表示失败发生在任何成功写入前,DB 保持变更前一致状态,不 fail-closed 安全;refreshOnly 仅在 ReconcileSchedulers 末尾触发,此时 store 已完整一致落库,仅控制器内存未同步,跳过 fail-closed 避免放大瞬态刷新失败。单测覆盖 read-before-mutation / trigger replacement / scheduler enable / controller refresh 四类失败下判定函数的布尔结果。 整体评估:修复方向正确、逻辑自洽,核心分支有单测覆盖,未发现高置信度新正确性缺陷。报告了一个低严重度测试覆盖缺口:多调度器「部分完整落库后再失败」的跨条目部分应用场景,以及 controller 集成层对条件化 fail-closed 是否被正确调用,均缺少回归测试。另建议团队确认 refresh 失败时 fail-open(DB 已落库新状态而运行态仍旧)这一安全姿态转变是否符合预期。 |
| {name: "scheduler enable", enableErr: enableErr, wantCleanup: true}, | ||
| {name: "read before mutation", getErr: errors.New("read failed")}, | ||
| {name: "trigger replacement", replaceErr: replaceErr, wantCleanup: true, wantFailClosed: true}, | ||
| {name: "scheduler enable", enableErr: enableErr, wantCleanup: true, wantFailClosed: true}, |
There was a problem hiding this comment.
条件化 fail-closed 缺少多调度器部分写入与控制器集成层测试,存在静默回归风险
本 PR 将 fail-closed 从『任意 reconcile 错误』收紧为『store 已变更且非 refresh 阶段』,这是修复历史 finding 的核心行为变化,直接影响部分写入后的数据一致性补偿是否触发。但当前新增测试仅覆盖单个调度器内的失败分支(read-before-mutation 验证 storeChanged=false 不 fail-closed,trigger replacement/scheduler enable 验证 storeChanged=true 时 fail-closed,controller refresh 验证 refreshOnly 不 fail-closed)。缺少两类覆盖:(1) 跨多个调度器场景——spec 中第一个调度器已完整落库(changed=true)后第二个调度器在读取/替换阶段失败,必须仍判定为需要 fail-closed;这是该判定存在的主要目的,一旦 mutationTrackingSchedulerStore 的 changed 追踪被误改成仅反映最后一次操作,或未来接口新增写方法而忘记包装,现有单调度器测试无法发现;(2) controller.applyProject 集成层没有任何测试验证 needsFailClosed=false 时 disableProjectSchedulersAfterReconcileFailure 不再被调用、needsFailClosed=true 时仍会被调用,该行为若被后续重构破坏只能靠人工回归发现。
Problem code:
Changed code at internal/projects/reconcile_scheduler_test.go:132-134
Recommendation:
建议补充:(1) 在 TestReconcileSchedulersFailureReportsCleanupAndPartialChanges 增加多调度器用例:请求包含两个 scheduler,第一个正常完成 upsert+replace+enable(使 trackedStore.changed=true),第二个在读取或替换阶段注入错误,断言 schedulerReconcileNeedsFailClosed(err)==true;(2) 增加 controller 层集成测试,mock store 与 refresh,分别验证 reconcile 失败且 needsFailClosed=false(如 read-before-mutation、refresh 失败)时 disableProjectSchedulersAfterReconcileFailure 不被调用、调度器保持原状态,以及 needsFailClosed=true 时该补偿被调用且所有 enabled 调度器被禁用。
|
已修复 mutation-unknown 状态:
本地验证: |
|
PR Title: Fix: fail closed after project reconcile errors Commit: 本次改动围绕调度器(scheduler)对账(reconcile)的 fail-closed 语义做加固:在 mutationTrackingSchedulerStore 中新增 uncertain 标志——当 UpsertProjectScheduler / SetProjectSchedulerEnabled / ReplaceSchedulerTriggers 任一写操作返回错误时置位;schedulerReconcileError 增加 storeMutationUncertain 字段并在所有错误出口透传;schedulerReconcileNeedsFailClosed 改为 storeChanged || storeMutationUncertain 时返回 true(refreshOnly 仍排除)。核心意图是:此前只有"确有成功写入"才 fail-closed,而"首次写入即失败、结果未知(如网络超时但实际已落库)"不会 fail-closed,可能让调用方误以为存储未变化而推进状态,导致半迁移漂移;改动后这类歧义写入也按 fail-closed 处理。测试补充了 upsertErr 注入与一条 "ambiguous scheduler write" 用例,验证无任何成功写入时写失败即 fail-closed。 整体评估:改动方向正确、范围聚焦,错误传播路径完整(两处变更出口及 refreshOnly 出口均已带上新字段),新增测试覆盖了主要的"首次 upsert 失败即 fail-closed"新行为,且 refreshOnly/仅读失败等旧语义保持不变。唯一顾虑是"uncertain"把所有写错误一律视为结果未知,可能与"确定性未落盘"错误(如 ErrNotFound、校验类错误)混淆,导致良性场景下过度 fail-closed(已作为低严重度发现提交)。 |
|
已处理最新 Review 建议:
本地相关测试与 |
|
PR Title: Fix: fail closed after project reconcile errors Commit: 本次改动修复 reconcile 流程中"禁用已移除调度器时遇到 ErrNotFound 被误判为 fail-closed"的问题(对应历史发现 f298986e)。 改动要点:
总体评估:修复方向正确,且与既有约定(ErrNotFound 表示确定性"不存在")一致。disableRemovedSchedulers 的 ErrNotFound 特判与包装器的新条件相互配合,非 NotFound 写错误仍会置 uncertain 并 fail-closed,逻辑自洽。历史发现中描述的 disable-removed 良性竞态场景已被解决。主要遗留风险是:新增的错误分类边界(NotFound=收敛 vs 其他错误=fail-closed)只覆盖了 NotFound 一侧的测试,非 NotFound 的禁用移除错误路径缺少回归测试。 |
|
已修复 CI Lint 失败:
提交: |
|
PR Title: Fix: fail closed after project reconcile errors Commit: 改动位于 internal/projects/reconcile.go 的 mutationTrackingSchedulerStore.SetProjectSchedulerEnabled 包装器中,将错误处理分支条件从 |
|
已补充非
提交: |
|
PR Title: Fix: fail closed after project reconcile errors Commit: 本次变更为 internal/projects/reconcile_scheduler_test.go 新增单元测试 TestReconcileSchedulersFailsClosedOnRemovedSchedulerWriteError,验证从 spec 移除的启用中调度器在禁用写入阶段返回非 ErrNotFound 错误时,ReconcileSchedulers 返回该错误且 schedulerReconcileNeedsFailClosed 为 true。经与 reconcile.go 实现核对:该错误经 mutationTrackingSchedulerStore 使 storeMutationUncertain=true,并随 schedulerReconcileError 透传,断言沿 Unwrap 链成立;与 ErrNotFound 视为已收敛的相邻测试互补,正确覆盖 disableRemovedSchedulers 路径的 fail-closed 分支。测试本身正确,无新增缺陷。仍需关注的是历史 finding 残留的控制器集成层测试缺口:applyProject 仅当 schedulerReconcileNeedsFailClosed(err) 为真时才调用 disableProjectSchedulersAfterReconcileFailure 执行补偿,该条件边界无集成测试保护,存在静默回归风险(详见所提交 finding)。 |
| if err == nil || !errors.Is(err, writeErr) || !schedulerReconcileNeedsFailClosed(err) { | ||
| t.Fatalf("removed scheduler write error = %v, want fail-closed error", err) | ||
| } | ||
| } |
There was a problem hiding this comment.
控制器集成层仍缺 fail-closed 补偿触发测试:applyProject 仅当 needsFailClosed 为真时才调用 disableProjectSchedulersAfterReconcileFailure
本 diff 新增的 TestReconcileSchedulersFailsClosedOnRemovedSchedulerWriteError 是单元级测试,仅验证 ReconcileSchedulers 在移除调度器禁用写入失败(非 ErrNotFound)时返回 needsFailClosed=true 的错误,未触达 controller 集成边界。controller.go 的 applyProject 在 ReconcileSchedulers 返回错误后,仅当 schedulerReconcileNeedsFailClosed(err) 为真时才调用 disableProjectSchedulersAfterReconcileFailure 对悬空的已移除调度器做补偿关闭;该条件分支决定失败后补偿是否执行,直接关系数据一致性。但 internal/projects 下未见 applyProject/controller 集成测试覆盖两个方向:needsFailClosed=false 时不调用补偿、needsFailClosed=true 时必定调用补偿。若后续重构误删/反转该条件或改动 needsFailClosed 语义,仅靠现有单元测试无法发现,属于静默回归风险。此为历史 finding(多调度器部分写入与控制器集成层测试缺口)中仍残留的部分:多调度器部分写入场景已由 TestReconcileSchedulersPreservesPartialMutationSignalAcrossSchedulers 覆盖,本次新增也补充了移除路径的写错误单元覆盖,唯独控制器集成层仍无测试。
Problem code:
Changed code at internal/projects/reconcile_scheduler_test.go:140-153
Recommendation:
在 Controller.applyProject 层补充集成测试:构造一个令 ReconcileSchedulers 返回 needsFailClosed=true 的 store(例如移除调度器禁用写入返回非 ErrNotFound 错误,或调度器启用写入返回不确定错误),断言 ApplyProject 返回 Applied=false 且 disableProjectSchedulersAfterReconcileFailure 被调用;再构造 needsFailClosed=false 的失败(如 refresh 阶段失败或读前失败),断言补偿函数不被调用。通过注入 SetProjectSchedulerEnabled/UpsertProjectScheduler/ListProjectSchedulers 错误的伪 store 驱动两条分支即可。
|
当前唯一失败检查 失败日志: 该 workflow 尝试重新运行 Protobuf compatibility,但自身 我尝试用 Fork 作者凭证手动 rerun,同样被 GitHub 拒绝: 需要仓库管理员执行其一:
由于 |
问题
Project Apply 会先持久化新 revision、agent 和 scheduler projection,再执行 scheduler reconcile。若后续 scheduler trigger 更新、启用或 controller refresh 失败,之前已成功启用的 project scheduler 可能继续运行,而 Apply 已返回失败。
影响
系统会同时存在“Apply 失败”与“部分新配置仍在运行”的状态:旧 scheduler 或已部分更新的 scheduler 可能执行不完整 revision,造成调度配置与持久化期望状态不一致。该问题属于状态机一致性缺陷。
修复内容
errors.Join保留原始 reconcile 错误及补偿/刷新错误,便于诊断。验证
go test ./internal/projects -run 'TestDisableProjectSchedulersAfterReconcileFailure' -count=1git diff --check说明:完整项目测试当前仍受 main 基线中 Protobuf 生成文件与
.proto源码不同步影响,未在本 PR 中处理该独立问题。