fix: 状态/用量回写升级 JetStream 持久 —— 堵住漏账(core NATS fire-and-forget)
问题:usage(计费)/status 回写走 core NATS,网关离线/慢消费者/NATS 抖动期间 dispatcher 发的事件直接丢——任务照跑照烧 token,但这次计费凭空消失(漏账),且零重试零对账。 (对比:提交/审批/入库本就 JetStream 持久,唯独回写是 best-effort。) 修复(照 tasks/approvals 套路): - 新增 JetStream 流 SUNDYNIX_USAGE(MaxAge 72h) / SUNDYNIX_STATUS(24h),捕获 usage.task/status.task。 - PublishUsage/PublishTaskStatus 改 js.Publish(同步等 stream ack);dispatcher+gateway 启动各自 ensure 流。 - ConsumeUsage/ConsumeTaskStatus 持久消费者 + 显式 ack:落库成功 Ack、失败 Nak 重投自愈、脏数据 Term。 - 幂等保证 at-least-once 安全:usage_event.task_id 唯一 + 门控;SaveUsageEvent 返回 inserted, 仅新插入才累计 Redis 日计数(非幂等旁路,防重投重复累加);UpdateTaskStatus 按 task_id 覆盖幂等。 live 验证(复现原漏账场景):提交任务→立刻杀网关→dispatcher 跑完把 usage 发进持久流 (网关离线,usage_event=0 但流积压 1 条=钱没丢)→重启网关→自动补消费:任务 done、 usage_event 补上、公司A 余额扣 0.098、消费者 num_pending/ack_pending 归零。旧设计下这笔会永久丢失。 eval 回写仍 core NATS(仅观测,低价值,暂不改)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -76,13 +76,13 @@ func main() {
|
||||
log.Printf("[gateway] serve prompts: %v", err)
|
||||
}
|
||||
|
||||
// 任务生命周期:订阅 dispatcher 回写的状态流转(running/done/failed/timeout),落 PG 供 UI 查询。
|
||||
if _, err := bus.SubscribeTaskStatus(func(ev *contract.TaskStatusEvent) {
|
||||
if err := db.UpdateTaskStatus(context.Background(), ev.TaskID, ev.Status, ev.Detail); err != nil {
|
||||
log.Printf("[gateway] 更新任务状态 %s=%s 失败: %v", ev.TaskID, ev.Status, err)
|
||||
}
|
||||
}); err != nil {
|
||||
log.Printf("[gateway] subscribe task status: %v", err)
|
||||
// 任务生命周期:持久消费 dispatcher 回写的状态流转(running/done/failed/timeout),落 PG 供 UI 查询。
|
||||
// JetStream at-least-once:落库失败 → Nak 重投自愈;UpdateTaskStatus 幂等(按 task_id 覆盖),重投无害。
|
||||
statusDrain, serr := bus.ConsumeTaskStatus(context.Background(), func(ctx context.Context, ev *contract.TaskStatusEvent) error {
|
||||
return db.UpdateTaskStatus(ctx, ev.TaskID, ev.Status, ev.Detail)
|
||||
})
|
||||
if serr != nil {
|
||||
log.Printf("[gateway] consume task status: %v", serr)
|
||||
}
|
||||
|
||||
// 评测闭环:订阅 dispatcher 回写的自动化评测结果,落 PG 供 UI 查询 / 质量趋势 / 门控。
|
||||
@@ -98,22 +98,27 @@ func main() {
|
||||
log.Printf("[gateway] subscribe eval: %v", err)
|
||||
}
|
||||
|
||||
// 成本护栏:订阅 dispatcher 回写的任务 token 用量,按用户按天累计到 Redis(供提交前日预算门控 / 计费)。
|
||||
if _, err := bus.SubscribeUsage(func(ev *contract.UsageEvent) {
|
||||
// 成本护栏:持久消费 dispatcher 回写的任务 token 用量(计费事实源,JetStream at-least-once + 幂等)。
|
||||
usageDrain, uerr := bus.ConsumeUsage(context.Background(), func(ctx context.Context, ev *contract.UsageEvent) error {
|
||||
if ev.UserID == "" || ev.TotalTok <= 0 {
|
||||
return
|
||||
return nil // 无效事件:Ack 丢弃,不重投
|
||||
}
|
||||
day := time.UnixMilli(ev.TS).Format("20060102")
|
||||
// 快速配额校验:Redis 按用户按天累计(48h TTL,供提交前门控)。
|
||||
if _, err := cache.AddUsage(context.Background(), ev.UserID, day, ev.TotalTok); err != nil {
|
||||
log.Printf("[gateway] 累计用量 user=%s 失败: %v", ev.UserID, err)
|
||||
// 计费事实源:折算 credits + cost 幂等落持久明细(按 task_id 去重)。失败 → Nak 重投自愈。
|
||||
inserted, err := db.SaveUsageEvent(ctx, ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 计费事实源:折算 credits + cost 幂等落持久明细(按 task_id 去重)。
|
||||
if err := db.SaveUsageEvent(context.Background(), ev); err != nil {
|
||||
log.Printf("[gateway] 落库用量明细 task=%s 失败: %v", ev.TaskID, err)
|
||||
// 仅"新插入"时累计 Redis 日计数(非幂等,重投不能重复累加;供提交前日预算门控)。
|
||||
if inserted {
|
||||
day := time.UnixMilli(ev.TS).Format("20060102")
|
||||
if _, err := cache.AddUsage(ctx, ev.UserID, day, ev.TotalTok); err != nil {
|
||||
log.Printf("[gateway] 累计用量 user=%s 失败: %v", ev.UserID, err) // best-effort,不影响已落库的计费
|
||||
}
|
||||
}
|
||||
}); err != nil {
|
||||
log.Printf("[gateway] subscribe usage: %v", err)
|
||||
return nil
|
||||
})
|
||||
if uerr != nil {
|
||||
log.Printf("[gateway] consume usage: %v", uerr)
|
||||
}
|
||||
|
||||
// 入库工作队列:启动有界并发 worker 池消费 JetStream 入库作业(背压防 OOM、崩溃重投兜底)。
|
||||
@@ -155,6 +160,13 @@ func main() {
|
||||
ingestDrain(ictx)
|
||||
icancel()
|
||||
}
|
||||
// 停状态/用量持久消费者(未 ack 的重启后由 JetStream 重投,幂等落库不丢不重)。
|
||||
if statusDrain != nil {
|
||||
statusDrain(context.Background())
|
||||
}
|
||||
if usageDrain != nil {
|
||||
usageDrain(context.Background())
|
||||
}
|
||||
log.Println("[gateway] 已优雅停机")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user