Files
junhong_cmp_fiber/openspec/changes/fix-package-activation-starvation/design.md
break 5e552d99bc 收口审计治理与套餐任务进展
Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展
Confidence: medium
Scope-risk: broad
Directive: 后续修改需保持审计事件与业务事务边界一致
Tested: git diff --cached --check
Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
2026-08-05 14:30:54 +08:00

7.2 KiB
Raw Blame History

Context

线上诊断得到旧孤儿扫描窗口 scanned_count=100skipped_as_occupied=100waiting_realname=0。这证明“先取前 100 条待生效记录,再在 Go 中逐条排除占位载体”的实现会让窗口之外的真实孤儿永久饥饿。

七月迭代分支虽然仍保留旧 enqueueActivationTask,但已经具备更合适的本地接续边界:ActivationService.ActivateNextPendingMainPackage 负责载体锁、队首选择、条款快照、状态事务和提交后复机;实际激活事务还会原子追加 card.observation.series.requested Outbox。套餐状态推进本身是本地数据库用例不需要再经过一次 Asynq 才能执行。

本设计仅适用于 Iteration/7-11,不包含跨分支兼容或移植决策。

Goals / Non-Goals

Goals:

  • 每轮最多 100 个恢复名额只用于无占位主套餐的真实孤儿载体,同一载体只选择队首套餐。
  • 旧主套餐过期事实提交后,直接调用七月分支现有接续能力推进下一套餐。
  • 孤儿恢复直接复用同一接续能力,不再创建第二条异步激活链。
  • 套餐激活状态与既有卡观测 Outbox 保持同一事务,后续观测继续可靠投递。
  • 日志准确区分实际激活、幂等、锁冲突和业务条件未满足。

Non-Goals:

  • 不提供跨分支兼容或移植方案。
  • 不新增套餐激活 Outbox 事件、消费者、迁移或队列类型。
  • 不删除仍可能被其他旧入口使用的 TaskTypePackageQueueActivation 和 Handler只停止过期接续与孤儿恢复继续走该路径。
  • 不修改购买、优先级分配、实名激活、流量扣减、退款失效和停复机规则。
  • 不新增 API、DTO、路由、依赖、数据库字段或索引。
  • 按用户要求,不新增、修改或运行自动化测试。

Decisions

决策 1数据库先选择真实孤儿队首再执行 LIMIT

孤儿查询通过 GORM 执行 PostgreSQL CTE/窗口查询:

  1. status=0 AND master_usage_id IS NULL AND deleted_at IS NULL 按卡或设备载体分组。
  2. 每个载体按 priority ASC, created_at ASC, id ASC 选择唯一队首。
  3. 通过相关 NOT EXISTS 排除仍有 status IN (1,2) 主套餐的载体。
  4. 对真实孤儿稳定排序后执行 LIMIT 100

查询结果直接进入同步接续循环,删除逐条 Count 和 Go map 二次分组。

拒绝:仅增大 LIMIT。 数据增长后仍会复现,并放大 N+1。

拒绝:游标扫描所有待生效记录。 需要维护扫描状态,复杂度高于一次正确查询。

决策 2过期事务提交后同步调用现有接续服务

processExpiredPackage 的事务继续负责旧主套餐 status=3 和关联加油包 status=4。事务成功提交后Handler 调用 ActivateNextPendingMainPackage(ctx, carrierType, carrierID),不再调用 activateNextPackage 投递 Asynq。

若进程在两次事务之间退出,数据库会留下“无占位主套餐 + 有待生效套餐”的持久状态,下一轮修正后的孤儿扫描会直接调用同一接续能力恢复,最长增加一个轮询周期。

拒绝:事务内直接投递 Asynq。 消费者可能早于事务提交读取旧状态。

拒绝:新增套餐激活 Outbox。 状态推进是本地同步用例,现有服务已经具备完整事务;新增事件只会形成第二套调度和消费状态。需要可靠投递的后续卡观测已经由激活事务写入现有 Outbox。

