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

107 lines
5.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
线上只读数据得到旧孤儿扫描窗口 `100/100/0`:固定取出的 100 条待生效记录全部仍有 `status IN (1,2)` 占位主套餐,而真正无占位套餐的记录位于窗口之外。当前实现先 `LIMIT 100`,再逐条查询占位状态,因此同一批无效候选会永久挡住真实孤儿。
过期路径还在数据库事务内调用 `enqueueActivationTask`。Asynq 消费者可能早于事务提交读取旧主套餐,随后 `ActivateSpecificPackage` 判断“已有生效主套餐”并返回 `nil`Handler 继续记录“套餐激活成功”任务不再重试。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 ASC``ROW_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/A`tb_package_usage` 是权威事实。
- Integration LogN/A无新增外部调用。
- OutboxN/A保持当前 Asynq + 周期自愈。
按用户要求不写或运行自动化测试。验证使用 `gofmt``git diff --check``go 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
无。