Skip to content

Commit 28b482d

Browse files
committed
fix(redis): give every readiness waiter its own deadline and correct the budget model
Three corrections from an independent review of the branch. The shared readiness wait carried one timer, so a subscribe that joined an in-flight warm-up inherited only the remainder of that warm-up's budget — down to nothing — where before this branch it was guaranteed a full wait of its own. Waiters now share one ready/end listener pair but each runs its own deadline, and the signal is torn down when its last waiter gives up. The budget model claimed a dead handshake always costs two command deadlines. That is true only without a password: with one, ioredis treats a timed-out AUTH as fatal and tears the socket down after a single deadline, and a connect that never completes is bounded by connectTimeout, which the formula had no term for. `coldConnectionBudgetMs` now charges the largest of those so it holds for either URL shape, and moves to @sim/utils/retry: a pure helper evaluated at module load must not live in a module the test setup replaces wholesale, which had forced the shared mock to carry a verbatim mirror of the arithmetic. The subscriber derives its budget in its constructor from the exact options it is built with; the shared client's reconnect delay is a pure function used by both retryStrategy and the warm-up budget. The Next server no longer opens the signal subscriber at boot: the execution entry point already warms it on intent, and an eagerly opened subscriber that cannot reach Redis would reconnect on a five-second cadence in every idle replica for the life of the process.
1 parent fa2da17 commit 28b482d

10 files changed

Lines changed: 288 additions & 190 deletions

File tree

apps/sim/instrumentation-node.ts

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

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-
])
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')
414411
void warmRedisConnection()
415-
void warmExecutionSignalHub()
416412
}

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

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createMockRedis } from '@sim/testing'
2+
import { coldConnectionBudgetMs } from '@sim/utils/retry'
23
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
34

