Files
junhong_cmp_fiber/openspec/changes/fix-main-package-activation-starvation/design.md
break a0de08d789
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m6s
避免套餐过期后排队权益永久失联
线上保持现有纯 Asynq 架构,以公平孤儿扫描和提交后投递消除永久饥饿及事务可见性竞态。

Constraint: 线上保持现有纯 Asynq 架构,不引入 Outbox、迁移或新任务基础设施。

Rejected: 事务内投递或扩大扫描 LIMIT | 无法消除竞态和永久饥饿。

Confidence: high

Scope-risk: narrow

Directive: 后续分支整合时按目标分支的套餐接续架构独立处理,不混用本热修实现。

Tested: go build ./...(退出码 0);git diff --check;openspec validate fix-main-package-activation-starvation --strict。

Not-tested: 按用户要求未新增、修改或运行自动化测试;线上 SQL、查询计划和日志待部署后核验。
2026-08-03 09:58:05 +08:00

5.5 KiB
Raw Blame History

Context

线上只读数据得到旧孤儿扫描窗口 100/100/0:固定取出的 100 条待生效记录全部仍有 status IN (1,2) 占位主套餐,而真正无占位套餐的记录位于窗口之外。当前实现先 LIMIT 100,再逐条查询占位状态,因此同一批无效候选会永久挡住真实孤儿。

过期路径还在数据库事务内调用 enqueueActivationTask。Asynq 消费者可能早于事务提交读取旧主套餐,随后 ActivateSpecificPackage 判断“已有生效主套餐”并返回 nilHandler 继续记录“套餐激活成功”任务不再重试。Redis 锁冲突也返回 nil,存在另一条假成功路径。

本热修保持当前线上 Polling Handler → GORM transaction → Asynq → Package Activation Service 架构,不引入新的可靠投递设施。

Goals / Non-Goals

Goals:

  • 每轮 100 个恢复名额只用于真实孤儿载体,同一载体只选择队首套餐。
  • 旧套餐过期事实提交后才投递 Asynq消除事务可见性竞态。
  • Redis 锁冲突返回错误,由现有 Asynq 重试。
  • 成功日志只对应实际激活或明确的已完成幂等事实。

Non-Goals:

  • 不新增 Outbox、迁移、索引、依赖、队列或任务类型。
  • 不重构套餐购买、实名激活、流量扣减、退款和停复机。
  • 不修改 API、DTO、路由或前端。
  • 不批量修复历史数据。
  • 按用户要求,不新增、修改或运行自动化测试。

Decisions

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

使用 GORM Raw 执行 PostgreSQL CTE/窗口查询:

  1. 从有效 status=0 主套餐按卡/设备载体分组。
  2. 每组按 priority ASC, created_at ASC, id ASCROW_NUMBER()=1
  3. 使用相关 NOT EXISTS 排除同载体 status IN (1,2) 主套餐。
  4. 稳定排序后 LIMIT 100

删除现有逐条 Count 和 Go map 分组。查询仍返回完整 PackageUsage,沿用现有投递循环。

拒绝:扩大 LIMIT。 只会推迟复现并增加 N+1。

拒绝:分页遍历所有 pending。 需要游标状态,复杂度高于一次正确查询。

决策 2过期事务提交后再投递现有 Asynq

processExpiredPackage 事务只更新旧主套餐和关联加油包。提交成功后,使用普通数据库句柄调用现有 activateNextPackage 查询队首并入队。

提交后入队失败时返回错误并记录上下文;同一轮末尾及后续轮询的真实孤儿扫描会再次发现该载体,提供持久状态驱动的补偿。

拒绝:任务增加固定延迟。 固定延迟不能证明事务已提交。

拒绝:引入 Outbox。 当前线上没有该基础设施,热修不扩张架构。

决策 3锁冲突必须触发 Asynq 重试

ActivateSpecificPackage 未取得 RedisPackageActivationLockKey 时返回现有 CodePackageActivationConflict。Handler 原样返回错误,由任务已有 MaxRetry(3) 处理。

Redis Key 继续使用 pkg/constants/redis.go 的生成函数,不新增硬编码 Key。

决策 4显式返回本次是否激活

ActivateSpecificPackage 返回 (bool, error)

  • true,nil:本次把待生效套餐推进为生效中;
  • false,nil:记录已非待生效、存在占位套餐或条件暂不满足;
  • false,error数据库、Redis 或锁冲突,应由任务重试或记录失败。

Handler 仅在 true,nil 时记录“套餐激活成功”。Handler 已在调用前识别 status=1 的重复任务并记录幂等跳过。

决策 5依赖注入和事务边界保持不变

PackageActivationHandler 继续通过结构体字段持有 *gorm.DB*redis.Client*asynq.Client*ActivationService 和 Zap Logger不新增单实现接口或工厂。套餐激活仍由 Service 自己开启 GORM 事务Handler 不直接更新新套餐状态。

决策 6公共能力与验证

  • Audit EventN/A系统自动生命周期推进。
  • Domain LedgerN/Atb_package_usage 是权威事实。
  • Integration LogN/A无新增外部调用。
  • OutboxN/A保持当前 Asynq + 周期自愈。

按用户要求不写或运行自动化测试。验证使用 gofmtgit diff --checkgo build ./...、只读 SQL、查询计划和日志检查。

Risks / Trade-offs

  • [风险] CTE 扫描大量 pending → 用 EXPLAIN (ANALYZE, BUFFERS) 验证;无证据不新增索引。
  • [风险] 提交后、入队前进程退出 → 下一轮真实孤儿扫描恢复,最长增加一个轮询周期。
  • [风险] 多实例重复入队 → 载体 Redis 锁和套餐状态幂等保证只实际激活一次。
  • [风险] 方法签名变化遗漏调用点 → 使用 rg 检查全部调用方并以全量构建证明编译契约。
  • [权衡] Asynq 最终失败后仍依赖轮询重新入队 → 这是当前线上架构的既有补偿边界,本热修不扩建基础设施。
  • [权衡] 不新增自动化测试 → 遵循用户边界以构建、SQL 和日志证据替代。

Migration Plan

  1. 实施真实孤儿查询、提交后入队、锁冲突重试和准确日志。
  2. 执行格式化、静态检查、go build ./... 和只读 SQL语义检查。
  3. 形成独立中文 Lore 热修提交。
  4. 部署 Worker观察至少两个轮询周期内真实孤儿收敛和任务日志。

回滚

  • 无数据库迁移revert 热修提交并重新部署 Worker。
  • 已正确激活的套餐保持业务事实,不执行反向 SQL。
  • 回滚后新增孤儿继续使用带状态保护的单卡 SQL逐条恢复。

Open Questions

无。