Skip to content

Commit 37e7121

Browse files
authored
improvement(billing): self-heal null usage limits and debounce api-key last-used writes (#5000)
* improvement(billing): self-heal null usage limits and debounce api-key last-used writes * fix(billing): make usage-limit self-heal best-effort and respect concurrent writes * improvement(api-key): widen last-used staleness window to 10 minutes
1 parent a5b92b1 commit 37e7121

4 files changed

Lines changed: 254 additions & 8 deletions

File tree

apps/sim/lib/api-key/service.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
5252
}))
5353

5454
import { hashApiKey } from '@/lib/api-key/crypto'
55-
import { authenticateApiKeyFromHeader } from '@/lib/api-key/service'
55+
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
5656

5757
function personalKeyRecord(overrides: Partial<Record<string, unknown>> = {}) {
5858
return {
@@ -141,3 +141,33 @@ describe('authenticateApiKeyFromHeader', () => {
141141
expect(JSON.stringify(filter)).toContain(expected)
142142
})
143143
})
144+
145+
describe('updateApiKeyLastUsed', () => {
146+
beforeEach(() => {
147+
vi.clearAllMocks()
148+
})
149+
150+
it('only writes when the stored lastUsed is missing or stale', async () => {
151+
await updateApiKeyLastUsed('key-1')
152+
153+
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
154+
expect(dbChainMockFns.set).toHaveBeenCalledWith({ lastUsed: expect.any(Date) })
155+
const [condition] = dbChainMockFns.where.mock.calls[0]
156+
expect(condition).toMatchObject({
157+
type: 'and',
158+
conditions: [
159+
{ type: 'eq', right: 'key-1' },
160+
{ type: 'or', conditions: [{ type: 'isNull' }, { type: 'lt' }] },
161+
],
162+
})
163+
})
164+
165+
it('swallows database errors instead of failing the request', async () => {
166+
dbChainMockFns.update.mockImplementationOnce(() => {
167+
throw new Error('connection lost')
168+
})
169+
170+
await expect(updateApiKeyLastUsed('key-1')).resolves.toBeUndefined()
171+
expect(serviceLogger.error).toHaveBeenCalled()
172+
})
173+
})

apps/sim/lib/api-key/service.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { db } from '@sim/db'
22
import { apiKey as apiKeyTable, user as userTable } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { and, eq } from 'drizzle-orm'
4+
import { and, eq, isNull, lt, or } from 'drizzle-orm'
55
import { hashApiKey } from '@/lib/api-key/crypto'
66
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
77
import { getWorkspaceBillingSettings, type WorkspaceBillingSettings } from '@/lib/workspaces/utils'
@@ -136,12 +136,30 @@ export async function authenticateApiKeyFromHeader(
136136
}
137137
}
138138