决策 3孤儿恢复同步调用同一接续服务

每个真实孤儿候选只携带载体类型和 ID调用 ActivateNextPendingMainPackage。服务在事务内重新检查占位状态和队首,避免依赖扫描快照执行写操作;载体级 Redis 锁防止多个轮询实例并发激活。

锁冲突返回现有 CodePackageActivationConflict。轮询记录原因后结束本次候选,下一轮扫描继续恢复,不依赖 Asynq 重试。

决策 4复用现有卡观测 Outbox

成功激活仍通过 appendActivationObservation 在套餐激活事务内追加稳定事件:

  • 事件类型:card.observation.series.requested
  • 稳定事件 IDcard-observation:package-usage:{usage_id}:activated
  • 同步类型:实名、流量、网络
  • 资源:实际卡或设备载体

Outbox 写入失败时套餐激活事务回滚避免状态已生效但后续观测请求丢失。不新增套餐激活事件、Relay 或消费者。

决策 5日志以实际结果为准

ActivateNextPendingMainPackage 已返回 activated bool。调用方仅在 activated=true 时记录本轮成功;false,nil 表示幂等或条件暂不满足,记录明确结果;锁冲突和数据库错误按错误路径记录,不伪造成功。

决策 6公共能力决定

  • Audit EventN/A。 系统自动生命周期推进,不是人工敏感操作。
  • Domain LedgerN/A。 tb_package_usage 是套餐状态、激活和到期的权威事实。
  • Integration LogN/A。 本修复不新增外部请求。
  • Outbox复用。 成功激活继续在同一事务写 card.observation.series.requested;不新增事件类型。

实施时增量维护 .scratch/tech-global-audit/审计覆盖基线.md

决策 7不包含自动化测试

按用户明确要求,本 Change 不创建、修改或运行自动化测试。验证使用:

  • gofmt 和现有静态检查;
  • go build ./...
  • 只读候选 SQL及 EXPLAIN (ANALYZE, BUFFERS)
  • 日志顺序和激活结果核验;
  • 激活事务与卡观测 Outbox 记录的一致性查询。

Risks / Trade-offs

  • [风险] 候选查询扫描大量待生效记录 → 用现有索引和查询计划验证;没有性能证据前不新增索引。
  • [风险] 旧套餐提交后、接续调用前进程退出 → 下一轮真实孤儿扫描从数据库权威状态恢复。
  • [风险] 多实例重复处理同一载体 → 服务内载体级 Redis 锁、事务内占位复检和状态幂等共同收敛。
  • [风险] 同步接续增加单轮耗时 → 单轮最多 100 个真实孤儿,接续仅执行本地 Redis/数据库事务;记录耗时后再决定是否需要批次调整。
  • [权衡] 保留旧 Asynq Handler → 避免扩大未触碰调用方;本 Change 只让过期和孤儿两条路径停止使用它。
  • [权衡] 不新增自动化测试 → 遵循用户边界以构建、SQL、查询计划和日志证据替代。

Migration Plan

  1. Iteration/7-11 实施真实孤儿查询和同步接续调用。
  2. 更新审计覆盖基线与功能总结,执行格式化、静态检查和 go build ./...
  3. 用只读 SQL/查询计划验证候选公平性,用运行日志和数据库事实核验同步接续及卡观测 Outbox。
  4. 复核七月分支专属差异并保留在当前工作树,不创建提交。
  5. 发布七月迭代时观察至少两个轮询周期,确认真实孤儿收敛且卡观测 Outbox 正常投递。

回滚

  • 无数据库迁移,发布后如需回滚,则回退七月专属修复代码并重新部署 Worker。
  • 已正确激活的套餐保持业务事实,不执行反向 SQL。
  • 回滚后出现孤儿时,使用带状态条件的单卡修复 SQL逐条处理。

Open Questions

无。本 Change 明确仅属于七月迭代分支。