Skip to content

Commit b0dc327

Browse files
committed
fix(billing): bound cumulative usage lock holders
1 parent 0bba808 commit b0dc327

9 files changed

Lines changed: 498 additions & 76 deletions

File tree

.github/workflows/test-build.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ jobs:
8383
ee/scim/lib/managed-membership.postgres.test.ts
8484
lib/auth/sso/application/admit-sso-user.postgres.test.ts
8585
86+
- name: Verify cumulative billing timeout recovery in PostgreSQL
87+
working-directory: apps/sim
88+
env:
89+
BILLING_USAGE_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim
90+
run: bunx vitest run lib/billing/core/usage-log.postgres.test.ts
91+
8692
- name: Verify SCIM and administration over real HTTP
8793
working-directory: apps/sim
8894
env:

apps/sim/app/api/billing/update-cost/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ async function updateCostInner(req: NextRequest, span: Span): Promise<NextRespon
312312
* Every accepted callback has a stable key, so the maximum cumulative cost
313313
* converges on one ledger event without underbilling or double-billing.
314314
*/
315+
const usageStartedAt = Date.now()
315316
const result = await recordCumulativeUsage({
316317
userId,
317318
workspaceId: resolvedWorkspaceId,
@@ -330,6 +331,7 @@ async function updateCostInner(req: NextRequest, span: Span): Promise<NextRespon
330331
billedDelta: result.delta,
331332
newTotal: result.total,
332333
billed: result.billed,
334+
durationMs: Date.now() - usageStartedAt,
333335
})
334336

