Skip to content

Commit 82c9d39

Browse files
waleedlatif1claude
andcommitted
fix(search): reserve interactive embeddings from their own admission lane
A search embeds its query through the same per-credential admission bucket as bulk indexing, so during a crawl one short interactive request competed with hundreds of batches for the bucket's refill. Non-checkpointed callers now reserve from a separate lane under the same identity; the provider cooldown and quota gates stay shared, so a provider 429 or an exhausted balance still pauses every caller. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JBacX6HGVhPMUySuMfANwn
1 parent 6d42e7d commit 82c9d39

4 files changed

Lines changed: 39 additions & 6 deletions

File tree

‎apps/sim/lib/core/rate-limiter/provider-admission.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,19 @@ describe('provider admission', () => {
8686
)
8787
})
8888

89+
it('reserves an interactive lane from its own buckets while sharing the provider gates', async () => {
90+
await waitForProviderAdmission({ ...INPUT, lane: 'interactive' })
91+
const [reservations, options] = consumeTokens.mock.calls[0]
92+
expect(reservations.map((item: { key: string }) => item.key)).toEqual([
93+
'provider:embedding:openai:hashed-credential:interactive:tokens',
94+
'provider:embedding:openai:hashed-credential:interactive:requests',
95+
])
96+
expect(options.cooldownKeys).toEqual([
97+
'provider:embedding:openai:hashed-credential:cooldown',
98+
'provider:embedding:openai:hashed-credential:quota',
99+
])
100+
})
101+
89102
it('isolates another credential and does not impose token costs on OCR', async () => {
90103
await waitForProviderAdmission({
91104
...INPUT,

‎apps/sim/lib/core/rate-limiter/provider-admission.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,19 @@ export interface ProviderIdentity {
1212
operation: 'embedding' | 'ocr' | 'rerank'
1313
}
1414

15+
/**
16+
* A lane reserves from its own request and token buckets under the same
17+
* provider identity, so a person waiting on one embedding never queues behind
18+
* a bulk crawl's batches. Cooldown and quota gates stay per identity: a
19+
* provider pause or an exhausted balance still stops every lane.
20+
*/
21+
export type ProviderAdmissionLane = 'interactive'
22+
1523
interface ProviderAdmissionInput extends ProviderIdentity {
1624
inputTokens?: number
1725
signal?: AbortSignal
1826
maxWaitMs: number
27+
lane?: ProviderAdmissionLane
1928
}
2029

2130
/**
@@ -49,6 +58,7 @@ export async function waitForProviderAdmission(input: ProviderAdmissionInput): P
4958
input.signal?.throwIfAborted()
5059
const deadlineAt = Date.now() + input.maxWaitMs
5160
const key = providerKey(input)
61+
const bucketKey = input.lane ? `${key}:${input.lane}` : key
5262
const requestsPerMinute =
5363
input.operation === 'embedding'
5464
? envNumber(env.KB_CONFIG_EMBEDDING_REQUESTS_PER_MINUTE, 600, { min: 1 })
@@ -64,7 +74,7 @@ export async function waitForProviderAdmission(input: ProviderAdmissionInput): P
6474
throw new Error('Embedding request exceeds the configured per-credential token budget')
6575
}
6676
reservations.push({
67-
key: `${key}:tokens`,
77+
key: `${bucketKey}:tokens`,
6878
cost: input.inputTokens,
6979
config: {
7080
maxTokens: tokensPerMinute,
@@ -74,7 +84,7 @@ export async function waitForProviderAdmission(input: ProviderAdmissionInput): P
7484
})
7585
}
7686
reservations.push({
77-
key: `${key}:requests`,
87+
key: `${bucketKey}:requests`,
7888
cost: 1,
7989
config: {
8090
maxTokens: Math.min(

‎apps/sim/lib/embeddings/client.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1807,13 +1807,14 @@ describe('durable embedding batches', () => {
18071807
expect(KNOWLEDGE_EMBEDDING_ADMISSION_WAIT_MS).toBeLessThan(EMBEDDING_RETRY_BUDGET_MS)
18081808
})
18091809

1810-
it('limits checkpointed admission waits while retaining the interactive request budget', async () => {
1810+
it('limits checkpointed admission waits and keeps interactive callers on their own lane', async () => {
18111811
fetchMock.mockImplementation(() => Promise.resolve(jsonResponse(openAIBody([[1]], 7))))
18121812
await embed(['text'], { apiKey: 'fixture-key', checkpoints: memoryCheckpoints() })
18131813
expect(mockAdmit).toHaveBeenLastCalledWith(
1814-
expect.objectContaining({ maxWaitMs: KNOWLEDGE_EMBEDDING_ADMISSION_WAIT_MS })
1814+
expect.objectContaining({ maxWaitMs: KNOWLEDGE_EMBEDDING_ADMISSION_WAIT_MS, lane: undefined })
18151815
)
18161816
await embed(['text'], { apiKey: 'fixture-key' })
1817+
expect(mockAdmit).toHaveBeenLastCalledWith(expect.objectContaining({ lane: 'interactive' }))
18171818
expect(mockAdmit.mock.lastCall?.[0].maxWaitMs).toBeGreaterThan(
18181819
KNOWLEDGE_EMBEDDING_ADMISSION_WAIT_MS
18191820
)

‎apps/sim/lib/embeddings/client.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from '@/lib/core/config/env-capabilities'
1212
import { isHosted } from '@/lib/core/config/env-flags'
1313
import {
14+
type ProviderAdmissionLane,
1415
ProviderQuotaExhaustedError,
1516
recordProviderCooldown,
1617
waitForProviderAdmission,
@@ -544,7 +545,8 @@ async function callEmbeddingAPI(
544545
expectedDimensions: number | undefined,
545546
isBYOK: boolean,
546547
signal?: AbortSignal,
547-
admissionWaitMs = EMBEDDING_RETRY_BUDGET_MS
548+
admissionWaitMs = EMBEDDING_RETRY_BUDGET_MS,
549+
lane?: ProviderAdmissionLane
548550
): Promise<{ embeddings: number[][]; totalTokens: number; dimensions: number }> {
549551
const admissionIdentity = embeddingAdmissionIdentity({ providerId, quotaCircuitIdentity, isBYOK })
550552
return retryWithExponentialBackoff(
@@ -563,6 +565,7 @@ async function callEmbeddingAPI(
563565
),
564566
signal: operationSignal,
565567
maxWaitMs: Math.min(admissionWaitMs, Math.max(0, deadlineAt - Date.now())),
568+
lane,
566569
})
567570
} catch (error) {
568571
if (error instanceof ProviderQuotaExhaustedError)
@@ -848,7 +851,13 @@ async function callCheckpointedEmbeddingBatch(
848851
provider.dimensions,
849852
provider.isBYOK,
850853
signal,
851-
checkpoints ? KNOWLEDGE_EMBEDDING_ADMISSION_WAIT_MS : undefined
854+
checkpoints ? KNOWLEDGE_EMBEDDING_ADMISSION_WAIT_MS : undefined,
855+
/**
856+
* Checkpoints mark the bulk indexing path. Everything else has a person
857+
* waiting on it, so it reserves from the interactive lane and never
858+
* queues behind a crawl's batches.
859+
*/
860+
checkpoints ? undefined : 'interactive'
852861
)
853862
if (identity) await checkpoints!.save(identity, result, signal)
854863
return result

0 commit comments

Comments
 (0)