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
+7
View File
@@ -46,6 +46,13 @@ const (
StreamApprovals = "SUNDYNIX_APPROVALS" // 审批决定 JetStream 流(持久,决定不因 dispatcher 离线而丢)
ConsumerApprovals = "approval-resumers" // 审批决定持久消费者(队列组:多副本下每条决定只一个副本处理 resume)
// 状态 / 用量回写升级为 JetStream 持久(此前 core NATS:网关离线/慢消费者会丢——尤其 usage 丢=漏账)。
// 落库幂等(task_id 唯一 + 门控),故 at-least-once 重投安全,不会重复扣费。subject 均在 tasks.> 之外。
StreamStatus = "SUNDYNIX_STATUS" // 任务状态回写流(持久,done/failed 不因网关离线而丢)
ConsumerStatus = "gateway-status" // 状态回写持久消费者(队列组:多网关副本每条只落一次)
StreamUsage = "SUNDYNIX_USAGE" // 用量回写流(持久,计费凭据不丢)
ConsumerUsage = "gateway-usage" // 用量回写持久消费者
// BucketCheckpoints 是 HITL 持久化中断的 JetStream KV 桶名:存 compose 图 checkpoint
// (键=task_id)与 resume 记录(键=pending:task_id),dispatcher 重启后可据此恢复在途审批。
BucketCheckpoints = "SUNDYNIX_CHECKPOINTS"