Skip to content

Commit 6a0469e

Browse files
committed
fix(billing): serve execution admission from the usage gate cache instead of re-summing the billing period per run
1 parent 35a1c58 commit 6a0469e

12 files changed

Lines changed: 61 additions & 23 deletions

File tree

‎apps/sim/app/api/v1/knowledge/search/route.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
7070
vi.mock('@/lib/billing/core/billing-attribution', () => ({
7171
resolveBillingAttribution: mockResolveBillingAttribution,
7272
resolveSystemBillingAttribution: mockResolveSystemBillingAttribution,
73-
checkAttributedUsageLimits: vi.fn().mockResolvedValue({ isExceeded: false }),
73+
}))
74+
vi.mock('@/lib/billing/core/usage-gate-cache', () => ({
75+
checkSearchUsageLimits: vi.fn().mockResolvedValue({ isExceeded: false }),
7476
}))
7577

7678
vi.mock('@/lib/knowledge/embeddings', () => ({

‎apps/sim/app/api/v1/knowledge/search/route.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import { type NextRequest, NextResponse } from 'next/server'
22
import { v1KnowledgeSearchContract } from '@/lib/api/contracts/v1/knowledge'
33
import { parseRequest } from '@/lib/api/server'
44
import {
5-
checkAttributedUsageLimits,
65
resolveBillingAttribution,
76
resolveSystemBillingAttribution,
87
} from '@/lib/billing/core/billing-attribution'
8+
import { checkSearchUsageLimits } from '@/lib/billing/core/usage-gate-cache'
99
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1010
import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants'
1111
import { toKbEmbeddingDimensions } from '@/lib/knowledge/embedding-models'
@@ -82,7 +82,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8282
* keys resolve their system actor and immutable payer from one workspace read.
8383
*/
8484
if (billingAttribution) {
85-
const usage = await checkAttributedUsageLimits(billingAttribution)
85+
const usage = await checkSearchUsageLimits(billingAttribution)
8686
if (usage.isExceeded) {
8787
return NextResponse.json(
8888
{ error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' },

‎apps/sim/background/workflow-column-execution.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import { and, eq, isNull, or } from 'drizzle-orm'
1010
import {
1111
assertBillingAttributionSnapshot,
1212
type BillingAttributionSnapshot,
13-
checkAttributedUsageLimits,
1413
toBillingContext,
1514
} from '@/lib/billing/core/billing-attribution'
15+
import { checkExecutionUsageLimits } from '@/lib/billing/core/usage-gate-cache'
1616
import { checkAndBillPayerOverageThreshold } from '@/lib/billing/threshold-billing'
1717
import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure'
1818
import {
@@ -575,7 +575,7 @@ async function runWorkflowAndWriteTerminal(
575575
* Gate the exact workspace payer and member cap before hosted-key cost.
576576
* A denial clears the cell pre-stamp and surfaces the upgrade state.
577577
*/
578-
const usage = await checkAttributedUsageLimits(enrichmentBillingAttribution)
578+
const usage = await checkExecutionUsageLimits(enrichmentBillingAttribution)
579579
if (usage.isExceeded) {
580580
logger.warn(
581581
`Usage limit reached — halting enrichment (table=${tableId} row=${rowId} group=${groupId})`

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({
1212

1313
import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution'
1414
import {
15+
checkExecutionUsageLimits,
1516
checkIngestionUsageLimits,
1617
checkSearchUsageLimits,
1718
resetUsageGateCache,
@@ -158,3 +159,23 @@ describe('checkSearchUsageLimits', () => {
158159
expect(mockCheck).toHaveBeenCalledTimes(2)
159160
})
160161
})
162+
163+
describe('checkExecutionUsageLimits', () => {
164+
beforeEach(() => {
165+
resetUsageGateCache()
166+
mockCheck.mockReset().mockResolvedValue({ isExceeded: false })
167+
})
168+
169+
it('reuses an admission across workspaces of the same payer', async () => {
170+
await checkExecutionUsageLimits(ATTRIBUTION)
171+
await checkExecutionUsageLimits({ ...ATTRIBUTION, workspaceId: 'ws-2' })
172+
expect(mockCheck).toHaveBeenCalledTimes(1)
173+
})
174+
175+
it('re-reads a refusal', async () => {
176+
mockCheck.mockResolvedValue({ isExceeded: true, message: 'over' })
177+
await checkExecutionUsageLimits(ATTRIBUTION)
178+
await checkExecutionUsageLimits(ATTRIBUTION)
179+
expect(mockCheck).toHaveBeenCalledTimes(2)
180+
})
181+
})

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,15 +99,28 @@ export function checkIngestionUsageLimits(
9999
* {@link checkAttributedUsageLimits} for knowledge search. Serves only a cached
100100
* admission: a refusal is always re-read, so a payer who just raised their limit
101101
* or upgraded is never held behind a cached block while they wait on a search.
102-
* Every other interactive caller (uploads, execution admission, the settings
103-
* surfaces) keeps reading the gate fresh.
102+
* Every other interactive caller (uploads, the settings surfaces) keeps reading
103+
* the gate fresh.
104104
*/
105105
export function checkSearchUsageLimits(
106106
attribution: BillingAttributionSnapshot
107107
): Promise<AttributedUsageLimitsResult> {
108108
return checkUsageLimitsThroughCache(attribution, false)
109109
}
110110

111+
/**
112+
* {@link checkAttributedUsageLimits} for the execution path, the highest-volume
113+
* reader of the gate. Same policy as search: only an admission is served from
114+
* cache, so a raised limit applies on the next run. The admission's usage figure
115+
* is up to one TTL stale, which widens the execution-slot reservation headroom
116+
* by that much and no more.
117+
*/
118+
export function checkExecutionUsageLimits(
119+
attribution: BillingAttributionSnapshot
120+
): Promise<AttributedUsageLimitsResult> {
121+
return checkUsageLimitsThroughCache(attribution, false)
122+
}
123+
111124
/** Drops every cached gate answer. Test seam; never called in production code. */
112125
export function resetUsageGateCache(): void {
113126
gateCache.clear()

‎apps/sim/lib/execution/preprocessing.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,12 @@ vi.mock('@/lib/billing/calculations/usage-reservation', () => ({
4646
}))
4747
vi.mock('@/lib/billing/core/billing-attribution', () => ({
4848
assertBillingAttributionSnapshot: vi.fn((value) => value),
49-
checkAttributedUsageLimits: mockCheckAttributedUsageLimits,
5049
resolveBillingAttribution: mockResolveBillingAttribution,
5150
resolveSystemBillingAttribution: mockResolveSystemBillingAttribution,
5251
}))
52+
vi.mock('@/lib/billing/core/usage-gate-cache', () => ({
53+
checkExecutionUsageLimits: mockCheckAttributedUsageLimits,
54+
}))
5355
vi.mock('@/lib/billing/core/subscription', () => ({
5456
getHighestPrioritySubscription: vi.fn(),
5557
}))

‎apps/sim/lib/execution/preprocessing.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ import {
99
import {
1010
assertBillingAttributionSnapshot,
1111
type BillingAttributionSnapshot,
12-
checkAttributedUsageLimits,
1312
resolveBillingAttribution,
1413
resolveSystemBillingAttribution,
1514
} from '@/lib/billing/core/billing-attribution'
1615
import type { HighestPrioritySubscription } from '@/lib/billing/core/plan'
1716
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
17+
import { checkExecutionUsageLimits } from '@/lib/billing/core/usage-gate-cache'
1818
import {
1919
type AdmissionErrorDescriptor,
2020
getReservationDenialDescriptor,
@@ -574,8 +574,8 @@ export async function preprocessExecution(
574574
let snapshot: UsageSnapshot | null = null
575575
try {
576576
const usageCheck = await withDatabaseReadRetry(
577-
() => checkAttributedUsageLimits(billingAttribution),
578-
{ label: 'checkAttributedUsageLimits' }
577+
() => checkExecutionUsageLimits(billingAttribution),
578+
{ label: 'checkExecutionUsageLimits' }
579579
)
580580
snapshot = usageCheck.payerUsage
581581
? {

‎apps/sim/lib/internal/guardrails/operations.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@ vi.mock('@/lib/auth/credential-access', () => ({
2727
authorizeCredentialUseForAuth: mocks.authorizeCredential,
2828
}))
2929
vi.mock('@/lib/billing/core/billing-attribution', () => ({
30-
checkAttributedUsageLimits: mocks.checkAttributedUsageLimits,
3130
requireBillingAttributionHeader: mocks.requireBillingAttribution,
3231
toBillingContext: vi.fn(() => ({})),
3332
}))
33+
vi.mock('@/lib/billing/core/usage-gate-cache', () => ({
34+
checkExecutionUsageLimits: mocks.checkAttributedUsageLimits,
35+
}))
3436
vi.mock('@/lib/billing/threshold-billing', () => ({
3537
checkAndBillPayerOverageThreshold: vi.fn(),
3638
}))

‎apps/sim/lib/internal/guardrails/operations.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import { authorizeCredentialUseForAuth } from '@/lib/auth/credential-access'
66
import { AuthType } from '@/lib/auth/hybrid'
77
import {
88
type BillingAttributionSnapshot,
9-
checkAttributedUsageLimits,
109
requireBillingAttributionHeader,
1110
toBillingContext,
1211
} from '@/lib/billing/core/billing-attribution'
12+
import { checkExecutionUsageLimits } from '@/lib/billing/core/usage-gate-cache'
1313
import { checkAndBillPayerOverageThreshold } from '@/lib/billing/threshold-billing'
1414
import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context'
1515
import { inspectModelInputProvenanceRequest } from '@/lib/execution/model-input-provenance'
@@ -164,7 +164,7 @@ async function prepareHallucinationContext(
164164
throw error
165165
}
166166

167-
const usage = await checkAttributedUsageLimits(billingAttribution)
167+
const usage = await checkExecutionUsageLimits(billingAttribution)
168168
if (usage.isExceeded) {
169169
fail(402, usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.')
170170
}

‎apps/sim/lib/workflows/custom-blocks/child-execution.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ const { mockCheckAttributedUsageLimits, mockSubscribe, mockUnsubscribe } = vi.ho
1010
mockUnsubscribe: vi.fn(),
1111
}))
1212

13-
vi.mock('@/lib/billing/core/billing-attribution', () => ({
14-
checkAttributedUsageLimits: mockCheckAttributedUsageLimits,
13+
vi.mock('@/lib/billing/core/usage-gate-cache', () => ({
14+
checkExecutionUsageLimits: mockCheckAttributedUsageLimits,
1515
}))
1616

1717
vi.mock('@/lib/execution/cancellation', () => ({

0 commit comments

Comments
 (0)