Skip to content

Commit 825959b

Browse files
committed
fix(auth): amortize the entitlement check and bound cache eviction cost
Self-review follow-ups on the cache-coherence change: - Splitting the entitlement check out of the combined policy cache meant it ran on every session refresh. A policy save or org-wide revoke makes every member session refresh at once, so a large org would have stampeded the billing tables with one plan check per session. Entitlement is now cached on its own TTL, independent of the org security record (a policy save invalidates the policy but says nothing about the plan). Staleness is harmless in both directions, and a failed check falls back to the last known decision. - Pruning trimmed exactly to the cap, so the next insert was over again and every subsequent write rescanned the whole map. Prune to a low water mark instead, which keeps eviction amortized O(1). - The session create hook no longer writes an `expiresAt: undefined` key when the clamp has nothing to narrow; it only ever overrides a real value. - Drop the redundant `isBillingEnabled` short-circuit — the same condition is already the first branch of `resolveOrganizationEnterprisePlan`.
1 parent d1fc695 commit 825959b

4 files changed

Lines changed: 62 additions & 7 deletions

File tree

apps/sim/lib/auth/auth.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,10 @@ export const auth = betterAuth({
695695
return {
696696
data: {
697697
...session,
698-
expiresAt,
698+
// Only ever narrows an existing expiry — never introduces an
699+
// `expiresAt: undefined` key that would blank out the value
700+
// Better Auth already put on the row.
701+
...(expiresAt ? { expiresAt } : {}),
699702
...(membershipOrgId ? { activeOrganizationId: membershipOrgId } : {}),
700703
},
701704
}

apps/sim/lib/auth/security-policy.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,18 +60,28 @@ interface OrgSecurityCacheEntry extends OrgSecurityRecord {
6060
const orgSecurityCache = new Map<string, OrgSecurityCacheEntry>()
6161

6262
/**
63-
* Evicts down to `maxEntries`, expired entries first. Map iteration follows
63+
* Fraction of the cap an over-capacity prune evicts down to. Trimming to a low
64+
* water mark rather than exactly to the cap is what keeps eviction amortized
65+
* O(1): pruning back to the cap would leave the very next insert over again,
66+
* so every subsequent write would rescan the whole map.
67+
*/
68+
const PRUNE_TARGET_RATIO = 0.9
69+
70+
/**
71+
* Evicts down to a low water mark, expired entries first. Map iteration follows
6472
* insertion order and {@link touch} re-inserts on every refresh, so whatever
6573
* remains at the head is the least recently refreshed.
6674
*/
6775
function prune(cache: Map<string, { fetchedAt: number }>, maxEntries: number): void {
6876
if (cache.size <= maxEntries) return
77+
const target = Math.floor(maxEntries * PRUNE_TARGET_RATIO)
6978
const cutoff = Date.now() - SECURITY_POLICY_CACHE_TTL_MS
7079
for (const [key, entry] of cache) {
80+
if (cache.size <= target) break
7181
if (entry.fetchedAt < cutoff) cache.delete(key)
7282
}
7383
for (const key of cache.keys()) {
74-
if (cache.size <= maxEntries) break
84+
if (cache.size <= target) break
7585
cache.delete(key)
7686
}
7787
}

apps/sim/lib/auth/session-policy.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,23 @@ describe('getSessionPolicy', () => {
147147
})
148148
})
149149

150+
it('amortizes the entitlement check across repeated resolutions', async () => {
151+
const orgId = nextOrgId()
152+
const bounded = {
153+
version: 1,
154+
sessionPolicySettings: { maxSessionHours: 8, idleTimeoutHours: null },
155+
}
156+
queueTableRows(organization, [bounded])
157+
queueTableRows(organization, [bounded])
158+
159+
// A policy save makes every member session refresh at once; the plan check
160+
// must not run once per refreshing session.
161+
await getSessionPolicy(orgId)
162+
await getSessionPolicy(orgId, { bypassCache: true })
163+
164+
expect(mockResolveEnterprisePlan).toHaveBeenCalledTimes(1)
165+
})
166+
150167
it('keeps enforcing when the entitlement check itself fails', async () => {
151168
queueTableRows(organization, [
152169
{ version: 1, sessionPolicySettings: { maxSessionHours: 8, idleTimeoutHours: null } },

apps/sim/lib/auth/session-policy.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
invalidateMembershipCache,
99
} from '@/lib/auth/security-policy'
1010
import { resolveOrganizationEnterprisePlan } from '@/lib/billing/core/subscription'
11-
import { isBillingEnabled } from '@/lib/core/config/env-flags'
1211

1312
const logger = createLogger('SessionPolicy')
1413

@@ -24,6 +23,20 @@ const NO_POLICY: ResolvedSessionPolicy = {
2423
idleTimeoutHours: null,
2524
}
2625

26+
/**
27+
* Entitlement is billing state, so it is cached independently of the org
28+
* security record: a policy save must invalidate the policy, but it says
29+
* nothing about the plan. The TTL matters because saving a policy or revoking
30+
* org-wide makes every member session refresh at once — without it, each of
31+
* those refreshes would re-run the plan check and stampede the billing tables.
32+
* Staleness is harmless in both directions: a just-downgraded org keeps
33+
* enforcing for one TTL, and a just-upgraded one starts one TTL late.
34+
*/
35+
const ENTITLEMENT_CACHE_TTL_MS = 60 * 1000
36+
const MAX_ENTITLEMENT_CACHE_ENTRIES = 5_000
37+
38+
const entitlementCache = new Map<string, { entitled: boolean; fetchedAt: number }>()
39+
2740
/**
2841
* Whether stored session bounds should be enforced for this org. Mirroring
2942
* data-retention's plan-gated effective settings, a hosted org that leaves the
@@ -38,15 +51,27 @@ const NO_POLICY: ResolvedSessionPolicy = {
3851
* `lib/logs/execution/logger.ts`.
3952
*/
4053
async function isPolicyEnforced(organizationId: string, hasBounds: boolean): Promise<boolean> {
41-
if (!hasBounds || !isBillingEnabled) return true
54+
// Orgs with nothing stored are the common case and need no plan check at all.
55+
if (!hasBounds) return true
56+
57+
const cached = entitlementCache.get(organizationId)
58+
if (cached && Date.now() - cached.fetchedAt < ENTITLEMENT_CACHE_TTL_MS) {
59+
return cached.entitled
60+
}
61+
4262
try {
43-
return await resolveOrganizationEnterprisePlan(organizationId)
63+
const entitled = await resolveOrganizationEnterprisePlan(organizationId)
64+
// Entitled orgs are few, so a wholesale clear at the cap is enough — no
65+
// eviction policy needed for a set this small and this slow-moving.
66+
if (entitlementCache.size >= MAX_ENTITLEMENT_CACHE_ENTRIES) entitlementCache.clear()
67+
entitlementCache.set(organizationId, { entitled, fetchedAt: Date.now() })
68+
return entitled
4469
} catch (error) {
4570
logger.error('Enterprise entitlement check failed; enforcing stored session policy', {
4671
organizationId,
4772
error,
4873
})
49-
return true
74+
return cached?.entitled ?? true
5075
}
5176
}
5277

0 commit comments

Comments
 (0)