Skip to content

Commit 5b4bcf9

Browse files
committed
improvement(billing): skip the shared usage read while Redis is not ready
1 parent 655432c commit 5b4bcf9

4 files changed

Lines changed: 122 additions & 44 deletions

File tree

‎apps/sim/lib/billing/core/reporting-usage-cache.integration.ts‎

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* real Redis. Skipped without `TEST_REDIS_URL`. Each test uses a fresh payer, so the in-process
44
* cache is always cold and every read models a new process.
55
*/
6+
7+
import { type AddressInfo, createServer, type Socket } from 'node:net'
68
import type { db } from '@sim/db'
79
import * as schema from '@sim/db/schema'
810
import { readTestDatabaseUrl, readTestRedisUrl } from '@sim/db/testing/test-infrastructure'
@@ -78,16 +80,14 @@ describe.runIf(Boolean(redisUrl))('shared reporting usage read', () => {
7880
await connection.end()
7981
})
8082

81-
it('serves a sum another process stored without summing the ledger', async () => {
83+
it('serves a sum another process stored instead of the ledger', async () => {
8284
await redis.set(sharedKey(payer), '12.5', 'PX', 30_000)
8385

8486
await expect(readSoftGateUsageCost(payer, REPORTING)).resolves.toBe(12.5)
85-
expect(transaction).not.toHaveBeenCalled()
8687
})
8788

8889
it('sums the ledger exactly on a miss and stores the sum for other processes', async () => {
8990
await expect(readSoftGateUsageCost(payer, REPORTING)).resolves.toBe(5.75)
90-
expect(transaction).toHaveBeenCalledTimes(1)
9191

9292
await vi.waitFor(async () => expect(await redis.get(sharedKey(payer))).toBe('5.75'))
9393
const ttl = await redis.pttl(sharedKey(payer))
@@ -99,7 +99,6 @@ describe.runIf(Boolean(redisUrl))('shared reporting usage read', () => {
9999
await redis.set(sharedKey(payer), 'not-a-number', 'PX', 30_000)
100100

101101
await expect(readSoftGateUsageCost(payer, REPORTING)).resolves.toBe(5.75)
102-
expect(transaction).toHaveBeenCalledTimes(1)
103102
})
104103

105104
it('never lets a slower, older sum replace one stored while it ran', async () => {
@@ -121,20 +120,45 @@ describe.runIf(Boolean(redisUrl))('shared reporting usage read', () => {
121120
await expect(readSoftGateUsageCost(payer, REPORTING)).resolves.toBe(5.75)
122121
})
123122

124-
it('sums the ledger promptly when Redis is unreachable', async () => {
125-
const unreachable = new Redis('redis://127.0.0.1:1', {
126-
enableOfflineQueue: true,
127-
maxRetriesPerRequest: null,
128-
retryStrategy: () => 1_000,
123+
it('sums the ledger without issuing or queuing a command while Redis is not ready', async () => {
124+
const notReady = new Redis(redisUrl!, { lazyConnect: true, maxRetriesPerRequest: 0 })
125+
redisConfigMockFns.mockGetRedisClient.mockReturnValue(notReady)
126+
try {
127+
await expect(readSoftGateUsageCost(payer, REPORTING)).resolves.toBe(5.75)
128+
expect(notReady.status).toBe('wait')
129+
130+
await notReady.connect()
131+
await notReady.ping()
132+
expect(await redis.get(sharedKey(payer))).toBeNull()
133+
} finally {
134+
notReady.disconnect()
135+
}
136+
})
137+
138+
it('sums the ledger promptly when a connected Redis stops answering', async () => {
139+
const sockets = new Set<Socket>()
140+
/** Completes the client's handshake, then never answers a read. */
141+
const silent = createServer((socket) => {
142+
sockets.add(socket)
143+
socket.on('data', (data) => {
144+
const text = data.toString()
145+
if (/\bGET\b/i.test(text)) return
146+
socket.write('+OK\r\n'.repeat(text.match(/^\*\d+\r\n/gm)?.length ?? 0))
147+
})
129148
})
130-
unreachable.on('error', () => undefined)
131-
redisConfigMockFns.mockGetRedisClient.mockReturnValue(unreachable)
149+
await new Promise<void>((resolve) => silent.listen(0, '127.0.0.1', resolve))
150+
const { port } = silent.address() as AddressInfo
151+
const hung = new Redis({ host: '127.0.0.1', port, lazyConnect: true, enableReadyCheck: false })
152+
redisConfigMockFns.mockGetRedisClient.mockReturnValue(hung)
132153
try {
154+
await hung.connect()
133155
const startedAt = Date.now()
134156
await expect(readSoftGateUsageCost(payer, REPORTING)).resolves.toBe(5.75)
135157
expect(Date.now() - startedAt).toBeLessThan(2_000)
136158
} finally {
137-
unreachable.disconnect()
159+
hung.disconnect()
160+
for (const socket of sockets) socket.destroy()
161+
silent.close()
138162
}
139163
})
140164
})

‎apps/sim/lib/billing/core/reporting-usage-cache.ts‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
22
import { createLogger } from '@sim/logger'
33
import { getErrorMessage } from '@sim/utils/errors'
44
import { randomInt } from '@sim/utils/random'
5+
import type Redis from 'ioredis'
56
import { LRUCache } from 'lru-cache'
67
import {
78
type BillingEntity,
@@ -37,9 +38,8 @@ export const REPORTING_USAGE_CACHE_TTL_MS = 30_000
3738
const SHARED_TTL_JITTER_MS = 5_000
3839

3940
/**
40-
* How long a read waits on Redis before summing the ledger instead. The shared client queues
41-
* commands while disconnected and has long timeouts, so without this a Redis outage would stall
42-
* every gate behind it rather than cost one sum.
41+
* How long a read waits on a connected Redis before summing the ledger instead, so a socket that
42+
* has silently stopped answering costs one sum rather than the shared client's long timeouts.
4343
*/
4444
const SHARED_READ_TIMEOUT_MS = 250
4545

@@ -53,14 +53,26 @@ function sharedReportingUsageKey(key: string): string {
5353
return `usage:reporting:${SHARED_KEY_VERSION}:${key}`
5454
}
5555

56+
/**
57+
* The shared client, only while its connection is ready. A disconnected client parks commands in
58+
* its offline queue and replays them on reconnect, which would land a stale sum with a fresh
59+
* expiry, and `NX` would then keep newer sums out; so while it is not ready, no command is issued
60+
* at all and the ledger answers instead. Trigger.dev's `init` hook warms the connection, so it is
61+
* ready by the time a task reads.
62+
*/
63+
function readyRedisClient(): Redis | null {
64+
const redis = getRedisClient()
65+
return redis?.status === 'ready' ? redis : null
66+
}
67+
5668
/**
5769
* A sum another process stored, or `undefined` when there is none to use. Redis being absent,
58-
* slow, or failing, and a value that is not a non-negative number, are all misses: the caller
59-
* sums the ledger, so the cache can cost a read its latency but never its answer.
70+
* not ready, slow, or failing, and a value that is not a non-negative number, are all misses:
71+
* the caller sums the ledger, so the cache can cost a read its latency but never its answer.
6072
*/
6173
async function readSharedReportingUsageCost(key: string): Promise<number | undefined> {
6274
try {
63-
const redis = getRedisClient()
75+
const redis = readyRedisClient()
6476
if (!redis) return undefined
6577
const stored = await withinDeadline(
6678
() => redis.get(sharedReportingUsageKey(key)),
@@ -89,7 +101,7 @@ function warnSharedWriteFailed(error: unknown): void {
89101
*/
90102
function writeSharedReportingUsageCost(key: string, cost: number): void {
91103
try {
92-
const redis = getRedisClient()
104+
const redis = readyRedisClient()
93105
if (!redis) return
94106
const ttlMs = REPORTING_USAGE_CACHE_TTL_MS + randomInt(0, SHARED_TTL_JITTER_MS)
95107
redis

‎apps/sim/lib/billing/core/usage-threshold-email.integration.ts‎

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
/**
22
* The level-triggered usage threshold email against real claim state in a disposable PostgreSQL
3-
* schema. Only delivery is stubbed: the mailer is the external boundary, and a sent email is the
4-
* outcome under test.
3+
* schema. Only delivery is stubbed: the mailer is the external boundary, so the outcomes under
4+
* test are the messages handed to it and the claim state left in the database.
55
*/
66
import type { db } from '@sim/db'
77
import * as schema from '@sim/db/schema'
88
import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
99
import { emailMailerMock, emailMailerMockFns } from '@sim/testing/mocks/email-mailer.mock'
10-
import { emailTemplatesMock } from '@sim/testing/mocks/email-templates.mock'
10+
import { emailTemplatesMock, emailTemplatesMockFns } from '@sim/testing/mocks/email-templates.mock'
1111
import { envFlagsMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing/mocks/env-flags.mock'
1212
import { generateId } from '@sim/utils/id'
1313
import { drizzle } from 'drizzle-orm/postgres-js'
@@ -59,6 +59,40 @@ function notify(
5959
})
6060
}
6161

62+
/** Every message handed to the mailer so far, as who received which email. */
63+
function delivered(): { to: string; subject: string }[] {
64+
return mockSendEmail.mock.calls.map(([message]) => ({ to: message.to, subject: message.subject }))
65+
}
66+
67+
function warning(): { to: string; subject: string } {
68+
return {
69+
to: `${adminId}@example.com`,
70+
subject: emailTemplatesMockFns.mockGetEmailSubject('usage-threshold'),
71+
}
72+
}
73+
74+
function reached(): { to: string; subject: string } {
75+
return {
76+
to: `${adminId}@example.com`,
77+
subject: emailTemplatesMockFns.mockGetLimitEmailSubject('credits', 'reached'),
78+
}
79+
}
80+
81+
/** The organization's persisted threshold claims. */
82+
async function claims(): Promise<Record<string, number>> {
83+
const [row] = await connection<{ limit_notifications: Record<string, number> | null }[]>`
84+
SELECT limit_notifications FROM organization WHERE id = ${organizationId}`
85+
return row.limit_notifications ?? {}
86+
}
87+
88+
function claimOf(threshold: 80 | 100, periodStart = SEPTEMBER, limitCents = 10_000) {
89+
return {
90+
credits: threshold,
91+
creditsPeriod: Math.floor(periodStart.getTime() / 86_400_000),
92+
creditsLimit: limitCents,
93+
}
94+
}
95+
6296
async function setNotificationsEnabled(enabled: boolean): Promise<void> {
6397
await connection`INSERT INTO settings VALUES (${adminId}, ${adminId}, ${enabled})
6498
ON CONFLICT (id) DO UPDATE SET billing_usage_notifications_enabled = ${enabled}`
@@ -97,7 +131,8 @@ describe('usage threshold email', () => {
97131
await Promise.all([notify(85), notify(85), notify(86)])
98132
await notify(90)
99133

100-
expect(mockSendEmail).toHaveBeenCalledTimes(1)
134+
expect(delivered()).toEqual([warning()])
135+
expect(await claims()).toEqual(claimOf(80))
101136
})
102137

103138
it('still sends the reached email after the warning, but never the warning after it', async () => {
@@ -106,7 +141,8 @@ describe('usage threshold email', () => {
106141
await notify(100)
107142
await notify(85)
108143

109-
expect(mockSendEmail).toHaveBeenCalledTimes(2)
144+
expect(delivered()).toEqual([warning(), reached()])
145+
expect(await claims()).toEqual(claimOf(100))
110146
})
111147

112148
it('re-arms both thresholds whenever the billing period changes, even to an earlier one', async () => {
@@ -115,31 +151,36 @@ describe('usage threshold email', () => {
115151
await notify(100, { periodStart: OCTOBER })
116152
await notify(85)
117153

118-
expect(mockSendEmail).toHaveBeenCalledTimes(4)
154+
expect(delivered()).toEqual([reached(), warning(), reached(), warning()])
155+
expect(await claims()).toEqual(claimOf(80))
119156
})
120157

121158
it('warns again at a raised limit after the old one was reached', async () => {
122159
await notify(100)
123160
await notify(100, { limit: 125 })
124161
await notify(110, { limit: 125 })
125162

126-
expect(mockSendEmail).toHaveBeenCalledTimes(2)
163+
expect(delivered()).toEqual([reached(), warning()])
164+
expect(await claims()).toEqual(claimOf(80, SEPTEMBER, 12_500))
127165
})
128166

129167
it('keeps the claim for a later completion when nobody can be notified', async () => {
130168
await setNotificationsEnabled(false)
131169
await notify(90)
170+
expect(delivered()).toEqual([])
171+
expect(await claims()).toEqual({})
172+
132173
await setNotificationsEnabled(true)
133174
await notify(90)
134-
135-
expect(mockSendEmail).toHaveBeenCalledTimes(1)
175+
expect(delivered()).toEqual([warning()])
136176
})
137177

138178
it('keeps the claim when a completion recorded no cost', async () => {
139179
await notify(90, { costDelta: 0 })
140-
expect(mockSendEmail).not.toHaveBeenCalled()
180+
expect(delivered()).toEqual([])
181+
expect(await claims()).toEqual({})
141182

142183
await notify(90)
143-
expect(mockSendEmail).toHaveBeenCalledTimes(1)
184+
expect(delivered()).toEqual([warning()])
144185
})
145186
})

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

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -331,20 +331,25 @@ describe('maybeSendUsageThresholdEmail', () => {
331331
resetEnvFlagsMock()
332332
})
333333

334+
/** Who received which email, identified by its subject and rendered template. */
335+
function sentMessages() {
336+
return mockSendEmail.mock.calls.map(([message]) => ({
337+
to: message.to,
338+
subject: message.subject,
339+
html: message.html,
340+
}))
341+
}
342+
334343
it('emails a paid personal account at 100% with the raise-your-limit template', async () => {
335344
await maybeSendUsageThresholdEmail({
336345
...paidUser,
337346
usageBefore: 19,
338347
costDelta: 1,
339348
})
340349

341-
expect(mockRenderUsageLimitReached).toHaveBeenCalledWith(
342-
expect.objectContaining({ scope: 'user', planName: 'Pro' })
343-
)
344-
expect(mockRenderCreditsExhausted).not.toHaveBeenCalled()
345-
expect(mockRenderUsageThreshold).not.toHaveBeenCalled()
346-
expect(mockGetLimitEmailSubject).toHaveBeenCalledWith('credits', 'reached')
347-
expect(mockSendEmail).toHaveBeenCalledTimes(1)
350+
expect(sentMessages()).toEqual([
351+
{ to: 'user-1@example.com', subject: 'Limit subject', html: '<html>reached</html>' },
352+
])
348353
})
349354

350355
it('fans out to org admins at 100% and skips non-admin members', async () => {
@@ -365,12 +370,8 @@ describe('maybeSendUsageThresholdEmail', () => {
365370
limit: 500,
366371
})
367372

368-
expect(mockSendEmail).toHaveBeenCalledTimes(1)
369-
expect(mockSendEmail).toHaveBeenCalledWith(
370-
expect.objectContaining({ to: 'admin@example.com', emailType: 'notifications' })
371-
)
372-
expect(mockRenderUsageLimitReached).toHaveBeenCalledWith(
373-
expect.objectContaining({ scope: 'organization' })
374-
)
373+
expect(sentMessages()).toEqual([
374+
{ to: 'admin@example.com', subject: 'Limit subject', html: '<html>reached</html>' },
375+
])
375376
})
376377
})

0 commit comments

Comments
 (0)