Skip to content

Commit c10f656

Browse files
committed
fix(billing): drop priorUsage re-arm to make dedup a single atomic claim (no duplicate-email race)
1 parent d43e683 commit c10f656

3 files changed

Lines changed: 17 additions & 58 deletions

File tree

apps/sim/lib/billing/core/limit-notifications.test.ts

Lines changed: 5 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -118,28 +118,12 @@ describe('maybeSendLimitThresholdEmail', () => {
118118
expect(sendEmailSpy).not.toHaveBeenCalled()
119119
})
120120

121-
it('re-arms then claims when a single jump crosses from below the band past 80% (priorUsage)', async () => {
122-
// prior 50% (re-arm band) → current 90%: re-arm (update) + claim (update) = 2 updates, then send.
123-
await maybeSendLimitThresholdEmail({
124-
...baseUserParams,
125-
currentUsage: 4.5,
126-
limit: 5,
127-
priorUsage: 2.5,
128-
})
129-
expect(dbUpdateSpy).toHaveBeenCalledTimes(2)
130-
expect(sendEmailSpy).toHaveBeenCalledTimes(1)
131-
expect(renderMock).toHaveBeenCalledWith(expect.objectContaining({ kind: 'warning' }))
132-
})
133-
134-
it('does not re-arm when prior usage was already in-band (no spurious reset)', async () => {
135-
// prior 85% and current 90%: both >= re-arm band → claim only, no re-arm update.
136-
await maybeSendLimitThresholdEmail({
137-
...baseUserParams,
138-
currentUsage: 4.5,
139-
limit: 5,
140-
priorUsage: 4.25,
141-
})
121+
it('claims without re-arming on a crossing (re-arm and claim are mutually exclusive)', async () => {
122+
// 90% crossing: a single claim update, no re-arm — so there is no re-arm/claim
123+
// interleaving for a concurrent caller to exploit into a duplicate email.
124+
await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 4.5, limit: 5 })
142125
expect(dbUpdateSpy).toHaveBeenCalledTimes(1)
126+
expect(mockClaim).toHaveBeenCalledTimes(1)
143127
expect(sendEmailSpy).toHaveBeenCalledTimes(1)
144128
})
145129

@@ -185,18 +169,6 @@ describe('maybeSendLimitThresholdEmail', () => {
185169
expect(sendEmailSpy).not.toHaveBeenCalled()
186170
})
187171

