Skip to content

Commit 6d42e7d

Browse files
waleedlatif1claude
andauthored
fix(knowledge): cache the ingestion usage gate for a minute (#7725)
Every document run checked the payer's spend by summing the usage ledger for the billing period. The ledger grows with every indexed document, so a bulk connector sync re-read the whole period's ledger once per document: 41,000 sums over roughly 436,000 rows each during one crawl, which was the largest single database consumer while it ran and slowed unrelated queries, including search. Background ingestion now reads the gate through a per-process LRU cache keyed by payer, billing period and acting member, with a 60-second TTL and coalesced misses. Staleness fails in the harmless direction: a payer at their limit indexes for at most another minute, and a raised limit takes at most a minute to apply. Interactive callers keep reading the gate fresh. Claude-Session: https://claude.ai/code/session_01JBacX6HGVhPMUySuMfANwn Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 0d581ce commit 6d42e7d

5 files changed

Lines changed: 173 additions & 2 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { sleep } from '@sim/utils/helpers'
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockCheck } = vi.hoisted(() => ({ mockCheck: vi.fn() }))
8+
9+
vi.mock('@/lib/billing/core/billing-attribution', () => ({
10+
checkAttributedUsageLimits: mockCheck,
11+
}))
12+
13+
import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution'
14+
import {
15+
checkIngestionUsageLimits,
16+
INGESTION_USAGE_GATE_TTL_MS,
17+
resetIngestionUsageGateCache,
18+
} from '@/lib/billing/core/ingestion-usage-gate'
19+
20+
const ATTRIBUTION: BillingAttributionSnapshot = {
21+
actorUserId: 'member-1',
22+
workspaceId: null,
23+
organizationId: 'org-1',
24+
billedAccountUserId: 'owner-1',
25+
billingEntity: { type: 'organization', id: 'org-1' },
26+
billingPeriod: {
27+
start: '2026-09-01T00:00:00.000Z',
28+
end: '2026-10-01T00:00:00.000Z',
29+
source: 'stripe',
30+
},
31+
payerSubscription: null,
32+
}
33+
34+
describe('checkIngestionUsageLimits', () => {
35+
beforeEach(() => {
36+
resetIngestionUsageGateCache()
37+
mockCheck.mockReset().mockResolvedValue({ isExceeded: false })
38+
})
39+
afterEach(() => vi.restoreAllMocks())
40+
41+
it('reads the ledger once per payer, period and actor within the TTL', async () => {
42+
await checkIngestionUsageLimits(ATTRIBUTION)
43+
await checkIngestionUsageLimits(ATTRIBUTION)
44+
await checkIngestionUsageLimits({ ...ATTRIBUTION, workspaceId: 'workspace-9' })
45+
expect(mockCheck).toHaveBeenCalledTimes(1)
46+
})
47+
48+
it('collapses concurrent misses onto one ledger read', async () => {
49+
let resolve!: (value: { isExceeded: boolean }) => void
50+
mockCheck.mockReturnValueOnce(new Promise((r) => (resolve = r)))
51+
const pending = Promise.all([
52+
checkIngestionUsageLimits(ATTRIBUTION),
53+
checkIngestionUsageLimits(ATTRIBUTION),
54+
checkIngestionUsageLimits(ATTRIBUTION),
55+
])
56+
await sleep(0)
57+
resolve({ isExceeded: false })
58+
const results = await pending
59+
expect(results.every((result) => result.isExceeded === false)).toBe(true)
60+
expect(mockCheck).toHaveBeenCalledTimes(1)
61+
})
62+
63+
it('keeps a refusal for the same bounded window, then reads fresh', async () => {
64+
mockCheck.mockResolvedValueOnce({ isExceeded: true, scope: 'payer', message: 'over' })
65+
expect((await checkIngestionUsageLimits(ATTRIBUTION)).isExceeded).toBe(true)
66+
expect((await checkIngestionUsageLimits(ATTRIBUTION)).isExceeded).toBe(true)
67+
expect(mockCheck).toHaveBeenCalledTimes(1)
68+
69+
/** `lru-cache` reads `performance.now()` and debounces it behind a real 1 ms timer. */
70+
const start = performance.now()
71+
vi.spyOn(performance, 'now').mockReturnValue(start + INGESTION_USAGE_GATE_TTL_MS + 1)
72+
await sleep(5)
73+
expect((await checkIngestionUsageLimits(ATTRIBUTION)).isExceeded).toBe(false)
74+
expect(mockCheck).toHaveBeenCalledTimes(2)
75+
})
76+
77+
it('separates answers by actor, period and payer', async () => {
78+
await checkIngestionUsageLimits(ATTRIBUTION)
79+
await checkIngestionUsageLimits({ ...ATTRIBUTION, actorUserId: 'member-2' })
80+
await checkIngestionUsageLimits({
81+
...ATTRIBUTION,
82+
billingPeriod: { ...ATTRIBUTION.billingPeriod, start: '2026-10-01T00:00:00.000Z' },
83+
})
84+
await checkIngestionUsageLimits({
85+
...ATTRIBUTION,
86+
billedAccountUserId: 'owner-2',
87+
billingEntity: { type: 'user', id: 'owner-2' },
88+
})
89+
expect(mockCheck).toHaveBeenCalledTimes(4)
90+
})
91+
92+
it('does not cache a failed read', async () => {
93+
mockCheck.mockRejectedValueOnce(new Error('ledger unavailable'))
94+
await expect(checkIngestionUsageLimits(ATTRIBUTION)).rejects.toThrow('ledger unavailable')
95+
await checkIngestionUsageLimits(ATTRIBUTION)
96+
expect(mockCheck).toHaveBeenCalledTimes(2)
97+
})
98+
})
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { LRUCache } from 'lru-cache'
2+
import {
3+
type AttributedUsageLimitsResult,
4+
type BillingAttributionSnapshot,
5+
checkAttributedUsageLimits,
6+
} from '@/lib/billing/core/billing-attribution'
7+
import { coalesceLocally } from '@/lib/concurrency/singleflight'
8+
9+
/**
10+
* How long a usage-gate answer stays usable on the ingestion path.
11+
*
12+
* The gate sums the payer's usage ledger for the billing period, which grows
13+
* with every indexed document, so a bulk sync that re-checks per document
14+
* reads the whole period's ledger tens of thousands of times. Staleness fails
15+
* in the harmless direction: a payer at their limit keeps indexing for at most
16+
* this long, and a payer whose limit was just raised waits at most this long.
17+
* Nothing on this path has a person waiting for the answer.
18+
*/
19+
export const INGESTION_USAGE_GATE_TTL_MS = 60 * 1000
20+
21+
/** Recent gate answers, with `LRUCache` supplying the TTL and the size bound. */
22+
const gateCache = new LRUCache<string, AttributedUsageLimitsResult>({
23+
max: 10_000,
24+
ttl: INGESTION_USAGE_GATE_TTL_MS,
25+
})
26+
27+
/**
28+
* The gate depends on who pays, for which period, and which member acts: the
29+
* payer pool and the per-member cap are both part of the answer.
30+
*/
31+
function gateKey(attribution: BillingAttributionSnapshot): string {
32+
return [
33+
attribution.billingEntity.type,
34+
attribution.billingEntity.id,
35+
attribution.billingPeriod.start,
36+
attribution.billingPeriod.end,
37+
attribution.billedAccountUserId,
38+
attribution.actorUserId,
39+
].join(':')
40+
}
41+
42+
/**
43+
* {@link checkAttributedUsageLimits} for background ingestion, with bounded
44+
* staleness. Interactive callers (uploads, search, the settings surfaces) keep
45+
* reading the gate fresh so a limit change is visible at once.
46+
*
47+
* `coalesceLocally` collapses the concurrent misses of a batch onto one ledger
48+
* read and bounds a hung read at its settle deadline. The cache write stays on
49+
* the value this caller received, so a producer that timed out and later
50+
* resolved cannot overwrite a fresher answer.
51+
*/
52+
export async function checkIngestionUsageLimits(
53+
attribution: BillingAttributionSnapshot
54+
): Promise<AttributedUsageLimitsResult> {
55+
const key = gateKey(attribution)
56+
const cached = gateCache.get(key)
57+
if (cached !== undefined) return cached
58+
59+
const result = await coalesceLocally(`ingestion-usage-gate:${key}`, () =>
60+
checkAttributedUsageLimits(attribution)
61+
)
62+
gateCache.set(key, result)
63+
return result
64+
}
65+
66+
/** Drops every cached gate answer. Test seam; never called in production code. */
67+
export function resetIngestionUsageGateCache(): void {
68+
gateCache.clear()
69+
}

