Skip to content

Commit 237c22b

Browse files
committed
fix(billing): bound the ledger sum at the database and derive the gate deadline from it
1 parent fbc87b5 commit 237c22b

5 files changed

Lines changed: 70 additions & 19 deletions

File tree

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,9 +183,7 @@ describe('checkExecutionUsageLimits', () => {
183183
it('waits for a slow ledger read past the singleflight default instead of blocking', async () => {
184184
vi.useFakeTimers()
185185
try {
186-
mockCheck.mockReturnValueOnce(
187-
new Promise((resolve) => setTimeout(() => resolve({ isExceeded: false }), 45_000))
188-
)
186+
mockCheck.mockImplementationOnce(() => sleep(45_000).then(() => ({ isExceeded: false })))
189187
const pending = checkExecutionUsageLimits(ATTRIBUTION)
190188
await vi.advanceTimersByTimeAsync(45_000)
191189
await expect(pending).resolves.toEqual({ isExceeded: false })

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

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type BillingAttributionSnapshot,
55
checkAttributedUsageLimits,
66
} from '@/lib/billing/core/billing-attribution'
7+
import { USAGE_LEDGER_STATEMENT_TIMEOUT_MS } from '@/lib/billing/core/usage-log'
78
import { coalesceLocally } from '@/lib/concurrency/singleflight'
89

910
/**
@@ -21,17 +22,15 @@ import { coalesceLocally } from '@/lib/concurrency/singleflight'
2122
export const USAGE_GATE_TTL_MS = 5 * 60 * 1000
2223

2324
/**
24-
* How long a coalesced ledger read may take before its callers give up on it. The read sums a
25-
* payer's ledger for the billing period, which for a large organization is millions of rows and,
26-
* from a cold cache or under heavy I/O, takes longer than the singleflight default of 30 s. That
27-
* default exists to bound a hung producer, and a slow read is not a hung one: the database bounds
28-
* every statement with its own timeout, after which the read fails on its own and the failure is
29-
* reported rather than cached. The deadline therefore sits above any statement ceiling the
30-
* deployment applies, so only a connection that never answers is given up on. A shorter deadline
31-
* fails the callers while the read is still running, and the next caller starts a second read
32-
* of the same ledger alongside it.
25+
* 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.
3332
*/
34-
export const USAGE_GATE_SETTLE_TIMEOUT_MS = 120_000
33+
export const USAGE_GATE_SETTLE_TIMEOUT_MS = USAGE_LEDGER_STATEMENT_TIMEOUT_MS + 15_000
3534

3635
/**
3736
* Recent gate answers, admitted and refused, with `LRUCache` supplying the TTL

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,15 @@ vi.mock('@/lib/billing/subscriptions/utils', () => ({
3636
import {
3737
CUMULATIVE_COST_EPSILON,
3838
CumulativeUsageContextMismatchError,
39+
getBillingPeriodUsageCost,
3940
getUserUsageLogs,
4041
getWorkspaceUsageLogs,
4142
recordCumulativeUsage,
4243
recordUsage,
4344
resolveCumulativeTopUp,
4445
UNKNOWN_CURSOR_MESSAGE,
4546
UnknownUsageCursorError,
47+
USAGE_LEDGER_STATEMENT_TIMEOUT_MS,
4648
} from '@/lib/billing/core/usage-log'
4749
import { asOrchestrationError } from '@/lib/core/orchestration/types'
4850
import { HttpError } from '@/lib/core/utils/http-error'
@@ -554,3 +556,35 @@ describe('usage-log query scopes', () => {
554556
})
555557
})
556558
})
559+
560+
describe('getBillingPeriodUsageCost', () => {
561+
beforeEach(() => {
562+
vi.clearAllMocks()
563+
installSharedDbMocks()
564+
})
565+
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+
})
590+
})

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

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -212,9 +212,22 @@ async function resolveBillingContext(
212212
}
213213
}
214214

215+
/**
216+
* Bound on one ledger sum. A large payer's period covers millions of rows, and from a cold
217+
* cache or under heavy I/O the sum can run for tens of seconds; past this the database ends it
218+
* and the read fails, so a caller that admits on the sum fails closed rather than waiting
219+
* without limit. The usage gate derives its coalescing deadline from this bound, so the sum
220+
* always ends at the database before the gate gives up on it.
221+
*/
222+
export const USAGE_LEDGER_STATEMENT_TIMEOUT_MS = 60_000
223+
215224
/**
216225
* Returns attributed ledger usage for a billing entity/period. The ledger is
217226
* the sole source of truth for usage — there is no userStats baseline.
227+
*
228+
* The sum runs in a transaction of its own on the given client so that it can
229+
* be bounded by {@link USAGE_LEDGER_STATEMENT_TIMEOUT_MS} for that statement
230+
* alone: `SET LOCAL` ends with the transaction and never reaches the pool.
218231
*/
219232
export async function getBillingPeriodUsageCost(
220233
billingEntity: BillingEntity,
@@ -238,12 +251,17 @@ export async function getBillingPeriodUsageCost(
238251
)
239252
}
240253

241-
const [row] = await executor
242-
.select({
243-
cost: sql<string>`COALESCE(SUM(${usageLog.cost}), 0)`,
244-
})
245-
.from(usageLog)
246-
.where(and(...conditions))
254+
const [row] = await executor.transaction(async (tx) => {
255+
await tx.execute(
256+
sql.raw(`SET LOCAL statement_timeout = '${USAGE_LEDGER_STATEMENT_TIMEOUT_MS}ms'`)
257+
)
258+
return tx
259+
.select({
260+
cost: sql<string>`COALESCE(SUM(${usageLog.cost}), 0)`,
261+
})
262+
.from(usageLog)
263+
.where(and(...conditions))
264+
})
247265

248266
return Number.parseFloat(row?.cost ?? '0')
249267
}

‎apps/sim/lib/billing/enterprise-provisioning.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,8 @@ describe('Enterprise issuance preflight', () => {
199199
queueTableRows(schemaMock.workspace, [])
200200
queueTableRows(schemaMock.workspace, [])
201201
queueTableRows(schemaMock.subscription, [])
202+
/** The run count resolves first; the ledger sum opens its bounded transaction before it reads. */
203+
queueTableRows(schemaMock.usageLog, [{ workflowRuns: 0 }])
202204
queueTableRows(schemaMock.usageLog, [{ cost: '150' }])
203205

204206
const result = await getEnterpriseIssuancePreflight({

0 commit comments

Comments
 (0)