Skip to content

Commit 98cac46

Browse files
committed
fix(redis): size cold-connection waits to survive a dead handshake
A dead Redis handshake costs two command deadlines, not one. ioredis sends CLIENT SETNAME/SETINFO on connect and dispatches the INFO ready check only once those settle — on a socket that never answers, they settle by timing out. Only then does the ready check start its own deadline, fail, and tear the socket down for retryStrategy to reconnect. Every readiness wait in the codebase was sized to a single deadline, so it expired while the first attempt was still being diagnosed and a configured retry could never be the thing that rescued it. Introduces `coldConnectionBudgetMs`, which derives a wait from the command deadline and the reconnect delays a caller's own retryStrategy would return, and states the 2x in one place. Both waits now derive from it: - The execution-signal subscriber's readiness budget was the same constant as its commandTimeout (5s and 5s), so ioredis's reconnect was decorative on the cold path that failed in production. It now has a 2s command deadline — this client only issues SUBSCRIBE/UNSUBSCRIBE, always after ready — and a budget with room for two dead attempts and a healthy one. - The shared client's warm-up budget was 10s against a 5s deadline, exactly the moment ioredis would first tear a stalled socket down, so on the case warming exists for it gave up just before recovery could land. Warms the execution-signal subscriber alongside the shared client at process start, in parallel, never throwing: it is the second connection a run opens and was paying its handshake inside its own readiness budget. The shared redis-config mock mirrors the pure budget helper, since consumers now evaluate it at module load and would otherwise fail to import under the global mock.
1 parent 3dafafd commit 98cac46

8 files changed

Lines changed: 295 additions & 35 deletions

File tree

apps/sim/instrumentation-node.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -404,9 +404,13 @@ export async function register() {
404404
const { startMemoryTelemetry } = await import('./lib/monitoring/memory-telemetry')
405405
startMemoryTelemetry()
406406

407-
// Not awaited: the connection is warmed in the background so the first request
408-
// that needs Redis does not pay the handshake inside its own command deadline,
409-
// but boot never waits on Redis to serve requests that do not touch it.
410-
const { warmRedisConnection } = await import('./lib/core/config/redis')
407+
// Not awaited: both Redis connections are warmed in the background so the
408+
// first request that needs one does not pay the handshake inside its own
409+
// deadline, but boot never waits on Redis to serve requests that do not touch it.
410+
const [{ warmRedisConnection }, { warmExecutionSignalHub }] = await Promise.all([
411+
import('./lib/core/config/redis'),
412+
import('./lib/execution/execution-signal'),
413+
])
411414
void warmRedisConnection()
415+
void warmExecutionSignalHub()
412416
}

