Skip to content

Commit 6065401

Browse files
committed
fix(billing): refuse expired reads and recover transaction timeouts
1 parent 24e5adc commit 6065401

5 files changed

Lines changed: 47 additions & 11 deletions

File tree

‎apps/sim/lib/api/server/routes/billing-read.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@ import {
44
internalErrorResponse,
55
internalOrchestrationErrorPolicy,
66
} from '@/lib/api/server/routes/internal-json-route'
7-
import { isTransientDatabaseReadError } from '@/lib/db/read-retry'
7+
import { DatabaseReadDeadlineError, isTransientDatabaseReadError } from '@/lib/db/read-retry'
88

99
export const internalBillingReadErrorPolicy = extendInternalErrorPolicy(
1010
internalOrchestrationErrorPolicy,
1111
(error) => {
12+
const cancellationReason = getPostgresCancellationReason(error)
1213
if (
14+
!(error instanceof DatabaseReadDeadlineError) &&
1315
!isTransientDatabaseReadError(error) &&
14-
getPostgresCancellationReason(error) !== 'statement_timeout'
16+
cancellationReason !== 'statement_timeout' &&
17+
cancellationReason !== 'transaction_timeout'
1518
)
1619
return null
1720
return internalErrorResponse(

‎apps/sim/lib/billing/application/organization-billing/get-organization-plan-seats.integration.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ describe('organization plan and seats without a usage ledger', () => {
122122
it.each([
123123
{ code: '40001', message: 'fixture recovery conflict', status: 503 },
124124
{ code: '57014', message: 'canceling statement due to statement timeout', status: 503 },
125+
{ code: '25P04', message: 'terminating connection due to transaction timeout', status: 503 },
126+
{ code: '57014', message: 'canceling statement due to user request', status: 500 },
125127
{ code: '42P01', message: 'fixture permanent database error', status: 500 },
126128
])(
127129
'projects $code over HTTP without leaking database details',

‎apps/sim/lib/billing/core/ledger-read.integration.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@ import { generateId } from '@sim/utils/id'
77
import { sql } from 'drizzle-orm'
88
import { drizzle } from 'drizzle-orm/postgres-js'
99
import postgres from 'postgres'
10-
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
10+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
1111
import { USAGE_LEDGER_STATEMENT_TIMEOUT_MS } from '@/lib/billing/constants'
1212
import { readLedgerBounded } from '@/lib/billing/core/ledger-read'
1313

14+
vi.mock('@/lib/billing/constants', async (importOriginal) => ({
15+
...(await importOriginal<typeof import('@/lib/billing/constants')>()),
16+
USAGE_LEDGER_STATEMENT_TIMEOUT_MS: 1000,
17+
}))
18+
1419
const schemaName = `ledger_read_${generateId().replaceAll('-', '')}`
1520
const connection = postgres(readTestDatabaseUrl(), {
1621
max: 1,
@@ -79,6 +84,25 @@ describe('bounded ledger reads with PostgreSQL', () => {
7984
expect(await observeRead).toBeLessThanOrEqual(USAGE_LEDGER_STATEMENT_TIMEOUT_MS - 80)
8085
})
8186

87+
it('rejects a ledger read whose connection wait exhausted the budget', async () => {
88+
let outcome: Promise<number> | undefined
89+
await connection.begin(async () => {
90+
outcome = readLedgerBounded(database, async (tx) => {
91+
const rows = await tx.execute<{ total: string }>(
92+
sql`select sum(cost) as total from ledger_probe`
93+
)
94+
return Number(rows[0].total)
95+
})
96+
void outcome.catch(() => undefined)
97+
await sleep(USAGE_LEDGER_STATEMENT_TIMEOUT_MS + 100)
98+
})
99+
if (!outcome) throw new Error('Ledger read was not started')
100+
await expect(outcome).rejects.toThrow('Database read deadline exceeded')
101+
expect(await connection`select sum(cost)::text as total from ledger_probe`).toEqual([
102+
{ total: '1.00' },
103+
])
104+
})
105+
82106
it('cannot replay a write under the read-retry policy', async () => {
83107
const error = await readLedgerBounded(database, async (tx) => {
84108
await tx.execute(sql`insert into ledger_probe values (999)`)

‎apps/sim/lib/billing/core/ledger-read.ts‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { sql } from 'drizzle-orm'
22
import { USAGE_LEDGER_STATEMENT_TIMEOUT_MS } from '@/lib/billing/constants'
3-
import { withDatabaseReadRetry } from '@/lib/db/read-retry'
3+
import { DatabaseReadDeadlineError, withDatabaseReadRetry } from '@/lib/db/read-retry'
44
import type { DbClient, DbTransaction } from '@/lib/db/types'
55

66
/**
7-
* Runs one aggregate over a payer's usage ledger in a transaction of its own, bounded by
8-
* {@link USAGE_LEDGER_STATEMENT_TIMEOUT_MS}. `SET LOCAL` scopes the bound to that transaction,
7+
* Runs one aggregate over a payer's usage ledger in a transaction of its own, sharing
8+
* {@link USAGE_LEDGER_STATEMENT_TIMEOUT_MS} across retries and statement execution. Pool wait
9+
* is deducted on acquisition; an exhausted budget refuses the read. `SET LOCAL` scopes the bound,
910
* so it ends with the read and never reaches the pool. Every sum over a payer's billing period
1011
* reads through here, whether it admits a run, closes a cycle or previews a bill: a payer whose
1112
* period has grown past what one statement can sum within the bound fails at the database
@@ -22,12 +23,10 @@ export function readLedgerBounded<T>(
2223
const attemptStartedAt = Date.now()
2324
return executor.transaction(
2425
async (tx) => {
25-
const timeoutMs = Math.max(
26-
1,
27-
Math.floor(
28-
(remainingMs ?? USAGE_LEDGER_STATEMENT_TIMEOUT_MS) - (Date.now() - attemptStartedAt)
29-
)
26+
const timeoutMs = Math.floor(
27+
(remainingMs ?? USAGE_LEDGER_STATEMENT_TIMEOUT_MS) - (Date.now() - attemptStartedAt)
3028
)
29+
if (timeoutMs <= 0) throw new DatabaseReadDeadlineError()
3130
await tx.execute(sql.raw(`SET LOCAL statement_timeout = '${timeoutMs}ms'`))
3231
return read(tx)
3332
},

‎apps/sim/lib/db/read-retry.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ const TRANSIENT_READ_CODES = new Set([
2727
'ETIMEDOUT',
2828
])
2929

30+
/** A read exhausted its budget before its query could begin. */
31+
export class DatabaseReadDeadlineError extends Error {
32+
constructor() {
33+
super('Database read deadline exceeded')
34+
this.name = 'DatabaseReadDeadlineError'
35+
}
36+
}
37+
3038
/** Only driver codes identify retryable reads; query cancellation and application errors propagate. */
3139
export function isTransientDatabaseReadError(error: unknown): boolean {
3240
const code = getPostgresErrorCode(error)

0 commit comments

Comments
 (0)