188-
it('re-arms then sends on wipe-then-rebuild (priorUsage 0)', async () => {
189-
// prior 0% (empty table) → current 90%: re-arm + claim + send.
190-
await maybeSendLimitThresholdEmail({
191-
...baseUserParams,
192-
currentUsage: 4.5,
193-
limit: 5,
194-
priorUsage: 0,
195-
})
196-
expect(dbUpdateSpy).toHaveBeenCalledTimes(2)
197-
expect(sendEmailSpy).toHaveBeenCalledTimes(1)
198-
})
199-
200172
it('skips when the limit is non-positive', async () => {
201173
await maybeSendLimitThresholdEmail({ ...baseUserParams, currentUsage: 4, limit: 0 })
202174
expect(dbUpdateSpy).not.toHaveBeenCalled()

apps/sim/lib/billing/core/limit-notifications.ts

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -172,13 +172,6 @@ export async function maybeSendLimitThresholdEmail(params: {
172172
usageLabel: string
173173
/** Pre-formatted limit for the email body, e.g. "5 GB", "10 seats". */
174174
limitLabel: string
175-
/**
176-
* Usage immediately BEFORE the mutation, when known (e.g. the pre-insert row
177-
* count). Lets a single large change that jumps from below the re-arm band
178-
* past a threshold still re-arm before claiming, so the re-warning isn't
179-
* suppressed. Omit when only the post-mutation usage is observable.
180-
*/
181-
priorUsage?: number
182175
/**
183176
* When true, only the re-arm is evaluated and no email is ever sent. Used by
184177
* usage-decrease paths (e.g. a storage shrink) where usage can still be above
@@ -198,17 +191,15 @@ export async function maybeSendLimitThresholdEmail(params: {
198191

199192
const { category, scope } = params
200193
const percent = Math.max(0, (params.currentUsage / params.limit) * 100)
201-
const priorPercent =
202-
params.priorUsage != null && params.priorUsage >= 0
203-
? (params.priorUsage / params.limit) * 100
204-
: percent
205194
const desired = thresholdFor(percent)
206195

207196
const stateId = scope === 'user' ? params.userId : params.organizationId
208197
if (!stateId) return
209198

210-
// Re-arm if usage is (or just was) back in the low band, so a fresh climb re-notifies.
211-
if (Math.min(percent, priorPercent) < REARM_BELOW) {
199+
// Re-arm when current usage is back in the low band, so a fresh climb re-notifies.
200+
// Re-arm and claim are mutually exclusive (re-arm only when desired === 0), which
201+
// keeps the dedup a single atomic claim with no re-arm/claim interleaving race.
202+
if (percent < REARM_BELOW) {
212203
await rearmThreshold(scope, stateId, category)
213204
}
214205

@@ -289,8 +280,6 @@ export async function maybeNotifyLimit(params: {
289280
limit: number
290281
usageLabel: string
291282
limitLabel: string
292-
/** Usage before the mutation, when known — see {@link maybeSendLimitThresholdEmail}. */
293-
priorUsage?: number
294283
/** Re-arm only, never send — for usage-decrease callers. See {@link maybeSendLimitThresholdEmail}. */
295284
rearmOnly?: boolean
296285
}): Promise<void> {
@@ -318,7 +307,6 @@ export async function maybeNotifyLimit(params: {
318307
limit: params.limit,
319308
usageLabel: params.usageLabel,
320309
limitLabel: params.limitLabel,
321-
priorUsage: params.priorUsage,
322310
rearmOnly: params.rearmOnly,
323311
userId: params.billedUserId,
324312
userEmail,

apps/sim/lib/table/billing.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ const TABLE_ROW_NOTIFY_PERCENT = 80
2323
*/
2424
async function maybeNotifyTableRowLimit(
2525
workspaceId: string,
26-
currentRowCount: number,
2726
projectedRowCount: number,
2827
limit: number
2928
): Promise<void> {
@@ -39,7 +38,6 @@ async function maybeNotifyTableRowLimit(
3938
limit,
4039
usageLabel: `${projectedRowCount.toLocaleString('en-US')} rows`,
4140
limitLabel: `${limit.toLocaleString('en-US')} rows`,
42-
priorUsage: currentRowCount,
4341
})
4442
} catch (error) {
4543
logger.error('Error evaluating table row-limit notification:', error)
@@ -51,7 +49,13 @@ async function maybeNotifyTableRowLimit(
5149
* gated so only near-limit writes pay the cost. Shared by every insert path
5250
* ({@link assertRowCapacity} and the transactional upsert/import branches that
5351
* check capacity with {@link wouldExceedRowLimit} instead). Pass the pre-insert
54-
* `currentRowCount` so a delete-then-insert jump re-arms correctly.
52+
* `currentRowCount` and `addedRows` so the projected count drives the notify gate.
53+
*
54+
* Re-arm (allowing a fresh warning) happens on any insert that lands the table
55+
* back below the re-arm band. Deleting straight below the band and then jumping
56+
* back over a threshold in a single insert (with no intermediate write) is a
57+
* best-effort gap — the 100% reached email and first-time crossings are
58+
* unaffected. This keeps the dedup a single atomic claim with no re-arm/claim race.
5559
*/
5660
export function notifyTableRowUsage(params: {
5761
workspaceId: string
@@ -62,12 +66,7 @@ export function notifyTableRowUsage(params: {
6266
if (params.limit <= 0) return
6367
const projected = params.currentRowCount + params.addedRows
6468
if ((projected / params.limit) * 100 >= TABLE_ROW_NOTIFY_PERCENT) {
65-
void maybeNotifyTableRowLimit(
66-
params.workspaceId,
67-
params.currentRowCount,
68-
projected,
69-
params.limit
70-
)
69+
void maybeNotifyTableRowLimit(params.workspaceId, projected, params.limit)
7170
}
7271
}
7372

0 commit comments

Comments
 (0)