Skip to content

Commit 6f36c45

Browse files
committed
fix(billing): bound every ledger aggregate at the database and size the gate deadline for two
1 parent cbd2603 commit 6f36c45

10 files changed

Lines changed: 309 additions & 176 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { USAGE_LEDGER_STATEMENT_TIMEOUT_MS } from '@/lib/billing/constants'
6+
import { readLedgerBounded } from '@/lib/billing/core/ledger-read'
7+
import type { DbClient } from '@/lib/db/types'
8+
9+
const renderedSql = (statement: unknown) =>
10+
(statement as { toSQL: () => { sql: string } }).toSQL().sql
11+
12+
describe('readLedgerBounded', () => {
13+
const execute = vi.fn().mockResolvedValue([])
14+
const tx = { execute }
15+
const transaction = vi.fn((callback: (client: typeof tx) => Promise<unknown>) => callback(tx))
16+
const executor = { transaction } as unknown as DbClient
17+
18+
beforeEach(() => vi.clearAllMocks())
19+
20+
it('bounds the statement inside one transaction on the given client, before the read', async () => {
21+
const read = vi.fn().mockResolvedValue([{ cost: '12.5' }])
22+
await expect(readLedgerBounded(executor, read)).resolves.toEqual([{ cost: '12.5' }])
23+
expect(transaction).toHaveBeenCalledTimes(1)
24+
expect(execute.mock.calls.map(([statement]) => renderedSql(statement))).toEqual([
25+
`SET LOCAL statement_timeout = '${USAGE_LEDGER_STATEMENT_TIMEOUT_MS}ms'`,
26+
])
27+
expect(read).toHaveBeenCalledWith(tx)
28+
expect(execute.mock.invocationCallOrder[0]).toBeLessThan(read.mock.invocationCallOrder[0])
29+
})
30+
31+
it('surfaces the read failure to the caller', async () => {
32+
const failure = new Error('canceling statement due to statement timeout')
33+
await expect(readLedgerBounded(executor, () => Promise.reject(failure))).rejects.toBe(failure)
34+
})
35+
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { sql } from 'drizzle-orm'
2+
import { USAGE_LEDGER_STATEMENT_TIMEOUT_MS } from '@/lib/billing/constants'
3+
import type { DbClient, DbTransaction } from '@/lib/db/types'
4+
5+
/**
6+
* Runs one aggregate over the usage ledger in a transaction of its own, bounded by
7+
* {@link USAGE_LEDGER_STATEMENT_TIMEOUT_MS}. `SET LOCAL` scopes the bound to that transaction,
8+
* so it ends with the read and never reaches the pool. Every ledger aggregate reads through
9+
* here: a payer whose period has grown past what one statement can sum within the bound fails
10+
* at the database instead of holding a connection without limit, and a caller that admits on
11+
* the answer can size its own deadline from the bound.
12+
*/
13+
export function readLedgerBounded<T>(
14+
executor: DbClient,
15+
read: (tx: DbTransaction) => Promise<T>
16+
): Promise<T> {
17+
return executor.transaction(async (tx) => {
18+
await tx.execute(
19+
sql.raw(`SET LOCAL statement_timeout = '${USAGE_LEDGER_STATEMENT_TIMEOUT_MS}ms'`)
20+
)
21+
return read(tx)
22+
})
23+
}

‎apps/sim/lib/billing/core/usage-gate-cache.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,16 @@ export const USAGE_GATE_TTL_MS = 5 * 60 * 1000
2323

2424
/**
2525
* How long a coalesced usage read may take before its callers give up on it. The read's cost is
26-
* the ledger sum, which the database ends at {@link USAGE_LEDGER_STATEMENT_TIMEOUT_MS}; the
27-
* remainder is a few indexed lookups and the connection waits around them. The singleflight
28-
* default of 30 s exists to bound a hung producer, and a slow sum is not a hung one: given up on
29-
* early, it keeps running detached while every joined caller fails and the next caller starts a
30-
* second sum alongside it. Derived from the statement bound so the database always ends the sum
31-
* first, and the gate only gives up on a connection that never answers.
26+
* its ledger aggregates, each of which the database ends at
27+
* {@link USAGE_LEDGER_STATEMENT_TIMEOUT_MS}; at most two run in sequence (the payer's usage,
28+
* then a member's cap), and the remainder is a few indexed lookups and the connection waits
29+
* around them. The singleflight default of 30 s exists to bound a hung producer, and a slow
30+
* aggregate is not a hung one: given up on early, it keeps running detached while every joined
31+
* caller fails and the next caller starts a second one alongside it. Sized from the statement
32+
* bound so the database always ends the aggregates first, and the gate only gives up on a
33+
* connection that never answers.
3234
*/
33-
export const USAGE_GATE_SETTLE_TIMEOUT_MS = USAGE_LEDGER_STATEMENT_TIMEOUT_MS + 15_000
35+
export const USAGE_GATE_SETTLE_TIMEOUT_MS = 2 * USAGE_LEDGER_STATEMENT_TIMEOUT_MS + 15_000
3436

3537
/**
3638
* Recent gate answers, admitted and refused, with `LRUCache` supplying the TTL

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

Lines changed: 67 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ import {
3838
CUMULATIVE_COST_EPSILON,
3939
CumulativeUsageContextMismatchError,
4040
getBillingPeriodUsageCost,
41+
getBillingPeriodUsageCostByUser,
42+
getBillingPeriodUsageCostWithSourceSubset,
43+
getBillingPeriodWorkflowRunCount,
4144
getUserUsageLogs,
4245
getWorkspaceUsageLogs,
4346
recordCumulativeUsage,
@@ -557,34 +560,73 @@ describe('usage-log query scopes', () => {
557560
})
558561
})
559562

560-
describe('getBillingPeriodUsageCost', () => {
563+
describe('ledger aggregates', () => {
564+
const billingEntity = { type: 'organization' as const, id: 'org-1' }
565+
const billingPeriod = {
566+
start: new Date('2026-05-01T00:00:00Z'),
567+
end: new Date('2027-05-01T00:00:00Z'),
568+
}
569+
/** Every aggregate over the ledger, with the row the mocked read hands back and the value it yields. */
570+
const aggregates: Array<{
571+
name: string
572+
read: () => Promise<unknown>
573+
rows: unknown[]
574+
expected: unknown
575+
}> = [
576+
{
577+
name: 'getBillingPeriodUsageCost',
578+
read: () => getBillingPeriodUsageCost(billingEntity, billingPeriod),
579+
rows: [{ cost: '12.5' }],
580+
expected: 12.5,
581+
},
582+
{
583+
name: 'getBillingPeriodWorkflowRunCount',
584+
read: () => getBillingPeriodWorkflowRunCount(billingEntity, billingPeriod),
585+
rows: [{ workflowRuns: 7 }],
586+
expected: 7,
587+
},
588+
{
589+
name: 'getBillingPeriodUsageCostWithSourceSubset',
590+
read: () =>
591+
getBillingPeriodUsageCostWithSourceSubset(billingEntity, billingPeriod, ['workflow']),
592+
rows: [{ total: '20', subset: '5' }],
593+
expected: { total: 20, subset: 5 },
594+
},
595+
{
596+
name: 'getBillingPeriodUsageCostByUser',
597+
read: () => getBillingPeriodUsageCostByUser(billingEntity, billingPeriod),
598+
rows: [{ userId: 'user-1', cost: '3' }],
599+
expected: new Map([['user-1', 3]]),
600+
},
601+
]
602+
561603
beforeEach(() => {
562604
vi.clearAllMocks()
563605
installSharedDbMocks()
564606
})
565607

566-
it('bounds the ledger sum with its own statement timeout inside one transaction', async () => {
567-
const execute = vi.fn().mockResolvedValue([])
568-
const where = vi.fn().mockResolvedValue([{ cost: '12.5' }])
569-
const tx = { execute, select: vi.fn(() => ({ from: vi.fn(() => ({ where })) })) }
570-
mockTransaction.mockImplementation((callback: (client: typeof tx) => Promise<unknown>) =>
571-
callback(tx)
572-
)
573-
574-
const cost = await getBillingPeriodUsageCost(
575-
{ type: 'organization', id: 'org-1' },
576-
{ start: new Date('2026-05-01T00:00:00Z'), end: new Date('2027-05-01T00:00:00Z') }
577-
)
578-
579-
expect(cost).toBe(12.5)
580-
expect(mockTransaction).toHaveBeenCalledTimes(1)
581-
const executed = execute.mock.calls.map(
582-
([statement]) => (statement as { toSQL: () => { sql: string } }).toSQL().sql
583-
)
584-
expect(executed).toContain(
585-
`SET LOCAL statement_timeout = '${USAGE_LEDGER_STATEMENT_TIMEOUT_MS}ms'`
586-
)
587-
/** The bound is set before the sum runs, not after. */
588-
expect(execute.mock.invocationCallOrder[0]).toBeLessThan(where.mock.invocationCallOrder[0])
589-
})
608+
for (const aggregate of aggregates) {
609+
it(`${aggregate.name} reads through the bounded ledger transaction`, async () => {
610+
const execute = vi.fn().mockResolvedValue([])
611+
const terminal = vi.fn().mockResolvedValue(aggregate.rows)
612+
const chain: Record<string, unknown> = {}
613+
for (const step of ['select', 'from', 'where', 'leftJoin']) chain[step] = vi.fn(() => chain)
614+
chain.groupBy = terminal
615+
chain.then = (resolve: (rows: unknown[]) => unknown) => terminal().then(resolve)
616+
const tx = { execute, select: chain.select }
617+
mockTransaction.mockImplementation((callback: (client: typeof tx) => Promise<unknown>) =>
618+
callback(tx)
619+
)
620+
621+
await expect(aggregate.read()).resolves.toEqual(aggregate.expected)
622+
expect(mockTransaction).toHaveBeenCalledTimes(1)
623+
expect(
624+
execute.mock.calls.map(
625+
([statement]) => (statement as { toSQL: () => { sql: string } }).toSQL().sql
626+
)
627+
).toEqual([`SET LOCAL statement_timeout = '${USAGE_LEDGER_STATEMENT_TIMEOUT_MS}ms'`])
628+
/** The bound is set before the aggregate runs, not after. */
629+
expect(execute.mock.invocationCallOrder[0]).toBeLessThan(terminal.mock.invocationCallOrder[0])
630+
})
631+
}
590632
})

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

