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

121 lines
7.2 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
线上诊断得到旧孤儿扫描窗口 `scanned_count=100``skipped_as_occupied=100``waiting_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`
- 稳定事件 ID`card-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 明确仅属于七月迭代分支。