Skip to content

Commit c41323b

Browse files
authored
fix(billing): re-read the usage gate often once a payer nears its limit (#9091)
* fix(billing): re-read the usage gate often once a payer nears its limit The usage gate cached a within-limit admission for five minutes, so a payer who crossed the limit kept running executions and copilot steps for up to that long. On a free plan that is unbilled spend; on a capped plan it bills past the cap. An admission is now held for 10s when the payer or the acting member has less headroom than the larger of $25 and 20% of the limit (every free-plan payer), and for the full five minutes otherwise. The direct-v1 account verdict cache follows the same rule. * test(billing): drive the gate-cache clock with faked timers instead of real waits
1 parent 44b824a commit c41323b

7 files changed

Lines changed: 191 additions & 26 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,8 @@ function invalidBillingProtocolResponse(requestId: string, span: Span): NextResp
8282
* once it crosses the limit instead of at its next admission, with the card the worker writes
8383
* to its log. The payer is the attributed run's, or the one a direct-v1 run was admitted with.
8484
* A duplicate callback answers too: it is often a retry whose first answer was lost. An
85-
* admission is cached per payer and actor for the gate TTL and a refusal is always re-read, so
86-
* steady-state steps cost no ledger read. The charge is
85+
* admission is cached per payer and actor (briefly once near the limit) and a refusal is always
86+
* re-read, so most steps cost no ledger read. The charge is
8787
* already recorded when this runs; a gate that cannot answer reports not-exceeded and leaves the
8888
* refusal to the next step or re-check rather than ending a paying run on a database blip,
8989
* and so does a verdict read that outlasts {@link USAGE_STANDING_TIMEOUT_MS}. An exceeded

‎apps/sim/app/api/copilot/api-keys/validate/route.test.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
schemaMock,
77
setEnvFlags,
88
} from '@sim/testing'
9+
import { createRouteContext } from '@sim/testing/helpers/http'
910
import { billingCoreMock, billingCoreMockFns } from '@sim/testing/mocks/billing-core.mock'
1011
import { billingPlanMock, billingPlanMockFns } from '@sim/testing/mocks/billing-plan.mock'
1112
import {
@@ -145,7 +146,10 @@ import {
145146
validateCopilotApiKeyContract,
146147
} from '@/lib/api/contracts/copilot'
147148
import { resetMidRunUsageCaches } from '@/lib/billing/core/mid-run-usage'
148-
import { resetUsageGateCache } from '@/lib/billing/core/usage-gate-cache'
149+
import {
150+
NEAR_LIMIT_USAGE_GATE_TTL_MS,
151+
resetUsageGateCache,
152+
} from '@/lib/billing/core/usage-gate-cache'
149153
import { POST } from '@/app/api/copilot/api-keys/validate/route'
150154

151155
const { mockGetWorkspaceBillingSettings } = workspacesUtilsMockFns
@@ -629,6 +633,25 @@ describe('validation lifecycle purposes', () => {
629633
})
630634
})
631635

