feat(server): reimplement message queue, drop outbox

This commit is contained in:
RainbowBird
2026-03-27 11:41:17 +08:00
committed by RainbowBird
parent 405904183f
commit eab60d0bdb
28 changed files with 1928 additions and 1154 deletions
+2 -19
View File
@@ -53,28 +53,11 @@ services:
timeout: 5s
retries: 5
outbox-dispatcher:
billing-consumer:
build:
context: ../..
dockerfile: apps/server/Dockerfile
command: ['pnpm', '-F', '@proj-airi/server', 'run', 'server', 'outbox-dispatcher']
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
env_file:
- path: .env
required: false
- path: .env.local
required: false
restart: unless-stopped
cache-sync-consumer:
build:
context: ../..
dockerfile: apps/server/Dockerfile
command: ['pnpm', '-F', '@proj-airi/server', 'run', 'server', 'cache-sync-consumer']
command: ['pnpm', '-F', '@proj-airi/server', 'run', 'server', 'billing-consumer']
depends_on:
db:
condition: service_healthy
@@ -27,11 +27,10 @@
- 注入 WebSocket
- 绑定 `uncaughtException` / `unhandledRejection`
CLI 入口在 `src/bin/run.ts`,支持种角色:
CLI 入口在 `src/bin/run.ts`,支持种角色:
- `api`
- `cache-sync-consumer`
- `outbox-dispatcher`
- `billing-consumer`
## 依赖注入结构
@@ -44,7 +43,6 @@ CLI 入口在 `src/bin/run.ts`,支持三种角色:
- `redis`
- `configKV`
- 服务
- `outboxService`
- `auth`
- `characterService`
- `providerService`
@@ -57,7 +55,7 @@ CLI 入口在 `src/bin/run.ts`,支持三种角色:
这个装配顺序说明了几个事实:
- `billingService` 依赖 `db + redis + outboxService`
- `billingService` 依赖 `db + redis`
- `fluxService` 只读余额,不承担余额写入职责
- `auth` 直接绑定数据库 schema,不是外部独立服务
@@ -82,7 +80,6 @@ CLI 入口在 `src/bin/run.ts`,支持三种角色:
- `flux.ts`
- `billing-service.ts`
- `stripe.ts`
- `outbox-service.ts`
这里是主要改动面。大多数业务改动都不应该直接写进 route handler。
@@ -92,7 +89,7 @@ CLI 入口在 `src/bin/run.ts`,支持三种角色:
- Drizzle schema 基本覆盖了所有核心表
- 数据迁移由 `@proj-airi/server-schema` 提供
- `app.ts``run-outbox-dispatcher.ts` 启动时会执行迁移
- `app.ts` 启动时会执行迁移
## 中间件与通用约束
@@ -134,7 +131,7 @@ CLI 入口在 `src/bin/run.ts`,支持三种角色:
- 新用户首次读取时初始化余额
- `BillingService`
- 面向写入
- 事务内更新余额、流水审计、outbox
- debitFlux:事务内更新余额,事务后 XADD Redis Streamcredit 方法:事务内同步写流水审计
这是服务端最重要的边界之一,尽量不要把写余额逻辑重新塞回 `flux.ts`
@@ -2,31 +2,34 @@
## 架构概述
`apps/server` 的计费链采用 **Postgres 作为唯一账本真相源**Redis 仅作缓存。所有余额变化在 DB 事务内原子完成,同步写入 `flux_ledger`(流水)和 `outbox_events`(事件),通过 Redis Streams 分发给下游 consumer
`apps/server` 的计费链采用 **Postgres 作为唯一账本真相源**Redis 仅作缓存。余额变化路径分两类:`debitFlux` 在 DB 事务内只做 `UPDATE user_flux`ledger/audit/请求日志通过 Redis Stream 异步写入;credit 方法仍在事务内同步写入 ledger 和 audit
### 数据模型
- **`user_flux`** — 用户余额快照(单行/用户)
- **`flux_ledger`** — append-only 账务流水(type: credit/debit/initial, amount, balanceBefore, balanceAfter, requestId
- 含 partial unique index `(userId, requestId) WHERE requestId IS NOT NULL`DB 层幂等防重
- **`outbox_events`** — 事件暂存,claim-lease 模式分发
- **`flux_audit_log`** — 用户可见的历史记录
### 同步链路(已实现)
### debitFlux 链路(已实现)
每次余额变化的 DB 事务内:
DB 事务内仅做
1. `SELECT user_flux FOR UPDATE` 锁行
2. 更新 `user_flux.flux`
3. `flux_ledger`
4. `flux_audit_log`
5. `outbox_events`
6. 事务提交后 best-effort 更新 Redis 缓存
2. 检查余额(不足返回 402
3. 更新 `user_flux.flux`
4. 事务提交后 XADD Redis Stream`billing-events`),携带扣费金额、余额快照、requestId 等
5. 事务提交后 best-effort `redis.set` 更新 Flux 余额缓存
ledger / audit / llm_request_log 的写入均由 **billing-consumer** 异步完成。
### credit 方法链路(已实现)
credit 方法(`creditFlux` / `creditFluxFromStripeCheckout` / `creditFluxFromInvoice`)仍在 DB 事务内同步写入 `flux_ledger``flux_audit_log`
### 异步链路(已实现)
- **outbox-dispatcher** — 轮询 `outbox_events`,发布到 Redis Stream `billing-events`
- **cache-sync-consumer** — 消费 Stream 事件,同步 Redis 缓存(处理 `flux.debited``flux.credited`
- **billing-consumer** — 消费 Redis Stream `billing-events`,将 ledger、audit log、LLM 请求日志异步写入 DB
### 事件模型
@@ -44,8 +47,7 @@ Stream: `billing-events`
通过 `src/bin/run.ts` 分角色启动:
- `api` — HTTP 服务
- `outbox-dispatcher` — outbox → Redis Stream
- `cache-sync-consumer` — Redis 缓存同步(处理 `flux.debited` + `flux.credited`
- `billing-consumer` — 消费 Redis Stream,异步写入 ledger、audit log、LLM 请求日志到 DB
## 关键服务
@@ -53,7 +55,7 @@ Stream: `billing-events`
所有余额写操作的唯一入口:
- **`debitFlux()`** — 扣费(LLM 请求),事务内:锁行 → 检余额(402) → 更新余额ledgeraudit → outbox(`flux.debited`)
- **`debitFlux()`** — 扣费(LLM 请求),事务内:锁行 → 检余额(402) → 更新余额;事务提交后 XADD `flux.debited` 到 Redis Streamledger/audit 由 billing-consumer 异步写入
- **`creditFlux()`** — 通用充值
- **`creditFluxFromStripeCheckout()`** — Stripe 一次性支付充值,幂等(`fluxCredited` 标志)
- **`creditFluxFromInvoice()`** — Stripe 订阅发票充值,幂等
@@ -79,20 +81,19 @@ Redis **不是**余额真相源,仅用于:
| Phase | 状态 | 关键点 |
|-------|------|--------|
| 1. DB-first 账本 | ✅ 已完成 | `flux_ledger` 表,`SELECT FOR UPDATE` 原子扣减,Redis 降为缓存 |
| 2. Outbox 事件 | ✅ 已完成 | 所有余额变化产生 outbox 事件,debit + credit 均覆盖 |
| 3. Redis Streams | ✅ 已完成 | MQ、dispatcher、worker 全部就位 |
| 4. Stripe 幂等 | ✅ 已完成 | checkout + invoice 事务内幂等检查 |
| 5. LLM 计费优化 | ⚠️ 部分 | 已有 `requestId` 和 DB 事务扣费,待加 tiktoken fallback |
| 6. 部署拆分 | ✅ 已完成 | `bin/run.ts` 三角色启动(api / outbox-dispatcher / cache-sync-consumer |
| 7. 幂等防重 | ✅ 已完成 | `flux_ledger` partial unique index on `(userId, requestId)` |
| 8. Cache-sync 适配 | ✅ 已完成 | 同时处理 `flux.debited``flux.credited` 事件 |
| 2. Redis Streams 异步写入 | ✅ 已完成 | debitFlux 事务后 XADDbilling-consumer 异步写 ledger/audit/请求日志 |
| 3. Stripe 幂等 | ✅ 已完成 | checkout + invoice 事务内幂等检查 |
| 4. LLM 计费优化 | ⚠️ 部分 | 已有 `requestId` 和 DB 事务扣费,待加 tiktoken fallback |
| 5. 部署拆分 | ✅ 已完成 | `bin/run.ts` 两角色启动(api / billing-consumer |
| 6. 幂等防重 | ✅ 已完成 | `flux_ledger` partial unique index on `(userId, requestId)` |
### 已删除
- `flux-write-back.ts` — 定时回写补偿机制,不再需要
- `FluxService.consumeFlux()` / `addFlux()` — 写操作已移至 BillingService
- `llm_request_log.settled` — 无消费者,已移除
- `billing-consumer` 进程角色 — 空壳(仅 log),已移除;需要账务分析时重新添加
- `outbox_events` 表及 outbox-dispatcher 进程 — 已移除,统一由 billing-consumer 处理异步写入
- `cache-sync-consumer` 进程角色 — 已合并进 billing-consumer
## 剩余 TODO
@@ -10,7 +10,6 @@
- Flux 余额与账本
- Stripe 业务镜像
- LLM 请求日志
- outbox 事件
- `Redis`
- Flux 余额缓存
- 服务配置 KV
@@ -150,19 +149,6 @@
- 只做追加写入
- 明确不加 user 外键,以避免高并发写入的额外约束成本
### Outbox
- `outbox_events`
来源文件:
- `src/schemas/outbox-events.ts`
说明:
- 本质上是 DB 内事件暂存区
- 通过 `claimedBy + claimExpiresAt + publishedAt` 实现 lease/claim 分发
## 服务与状态写入边界
### `createFluxService()`
@@ -177,7 +163,7 @@
- 扣费
- 充值
- ledger / audit / outbox 写入
- ledger / audit 写入
### `createBillingService()`
@@ -185,8 +171,9 @@
- 所有余额写操作
- DB 事务
- ledger / audit / outbox 联动
- 事务完成后 best-effort 更新 Redis
- debitFlux:事务内仅更新余额;事务后 XADD Redis Streamledger/audit 由 billing-consumer 异步写入
- credit 方法:事务内同步写 ledger / audit
- 事务提交后 best-effort `redis.set` 更新 Flux 余额缓存
这是所有 Flux 写路径应收敛到的中心。
@@ -212,8 +199,7 @@
写入来源:
- `fluxService.getFlux()` cache miss 后回填
- `billingService` 余额事务成功后 best-effort 更新
- `cache-sync-consumer` 消费 `flux.debited` / `flux.credited` 后同步
- `billingService` 余额事务提交后 best-effort `redis.set` 直接更新(API 进程内同步)
### 配置 KV
@@ -241,8 +227,8 @@
1. `SELECT user_flux FOR UPDATE`
2. 计算新余额
3. 写余额
4. 写 ledger / audit / outbox
3. 写余额debitFlux 事务内仅此一步;credit 方法同步写 ledger / audit
4. 事务提交后 XADD Redis StreamdebitFlux)或直接返回(credit
这保证同一用户余额更新是串行化的。
@@ -254,15 +240,6 @@
- `stripe_invoice.fluxCredited`
- `flux_ledger(userId, requestId)` 唯一约束
### outbox 并发
`outbox-service.ts` 使用:
- `FOR UPDATE SKIP LOCKED`
- `claimExpiresAt`
这允许多个 dispatcher 并行拉取待发布事件。
## 现有代码中的结构信号
- `request-log.ts``llm-request-log.ts` 完全重叠,后者更像旧名残留。
@@ -6,12 +6,10 @@
- `api`
- 启动 Hono HTTP + WebSocket 服务
- `cache-sync-consumer`
- 消费 Redis Streams 中的计费事件,回写 Flux Redis 缓存
- `outbox-dispatcher`
- 从 Postgres `outbox_events` 拉取未发布事件,投递到 Redis Streams
- `billing-consumer`
- 消费 Redis Stream `billing-events`,异步将 ledger、audit log、LLM 请求日志写入 DB
个角色已经是当前服务端部署拆分的基本单位。
个角色是当前服务端部署拆分的基本单位。
## API 角色
@@ -32,61 +30,21 @@
- 启动 HTTP server
- 注入 WebSocket
## Outbox Dispatcher
## Billing Consumer
实现位置:
- 入口:`src/bin/run-outbox-dispatcher.ts`
- 服务:`src/services/outbox-dispatcher.ts`
- 存储:`src/services/outbox-service.ts`
- MQ`src/services/billing-mq.ts`
工作流程:
1.`outbox_events` claim 一批未发布事件
2. 逐条发布到 Redis Stream
3. 发布成功后写 `publishedAt``streamMessageId`
4. 失败则释放 claim,等待下一轮处理
关键机制:
- 支持多实例并发 dispatcher
- claim 通过 TTL 失效,避免 worker 崩掉后永久锁死
相关环境变量:
- `OUTBOX_DISPATCHER_NAME`
- `OUTBOX_DISPATCHER_BATCH_SIZE`
- `OUTBOX_DISPATCHER_CLAIM_TTL_MS`
- `OUTBOX_DISPATCHER_POLL_MS`
- `BILLING_EVENTS_STREAM`
## Billing Events Consumer
实现位置:
- 入口:`src/bin/run-billing-events-consumer.ts`
- 入口:`src/bin/run-billing-consumer.ts`
- worker`src/services/billing-mq-worker.ts`
- stream adapter`src/services/billing-mq.ts`
当前默认 handler
工作流程
- `handleCacheSyncMessage()`
它只处理:
- `flux.credited`
- `flux.debited`
并把 `payload.balanceAfter` 写回 Redis
- key: `flux:<userId>`
这说明当前 consumer 的目标非常克制:
- 不是账务真相处理器
- 不是分析流水处理器
- 只是缓存一致性补偿器
1. 以 consumer group 模式消费 Redis Stream `billing-events`
2. 根据事件类型分发处理:
- `flux.debited` — 写 `flux_ledger``flux_audit_log`
- `llm.request.log` — 写 `llm_request_log`
3. 处理成功后 ACK;handler 抛错时不 ACK,消息保持 pending 等待重试
相关环境变量:
@@ -172,17 +130,13 @@
- `STRIPE_SECRET_KEY`
- `STRIPE_WEBHOOK_SECRET`
### Billing MQ / Outbox
### Billing MQ
- `BILLING_EVENTS_STREAM`
- `BILLING_EVENTS_CONSUMER_NAME`
- `BILLING_EVENTS_BATCH_SIZE`
- `BILLING_EVENTS_BLOCK_MS`
- `BILLING_EVENTS_MIN_IDLE_MS`
- `OUTBOX_DISPATCHER_NAME`
- `OUTBOX_DISPATCHER_BATCH_SIZE`
- `OUTBOX_DISPATCHER_CLAIM_TTL_MS`
- `OUTBOX_DISPATCHER_POLL_MS`
### OTel
@@ -200,7 +154,7 @@
- 新增 worker
- 先看 `run.ts` 的角色模型和 `billing-mq-worker.ts`
- 改事件分发
- 先看 outbox,而不是直接在业务事务里调用 Redis Streams
- 先看 billing-consumer handler,在 `billing-mq-worker.ts` 中增加新的事件处理分支
- 改聊天同步
- 先区分“持久化消息”与“广播通知”两层
- 改部署限流
@@ -10,25 +10,6 @@ CREATE TABLE "flux_ledger" (
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "outbox_events" (
"id" text PRIMARY KEY NOT NULL,
"event_id" text NOT NULL,
"event_type" text NOT NULL,
"aggregate_id" text NOT NULL,
"user_id" text NOT NULL,
"request_id" text,
"schema_version" integer NOT NULL,
"payload" text NOT NULL,
"occurred_at" timestamp NOT NULL,
"available_at" timestamp DEFAULT now() NOT NULL,
"claimed_by" text,
"claim_expires_at" timestamp,
"published_at" timestamp,
"stream_message_id" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "llm_request_log" DROP CONSTRAINT "llm_request_log_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "messages" ALTER COLUMN "sender_id" DROP NOT NULL;--> statement-breakpoint
@@ -40,7 +21,4 @@ ALTER TABLE "flux_ledger" ADD CONSTRAINT "flux_ledger_user_id_user_id_fk" FOREIG
CREATE INDEX "flux_ledger_user_id_idx" ON "flux_ledger" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "flux_ledger_created_at_idx" ON "flux_ledger" USING btree ("created_at");--> statement-breakpoint
CREATE UNIQUE INDEX "flux_ledger_user_request_uniq" ON "flux_ledger" USING btree ("user_id","request_id") WHERE request_id IS NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "outbox_events_event_id_idx" ON "outbox_events" USING btree ("event_id");--> statement-breakpoint
CREATE INDEX "outbox_events_publish_scan_idx" ON "outbox_events" USING btree ("published_at","available_at","claim_expires_at","created_at");--> statement-breakpoint
CREATE INDEX "outbox_events_claimed_by_idx" ON "outbox_events" USING btree ("claimed_by");--> statement-breakpoint
ALTER TABLE "llm_request_log" DROP COLUMN "settled";
+1 -177
View File
@@ -1,5 +1,5 @@
{
"id": "22b43247-dc0b-4bde-8869-955460dbd3e2",
"id": "11d1fa8c-cf77-42ef-9776-3a74733be0ac",
"prevId": "45bc0dad-65f5-4695-9115-3d7d376b6440",
"version": "7",
"dialect": "postgresql",
@@ -1529,182 +1529,6 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.outbox_events": {
"name": "outbox_events",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"event_id": {
"name": "event_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"event_type": {
"name": "event_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"aggregate_id": {
"name": "aggregate_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"request_id": {
"name": "request_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"schema_version": {
"name": "schema_version",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"payload": {
"name": "payload",
"type": "text",
"primaryKey": false,
"notNull": true
},
"occurred_at": {
"name": "occurred_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"available_at": {
"name": "available_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"claimed_by": {
"name": "claimed_by",
"type": "text",
"primaryKey": false,
"notNull": false
},
"claim_expires_at": {
"name": "claim_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"published_at": {
"name": "published_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"stream_message_id": {
"name": "stream_message_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"outbox_events_event_id_idx": {
"name": "outbox_events_event_id_idx",
"columns": [
{
"expression": "event_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"outbox_events_publish_scan_idx": {
"name": "outbox_events_publish_scan_idx",
"columns": [
{
"expression": "published_at",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "available_at",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "claim_expires_at",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"outbox_events_claimed_by_idx": {
"name": "outbox_events_claimed_by_idx",
"columns": [
{
"expression": "claimed_by",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.system_provider_configs": {
"name": "system_provider_configs",
"schema": "",
+2 -2
View File
@@ -40,8 +40,8 @@
{
"idx": 5,
"version": "7",
"when": 1774552566896,
"tag": "0005_tearful_kronos",
"when": 1774582846222,
"tag": "0005_tough_living_tribunal",
"breakpoints": true
}
]
+35 -48
View File
@@ -2,6 +2,15 @@ import type Redis from 'ioredis'
import type { Env } from './libs/env'
import type { OtelInstance } from './libs/otel'
import type { BillingMqService } from './services/billing-mq'
import type { BillingService } from './services/billing-service'
import type { CharacterService } from './services/characters'
import type { ChatService } from './services/chats'
import type { ConfigKVService } from './services/config-kv'
import type { FluxService } from './services/flux'
import type { FluxAuditService } from './services/flux-audit'
import type { ProviderService } from './services/providers'
import type { StripeService } from './services/stripe'
import type { HonoEnv } from './types/hono'
import process from 'node:process'
@@ -31,61 +40,36 @@ import { createFluxRoutes } from './routes/flux'
import { createProviderRoutes } from './routes/providers'
import { createStripeRoutes } from './routes/stripe'
import { createV1CompletionsRoutes } from './routes/v1completions'
import { createBillingMqService } from './services/billing-mq'
import { createBillingService } from './services/billing-service'
import { createCharacterService } from './services/characters'
import { createChatService } from './services/chats'
import { createConfigKVService } from './services/config-kv'
import { createFluxService } from './services/flux'
import { createFluxAuditService } from './services/flux-audit'
import { createOutboxService } from './services/outbox-service'
import { createProviderService } from './services/providers'
import { createRequestLogService } from './services/request-log'
import { createStripeService } from './services/stripe'
import { ApiError, createInternalError, createUnauthorizedError } from './utils/error'
import { getTrustedOrigin } from './utils/origin'
type AuthService = ReturnType<typeof createAuth>
type CharacterService = ReturnType<typeof createCharacterService>
type ChatService = ReturnType<typeof createChatService>
type ProviderService = ReturnType<typeof createProviderService>
type FluxService = ReturnType<typeof createFluxService>
type ConfigKVService = ReturnType<typeof createConfigKVService>
type RequestLogService = ReturnType<typeof createRequestLogService>
type StripeDBService = ReturnType<typeof createStripeService>
type FluxAuditService = ReturnType<typeof createFluxAuditService>
type BillingService = ReturnType<typeof createBillingService>
interface AppDeps {
auth: AuthService
auth: ReturnType<typeof createAuth>
characterService: CharacterService
chatService: ChatService
providerService: ProviderService
fluxService: FluxService
fluxAuditService: FluxAuditService
requestLogService: RequestLogService
stripeService: StripeDBService
stripeService: StripeService
billingService: BillingService
billingMqService: BillingMqService
configKV: ConfigKVService
redis: Redis
env: Env
otel: OtelInstance | null
}
function buildApp({
auth,
characterService,
chatService,
providerService,
fluxService,
fluxAuditService,
requestLogService,
stripeService,
billingService,
configKV,
redis,
env,
otel,
}: AppDeps) {
function buildApp(deps: AppDeps) {
const logger = useLogger('app').useGlobalConfig()
const app = new Hono<HonoEnv>()
@@ -98,20 +82,20 @@ function buildApp({
)
.use(honoLogger())
if (otel) {
app.use('*', otelMiddleware(otel.http))
if (deps.otel) {
app.use('*', otelMiddleware(deps.otel.http))
}
// WebSocket setup — must be registered BEFORE bodyLimit middleware
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
const chatWsSetup = createChatWsHandlers(chatService, redis, otel?.engagement ?? null)
const chatWsSetup = createChatWsHandlers(deps.chatService, deps.redis, deps.otel?.engagement ?? null)
app.get('/ws/chat', upgradeWebSocket(async (c) => {
const token = c.req.query('token')
if (!token) {
throw createUnauthorizedError('Missing token')
}
const session = await auth.api.getSession({
const session = await deps.auth.api.getSession({
headers: new Headers({ Authorization: `Bearer ${token}` }),
})
if (!session?.user) {
@@ -121,7 +105,7 @@ function buildApp({
}))
const builtApp = app
.use('*', sessionMiddleware(auth))
.use('*', sessionMiddleware(deps.auth))
.use('*', bodyLimit({ maxSize: 1024 * 1024 }))
.onError((err, c) => {
if (err instanceof ApiError) {
@@ -157,37 +141,37 @@ function buildApp({
windowSec: 60,
keyGenerator: c => c.req.header('x-forwarded-for') ?? c.req.header('x-real-ip') ?? 'unknown',
}))
.on(['POST', 'GET'], '/api/auth/*', c => auth.handler(c.req.raw))
.on(['POST', 'GET'], '/api/auth/*', c => deps.auth.handler(c.req.raw))
/**
* Character routes are handled by the character service.
*/
.route('/api/characters', createCharacterRoutes(characterService))
.route('/api/characters', createCharacterRoutes(deps.characterService))
/**
* Provider routes are handled by the provider service.
*/
.route('/api/providers', createProviderRoutes(providerService))
.route('/api/providers', createProviderRoutes(deps.providerService))
/**
* Chat routes are handled by the chat service.
*/
.route('/api/chats', createChatRoutes(chatService))
.route('/api/chats', createChatRoutes(deps.chatService))
/**
* V1 routes for official provider.
*/
.route('/api/v1', createV1CompletionsRoutes(fluxService, billingService, configKV, requestLogService, otel?.llm ?? null))
.route('/api/v1', createV1CompletionsRoutes(deps.fluxService, deps.billingService, deps.configKV, deps.billingMqService, deps.otel?.llm))
/**
* Flux routes.
*/
.route('/api/flux', createFluxRoutes(fluxService, fluxAuditService))
.route('/api/flux', createFluxRoutes(deps.fluxService, deps.fluxAuditService))
/**
* Stripe routes.
*/
.route('/api/stripe', createStripeRoutes(fluxService, stripeService, billingService, configKV, env, otel?.revenue))
.route('/api/stripe', createStripeRoutes(deps.fluxService, deps.stripeService, deps.billingService, deps.configKV, deps.env, deps.otel?.revenue))
return { app: builtApp, injectWebSocket }
}
@@ -272,9 +256,11 @@ export async function createApp() {
build: ({ dependsOn }) => createConfigKVService(dependsOn.redis),
})
const outboxService = injeca.provide('services:outbox', {
dependsOn: { db },
build: ({ dependsOn }) => createOutboxService(dependsOn.db),
const billingMqService = injeca.provide('services:billingMq', {
dependsOn: { redis, env: parsedEnv },
build: ({ dependsOn }) => createBillingMqService(dependsOn.redis, {
stream: dependsOn.env.BILLING_EVENTS_STREAM,
}),
})
const auth = injeca.provide('services:auth', {
@@ -318,8 +304,8 @@ export async function createApp() {
})
const billingService = injeca.provide('services:billing', {
dependsOn: { db, redis, outboxService, configKV, otel },
build: ({ dependsOn }) => createBillingService(dependsOn.db, dependsOn.redis, dependsOn.outboxService, dependsOn.configKV, dependsOn.otel?.revenue),
dependsOn: { db, redis, billingMqService, configKV, otel },
build: ({ dependsOn }) => createBillingService(dependsOn.db, dependsOn.redis, dependsOn.billingMqService, dependsOn.configKV, dependsOn.otel?.revenue),
})
await injeca.start()
@@ -334,6 +320,7 @@ export async function createApp() {
requestLogService,
stripeService,
billingService,
billingMqService,
configKV,
redis,
env: parsedEnv,
@@ -346,9 +333,9 @@ export async function createApp() {
providerService: resolved.providerService,
fluxService: resolved.fluxService,
fluxAuditService: resolved.fluxAuditService,
requestLogService: resolved.requestLogService,
stripeService: resolved.stripeService,
billingService: resolved.billingService,
billingMqService: resolved.billingMqService,
configKV: resolved.configKV,
redis: resolved.redis,
env: resolved.env,
@@ -1,58 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { handleCacheSyncMessage } from '../run-billing-events-consumer'
describe('handleCacheSyncMessage', () => {
it('updates the flux cache when a balance event includes balanceAfter', async () => {
const redis = {
set: vi.fn(async () => 'OK'),
}
await handleCacheSyncMessage({
streamMessageId: '1740000000000-0',
event: {
eventId: 'evt-1',
eventType: 'flux.credited',
aggregateId: 'user-1',
userId: 'user-1',
requestId: 'req-1',
occurredAt: '2026-03-24T00:00:00.000Z',
schemaVersion: 1,
payload: {
amount: 10,
balanceAfter: 110,
source: 'stripe.checkout.completed',
},
},
}, redis as any)
expect(redis.set).toHaveBeenCalledWith('flux:user-1', '110')
})
it('ignores non-balance events', async () => {
const redis = {
set: vi.fn(async () => 'OK'),
}
await handleCacheSyncMessage({
streamMessageId: '1740000000000-1',
event: {
eventId: 'evt-2',
eventType: 'stripe.checkout.completed',
aggregateId: 'sess-1',
userId: 'user-1',
requestId: 'req-2',
occurredAt: '2026-03-24T00:00:00.000Z',
schemaVersion: 1,
payload: {
stripeEventId: 'stripe-evt-1',
stripeSessionId: 'sess-1',
amount: 500,
currency: 'usd',
},
},
}, redis as any)
expect(redis.set).not.toHaveBeenCalled()
})
})
+2 -4
View File
@@ -5,8 +5,7 @@ import { createServerCli, parseServerRole } from '../run'
describe('server cli', () => {
it('parses supported roles', () => {
expect(parseServerRole(['api'])).toBe('api')
expect(parseServerRole(['cache-sync-consumer'])).toBe('cache-sync-consumer')
expect(parseServerRole(['outbox-dispatcher'])).toBe('outbox-dispatcher')
expect(parseServerRole(['billing-consumer'])).toBe('billing-consumer')
})
it('returns null for unsupported or missing roles', () => {
@@ -19,8 +18,7 @@ describe('server cli', () => {
expect(cli.commands.map(command => command.name)).toEqual(expect.arrayContaining([
'api',
'cache-sync-consumer',
'outbox-dispatcher',
'billing-consumer',
]))
})
})
@@ -6,9 +6,9 @@ import { createDrizzle, migrateDatabase } from '../libs/db'
import { parseEnv } from '../libs/env'
import { initializeExternalDependency } from '../libs/external-dependency'
import { createRedis } from '../libs/redis'
import { createBillingConsumerHandler } from '../services/billing-consumer-handler'
import { createBillingMqService } from '../services/billing-mq'
import { createOutboxDispatcher } from '../services/outbox-dispatcher'
import { createOutboxService } from '../services/outbox-service'
import { createBillingMqWorker } from '../services/billing-mq-worker'
function parsePositiveInteger(rawValue: string, envKey: string): number {
const parsed = Number(rawValue)
@@ -19,11 +19,11 @@ function parsePositiveInteger(rawValue: string, envKey: string): number {
return parsed
}
export async function runOutboxDispatcher(): Promise<void> {
export async function runBillingConsumer(): Promise<void> {
initLogger(LoggerLevel.Debug, LoggerFormat.Pretty)
const env = parseEnv(process.env)
const logger = useLogger('outbox-dispatcher').useGlobalConfig()
const logger = useLogger('billing-consumer').useGlobalConfig()
const { db, pool } = await initializeExternalDependency(
'Database',
logger,
@@ -62,14 +62,14 @@ export async function runOutboxDispatcher(): Promise<void> {
)
const abortController = new AbortController()
const claimedBy = env.OUTBOX_DISPATCHER_NAME ?? `outbox-dispatcher-${pid}`
const consumer = env.BILLING_EVENTS_CONSUMER_NAME ?? `billing-consumer-${pid}`
const shutdown = (signalName: string) => {
if (abortController.signal.aborted) {
return
}
logger.withFields({ signalName }).log('Stopping outbox dispatcher')
logger.withFields({ signalName }).log('Stopping billing consumer')
abortController.abort()
}
@@ -77,18 +77,21 @@ export async function runOutboxDispatcher(): Promise<void> {
process.once('SIGTERM', () => shutdown('SIGTERM'))
try {
const outboxService = createOutboxService(db)
const billingMqService = createBillingMqService(redis, {
const mq = createBillingMqService(redis, {
stream: env.BILLING_EVENTS_STREAM,
})
const dispatcher = createOutboxDispatcher(outboxService, billingMqService)
await dispatcher.run({
claimedBy,
const handler = createBillingConsumerHandler(db)
const worker = createBillingMqWorker(mq)
await worker.run({
group: 'billing-consumer',
consumer,
signal: abortController.signal,
batchSize: parsePositiveInteger(env.OUTBOX_DISPATCHER_BATCH_SIZE, 'OUTBOX_DISPATCHER_BATCH_SIZE'),
claimTtlMs: parsePositiveInteger(env.OUTBOX_DISPATCHER_CLAIM_TTL_MS, 'OUTBOX_DISPATCHER_CLAIM_TTL_MS'),
pollIntervalMs: parsePositiveInteger(env.OUTBOX_DISPATCHER_POLL_MS, 'OUTBOX_DISPATCHER_POLL_MS'),
batchSize: parsePositiveInteger(env.BILLING_EVENTS_BATCH_SIZE, 'BILLING_EVENTS_BATCH_SIZE'),
blockMs: parsePositiveInteger(env.BILLING_EVENTS_BLOCK_MS, 'BILLING_EVENTS_BLOCK_MS'),
minIdleTimeMs: parsePositiveInteger(env.BILLING_EVENTS_MIN_IDLE_MS, 'BILLING_EVENTS_MIN_IDLE_MS'),
onMessage: message => handler.handleMessage(message),
})
}
finally {
@@ -1,112 +0,0 @@
import type { BillingStreamMessage } from '../services/billing-mq'
import process, { pid } from 'node:process'
import { initLogger, LoggerFormat, LoggerLevel, useLogger } from '@guiiai/logg'
import { parseEnv } from '../libs/env'
import { initializeExternalDependency } from '../libs/external-dependency'
import { createRedis } from '../libs/redis'
import { createBillingMqService } from '../services/billing-mq'
import { createBillingMqWorker } from '../services/billing-mq-worker'
import { fluxRedisKey } from '../services/flux'
function parsePositiveInteger(rawValue: string, envKey: string): number {
const parsed = Number(rawValue)
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${envKey} must be a positive integer`)
}
return parsed
}
export interface RunBillingEventsConsumerOptions {
group: string
loggerName: string
handleMessage?: (message: BillingStreamMessage, redis: ReturnType<typeof createRedis>) => Promise<void>
}
export async function runBillingEventsConsumer(options: RunBillingEventsConsumerOptions): Promise<void> {
initLogger(LoggerLevel.Debug, LoggerFormat.Pretty)
const env = parseEnv(process.env)
const logger = useLogger(options.loggerName).useGlobalConfig()
const redis = await initializeExternalDependency(
'Redis',
logger,
async (attempt) => {
const instance = createRedis(env.REDIS_URL)
try {
await instance.connect()
logger.log(`Connected to Redis on attempt ${attempt}`)
return instance
}
catch (error) {
instance.disconnect()
throw error
}
},
)
const abortController = new AbortController()
const consumer = env.BILLING_EVENTS_CONSUMER_NAME ?? `${options.group}-${pid}`
const shutdown = async (signalName: string) => {
if (abortController.signal.aborted) {
return
}
logger.withFields({ signalName }).log('Stopping billing MQ consumer')
abortController.abort()
}
process.once('SIGINT', () => {
void shutdown('SIGINT')
})
process.once('SIGTERM', () => {
void shutdown('SIGTERM')
})
try {
const mq = createBillingMqService(redis, {
stream: env.BILLING_EVENTS_STREAM,
})
const worker = createBillingMqWorker(mq)
const handleMessage = options.handleMessage ?? (async (message: BillingStreamMessage) => {
logger.withFields({
group: options.group,
consumer,
eventId: message.event.eventId,
eventType: message.event.eventType,
aggregateId: message.event.aggregateId,
userId: message.event.userId,
streamMessageId: message.streamMessageId,
}).log('Consumed billing MQ event')
})
await worker.run({
group: options.group,
consumer,
signal: abortController.signal,
batchSize: parsePositiveInteger(env.BILLING_EVENTS_BATCH_SIZE, 'BILLING_EVENTS_BATCH_SIZE'),
blockMs: parsePositiveInteger(env.BILLING_EVENTS_BLOCK_MS, 'BILLING_EVENTS_BLOCK_MS'),
minIdleTimeMs: parsePositiveInteger(env.BILLING_EVENTS_MIN_IDLE_MS, 'BILLING_EVENTS_MIN_IDLE_MS'),
onMessage: message => handleMessage(message, redis),
})
}
finally {
await redis.quit()
}
}
export async function handleCacheSyncMessage(
message: BillingStreamMessage,
redis: ReturnType<typeof createRedis>,
): Promise<void> {
if ((message.event.eventType === 'flux.credited' || message.event.eventType === 'flux.debited')
&& message.event.payload.balanceAfter != null) {
await redis.set(fluxRedisKey(message.event.userId), String(message.event.payload.balanceAfter))
}
}
+6 -18
View File
@@ -8,10 +8,9 @@ import { errorMessageFrom } from '@moeru/std'
import { cac } from 'cac'
import { runApiServer } from '../app'
import { handleCacheSyncMessage, runBillingEventsConsumer } from './run-billing-events-consumer'
import { runOutboxDispatcher } from './run-outbox-dispatcher'
import { runBillingConsumer } from './run-billing-consumer'
const serverRoles = ['api', 'cache-sync-consumer', 'outbox-dispatcher'] as const
const serverRoles = ['api', 'billing-consumer'] as const
type ServerRole = typeof serverRoles[number]
@@ -20,15 +19,8 @@ async function runServerRole(role: ServerRole): Promise<void> {
case 'api':
await runApiServer()
return
case 'cache-sync-consumer':
await runBillingEventsConsumer({
group: 'cache-sync',
loggerName: 'cache-sync-consumer',
handleMessage: handleCacheSyncMessage,
})
return
case 'outbox-dispatcher':
await runOutboxDispatcher()
case 'billing-consumer':
await runBillingConsumer()
}
}
@@ -41,12 +33,8 @@ export function createServerCli() {
.action(() => runServerRole('api'))
cli
.command('cache-sync-consumer', 'Start the cache-sync Redis Streams consumer')
.action(() => runServerRole('cache-sync-consumer'))
cli
.command('outbox-dispatcher', 'Publish DB outbox events to Redis Streams')
.action(() => runServerRole('outbox-dispatcher'))
.command('billing-consumer', 'Start the billing events consumer (ledger, audit, request logs)')
.action(() => runServerRole('billing-consumer'))
cli.help()
-4
View File
@@ -30,10 +30,6 @@ const EnvSchema = object({
BILLING_EVENTS_BLOCK_MS: optional(string(), '5000'),
BILLING_EVENTS_MIN_IDLE_MS: optional(string(), '30000'),
OUTBOX_DISPATCHER_NAME: optional(string()),
OUTBOX_DISPATCHER_BATCH_SIZE: optional(string(), '10'),
OUTBOX_DISPATCHER_CLAIM_TTL_MS: optional(string(), '30000'),
OUTBOX_DISPATCHER_POLL_MS: optional(string(), '1000'),
// OpenTelemetry
OTEL_SERVICE_NAMESPACE: optional(string(), 'airi'),
OTEL_SERVICE_NAME: optional(string(), 'server'),
@@ -1,7 +1,7 @@
import type { BillingMqService } from '../../services/billing-mq'
import type { BillingService } from '../../services/billing-service'
import type { ConfigKVService } from '../../services/config-kv'
import type { FluxService } from '../../services/flux'
import type { RequestLogService } from '../../services/request-log'
import type { HonoEnv } from '../../types/hono'
import { Hono } from 'hono'
@@ -53,19 +53,24 @@ function createMockConfigKV(overrides: Record<string, any> = {}): ConfigKVServic
} as any
}
function createMockRequestLogService(): RequestLogService {
function createMockBillingMq(): BillingMqService {
return {
logRequest: vi.fn(async () => {}),
stream: 'billing-events',
publish: vi.fn(async () => '1-0'),
ensureConsumerGroup: vi.fn(async () => true),
consume: vi.fn(async () => []),
claimIdleMessages: vi.fn(async () => []),
ack: vi.fn(async () => 1),
} as any
}
function createTestApp(
fluxService: FluxService,
configKV: ConfigKVService,
requestLogService: RequestLogService,
billingService?: BillingService,
billingMq?: BillingMqService,
) {
const routes = createV1CompletionsRoutes(fluxService, billingService ?? createMockBillingService(), configKV, requestLogService, null)
const routes = createV1CompletionsRoutes(fluxService, billingService ?? createMockBillingService(), configKV, billingMq ?? createMockBillingMq(), null)
const app = new Hono<HonoEnv>()
app.onError((err, c) => {
@@ -108,7 +113,6 @@ describe('v1CompletionsRoutes', () => {
const app = createTestApp(
createMockFluxService(),
createMockConfigKV(),
createMockRequestLogService(),
)
const res = await app.request('/api/v1/chat/completions', {
@@ -123,7 +127,6 @@ describe('v1CompletionsRoutes', () => {
const app = createTestApp(
createMockFluxService(0),
createMockConfigKV(),
createMockRequestLogService(),
)
const res = await app.fetch(
@@ -147,8 +150,7 @@ describe('v1CompletionsRoutes', () => {
const fluxService = createMockFluxService(100)
const billingService = createMockBillingService(100)
const configKV = createMockConfigKV({ GATEWAY_BASE_URL: 'http://mock-gateway/' })
const requestLogService = createMockRequestLogService()
const app = createTestApp(fluxService, configKV, requestLogService, billingService)
const app = createTestApp(fluxService, configKV, billingService)
const res = await app.fetch(
new Request('http://localhost/api/v1/chat/completions', {
@@ -187,7 +189,6 @@ describe('v1CompletionsRoutes', () => {
const app = createTestApp(
createMockFluxService(),
createMockConfigKV({ DEFAULT_CHAT_MODEL: 'anthropic/claude-sonnet' }),
createMockRequestLogService(),
)
await app.fetch(
@@ -213,7 +214,7 @@ describe('v1CompletionsRoutes', () => {
headers: { 'Content-Type': 'application/json' },
}))
const app = createTestApp(createMockFluxService(), createMockConfigKV(), createMockRequestLogService())
const app = createTestApp(createMockFluxService(), createMockConfigKV())
await app.fetch(
new Request('http://localhost/api/v1/chat/completions', {
@@ -239,7 +240,7 @@ describe('v1CompletionsRoutes', () => {
}))
const billingService = createMockBillingService(100)
const app = createTestApp(createMockFluxService(100), createMockConfigKV(), createMockRequestLogService(), billingService)
const app = createTestApp(createMockFluxService(100), createMockConfigKV(), billingService)
const res = await app.fetch(
new Request('http://localhost/api/v1/chat/completions', {
@@ -260,7 +261,7 @@ describe('v1CompletionsRoutes', () => {
// Override getOptional to return null for required keys
configKV.getOptional = vi.fn(async () => null)
const app = createTestApp(createMockFluxService(), configKV, createMockRequestLogService())
const app = createTestApp(createMockFluxService(), configKV)
const res = await app.fetch(
new Request('http://localhost/api/v1/chat/completions', {
@@ -273,14 +274,14 @@ describe('v1CompletionsRoutes', () => {
expect(res.status).toBe(503)
})
it('should log the request', async () => {
it('should publish request log event via billingMq', async () => {
globalThis.fetch = vi.fn(async () => new Response('{}', {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
const requestLogService = createMockRequestLogService()
const app = createTestApp(createMockFluxService(), createMockConfigKV(), requestLogService)
const billingMq = createMockBillingMq()
const app = createTestApp(createMockFluxService(), createMockConfigKV(), undefined, billingMq)
await app.fetch(
new Request('http://localhost/api/v1/chat/completions', {
@@ -291,12 +292,16 @@ describe('v1CompletionsRoutes', () => {
{ user: testUser } as any,
)
expect(requestLogService.logRequest).toHaveBeenCalledWith(
expect(billingMq.publish).toHaveBeenCalledWith(
expect.objectContaining({
eventType: 'llm.request.log',
aggregateId: 'user-1',
userId: 'user-1',
model: 'gpt-4',
status: 200,
fluxConsumed: 1,
payload: expect.objectContaining({
model: 'gpt-4',
status: 200,
fluxConsumed: 1,
}),
}),
)
})
@@ -310,7 +315,7 @@ describe('v1CompletionsRoutes', () => {
headers: { 'Content-Type': 'audio/mpeg' },
}))
const app = createTestApp(createMockFluxService(), createMockConfigKV(), createMockRequestLogService())
const app = createTestApp(createMockFluxService(), createMockConfigKV())
const res = await app.fetch(
new Request('http://localhost/api/v1/audio/speech', {
@@ -336,7 +341,7 @@ describe('v1CompletionsRoutes', () => {
headers: { 'Content-Type': 'application/json' },
}))
const app = createTestApp(createMockFluxService(), createMockConfigKV(), createMockRequestLogService())
const app = createTestApp(createMockFluxService(), createMockConfigKV())
const formData = new FormData()
formData.append('file', new Blob(['audio']), 'test.wav')
@@ -360,7 +365,7 @@ describe('v1CompletionsRoutes', () => {
describe('route matching', () => {
it('gET /api/v1/chat/completions should return 404', async () => {
const app = createTestApp(createMockFluxService(), createMockConfigKV(), createMockRequestLogService())
const app = createTestApp(createMockFluxService(), createMockConfigKV())
const res = await app.fetch(
new Request('http://localhost/api/v1/chat/completions', { method: 'GET' }),
@@ -375,7 +380,7 @@ describe('v1CompletionsRoutes', () => {
headers: { 'Content-Type': 'application/json' },
}))
const app = createTestApp(createMockFluxService(), createMockConfigKV(), createMockRequestLogService())
const app = createTestApp(createMockFluxService(), createMockConfigKV())
const res = await app.fetch(
new Request('http://localhost/api/v1/chat/completion', {
+29 -10
View File
@@ -2,10 +2,10 @@ import type { Context } from 'hono'
import type { LlmMetrics } from '../libs/otel'
import type { UsageInfo } from '../services/billing'
import type { BillingMqService } from '../services/billing-mq'
import type { BillingService } from '../services/billing-service'
import type { ConfigKVService } from '../services/config-kv'
import type { FluxService } from '../services/flux'
import type { RequestLogService } from '../services/request-log'
import type { HonoEnv } from '../types/hono'
import { useLogger } from '@guiiai/logg'
@@ -42,7 +42,7 @@ function normalizeBaseUrl(gatewayBaseUrl: string): string {
return gatewayBaseUrl.endsWith('/') ? gatewayBaseUrl : `${gatewayBaseUrl}/`
}
export function createV1CompletionsRoutes(fluxService: FluxService, billingService: BillingService, configKV: ConfigKVService, requestLogService: RequestLogService, llm: LlmMetrics | null) {
export function createV1CompletionsRoutes(fluxService: FluxService, billingService: BillingService, configKV: ConfigKVService, billingMq: BillingMqService, llm?: LlmMetrics | null) {
const logger = useLogger('v1-completions').useGlobalConfig()
function recordMetrics(opts: { model: string, status: number, type: string, durationMs: number, fluxConsumed: number, promptTokens?: number, completionTokens?: number }) {
@@ -58,6 +58,25 @@ export function createV1CompletionsRoutes(fluxService: FluxService, billingServi
llm.tokensCompletion.add(opts.completionTokens, { model: opts.model })
}
function publishRequestLog(entry: { userId: string, model: string, status: number, durationMs: number, fluxConsumed: number, promptTokens?: number, completionTokens?: number }) {
billingMq.publish({
eventId: nanoid(),
eventType: 'llm.request.log' as const,
aggregateId: entry.userId,
userId: entry.userId,
occurredAt: new Date().toISOString(),
schemaVersion: 1,
payload: {
model: entry.model,
status: entry.status,
durationMs: entry.durationMs,
fluxConsumed: entry.fluxConsumed,
promptTokens: entry.promptTokens,
completionTokens: entry.completionTokens,
},
}).catch(err => logger.withError(err).warn('Failed to publish request log event'))
}
// NOTICE: Billing is best-effort — flux is debited AFTER the LLM response is sent.
// This is a deliberate tradeoff: users get lower latency and uninterrupted streaming,
// at the cost of a small revenue leak when debit fails (e.g. DB timeout).
@@ -174,7 +193,7 @@ export function createV1CompletionsRoutes(fluxService: FluxService, billingServi
}
catch (err) { logger.withError(err).withFields({ userId: user.id, fluxConsumed, requestId }).error('Failed to debit flux after streaming — unpaid usage') }
requestLogService.logRequest({
publishRequestLog({
userId: user.id,
model: requestModel,
status: response.status,
@@ -182,7 +201,7 @@ export function createV1CompletionsRoutes(fluxService: FluxService, billingServi
fluxConsumed: actualCharged,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
}).catch(err => logger.withError(err).warn('Failed to log streaming request'))
})
}
})()
@@ -215,7 +234,7 @@ export function createV1CompletionsRoutes(fluxService: FluxService, billingServi
description: requestModel,
})
requestLogService.logRequest({
publishRequestLog({
userId: user.id,
model: requestModel,
status: response.status,
@@ -223,7 +242,7 @@ export function createV1CompletionsRoutes(fluxService: FluxService, billingServi
fluxConsumed,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
}).catch(err => logger.withError(err).warn('Failed to log request'))
})
return c.json(responseBody)
}
@@ -278,13 +297,13 @@ export function createV1CompletionsRoutes(fluxService: FluxService, billingServi
span.end()
recordMetrics({ model: requestModel, status: response.status, type: 'tts', durationMs, fluxConsumed: fluxPerRequest })
requestLogService.logRequest({
publishRequestLog({
userId: user.id,
model: requestModel,
status: response.status,
durationMs,
fluxConsumed: fluxPerRequest,
}).catch(err => logger.withError(err).warn('Failed to log TTS request'))
})
return new Response(response.body, {
status: response.status,
@@ -343,13 +362,13 @@ export function createV1CompletionsRoutes(fluxService: FluxService, billingServi
span.end()
recordMetrics({ model: 'auto', status: response.status, type: 'asr', durationMs, fluxConsumed: fluxPerRequest })
requestLogService.logRequest({
publishRequestLog({
userId: user.id,
model: 'auto',
status: response.status,
durationMs,
fluxConsumed: fluxPerRequest,
}).catch(err => logger.withError(err).warn('Failed to log ASR request'))
})
return new Response(response.body, {
status: response.status,
-1
View File
@@ -5,7 +5,6 @@ export * from './flux'
export * from './flux-audit-log'
export * from './flux-ledger'
export * from './llm-request-log'
export * from './outbox-events'
export * from './providers'
export * from './stripe'
export * from './user-character'
-31
View File
@@ -1,31 +0,0 @@
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
import { index, integer, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'
import { nanoid } from '../utils/id'
export const outboxEvents = pgTable('outbox_events', {
id: text('id').primaryKey().$defaultFn(() => nanoid()),
eventId: text('event_id').notNull(),
eventType: text('event_type').notNull(),
aggregateId: text('aggregate_id').notNull(),
userId: text('user_id').notNull(),
requestId: text('request_id'),
schemaVersion: integer('schema_version').notNull(),
payload: text('payload').notNull(),
occurredAt: timestamp('occurred_at').notNull(),
availableAt: timestamp('available_at').defaultNow().notNull(),
claimedBy: text('claimed_by'),
claimExpiresAt: timestamp('claim_expires_at'),
publishedAt: timestamp('published_at'),
streamMessageId: text('stream_message_id'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, table => [
uniqueIndex('outbox_events_event_id_idx').on(table.eventId),
index('outbox_events_publish_scan_idx').on(table.publishedAt, table.availableAt, table.claimExpiresAt, table.createdAt),
index('outbox_events_claimed_by_idx').on(table.claimedBy),
])
export type OutboxEvent = InferSelectModel<typeof outboxEvents>
export type NewOutboxEvent = InferInsertModel<typeof outboxEvents>
@@ -1,6 +1,7 @@
import type Redis from 'ioredis'
import type { Database } from '../../libs/db'
import type { BillingMqService } from '../billing-mq'
import type { createConfigKVService } from '../config-kv'
import { eq } from 'drizzle-orm'
@@ -8,7 +9,6 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mockDB } from '../../libs/mock-db'
import { createBillingService } from '../billing-service'
import { createOutboxService } from '../outbox-service'
import * as schema from '../../schemas'
@@ -30,15 +30,25 @@ function createMockRedis(): Redis {
} as unknown as Redis
}
function createMockBillingMq(): BillingMqService {
return {
stream: 'billing-events',
publish: vi.fn(async () => '1-0'),
ensureConsumerGroup: vi.fn(async () => true),
consume: vi.fn(async () => []),
claimIdleMessages: vi.fn(async () => []),
ack: vi.fn(async () => 1),
} as any
}
describe('billingService', () => {
let db: Database
let redis: Redis
let outboxService: ReturnType<typeof createOutboxService>
let billingMq: BillingMqService
let billingService: ReturnType<typeof createBillingService>
beforeAll(async () => {
db = await mockDB(schema)
outboxService = createOutboxService(db)
await db.insert(schema.user).values({
id: 'user-billing-1',
@@ -49,9 +59,9 @@ describe('billingService', () => {
beforeEach(async () => {
redis = createMockRedis()
billingService = createBillingService(db, redis, outboxService, createMockConfigKV())
billingMq = createMockBillingMq()
billingService = createBillingService(db, redis, billingMq, createMockConfigKV())
await db.delete(schema.outboxEvents)
await db.delete(schema.fluxAuditLog)
await db.delete(schema.fluxLedger)
await db.delete(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
@@ -98,10 +108,10 @@ describe('billingService', () => {
expect(auditRecords).toHaveLength(1)
expect(auditRecords[0]?.amount).toBe(50)
// Verify outbox events
const outboxRecords = await db.select().from(schema.outboxEvents).orderBy(schema.outboxEvents.createdAt)
expect(outboxRecords).toHaveLength(2)
expect(outboxRecords.map(record => record.eventType)).toEqual(['flux.credited', 'stripe.checkout.completed'])
// Verify billing events published to stream
expect(billingMq.publish).toHaveBeenCalledTimes(2)
expect(billingMq.publish).toHaveBeenCalledWith(expect.objectContaining({ eventType: 'flux.credited' }))
expect(billingMq.publish).toHaveBeenCalledWith(expect.objectContaining({ eventType: 'stripe.checkout.completed' }))
// Verify stripe session marked as credited
const [sessionRecord] = await db.select().from(schema.stripeCheckoutSession).where(eq(schema.stripeCheckoutSession.stripeSessionId, 'sess-billing-1'))
@@ -132,13 +142,13 @@ describe('billingService', () => {
expect(second).toEqual({ applied: false })
const outboxRecords = await db.select().from(schema.outboxEvents)
expect(outboxRecords).toHaveLength(2)
// Only 2 publish calls from the first invocation
expect(billingMq.publish).toHaveBeenCalledTimes(2)
})
})
describe('debitFlux', () => {
it('deducts balance, writes ledger + audit + outbox, updates Redis', async () => {
it('deducts balance, publishes flux.debited event, updates Redis', async () => {
// Setup: give user some flux first
await db.insert(schema.userFlux).values({ userId: 'user-billing-1', flux: 100 })
@@ -155,26 +165,16 @@ describe('billingService', () => {
const [fluxRecord] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1'))
expect(fluxRecord?.flux).toBe(70)
// Verify ledger
const ledgerRecords = await db.select().from(schema.fluxLedger).where(eq(schema.fluxLedger.userId, 'user-billing-1'))
expect(ledgerRecords).toHaveLength(1)
expect(ledgerRecords[0]).toMatchObject({
type: 'debit',
amount: 30,
balanceBefore: 100,
balanceAfter: 70,
requestId: 'req-1',
})
// Verify audit log
const auditRecords = await db.select().from(schema.fluxAuditLog).where(eq(schema.fluxAuditLog.userId, 'user-billing-1'))
expect(auditRecords).toHaveLength(1)
expect(auditRecords[0]?.amount).toBe(-30)
// Verify outbox event
const outboxRecords = await db.select().from(schema.outboxEvents)
expect(outboxRecords).toHaveLength(1)
expect(outboxRecords[0]?.eventType).toBe('flux.debited')
// Verify flux.debited event published to stream (ledger + audit written by consumer)
expect(billingMq.publish).toHaveBeenCalledTimes(1)
expect(billingMq.publish).toHaveBeenCalledWith(expect.objectContaining({
eventType: 'flux.debited',
userId: 'user-billing-1',
payload: expect.objectContaining({
amount: 30,
balanceAfter: 70,
}),
}))
// Verify Redis cache updated
expect(redis.set).toHaveBeenCalledWith('flux:user-billing-1', '70')
@@ -195,8 +195,8 @@ describe('billingService', () => {
const ledgerRecords = await db.select().from(schema.fluxLedger)
expect(ledgerRecords).toHaveLength(0)
const outboxRecords = await db.select().from(schema.outboxEvents)
expect(outboxRecords).toHaveLength(0)
// Verify no event was published
expect(billingMq.publish).not.toHaveBeenCalled()
})
})
@@ -222,10 +222,9 @@ describe('billingService', () => {
balanceAfter: 50,
})
// Verify outbox
const outboxRecords = await db.select().from(schema.outboxEvents)
expect(outboxRecords).toHaveLength(1)
expect(outboxRecords[0]?.eventType).toBe('flux.credited')
// Verify billing event published to stream
expect(billingMq.publish).toHaveBeenCalledTimes(1)
expect(billingMq.publish).toHaveBeenCalledWith(expect.objectContaining({ eventType: 'flux.credited' }))
})
})
})
@@ -1,80 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { createOutboxDispatcher } from '../outbox-dispatcher'
describe('outboxDispatcher', () => {
it('publishes claimed events and marks them as published', async () => {
const outboxService = {
claimPending: vi.fn(async () => ([
{
id: 'outbox-1',
event: {
eventId: 'evt-1',
eventType: 'flux.credited' as const,
aggregateId: 'user-1',
userId: 'user-1',
requestId: 'req-1',
occurredAt: '2026-03-24T00:00:00.000Z',
schemaVersion: 1,
payload: {
amount: 10,
balanceAfter: 110,
source: 'stripe.checkout.completed',
},
},
},
])),
markPublished: vi.fn(async () => {}),
releaseClaim: vi.fn(async () => {}),
}
const billingMqService = {
publish: vi.fn(async () => '1740000000000-0'),
}
const dispatcher = createOutboxDispatcher(outboxService as any, billingMqService as any)
await expect(dispatcher.dispatchBatch('dispatcher-1', 10, 30_000)).resolves.toBe(1)
expect(billingMqService.publish).toHaveBeenCalled()
expect(outboxService.markPublished).toHaveBeenCalledWith('outbox-1', '1740000000000-0')
expect(outboxService.releaseClaim).not.toHaveBeenCalled()
})
it('releases claims when publish fails', async () => {
const outboxService = {
claimPending: vi.fn(async () => ([
{
id: 'outbox-2',
event: {
eventId: 'evt-2',
eventType: 'flux.credited' as const,
aggregateId: 'user-2',
userId: 'user-2',
requestId: 'req-2',
occurredAt: '2026-03-24T00:00:00.000Z',
schemaVersion: 1,
payload: {
amount: 5,
balanceAfter: 15,
source: 'stripe.checkout.completed',
},
},
},
])),
markPublished: vi.fn(async () => {}),
releaseClaim: vi.fn(async () => {}),
}
const billingMqService = {
publish: vi.fn(async () => {
throw new Error('redis down')
}),
}
const dispatcher = createOutboxDispatcher(outboxService as any, billingMqService as any)
await expect(dispatcher.dispatchBatch('dispatcher-2', 10, 30_000)).resolves.toBe(1)
expect(outboxService.releaseClaim).toHaveBeenCalledWith('outbox-2')
expect(outboxService.markPublished).not.toHaveBeenCalled()
})
})
@@ -1,91 +0,0 @@
import type { Database } from '../../libs/db'
import { eq } from 'drizzle-orm'
import { beforeAll, describe, expect, it } from 'vitest'
import { mockDB } from '../../libs/mock-db'
import { createOutboxService } from '../outbox-service'
import * as schema from '../../schemas'
describe('outboxService', () => {
let db: Database
let outboxService: ReturnType<typeof createOutboxService>
beforeAll(async () => {
db = await mockDB(schema)
outboxService = createOutboxService(db)
})
it('enqueues and claims unpublished events', async () => {
await outboxService.enqueue(db, {
eventId: 'evt-1',
eventType: 'flux.credited',
aggregateId: 'user-1',
userId: 'user-1',
requestId: 'req-1',
occurredAt: '2026-03-24T00:00:00.000Z',
schemaVersion: 1,
payload: {
amount: 10,
balanceAfter: 110,
source: 'stripe.checkout.completed',
},
})
const claimed = await outboxService.claimPending({
claimedBy: 'dispatcher-1',
limit: 10,
claimTtlMs: 30_000,
})
expect(claimed).toHaveLength(1)
expect(claimed[0]?.event).toEqual({
eventId: 'evt-1',
eventType: 'flux.credited',
aggregateId: 'user-1',
userId: 'user-1',
requestId: 'req-1',
occurredAt: '2026-03-24T00:00:00.000Z',
schemaVersion: 1,
payload: {
amount: 10,
balanceAfter: 110,
source: 'stripe.checkout.completed',
},
})
})
it('marks events as published and can release claims', async () => {
await outboxService.enqueue(db, {
eventId: 'evt-2',
eventType: 'stripe.checkout.completed',
aggregateId: 'sess-2',
userId: 'user-2',
requestId: 'req-2',
occurredAt: '2026-03-24T00:00:00.000Z',
schemaVersion: 1,
payload: {
stripeEventId: 'stripe-evt-2',
stripeSessionId: 'sess-2',
amount: 500,
currency: 'usd',
},
})
const [claimed] = await outboxService.claimPending({
claimedBy: 'dispatcher-2',
limit: 10,
claimTtlMs: 30_000,
})
expect(claimed).toBeDefined()
await outboxService.releaseClaim(claimed!.id)
await outboxService.markPublished(claimed!.id, '1740000000000-0')
const [published] = await db.select().from(schema.outboxEvents).where(eq(schema.outboxEvents.id, claimed!.id))
expect(published?.streamMessageId).toBe('1740000000000-0')
expect(published?.publishedAt).toBeInstanceOf(Date)
expect(published?.claimedBy).toBeNull()
})
})
@@ -0,0 +1,83 @@
import type { Database } from '../libs/db'
import type { BillingStreamMessage } from './billing-mq'
import { useLogger } from '@guiiai/logg'
import * as fluxAuditSchema from '../schemas/flux-audit-log'
import * as fluxLedgerSchema from '../schemas/flux-ledger'
import * as llmRequestLogSchema from '../schemas/llm-request-log'
const logger = useLogger('billing-consumer-handler').useGlobalConfig()
export function createBillingConsumerHandler(db: Database) {
return {
async handleMessage(message: BillingStreamMessage): Promise<void> {
const { event } = message
switch (event.eventType) {
case 'flux.debited': {
const balanceBefore = event.payload.balanceAfter != null
? event.payload.balanceAfter + event.payload.amount
: 0
await db.insert(fluxLedgerSchema.fluxLedger).values({
userId: event.userId,
type: 'debit',
amount: event.payload.amount,
balanceBefore,
balanceAfter: event.payload.balanceAfter ?? balanceBefore - event.payload.amount,
requestId: event.requestId,
description: event.payload.source ?? 'LLM request',
})
await db.insert(fluxAuditSchema.fluxAuditLog).values({
userId: event.userId,
type: 'consumption',
amount: -event.payload.amount,
description: event.payload.source ?? 'LLM request',
})
logger.withFields({
eventId: event.eventId,
userId: event.userId,
amount: event.payload.amount,
}).log('Wrote debit ledger + audit')
break
}
case 'llm.request.log': {
await db.insert(llmRequestLogSchema.llmRequestLog).values({
userId: event.userId,
model: event.payload.model,
status: event.payload.status,
durationMs: event.payload.durationMs,
fluxConsumed: event.payload.fluxConsumed,
promptTokens: event.payload.promptTokens,
completionTokens: event.payload.completionTokens,
})
logger.withFields({
eventId: event.eventId,
userId: event.userId,
model: event.payload.model,
}).log('Wrote LLM request log')
break
}
case 'flux.credited':
case 'stripe.checkout.completed':
case 'llm.request.completed': {
// These events are handled synchronously or not yet consumed.
// Log for observability but no async DB writes needed.
logger.withFields({
eventId: event.eventId,
eventType: event.eventType,
}).log('Acknowledged event (no async action)')
break
}
}
},
}
}
export type BillingConsumerHandler = ReturnType<typeof createBillingConsumerHandler>
@@ -20,6 +20,7 @@ const BillingEventTypeSchema = union([
literal('flux.credited'),
literal('stripe.checkout.completed'),
literal('llm.request.completed'),
literal('llm.request.log'),
])
const BalanceChangePayloadSchema = object({
@@ -43,6 +44,15 @@ const LlmRequestCompletedPayloadSchema = object({
completionTokens: optional(number()),
})
const LlmRequestLogPayloadSchema = object({
model: pipe(string(), nonEmpty()),
status: number(),
durationMs: number(),
fluxConsumed: number(),
promptTokens: optional(number()),
completionTokens: optional(number()),
})
const BillingEventEnvelopeSchema = object({
eventId: pipe(string(), nonEmpty()),
eventType: BillingEventTypeSchema,
@@ -60,6 +70,7 @@ type BillingEventEnvelope = InferOutput<typeof BillingEventEnvelopeSchema>
type BalanceChangePayload = InferOutput<typeof BalanceChangePayloadSchema>
type StripeCheckoutCompletedPayload = InferOutput<typeof StripeCheckoutCompletedPayloadSchema>
type LlmRequestCompletedPayload = InferOutput<typeof LlmRequestCompletedPayloadSchema>
type LlmRequestLogPayload = InferOutput<typeof LlmRequestLogPayloadSchema>
export type FluxDebitedEvent = BillingEventEnvelope & {
eventType: 'flux.debited'
@@ -81,11 +92,17 @@ export type LlmRequestCompletedEvent = BillingEventEnvelope & {
payload: LlmRequestCompletedPayload
}
export type LlmRequestLogEvent = BillingEventEnvelope & {
eventType: 'llm.request.log'
payload: LlmRequestLogPayload
}
export type BillingEvent
= | FluxDebitedEvent
| FluxCreditedEvent
| StripeCheckoutCompletedEvent
| LlmRequestCompletedEvent
| LlmRequestLogEvent
export interface SerializedBillingEventFields extends Record<string, string | undefined> {
event_id: string
@@ -153,5 +170,11 @@ export function parseBillingEvent(fields: Record<string, string | undefined>): B
eventType: 'llm.request.completed',
payload: parse(LlmRequestCompletedPayloadSchema, parsedEnvelope.payload),
}
case 'llm.request.log':
return {
...parsedEnvelope,
eventType: 'llm.request.log',
payload: parse(LlmRequestLogPayloadSchema, parsedEnvelope.payload),
}
}
}
+80 -76
View File
@@ -2,8 +2,9 @@ import type Redis from 'ioredis'
import type { Database } from '../libs/db'
import type { RevenueMetrics } from '../libs/otel'
import type { BillingEvent } from './billing-events'
import type { BillingMqService } from './billing-mq'
import type { ConfigKVService } from './config-kv'
import type { OutboxService } from './outbox-service'
import { useLogger } from '@guiiai/logg'
import { eq } from 'drizzle-orm'
@@ -22,7 +23,7 @@ const logger = useLogger('billing-service')
export function createBillingService(
db: Database,
redis: Redis,
outboxService: OutboxService,
billingMq: BillingMqService,
_configKV: ConfigKVService,
metrics?: RevenueMetrics | null,
) {
@@ -39,10 +40,29 @@ export function createBillingService(
}
}
/**
* Publish a billing event to the Redis Stream.
* Best-effort: failures are logged but not re-thrown so callers are not blocked.
*/
async function publishEvent(event: BillingEvent): Promise<void> {
try {
await billingMq.publish(event)
}
catch (error) {
logger.withError(error).withFields({
eventId: event.eventId,
eventType: event.eventType,
userId: event.userId,
}).error('Failed to publish billing event to stream')
}
}
return {
/**
* Debit flux from a user's balance within a DB transaction.
* Writes flux_ledger + flux_audit_log + outbox event atomically.
* The transaction ONLY locks the row and updates the balance.
* Ledger + audit entries are written by the billing-mq consumer
* after it processes the flux.debited event published post-commit.
*/
async debitFlux(input: {
userId: string
@@ -75,54 +95,36 @@ export function createBillingService(
.set({ flux: balanceAfter, updatedAt: new Date() })
.where(eq(fluxSchema.userFlux.userId, input.userId))
// 3. Append ledger entry
await tx.insert(fluxLedgerSchema.fluxLedger).values({
userId: input.userId,
type: 'debit',
amount: input.amount,
balanceBefore,
balanceAfter,
requestId: input.requestId,
description: input.description ?? 'LLM request',
})
// 4. Append audit log (user-facing history)
await tx.insert(fluxAuditSchema.fluxAuditLog).values({
userId: input.userId,
type: 'consumption',
amount: -input.amount,
description: input.description ?? 'LLM request',
})
// 5. Enqueue outbox event
await outboxService.enqueue(tx, {
eventId: nanoid(),
eventType: 'flux.debited',
aggregateId: input.userId,
userId: input.userId,
requestId: input.requestId,
occurredAt: new Date().toISOString(),
schemaVersion: 1,
payload: {
amount: input.amount,
balanceAfter,
source: 'llm.request',
},
})
return { userId: input.userId, flux: balanceAfter }
return { userId: input.userId, flux: balanceAfter, balanceBefore }
})
// 6. Update Redis cache after commit (best-effort)
// 3. Update Redis cache after commit (best-effort)
await updateRedisCache(input.userId, result.flux)
// 4. Publish flux.debited event to stream; ledger + audit written by consumer
await publishEvent({
eventId: nanoid(),
eventType: 'flux.debited',
aggregateId: input.userId,
userId: input.userId,
requestId: input.requestId,
occurredAt: new Date().toISOString(),
schemaVersion: 1,
payload: {
amount: input.amount,
balanceAfter: result.flux,
source: 'llm.request',
},
})
logger.withFields({ userId: input.userId, amount: input.amount, balance: result.flux }).log('Debited flux')
return result
return { userId: result.userId, flux: result.flux }
},
/**
* Credit flux to a user's balance within a DB transaction.
* Generic credit method for non-Stripe flows (e.g. admin grants).
* Ledger + audit entries are written inside the transaction for immediate visibility.
*/
async creditFlux(input: {
userId: string
@@ -173,27 +175,27 @@ export function createBillingService(
metadata: input.auditMetadata,
})
// Outbox event
await outboxService.enqueue(tx, {
eventId: nanoid(),
eventType: 'flux.credited',
aggregateId: input.userId,
userId: input.userId,
requestId: input.requestId,
occurredAt: new Date().toISOString(),
schemaVersion: 1,
payload: {
amount: input.amount,
balanceAfter,
source: input.source,
},
})
return { balanceBefore, balanceAfter }
})
await updateRedisCache(input.userId, result.balanceAfter)
// Publish flux.credited event after commit
await publishEvent({
eventId: nanoid(),
eventType: 'flux.credited',
aggregateId: input.userId,
userId: input.userId,
requestId: input.requestId,
occurredAt: new Date().toISOString(),
schemaVersion: 1,
payload: {
amount: input.amount,
balanceAfter: result.balanceAfter,
source: input.source,
},
})
logger.withFields({ userId: input.userId, amount: input.amount, balance: result.balanceAfter }).log('Credited flux')
return result
},
@@ -201,6 +203,7 @@ export function createBillingService(
/**
* Credit flux from a Stripe checkout session (one-time payment).
* Idempotent: checks fluxCredited flag before applying.
* Ledger + audit entries are written inside the transaction for immediate visibility.
*/
async creditFluxFromStripeCheckout(input: {
stripeEventId: string
@@ -270,9 +273,15 @@ export function createBillingService(
},
})
// Outbox events
return { applied: true, balanceAfter }
})
if (txResult.applied && txResult.balanceAfter != null) {
await updateRedisCache(input.userId, txResult.balanceAfter)
// Publish both events after commit
const occurredAt = new Date().toISOString()
await outboxService.enqueue(tx, {
await publishEvent({
eventId: nanoid(),
eventType: 'flux.credited',
aggregateId: input.userId,
@@ -282,12 +291,12 @@ export function createBillingService(
schemaVersion: 1,
payload: {
amount: input.fluxAmount,
balanceAfter,
balanceAfter: txResult.balanceAfter,
source: 'stripe.checkout.completed',
},
})
await outboxService.enqueue(tx, {
await publishEvent({
eventId: nanoid(),
eventType: 'stripe.checkout.completed',
aggregateId: input.stripeSessionId,
@@ -302,12 +311,6 @@ export function createBillingService(
currency: input.currency ?? 'unknown',
},
})
return { applied: true, balanceAfter }
})
if (txResult.applied && txResult.balanceAfter != null) {
await updateRedisCache(input.userId, txResult.balanceAfter)
}
return txResult
@@ -316,6 +319,7 @@ export function createBillingService(
/**
* Credit flux from a Stripe invoice payment (subscription).
* Idempotent: checks fluxCredited flag on the invoice record.
* Ledger + audit entries are written inside the transaction for immediate visibility.
*/
async creditFluxFromInvoice(input: {
stripeEventId: string
@@ -385,8 +389,14 @@ export function createBillingService(
},
})
// Outbox event
await outboxService.enqueue(tx, {
return { applied: true, balanceAfter }
})
if (txResult.applied && txResult.balanceAfter != null) {
await updateRedisCache(input.userId, txResult.balanceAfter)
// Publish flux.credited event after commit
await publishEvent({
eventId: nanoid(),
eventType: 'flux.credited',
aggregateId: input.userId,
@@ -396,16 +406,10 @@ export function createBillingService(
schemaVersion: 1,
payload: {
amount: input.fluxAmount,
balanceAfter,
balanceAfter: txResult.balanceAfter,
source: 'invoice.paid',
},
})
return { applied: true, balanceAfter }
})
if (txResult.applied && txResult.balanceAfter != null) {
await updateRedisCache(input.userId, txResult.balanceAfter)
}
return txResult
@@ -1,83 +0,0 @@
import type { BillingMqService } from './billing-mq'
import type { OutboxService } from './outbox-service'
import { useLogger } from '@guiiai/logg'
export interface RunOutboxDispatcherOptions {
claimedBy: string
signal: AbortSignal
batchSize?: number
claimTtlMs?: number
pollIntervalMs?: number
}
const logger = useLogger('outbox-dispatcher').useGlobalConfig()
export function createOutboxDispatcher(outboxService: OutboxService, billingMqService: BillingMqService) {
return {
async dispatchBatch(claimedBy: string, batchSize: number, claimTtlMs: number): Promise<number> {
const claimedEvents = await outboxService.claimPending({
claimedBy,
limit: batchSize,
claimTtlMs,
})
for (const claimedEvent of claimedEvents) {
try {
const streamMessageId = await billingMqService.publish(claimedEvent.event)
await outboxService.markPublished(claimedEvent.id, streamMessageId)
}
catch (error) {
await outboxService.releaseClaim(claimedEvent.id)
logger.withError(error).withFields({
claimedBy,
outboxId: claimedEvent.id,
eventId: claimedEvent.event.eventId,
eventType: claimedEvent.event.eventType,
}).error('Failed to dispatch outbox event')
}
}
return claimedEvents.length
},
async run(options: RunOutboxDispatcherOptions): Promise<void> {
while (!options.signal.aborted) {
const dispatchedCount = await this.dispatchBatch(
options.claimedBy,
options.batchSize ?? 10,
options.claimTtlMs ?? 30_000,
)
if (dispatchedCount > 0) {
continue
}
await sleep(options.pollIntervalMs ?? 1_000, options.signal)
}
},
}
}
async function sleep(timeoutMs: number, signal: AbortSignal): Promise<void> {
if (signal.aborted) {
return
}
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, timeoutMs)
const onAbort = () => {
clearTimeout(timeout)
signal.removeEventListener('abort', onAbort)
resolve()
}
signal.addEventListener('abort', onAbort, { once: true })
})
}
export type OutboxDispatcher = ReturnType<typeof createOutboxDispatcher>
-123
View File
@@ -1,123 +0,0 @@
import type { Database } from '../libs/db'
import type { BillingEvent } from './billing-events'
import { and, asc, eq, inArray, isNull, lte, or } from 'drizzle-orm'
import { outboxEvents } from '../schemas/outbox-events'
import { parseBillingEvent, serializeBillingEvent } from './billing-events'
interface OutboxWriter {
insert: Database['insert']
}
export interface ClaimOutboxEventsOptions {
claimedBy: string
limit: number
claimTtlMs: number
}
export interface ClaimedOutboxEvent {
id: string
event: BillingEvent
}
export function createOutboxService(db: Database) {
return {
async enqueue(writer: OutboxWriter, event: BillingEvent): Promise<void> {
const serializedEvent = serializeBillingEvent(event)
await writer.insert(outboxEvents).values({
eventId: serializedEvent.event_id,
eventType: serializedEvent.event_type,
aggregateId: serializedEvent.aggregate_id,
userId: serializedEvent.user_id,
requestId: serializedEvent.request_id,
schemaVersion: Number(serializedEvent.schema_version),
payload: serializedEvent.payload,
occurredAt: new Date(serializedEvent.occurred_at),
})
},
async claimPending(options: ClaimOutboxEventsOptions): Promise<ClaimedOutboxEvent[]> {
const now = new Date()
const claimExpiresAt = new Date(now.getTime() + options.claimTtlMs)
return db.transaction(async (tx) => {
const claimable = tx.$with('claimable').as(
tx
.select({ id: outboxEvents.id })
.from(outboxEvents)
.where(and(
isNull(outboxEvents.publishedAt),
lte(outboxEvents.availableAt, now),
or(
isNull(outboxEvents.claimExpiresAt),
lte(outboxEvents.claimExpiresAt, now),
),
))
.orderBy(asc(outboxEvents.createdAt))
.limit(options.limit)
.for('update', { skipLocked: true }),
)
const claimedRows = await tx
.with(claimable)
.update(outboxEvents)
.set({
claimedBy: options.claimedBy,
claimExpiresAt,
updatedAt: now,
})
.where(inArray(outboxEvents.id, tx.select({ id: claimable.id }).from(claimable)))
.returning({
id: outboxEvents.id,
eventId: outboxEvents.eventId,
eventType: outboxEvents.eventType,
aggregateId: outboxEvents.aggregateId,
userId: outboxEvents.userId,
requestId: outboxEvents.requestId,
schemaVersion: outboxEvents.schemaVersion,
payload: outboxEvents.payload,
occurredAt: outboxEvents.occurredAt,
})
return claimedRows.map(row => ({
id: row.id,
event: parseBillingEvent({
event_id: row.eventId,
event_type: row.eventType,
aggregate_id: row.aggregateId,
user_id: row.userId,
request_id: row.requestId ?? undefined,
schema_version: String(row.schemaVersion),
payload: row.payload,
occurred_at: row.occurredAt.toISOString(),
}),
}))
})
},
async markPublished(id: string, streamMessageId: string): Promise<void> {
await db.update(outboxEvents)
.set({
publishedAt: new Date(),
streamMessageId,
claimedBy: null,
claimExpiresAt: null,
updatedAt: new Date(),
})
.where(eq(outboxEvents.id, id))
},
async releaseClaim(id: string): Promise<void> {
await db.update(outboxEvents)
.set({
claimedBy: null,
claimExpiresAt: null,
updatedAt: new Date(),
})
.where(eq(outboxEvents.id, id))
},
}
}
export type OutboxService = ReturnType<typeof createOutboxService>
File diff suppressed because it is too large Load Diff