refactor(server): split mq libs, organize billing services structure
This commit is contained in:
+11
-10
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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>>
|
||||||
+5
-5
@@ -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',
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
+15
-18
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user