Skip to content

Commit ea79a8b

Browse files
committed
improvement(billing): share the enterprise reporting usage read across processes
1 parent a0c93d6 commit ea79a8b

8 files changed

Lines changed: 406 additions & 88 deletions

File tree

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ const logger = createLogger('LimitNotifications')
1717
/** Limit categories that send per-category threshold emails (credits has its own path). */
1818
export type LimitCategory = Extract<UpgradeReason, 'storage' | 'tables' | 'seats'>
1919

20+
/** Every category whose emailed threshold is persisted, including credits. */
21+
type ClaimCategory = LimitCategory | Extract<UpgradeReason, 'credits'>
22+
2023
const WARN_THRESHOLD = 80
2124
const REACH_THRESHOLD = 100
2225
/** Usage must drop below this band before the same threshold can re-notify (hysteresis). */
@@ -41,7 +44,7 @@ function thresholdFor(percent: number): 0 | 80 | 100 {
4144
async function claimThreshold(
4245
scope: 'user' | 'organization',
4346
id: string,
44-
category: LimitCategory,
47+
category: ClaimCategory,
4548
threshold: number
4649
): Promise<boolean> {
4750
const setExpr = sql`jsonb_set(coalesce(${scope === 'user' ? userStats.limitNotifications : organization.limitNotifications}, '{}'::jsonb), ARRAY[${category}], to_jsonb(${threshold}::int))`
@@ -66,6 +69,24 @@ async function claimThreshold(
6669
return claimed.length > 0
6770
}
6871

72+
const DAY_MS = 24 * 60 * 60 * 1000
73+
74+
/**
75+
* Claim a credits threshold (80 or 100) once per billing period, returning whether THIS call won
76+
* it. The stored value is the period's start day followed by the threshold, so it only grows: a
77+
* later period outranks every claim of an earlier one and re-arms both thresholds with no reset
78+
* write, while within a period a claim of 100 also retires 80, and never the reverse.
79+
*/
80+
export function claimCreditsThreshold(
81+
scope: 'user' | 'organization',
82+
id: string,
83+
periodStart: Date,
84+
threshold: 80 | 100
85+
): Promise<boolean> {
86+
const periodDay = Math.floor(periodStart.getTime() / DAY_MS)
87+
return claimThreshold(scope, id, 'credits', periodDay * 1000 + threshold)
88+
}
89+
6990
/** Re-arm a category (reset its stored threshold to 0) once usage falls back into the low band. */
7091
async function rearmThreshold(
7192
scope: 'user' | 'organization',
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* The shared reporting-usage read against a real ledger in a disposable PostgreSQL schema and a
3+
* real Redis. Skipped without `TEST_REDIS_URL`. Each test uses a fresh payer, so the in-process
4+
* cache is always cold and every read models a new process.
5+
*/
6+
import type { db } from '@sim/db'
7+
import * as schema from '@sim/db/schema'
8+
import { readTestDatabaseUrl, readTestRedisUrl } from '@sim/db/testing/test-infrastructure'
9+
import { redisConfigMock, redisConfigMockFns } from '@sim/testing/mocks/redis-config.mock'
10+
import { generateId } from '@sim/utils/id'
11+
import { drizzle } from 'drizzle-orm/postgres-js'
12+
import Redis from 'ioredis'
13+
import postgres from 'postgres'
14+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
15+
16+
const { transaction } = vi.hoisted(() => ({ transaction: vi.fn() }))
17+
const databaseUrl = readTestDatabaseUrl()
18+
const redisUrl = readTestRedisUrl()
19+
20+
vi.mock('@sim/db', () => ({ db: { transaction }, dbReplica: {} }))
21+
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
22+
23+
import { readSoftGateUsageCost } from '@/lib/billing/core/reporting-usage-cache'
24+
import type { BillingEntity, UsageQueryPeriod } from '@/lib/billing/core/usage-log'
25+
26+
const schemaName = `reporting_usage_${generateId().replaceAll('-', '')}`
27+
const connection = postgres(databaseUrl, {
28+
max: 2,
29+
prepare: false,
30+
connection: { search_path: schemaName },
31+
onnotice: () => undefined,
32+
})
33+
const database = drizzle(connection, { schema }) as typeof db
34+
35+
const REPORTING: UsageQueryPeriod = {
36+
start: new Date('2026-01-01T00:00:00.000Z'),
37+
end: new Date('2027-01-01T00:00:00.000Z'),
38+
source: 'reporting',
39+
}
40+
41+
function sharedKey(payer: BillingEntity): string {
42+
return `usage:reporting:v1:${payer.type}:${payer.id}:reporting:${REPORTING.start.toISOString()}:${REPORTING.end.toISOString()}`
43+
}
44+
45+
describe.runIf(Boolean(redisUrl))('shared reporting usage read', () => {
46+
let redis: Redis
47+
let payer: BillingEntity
48+
49+
beforeAll(async () => {
50+
redis = new Redis(redisUrl!, { lazyConnect: true, maxRetriesPerRequest: 0 })
51+
await redis.connect()
52+
await connection.unsafe(`CREATE SCHEMA "${schemaName}"`)
53+
await connection.unsafe(`CREATE TABLE usage_log (
54+
id text PRIMARY KEY, cost numeric NOT NULL, billing_entity_type text,
55+
billing_entity_id text, billing_period_start timestamp, billing_period_end timestamp,
56+
created_at timestamp NOT NULL
57+
)`)
58+
transaction.mockImplementation((callback) => database.transaction(callback))
59+
})
60+
61+
beforeEach(async () => {
62+
transaction.mockClear()
63+
redisConfigMockFns.mockGetRedisClient.mockReturnValue(redis)
64+
payer = { type: 'organization', id: generateId() }
65+
await connection`INSERT INTO usage_log (id, cost, billing_entity_type, billing_entity_id, created_at)
66+
VALUES (${generateId()}, 4.25, 'organization', ${payer.id}, '2026-03-01'),
67+
(${generateId()}, 1.5, 'organization', ${payer.id}, '2026-06-01'),
68+
(${generateId()}, 99, 'organization', ${payer.id}, '2025-12-31')`
69+
})
70+
71+
afterEach(async () => {
72+
await redis.del(sharedKey(payer))
73+
})
74+
75+
afterAll(async () => {
76+
await redis?.quit()
77+
await connection.unsafe(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`)
78+
await connection.end()
79+
})
80+
81+
it('serves a sum another process stored without summing the ledger', async () => {
82+
await redis.set(sharedKey(payer), '12.5', 'PX', 30_000)
83+
84+
await expect(readSoftGateUsageCost(payer, REPORTING)).resolves.toBe(12.5)
85+
expect(transaction).not.toHaveBeenCalled()
86+
})
87+
88+
it('sums the ledger exactly on a miss and stores the sum for other processes', async () => {
89+
await expect(readSoftGateUsageCost(payer, REPORTING)).resolves.toBe(5.75)
90+
expect(transaction).toHaveBeenCalledTimes(1)
91+
92+
await vi.waitFor(async () => expect(await redis.get(sharedKey(payer))).toBe('5.75'))
93+
const ttl = await redis.pttl(sharedKey(payer))
94+
expect(ttl).toBeGreaterThan(25_000)
95+
expect(ttl).toBeLessThanOrEqual(35_000)
96+
})
97+
98+
it('treats an unreadable stored sum as a miss and overwrites it', async () => {
99+
await redis.set(sharedKey(payer), 'not-a-number', 'PX', 30_000)
100+
101+
await expect(readSoftGateUsageCost(payer, REPORTING)).resolves.toBe(5.75)
102+
await vi.waitFor(async () => expect(await redis.get(sharedKey(payer))).toBe('5.75'))
103+
})
104+
105+
it('sums the ledger promptly when Redis is unreachable', async () => {
106+
const unreachable = new Redis('redis://127.0.0.1:1', {
107+
enableOfflineQueue: true,
108+
maxRetriesPerRequest: null,
109+
retryStrategy: () => 1_000,
110+
})
111+
unreachable.on('error', () => undefined)
112+
redisConfigMockFns.mockGetRedisClient.mockReturnValue(unreachable)
113+
try {
114+
const startedAt = Date.now()
115+
await expect(readSoftGateUsageCost(payer, REPORTING)).resolves.toBe(5.75)
116+
expect(Date.now() - startedAt).toBeLessThan(2_000)
117+
} finally {
118+
unreachable.disconnect()
119+
}
120+
})
121+
})

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

Lines changed: 90 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import { db } from '@sim/db'
2+
import { createLogger } from '@sim/logger'
3+
import { getErrorMessage } from '@sim/utils/errors'
4+
import { randomInt } from '@sim/utils/random'
25
import { LRUCache } from 'lru-cache'
36
import {
47
type BillingEntity,
58
getBillingPeriodUsageCost,
69
type UsageQueryPeriod,
710
} from '@/lib/billing/core/usage-log'
11+
import { getRedisClient } from '@/lib/core/config/redis'
12+
import { withinDeadline } from '@/lib/core/utils/deadline'
813
import type { DbClient } from '@/lib/db/types'
914

15+
const logger = createLogger('ReportingUsageCache')
16+
1017
/**
1118
* How long a reporting-window usage sum is served before it is summed again.
1219
*
@@ -18,23 +25,97 @@ import type { DbClient } from '@/lib/db/types'
1825
* an admission gate lets a payer run on for at most this long past their limit, and a sum at or
1926
* above the limit is a refusal the true sum would also give. Thirty seconds keeps that overrun
2027
* small against a year-long allowance while turning a per-event scan into one per window.
28+
*
29+
* A sum is held both in Redis, shared by every process, and in each process that reads it, so a
30+
* served sum can be up to twice this old (plus the Redis expiry's jitter).
2131
*/
2232
export const REPORTING_USAGE_CACHE_TTL_MS = 30_000
2333

34+
/** Redis expiry is jittered by up to this much, so payers summed together do not expire together. */
35+
const SHARED_TTL_JITTER_MS = 5_000
36+
37+
/**
38+
* How long a read waits on Redis before summing the ledger instead. The shared client queues
39+
* commands while disconnected and has long timeouts, so without this a Redis outage would stall
40+
* every gate behind it rather than cost one sum.
41+
*/
42+
const SHARED_READ_TIMEOUT_MS = 250
43+
44+
/** Bump when a stored sum's meaning changes; old entries are then ignored. */
45+
const SHARED_KEY_VERSION = 'v1'
46+
2447
/** A usage window known to be an enterprise reporting window — the only kind this cache serves. */
2548
type ReportingQueryPeriod = UsageQueryPeriod & { source: 'reporting' }
2649

50+
function sharedReportingUsageKey(key: string): string {
51+
return `usage:reporting:${SHARED_KEY_VERSION}:${key}`
52+
}
53+
54+
/**
55+
* A sum another process stored, or `undefined` when there is none to use. Redis being absent,
56+
* slow, or failing, and a value that is not a non-negative number, are all misses: the caller
57+
* sums the ledger, so the cache can cost a read its latency but never its answer.
58+
*/
59+
async function readSharedReportingUsageCost(key: string): Promise<number | undefined> {
60+
const redis = getRedisClient()
61+
if (!redis) return undefined
62+
try {
63+
const stored = await withinDeadline(
64+
() => redis.get(sharedReportingUsageKey(key)),
65+
Date.now() + SHARED_READ_TIMEOUT_MS
66+
)
67+
if (stored === null) return undefined
68+
const cost = Number(stored)
69+
if (stored.trim() !== '' && Number.isFinite(cost) && cost >= 0) return cost
70+
logger.warn('Discarding unreadable shared reporting usage', { key })
71+
} catch (error) {
72+
logger.warn('Shared reporting usage read failed; summing the ledger', {
73+
error: getErrorMessage(error),
74+
})
75+
}
76+
return undefined
77+
}
78+
79+
/** Fire-and-forget: a read never waits on, or fails because of, the shared write. */
80+
function writeSharedReportingUsageCost(key: string, cost: number): void {
81+
const redis = getRedisClient()
82+
if (!redis) return
83+
const ttlMs = REPORTING_USAGE_CACHE_TTL_MS + randomInt(0, SHARED_TTL_JITTER_MS)
84+
redis.set(sharedReportingUsageKey(key), String(cost), 'PX', ttlMs).catch((error: unknown) => {
85+
logger.warn('Shared reporting usage write failed', { error: getErrorMessage(error) })
86+
})
87+
}
88+
89+
/**
90+
* The sum from Redis when another process stored one, else the ledger's exact sum, stored for
91+
* the others. Trigger.dev runs each task in a fresh process, so the in-process cache alone is
92+
* always cold there; the shared value is what spares those runs the scan.
93+
*/
94+
async function sumReportingUsageCost(
95+
key: string,
96+
entity: BillingEntity,
97+
period: ReportingQueryPeriod
98+
): Promise<number> {
99+
const shared = await readSharedReportingUsageCost(key)
100+
if (shared !== undefined) return shared
101+
const cost = await getBillingPeriodUsageCost(entity, period)
102+
writeSharedReportingUsageCost(key, cost)
103+
return cost
104+
}
105+
27106
/**
28-
* Sums shared across callers, one per payer and window. Every key is an enterprise payer's
29-
* current window, a few dozen bytes each, so the ceiling sits far above any process's working
30-
* set and only backstops memory; an eviction inside the TTL costs one extra sum.
107+
* Sums held by this process, one per payer and window, in front of the shared Redis value. Every
108+
* key is an enterprise payer's current window, a few dozen bytes each, so the ceiling sits far
109+
* above any process's working set and only backstops memory; an eviction inside the TTL costs
110+
* one extra read.
31111
*
32-
* `fetchMethod` coalesces concurrent misses onto one sum. A rejected sum is evicted rather than
112+
* `fetchMethod` coalesces concurrent misses onto one read. A rejected sum is evicted rather than
33113
* stored (`noDeleteOnFetchRejection` and `allowStaleOnFetchRejection` stay off), so every caller
34114
* of that read sees the error it would have seen uncached and the next call sums again. There is
35115
* no settle deadline: the sum runs under the ledger's own `statement_timeout`, so the database
36-
* ends a slow one. There is deliberately no invalidator either — usage is written by execution
37-
* workers in other processes, so the TTL is the real bound.
116+
* ends a slow one. There is deliberately no invalidator or lock either — usage is written by
117+
* execution workers in other processes, so the TTL is the real bound, and concurrent misses in
118+
* different processes each sum once.
38119
*/
39120
const reportingUsageCache = new LRUCache<
40121
string,
@@ -43,8 +124,8 @@ const reportingUsageCache = new LRUCache<
43124
>({
44125
max: 1_000,
45126
ttl: REPORTING_USAGE_CACHE_TTL_MS,
46-
fetchMethod: (_key, _stale, { context }) =>
47-
getBillingPeriodUsageCost(context.entity, context.period),
127+
fetchMethod: (key, _stale, { context }) =>
128+
sumReportingUsageCost(key, context.entity, context.period),
48129
})
49130

50131
/**
@@ -72,7 +153,7 @@ async function readCachedReportingUsageCost(
72153
/**
73154
* Period usage for a soft reader: an admission check, a display, or a level-triggered
74155
* notification that tolerates the cache's bounded under-count. Enterprise reporting windows are
75-
* served from the shared cache for up to {@link REPORTING_USAGE_CACHE_TTL_MS}, since their
156+
* served from the shared cache for up to twice {@link REPORTING_USAGE_CACHE_TTL_MS}, since their
76157
* year-long sum is the expensive one; every other period is summed exactly, as before. A read on
77158
* a caller's own executor (a transaction or a replica) keeps its own snapshot and is never shared.
78159
* Never use it for invoicing, cycle close, an edge-triggered decision, or a read that must see its

0 commit comments

Comments
 (0)