Lines changed: 67 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import {
1515
textKey,
1616
timestampKey,
1717
} from '@/lib/api/list-query'
18-
import { USAGE_LEDGER_STATEMENT_TIMEOUT_MS } from '@/lib/billing/constants'
1918
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
19+
import { readLedgerBounded } from '@/lib/billing/core/ledger-read'
2020
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
2121
import {
2222
resolveSubscriptionUsagePeriod,
@@ -216,10 +216,6 @@ async function resolveBillingContext(
216216
/**
217217
* Returns attributed ledger usage for a billing entity/period. The ledger is
218218
* the sole source of truth for usage — there is no userStats baseline.
219-
*
220-
* The sum runs in a transaction of its own on the given client so that it can
221-
* be bounded by {@link USAGE_LEDGER_STATEMENT_TIMEOUT_MS} for that statement
222-
* alone: `SET LOCAL` ends with the transaction and never reaches the pool.
223219
*/
224220
export async function getBillingPeriodUsageCost(
225221
billingEntity: BillingEntity,
@@ -243,17 +239,14 @@ export async function getBillingPeriodUsageCost(
243239
)
244240
}
245241

246-
const [row] = await executor.transaction(async (tx) => {
247-
await tx.execute(
248-
sql.raw(`SET LOCAL statement_timeout = '${USAGE_LEDGER_STATEMENT_TIMEOUT_MS}ms'`)
249-
)
250-
return tx
242+
const [row] = await readLedgerBounded(executor, (tx) =>
243+
tx
251244
.select({
252245
cost: sql<string>`COALESCE(SUM(${usageLog.cost}), 0)`,
253246
})
254247
.from(usageLog)
255248
.where(and(...conditions))
256-
})
249+
)
257250

258251
return Number.parseFloat(row?.cost ?? '0')
259252
}
@@ -274,36 +267,38 @@ export async function getBillingPeriodWorkflowRunCount(
274267
billingPeriod: UsageQueryPeriod,
275268
executor: DbClient = db
276269
): Promise<number> {
277-
const [row] = await executor
278-
.select({
279-
/**
280-
* The exclusion goes through `notInArray`, not `<> ALL(${array})`. Interpolating
281-
* a JavaScript array into a `sql` template emits parenthesized scalar binds —
282-
* `ALL(($1))` — which Postgres rejects outright with "op ANY/ALL (array)
283-
* requires array on right side". Unit tests cannot catch it, because `@sim/db`
284-
* is mocked and no statement is ever rendered.
285-
*/
286-
workflowRuns:
287-
sql<number>`COUNT(DISTINCT ${usageLog.executionId}) FILTER (WHERE ${usageLog.source} = 'workflow' AND ${notInArray(usageLog.category, [...UNBILLED_USAGE_CATEGORIES])})`.mapWith(
288-
Number
289-
),
290-
})
291-
.from(usageLog)
292-
.where(
293-
and(
294-
eq(usageLog.billingEntityType, billingEntity.type),
295-
eq(usageLog.billingEntityId, billingEntity.id),
296-
...(billingPeriod.source === 'reporting'
297-
? [
298-
gte(usageLog.createdAt, billingPeriod.start),
299-
lt(usageLog.createdAt, billingPeriod.end),
300-
]
301-
: [
302-
eq(usageLog.billingPeriodStart, billingPeriod.start),
303-
eq(usageLog.billingPeriodEnd, billingPeriod.end),
304-
])
270+
const [row] = await readLedgerBounded(executor, (tx) =>
271+
tx
272+
.select({
273+
/**
274+
* The exclusion goes through `notInArray`, not `<> ALL(${array})`. Interpolating
275+
* a JavaScript array into a `sql` template emits parenthesized scalar binds —
276+
* `ALL(($1))` — which Postgres rejects outright with "op ANY/ALL (array)
277+
* requires array on right side". Unit tests cannot catch it, because `@sim/db`
278+
* is mocked and no statement is ever rendered.
279+
*/
280+
workflowRuns:
281+
sql<number>`COUNT(DISTINCT ${usageLog.executionId}) FILTER (WHERE ${usageLog.source} = 'workflow' AND ${notInArray(usageLog.category, [...UNBILLED_USAGE_CATEGORIES])})`.mapWith(
282+
Number
283+
),
284+
})
285+
.from(usageLog)
286+
.where(
287+
and(
288+
eq(usageLog.billingEntityType, billingEntity.type),
289+
eq(usageLog.billingEntityId, billingEntity.id),
290+
...(billingPeriod.source === 'reporting'
291+
? [
292+
gte(usageLog.createdAt, billingPeriod.start),
293+
lt(usageLog.createdAt, billingPeriod.end),
294+
]
295+
: [
296+
eq(usageLog.billingPeriodStart, billingPeriod.start),
297+
eq(usageLog.billingPeriodEnd, billingPeriod.end),
298+
])
299+
)
305300
)
306-
)
301+
)
307302

308303
return row?.workflowRuns ?? 0
309304
}
@@ -321,27 +316,29 @@ export async function getBillingPeriodUsageCostWithSourceSubset(
321316
source: UsageLogSource[],
322317
executor: DbClient = db
323318
): Promise<{ total: number; subset: number }> {
324-
const [row] = await executor
325-
.select({
326-
total: sql<string>`COALESCE(SUM(${usageLog.cost}), 0)`,
327-
subset: sql<string>`COALESCE(SUM(${usageLog.cost}) FILTER (WHERE ${inArray(usageLog.source, source)}), 0)`,
328-
})
329-
.from(usageLog)
330-
.where(
331-
and(
332-
eq(usageLog.billingEntityType, billingEntity.type),
333-
eq(usageLog.billingEntityId, billingEntity.id),
334-
...(billingPeriod.source === 'reporting'
335-
? [
336-
gte(usageLog.createdAt, billingPeriod.start),
337-
lt(usageLog.createdAt, billingPeriod.end),
338-
]
339-
: [
340-
eq(usageLog.billingPeriodStart, billingPeriod.start),
341-
eq(usageLog.billingPeriodEnd, billingPeriod.end),
342-
])
319+
const [row] = await readLedgerBounded(executor, (tx) =>
320+
tx
321+
.select({
322+
total: sql<string>`COALESCE(SUM(${usageLog.cost}), 0)`,
323+
subset: sql<string>`COALESCE(SUM(${usageLog.cost}) FILTER (WHERE ${inArray(usageLog.source, source)}), 0)`,
324+
})
325+
.from(usageLog)
326+
.where(
327+
and(
328+
eq(usageLog.billingEntityType, billingEntity.type),
329+
eq(usageLog.billingEntityId, billingEntity.id),
330+
...(billingPeriod.source === 'reporting'
331+
? [
332+
gte(usageLog.createdAt, billingPeriod.start),
333+
lt(usageLog.createdAt, billingPeriod.end),
334+
]
335+
: [
336+
eq(usageLog.billingPeriodStart, billingPeriod.start),
337+
eq(usageLog.billingPeriodEnd, billingPeriod.end),
338+
])
339+
)
343340
)
344-
)
341+
)
345342

346343
return {
347344
total: Number.parseFloat(row?.total ?? '0'),
@@ -377,14 +374,16 @@ export async function getBillingPeriodUsageCostByUser(
377374
}
378375
if (userIds) conditions.push(inArray(usageLog.userId, [...userIds]))
379376

380-
const rows = await executor
381-
.select({
382-
userId: usageLog.userId,
383-
cost: sql<string>`COALESCE(SUM(${usageLog.cost}), 0)`,
384-
})
385-
.from(usageLog)
386-
.where(and(...conditions))
387-
.groupBy(usageLog.userId)
377+
const rows = await readLedgerBounded(executor, (tx) =>
378+
tx
379+
.select({
380+
userId: usageLog.userId,
381+
cost: sql<string>`COALESCE(SUM(${usageLog.cost}), 0)`,
382+
})
383+
.from(usageLog)
384+
.where(and(...conditions))
385+
.groupBy(usageLog.userId)
386+
)
388387

389388
return new Map(rows.map((row) => [row.userId, Number.parseFloat(row.cost ?? '0')]))
390389
}

0 commit comments

Comments
 (0)