@@ -36,18 +36,38 @@ function resolveRedisTlsOptions(url: string | undefined): { servername: string }
3636 return { servername : env . REDIS_TLS_SERVERNAME }
3737}
3838
39+ const REDIS_CONNECT_TIMEOUT_MS = 10_000
40+
41+ /**
42+ * Per-command deadline. MUST stay greater than `REDIS_CONNECT_TIMEOUT_MS`.
43+ *
44+ * `sendCommand` arms this timer *before* it checks whether the socket is
45+ * writable and before the `enableOfflineQueue` branch, so a command issued
46+ * while the connection is still being established is already counting down
47+ * while it waits in the offline queue. Set below the connect timeout, every
48+ * slow handshake surfaces as `Command timed out` — attributed to a Redis that
49+ * never received the command, with a stack containing only ioredis timer
50+ * frames.
51+ *
52+ * Production handshakes to ElastiCache measure 2-6s when several connections
53+ * are opened at once, so a 5s command timeout failed roughly 3% of cold-start
54+ * commands on Trigger.dev workers, which open a fresh connection per run.
55+ */
56+ export const REDIS_COMMAND_TIMEOUT_MS = 15_000
57+
3958/**
4059 * Shared connection defaults — keepAlive, connectTimeout, enableOfflineQueue,
4160 * and TLS SNI when REDIS_URL targets an IP. Every Redis client we open should
42- * spread this; callers add their own retry / timeout policy on top.
61+ * spread this; callers add their own retry policy on top and take their command
62+ * deadline from `REDIS_COMMAND_TIMEOUT_MS` so the invariant above holds.
4363 */
4464export function getRedisConnectionDefaults (
4565 url : string | undefined
4666) : Pick < RedisOptions , 'keepAlive' | 'connectTimeout' | 'enableOfflineQueue' | 'tls' > {
4767 const tls = resolveRedisTlsOptions ( url )
4868 return {
4969 keepAlive : 1000 ,
50- connectTimeout : 10000 ,
70+ connectTimeout : REDIS_CONNECT_TIMEOUT_MS ,
5171 enableOfflineQueue : true ,
5272 ...( tls ? { tls } : { } ) ,
5373 }
@@ -59,6 +79,7 @@ interface RedisState {
5979 pingInterval : NodeJS . Timeout | null
6080 pingInFlight : boolean
6181 reconnectListeners : Array < ( ) => void >
82+ warmPromise : Promise < void > | null
6283}
6384
6485const g = globalThis as typeof globalThis & { _redisState ?: RedisState }
@@ -69,13 +90,46 @@ if (!g._redisState) {
6990 pingInterval : null ,
7091 pingInFlight : false ,
7192 reconnectListeners : [ ] ,
93+ warmPromise : null ,
7294 }
7395}
7496const state = g . _redisState
7597
7698const PING_INTERVAL_MS = 15_000
7799const MAX_PING_FAILURES = 2
78100
101+ /**
102+ * Deadline for a single health probe.
103+ *
104+ * A PING is only ever issued on an already-established connection, so unlike a
105+ * general command it is never waiting on a handshake and takes a much tighter
106+ * deadline. Keeping it independent of `REDIS_COMMAND_TIMEOUT_MS` is what stops
107+ * the wider command deadline from slowing failover: two consecutive misses
108+ * still force a reconnect within roughly two intervals.
109+ */
110+ const REDIS_PING_TIMEOUT_MS = 5_000
111+
112+ /**
113+ * `commandTimeout` cannot express "probe deadline" separately from "command
114+ * deadline" — it is a single client-wide option — so the probe carries its own.
115+ */
116+ async function pingWithDeadline ( redis : Redis ) : Promise < void > {
117+ let timer : NodeJS . Timeout | undefined
118+ try {
119+ await Promise . race ( [
120+ redis . ping ( ) ,
121+ new Promise < never > ( ( _ , reject ) => {
122+ timer = setTimeout (
123+ ( ) => reject ( new Error ( 'Redis PING deadline exceeded' ) ) ,
124+ REDIS_PING_TIMEOUT_MS
125+ )
126+ } ) ,
127+ ] )
128+ } finally {
129+ if ( timer ) clearTimeout ( timer )
130+ }
131+ }
132+
79133export function getConfiguredRedisUrl ( ) : string | null {
80134 if ( getConfiguredCacheProvider ( ) === 'database' ) return null
81135
@@ -101,7 +155,7 @@ function startPingHealthCheck(redis: Redis): void {
101155 if ( state . pingInFlight ) return
102156 state . pingInFlight = true
103157 try {
104- await redis . ping ( )
158+ await pingWithDeadline ( redis )
105159 state . pingFailures = 0
106160 } catch ( error ) {
107161 state . pingFailures ++
@@ -117,6 +171,8 @@ function startPingHealthCheck(redis: Redis): void {
117171 state . pingFailures = 0
118172 // Clear before notifying listeners — they may call getRedisClient() and must see the reset state.
119173 state . client = null
174+ // The next client is cold again, so let it be warmed before first use.
175+ state . warmPromise = null
120176 if ( state . pingInterval ) {
121177 clearInterval ( state . pingInterval )
122178 state . pingInterval = null
@@ -161,7 +217,7 @@ export function getRedisClient(): Redis | null {
161217
162218 state . client = new Redis ( redisUrl , {
163219 ...defaults ,
164- commandTimeout : 5000 ,
220+ commandTimeout : REDIS_COMMAND_TIMEOUT_MS ,
165221 maxRetriesPerRequest : 5 ,
166222
167223 retryStrategy : ( times ) => {
@@ -199,6 +255,78 @@ export function getRedisClient(): Redis | null {
199255 }
200256}
201257
258+ /**
259+ * Establish the shared connection before the first command needs it.
260+ *
261+ * `commandTimeout` is a total deadline that starts the moment a command is
262+ * issued: ioredis arms it in `sendCommand` before it checks whether the socket
263+ * is writable, so the budget covers handshake and offline-queue wait as well as
264+ * execution. A process whose first command lands on a cold client therefore
265+ * spends most of that budget on the TLS handshake, which measures 2-6s against
266+ * ElastiCache when several connections open at once.
267+ *
268+ * Warming at process start moves that cost off the first command's clock, which
269+ * is the connection-reuse practice AWS recommends: establishing a TCP+TLS
270+ * connection is far more expensive than the commands that run over it, so it
271+ * should be paid once per process rather than once per unit of work.
272+ *
273+ * Best effort by design — resolves rather than rejects on failure, and is
274+ * bounded by the connect budget, so neither a degraded Redis nor a missing
275+ * configuration can stop a process from starting. Callers that skip warming
276+ * still work; they just pay the handshake on their first command as before.
277+ *
278+ * Memoized per client: a warm process returns immediately, and a forced
279+ * reconnect clears it so the replacement connection is warmed in turn.
280+ */
281+ export function warmRedisConnection ( ) : Promise < void > {
282+ if ( state . warmPromise ) return state . warmPromise
283+
284+ let client : Redis | null
285+ try {
286+ client = getRedisClient ( )
287+ } catch ( error ) {
288+ logger . warn ( 'Skipping Redis warm-up: client unavailable' , {
289+ error : toError ( error ) . message ,
290+ } )
291+ return Promise . resolve ( )
292+ }
293+
294+ if ( ! client ) return Promise . resolve ( )
295+ if ( client . status === 'ready' ) return Promise . resolve ( )
296+
297+ const startedAt = Date . now ( )
298+ state . warmPromise = new Promise < void > ( ( resolve ) => {
299+ let settled = false
300+ let timer : NodeJS . Timeout | undefined
301+
302+ const finish = ( outcome : 'ready' | 'deadline' ) => {
303+ if ( settled ) return
304+ settled = true
305+ if ( timer ) clearTimeout ( timer )
306+ client . off ( 'ready' , onReady )
307+ const elapsedMs = Date . now ( ) - startedAt
308+ if ( outcome === 'ready' ) {
309+ logger . info ( 'Redis connection warmed' , { elapsedMs } )
310+ } else {
311+ logger . warn ( 'Redis warm-up did not complete before its deadline' , { elapsedMs } )
312+ }
313+ resolve ( )
314+ }
315+
316+ const onReady = ( ) => finish ( 'ready' )
317+
318+ /**
319+ * Only `ready` settles early. A transient `error` is followed by ioredis's
320+ * own retry, so resolving on it would hand back a still-cold client and
321+ * reintroduce the very race this removes; the deadline is the backstop.
322+ */
323+ timer = setTimeout ( ( ) => finish ( 'deadline' ) , REDIS_CONNECT_TIMEOUT_MS )
324+ client . once ( 'ready' , onReady )
325+ } )
326+
327+ return state . warmPromise
328+ }
329+
202330/**
203331 * Lua script for safe lock release.
204332 * Only deletes the key if the value matches (ownership verification).
@@ -354,5 +482,6 @@ export function resetForTesting(): void {
354482 state . client = null
355483 state . pingFailures = 0
356484 state . pingInFlight = false
485+ state . warmPromise = null
357486 state . reconnectListeners . length = 0
358487}
0 commit comments