Skip to content

Commit 7e842d1

Browse files
committed
fix(billing): anchor shared sum expiry with a portable PX and a floor for slow sums
1 parent 79ba544 commit 7e842d1

2 files changed

Lines changed: 29 additions & 12 deletions

File tree

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ describe.runIf(Boolean(redisUrl))('shared reporting usage read', () => {
105105
await expect(readSoftGateUsageCost(payer, REPORTING)).resolves.toBe(5.75)
106106
})
107107

108-
it('does not store a sum that began more than a TTL ago', async () => {
108+
it('shares a sum that ran longer than the TTL for a short floor instead of dropping it', async () => {
109109
const now = Date.now()
110110
vi.useFakeTimers({ toFake: ['Date'] })
111111
vi.setSystemTime(now - REPORTING_USAGE_CACHE_TTL_MS - 1_000)
@@ -116,7 +116,10 @@ describe.runIf(Boolean(redisUrl))('shared reporting usage read', () => {
116116
})
117117

118118
await expect(readSoftGateUsageCost(payer, REPORTING)).resolves.toBe(5.75)
119-
expect(await redis.get(sharedKey(payer))).toBeNull()
119+
expect(await redis.get(sharedKey(payer))).toBe('5.75')
120+
const ttl = await redis.pttl(sharedKey(payer))
121+
expect(ttl).toBeGreaterThan(4_000)
122+
expect(ttl).toBeLessThanOrEqual(5_000)
120123
})
121124

122125
it('anchors a stored sum expiry to when the sum began, not when it was written', async () => {

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

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,22 @@ const logger = createLogger('ReportingUsageCache')
2929
*
3030
* A sum is held both in Redis, shared by every process, and in each process that reads it. It
3131
* reflects the ledger as of the moment its sum began, and its Redis expiry is anchored to that
32-
* moment, so a sum is served from Redis for at most this TTL plus its jitter after it began —
33-
* including however long the sum itself ran — plus up to this TTL again in the reading process.
32+
* moment, so it is served from Redis for at most max(this TTL + jitter, the sum's duration +
33+
* {@link MIN_SHARED_TTL_MS}) after it began, plus up to this TTL again in the reading process.
3434
*/
3535
export const REPORTING_USAGE_CACHE_TTL_MS = 30_000
3636

3737
/** Redis expiry is jittered by up to this much, so payers summed together do not expire together. */
3838
const SHARED_TTL_JITTER_MS = 5_000
3939

40+
/**
41+
* The shortest time a sum is kept in Redis. A sum that ran longer than the TTL would otherwise
42+
* expire on arrival, and under the database pressure that makes sums slow, every process would
43+
* then run the same slow sum again. Five seconds shares it with the processes waiting on it while
44+
* adding little staleness next to the time the sum itself took.
45+
*/
46+
const MIN_SHARED_TTL_MS = 5_000
47+
4048
/**
4149
* How long a read waits on a connected Redis before summing the ledger instead, so a socket that
4250
* has silently stopped answering costs one sum rather than the shared client's long timeouts.
@@ -96,20 +104,26 @@ function warnSharedWriteFailed(error: unknown): void {
96104

97105
/**
98106
* Fire-and-forget: a read never waits on, or fails because of, the shared write. The expiry is
99-
* anchored to when the sum began (`PXAT`), and a sum already older than the TTL is not written, so
100-
* neither a slow sum nor a write the client resends after a reconnect can give an old total a
101-
* fresh expiry. The write only lands when no sum is stored (`NX`), so an older sum can never
102-
* replace a fresher one or extend its expiry.
107+
* anchored to when the sum began: the remaining lifetime is computed here and written with `PX`,
108+
* which every Redis version accepts, floored at {@link MIN_SHARED_TTL_MS}. A write the client
109+
* resends after a reconnect re-applies that same relative lifetime from the resend, so it can
110+
* extend the expiry by at most the reconnect delay. The write only lands when no sum is stored
111+
* (`NX`), so an older sum can never replace a fresher one or extend its expiry.
103112
*/
104113
function writeSharedReportingUsageCost(key: string, cost: number, sumStartedAt: number): void {
105114
try {
106-
if (Date.now() >= sumStartedAt + REPORTING_USAGE_CACHE_TTL_MS) return
107115
const redis = readyRedisClient()
108116
if (!redis) return
109-
const expiresAt =
110-
sumStartedAt + REPORTING_USAGE_CACHE_TTL_MS + randomInt(0, SHARED_TTL_JITTER_MS)
117+
const remainingMs =
118+
sumStartedAt + REPORTING_USAGE_CACHE_TTL_MS + randomInt(0, SHARED_TTL_JITTER_MS) - Date.now()
111119
redis
112-
.set(sharedReportingUsageKey(key), String(cost), 'PXAT', expiresAt, 'NX')
120+
.set(
121+
sharedReportingUsageKey(key),
122+
String(cost),
123+
'PX',
124+
Math.max(remainingMs, MIN_SHARED_TTL_MS),
125+
'NX'
126+
)
113127
.catch(warnSharedWriteFailed)
114128
} catch (error) {
115129
warnSharedWriteFailed(error)

0 commit comments

Comments
 (0)