335337
// Reconcile the payer's ledger-backed threshold after every cumulative
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Uses a disposable schema in local PostgreSQL 17+. The paused callback models
5+
* a client that stops progressing after writing usage but before COMMIT; the
6+
* database must release its locks without waiting for that client to resume.
7+
*/
8+
import { createRequire } from 'node:module'
9+
import { getPostgresErrorCode } from '@sim/utils/errors'
10+
import { generateId } from '@sim/utils/id'
11+
import { sql } from 'drizzle-orm'
12+
import { drizzle } from 'drizzle-orm/postgres-js'
13+
import postgres from 'postgres'
14+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
15+
16+
const { databaseUrl, transaction } = vi.hoisted(() => {
17+
const databaseUrl = process.env.BILLING_USAGE_TEST_DATABASE_URL
18+
if (databaseUrl && !['localhost', '127.0.0.1', '[::1]'].includes(new URL(databaseUrl).hostname)) {
19+
throw new Error('Billing usage integration tests require a disposable local database')
20+
}
21+
return { databaseUrl, transaction: vi.fn() }
22+
})
23+
24+
vi.unmock('@sim/db/schema')
25+
vi.unmock('drizzle-orm')
26+
vi.mock('@sim/db', () => ({ db: { transaction }, dbReplica: {} }))
27+
vi.mock('@/lib/billing/core/plan', () => ({ getHighestPrioritySubscription: vi.fn() }))
28+
vi.mock('@/lib/billing/subscriptions/utils', () => ({ isOrgScopedSubscription: vi.fn() }))
29+
30+
import {
31+
CumulativeUsageContextMismatchError,
32+
type RecordCumulativeUsageParams,
33+
recordCumulativeUsage,
34+
} from '@/lib/billing/core/usage-log'
35+
36+
const require = createRequire(import.meta.url)
37+
const commonJsPostgres = require('postgres') as typeof postgres
38+
39+
const schemaName = `billing_usage_${generateId().replaceAll('-', '')}`
40+
const connection = databaseUrl
41+
? postgres(databaseUrl, {
42+
max: 8,
43+
prepare: false,
44+
fetch_types: false,
45+
connection: { search_path: schemaName },
46+
onnotice: () => undefined,
47+
})
48+
: undefined
49+
const database = connection ? drizzle(connection) : undefined
50+
51+
type Transaction = Parameters<Parameters<NonNullable<typeof database>['transaction']>[0]>[0]
52+
53+
function deferred() {
54+
let resolve: () => void = () => undefined
55+
const promise = new Promise<void>((done) => {
56+
resolve = done
57+
})
58+
return { promise, resolve }
59+
}
60+
61+
interface PausedTransaction {
62+
reached: ReturnType<typeof deferred>
63+
release: ReturnType<typeof deferred>
64+
lockOnly: boolean
65+
}
66+
67+
let nextPause: PausedTransaction | undefined
68+
69+
function pauseNextTransaction(lockOnly = false): PausedTransaction {
70+
const pause = { reached: deferred(), release: deferred(), lockOnly }
71+
nextPause = pause
72+
return pause
73+
}
74+
75+
function usage(cost: number, eventKey = 'update-cost:shared-request'): RecordCumulativeUsageParams {
76+
return {
77+
userId: 'actor',
78+
workspaceId: 'workspace',
79+
billingEntity: { type: 'organization', id: 'payer' },
80+
billingPeriod: {
81+
start: new Date('2026-09-01T00:00:00.000Z'),
82+
end: new Date('2026-10-01T00:00:00.000Z'),
83+
},
84+
source: 'workspace-chat',
85+
model: 'test-model',
86+
eventKey,
87+
cost,
88+
metadata: { inputTokens: 10, outputTokens: 5 },
89+
}
90+
}
91+
92+
async function ledgerRows() {
93+
if (!connection) throw new Error('PostgreSQL fixture is unavailable')
94+
return connection<{ event_key: string; cost: string }[]>`
95+
select event_key, cost from usage_log order by event_key
96+
`
97+
}
98+
99+
afterAll(async () => {
100+
nextPause?.release.resolve()
101+
if (connection) {
102+
await connection.unsafe(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`)
103+
await connection.end()
104+
}
105+
})
106+
107+
describe.skipIf(!databaseUrl)('Cumulative billing with PostgreSQL', () => {
108+
beforeAll(async () => {
109+
if (!connection || !database) throw new Error('PostgreSQL fixture is unavailable')
110+
const [version] =
111+
await connection`select current_setting('server_version_num')::integer as version`
112+
expect(version.version).toBeGreaterThanOrEqual(170000)
113+
await connection.unsafe(`CREATE SCHEMA "${schemaName}"`)
114+
await connection.unsafe(`
115+
CREATE TABLE usage_log (
116+
id text PRIMARY KEY, user_id text NOT NULL, category text NOT NULL,
117+
source text NOT NULL, description text NOT NULL, metadata jsonb,
118+
cost numeric NOT NULL, event_key text, billing_entity_type text,
119+
billing_entity_id text, billing_period_start timestamp, billing_period_end timestamp,
120+
workspace_id text, workflow_id text, execution_id text,
121+
created_at timestamp NOT NULL DEFAULT now()
122+
);
123+
CREATE UNIQUE INDEX usage_log_event_key_unique ON usage_log(event_key)
124+
WHERE event_key IS NOT NULL;
125+
CREATE TABLE driver_probe (id text PRIMARY KEY)
126+
`)
127+
transaction.mockImplementation(async (callback: (tx: Transaction) => Promise<unknown>) => {
128+
const pause = nextPause
129+
nextPause = undefined
130+
return database.transaction(async (tx) => {
131+
const result = await callback(tx)
132+
if (pause) {
133+
if (pause.lockOnly) {
134+
/** Reproduce the old policy: lock_timeout does not expire an idle holder. */
135+
await tx.execute(sql`select set_config('transaction_timeout', '0', true)`)
136+
}
137+
pause.reached.resolve()
138+
await pause.release.promise
139+
}
140+
return result
141+
})
142+
})
143+
})
144+
145+
beforeEach(async () => {
146+
nextPause = undefined
147+
if (!connection) throw new Error('PostgreSQL fixture is unavailable')
148+
await connection`truncate usage_log`
149+
})
150+
151+
it.each([
152+
{ name: 'ESM', create: postgres },
153+
{ name: 'CommonJS', create: commonJsPostgres },
154+
])(
155+
'rejects resumed $name transaction queries after its connection is reused',
156+
async ({ create }) => {
157+
const pool = create(databaseUrl!, {
158+
max: 1,
159+
prepare: false,
160+
fetch_types: false,
161+
connection: { search_path: schemaName },
162+
})
163+
const release = deferred()
164+
const resumed = deferred()
165+
let resumedError: unknown
166+
const holder = pool.begin(async (tx) => {
167+
await tx`select set_config('transaction_timeout', '150ms', true)`
168+
await tx`select 1`
169+
await release.promise
170+
try {
171+
await tx`insert into driver_probe (id) values (${generateId()})`
172+
} catch (error) {
173+
resumedError = error
174+
} finally {
175+
resumed.resolve()
176+
}
177+
})
178+
try {
179+
await expect(holder).rejects.toMatchObject({ code: 'CONNECTION_CLOSED' })
180+
/** max: 1 forces the underlying connection object to serve a new session. */
181+
await pool`select 1`
182+
release.resolve()
183+
await resumed.promise
184+
expect(getPostgresErrorCode(resumedError)).toBe('CONNECTION_CLOSED')
185+
const [row] = await pool`select count(*)::integer as count from driver_probe`
186+
expect(row.count).toBe(0)
187+
} finally {
188+
release.resolve()
189+
await pool.end({ timeout: 0 })
190+
}
191+
}
192+
)
193+
194+
it('reproduces a retry timing out behind an idle holder when only lock waits are bounded', async () => {
195+
const pause = pauseNextTransaction(true)
196+
const holder = recordCumulativeUsage(usage(0.4))
197+
try {
198+
await pause.reached.promise
199+
const failure = await recordCumulativeUsage(usage(0.8)).catch((error: unknown) => error)
200+
expect(getPostgresErrorCode(failure)).toBe('55P03')
201+
expect(await ledgerRows()).toEqual([])
202+
} finally {
203+
pause.release.resolve()
204+
await holder
205+
}
206+
expect(await ledgerRows()).toEqual([{ event_key: usage(0).eventKey, cost: '0.4' }])
207+
}, 10_000)
208+
209+
it.each([0, 0.2])(
210+
'expires the idle holder, rolls back its write, and recovers exactly once (initial %s)',
211+
async (initial) => {
212+
if (initial > 0) await recordCumulativeUsage(usage(initial))
213+
const pause = pauseNextTransaction()
214+
const holder = recordCumulativeUsage(usage(0.4)).catch((error: unknown) => error)
215+
try {
216+
await pause.reached.promise
217+
const failure = await recordCumulativeUsage(usage(0.8)).catch((error: unknown) => error)
218+
expect(getPostgresErrorCode(failure)).toBe('55P03')
219+
const recovered = await recordCumulativeUsage(usage(0.8))
220+
expect(recovered.billed).toBe(true)
221+
expect(recovered.delta).toBeCloseTo(0.8 - initial, 9)
222+
expect(recovered.total).toBe(0.8)
223+
expect(await recordCumulativeUsage(usage(0.8))).toEqual({
224+
billed: false,
225+
delta: 0,
226+
total: 0.8,
227+
})
228+
expect(await recordCumulativeUsage(usage(0.3))).toEqual({
229+
billed: false,
230+
delta: 0,
231+
total: 0.8,
232+
})
233+
expect(await ledgerRows()).toEqual([{ event_key: usage(0).eventKey, cost: '0.8' }])
234+
} finally {
235+
pause.release.resolve()
236+
}
237+
expect(getPostgresErrorCode(await holder)).toBe('CONNECTION_CLOSED')
238+
},
239+
12_000
240+
)
241+
242+
it('converges concurrent out-of-order callbacks and independent events to their exact totals', async () => {
243+
const costs = [0.4, 0.1, 0.8, 0.3, 0.8, 0.6]
244+
const results = await Promise.all(costs.map((cost) => recordCumulativeUsage(usage(cost))))
245+
expect(results.reduce((total, result) => total + result.delta, 0)).toBeCloseTo(0.8, 9)
246+
await Promise.all(
247+
Array.from({ length: 32 }, (_, index) =>
248+
recordCumulativeUsage(usage(0.25, `independent:${index}`))
249+
)
250+
)
251+
expect(await ledgerRows()).toHaveLength(33)
252+
expect(await recordCumulativeUsage(usage(0.8))).toEqual({ billed: false, delta: 0, total: 0.8 })
253+
})
254+
255+
it.each([0.2, 0.8])(
256+
'rejects an actor mismatch even for a non-increasing callback (%s)',
257+
async (cost) => {
258+
await recordCumulativeUsage(usage(0.8))
259+
await expect(
260+
recordCumulativeUsage({ ...usage(cost), userId: 'another-actor' })
261+
).rejects.toBeInstanceOf(CumulativeUsageContextMismatchError)
262+
expect(await ledgerRows()).toEqual([{ event_key: usage(0).eventKey, cost: '0.8' }])
263+
}
264+
)
265+
})

apps/sim/lib/billing/core/usage-log.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ describe('recordCumulativeUsage', () => {
429429
})
430430
})
431431

432-
it('bounds the advisory-lock wait and locks on the 64-bit event-key hash', async () => {
432+
it('bounds the holder lifetime and lock wait before acquiring the event-key lock', async () => {
433433
const { tx } = setupTx({ id: 'row-1', cost: '0.3474447' })
434434
await recordCumulativeUsage({
435435
userId: 'user-1',
@@ -438,7 +438,10 @@ describe('recordCumulativeUsage', () => {
438438
cost: 0.4662453,
439439
eventKey: 'update-cost:msg-1-billing',
440440
})
441+
expect(executedSqlContaining(tx, 'transaction_timeout')).toBe(true)
442+
expect(executedSqlContaining(tx, 'statement_timeout')).toBe(true)
441443
expect(executedSqlContaining(tx, 'lock_timeout')).toBe(true)
444+
expect(tx.execute.mock.calls[0][0]).toMatchObject({ values: ['4000ms', '3500ms', '3000ms'] })
442445
expect(executedSqlContaining(tx, 'pg_advisory_xact_lock')).toBe(true)
443446
expect(executedSqlContaining(tx, 'hashtextextended')).toBe(true)
444447
})

0 commit comments

Comments
 (0)