Skip to content

Commit 8e5305b

Browse files
fix(billing): drop transaction wrapper in recordUsage to relieve pool contention
1 parent bdc42a2 commit 8e5305b

1 file changed

Lines changed: 40 additions & 37 deletions

File tree

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

Lines changed: 40 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,13 @@ export interface RecordUsageParams {
7171
}
7272

7373
/**
74-
* Records usage in a single atomic transaction.
74+
* Records usage by inserting into usage_log and incrementing userStats counters.
7575
*
76-
* Inserts all entries into usage_log and updates userStats counters
77-
* (totalCost, currentPeriodCost, lastActive) within one Postgres transaction.
78-
* The total cost added to userStats is derived from summing entry costs,
79-
* ensuring usage_log and currentPeriodCost can never drift apart.
80-
*
81-
* If billing is disabled, total cost is zero, or no entries have positive cost,
82-
* this function returns early without writing anything.
76+
* The two writes are intentionally not wrapped in a transaction: under high
77+
* concurrency for the same userId, holding BEGIN/COMMIT across the user_stats
78+
* row-lock wait pins pgbouncer connections and exhausts the pool. usage_log
79+
* is the source of truth; if the userStats UPDATE fails the counter drifts
80+
* and must be reconciled from usage_log out-of-band.
8381
*/
8482
export async function recordUsage(params: RecordUsageParams): Promise<void> {
8583
if (!isBillingEnabled) {
@@ -103,47 +101,52 @@ export async function recordUsage(params: RecordUsageParams): Promise<void> {
103101
? Object.fromEntries(Object.entries(additionalStats).filter(([k]) => !RESERVED_KEYS.has(k)))
104102
: undefined
105103

106-
await db.transaction(async (tx) => {
107-
if (validEntries.length > 0) {
108-
await tx.insert(usageLog).values(
109-
validEntries.map((entry) => ({
110-
id: generateId(),
111-
userId,
112-
category: entry.category,
113-
source: entry.source,
114-
description: entry.description,
115-
metadata: entry.metadata ?? null,
116-
cost: entry.cost.toString(),
117-
workspaceId: workspaceId ?? null,
118-
workflowId: workflowId ?? null,
119-
executionId: executionId ?? null,
120-
}))
121-
)
122-
}
104+
if (validEntries.length > 0) {
105+
await db.insert(usageLog).values(
106+
validEntries.map((entry) => ({
107+
id: generateId(),
108+
userId,
109+
category: entry.category,
110+
source: entry.source,
111+
description: entry.description,
112+
metadata: entry.metadata ?? null,
113+
cost: entry.cost.toString(),
114+
workspaceId: workspaceId ?? null,
115+
workflowId: workflowId ?? null,
116+
executionId: executionId ?? null,
117+
}))
118+
)
119+
}
123120

124-
const updateFields: Record<string, SQL | Date> = {
125-
lastActive: new Date(),
126-
...(totalCost > 0 && {
127-
totalCost: sql`total_cost + ${totalCost}`,
128-
currentPeriodCost: sql`current_period_cost + ${totalCost}`,
129-
}),
130-
...safeStats,
131-
}
121+
const updateFields: Record<string, SQL | Date> = {
122+
lastActive: new Date(),
123+
...(totalCost > 0 && {
124+
totalCost: sql`total_cost + ${totalCost}`,
125+
currentPeriodCost: sql`current_period_cost + ${totalCost}`,
126+
}),
127+
...safeStats,
128+
}
132129

133-
const result = await tx
130+
try {
131+
const result = await db
134132
.update(userStats)
135133
.set(updateFields)
136134
.where(eq(userStats.userId, userId))
137135
.returning({ userId: userStats.userId })
138136

139137
if (result.length === 0) {
140-
logger.warn('recordUsage: userStats row not found, transaction will roll back', {
138+
logger.warn('recordUsage: userStats row not found; counter will drift from usage_log', {
141139
userId,
142140
totalCost,
143141
})
144-
throw new Error(`userStats row not found for userId: ${userId}`)
145142
}
146-
})
143+
} catch (error) {
144+
logger.error('recordUsage: userStats update failed; counter will drift from usage_log', {
145+
error: toError(error).message,
146+
userId,
147+
totalCost,
148+
})
149+
}
147150

148151
logger.debug('Recorded usage', {
149152
userId,

0 commit comments

Comments
 (0)