139+
const LAST_USED_STALENESS_WINDOW_MS = 10 * 60 * 1000
140+
139141
/**
140-
* Update the last used timestamp for an API key
142+
* Update the last used timestamp for an API key.
143+
*
144+
* `lastUsed` is display-only, so the write uses a staleness window: it only
145+
* fires when the stored value is older than
146+
* {@link LAST_USED_STALENESS_WINDOW_MS}. High-traffic keys otherwise rewrite
147+
* the same row on every request, serializing concurrent requests behind row
148+
* locks. The 10-minute window matches GitLab's personal-access-token
149+
* last-used tracking.
141150
*/
142151
export async function updateApiKeyLastUsed(keyId: string): Promise<void> {
143152
try {
144-
await db.update(apiKeyTable).set({ lastUsed: new Date() }).where(eq(apiKeyTable.id, keyId))
153+
const staleBefore = new Date(Date.now() - LAST_USED_STALENESS_WINDOW_MS)
154+
await db
155+
.update(apiKeyTable)
156+
.set({ lastUsed: new Date() })
157+
.where(
158+
and(
159+
eq(apiKeyTable.id, keyId),
160+
or(isNull(apiKeyTable.lastUsed), lt(apiKeyTable.lastUsed, staleBefore))
161+
)
162+
)
145163
} catch (error) {
146164
logger.error('Error updating API key last used:', error)
147165
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/**
2+
* Tests for getUserUsageLimit.
3+
*
4+
* Org-scoped members carry a null `currentUsageLimit` by design, so a user
5+
* whose subscription stops being org-scoped without a resync is left null.
6+
* The limit read must self-heal that state to the plan default instead of
7+
* failing closed and blocking every execution.
8+
*
9+
* @vitest-environment node
10+
*/
11+
import { dbChainMock, dbChainMockFns } from '@sim/testing'
12+
import { beforeEach, describe, expect, it, vi } from 'vitest'
13+
14+
vi.mock('@sim/db', () => dbChainMock)
15+
16+
const {
17+
mockGetFreeTierLimit,
18+
mockGetPerUserMinimumLimit,
19+
mockHasPaidSubscriptionStatus,
20+
mockIsOrgScopedSubscription,
21+
} = vi.hoisted(() => ({
22+
mockGetFreeTierLimit: vi.fn(),
23+
mockGetPerUserMinimumLimit: vi.fn(),
24+
mockHasPaidSubscriptionStatus: vi.fn(),
25+
mockIsOrgScopedSubscription: vi.fn(),
26+
}))
27+
28+
vi.mock('@/lib/billing/subscriptions/utils', () => ({
29+
canEditUsageLimit: vi.fn(),
30+
getFreeTierLimit: mockGetFreeTierLimit,
31+
getPerUserMinimumLimit: mockGetPerUserMinimumLimit,
32+
getPlanPricing: vi.fn(() => ({ basePrice: 20 })),
33+
hasPaidSubscriptionStatus: mockHasPaidSubscriptionStatus,
34+
hasUsableSubscriptionAccess: vi.fn(),
35+
isOrgScopedSubscription: mockIsOrgScopedSubscription,
36+
}))
37+
38+
vi.mock('@/lib/billing/core/plan', () => ({
39+
getHighestPrioritySubscription: vi.fn(),
40+
}))
41+
42+
vi.mock('@/lib/billing/core/access', () => ({
43+
getEffectiveBillingStatus: vi.fn(),
44+
}))
45+
46+
vi.mock('@/lib/billing/core/usage-log', () => ({
47+
getBillingPeriodUsageCost: vi.fn(),
48+
}))
49+
50+
vi.mock('@/lib/billing/credits/daily-refresh', () => ({
51+
computeDailyRefreshConsumed: vi.fn(),
52+
getOrgMemberRefreshBounds: vi.fn(),
53+
}))
54+
55+
vi.mock('@/components/emails', () => ({
56+
getEmailSubject: vi.fn(),
57+
renderCreditsExhaustedEmail: vi.fn(),
58+
renderFreeTierUpgradeEmail: vi.fn(),
59+
renderUsageThresholdEmail: vi.fn(),
60+
}))
61+
62+
vi.mock('@/lib/messaging/email/mailer', () => ({
63+
sendEmail: vi.fn(),
64+
}))
65+
66+
vi.mock('@/lib/messaging/email/unsubscribe', () => ({
67+
getEmailPreferences: vi.fn(),
68+
}))
69+
70+
import { getUserUsageLimit } from '@/lib/billing/core/usage'
71+
72+
const PRO_SUBSCRIPTION = {
73+
id: 'sub-1',
74+
plan: 'pro',
75+
status: 'active',
76+
referenceId: 'user-1',
77+
seats: 1,
78+
periodStart: null,
79+
periodEnd: null,
80+
} as never
81+
82+
describe('getUserUsageLimit', () => {
83+
beforeEach(() => {
84+
vi.clearAllMocks()
85+
mockIsOrgScopedSubscription.mockReturnValue(false)
86+
})
87+
88+
it('returns the stored limit when set', async () => {
89+
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: '25' }])
90+
91+
const limit = await getUserUsageLimit('user-1', null)
92+
93+
expect(limit).toBe(25)
94+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
95+
})
96+
97+
it('throws when no userStats row exists', async () => {
98+
dbChainMockFns.limit.mockResolvedValueOnce([])
99+
100+
await expect(getUserUsageLimit('user-1', null)).rejects.toThrow('No user stats record found')
101+
})
102+
103+
it('heals a null limit to the free-tier default for free users', async () => {
104+
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: null }])
105+
dbChainMockFns.returning.mockResolvedValueOnce([{ currentUsageLimit: '10' }])
106+
mockGetFreeTierLimit.mockReturnValue(10)
107+
108+
const limit = await getUserUsageLimit('user-1', null)
109+
110+
expect(limit).toBe(10)
111+
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
112+
expect(dbChainMockFns.set).toHaveBeenCalledWith({
113+
currentUsageLimit: '10',
114+
usageLimitUpdatedAt: expect.any(Date),
115+
})
116+
const [condition] = dbChainMockFns.where.mock.calls.at(-1) ?? []
117+
expect(condition).toMatchObject({
118+
type: 'and',
119+
conditions: [{ type: 'eq', right: 'user-1' }, { type: 'isNull' }],
120+
})
121+
})
122+
123+
it('heals a null limit to the plan minimum for paid personal subscriptions', async () => {
124+
dbChainMockFns.limit.mockResolvedValueOnce([{ currentUsageLimit: null }])
125+
dbChainMockFns.returning.mockResolvedValueOnce([{ currentUsageLimit: '40' }])
126+
mockHasPaidSubscriptionStatus.mockReturnValue(true)
127+
mockGetPerUserMinimumLimit.mockReturnValue(40)
128+
129+
const limit = await getUserUsageLimit('user-1', PRO_SUBSCRIPTION)
130+
131+
expect(limit).toBe(40)
132+
expect(mockGetPerUserMinimumLimit).toHaveBeenCalledWith(PRO_SUBSCRIPTION)
133+
expect(dbChainMockFns.set).toHaveBeenCalledWith({
134+
currentUsageLimit: '40',
135+
usageLimitUpdatedAt: expect.any(Date),
136+
})
137+
})
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+
})
157+
})

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

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { db } from '@sim/db'
22
import { member, organization, settings, user, userStats } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { generateId } from '@sim/utils/id'
5-
import { eq } from 'drizzle-orm'
5+
import { and, eq, isNull } from 'drizzle-orm'
66
import {
77
getEmailSubject,
88
renderCreditsExhaustedEmail,
@@ -495,6 +495,13 @@ export async function updateUserUsageLimit(
495495
* Get usage limit for a user (used by checkUsageStatus for server-side
496496
* checks). Org-scoped subs return the organization limit;
497497
* personally-scoped subs return the individual user limit from userStats.
498+
*
499+
* Org-scoped members carry a null `currentUsageLimit` by design (see
500+
* `syncUsageLimitsFromSubscription`). A user whose subscription stops being
501+
* 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. 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.
498505
*/
499506
export async function getUserUsageLimit(
500507
userId: string,
@@ -537,9 +544,43 @@ export async function getUserUsageLimit(
537544
}
538545

539546
if (!userStatsQuery[0].currentUsageLimit) {
540-
throw new Error(
541-
`Invalid null usage limit for ${subscription?.plan || 'free'} user: ${userId}. User stats must be properly initialized.`
542-
)
547+
const fallbackLimit =
548+
subscription && hasPaidSubscriptionStatus(subscription.status)
549+
? getPerUserMinimumLimit(subscription)
550+
: getFreeTierLimit()
551+
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)
568+
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+
}
582+
583+
return fallbackLimit
543584
}
544585

545586
return toNumber(toDecimal(userStatsQuery[0].currentUsageLimit))

0 commit comments

Comments
 (0)