45
const { mockEnv, MockRedisConstructor, mockLogger } = vi.hoisted(() => ({
@@ -48,13 +49,12 @@ vi.mock('ioredis', () => ({
4849
import {
4950
acquireLock,
5051
closeRedisConnection,
51-
coldConnectionBudgetMs,
5252
describeRedisConnection,
5353
extendLock,
5454
getRedisClient,
5555
onRedisReconnect,
56-
REDIS_WARMUP_TIMEOUT_MS,
5756
resetForTesting,
57+
sharedReconnectDelayMs,
5858
warmRedisConnection,
5959
} from '@/lib/core/config/redis'
6060

@@ -473,17 +473,13 @@ describe('redis config', () => {
473473
})
474474
})
475475

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)
476+
describe('sharedReconnectDelayMs', () => {
477+
it('grows exponentially from the base and caps, with upward-only jitter', () => {
478+
expect(sharedReconnectDelayMs(1, 0)).toBe(1_000)
479+
expect(sharedReconnectDelayMs(2, 0)).toBe(2_000)
480+
expect(sharedReconnectDelayMs(5, 0)).toBe(10_000)
481+
expect(sharedReconnectDelayMs(6, 0)).toBe(10_000)
482+
expect(sharedReconnectDelayMs(1, 1)).toBe(1_300)
487483
})
488484
})
489485

@@ -492,9 +488,9 @@ describe('redis config', () => {
492488
mockRedisInstance.status = 'connecting'
493489
const warm = warmRedisConnection()
494490

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)
491+
// The dead attempt's own deadline, then the longest first reconnect
492+
// delay: the moment a healthy second attempt can begin.
493+
await vi.advanceTimersByTimeAsync(Math.max(10_000, 2 * 5_000) + sharedReconnectDelayMs(1, 1))
498494
const client = getRedisClient()
499495
Object.assign(client ?? {}, { status: 'ready' })
500496
client?.emit('ready')
@@ -506,7 +502,13 @@ describe('redis config', () => {
506502
mockRedisInstance.status = 'connecting'
507503
const warm = warmRedisConnection()
508504

509-
await vi.advanceTimersByTimeAsync(REDIS_WARMUP_TIMEOUT_MS)
505+
await vi.advanceTimersByTimeAsync(
506+
coldConnectionBudgetMs({
507+
connectTimeoutMs: 10_000,
508+
commandTimeoutMs: 5_000,
509+
reconnectDelayMs: sharedReconnectDelayMs(1, 1),
510+
})
511+
)
510512

511513
await expect(warm).resolves.toBe(false)
512514
})

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

Lines changed: 26 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { isIP } from 'node:net'
22
import { createLogger } from '@sim/logger'
33
import { toError } from '@sim/utils/errors'
44
import { randomFloat } from '@sim/utils/random'
5-
import Redis, { type RedisOptions } from 'ioredis'
5+
import { coldConnectionBudgetMs } from '@sim/utils/retry'
6+
import Redis from 'ioredis'
67
import { env } from '@/lib/core/config/env'
78
import { getConfiguredCacheProvider } from '@/lib/core/config/env-capabilities.server'
89

@@ -42,13 +43,20 @@ function resolveRedisTlsOptions(url: string | undefined): { servername: string }
4243
* and TLS SNI when REDIS_URL targets an IP. Every Redis client we open should
4344
* spread this; callers add their own retry / timeout policy on top.
4445
*/
45-
export function getRedisConnectionDefaults(
46-
url: string | undefined
47-
): Pick<RedisOptions, 'keepAlive' | 'connectTimeout' | 'enableOfflineQueue' | 'tls'> {
46+
export interface RedisConnectionDefaults {
47+
keepAlive: number
48+
connectTimeout: number
49+
enableOfflineQueue: boolean
50+
tls?: { servername: string }
51+
}
52+
53+
const CONNECT_TIMEOUT_MS = 10_000
54+
55+
export function getRedisConnectionDefaults(url: string | undefined): RedisConnectionDefaults {
4856
const tls = resolveRedisTlsOptions(url)
4957
return {
5058
keepAlive: 1000,
51-
connectTimeout: 10000,
59+
connectTimeout: CONNECT_TIMEOUT_MS,
5260
enableOfflineQueue: true,
5361
...(tls ? { tls } : {}),
5462
}
@@ -207,44 +215,28 @@ const SHARED_COMMAND_TIMEOUT_MS = 5_000
207215
const RECONNECT_BASE_MS = 1_000
208216
const RECONNECT_MAX_BASE_MS = 10_000
209217
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
212218

213219
/**
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.
220+
* The shared client's reconnect delay for attempt `times`, with `jitter` in
221+
* `[0, 1]` scaling the upward-only jitter band. Pure so the same formula can
222+
* be evaluated for a budget — `sharedReconnectDelayMs(1, 1)` is the longest
223+
* possible first reconnect — as well as from `retryStrategy`, which adds the
224+
* bookkeeping around it.
228225
*/
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
226+
export function sharedReconnectDelayMs(times: number, jitter: number): number {
227+
const base = Math.min(RECONNECT_BASE_MS * 2 ** (times - 1), RECONNECT_MAX_BASE_MS)
228+
return Math.round(base + jitter * base * RECONNECT_JITTER_RATIO)
238229
}
239230

240231
/**
241-
* Warm-up budget: one dead handshake, its longest possible first reconnect
232+
* Warm-up budget: one dead attempt, its longest possible first reconnect
242233
* delay, then a healthy attempt. Giving up sooner returns the handshake to the
243234
* first command's deadline, which is the thing warming exists to avoid.
244235
*/
245-
export const REDIS_WARMUP_TIMEOUT_MS = coldConnectionBudgetMs({
236+
const REDIS_WARMUP_TIMEOUT_MS = coldConnectionBudgetMs({
237+
connectTimeoutMs: CONNECT_TIMEOUT_MS,
246238
commandTimeoutMs: SHARED_COMMAND_TIMEOUT_MS,
247-
retryDelaysMs: [RECONNECT_BASE_MS * (1 + RECONNECT_JITTER_RATIO)],
239+
reconnectDelayMs: sharedReconnectDelayMs(1, 1),
248240
})
249241

250242
export function getConfiguredRedisUrl(): string | null {
@@ -341,9 +333,7 @@ export function getRedisClient(): Redis | null {
341333
logger.error(`Redis reconnection attempt ${times}`, { nextRetryMs: 30000 })
342334
return 30000
343335
}
344-
const base = Math.min(RECONNECT_BASE_MS * 2 ** (times - 1), RECONNECT_MAX_BASE_MS)
345-
const jitter = randomFloat() * base * RECONNECT_JITTER_RATIO
346-
const delay = Math.round(base + jitter)
336+
const delay = sharedReconnectDelayMs(times, randomFloat())
347337
state.reconnects++
348338
logger.warn('Redis reconnecting', { attempt: times, nextRetryMs: delay })
349339
return delay

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

Lines changed: 79 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,22 @@
22
* @vitest-environment node
33
*/
44
import { EventEmitter } from 'node:events'
5+
import { coldConnectionBudgetMs } from '@sim/utils/retry'
56
import { beforeEach, describe, expect, it, vi } from 'vitest'
67

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-
)
8+
const { connection, mockRedisUrl, mockSubscribe, mockUnsubscribe } = vi.hoisted(() => ({
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+
}))
2521

2622
vi.mock('ioredis', () => ({
2723
default: class extends EventEmitter {
@@ -40,32 +36,35 @@ vi.mock('ioredis', () => ({
4036
},
4137
}))
4238

43-
vi.mock('@/lib/core/config/redis', async () => {
44-
const { redisConfigMock } = await import('@sim/testing')
45-
return {
46-
getConfiguredRedisUrl: () => {
47-
if (mockRedisUrl.error) throw mockRedisUrl.error
48-
return mockRedisUrl.value
49-
},
50-
getRedisConnectionDefaults: () => ({}),
51-
// Records the inputs, then answers with the shared mirror of the real
52-
// arithmetic so the derived budget here is the number production derives.
53-
coldConnectionBudgetMs: (
54-
inputs: Parameters<typeof redisConfigMock.coldConnectionBudgetMs>[0]
55-
) => {
56-
budgetInputs.value = inputs
57-
return redisConfigMock.coldConnectionBudgetMs(inputs)
58-
},
59-
}
60-
})
39+
vi.mock('@/lib/core/config/redis', () => ({
40+
getConfiguredRedisUrl: () => {
41+
if (mockRedisUrl.error) throw mockRedisUrl.error
42+
return mockRedisUrl.value
43+
},
44+
// Realistic defaults: the readiness budget has a connect-deadline term.
45+
getRedisConnectionDefaults: () => ({ connectTimeout: 10_000 }),
46+
}))
6147

6248
import {
6349
getExecutionSignalHub,
6450
publishLocalExecutionSignal,
65-
SUBSCRIBER_READY_TIMEOUT_MS,
6651
warmExecutionSignalHub,
6752
} from '@/lib/execution/execution-signal'
6853

54+
/** The readiness budget production derives from the options the subscriber was built with. */
55+
function readyBudgetMs(): number {
56+
const options = connection.options as {
57+
connectTimeout: number
58+
commandTimeout: number
59+
retryStrategy: (attempt: number) => number
60+
}
61+
return coldConnectionBudgetMs({
62+
connectTimeoutMs: options.connectTimeout,
63+
commandTimeoutMs: options.commandTimeout,
64+
reconnectDelayMs: options.retryStrategy(1),
65+
})
66+
}
67+
6968
describe('ExecutionSignalHub', () => {
7069
beforeEach(() => {
7170
vi.clearAllMocks()
@@ -268,7 +267,7 @@ describe('ExecutionSignalHub', () => {
268267
'Timed out waiting for Redis subscriber readiness'
269268
)
270269

271-
const timeout = vi.advanceTimersByTimeAsync(SUBSCRIBER_READY_TIMEOUT_MS - 1000).then(() => {
270+
const timeout = vi.advanceTimersByTimeAsync(readyBudgetMs() - 1000).then(() => {
272271
connection.client?.emit('error', new Error('ECONNREFUSED'))
273272
return vi.advanceTimersByTimeAsync(1000)
274273
})
@@ -383,23 +382,49 @@ describe('ExecutionSignalHub', () => {
383382
expect(replacement).not.toHaveBeenCalledWith('unavailable')
384383
})
385384

386-
it('derives the readiness budget from the exact options the subscriber is built with', () => {
387-
getExecutionSignalHub()
388-
const options = connection.options as {
389-
commandTimeout: number
390-
retryStrategy: (attempt: number) => number
385+
it('waits exactly the budget derived from the options the subscriber is built with', async () => {
386+
vi.useFakeTimers()
387+
try {
388+
connection.status = 'connect'
389+
const hub = getExecutionSignalHub()
390+
const subscription = hub.subscribe('execution-1', vi.fn())
391+
const settled = vi.fn()
392+
void subscription.then(settled, settled)
393+
394+
await vi.advanceTimersByTimeAsync(readyBudgetMs() - 1)
395+
expect(settled).not.toHaveBeenCalled()
396+
397+
await vi.advanceTimersByTimeAsync(1)
398+
await expect(subscription).rejects.toThrow('Timed out waiting for Redis subscriber readiness')
399+
} finally {
400+
vi.useRealTimers()
391401
}
402+
})
392403

393-
// One dead handshake, so the budget states the reconnect delay after
394-
// attempt 1 as the client's own retryStrategy would return it.
395-
expect(budgetInputs.value).toEqual({
396-
commandTimeoutMs: options.commandTimeout,
397-
retryDelaysMs: [options.retryStrategy(1)],
398-
})
399-
// And the wait must outlast at least one full dead attempt, or the retry is decorative.
400-
expect(SUBSCRIBER_READY_TIMEOUT_MS).toBeGreaterThan(
401-
2 * options.commandTimeout + options.retryStrategy(1)
402-
)
404+
it('gives a subscribe that joins an in-flight warm-up its own full budget', async () => {
405+
vi.useFakeTimers()
406+
try {
407+
connection.status = 'connect'
408+
const warm = warmExecutionSignalHub()
409+
// Late joiner: the warm-up has almost spent its budget when this subscribe begins.
410+
await vi.advanceTimersByTimeAsync(readyBudgetMs() - 1000)
411+
const subscription = getExecutionSignalHub().subscribe('execution-1', vi.fn())
412+
const settled = vi.fn()
413+
void subscription.then(settled, settled)
414+
415+
await vi.advanceTimersByTimeAsync(1000)
416+
await expect(warm).resolves.toBe(false)
417+
// The warm-up's deadline was its own; the subscribe is still waiting.
418+
expect(settled).not.toHaveBeenCalled()
419+
expect(connection.client?.listenerCount('ready')).toBe(2)
420+
421+
connection.status = 'ready'
422+
connection.client?.emit('ready')
423+
await subscription
424+
expect(mockSubscribe).toHaveBeenCalledOnce()
425+
} finally {
426+
vi.useRealTimers()
427+
}
403428
})
404429

405430
it('warms to true once the subscriber becomes ready', async () => {
@@ -418,7 +443,7 @@ describe('ExecutionSignalHub', () => {
418443
connection.status = 'connect'
419444
const warm = warmExecutionSignalHub()
420445

421-
await vi.advanceTimersByTimeAsync(SUBSCRIBER_READY_TIMEOUT_MS)
446+
await vi.advanceTimersByTimeAsync(readyBudgetMs())
422447

423448
await expect(warm).resolves.toBe(false)
424449
expect(vi.getTimerCount()).toBe(0)

0 commit comments

Comments
 (0)