Skip to content

Commit 90ed227

Browse files
authored
improvement(db): cut hot-path connector count and rate-limit lock costs (#8944)
* improvement(rate-limit): admit provider requests in one statement on the database bucket Lock, refill, decide and spend every dimension in a single CTE so a hot bucket row stays locked for one statement plus the commit instead of a round trip per dimension. The clock is read before the statement, matching the Redis backend, so a contended admission never sees more tokens. * improvement(connectors): skip the live document count on unfinished sync slices An unfinished content pass keeps the count its last finished pass recorded, the pass lastSyncAt still names. Connectors with no recorded count are still counted so a first pass shows progress. * fix(rate-limit): judge atomic admission at the moment the bucket locks are held Advance the caller's clock by the lock wait measured inside the statement, so a contended admission refills the time it spent waiting as it did before the single-statement change. Cover the spend rollback when the deadline passes or the caller aborts while the statement runs. * fix(rate-limit): count only the lock wait in the atomic admission clock Sample clock_timestamp() before the first row can reach the lock and again after the last is held, so an uncontended admission decides at exactly the caller's clock and planning or execution time never shifts a refill. * fix(connectors): keep counting every slice until a connector's first pass finishes A first pass's own unfinished slices wrote a count, so later slices of that pass saw it and stopped counting. Skip only once lastSyncAt shows a finished pass, which unfinished slices never advance.
1 parent 1cdb8c1 commit 90ed227

6 files changed

Lines changed: 482 additions & 118 deletions

File tree

‎apps/sim/lib/core/rate-limiter/provider-admission.test.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,8 @@ describe('provider admission', () => {
167167
maxWaitMs: 1,
168168
}
169169
let stored: { key: string; tokens: string; lastRefillAt: Date } | undefined
170-
dbChainMockFns.values.mockImplementation((rows) => {
171-
stored ??= rows.find((row: { key: string }) => row.key.endsWith(':requests'))
170+
dbChainMockFns.values.mockImplementation((row) => {
171+
stored ??= { ...row }
172172
return { onConflictDoNothing: vi.fn().mockResolvedValue(undefined) }
173173
})
174174
dbChainMockFns.limit.mockImplementation(async () => [stored])
@@ -177,9 +177,15 @@ describe('provider admission', () => {
177177
return { where: vi.fn().mockResolvedValue(undefined) }
178178
})
179179
const bucket = new DbTokenBucket()
180-
consumeTokens.mockImplementation((reservations, options) =>
181-
bucket.consumeTokensAtomically(reservations, options)
182-
)
180+
/** The single-dimension refill arithmetic the atomic statement mirrors in SQL. */
181+
consumeTokens.mockImplementation(async ([reservation]) => {
182+
const result = await bucket.consumeTokens(
183+
reservation.key,
184+
reservation.cost,
185+
reservation.config
186+
)
187+
return { allowed: result.allowed, retryAfterMs: result.retryAfterMs ?? 0 }
188+
})
183189

184190
for (let request = 0; request < 16; request++) await waitForProviderAdmission(input)
185191
await expect(waitForProviderAdmission(input)).rejects.toMatchObject({ retryAfterMs: 1000 })

‎apps/sim/lib/core/rate-limiter/storage/db-token-bucket.test.ts‎

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -44,41 +44,6 @@ describe('PostgreSQL token bucket', () => {
4444
expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ tokens: '0' }))
4545
})
4646

47-
it('initializes request and cooldown buckets in a consistent order without duplicate keys', async () => {
48-
const now = new Date()
49-
dbChainMockFns.limit.mockResolvedValue([
50-
{ key: 'cooldown', tokens: '0', lastRefillAt: now, blockedUntil: null },
51-
{ key: 'requests', tokens: '10', lastRefillAt: now, blockedUntil: null },
52-
{ key: 'tokens', tokens: '10', lastRefillAt: now, blockedUntil: null },
53-
])
54-
55-
expect(
56-
await new DbTokenBucket().consumeTokensAtomically(
57-
[
58-
{ key: 'tokens', cost: 2, config: CONFIG },
59-
{ key: 'requests', cost: 1, config: CONFIG },
60-
],
61-
{ cooldownKeys: ['cooldown', 'cooldown'], deadlineAt: now.getTime() + 1000 }
62-
)
63-
).toEqual({ allowed: true, retryAfterMs: 0 })
64-
65-
expect(dbChainMockFns.values).toHaveBeenCalledExactlyOnceWith([
66-
{ key: 'cooldown', tokens: '0', lastRefillAt: now, updatedAt: now },
67-
{ key: 'requests', tokens: '10', lastRefillAt: now, updatedAt: now },
68-
{ key: 'tokens', tokens: '10', lastRefillAt: now, updatedAt: now },
69-
])
70-
expect(dbChainMockFns.set).toHaveBeenCalledWith({
71-
tokens: '8',
72-
lastRefillAt: now,
73-
updatedAt: now,
74-
})
75-
expect(dbChainMockFns.set).toHaveBeenCalledWith({
76-
tokens: '9',
77-
lastRefillAt: now,
78-
updatedAt: now,
79-
})
80-
})
81-
8247
it('accepts an empty reservation without attempting an empty insert', async () => {
8348
dbChainMockFns.limit.mockResolvedValue([])
8449

‎apps/sim/lib/core/rate-limiter/storage/db-token-bucket.ts‎

Lines changed: 217 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { db } from '@sim/db'
22
import { rateLimitBucket } from '@sim/db/schema'
3-
import { eq, inArray, sql } from 'drizzle-orm'
3+
import { eq, type SQL, sql } from 'drizzle-orm'
44
import type {
55
AtomicAdmissionOptions,
66
AtomicAdmissionResult,
@@ -11,6 +11,191 @@ import type {
1111
TokenStatus,
1212
} from '@/lib/core/rate-limiter/storage/adapter'
1313

14+
/** Thrown inside the admission transaction to roll back a spend that finished past its deadline. */
15+
class AdmissionExpiredError extends Error {}
16+
17+
interface AdmissionRow extends Record<string, unknown> {
18+
kind: 'reservation' | 'cooldown'
19+
cost: number | null
20+
available: number | null
21+
last_refill_ms: number | null
22+
refill_rate: number | null
23+
refill_interval_ms: number | null
24+
blocked_until_ms: number | null
25+
allowed: boolean
26+
spent: number
27+
now_ms: number
28+
}
29+
30+
/** An array of scalar binds; the pool cannot bind a JavaScript array as one parameter. */
31+
function sqlArray(values: readonly (string | number)[], type: 'text' | 'float8'): SQL {
32+
return sql`ARRAY[${sql.join(
33+
values.map((value) => sql`${value}`),
34+
sql`, `
35+
)}]::${sql.raw(type)}[]`
36+
}
37+
38+
/**
39+
* Locks every key in sorted order, refills each reservation to the moment every lock is held
40+
* with the same float8 arithmetic {@link DbTokenBucket.consumeTokens} uses in JavaScript, and
41+
* spends all reservations only when no cooldown is active and every balance covers its cost.
42+
* Returns one row per reservation and per cooldown key, each carrying the decision, how many
43+
* buckets were spent, and the clock the decision used.
44+
*
45+
* That clock is the caller's `sentAt` advanced by the time spent acquiring the locks, in whole
46+
* milliseconds. `lock_start` is joined into `locked`, so it is read before the first row can
47+
* reach the lock; `clock` counts every `locked` row before reading `clock_timestamp()` again, so
48+
* that read happens only once the last lock is held. An uncontended admission therefore decides
49+
* at exactly `sentAt`, and measuring the wait rather than reading the database's absolute time
50+
* keeps every stored instant on the application clock that wrote it.
51+
*/
52+
function buildAdmissionStatement(
53+
reservations: readonly TokenBucketReservation[],
54+
cooldownKeys: readonly string[],
55+
keys: readonly string[],
56+
sentAt: number
57+
): SQL {
58+
return sql`
59+
WITH reservation AS (
60+
SELECT * FROM unnest(
61+
${sqlArray(
62+
reservations.map((item) => item.key),
63+
'text'
64+
)},
65+
${sqlArray(
66+
reservations.map((item) => item.cost),
67+
'float8'
68+
)},
69+
${sqlArray(
70+
reservations.map((item) => item.config.maxTokens),
71+
'float8'
72+
)},
73+
${sqlArray(
74+
reservations.map((item) => item.config.refillRate),
75+
'float8'
76+
)},
77+
${sqlArray(
78+
reservations.map((item) => item.config.refillIntervalMs),
79+
'float8'
80+
)}
81+
) AS r(key, cost, max_tokens, refill_rate, refill_interval_ms)
82+
),
83+
lock_start AS (
84+
SELECT clock_timestamp() AS started_at
85+
),
86+
locked AS (
87+
SELECT bucket.key, bucket.tokens, bucket.last_refill_at, bucket.blocked_until
88+
FROM ${rateLimitBucket} AS bucket
89+
CROSS JOIN lock_start
90+
WHERE bucket.key = ANY(${sqlArray(keys, 'text')})
91+
ORDER BY bucket.key
92+
FOR UPDATE OF bucket
93+
),
94+
clock AS (
95+
SELECT ${sentAt}::float8
96+
+ GREATEST(0, floor(extract(epoch FROM (held.locked_at - lock_start.started_at)) * 1000))
97+
AS now_ms
98+
FROM (SELECT clock_timestamp() AS locked_at FROM (SELECT count(*) FROM locked) every_lock) held
99+
CROSS JOIN lock_start
100+
),
101+
balance AS (
102+
SELECT
103+
r.key,
104+
r.cost,
105+
r.refill_rate,
106+
r.refill_interval_ms,
107+
LEAST(r.max_tokens, GREATEST(0, l.tokens::float8) + e.elapsed * r.refill_rate) AS available,
108+
m.last_ms + e.elapsed * r.refill_interval_ms AS last_refill_ms,
109+
l.last_refill_at + (e.elapsed * r.refill_interval_ms) * interval '1 millisecond' AS last_refill_at
110+
FROM reservation r
111+
JOIN locked l ON l.key = r.key
112+
CROSS JOIN clock
113+
CROSS JOIN LATERAL (
114+
SELECT (extract(epoch FROM l.last_refill_at) * 1000)::float8 AS last_ms
115+
) m
116+
CROSS JOIN LATERAL (
117+
SELECT GREATEST(0, floor((clock.now_ms - m.last_ms) / r.refill_interval_ms)) AS elapsed
118+
) e
119+
),
120+
cooldown AS (
121+
SELECT (extract(epoch FROM blocked_until) * 1000)::float8 AS blocked_until_ms
122+
FROM locked
123+
WHERE key = ANY(${sqlArray(cooldownKeys, 'text')})
124+
),
125+
decision AS (
126+
SELECT
127+
NOT EXISTS (SELECT 1 FROM balance WHERE available < cost)
128+
AND NOT EXISTS (
129+
SELECT 1 FROM cooldown, clock WHERE cooldown.blocked_until_ms > clock.now_ms
130+
) AS allowed
131+
),
132+
spent AS (
133+
UPDATE ${rateLimitBucket} AS bucket
134+
SET
135+
tokens = (balance.available - balance.cost)::text::numeric,
136+
last_refill_at = balance.last_refill_at,
137+
updated_at = timestamp '1970-01-01' + clock.now_ms * interval '1 millisecond'
138+
FROM balance, decision, clock
139+
WHERE decision.allowed AND bucket.key = balance.key
140+
RETURNING bucket.key
141+
),
142+
outcome AS (
143+
SELECT decision.allowed, (SELECT count(*) FROM spent)::int AS spent, clock.now_ms
144+
FROM decision, clock
145+
)
146+
SELECT 'reservation' AS kind, b.cost, b.available, b.last_refill_ms, b.refill_rate,
147+
b.refill_interval_ms, NULL::float8 AS blocked_until_ms, o.allowed, o.spent, o.now_ms
148+
FROM balance b CROSS JOIN outcome o
149+
UNION ALL
150+
SELECT 'cooldown', NULL, NULL, NULL, NULL, NULL, c.blocked_until_ms, o.allowed, o.spent,
151+
o.now_ms
152+
FROM cooldown c CROSS JOIN outcome o
153+
`
154+
}
155+
156+
/**
157+
* Derives the wait from the locked balances and cross-checks it against the statement's own
158+
* decision, so the JavaScript reading and the SQL spend can never disagree silently: a mismatch,
159+
* or a reservation whose bucket vanished, throws and rolls the spend back.
160+
*/
161+
function readAdmissionResult(
162+
rows: readonly AdmissionRow[],
163+
reservations: readonly TokenBucketReservation[],
164+
now: number
165+
): AtomicAdmissionResult {
166+
let retryAfterMs = 0
167+
let reserved = 0
168+
for (const row of rows) {
169+
if (row.kind === 'cooldown') {
170+
if (row.blocked_until_ms !== null) {
171+
retryAfterMs = Math.max(retryAfterMs, Number(row.blocked_until_ms) - now)
172+
}
173+
continue
174+
}
175+
reserved++
176+
const available = Number(row.available)
177+
const cost = Number(row.cost)
178+
if (available < cost) {
179+
retryAfterMs = Math.max(
180+
retryAfterMs,
181+
Number(row.last_refill_ms) +
182+
Math.ceil((cost - available) / Number(row.refill_rate)) * Number(row.refill_interval_ms) -
183+
now
184+
)
185+
}
186+
}
187+
if (reserved !== reservations.length) {
188+
throw new Error('Rate limit bucket disappeared during admission')
189+
}
190+
const allowed = rows[0]?.allowed ?? true
191+
const spent = rows[0]?.spent ?? 0
192+
const spentExpected = allowed ? new Set(reservations.map((item) => item.key)).size : 0
193+
if (allowed !== retryAfterMs <= 0 || spent !== spentExpected) {
194+
throw new Error('Rate limit admission decision is inconsistent')
195+
}
196+
return allowed ? { allowed: true, retryAfterMs: 0 } : { allowed: false, retryAfterMs }
197+
}
198+
14199
export class DbTokenBucket implements RateLimitStorageAdapter {
15200
async consumeTokens(
16201
key: string,
@@ -78,23 +263,31 @@ export class DbTokenBucket implements RateLimitStorageAdapter {
78263
})
79264
}
80265

81-
/** Locks all dimensions and provider gates before spending any capacity. */
266+
/**
267+
* Locks all dimensions and provider gates before spending any capacity.
268+
*
269+
* The lock, refill, decision, and spend run as one statement, so the bucket rows stay locked
270+
* for that statement and the commit rather than for a round trip per dimension: every waiter
271+
* on a hot provider key queues behind that whole hold. Refill and cooldowns are judged at the
272+
* moment the locks are held, as before, so a wait for a contended lock is refilled.
273+
*/
82274
async consumeTokensAtomically(
83275
reservations: readonly TokenBucketReservation[],
84276
options: AtomicAdmissionOptions
85277
): Promise<AtomicAdmissionResult> {
86278
options.signal?.throwIfAborted()
87279
if (Date.now() >= options.deadlineAt) return { allowed: false, retryAfterMs: 0 }
88-
return db.transaction(async (tx) => {
89-
const remainingMs = Math.max(1, options.deadlineAt - Date.now())
90-
await tx.execute(
91-
sql`SELECT set_config('statement_timeout', ${String(remainingMs)}, true), set_config('lock_timeout', ${String(remainingMs)}, true)`
92-
)
93-
const keys = [
94-
...new Set([...options.cooldownKeys, ...reservations.map((item) => item.key)]),
95-
].sort()
96-
const createdAt = new Date()
97-
if (keys.length > 0) {
280+
const keys = [
281+
...new Set([...options.cooldownKeys, ...reservations.map((item) => item.key)]),
282+
].sort()
283+
if (keys.length === 0) return { allowed: true, retryAfterMs: 0 }
284+
try {
285+
return await db.transaction(async (tx) => {
286+
const remainingMs = Math.max(1, options.deadlineAt - Date.now())
287+
await tx.execute(
288+
sql`SELECT set_config('statement_timeout', ${String(remainingMs)}, true), set_config('lock_timeout', ${String(remainingMs)}, true)`
289+
)
290+
const createdAt = new Date()
98291
await tx
99292
.insert(rateLimitBucket)
100293
.values(
@@ -109,62 +302,20 @@ export class DbTokenBucket implements RateLimitStorageAdapter {
109302
})
110303
)
111304
.onConflictDoNothing()
112-
}
113-
const rows = await tx
114-
.select()
115-
.from(rateLimitBucket)
116-
.where(inArray(rateLimitBucket.key, keys))
117-
.orderBy(rateLimitBucket.key)
118-
.for('update')
119-
.limit(keys.length)
120-
options.signal?.throwIfAborted()
121-
const now = Date.now()
122-
if (now >= options.deadlineAt) return { allowed: false, retryAfterMs: 0 }
123-
let retryAfterMs = 0
124-
for (const key of options.cooldownKeys) {
125-
const row = rows.find((candidate) => candidate.key === key)
126-
retryAfterMs = Math.max(retryAfterMs, (row?.blockedUntil?.getTime() ?? 0) - now)
127-
}
128-
const balances = reservations.map((reservation) => {
129-
const row = rows.find((candidate) => candidate.key === reservation.key)
130-
if (!row) throw new Error('Rate limit bucket disappeared during admission')
131-
const { config, cost } = reservation
132-
const elapsed = Math.max(
133-
0,
134-
Math.floor((now - row.lastRefillAt.getTime()) / config.refillIntervalMs)
135-
)
136-
const available = Math.min(
137-
config.maxTokens,
138-
Math.max(0, Number(row.tokens)) + elapsed * config.refillRate
305+
options.signal?.throwIfAborted()
306+
const sentAt = Date.now()
307+
const rows = await tx.execute<AdmissionRow>(
308+
buildAdmissionStatement(reservations, options.cooldownKeys, keys, sentAt)
139309
)
140-
const lastRefillAt = new Date(
141-
row.lastRefillAt.getTime() + elapsed * config.refillIntervalMs
142-
)
143-
if (available < cost) {
144-
retryAfterMs = Math.max(
145-
retryAfterMs,
146-
lastRefillAt.getTime() +
147-
Math.ceil((cost - available) / config.refillRate) * config.refillIntervalMs -
148-
now
149-
)
150-
}
151-
return { reservation, available, lastRefillAt }
310+
const now = rows[0]?.now_ms ?? sentAt
311+
options.signal?.throwIfAborted()
312+
if (Math.max(now, Date.now()) >= options.deadlineAt) throw new AdmissionExpiredError()
313+
return readAdmissionResult(rows, reservations, now)
152314
})
153-
if (retryAfterMs > 0) return { allowed: false, retryAfterMs }
154-
options.signal?.throwIfAborted()
155-
for (const { reservation, available, lastRefillAt } of balances) {
156-
await tx
157-
.update(rateLimitBucket)
158-
.set({
159-
tokens: String(available - reservation.cost),
160-
lastRefillAt,
161-
updatedAt: new Date(now),
162-
})
163-
.where(eq(rateLimitBucket.key, reservation.key))
164-
}
165-
options.signal?.throwIfAborted()
166-
return { allowed: true, retryAfterMs: 0 }
167-
})
315+
} catch (error) {
316+
if (error instanceof AdmissionExpiredError) return { allowed: false, retryAfterMs: 0 }
317+
throw error
318+
}
168319
}
169320

170321
async getCooldownUntil(key: string): Promise<Date | null> {

0 commit comments

Comments
 (0)