apps/sim/lib/knowledge/documents/document-indexing-usage.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,13 @@ vi.mock('@/providers/utils', () => ({
6565
}))
6666

6767
import * as billingAttribution from '@/lib/billing/core/billing-attribution'
68+
import { resetIngestionUsageGateCache } from '@/lib/billing/core/ingestion-usage-gate'
6869
import * as embeddingClient from '@/lib/embeddings/client'
6970
import { processDocumentAsync } from '@/lib/knowledge/documents/service'
7071

7172
const mockEmbeddingCapacity = vi.fn<typeof embeddingClient.assertKnowledgeEmbeddingCapacity>()
7273
beforeEach(() => {
74+
resetIngestionUsageGateCache()
7375
vi.spyOn(billingAttribution, 'checkAttributedUsageLimits').mockImplementation(
7476
mockCheckAttributedUsageLimits
7577
)

apps/sim/lib/knowledge/documents/document-processing-source.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ vi.mock('@/lib/uploads/server/metadata', () => ({
6666
}))
6767

6868
import * as billingAttribution from '@/lib/billing/core/billing-attribution'
69+
import { resetIngestionUsageGateCache } from '@/lib/billing/core/ingestion-usage-gate'
6970
import { env } from '@/lib/core/config/env'
7071
import {
7172
markInsideTriggerRun,
@@ -92,6 +93,7 @@ import { MAX_PROCESSING_ATTEMPTS } from '@/lib/knowledge/documents/types'
9293

9394
const mockEmbeddingCapacity = vi.fn<typeof embeddingClient.assertKnowledgeEmbeddingCapacity>()
9495
beforeEach(() => {
96+
resetIngestionUsageGateCache()
9597
vi.spyOn(billingAttribution, 'checkAttributedUsageLimits').mockImplementation(
9698
mockCheckAttributedUsageLimits
9799
)

apps/sim/lib/knowledge/documents/service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ import {
3434
assertBillingAttributionOwner,
3535
assertBillingAttributionSnapshot,
3636
type BillingAttributionSnapshot,
37-
checkAttributedUsageLimits,
3837
toBillingContext,
3938
} from '@/lib/billing/core/billing-attribution'
39+
import { checkIngestionUsageLimits } from '@/lib/billing/core/ingestion-usage-gate'
4040
import { recordUsage } from '@/lib/billing/core/usage-log'
4141
import {
4242
applyStorageUsageDeltasInTx,
@@ -1618,7 +1618,7 @@ export async function processDocumentAsync(
16181618
assertBillingAttributionOwner(billingAttribution, ctx)
16191619
const documentActorUserId = billingAttribution.actorUserId
16201620

1621-
const usageGate = await checkAttributedUsageLimits(billingAttribution)
1621+
const usageGate = await checkIngestionUsageLimits(billingAttribution)
16221622
if (usageGate.isExceeded) {
16231623
logger.warn(`[${documentId}] Usage limit reached — skipping document indexing`)
16241624
throw new UsageLimitDocumentProcessingError(

0 commit comments

Comments
 (0)