Skip to content

Commit eb7bf1d

Browse files
committed
fix(billing): make usage-limit self-heal best-effort and respect concurrent writes
1 parent 077ee24 commit eb7bf1d

2 files changed

Lines changed: 53 additions & 13 deletions

File tree

apps/sim/lib/billing/core/usage.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ describe('getUserUsageLimit', () => {
102102

103103
it('heals a null limit to the free-tier default for free users', async () => {
104104
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: null }])
105+
dbChainMockFns.returning.mockResolvedValueOnce([{ currentUsageLimit: '10' }])
105106
mockGetFreeTierLimit.mockReturnValue(10)
106107

107108
const limit = await getUserUsageLimit('user-1', null)
@@ -121,6 +122,7 @@ describe('getUserUsageLimit', () => {
121122

122123
it('heals a null limit to the plan minimum for paid personal subscriptions', async () => {
123124
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: null }])
125+
dbChainMockFns.returning.mockResolvedValueOnce([{ currentUsageLimit: '40' }])
124126
mockHasPaidSubscriptionStatus.mockReturnValue(true)
125127
mockGetPerUserMinimumLimit.mockReturnValue(40)
126128

@@ -133,4 +135,23 @@ describe('getUserUsageLimit', () => {
133135
usageLimitUpdatedAt: expect.any(Date),
134136
})
135137
})
138+
139+
it('returns a concurrently written limit when the guarded heal matches no rows', async () => {
140+
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: null }])
141+
dbChainMockFns.returning.mockResolvedValueOnce([])
142+
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: '30' }])
143+
mockGetFreeTierLimit.mockReturnValue(10)
144+
145+
const limit = await getUserUsageLimit('user-1', null)
146+
147+
expect(limit).toBe(30)
148+
})
149+
150+
it('still returns the fallback when the heal write fails', async () => {
151+
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: null }])
152+
dbChainMockFns.returning.mockRejectedValueOnce(new Error('connection lost'))
153+
mockGetFreeTierLimit.mockReturnValue(10)
154+
155+
await expect(getUserUsageLimit('user-1', null)).resolves.toBe(10)
156+
})
136157
})

apps/sim/lib/billing/core/usage.ts

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,9 @@ export async function updateUserUsageLimit(
499499
* Org-scoped members carry a null `currentUsageLimit` by design (see
500500
* `syncUsageLimitsFromSubscription`). A user whose subscription stops being
501501
* org-scoped without a resync would otherwise stay null and fail closed on
502-
* every execution, so a null limit self-heals to the plan default here.
502+
* every execution, so a null limit self-heals to the plan default here. The
503+
* write-back is best-effort: a limit written concurrently wins, and a failed
504+
* write still resolves to the fallback instead of blocking execution.
503505
*/
504506
export async function getUserUsageLimit(
505507
userId: string,
@@ -547,19 +549,36 @@ export async function getUserUsageLimit(
547549
? getPerUserMinimumLimit(subscription)
548550
: getFreeTierLimit()
549551

550-
await db
551-
.update(userStats)
552-
.set({
553-
currentUsageLimit: fallbackLimit.toString(),
554-
usageLimitUpdatedAt: new Date(),
555-
})
556-
.where(and(eq(userStats.userId, userId), isNull(userStats.currentUsageLimit)))
552+
try {
553+
const healed = await db
554+
.update(userStats)
555+
.set({
556+
currentUsageLimit: fallbackLimit.toString(),
557+
usageLimitUpdatedAt: new Date(),
558+
})
559+
.where(and(eq(userStats.userId, userId), isNull(userStats.currentUsageLimit)))
560+
.returning({ currentUsageLimit: userStats.currentUsageLimit })
561+
562+
if (healed.length === 0) {
563+
const concurrent = await db
564+
.select({ currentUsageLimit: userStats.currentUsageLimit })
565+
.from(userStats)
566+
.where(eq(userStats.userId, userId))
567+
.limit(1)
557568

558-
logger.warn('Healed null usage limit to plan default', {
559-
userId,
560-
plan: subscription?.plan || 'free',
561-
fallbackLimit,
562-
})
569+
if (concurrent[0]?.currentUsageLimit) {
570+
return toNumber(toDecimal(concurrent[0].currentUsageLimit))
571+
}
572+
}
573+
574+
logger.warn('Healed null usage limit to plan default', {
575+
userId,
576+
plan: subscription?.plan || 'free',
577+
fallbackLimit,
578+
})
579+
} catch (error) {
580+
logger.error('Failed to heal null usage limit', { userId, fallbackLimit, error })
581+
}
563582

564583
return fallbackLimit
565584
}

0 commit comments

Comments
 (0)