apps/sim/lib/core/config/redis.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,12 @@ vi.mock('ioredis', () => ({
4848
import {
4949
acquireLock,
5050
closeRedisConnection,
51+
coldConnectionBudgetMs,
5152
describeRedisConnection,
5253
extendLock,
5354
getRedisClient,
5455
onRedisReconnect,
56+
REDIS_WARMUP_TIMEOUT_MS,
5557
resetForTesting,
5658
warmRedisConnection,
5759
} from '@/lib/core/config/redis'
@@ -471,7 +473,44 @@ describe('redis config', () => {
471473
})
472474
})
473475

476+
describe('coldConnectionBudgetMs', () => {
477+
it('charges two command deadlines per dead handshake, plus each reconnect delay', () => {
478+
// SETNAME/SETINFO gate the INFO ready check, and on a dead socket they
479+
// settle only by timing out — so a dead attempt costs 2x, not 1x.
480+
expect(coldConnectionBudgetMs({ commandTimeoutMs: 2_000, retryDelaysMs: [500, 1_000] })).toBe(
481+
2 * 2_000 + 500 + 2 * 2_000 + 1_000 + 1_000
482+
)
483+
})
484+
485+
it('leaves only the healthy-handshake allowance when no dead attempts are tolerated', () => {
486+
expect(coldConnectionBudgetMs({ commandTimeoutMs: 5_000, retryDelaysMs: [] })).toBe(1_000)
487+
})
488+
})
489+
474490
describe('warmRedisConnection', () => {
491+
it('outlasts one dead handshake so the reconnect can be what warms it', async () => {
492+
mockRedisInstance.status = 'connecting'
493+
const warm = warmRedisConnection()
494+
495+
// Two command deadlines to diagnose the dead socket, then the longest
496+
// first reconnect delay: the moment a healthy second attempt can begin.
497+
await vi.advanceTimersByTimeAsync(2 * 5_000 + 1_300)
498+
const client = getRedisClient()
499+
Object.assign(client ?? {}, { status: 'ready' })
500+
client?.emit('ready')
501+
502+
await expect(warm).resolves.toBe(true)
503+
})
504+
505+
it('still gives up once the budget is spent', async () => {
506+
mockRedisInstance.status = 'connecting'
507+
const warm = warmRedisConnection()
508+
509+
await vi.advanceTimersByTimeAsync(REDIS_WARMUP_TIMEOUT_MS)
510+
511+
await expect(warm).resolves.toBe(false)
512+
})
513+
475514
it('resolves immediately when the connection is already usable', async () => {
476515
mockRedisInstance.status = 'ready'
477516

apps/sim/lib/core/config/redis.ts

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -203,12 +203,49 @@ export function describeRedisConnection(
203203

204204
const PING_INTERVAL_MS = 15_000
205205
const MAX_PING_FAILURES = 2
206+
const SHARED_COMMAND_TIMEOUT_MS = 5_000
207+
const RECONNECT_BASE_MS = 1_000
208+
const RECONNECT_MAX_BASE_MS = 10_000
209+
const RECONNECT_JITTER_RATIO = 0.3
210+
/** Generous room for the healthy handshake that follows a recovered stall; a real one takes tens of milliseconds. */
211+
const HEALTHY_HANDSHAKE_ALLOWANCE_MS = 1_000
212+
213+
/**
214+
* How long a wait for a cold connection must allow before giving up, if it is
215+
* to survive `retryDelaysMs.length` dead handshakes and still see a healthy one
216+
* land.
217+
*
218+
* A dead handshake costs **two** command deadlines, not one. ioredis sends
219+
* `CLIENT SETNAME`/`CLIENT SETINFO` on connect and dispatches the `INFO` ready
220+
* check only once those settle — and on a socket that never answers they
221+
* settle by timing out. Only then does the ready check start its own deadline,
222+
* fail, and tear the socket down for `retryStrategy` to reconnect. A budget
223+
* sized to one deadline expires while the first attempt is still being
224+
* diagnosed, so a configured retry can never be the thing that saves it.
225+
*
226+
* `retryDelaysMs` lists the reconnect delay after each dead attempt, in order,
227+
* so a caller states exactly what its own `retryStrategy` would return.
228+
*/
229+
export function coldConnectionBudgetMs(options: {
230+
commandTimeoutMs: number
231+
retryDelaysMs: readonly number[]
232+
}): number {
233+
const recovery = options.retryDelaysMs.reduce(
234+
(total, retryDelayMs) => total + 2 * options.commandTimeoutMs + retryDelayMs,
235+
0
236+
)
237+
return recovery + HEALTHY_HANDSHAKE_ALLOWANCE_MS
238+
}
239+
206240
/**
207-
* Warm-up budget. Sized to outlast a slow handshake rather than a fast one,
208-
* because giving up early just returns the handshake to the first command's
209-
* deadline, which is the thing this exists to avoid.
241+
* Warm-up budget: one dead handshake, its longest possible first reconnect
242+
* delay, then a healthy attempt. Giving up sooner returns the handshake to the
243+
* first command's deadline, which is the thing warming exists to avoid.
210244
*/
211-
const REDIS_WARMUP_TIMEOUT_MS = 10_000
245+
export const REDIS_WARMUP_TIMEOUT_MS = coldConnectionBudgetMs({
246+
commandTimeoutMs: SHARED_COMMAND_TIMEOUT_MS,
247+
retryDelaysMs: [RECONNECT_BASE_MS * (1 + RECONNECT_JITTER_RATIO)],
248+
})
212249

213250
export function getConfiguredRedisUrl(): string | null {
214251
if (getConfiguredCacheProvider() === 'database') return null
@@ -296,16 +333,16 @@ export function getRedisClient(): Redis | null {
296333

297334
state.client = new Redis(redisUrl, {
298335
...defaults,
299-
commandTimeout: 5000,
336+
commandTimeout: SHARED_COMMAND_TIMEOUT_MS,
300337
maxRetriesPerRequest: 5,
301338

302339
retryStrategy: (times) => {
303340
if (times > 10) {
304341
logger.error(`Redis reconnection attempt ${times}`, { nextRetryMs: 30000 })
305342
return 30000
306343
}
307-
const base = Math.min(1000 * 2 ** (times - 1), 10000)
308-
const jitter = randomFloat() * base * 0.3
344+
const base = Math.min(RECONNECT_BASE_MS * 2 ** (times - 1), RECONNECT_MAX_BASE_MS)
345+
const jitter = randomFloat() * base * RECONNECT_JITTER_RATIO
309346
const delay = Math.round(base + jitter)
310347
state.reconnects++
311348
logger.warn('Redis reconnecting', { attempt: times, nextRetryMs: delay })

apps/sim/lib/execution/execution-signal.test.ts

Lines changed: 91 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,31 @@
44
import { EventEmitter } from 'node:events'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

7-
const { connection, mockRedisUrl, mockSubscribe, mockUnsubscribe } = vi.hoisted(() => ({
8-
connection: {
9-
status: 'ready',
10-
client: undefined as EventEmitter | undefined,
11-
},
12-
mockRedisUrl: { value: 'redis://localhost:6379' as string | undefined },
13-
mockSubscribe: vi.fn(),
14-
mockUnsubscribe: vi.fn(),
15-
}))
7+
const { connection, mockRedisUrl, mockSubscribe, mockUnsubscribe, budgetInputs } = vi.hoisted(
8+
() => ({
9+
connection: {
10+
status: 'ready',
11+
client: undefined as EventEmitter | undefined,
12+
options: undefined as Record<string, unknown> | undefined,
13+
},
14+
mockRedisUrl: {
15+
value: 'redis://localhost:6379' as string | undefined,
16+
error: undefined as Error | undefined,
17+
},
18+
mockSubscribe: vi.fn(),
19+
mockUnsubscribe: vi.fn(),
20+
// A plain object, not a spy: the budget is computed once at module load, and
21+
// `vi.clearAllMocks()` in beforeEach would erase a spy's record of that call.
22+
budgetInputs: { value: undefined as unknown },
23+
})
24+
)
1625

1726
vi.mock('ioredis', () => ({
1827
default: class extends EventEmitter {
19-
constructor() {
28+
constructor(_url: string, options: Record<string, unknown>) {
2029
super()
2130
connection.client = this
31+
connection.options = options
2232
}
2333

2434
get status() {
@@ -31,13 +41,24 @@ vi.mock('ioredis', () => ({
3141
}))
3242

3343
vi.mock('@/lib/core/config/redis', () => ({
34-
getConfiguredRedisUrl: () => mockRedisUrl.value,
44+
getConfiguredRedisUrl: () => {
45+
if (mockRedisUrl.error) throw mockRedisUrl.error
46+
return mockRedisUrl.value
47+
},
3548
getRedisConnectionDefaults: () => ({}),
49+
// The budget arithmetic itself is covered in redis.test.ts; here only the
50+
// resulting number matters, and the readiness test reads it back by name.
51+
coldConnectionBudgetMs: (inputs: unknown) => {
52+
budgetInputs.value = inputs
53+
return 10_000
54+
},
3655
}))
3756

3857
import {
3958
getExecutionSignalHub,
4059
publishLocalExecutionSignal,
60+
SUBSCRIBER_READY_TIMEOUT_MS,
61+
warmExecutionSignalHub,
4162
} from '@/lib/execution/execution-signal'
4263

4364
describe('ExecutionSignalHub', () => {
@@ -48,6 +69,7 @@ describe('ExecutionSignalHub', () => {
4869
mockSubscribe.mockResolvedValue(1)
4970
mockUnsubscribe.mockResolvedValue(0)
5071
mockRedisUrl.value = 'redis://localhost:6379'
72+
mockRedisUrl.error = undefined
5173
const signalGlobal = globalThis as typeof globalThis & { _executionSignalHub?: unknown }
5274
signalGlobal._executionSignalHub = undefined
5375
})
@@ -241,7 +263,7 @@ describe('ExecutionSignalHub', () => {
241263
'Timed out waiting for Redis subscriber readiness'
242264
)
243265

244-
const timeout = vi.advanceTimersByTimeAsync(4000).then(() => {
266+
const timeout = vi.advanceTimersByTimeAsync(SUBSCRIBER_READY_TIMEOUT_MS - 1000).then(() => {
245267
connection.client?.emit('error', new Error('ECONNREFUSED'))
246268
return vi.advanceTimersByTimeAsync(1000)
247269
})
@@ -356,6 +378,63 @@ describe('ExecutionSignalHub', () => {
356378
expect(replacement).not.toHaveBeenCalledWith('unavailable')
357379
})
358380

381+
it('derives the readiness budget from the exact options the subscriber is built with', () => {
382+
getExecutionSignalHub()
383+
const options = connection.options as {
384+
commandTimeout: number
385+
retryStrategy: (attempt: number) => number
386+
}
387+
388+
// Two dead handshakes, so the budget states the reconnect delays after
389+
// attempt 1 and attempt 2 as the client's own retryStrategy would return them.
390+
expect(budgetInputs.value).toEqual({
391+
commandTimeoutMs: options.commandTimeout,
392+
retryDelaysMs: [options.retryStrategy(1), options.retryStrategy(2)],
393+
})
394+
// And the wait must outlast at least one full dead attempt, or the retry is decorative.
395+
expect(SUBSCRIBER_READY_TIMEOUT_MS).toBeGreaterThan(
396+
2 * options.commandTimeout + options.retryStrategy(1)
397+
)
398+
})
399+
400+
it('warms to true once the subscriber becomes ready', async () => {
401+
connection.status = 'connecting'
402+
const warm = warmExecutionSignalHub()
403+
404+
connection.status = 'ready'
405+
connection.client?.emit('ready')
406+
407+
await expect(warm).resolves.toBe(true)
408+
})
409+
410+
it('warms to false, not a rejection, when readiness never arrives', async () => {
411+
vi.useFakeTimers()
412+
try {
413+
connection.status = 'connect'
414+
const warm = warmExecutionSignalHub()
415+
416+
await vi.advanceTimersByTimeAsync(SUBSCRIBER_READY_TIMEOUT_MS)
417+
418+
await expect(warm).resolves.toBe(false)
419+
expect(vi.getTimerCount()).toBe(0)
420+
} finally {
421+
vi.useRealTimers()
422+
}
423+
})
424+
425+
it('reports not-warm instead of throwing when Redis is misconfigured', async () => {
426+
// Start-up hooks call this; a throw there would fail the run attempt.
427+
mockRedisUrl.error = new Error('Cache capability selected Redis but REDIS_URL is missing')
428+
429+
await expect(warmExecutionSignalHub()).resolves.toBe(false)
430+
})
431+
432+
it('is trivially warm when signals are process-local', async () => {
433+
mockRedisUrl.value = undefined
434+
435+
await expect(warmExecutionSignalHub()).resolves.toBe(true)
436+
})
437+
359438
it('uses a process-local signal hub when Redis is not configured', async () => {
360439
mockRedisUrl.value = undefined
361440
const handler = vi.fn()

0 commit comments

Comments
 (0)