Skip to content

Commit f44f14c

Browse files
committed
fix(knowledge): keep search-path refusals out of the shared usage gate cache
1 parent ee787f7 commit f44f14c

2 files changed

Lines changed: 19 additions & 7 deletions

File tree

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,13 @@ describe('checkSearchUsageLimits', () => {
140140
expect(mockCheck).toHaveBeenCalledTimes(2)
141141
})
142142

143+
it('never stores a refusal for ingestion to serve', async () => {
144+
mockCheck.mockResolvedValueOnce({ isExceeded: true, scope: 'payer', message: 'over' })
145+
expect((await checkSearchUsageLimits(ATTRIBUTION)).isExceeded).toBe(true)
146+
expect((await checkIngestionUsageLimits(ATTRIBUTION)).isExceeded).toBe(false)
147+
expect(mockCheck).toHaveBeenCalledTimes(2)
148+
})
149+
143150
it('does not cache a failed read', async () => {
144151
mockCheck.mockRejectedValueOnce(new Error('ledger unavailable'))
145152
await expect(checkSearchUsageLimits(ATTRIBUTION)).rejects.toThrow('ledger unavailable')

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,27 +52,32 @@ function gateKey(attribution: BillingAttributionSnapshot): string {
5252
/**
5353
* Serves a cached answer the caller accepts, otherwise reads the gate.
5454
*
55+
* `cacheRefusals` governs both directions: a caller that must re-read refusals
56+
* also never stores one, so a refusal read on the search path never reaches
57+
* ingestion. The usage read fails closed (a ledger error comes back as
58+
* exceeded), which makes that the only way a search-path outage stays out of
59+
* the cache. A read that throws writes nothing.
60+
*
5561
* `coalesceLocally` collapses concurrent misses onto one ledger read and bounds
56-
* a hung read at its settle deadline. A failed read throws without writing, so
57-
* an outage is never recorded as an answer. The write stays on the value this
58-
* caller received, so a producer that timed out and later resolved cannot
59-
* overwrite a fresher answer.
62+
* a hung read at its settle deadline. The write stays on the value this caller
63+
* received, so a producer that timed out and later resolved cannot overwrite a
64+
* fresher answer.
6065
*
6166
* There is deliberately no invalidator: usage and limit changes land in other
6267
* processes (execution workers, Stripe webhooks), so the TTL is the real bound.
6368
*/
6469
async function checkUsageLimitsThroughCache(
6570
attribution: BillingAttributionSnapshot,
66-
serveCachedRefusal: boolean
71+
cacheRefusals: boolean
6772
): Promise<AttributedUsageLimitsResult> {
6873
const key = gateKey(attribution)
6974
const cached = gateCache.get(key)
70-
if (cached !== undefined && (serveCachedRefusal || !cached.isExceeded)) return cached
75+
if (cached !== undefined && (cacheRefusals || !cached.isExceeded)) return cached
7176

7277
const result = await coalesceLocally(`usage-gate:${key}`, () =>
7378
checkAttributedUsageLimits(attribution)
7479
)
75-
gateCache.set(key, result)
80+
if (cacheRefusals || !result.isExceeded) gateCache.set(key, result)
7681
return result
7782
}
7883

0 commit comments

Comments
 (0)