1
+ import { json } from "@remix-run/server-runtime" ;
1
2
import Redis , { Callback , Result , type RedisOptions } from "ioredis" ;
3
+ import { randomUUID } from "node:crypto" ;
2
4
import { longPollingFetch } from "~/utils/longPollingFetch" ;
3
- import { getCachedLimit } from "./platform.v3.server" ;
4
5
import { logger } from "./logger.server" ;
5
- import { AuthenticatedEnvironment } from "./apiAuth.server" ;
6
- import { json } from "@remix-run/server-runtime" ;
7
- import { env } from "~/env.server" ;
8
- import { singleton } from "~/utils/singleton" ;
6
+
7
+ export interface CachedLimitProvider {
8
+ getCachedLimit : ( organizationId : string , defaultValue : number ) => Promise < number | undefined > ;
9
+ }
9
10
10
11
export type RealtimeClientOptions = {
11
12
electricOrigin : string ;
12
13
redis : RedisOptions ;
14
+ cachedLimitProvider : CachedLimitProvider ;
13
15
keyPrefix : string ;
14
- expiryTime ?: number ;
16
+ expiryTimeInSeconds ?: number ;
17
+ } ;
18
+
19
+ export type RealtimeEnvironment = {
20
+ id : string ;
21
+ organizationId : string ;
15
22
} ;
16
23
17
24
export class RealtimeClient {
18
25
private redis : Redis ;
19
- private expiryTime : number ;
26
+ private expiryTimeInSeconds : number ;
27
+ private cachedLimitProvider : CachedLimitProvider ;
20
28
21
29
constructor ( private options : RealtimeClientOptions ) {
22
30
this . redis = new Redis ( options . redis ) ;
23
- this . expiryTime = options . expiryTime ?? 3600 ; // default to 1 hour
31
+ this . expiryTimeInSeconds = options . expiryTimeInSeconds ?? 60 * 5 ; // default to 5 minutes
32
+ this . cachedLimitProvider = options . cachedLimitProvider ;
24
33
this . #registerCommands( ) ;
25
34
}
26
35
27
36
async streamRunsWhere (
28
37
url : URL | string ,
29
- authenticatedEnv : AuthenticatedEnvironment ,
38
+ environment : RealtimeEnvironment ,
30
39
whereClause : string ,
31
40
responseWrapper ?: ( response : Response ) => Promise < Response >
32
41
) {
33
42
const electricUrl = this . #constructElectricUrl( url , whereClause ) ;
34
43
35
- return this . #performElectricRequest( electricUrl , authenticatedEnv , responseWrapper ) ;
44
+ return this . #performElectricRequest( electricUrl , environment , responseWrapper ) ;
36
45
}
37
46
38
47
#constructElectricUrl( url : URL | string , whereClause : string ) : URL {
@@ -51,7 +60,7 @@ export class RealtimeClient {
51
60
52
61
async #performElectricRequest(
53
62
url : URL ,
54
- authenticatedEnv : AuthenticatedEnvironment ,
63
+ environment : RealtimeEnvironment ,
55
64
responseWrapper : ( response : Response ) => Promise < Response > = ( r ) => Promise . resolve ( r )
56
65
) {
57
66
const shapeId = extractShapeId ( url ) ;
@@ -67,40 +76,40 @@ export class RealtimeClient {
67
76
return longPollingFetch ( url . toString ( ) ) ;
68
77
}
69
78
79
+ const requestId = randomUUID ( ) ;
80
+
70
81
// We now need to wrap the longPollingFetch in a concurrency tracker
71
- const concurrencyLimitResult = await getCachedLimit (
72
- authenticatedEnv . organizationId ,
73
- "realtimeConcurrentConnections" ,
82
+ const concurrencyLimit = await this . cachedLimitProvider . getCachedLimit (
83
+ environment . organizationId ,
74
84
100_000
75
85
) ;
76
86
77
- if ( ! concurrencyLimitResult . val ) {
87
+ if ( ! concurrencyLimit ) {
78
88
logger . error ( "Failed to get concurrency limit" , {
79
- organizationId : authenticatedEnv . organizationId ,
80
- concurrencyLimitResult,
89
+ organizationId : environment . organizationId ,
81
90
} ) ;
82
91
83
92
return responseWrapper ( json ( { error : "Failed to get concurrency limit" } , { status : 500 } ) ) ;
84
93
}
85
94
86
- const concurrencyLimit = concurrencyLimitResult . val ;
87
-
88
95
logger . debug ( "[realtimeClient] increment and check" , {
89
96
concurrencyLimit,
90
97
shapeId,
91
- authenticatedEnv : {
92
- id : authenticatedEnv . id ,
93
- organizationId : authenticatedEnv . organizationId ,
98
+ requestId,
99
+ environment : {
100
+ id : environment . id ,
101
+ organizationId : environment . organizationId ,
94
102
} ,
95
103
} ) ;
96
104
97
- const canProceed = await this . #incrementAndCheck(
98
- authenticatedEnv . id ,
99
- shapeId ,
100
- concurrencyLimit
101
- ) ;
105
+ const canProceed = await this . #incrementAndCheck( environment . id , requestId , concurrencyLimit ) ;
102
106
103
107
if ( ! canProceed ) {
108
+ logger . debug ( "[realtimeClient] too many concurrent requests" , {
109
+ requestId,
110
+ environmentId : environment . id ,
111
+ } ) ;
112
+
104
113
return responseWrapper ( json ( { error : "Too many concurrent requests" } , { status : 429 } ) ) ;
105
114
}
106
115
@@ -109,41 +118,42 @@ export class RealtimeClient {
109
118
const response = await longPollingFetch ( url . toString ( ) ) ;
110
119
111
120
// Decrement the counter after the long polling request is complete
112
- await this . #decrementConcurrency( authenticatedEnv . id , shapeId ) ;
121
+ await this . #decrementConcurrency( environment . id , requestId ) ;
113
122
114
123
return response ;
115
124
} catch ( error ) {
116
125
// Decrement the counter if the request fails
117
- await this . #decrementConcurrency( authenticatedEnv . id , shapeId ) ;
126
+ await this . #decrementConcurrency( environment . id , requestId ) ;
118
127
119
128
throw error ;
120
129
}
121
130
}
122
131
123
- async #incrementAndCheck( environmentId : string , shapeId : string , limit : number ) {
132
+ async #incrementAndCheck( environmentId : string , requestId : string , limit : number ) {
124
133
const key = this . #getKey( environmentId ) ;
125
- const now = Date . now ( ) . toString ( ) ;
134
+ const now = Date . now ( ) ;
126
135
127
136
const result = await this . redis . incrementAndCheckConcurrency (
128
137
key ,
129
- now ,
130
- shapeId ,
131
- this . expiryTime . toString ( ) ,
138
+ now . toString ( ) ,
139
+ requestId ,
140
+ this . expiryTimeInSeconds . toString ( ) , // expiry time
141
+ ( now - this . expiryTimeInSeconds * 1000 ) . toString ( ) , // cutoff time
132
142
limit . toString ( )
133
143
) ;
134
144
135
145
return result === 1 ;
136
146
}
137
147
138
- async #decrementConcurrency( environmentId : string , shapeId : string ) {
148
+ async #decrementConcurrency( environmentId : string , requestId : string ) {
139
149
logger . debug ( "[realtimeClient] decrement" , {
140
- shapeId ,
150
+ requestId ,
141
151
environmentId,
142
152
} ) ;
143
153
144
154
const key = this . #getKey( environmentId ) ;
145
155
146
- await this . redis . zrem ( key , shapeId ) ;
156
+ await this . redis . zrem ( key , requestId ) ;
147
157
}
148
158
149
159
#getKey( environmentId : string ) : string {
@@ -153,13 +163,17 @@ export class RealtimeClient {
153
163
#registerCommands( ) {
154
164
this . redis . defineCommand ( "incrementAndCheckConcurrency" , {
155
165
numberOfKeys : 1 ,
156
- lua : `
166
+ lua : /* lua */ `
157
167
local concurrencyKey = KEYS[1]
158
168
159
- local timestamp = ARGV[1]
169
+ local timestamp = tonumber( ARGV[1])
160
170
local requestId = ARGV[2]
161
- local expiryTime = ARGV[3]
162
- local limit = tonumber(ARGV[4])
171
+ local expiryTime = tonumber(ARGV[3])
172
+ local cutoffTime = tonumber(ARGV[4])
173
+ local limit = tonumber(ARGV[5])
174
+
175
+ -- Remove expired entries
176
+ redis.call('ZREMRANGEBYSCORE', concurrencyKey, '-inf', cutoffTime)
163
177
164
178
-- Add the new request to the sorted set
165
179
redis.call('ZADD', concurrencyKey, timestamp, requestId)
@@ -199,25 +213,9 @@ declare module "ioredis" {
199
213
timestamp : string ,
200
214
requestId : string ,
201
215
expiryTime : string ,
216
+ cutoffTime : string ,
202
217
limit : string ,
203
218
callback ?: Callback < number >
204
219
) : Result < number , Context > ;
205
220
}
206
221
}
207
-
208
- function initializeRealtimeClient ( ) {
209
- return new RealtimeClient ( {
210
- electricOrigin : env . ELECTRIC_ORIGIN ,
211
- keyPrefix : `tr:realtime:concurrency` ,
212
- redis : {
213
- port : env . REDIS_PORT ,
214
- host : env . REDIS_HOST ,
215
- username : env . REDIS_USERNAME ,
216
- password : env . REDIS_PASSWORD ,
217
- enableAutoPipelining : true ,
218
- ...( env . REDIS_TLS_DISABLED === "true" ? { } : { tls : { } } ) ,
219
- } ,
220
- } ) ;
221
- }
222
-
223
- export const realtimeClient = singleton ( "realtimeClient" , initializeRealtimeClient ) ;
0 commit comments