Skip to content

Commit c28366c

Browse files
authored
fix(billing): bound every ledger aggregate at the database and size the gate deadline for two (#8148)
* fix(billing): bound every ledger aggregate at the database and size the gate deadline for two * fix(billing): bound the cycle-close per-user sum and state the helper's scope precisely
1 parent cae7142 commit c28366c

10 files changed

Lines changed: 332 additions & 184 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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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 a payer's 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 sum over a payer's billing period
9+
* reads through here, whether it admits a run, closes a cycle or previews a bill: a payer whose
10+
* period has grown past what one statement can sum within the bound fails at the database
11+
* instead of holding a connection without limit, and a caller that admits on the answer can
12+
* size its own deadline from the bound. Reads keyed to one execution or one stamped period
13+
* boundary, and the platform-wide admin analytics, are not period sums and read directly.
14+
*/
15+
export function readLedgerBounded<T>(
16+
executor: DbClient,
17+
read: (tx: DbTransaction) => Promise<T>
18+
): Promise<T> {
19+
return executor.transaction(async (tx) => {
20+
await tx.execute(
21+
sql.raw(`SET LOCAL statement_timeout = '${USAGE_LEDGER_STATEMENT_TIMEOUT_MS}ms'`)
22+
)
23+
return read(tx)
24+
})
25+
}

‎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: 78 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ import {
3838
CUMULATIVE_COST_EPSILON,
3939
CumulativeUsageContextMismatchError,
4040
getBillingPeriodUsageCost,
41+
getBillingPeriodUsageCostByUser,
42+
getBillingPeriodUsageCostWithSourceSubset,
43+
getBillingPeriodWorkflowRunCount,
44+
getStampedPeriodRangeUsageCostByUser,
4145
getUserUsageLogs,
4246
getWorkspaceUsageLogs,
4347
recordCumulativeUsage,
@@ -557,34 +561,83 @@ describe('usage-log query scopes', () => {
557561
})
558562
})
559563

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

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-
})
619+
for (const aggregate of aggregates) {
620+
it(`${aggregate.name} reads through the bounded ledger transaction`, async () => {
621+
const execute = vi.fn().mockResolvedValue([])
622+
const terminal = vi.fn().mockResolvedValue(aggregate.rows)
623+
const chain: Record<string, unknown> = {}
624+
for (const step of ['select', 'from', 'where', 'leftJoin']) chain[step] = vi.fn(() => chain)
625+
chain.groupBy = terminal
626+
chain.then = (resolve: (rows: unknown[]) => unknown) => terminal().then(resolve)
627+
const tx = { execute, select: chain.select }
628+
mockTransaction.mockImplementation((callback: (client: typeof tx) => Promise<unknown>) =>
629+
callback(tx)
630+
)
631+
632+
await expect(aggregate.read()).resolves.toEqual(aggregate.expected)
633+
expect(mockTransaction).toHaveBeenCalledTimes(1)
634+
expect(
635+
execute.mock.calls.map(
636+
([statement]) => (statement as { toSQL: () => { sql: string } }).toSQL().sql
637+
)
638+
).toEqual([`SET LOCAL statement_timeout = '${USAGE_LEDGER_STATEMENT_TIMEOUT_MS}ms'`])
639+
/** The bound is set before the aggregate runs, not after. */
640+
expect(execute.mock.invocationCallOrder[0]).toBeLessThan(terminal.mock.invocationCallOrder[0])
641+
})
642+
}
590643
})

0 commit comments

Comments
 (0)