refactor(server): split mq libs, organize billing services structure

This commit is contained in:
RainbowBird
2026-03-28 02:25:44 +08:00
committed by RainbowBird
parent aed2265d4a
commit 8322c2c622
14 changed files with 363 additions and 323 deletions
+11 -10
View File
@@ -1,8 +1,9 @@
import type Redis from 'ioredis' import type Redis from 'ioredis'
import type { Env } from './libs/env' import type { Env } from './libs/env'
import type { MqService } from './libs/mq'
import type { OtelInstance } from './libs/otel' import type { OtelInstance } from './libs/otel'
import type { BillingMqService } from './services/billing/billing-mq' import type { BillingEvent } from './services/billing/billing-events'
import type { BillingService } from './services/billing/billing-service' import type { BillingService } from './services/billing/billing-service'
import type { CharacterService } from './services/characters' import type { CharacterService } from './services/characters'
import type { ChatService } from './services/chats' import type { ChatService } from './services/chats'
@@ -40,7 +41,7 @@ import { createFluxRoutes } from './routes/flux'
import { createProviderRoutes } from './routes/providers' import { createProviderRoutes } from './routes/providers'
import { createStripeRoutes } from './routes/stripe' import { createStripeRoutes } from './routes/stripe'
import { createV1CompletionsRoutes } from './routes/v1completions' import { createV1CompletionsRoutes } from './routes/v1completions'
import { createBillingMqService } from './services/billing/billing-mq' import { createBillingMq } from './services/billing/billing-events'
import { createBillingService } from './services/billing/billing-service' import { createBillingService } from './services/billing/billing-service'
import { createCharacterService } from './services/characters' import { createCharacterService } from './services/characters'
import { createChatService } from './services/chats' import { createChatService } from './services/chats'
@@ -62,7 +63,7 @@ interface AppDeps {
fluxAuditService: FluxAuditService fluxAuditService: FluxAuditService
stripeService: StripeService stripeService: StripeService
billingService: BillingService billingService: BillingService
billingMqService: BillingMqService billingMq: MqService<BillingEvent>
configKV: ConfigKVService configKV: ConfigKVService
redis: Redis redis: Redis
env: Env env: Env
@@ -161,7 +162,7 @@ function buildApp(deps: AppDeps) {
/** /**
* V1 routes for official provider. * V1 routes for official provider.
*/ */
.route('/api/v1', createV1CompletionsRoutes(deps.fluxService, deps.billingService, deps.configKV, deps.billingMqService, deps.otel?.llm)) .route('/api/v1', createV1CompletionsRoutes(deps.fluxService, deps.billingService, deps.configKV, deps.billingMq, deps.otel?.llm))
/** /**
* Flux routes. * Flux routes.
@@ -256,9 +257,9 @@ export async function createApp() {
build: ({ dependsOn }) => createConfigKVService(dependsOn.redis), build: ({ dependsOn }) => createConfigKVService(dependsOn.redis),
}) })
const billingMqService = injeca.provide('services:billingMq', { const billingMq = injeca.provide('services:billingMq', {
dependsOn: { redis, env: parsedEnv }, dependsOn: { redis, env: parsedEnv },
build: ({ dependsOn }) => createBillingMqService(dependsOn.redis, { build: ({ dependsOn }) => createBillingMq(dependsOn.redis, {
stream: dependsOn.env.BILLING_EVENTS_STREAM, stream: dependsOn.env.BILLING_EVENTS_STREAM,
}), }),
}) })
@@ -304,8 +305,8 @@ export async function createApp() {
}) })
const billingService = injeca.provide('services:billing', { const billingService = injeca.provide('services:billing', {
dependsOn: { db, redis, billingMqService, configKV, otel }, dependsOn: { db, redis, billingMq, configKV, otel },
build: ({ dependsOn }) => createBillingService(dependsOn.db, dependsOn.redis, dependsOn.billingMqService, dependsOn.configKV, dependsOn.otel?.revenue), build: ({ dependsOn }) => createBillingService(dependsOn.db, dependsOn.redis, dependsOn.billingMq, dependsOn.configKV, dependsOn.otel?.revenue),
}) })
await injeca.start() await injeca.start()
@@ -320,7 +321,7 @@ export async function createApp() {
requestLogService, requestLogService,
stripeService, stripeService,
billingService, billingService,
billingMqService, billingMq,
configKV, configKV,
redis, redis,
env: parsedEnv, env: parsedEnv,
@@ -335,7 +336,7 @@ export async function createApp() {
fluxAuditService: resolved.fluxAuditService, fluxAuditService: resolved.fluxAuditService,
stripeService: resolved.stripeService, stripeService: resolved.stripeService,
billingService: resolved.billingService, billingService: resolved.billingService,
billingMqService: resolved.billingMqService, billingMq: resolved.billingMq,
configKV: resolved.configKV, configKV: resolved.configKV,
redis: resolved.redis, redis: resolved.redis,
env: resolved.env, env: resolved.env,
+8 -17
View File
@@ -5,19 +5,10 @@ import { initLogger, LoggerFormat, LoggerLevel, useLogger } from '@guiiai/logg'
import { createDrizzle, migrateDatabase } from '../libs/db' import { createDrizzle, migrateDatabase } from '../libs/db'
import { parseEnv } from '../libs/env' import { parseEnv } from '../libs/env'
import { initializeExternalDependency } from '../libs/external-dependency' import { initializeExternalDependency } from '../libs/external-dependency'
import { createMqWorker } from '../libs/mq'
import { createRedis } from '../libs/redis' import { createRedis } from '../libs/redis'
import { createBillingConsumerHandler } from '../services/billing/billing-consumer-handler' import { createBillingConsumerHandler } from '../services/billing/billing-consumer-handler'
import { createBillingMqService } from '../services/billing/billing-mq' import { createBillingMq } from '../services/billing/billing-events'
import { createBillingMqWorker } from '../services/billing/billing-mq-worker'
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 async function runBillingConsumer(): Promise<void> { export async function runBillingConsumer(): Promise<void> {
initLogger(LoggerLevel.Debug, LoggerFormat.Pretty) initLogger(LoggerLevel.Debug, LoggerFormat.Pretty)
@@ -28,7 +19,7 @@ export async function runBillingConsumer(): Promise<void> {
'Database', 'Database',
logger, logger,
async (attempt) => { async (attempt) => {
const connection = createDrizzle(env.DATABASE_URL) const connection = createDrizzle(env)
try { try {
await connection.db.execute('SELECT 1') await connection.db.execute('SELECT 1')
@@ -77,20 +68,20 @@ export async function runBillingConsumer(): Promise<void> {
process.once('SIGTERM', () => shutdown('SIGTERM')) process.once('SIGTERM', () => shutdown('SIGTERM'))
try { try {
const mq = createBillingMqService(redis, { const mq = createBillingMq(redis, {
stream: env.BILLING_EVENTS_STREAM, stream: env.BILLING_EVENTS_STREAM,
}) })
const handler = createBillingConsumerHandler(db) const handler = createBillingConsumerHandler(db)
const worker = createBillingMqWorker(mq) const worker = createMqWorker(mq)
await worker.run({ await worker.run({
group: 'billing-consumer', group: 'billing-consumer',
consumer, consumer,
signal: abortController.signal, signal: abortController.signal,
batchSize: parsePositiveInteger(env.BILLING_EVENTS_BATCH_SIZE, 'BILLING_EVENTS_BATCH_SIZE'), batchSize: env.BILLING_EVENTS_BATCH_SIZE,
blockMs: parsePositiveInteger(env.BILLING_EVENTS_BLOCK_MS, 'BILLING_EVENTS_BLOCK_MS'), blockMs: env.BILLING_EVENTS_BLOCK_MS,
minIdleTimeMs: parsePositiveInteger(env.BILLING_EVENTS_MIN_IDLE_MS, 'BILLING_EVENTS_MIN_IDLE_MS'), minIdleTimeMs: env.BILLING_EVENTS_MIN_IDLE_MS,
onMessage: message => handler.handleMessage(message), onMessage: message => handler.handleMessage(message),
}) })
} }
+13
View File
@@ -0,0 +1,13 @@
export { createMqService } from './stream'
export type { MqService } from './stream'
export type {
ClaimIdleOptions,
ConsumeOptions,
MqOptions,
RedisCommandClient,
StreamMessage,
WorkerOptions,
} from './types'
export { createMqWorker } from './worker'
export type { MqWorker } from './worker'
+192
View File
@@ -0,0 +1,192 @@
import type {
ClaimIdleOptions,
ConsumeOptions,
MqOptions,
RedisArgument,
RedisCommandClient,
StreamMessage,
} from './types'
import { useLogger } from '@guiiai/logg'
type RedisStreamEntry = [streamMessageId: string, fieldValues: string[]]
type RedisReadGroupResponse = [stream: string, entries: RedisStreamEntry[]][]
type RedisAutoClaimResponse = [nextStartId: string, entries: RedisStreamEntry[], deletedIds?: string[]]
const logger = useLogger('mq-stream').useGlobalConfig()
/**
* Create a typed Redis Stream service.
*
* The caller supplies serialize/deserialize functions so this module
* stays domain-agnostic — it only knows how to talk to Redis Streams.
*/
export function createMqService<TEvent>(redis: RedisCommandClient, options: MqOptions<TEvent>) {
const { stream, serialize, deserialize } = options
function parseEntry(entry: unknown): StreamMessage<TEvent> {
if (!Array.isArray(entry) || entry.length !== 2) {
throw new Error('Redis Stream entry has an invalid shape')
}
const [streamMessageId, rawFieldValues] = entry
if (typeof streamMessageId !== 'string') {
throw new TypeError('Redis Stream entry is missing a valid message id')
}
if (!Array.isArray(rawFieldValues)) {
throw new TypeError('Redis Stream entry fields are invalid')
}
return { streamMessageId, event: deserialize(toFieldRecord(rawFieldValues)) }
}
function parseReadGroupResponse(response: unknown): StreamMessage<TEvent>[] {
if (response == null) {
return []
}
if (!Array.isArray(response)) {
throw new TypeError('Redis XREADGROUP returned an invalid response')
}
return response.flatMap((streamResponse) => {
if (!Array.isArray(streamResponse) || streamResponse.length !== 2) {
throw new Error('Redis XREADGROUP returned an invalid stream payload')
}
const [, entries] = streamResponse as RedisReadGroupResponse[number]
return entries.map(parseEntry)
})
}
function parseAutoClaimResponse(response: unknown): StreamMessage<TEvent>[] {
if (response == null) {
return []
}
if (!Array.isArray(response) || response.length < 2) {
throw new Error('Redis XAUTOCLAIM returned an invalid response')
}
const [, entries] = response as RedisAutoClaimResponse
if (!Array.isArray(entries)) {
throw new TypeError('Redis XAUTOCLAIM returned invalid entries')
}
return entries.map(parseEntry)
}
return {
stream,
async publish(event: TEvent): Promise<string> {
const fields = serialize(event)
const xaddArgs: RedisArgument[] = [stream]
if (options.maxLength != null) {
xaddArgs.push('MAXLEN', '~', options.maxLength)
}
xaddArgs.push('*', ...toRedisFieldArguments(fields))
const streamMessageId = await redis.call('XADD', ...xaddArgs)
if (typeof streamMessageId !== 'string') {
throw new TypeError('Redis XADD did not return a stream message id')
}
logger.withFields({ stream, streamMessageId }).log('Published event to Redis Stream')
return streamMessageId
},
async ensureConsumerGroup(group: string, startId = '0'): Promise<boolean> {
try {
await redis.call('XGROUP', 'CREATE', stream, group, startId, 'MKSTREAM')
return true
}
catch (error) {
if (error instanceof Error && error.message.includes('BUSYGROUP')) {
return false
}
throw error
}
},
async consume(consumeOptions: ConsumeOptions): Promise<StreamMessage<TEvent>[]> {
const response = await redis.call(
'XREADGROUP',
'GROUP',
consumeOptions.group,
consumeOptions.consumer,
'COUNT',
consumeOptions.count ?? 10,
'BLOCK',
consumeOptions.blockMs ?? 5_000,
'STREAMS',
stream,
consumeOptions.startId ?? '>',
)
return parseReadGroupResponse(response)
},
async claimIdleMessages(claimOptions: ClaimIdleOptions): Promise<StreamMessage<TEvent>[]> {
const response = await redis.call(
'XAUTOCLAIM',
stream,
claimOptions.group,
claimOptions.consumer,
claimOptions.minIdleTimeMs,
claimOptions.startId ?? '0-0',
'COUNT',
claimOptions.count ?? 10,
)
return parseAutoClaimResponse(response)
},
async ack(group: string, streamMessageIds: string | string[]): Promise<number> {
const ids = Array.isArray(streamMessageIds) ? streamMessageIds : [streamMessageIds]
if (ids.length === 0) {
return 0
}
const acked = await redis.call('XACK', stream, group, ...ids)
if (typeof acked !== 'number') {
throw new TypeError('Redis XACK did not return an acknowledgement count')
}
return acked
},
}
}
function toRedisFieldArguments(fields: Record<string, string | undefined>): RedisArgument[] {
return Object.entries(fields)
.filter(([, value]) => value !== undefined)
.flatMap(([key, value]) => [key, value as string])
}
function toFieldRecord(fieldValues: string[]): Record<string, string> {
if (fieldValues.length % 2 !== 0) {
throw new Error('Redis Stream entry fields must be key/value pairs')
}
const fields: Record<string, string> = {}
for (let index = 0; index < fieldValues.length; index += 2) {
const key = fieldValues[index]
const value = fieldValues[index + 1]
if (typeof key !== 'string' || typeof value !== 'string') {
throw new TypeError('Redis Stream entry contains non-string field data')
}
fields[key] = value
}
return fields
}
export type MqService<TEvent> = ReturnType<typeof createMqService<TEvent>>
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { createBillingMqWorker } from '../billing-mq-worker' import { createMqWorker } from '../worker'
function createMessage() { function createMessage() {
return { return {
@@ -22,7 +22,7 @@ function createMessage() {
} }
} }
describe('billingMqWorker', () => { describe('mqWorker', () => {
it('reclaims pending messages before reading new ones and acks after handling', async () => { it('reclaims pending messages before reading new ones and acks after handling', async () => {
const controller = new AbortController() const controller = new AbortController()
const message = createMessage() const message = createMessage()
@@ -34,7 +34,7 @@ describe('billingMqWorker', () => {
ack: vi.fn(async () => 1), ack: vi.fn(async () => 1),
} }
const worker = createBillingMqWorker(mq as any) const worker = createMqWorker(mq as any)
const handled: string[] = [] const handled: string[] = []
await worker.run({ await worker.run({
@@ -70,7 +70,7 @@ describe('billingMqWorker', () => {
ack: vi.fn(async () => 1), ack: vi.fn(async () => 1),
} }
const worker = createBillingMqWorker(mq as any) const worker = createMqWorker(mq as any)
await worker.run({ await worker.run({
group: 'billing', group: 'billing',
@@ -102,7 +102,7 @@ describe('billingMqWorker', () => {
ack: vi.fn(async () => 1), ack: vi.fn(async () => 1),
} }
const worker = createBillingMqWorker(mq as any) const worker = createMqWorker(mq as any)
await worker.run({ await worker.run({
group: 'billing', group: 'billing',
+47
View File
@@ -0,0 +1,47 @@
export type RedisArgument = string | number
export interface RedisCommandClient {
call: (command: string, ...args: RedisArgument[]) => Promise<unknown>
}
export interface MqOptions<TEvent> {
/** Redis Stream key name. */
stream: string
/** Approximate max stream length (MAXLEN ~). Unbounded if omitted. */
maxLength?: number
/** Convert a typed event into flat Redis field/value pairs. */
serialize: (event: TEvent) => Record<string, string | undefined>
/** Reconstruct a typed event from flat Redis field/value pairs. */
deserialize: (fields: Record<string, string>) => TEvent
}
export interface StreamMessage<TEvent> {
streamMessageId: string
event: TEvent
}
export interface ConsumeOptions {
group: string
consumer: string
count?: number
blockMs?: number
startId?: string
}
export interface ClaimIdleOptions {
group: string
consumer: string
minIdleTimeMs: number
startId?: string
count?: number
}
export interface WorkerOptions<TEvent> {
group: string
consumer: string
signal: AbortSignal
batchSize?: number
blockMs?: number
minIdleTimeMs?: number
onMessage: (message: StreamMessage<TEvent>) => Promise<void>
}
@@ -1,22 +1,20 @@
import type { BillingMqService, BillingStreamMessage } from './billing-mq' import type { MqService } from './stream'
import type { StreamMessage, WorkerOptions } from './types'
import { useLogger } from '@guiiai/logg' import { useLogger } from '@guiiai/logg'
export interface RunBillingMqWorkerOptions { const logger = useLogger('mq-worker').useGlobalConfig()
group: string
consumer: string
signal: AbortSignal
batchSize?: number
blockMs?: number
minIdleTimeMs?: number
onMessage: (message: BillingStreamMessage) => Promise<void>
}
const logger = useLogger('billing-mq-worker').useGlobalConfig() /**
* Create a consumer worker that processes messages from a Redis Stream.
export function createBillingMqWorker(mq: BillingMqService) { *
* The loop first reclaims idle (possibly stalled) messages, then falls
* back to consuming new ones. Each message is passed to `onMessage`;
* on success it is acknowledged, on failure it stays pending for retry.
*/
export function createMqWorker<TEvent>(mq: MqService<TEvent>) {
return { return {
async run(options: RunBillingMqWorkerOptions): Promise<void> { async run(options: WorkerOptions<TEvent>): Promise<void> {
await mq.ensureConsumerGroup(options.group) await mq.ensureConsumerGroup(options.group)
while (!options.signal.aborted) { while (!options.signal.aborted) {
@@ -27,7 +25,7 @@ export function createBillingMqWorker(mq: BillingMqService) {
count: options.batchSize ?? 10, count: options.batchSize ?? 10,
}) })
const messages = reclaimedMessages.length > 0 const messages: StreamMessage<TEvent>[] = reclaimedMessages.length > 0
? reclaimedMessages ? reclaimedMessages
: await mq.consume({ : await mq.consume({
group: options.group, group: options.group,
@@ -49,9 +47,8 @@ export function createBillingMqWorker(mq: BillingMqService) {
logger.withError(error).withFields({ logger.withError(error).withFields({
group: options.group, group: options.group,
consumer: options.consumer, consumer: options.consumer,
eventId: message.event.eventId,
streamMessageId: message.streamMessageId, streamMessageId: message.streamMessageId,
}).error('Billing MQ handler failed; leaving message pending') }).error('MQ handler failed; leaving message pending')
} }
} }
} }
@@ -59,4 +56,4 @@ export function createBillingMqWorker(mq: BillingMqService) {
} }
} }
export type BillingMqWorker = ReturnType<typeof createBillingMqWorker> export type MqWorker<TEvent> = ReturnType<typeof createMqWorker<TEvent>>
@@ -1,4 +1,5 @@
import type { BillingMqService } from '../../services/billing/billing-mq' import type { MqService } from '../../libs/mq'
import type { BillingEvent } from '../../services/billing/billing-events'
import type { BillingService } from '../../services/billing/billing-service' import type { BillingService } from '../../services/billing/billing-service'
import type { ConfigKVService } from '../../services/config-kv' import type { ConfigKVService } from '../../services/config-kv'
import type { FluxService } from '../../services/flux' import type { FluxService } from '../../services/flux'
@@ -53,7 +54,7 @@ function createMockConfigKV(overrides: Record<string, any> = {}): ConfigKVServic
} as any } as any
} }
function createMockBillingMq(): BillingMqService { function createMockBillingMq(): MqService<BillingEvent> {
return { return {
stream: 'billing-events', stream: 'billing-events',
publish: vi.fn(async () => '1-0'), publish: vi.fn(async () => '1-0'),
@@ -68,7 +69,7 @@ function createTestApp(
fluxService: FluxService, fluxService: FluxService,
configKV: ConfigKVService, configKV: ConfigKVService,
billingService?: BillingService, billingService?: BillingService,
billingMq?: BillingMqService, billingMq?: MqService<BillingEvent>,
) { ) {
const routes = createV1CompletionsRoutes(fluxService, billingService ?? createMockBillingService(), configKV, billingMq ?? createMockBillingMq(), null) const routes = createV1CompletionsRoutes(fluxService, billingService ?? createMockBillingService(), configKV, billingMq ?? createMockBillingMq(), null)
const app = new Hono<HonoEnv>() const app = new Hono<HonoEnv>()
@@ -1,5 +1,6 @@
import type { Database } from '../../libs/db' import type { Database } from '../../libs/db'
import type { BillingStreamMessage } from './billing-mq' import type { StreamMessage } from '../../libs/mq'
import type { BillingEvent } from './billing-events'
import { useLogger } from '@guiiai/logg' import { useLogger } from '@guiiai/logg'
@@ -10,7 +11,7 @@ const logger = useLogger('billing-consumer-handler').useGlobalConfig()
export function createBillingConsumerHandler(db: Database) { export function createBillingConsumerHandler(db: Database) {
return { return {
async handleMessage(message: BillingStreamMessage): Promise<void> { async handleMessage(message: StreamMessage<BillingEvent>): Promise<void> {
const { event } = message const { event } = message
switch (event.eventType) { switch (event.eventType) {
@@ -19,6 +20,8 @@ export function createBillingConsumerHandler(db: Database) {
? event.payload.balanceAfter + event.payload.amount ? event.payload.balanceAfter + event.payload.amount
: 0 : 0
// NOTICE: onConflictDoNothing handles redelivery after crash —
// the unique index (userId, requestId) prevents duplicate ledger entries.
await db.insert(fluxLedgerSchema.fluxLedger).values({ await db.insert(fluxLedgerSchema.fluxLedger).values({
userId: event.userId, userId: event.userId,
type: 'debit', type: 'debit',
@@ -27,7 +30,7 @@ export function createBillingConsumerHandler(db: Database) {
balanceAfter: event.payload.balanceAfter ?? balanceBefore - event.payload.amount, balanceAfter: event.payload.balanceAfter ?? balanceBefore - event.payload.amount,
requestId: event.requestId, requestId: event.requestId,
description: event.payload.source ?? 'LLM request', description: event.payload.source ?? 'LLM request',
}) }).onConflictDoNothing()
logger.withFields({ logger.withFields({
eventId: event.eventId, eventId: event.eventId,
@@ -38,7 +41,9 @@ export function createBillingConsumerHandler(db: Database) {
} }
case 'llm.request.log': { case 'llm.request.log': {
// NOTICE: Use eventId as PK to make redelivery idempotent.
await db.insert(llmRequestLogSchema.llmRequestLog).values({ await db.insert(llmRequestLogSchema.llmRequestLog).values({
id: event.eventId,
userId: event.userId, userId: event.userId,
model: event.payload.model, model: event.payload.model,
status: event.payload.status, status: event.payload.status,
@@ -46,7 +51,7 @@ export function createBillingConsumerHandler(db: Database) {
fluxConsumed: event.payload.fluxConsumed, fluxConsumed: event.payload.fluxConsumed,
promptTokens: event.payload.promptTokens, promptTokens: event.payload.promptTokens,
completionTokens: event.payload.completionTokens, completionTokens: event.payload.completionTokens,
}) }).onConflictDoNothing()
logger.withFields({ logger.withFields({
eventId: event.eventId, eventId: event.eventId,
@@ -1,5 +1,7 @@
import type { InferOutput } from 'valibot' import type { InferOutput } from 'valibot'
import type { RedisCommandClient } from '../../libs/mq'
import { import {
literal, literal,
nonEmpty, nonEmpty,
@@ -13,6 +15,8 @@ import {
unknown, unknown,
} from 'valibot' } from 'valibot'
import { createMqService } from '../../libs/mq'
export const DEFAULT_BILLING_EVENTS_STREAM = 'billing-events' export const DEFAULT_BILLING_EVENTS_STREAM = 'billing-events'
const BillingEventTypeSchema = union([ const BillingEventTypeSchema = union([
@@ -128,6 +132,18 @@ export function serializeBillingEvent(event: BillingEvent): SerializedBillingEve
} }
} }
/**
* Create a Redis Stream MQ service pre-configured for billing events.
*/
export function createBillingMq(redis: RedisCommandClient, options: { stream?: string, maxLength?: number } = {}) {
return createMqService<BillingEvent>(redis, {
stream: options.stream ?? DEFAULT_BILLING_EVENTS_STREAM,
maxLength: options.maxLength,
serialize: serializeBillingEvent,
deserialize: parseBillingEvent,
})
}
export function parseBillingEvent(fields: Record<string, string | undefined>): BillingEvent { export function parseBillingEvent(fields: Record<string, string | undefined>): BillingEvent {
const payload = fields.payload const payload = fields.payload
if (payload == null) { if (payload == null) {
@@ -1,227 +0,0 @@
import type { BillingEvent } from './billing-events'
import { useLogger } from '@guiiai/logg'
import {
DEFAULT_BILLING_EVENTS_STREAM,
parseBillingEvent,
serializeBillingEvent,
} from './billing-events'
type RedisArgument = string | number
export interface RedisCommandClient {
call: (command: string, ...args: RedisArgument[]) => Promise<unknown>
}
export interface BillingMqOptions {
stream?: string
maxLength?: number
}
export interface ConsumeBillingMessagesOptions {
group: string
consumer: string
count?: number
blockMs?: number
startId?: string
}
export interface ClaimIdleBillingMessagesOptions {
group: string
consumer: string
minIdleTimeMs: number
startId?: string
count?: number
}
export interface BillingStreamMessage {
streamMessageId: string
event: BillingEvent
}
type RedisStreamEntry = [streamMessageId: string, fieldValues: string[]]
type RedisReadGroupResponse = [stream: string, entries: RedisStreamEntry[]][]
type RedisAutoClaimResponse = [nextStartId: string, entries: RedisStreamEntry[], deletedIds?: string[]]
const logger = useLogger('billing-mq').useGlobalConfig()
export function createBillingMqService(redis: RedisCommandClient, options: BillingMqOptions = {}) {
const stream = options.stream ?? DEFAULT_BILLING_EVENTS_STREAM
return {
stream,
async publish(event: BillingEvent): Promise<string> {
const serializedFields = serializeBillingEvent(event)
const xaddArgs: RedisArgument[] = [stream]
if (options.maxLength != null) {
xaddArgs.push('MAXLEN', '~', options.maxLength)
}
xaddArgs.push('*', ...toRedisFieldArguments(serializedFields))
const streamMessageId = await redis.call('XADD', ...xaddArgs)
if (typeof streamMessageId !== 'string') {
throw new TypeError('Redis XADD did not return a stream message id')
}
logger.withFields({
stream,
eventId: event.eventId,
eventType: event.eventType,
streamMessageId,
}).log('Published billing event to Redis Stream')
return streamMessageId
},
async ensureConsumerGroup(group: string, startId = '0'): Promise<boolean> {
try {
await redis.call('XGROUP', 'CREATE', stream, group, startId, 'MKSTREAM')
return true
}
catch (error) {
if (error instanceof Error && error.message.includes('BUSYGROUP')) {
return false
}
throw error
}
},
async consume(options: ConsumeBillingMessagesOptions): Promise<BillingStreamMessage[]> {
const response = await redis.call(
'XREADGROUP',
'GROUP',
options.group,
options.consumer,
'COUNT',
options.count ?? 10,
'BLOCK',
options.blockMs ?? 5_000,
'STREAMS',
stream,
options.startId ?? '>',
)
return parseReadGroupResponse(response)
},
async claimIdleMessages(options: ClaimIdleBillingMessagesOptions): Promise<BillingStreamMessage[]> {
const response = await redis.call(
'XAUTOCLAIM',
stream,
options.group,
options.consumer,
options.minIdleTimeMs,
options.startId ?? '0-0',
'COUNT',
options.count ?? 10,
)
return parseAutoClaimResponse(response)
},
async ack(group: string, streamMessageIds: string | string[]): Promise<number> {
const ids = Array.isArray(streamMessageIds) ? streamMessageIds : [streamMessageIds]
if (ids.length === 0) {
return 0
}
const acked = await redis.call('XACK', stream, group, ...ids)
if (typeof acked !== 'number') {
throw new TypeError('Redis XACK did not return an acknowledgement count')
}
return acked
},
}
}
function toRedisFieldArguments(fields: Record<string, string | undefined>): RedisArgument[] {
return Object.entries(fields)
.filter(([, value]) => value !== undefined)
.flatMap(([key, value]) => [key, value as string])
}
function parseReadGroupResponse(response: unknown): BillingStreamMessage[] {
if (response == null) {
return []
}
if (!Array.isArray(response)) {
throw new TypeError('Redis XREADGROUP returned an invalid response')
}
return response.flatMap((streamResponse) => {
if (!Array.isArray(streamResponse) || streamResponse.length !== 2) {
throw new Error('Redis XREADGROUP returned an invalid stream payload')
}
const [, entries] = streamResponse as RedisReadGroupResponse[number]
return entries.map(parseRedisStreamEntry)
})
}
function parseAutoClaimResponse(response: unknown): BillingStreamMessage[] {
if (response == null) {
return []
}
if (!Array.isArray(response) || response.length < 2) {
throw new Error('Redis XAUTOCLAIM returned an invalid response')
}
const [, entries] = response as RedisAutoClaimResponse
if (!Array.isArray(entries)) {
throw new TypeError('Redis XAUTOCLAIM returned invalid entries')
}
return entries.map(parseRedisStreamEntry)
}
function parseRedisStreamEntry(entry: unknown): BillingStreamMessage {
if (!Array.isArray(entry) || entry.length !== 2) {
throw new Error('Redis Stream entry has an invalid shape')
}
const [streamMessageId, rawFieldValues] = entry
if (typeof streamMessageId !== 'string') {
throw new TypeError('Redis Stream entry is missing a valid message id')
}
if (!Array.isArray(rawFieldValues)) {
throw new TypeError('Redis Stream entry fields are invalid')
}
const event = parseBillingEvent(toFieldRecord(rawFieldValues))
return {
streamMessageId,
event,
}
}
function toFieldRecord(fieldValues: string[]): Record<string, string> {
if (fieldValues.length % 2 !== 0) {
throw new Error('Redis Stream entry fields must be key/value pairs')
}
const fields: Record<string, string> = {}
for (let index = 0; index < fieldValues.length; index += 2) {
const key = fieldValues[index]
const value = fieldValues[index + 1]
if (typeof key !== 'string' || typeof value !== 'string') {
throw new TypeError('Redis Stream entry contains non-string field data')
}
fields[key] = value
}
return fields
}
export type BillingMqService = ReturnType<typeof createBillingMqService>
@@ -1,13 +1,13 @@
import type Redis from 'ioredis' import type Redis from 'ioredis'
import type { Database } from '../../libs/db' import type { Database } from '../../libs/db'
import type { MqService } from '../../libs/mq'
import type { RevenueMetrics } from '../../libs/otel' import type { RevenueMetrics } from '../../libs/otel'
import type { ConfigKVService } from '../config-kv' import type { ConfigKVService } from '../config-kv'
import type { BillingEvent } from './billing-events' import type { BillingEvent } from './billing-events'
import type { BillingMqService } from './billing-mq'
import { useLogger } from '@guiiai/logg' import { useLogger } from '@guiiai/logg'
import { eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { createPaymentRequiredError } from '../../utils/error' import { createPaymentRequiredError } from '../../utils/error'
import { nanoid } from '../../utils/id' import { nanoid } from '../../utils/id'
@@ -22,7 +22,7 @@ const logger = useLogger('billing-service')
export function createBillingService( export function createBillingService(
db: Database, db: Database,
redis: Redis, redis: Redis,
billingMq: BillingMqService, billingMq: MqService<BillingEvent>,
_configKV: ConfigKVService, _configKV: ConfigKVService,
metrics?: RevenueMetrics | null, metrics?: RevenueMetrics | null,
) { ) {
@@ -205,11 +205,16 @@ export function createBillingService(
fluxAmount: number fluxAmount: number
}): Promise<{ applied: boolean, balanceAfter?: number }> { }): Promise<{ applied: boolean, balanceAfter?: number }> {
const txResult = await db.transaction(async (tx) => { const txResult = await db.transaction(async (tx) => {
const record = await tx.query.stripeCheckoutSession.findFirst({ // Atomic claim: set fluxCredited = true only if currently false
where: (table, { eq }) => eq(table.stripeSessionId, input.stripeSessionId), const [claimed] = await tx.update(stripeSchema.stripeCheckoutSession)
}) .set({ fluxCredited: true, updatedAt: new Date() })
.where(and(
eq(stripeSchema.stripeCheckoutSession.stripeSessionId, input.stripeSessionId),
eq(stripeSchema.stripeCheckoutSession.fluxCredited, false),
))
.returning()
if (!record || record.fluxCredited) { if (!claimed) {
return { applied: false } return { applied: false }
} }
@@ -233,11 +238,6 @@ export function createBillingService(
.set({ flux: balanceAfter, updatedAt: new Date() }) .set({ flux: balanceAfter, updatedAt: new Date() })
.where(eq(fluxSchema.userFlux.userId, input.userId)) .where(eq(fluxSchema.userFlux.userId, input.userId))
// Mark checkout session as credited
await tx.update(stripeSchema.stripeCheckoutSession)
.set({ fluxCredited: true, updatedAt: new Date() })
.where(eq(stripeSchema.stripeCheckoutSession.stripeSessionId, input.stripeSessionId))
const description = `Stripe payment ${input.currency?.toUpperCase() ?? 'UNKNOWN'} ${(input.amountTotal / 100).toFixed(2)}` const description = `Stripe payment ${input.currency?.toUpperCase() ?? 'UNKNOWN'} ${(input.amountTotal / 100).toFixed(2)}`
// Ledger entry // Ledger entry
@@ -313,11 +313,16 @@ export function createBillingService(
fluxAmount: number fluxAmount: number
}): Promise<{ applied: boolean, balanceAfter?: number }> { }): Promise<{ applied: boolean, balanceAfter?: number }> {
const txResult = await db.transaction(async (tx) => { const txResult = await db.transaction(async (tx) => {
const record = await tx.query.stripeInvoice.findFirst({ // Atomic claim: set fluxCredited = true only if currently false
where: (table, { eq }) => eq(table.stripeInvoiceId, input.stripeInvoiceId), const [claimed] = await tx.update(stripeSchema.stripeInvoice)
}) .set({ fluxCredited: true, updatedAt: new Date() })
.where(and(
eq(stripeSchema.stripeInvoice.stripeInvoiceId, input.stripeInvoiceId),
eq(stripeSchema.stripeInvoice.fluxCredited, false),
))
.returning()
if (!record || record.fluxCredited) { if (!claimed) {
return { applied: false } return { applied: false }
} }
@@ -341,11 +346,6 @@ export function createBillingService(
.set({ flux: balanceAfter, updatedAt: new Date() }) .set({ flux: balanceAfter, updatedAt: new Date() })
.where(eq(fluxSchema.userFlux.userId, input.userId)) .where(eq(fluxSchema.userFlux.userId, input.userId))
// Mark invoice as credited
await tx.update(stripeSchema.stripeInvoice)
.set({ fluxCredited: true, updatedAt: new Date() })
.where(eq(stripeSchema.stripeInvoice.stripeInvoiceId, input.stripeInvoiceId))
const description = `Subscription invoice ${input.currency.toUpperCase()} ${(input.amountPaid / 100).toFixed(2)}` const description = `Subscription invoice ${input.currency.toUpperCase()} ${(input.amountPaid / 100).toFixed(2)}`
// Ledger entry // Ledger entry
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { createBillingMqService } from '../billing-mq' import { createBillingMq } from '../billing-events'
function createEvent() { function createEvent() {
return { return {
@@ -25,7 +25,7 @@ describe('billingMqService', () => {
call: vi.fn(async () => '1740000000000-0'), call: vi.fn(async () => '1740000000000-0'),
} }
const mq = createBillingMqService(redis, { const mq = createBillingMq(redis, {
stream: 'billing-events-test', stream: 'billing-events-test',
maxLength: 1_000, maxLength: 1_000,
}) })
@@ -62,7 +62,7 @@ describe('billingMqService', () => {
}) })
it('throws when publish does not return a stream message id', async () => { it('throws when publish does not return a stream message id', async () => {
const mq = createBillingMqService({ const mq = createBillingMq({
call: vi.fn(async () => 123), call: vi.fn(async () => 123),
}) })
@@ -70,7 +70,7 @@ describe('billingMqService', () => {
}) })
it('creates a consumer group and returns true when the group is new', async () => { it('creates a consumer group and returns true when the group is new', async () => {
const mq = createBillingMqService({ const mq = createBillingMq({
call: vi.fn(async () => 'OK'), call: vi.fn(async () => 'OK'),
}) })
@@ -78,7 +78,7 @@ describe('billingMqService', () => {
}) })
it('returns false when the consumer group already exists', async () => { it('returns false when the consumer group already exists', async () => {
const mq = createBillingMqService({ const mq = createBillingMq({
call: vi.fn(async () => { call: vi.fn(async () => {
throw new Error('BUSYGROUP Consumer Group name already exists') throw new Error('BUSYGROUP Consumer Group name already exists')
}), }),
@@ -88,7 +88,7 @@ describe('billingMqService', () => {
}) })
it('rethrows non-BUSYGROUP errors when creating a consumer group', async () => { it('rethrows non-BUSYGROUP errors when creating a consumer group', async () => {
const mq = createBillingMqService({ const mq = createBillingMq({
call: vi.fn(async () => { call: vi.fn(async () => {
throw new Error('NOAUTH') throw new Error('NOAUTH')
}), }),
@@ -98,7 +98,7 @@ describe('billingMqService', () => {
}) })
it('consumes stream entries from a consumer group', async () => { it('consumes stream entries from a consumer group', async () => {
const mq = createBillingMqService({ const mq = createBillingMq({
call: vi.fn(async () => [[ call: vi.fn(async () => [[
'billing-events', 'billing-events',
[[ [[
@@ -141,7 +141,7 @@ describe('billingMqService', () => {
}) })
it('returns an empty array when no messages are available', async () => { it('returns an empty array when no messages are available', async () => {
const mq = createBillingMqService({ const mq = createBillingMq({
call: vi.fn(async () => null), call: vi.fn(async () => null),
}) })
@@ -152,7 +152,7 @@ describe('billingMqService', () => {
}) })
it('throws when xreadgroup returns an invalid payload', async () => { it('throws when xreadgroup returns an invalid payload', async () => {
const mq = createBillingMqService({ const mq = createBillingMq({
call: vi.fn(async () => ['not-an-array-entry']), call: vi.fn(async () => ['not-an-array-entry']),
}) })
@@ -163,7 +163,7 @@ describe('billingMqService', () => {
}) })
it('claims idle pending messages', async () => { it('claims idle pending messages', async () => {
const mq = createBillingMqService({ const mq = createBillingMq({
call: vi.fn(async () => [ call: vi.fn(async () => [
'1740000000001-0', '1740000000001-0',
[[ [[
@@ -206,7 +206,7 @@ describe('billingMqService', () => {
}) })
it('throws when xautoclaim returns an invalid payload', async () => { it('throws when xautoclaim returns an invalid payload', async () => {
const mq = createBillingMqService({ const mq = createBillingMq({
call: vi.fn(async () => ['1740000000001-0']), call: vi.fn(async () => ['1740000000001-0']),
}) })
@@ -222,7 +222,7 @@ describe('billingMqService', () => {
call: vi.fn(async () => 2), call: vi.fn(async () => 2),
} }
const mq = createBillingMqService(redis) const mq = createBillingMq(redis)
await expect(mq.ack('billing', ['1-0', '2-0'])).resolves.toBe(2) await expect(mq.ack('billing', ['1-0', '2-0'])).resolves.toBe(2)
expect(redis.call).toHaveBeenCalledWith('XACK', 'billing-events', 'billing', '1-0', '2-0') expect(redis.call).toHaveBeenCalledWith('XACK', 'billing-events', 'billing', '1-0', '2-0')
}) })
@@ -232,13 +232,13 @@ describe('billingMqService', () => {
call: vi.fn(), call: vi.fn(),
} }
const mq = createBillingMqService(redis) const mq = createBillingMq(redis)
await expect(mq.ack('billing', [])).resolves.toBe(0) await expect(mq.ack('billing', [])).resolves.toBe(0)
expect(redis.call).not.toHaveBeenCalled() expect(redis.call).not.toHaveBeenCalled()
}) })
it('throws when ack does not return a number', async () => { it('throws when ack does not return a number', async () => {
const mq = createBillingMqService({ const mq = createBillingMq({
call: vi.fn(async () => '2'), call: vi.fn(async () => '2'),
}) })
@@ -1,8 +1,9 @@
import type Redis from 'ioredis' import type Redis from 'ioredis'
import type { Database } from '../../../libs/db' import type { Database } from '../../../libs/db'
import type { MqService } from '../../../libs/mq'
import type { createConfigKVService } from '../../config-kv' import type { createConfigKVService } from '../../config-kv'
import type { BillingMqService } from '../billing-mq' import type { BillingEvent } from '../billing-events'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -26,11 +27,14 @@ function createMockRedis(): Redis {
const store = new Map<string, string>() const store = new Map<string, string>()
return { return {
get: vi.fn(async (key: string) => store.get(key) ?? null), get: vi.fn(async (key: string) => store.get(key) ?? null),
set: vi.fn(async (key: string, value: string) => { store.set(key, value); return 'OK' }), set: vi.fn(async (key: string, value: string) => {
store.set(key, value)
return 'OK'
}),
} as unknown as Redis } as unknown as Redis
} }
function createMockBillingMq(): BillingMqService { function createMockBillingMq(): MqService<BillingEvent> {
return { return {
stream: 'billing-events', stream: 'billing-events',
publish: vi.fn(async () => '1-0'), publish: vi.fn(async () => '1-0'),
@@ -44,7 +48,7 @@ function createMockBillingMq(): BillingMqService {
describe('billingService', () => { describe('billingService', () => {
let db: Database let db: Database
let redis: Redis let redis: Redis
let billingMq: BillingMqService let billingMq: MqService<BillingEvent>
let billingService: ReturnType<typeof createBillingService> let billingService: ReturnType<typeof createBillingService>
beforeAll(async () => { beforeAll(async () => {