11import { db } from '@sim/db'
22import { rateLimitBucket } from '@sim/db/schema'
3- import { eq , inArray , sql } from 'drizzle-orm'
3+ import { eq , type SQL , sql } from 'drizzle-orm'
44import 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+
14199export 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