Skip to content

Commit 12e474d

Browse files
committed
refactor(byok): coalesce the entitlement read with the shared singleflight
lib/concurrency/singleflight.ts is the codebase's coalescing primitive and oauth/credential-service.ts already pairs it with a read-through cache. Adopting that shape fixes a case caching the promise directly did not: a *hung* billing read wedged every caller for the full 60s TTL, where coalesceLocally evicts and rejects at its settle deadline. It also removes the hand-rolled rejection eviction — the cache is written only on the success path, so an outage leaves no entry by construction. The cache now holds booleans, which introduces the one trap worth a test: a truthiness check would read a cached false as a miss and re-query billing on every resolution for lapsed organizations. Pinned.
1 parent 6824438 commit 12e474d

2 files changed

Lines changed: 40 additions & 24 deletions

File tree

apps/sim/lib/api-key/byok-entitlement.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,15 @@ import {
2323
isOrganizationBYOKEntitledCached,
2424
resetOrganizationBYOKEntitlementCache,
2525
} from '@/lib/api-key/byok-entitlement'
26+
import { __resetCoalesceLocallyForTests } from '@/lib/concurrency/singleflight'
2627

2728
const ORGANIZATION_ID = 'org-1'
2829

2930
describe('organization BYOK entitlement', () => {
3031
beforeEach(() => {
3132
vi.clearAllMocks()
3233
resetOrganizationBYOKEntitlementCache()
34+
__resetCoalesceLocallyForTests()
3335
mockIsHosted.value = true
3436
mockResolveOrganizationPlan.mockResolvedValue(true)
3537
})
@@ -75,6 +77,21 @@ describe('organization BYOK entitlement', () => {
7577
expect(mockResolveOrganizationPlan).toHaveBeenCalledTimes(1)
7678
})
7779

80+
/**
81+
* The cached value is a boolean, so a truthiness check would read a cached
82+
* `false` as a miss — re-querying billing on every resolution for exactly the
83+
* organizations the cache exists to protect (lapsed ones still holding keys).
84+
*/
85+
it('serves a cached false without re-reading billing', async () => {
86+
mockResolveOrganizationPlan.mockResolvedValue(false)
87+
88+
await expect(isOrganizationBYOKEntitledCached(ORGANIZATION_ID)).resolves.toBe(false)
89+
await expect(isOrganizationBYOKEntitledCached(ORGANIZATION_ID)).resolves.toBe(false)
90+
await expect(isOrganizationBYOKEntitledCached(ORGANIZATION_ID)).resolves.toBe(false)
91+
92+
expect(mockResolveOrganizationPlan).toHaveBeenCalledTimes(1)
93+
})
94+
7895
it('keeps organizations in separate cache entries', async () => {
7996
mockResolveOrganizationPlan.mockImplementation(async (id: string) => id === ORGANIZATION_ID)
8097

apps/sim/lib/api-key/byok-entitlement.ts

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { LRUCache } from 'lru-cache'
22
import { resolveOrganizationPlan } from '@/lib/billing/core/subscription'
3+
import { coalesceLocally } from '@/lib/concurrency/singleflight'
34
import { isHosted } from '@/lib/core/config/env-flags'
45

56
/**
@@ -13,13 +14,13 @@ import { isHosted } from '@/lib/core/config/env-flags'
1314
export const ORGANIZATION_BYOK_ENTITLEMENT_TTL_MS = 60 * 1000
1415

1516
/**
16-
* Caches the in-flight promise rather than the resolved boolean, following
17-
* `lib/copilot/entitlements.ts`. Storing the promise is what makes concurrent
18-
* callers collapse onto one resolution: a parallel or loop block resolving N
19-
* items issues one query set instead of N, with no separate in-flight
20-
* bookkeeping. `LRUCache` supplies the TTL and the size bound.
17+
* Resolved entitlements, with `LRUCache` supplying the TTL and the size bound.
18+
*
19+
* Values are booleans, so every read must test `!== undefined` — a plain
20+
* truthiness check would treat a cached `false` as a miss and re-query an
21+
* unentitled organization on every single resolution.
2122
*/
22-
const entitlementCache = new LRUCache<string, Promise<boolean>>({
23+
const entitlementCache = new LRUCache<string, boolean>({
2324
max: 500,
2425
ttl: ORGANIZATION_BYOK_ENTITLEMENT_TTL_MS,
2526
})
@@ -57,27 +58,25 @@ export function isOrganizationBYOKEntitledCached(organizationId: string): Promis
5758
if (!isHosted) return Promise.resolve(false)
5859

5960
const cached = entitlementCache.get(organizationId)
60-
if (cached) return cached
61+
if (cached !== undefined) return Promise.resolve(cached)
6162

6263
/**
63-
* `onError: 'throw'` is load-bearing: the resolver otherwise maps a failed
64-
* billing read to `false`, indistinguishable from a real plan lapse, and a
65-
* cached `false` would silently meter every inheriting run for the whole TTL.
64+
* `coalesceLocally` around a read-through cache is the shape
65+
* `oauth/credential-service.ts` uses. It collapses a parallel or loop block's
66+
* N simultaneous misses onto one resolution, and — unlike caching the promise
67+
* directly — bounds a *hung* billing read at its settle deadline instead of
68+
* wedging every caller for the whole TTL.
69+
*
70+
* Writing the cache only on the success path is what keeps a momentary
71+
* outage from being recorded as a plan lapse; `onError: 'throw'` is what
72+
* makes that outage distinguishable, since the resolver otherwise maps a
73+
* failed read to `false` exactly like a real lapse.
6674
*/
67-
const resolution = resolveOrganizationPlan(organizationId, { onError: 'throw' })
68-
entitlementCache.set(organizationId, resolution)
69-
70-
/**
71-
* Caching the promise means a rejected one would stay cached for the full TTL
72-
* — the neighbour in `copilot/entitlements.ts` never has to think about this
73-
* because its evaluators swallow their own errors. Evicting on rejection is
74-
* what keeps a momentary billing outage from pinning the gate shut. The
75-
* caller still sees the rejection through `resolution`; `getBYOKKey` catches
76-
* it and falls back for that one call.
77-
*/
78-
resolution.catch(() => entitlementCache.delete(organizationId))
79-
80-
return resolution
75+
return coalesceLocally(`byok-entitlement:${organizationId}`, async () => {
76+
const entitled = await resolveOrganizationPlan(organizationId, { onError: 'throw' })
77+
entitlementCache.set(organizationId, entitled)
78+
return entitled
79+
})
8180
}
8281

8382
/**

0 commit comments

Comments
 (0)