Constraint: 切换 main 前必须保存当前七月分支全部项目进展,套餐生效提案仅属于 Iteration/7-11。 Rejected: 将七月套餐修复直接移植到 main | 两个分支的可靠投递架构不同。 Confidence: medium Scope-risk: broad Directive: 不得将本提交整体 cherry-pick 到 main;main 套餐热修必须基于其纯 Asynq 代码独立实施。 Tested: git diff --check;openspec validate fix-package-activation-starvation --strict。 Not-tested: 按用户要求未运行自动化测试;go build ./... 因当前审计改造中的 Enterprise 模型字面量和 role.recordFailure 参数类型错误未通过。
121 lines
7.2 KiB
Markdown
121 lines
7.2 KiB
Markdown
## 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`,不包含任何 `main` 分支兼容、纯 Asynq 热修或跨分支移植决策。
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals:**
|
||
|
||
- 每轮最多 100 个恢复名额只用于无占位主套餐的真实孤儿载体,同一载体只选择队首套餐。
|
||
- 旧主套餐过期事实提交后,直接调用七月分支现有接续能力推进下一套餐。
|
||
- 孤儿恢复直接复用同一接续能力,不再创建第二条异步激活链。
|
||
- 套餐激活状态与既有卡观测 Outbox 保持同一事务,后续观测继续可靠投递。
|
||
- 日志准确区分实际激活、幂等、锁冲突和业务条件未满足。
|
||
|
||
**Non-Goals:**
|
||
|
||
- 不涉及 `main`,不生成可向 `main` cherry-pick 的修复提交。
|
||
- 不新增套餐激活 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 Event:N/A。** 系统自动生命周期推进,不是人工敏感操作。
|
||
- **Domain Ledger:N/A。** `tb_package_usage` 是套餐状态、激活和到期的权威事实。
|
||
- **Integration Log:N/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. 形成七月分支专属 Lore commit;该提交不面向 `main` 移植。
|
||
5. 发布七月迭代时观察至少两个轮询周期,确认真实孤儿收敛且卡观测 Outbox 正常投递。
|
||
|
||
### 回滚
|
||
|
||
- 无数据库迁移,回滚七月专属修复提交并重新部署 Worker。
|
||
- 已正确激活的套餐保持业务事实,不执行反向 SQL。
|
||
- 回滚后出现孤儿时,使用带状态条件的单卡修复 SQL逐条处理。
|
||
|
||
## Open Questions
|
||
|
||
无。本 Change 明确仅属于七月迭代分支。
|