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:
Blizzard
2026-07-09 17:07:31 +08:00
parent 248267984c
commit 1ee1e87371
6 changed files with 150 additions and 55 deletions
+13 -7
View File
@@ -27,7 +27,13 @@ func MustConnect(url string) *Bus {
if err := inner.EnsureIngestStream(context.Background()); err != nil {
log.Fatalf("[nats] ensure ingest stream: %v", err)
}
log.Printf("[nats] connected %s, task + ingest streams ready", url)
if err := inner.EnsureStatusStream(context.Background()); err != nil {
log.Fatalf("[nats] ensure status stream: %v", err)
}
if err := inner.EnsureUsageStream(context.Background()); err != nil {
log.Fatalf("[nats] ensure usage stream: %v", err)
}
log.Printf("[nats] connected %s, task + ingest + status + usage streams ready", url)
return &Bus{inner: inner}
}
@@ -62,9 +68,9 @@ func (b *Bus) Ping(ctx context.Context, subject string) ([]byte, error) {
return b.inner.Ping(ctx, subject)
}
// SubscribeTaskStatus 订阅 dispatcher 回写的任务生命周期状态(落 PG)。
func (b *Bus) SubscribeTaskStatus(onEvent func(*contract.TaskStatusEvent)) (func() error, error) {
return b.inner.SubscribeTaskStatus(onEvent)
// ConsumeTaskStatus 持久消费 dispatcher 回写的任务生命周期状态(落 PGat-least-once + 幂等)。
func (b *Bus) ConsumeTaskStatus(ctx context.Context, h func(context.Context, *contract.TaskStatusEvent) error) (func(context.Context), error) {
return b.inner.ConsumeTaskStatus(ctx, h)
}
// PublishApproval 把一次人工审批决定发给 dispatcher(解除审批节点阻塞)。
@@ -77,9 +83,9 @@ func (b *Bus) SubscribeEval(onEvent func(*contract.EvalEvent)) (func() error, er
return b.inner.SubscribeEval(onEvent)
}
// SubscribeUsage 订阅 dispatcher 回写的任务 token 用量(累计到用户日预算 / 计费)。
func (b *Bus) SubscribeUsage(onEvent func(*contract.UsageEvent)) (func() error, error) {
return b.inner.SubscribeUsage(onEvent)
// ConsumeUsage 持久消费 dispatcher 回写的任务 token 用量(计费,at-least-once + 幂等)。
func (b *Bus) ConsumeUsage(ctx context.Context, h func(context.Context, *contract.UsageEvent) error) (func(context.Context), error) {
return b.inner.ConsumeUsage(ctx, h)
}
// ServeConfig 让网关作为配置控制面,响应某 kind 的配置请求。
+7 -4
View File
@@ -57,12 +57,13 @@ func tokensPerCreditEnv() float64 {
return 1000
}
// SaveUsageEvent 折算 credits + cost 并幂等落一条用量明细。
// SaveUsageEvent 折算 credits + cost 并幂等落一条用量明细。返回 inserted(是否本次新插入):
// 供调用方据此只在"新插入"时更新非幂等的旁路(如 Redis 日计数),保证 at-least-once 重投下不重复累计。
// 折算模型:ev.Model 优先;为空则回退当前激活 chat 模型(近似——忽略 failover 到备用模型的情形)。
// 缺 Pricing → cost=0、weight=1(计量不因缺价而丢量,可事后补价重算)。
func (p *Postgres) SaveUsageEvent(ctx context.Context, ev *contract.UsageEvent) error {
func (p *Postgres) SaveUsageEvent(ctx context.Context, ev *contract.UsageEvent) (inserted bool, err error) {
if p.db == nil {
return nil
return false, nil
}
model := ev.Model
if model == "" {
@@ -96,7 +97,7 @@ func (p *Postgres) SaveUsageEvent(ctx context.Context, ev *contract.UsageEvent)
// 一个事务内:落明细 → 扣积分(账本+余额) → 累加 rollup。
// 幂等锚点:usage_event 的 task_id 唯一;插入若被冲突吞掉(RowsAffected==0)→重投,跳过后续,绝不重复计费。
return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err = p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
res := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "task_id"}},
DoNothing: true,
@@ -107,11 +108,13 @@ func (p *Postgres) SaveUsageEvent(ctx context.Context, ev *contract.UsageEvent)
if res.RowsAffected == 0 {
return nil // 重投:明细已存在,账本/余额/rollup 均不再动
}
inserted = true
if err := applyUsageCredit(tx, ev.TenantID, ev.TaskID, creditsMicro); err != nil {
return err
}
return upsertRollup(tx, ev.TenantID, day, int64(ev.TotalTok), creditsMicro, costMicros, currency)
})
return inserted, err
}
// RecentUsage 返回某租户最近 n 条用量明细(供用户看"最近消耗")。受租户表→用户 ctx 自动过滤,