636+
it('re-reads a direct-v1 account near its limit, so a continuation past it is refused', async () => {
637+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
638+
try {
639+
mockCheckUsageStatus.mockResolvedValue({ isExceeded: false, currentUsage: 4.2, limit: 5 })
640+
expect((await POST(request(body, directHeaders), createRouteContext({}))).status).toBe(200)
641+
642+
mockCheckUsageStatus.mockResolvedValue({ isExceeded: true, currentUsage: 5.4, limit: 5 })
643+
/** `lru-cache` captures `performance` at load: spy its clock, then fire its 1 ms debounce. */
644+
const start = performance.now()
645+
vi.spyOn(performance, 'now').mockReturnValue(start + NEAR_LIMIT_USAGE_GATE_TTL_MS + 1)
646+
await vi.advanceTimersByTimeAsync(1)
647+
648+
expect((await POST(request(body, directHeaders), createRouteContext({}))).status).toBe(402)
649+
} finally {
650+
vi.runOnlyPendingTimers()
651+
vi.useRealTimers()
652+
}
653+
})
654+
632655
it('admits a direct-v1 continuation whose usage cannot be read', async () => {
633656
mockCheckUsageStatus.mockResolvedValue({ isExceeded: true, unavailable: true })
634657
expect((await POST(request(body, directHeaders))).status).toBe(200)

‎apps/sim/lib/billing/core/mid-run-usage.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
checkExecutionUsageLimits,
1818
USAGE_GATE_SETTLE_TIMEOUT_MS,
1919
USAGE_GATE_TTL_MS,
20+
usageAdmissionTtlMs,
2021
} from '@/lib/billing/core/usage-gate-cache'
2122
import type { UsageUpgradePayer } from '@/lib/billing/usage-upgrade'
2223
import { coalesceLocally } from '@/lib/concurrency/singleflight'
@@ -141,7 +142,7 @@ export async function readMidRunUsageVerdict(
141142
}
142143

143144
/**
144-
* Admitted direct-v1 verdicts, served for the execution gate's TTL like
145+
* Admitted direct-v1 verdicts, served for {@link usageAdmissionTtlMs} like
145146
* {@link checkExecutionUsageLimits} serves attributed ones: the worker re-validates a run on
146147
* every resume leg, and each uncached read sums the payer's ledger for the period. Only a
147148
* `within` verdict is stored, so a refusal or an unreadable ledger is always read again.
@@ -227,7 +228,7 @@ export async function readMidRunAccountUsageVerdict(
227228
}
228229
}
229230
const within: MidRunUsageVerdict = { status: 'within' }
230-
accountVerdictCache.set(key, within)
231+
accountVerdictCache.set(key, within, { ttl: usageAdmissionTtlMs(usage) })
231232
return within
232233
} catch (error) {
233234
logger.warn('Mid-run account usage read failed; continuing the run', {

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

Lines changed: 107 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
billingAttributionMockFns,
66
} from '@sim/testing/mocks/billing-attribution.mock'
77
import { sleep } from '@sim/utils/helpers'
8-
import { beforeEach, describe, expect, it, vi } from 'vitest'
8+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
99

1010
vi.mock('@/lib/billing/core/attribution', () => billingAttributionMock)
1111

@@ -14,13 +14,38 @@ import {
1414
checkExecutionUsageLimits,
1515
checkIngestionUsageLimits,
1616
checkSearchUsageLimits,
17+
NEAR_LIMIT_USAGE_GATE_TTL_MS,
1718
resetUsageGateCache,
1819
USAGE_GATE_SETTLE_TIMEOUT_MS,
1920
USAGE_GATE_TTL_MS,
2021
} from '@/lib/billing/core/usage-gate-cache'
2122

2223
const mockCheck = billingAttributionMockFns.mockCheckAttributedUsageLimits
2324

25+
/** Fakes `setTimeout` for {@link advanceClockPast}; install it before the cache is first read. */
26+
function useFakeTimeouts(): void {
27+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
28+
}
29+
30+
/**
31+
* Fires the cache's pending clock debounce before restoring real timers; dropped unfired, it would
32+
* pin the module-level cache to this test's spied clock for every later test.
33+
*/
34+
function restoreRealTimers(): void {
35+
vi.runOnlyPendingTimers()
36+
vi.useRealTimers()
37+
}
38+
39+
/**
40+
* `lru-cache` captures `performance` at load, so fake timers cannot move its clock: spy the clock
41+
* forward, then fire the faked 1 ms timer behind which it debounces `performance.now()`.
42+
*/
43+
async function advanceClockPast(ms: number): Promise<void> {
44+
const start = performance.now()
45+
vi.spyOn(performance, 'now').mockReturnValue(start + ms + 1)
46+
await vi.advanceTimersByTimeAsync(1)
47+
}
48+
2449
const ATTRIBUTION: BillingAttributionSnapshot = {
2550
actorUserId: 'member-1',
2651
workspaceId: null,
@@ -74,17 +99,19 @@ describe('checkIngestionUsageLimits', () => {
7499
})
75100

76101
it('keeps a refusal for the same bounded window, then reads fresh', async () => {
77-
mockCheck.mockResolvedValueOnce({ isExceeded: true, scope: 'payer', message: 'over' })
78-
expect((await checkIngestionUsageLimits(ATTRIBUTION)).isExceeded).toBe(true)
79-
expect((await checkIngestionUsageLimits(ATTRIBUTION)).isExceeded).toBe(true)
80-
expect(mockCheck).toHaveBeenCalledTimes(1)
102+
useFakeTimeouts()
103+
try {
104+
mockCheck.mockResolvedValueOnce({ isExceeded: true, scope: 'payer', message: 'over' })
105+
expect((await checkIngestionUsageLimits(ATTRIBUTION)).isExceeded).toBe(true)
106+
expect((await checkIngestionUsageLimits(ATTRIBUTION)).isExceeded).toBe(true)
107+
expect(mockCheck).toHaveBeenCalledTimes(1)
81108

82-
/** `lru-cache` reads `performance.now()` and debounces it behind a real 1 ms timer. */
83-
const start = performance.now()
84-
vi.spyOn(performance, 'now').mockReturnValue(start + USAGE_GATE_TTL_MS + 1)
85-
await sleep(5)
86-
expect((await checkIngestionUsageLimits(ATTRIBUTION)).isExceeded).toBe(false)
87-
expect(mockCheck).toHaveBeenCalledTimes(2)
109+
await advanceClockPast(USAGE_GATE_TTL_MS)
110+
expect((await checkIngestionUsageLimits(ATTRIBUTION)).isExceeded).toBe(false)
111+
expect(mockCheck).toHaveBeenCalledTimes(2)
112+
} finally {
113+
restoreRealTimers()
114+
}
88115
})
89116

90117
it('separates answers by actor, period, period source, payer and plan', async () => {
@@ -212,3 +239,72 @@ describe('checkExecutionUsageLimits', () => {
212239
}
213240
})
214241
})
242+
243+
describe('admission lifetime by headroom', () => {
244+
beforeEach(() => {
245+
useFakeTimeouts()
246+
resetUsageGateCache()
247+
mockCheck.mockReset()
248+
})
249+
250+
afterEach(() => {
251+
restoreRealTimers()
252+
})
253+
254+
it('re-reads a free-plan admission after the near-limit window, so spend past the limit is refused', async () => {
255+
mockCheck
256+
.mockResolvedValueOnce({ isExceeded: false, payerUsage: { currentUsage: 1, limit: 5 } })
257+
.mockResolvedValueOnce({
258+
isExceeded: true,
259+
scope: 'payer',
260+
message: 'over',
261+
payerUsage: { currentUsage: 5.4, limit: 5 },
262+
})
263+
expect((await checkExecutionUsageLimits(ATTRIBUTION)).isExceeded).toBe(false)
264+
expect((await checkExecutionUsageLimits(ATTRIBUTION)).isExceeded).toBe(false)
265+
expect(mockCheck).toHaveBeenCalledTimes(1)
266+
267+
await advanceClockPast(NEAR_LIMIT_USAGE_GATE_TTL_MS)
268+
expect((await checkExecutionUsageLimits(ATTRIBUTION)).isExceeded).toBe(true)
269+
expect(mockCheck).toHaveBeenCalledTimes(2)
270+
})
271+
272+
it('re-reads a large pool in the last fifth of its limit', async () => {
273+
mockCheck.mockResolvedValue({
274+
isExceeded: false,
275+
payerUsage: { currentUsage: 900, limit: 1000 },
276+
})
277+
await checkExecutionUsageLimits(ATTRIBUTION)
278+
await advanceClockPast(NEAR_LIMIT_USAGE_GATE_TTL_MS)
279+
await checkExecutionUsageLimits(ATTRIBUTION)
280+
expect(mockCheck).toHaveBeenCalledTimes(2)
281+
})
282+
283+
it('re-reads when the acting member is near their cap though the pool has room', async () => {
284+
mockCheck.mockResolvedValue({
285+
isExceeded: false,
286+
payerUsage: { currentUsage: 100, limit: 1000 },
287+
memberUsage: { currentUsage: 48, limit: 50 },
288+
})
289+
await checkSearchUsageLimits(ATTRIBUTION)
290+
await advanceClockPast(NEAR_LIMIT_USAGE_GATE_TTL_MS)
291+
await checkSearchUsageLimits(ATTRIBUTION)
292+
expect(mockCheck).toHaveBeenCalledTimes(2)
293+
})
294+
295+
it('holds an admission with ample headroom for the full TTL', async () => {
296+
mockCheck.mockResolvedValue({
297+
isExceeded: false,
298+
payerUsage: { currentUsage: 100, limit: 1000 },
299+
memberUsage: { currentUsage: 10, limit: null },
300+
})
301+
await checkExecutionUsageLimits(ATTRIBUTION)
302+
await advanceClockPast(NEAR_LIMIT_USAGE_GATE_TTL_MS)
303+
await checkExecutionUsageLimits(ATTRIBUTION)
304+
expect(mockCheck).toHaveBeenCalledTimes(1)
305+
306+
await advanceClockPast(USAGE_GATE_TTL_MS)
307+
await checkExecutionUsageLimits(ATTRIBUTION)
308+
expect(mockCheck).toHaveBeenCalledTimes(2)
309+
})
310+
})

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

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,50 @@ import { coalesceLocally } from '@/lib/concurrency/singleflight'
1313
* The gate sums the payer's usage ledger for the billing period, which grows
1414
* with the payer's activity, so a large organization scans its whole period on
1515
* every uncached call. Bulk ingestion re-checks per document and knowledge
16-
* search checks per query. Staleness is bounded by this TTL and fails in the
17-
* harmless direction: a payer who crosses their limit keeps going for at most
18-
* this long, which charges nobody wrongly. Five minutes: the sum is a few
19-
* hundred milliseconds for a busy payer, and a minute made every search after
20-
* a pause pay it.
16+
* search checks per query. A payer who crosses their limit keeps going for at
17+
* most this long, so only an admission with ample headroom is held this long
18+
* (see {@link usageAdmissionTtlMs}). Five minutes: the sum is a few hundred
19+
* milliseconds for a busy payer, and a minute made every search after a pause
20+
* pay it.
2121
*/
2222
export const USAGE_GATE_TTL_MS = 5 * 60 * 1000
2323

24+
/**
25+
* How long an admission near its limit stays usable. Every spend landing
26+
* inside this window is spend past the limit nobody bills (a free plan) or
27+
* past a cap the payer set, so it is short; it stays above zero so a burst of
28+
* executions or model steps from one payer shares a read instead of summing
29+
* the ledger on each. Staleness is this plus any lag in the usage read itself.
30+
*/
31+
export const NEAR_LIMIT_USAGE_GATE_TTL_MS = 10 * 1000
32+
33+
/**
34+
* An admission is near its limit when the remaining headroom is under the
35+
* larger of this floor and {@link NEAR_LIMIT_HEADROOM_SHARE} of the limit. The
36+
* floor puts every free-plan payer permanently near its limit; the share keeps
37+
* a large pool from caching its last stretch.
38+
*/
39+
const NEAR_LIMIT_HEADROOM_FLOOR_USD = 25
40+
const NEAR_LIMIT_HEADROOM_SHARE = 0.2
41+
42+
type UsageStanding = NonNullable<AttributedUsageLimitsResult['memberUsage']>
43+
44+
function isNearLimit(usage: UsageStanding | undefined): boolean {
45+
if (!usage || usage.limit === null) return false
46+
const headroom = usage.limit - usage.currentUsage
47+
return headroom < Math.max(NEAR_LIMIT_HEADROOM_FLOOR_USD, usage.limit * NEAR_LIMIT_HEADROOM_SHARE)
48+
}
49+
50+
/**
51+
* How long an admission may be served from cache: the full gate TTL while
52+
* every standing (the payer's, the acting member's) has ample headroom,
53+
* otherwise {@link NEAR_LIMIT_USAGE_GATE_TTL_MS}. An admission that carries no
54+
* figures (billing off) has no limit to approach.
55+
*/
56+
export function usageAdmissionTtlMs(...standings: Array<UsageStanding | undefined>): number {
57+
return standings.some(isNearLimit) ? NEAR_LIMIT_USAGE_GATE_TTL_MS : USAGE_GATE_TTL_MS
58+
}
59+
2460
/**
2561
* How long a coalesced usage read may take before its callers give up on it. The read's cost is
2662
* its ledger aggregates, each of which the database ends at
@@ -78,7 +114,8 @@ function gateKey(attribution: BillingAttributionSnapshot): string {
78114
* `coalesceLocally` collapses concurrent misses onto one ledger read and bounds
79115
* a hung read at {@link USAGE_GATE_SETTLE_TIMEOUT_MS}. The write stays on the
80116
* value this caller received, so a producer that timed out and later resolved
81-
* cannot overwrite a fresher answer.
117+
* cannot overwrite a fresher answer. An admission is stored for
118+
* {@link usageAdmissionTtlMs}, a refusal for the full TTL.
82119
*
83120
* There is deliberately no invalidator: usage and limit changes land in other
84121
* processes (execution workers, Stripe webhooks), so the TTL is the real bound.
@@ -96,7 +133,13 @@ async function checkUsageLimitsThroughCache(
96133
() => checkAttributedUsageLimits(attribution),
97134
USAGE_GATE_SETTLE_TIMEOUT_MS
98135
)
99-
if (cacheRefusals || !result.isExceeded) gateCache.set(key, result)
136+
if (cacheRefusals || !result.isExceeded) {
137+
gateCache.set(key, result, {
138+
ttl: result.isExceeded
139+
? USAGE_GATE_TTL_MS
140+
: usageAdmissionTtlMs(result.payerUsage, result.memberUsage),
141+
})
142+
}
100143
return result
101144
}
102145

‎packages/testing/src/mocks/billing-usage-gate-cache.mock.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { vi } from 'vitest'
55
*
66
* The three `check*UsageLimits` gates are bare `vi.fn()` (resolve `undefined`); set
77
* `mockResolvedValue({ isExceeded: false })` for an admitted path. `resetUsageGateCache` is a
8-
* no-op.
8+
* no-op, and `usageAdmissionTtlMs` returns the full gate TTL.
99
*
1010
* @example
1111
* ```ts
@@ -21,6 +21,8 @@ export const billingUsageGateCacheMockFns = {
2121
mockResetUsageGateCache: vi.fn((): void => {}),
2222
}
2323

24+
const USAGE_GATE_TTL_MS = 5 * 60 * 1000
25+
2426
/**
2527
* Static mock module for `@/lib/billing/core/usage-gate-cache`. Constants carry the real
2628
* values (`USAGE_GATE_SETTLE_TIMEOUT_MS` = 2 x the 60 s ledger statement timeout + 15 s).
@@ -31,10 +33,11 @@ export const billingUsageGateCacheMockFns = {
3133
* ```
3234
*/
3335
export const billingUsageGateCacheMock = {
34-
USAGE_GATE_TTL_MS: 5 * 60 * 1000,
36+
USAGE_GATE_TTL_MS,
3537
USAGE_GATE_SETTLE_TIMEOUT_MS: 2 * 60_000 + 15_000,
3638
checkIngestionUsageLimits: billingUsageGateCacheMockFns.mockCheckIngestionUsageLimits,
3739
checkSearchUsageLimits: billingUsageGateCacheMockFns.mockCheckSearchUsageLimits,
3840
checkExecutionUsageLimits: billingUsageGateCacheMockFns.mockCheckExecutionUsageLimits,
3941
resetUsageGateCache: billingUsageGateCacheMockFns.mockResetUsageGateCache,
42+
usageAdmissionTtlMs: vi.fn((): number => USAGE_GATE_TTL_MS),
4043
}

‎scripts/test-patterns-baseline.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,6 @@
151151
"real-wait\tapps/sim/hooks/queries/workspace.test.tsx\tsleep() #1",
152152
"real-wait\tapps/sim/lib/billing/core/reporting-usage-cache.test.ts\tsleep() #1",
153153
"real-wait\tapps/sim/lib/billing/core/reporting-usage-cache.test.ts\tsleep() #2",
154-
"real-wait\tapps/sim/lib/billing/core/usage-gate-cache.test.ts\tsleep() #1",
155154
"real-wait\tapps/sim/lib/concurrency/leader-lock.test.ts\tsleep() #1",
156155
"real-wait\tapps/sim/lib/concurrency/singleflight.test.ts\tsleep() #1",
157156
"real-wait\tapps/sim/lib/core/async-jobs/backends/database.test.ts\tsleep() #1",

0 commit comments

Comments
 (0)