Skip to content

Commit b91b90a

Browse files
committed
fix(redis): connect the signal subscriber on intent and complete the budget model
Findings from an independent review of the finished branch. Constructing the execution-signal hub is what connects — ioredis dials in its constructor — so the warm-up that observed that handshake and returned a boolean its only caller discarded is gone. The execution entry point now constructs the hub, synchronously and never throwing, and the subscribe that follows waits on its own budget. With no observer to accommodate, the waiter machinery collapses to a race between the shared readiness signal and a per-waiter deadline, with a per-signal count so the last waiter detaches. The budget's dead-attempt term was still short for an unanswered handshake: after the ready check times out, ioredis half-closes the socket and destroys it only after `disconnectTimeout` when a wedged peer never answers with a FIN. Measured against such a peer: an unauthenticated connection reconnects at 12.5s, past the 11.5s the budget allowed. The term is now `max(connectTimeout, 2 * commandTimeout + disconnectTimeout)`, with the disconnect deadline stated in the shared connection defaults so the budget derives from it. The model is specific to ioredis 5's handshake sequence, so it lives beside the pinned client in `lib/core/config/redis-budget.ts` — a module the global test mock does not replace — rather than in a generic package. Corrects two doc claims: no caller warms the subscriber at server boot, and a late joiner never had a full wait of its own before this branch either — the memoized promise shared its single timer.
1 parent 39e039a commit b91b90a

11 files changed

Lines changed: 192 additions & 205 deletions

File tree

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
1+
/**
2+
* @vitest-environment node
3+
*/
14
import { describe, expect, it } from 'vitest'
2-
import { coldConnectionBudgetMs } from './retry'
5+
import { coldConnectionBudgetMs } from '@/lib/core/config/redis-budget'
36

47
describe('coldConnectionBudgetMs', () => {
5-
it('charges a dead handshake at two command deadlines when that exceeds the connect deadline', () => {
6-
// Unauthenticated: SETNAME/SETINFO settle by timing out before INFO starts its own deadline.
8+
it('charges an unanswered handshake at two command deadlines plus the half-close wait', () => {
9+
// Unauthenticated: SETNAME/SETINFO time out, then INFO times out, then the
10+
// half-closed socket waits for a FIN a wedged peer never sends.
711
expect(
812
coldConnectionBudgetMs({
913
connectTimeoutMs: 1_000,
10-
commandTimeoutMs: 2_000,
14+
commandTimeoutMs: 5_000,
15+
disconnectTimeoutMs: 2_000,
1116
reconnectDelayMs: 500,
1217
})
13-
).toBe(2 * 2_000 + 500 + 1_000)
18+
).toBe(2 * 5_000 + 2_000 + 500 + 1_000)
1419
})
1520

1621
it('charges the connect deadline when a connection that never completes is the longer case', () => {
@@ -20,14 +25,20 @@ describe('coldConnectionBudgetMs', () => {
2025
coldConnectionBudgetMs({
2126
connectTimeoutMs: 10_000,
2227
commandTimeoutMs: 3_000,
28+
disconnectTimeoutMs: 2_000,
2329
reconnectDelayMs: 500,
2430
})
2531
).toBe(10_000 + 500 + 1_000)
2632
})
2733

2834
it('always leaves room for the healthy attempt that follows the reconnect', () => {
2935
expect(
30-
coldConnectionBudgetMs({ connectTimeoutMs: 0, commandTimeoutMs: 0, reconnectDelayMs: 0 })
36+
coldConnectionBudgetMs({
37+
connectTimeoutMs: 0,
38+
commandTimeoutMs: 0,
39+
disconnectTimeoutMs: 0,
40+
reconnectDelayMs: 0,
41+
})
3142
).toBe(1_000)
3243
})
3344
})
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/** Generous room for the healthy handshake that follows a recovered stall; a real one takes tens of milliseconds. */
2+
const HEALTHY_HANDSHAKE_ALLOWANCE_MS = 1_000
3+
4+
export interface ColdConnectionBudgetOptions {
5+
/** The client's TCP connect deadline: bounds a connection that never completes at all. */
6+
connectTimeoutMs: number
7+
/** The client's per-command deadline: bounds a handshake command the server never answers. */
8+
commandTimeoutMs: number
9+
/** How long ioredis waits after half-closing a dead socket for the peer's FIN before destroying it. */
10+
disconnectTimeoutMs: number
11+
/** What the client's `retryStrategy` returns for its first reconnect. */
12+
reconnectDelayMs: number
13+
}
14+
15+
/**
16+
* How long a wait for a cold ioredis connection must allow before giving up,
17+
* if it is to survive one dead attempt and still see a healthy one land.
18+
*
19+
* A dead attempt is diagnosed by whichever deadline governs the phase it
20+
* stalls in. A connect that never completes costs `connectTimeoutMs`, and the
21+
* socket is destroyed outright. A connection that opens but whose handshake
22+
* is never answered costs command deadlines — one with a password, because a
23+
* timed-out `AUTH` is fatal; two without, because `CLIENT SETNAME`/`SETINFO`
24+
* must settle, by timing out, before the `INFO` ready check starts its own —
25+
* and then `disconnectTimeoutMs` more, because ioredis half-closes the socket
26+
* and a peer that is wedged never answers with a FIN. The budget takes the
27+
* larger phase so it holds for either URL shape without parsing it. Only then
28+
* does `retryStrategy` run and a fresh attempt begin.
29+
*
30+
* A wait sized to a single deadline expires while the first attempt is still
31+
* being diagnosed, so a configured retry can never be the thing that saves it.
32+
*
33+
* The guarantee is one dead attempt from a fresh or previously-ready client.
34+
* ioredis feeds `retryStrategy` its running attempt count, so a wait that
35+
* begins while the client is already deep in a reconnect loop faces larger
36+
* delays this does not model. The handshake sequence is ioredis 5's; keep this
37+
* beside the pinned client, not in a generic package.
38+
*/
39+
export function coldConnectionBudgetMs(options: ColdConnectionBudgetOptions): number {
40+
const deadAttemptMs = Math.max(
41+
options.connectTimeoutMs,
42+
2 * options.commandTimeoutMs + options.disconnectTimeoutMs
43+
)
44+
return deadAttemptMs + options.reconnectDelayMs + HEALTHY_HANDSHAKE_ALLOWANCE_MS
45+
}

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

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

54
const { mockEnv, MockRedisConstructor, mockLogger } = vi.hoisted(() => ({
@@ -48,15 +47,19 @@ vi.mock('ioredis', () => ({
4847

4948
import {
5049
acquireLock,
50+
CONNECT_TIMEOUT_MS,
5151
closeRedisConnection,
52+
DISCONNECT_TIMEOUT_MS,
5253
describeRedisConnection,
5354
extendLock,
5455
getRedisClient,
5556
onRedisReconnect,
5657
resetForTesting,
58+
SHARED_COMMAND_TIMEOUT_MS,
5759
sharedReconnectDelayMs,
5860
warmRedisConnection,
5961
} from '@/lib/core/config/redis'
62+
import { coldConnectionBudgetMs } from '@/lib/core/config/redis-budget'
6063

6164
describe('redis config', () => {
6265
beforeEach(() => {
@@ -488,9 +491,12 @@ describe('redis config', () => {
488491
mockRedisInstance.status = 'connecting'
489492
const warm = warmRedisConnection()
490493

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))
494+
// The dead attempt's own diagnosis and half-close, then the longest first
495+
// reconnect delay: the moment a healthy second attempt can begin.
496+
await vi.advanceTimersByTimeAsync(
497+
Math.max(CONNECT_TIMEOUT_MS, 2 * SHARED_COMMAND_TIMEOUT_MS + DISCONNECT_TIMEOUT_MS) +
498+
sharedReconnectDelayMs(1, 1)
499+
)
494500
const client = getRedisClient()
495501
Object.assign(client ?? {}, { status: 'ready' })
496502
client?.emit('ready')
@@ -504,8 +510,9 @@ describe('redis config', () => {
504510

505511
await vi.advanceTimersByTimeAsync(
506512
coldConnectionBudgetMs({
507-
connectTimeoutMs: 10_000,
508-
commandTimeoutMs: 5_000,
513+
connectTimeoutMs: CONNECT_TIMEOUT_MS,
514+
commandTimeoutMs: SHARED_COMMAND_TIMEOUT_MS,
515+
disconnectTimeoutMs: DISCONNECT_TIMEOUT_MS,
509516
reconnectDelayMs: sharedReconnectDelayMs(1, 1),
510517
})
511518
)

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ 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 { coldConnectionBudgetMs } from '@sim/utils/retry'
65
import Redis from 'ioredis'
76
import { env } from '@/lib/core/config/env'
87
import { getConfiguredCacheProvider } from '@/lib/core/config/env-capabilities.server'
8+
import { coldConnectionBudgetMs } from '@/lib/core/config/redis-budget'
99

1010
const logger = createLogger('Redis')
1111

@@ -46,17 +46,21 @@ function resolveRedisTlsOptions(url: string | undefined): { servername: string }
4646
export interface RedisConnectionDefaults {
4747
keepAlive: number
4848
connectTimeout: number
49+
disconnectTimeout: number
4950
enableOfflineQueue: boolean
5051
tls?: { servername: string }
5152
}
5253

53-
const CONNECT_TIMEOUT_MS = 10_000
54+
export const CONNECT_TIMEOUT_MS = 10_000
55+
/** ioredis's own default, stated so readiness budgets can be derived from it. */
56+
export const DISCONNECT_TIMEOUT_MS = 2_000
5457

5558
export function getRedisConnectionDefaults(url: string | undefined): RedisConnectionDefaults {
5659
const tls = resolveRedisTlsOptions(url)
5760
return {
5861
keepAlive: 1000,
5962
connectTimeout: CONNECT_TIMEOUT_MS,
63+
disconnectTimeout: DISCONNECT_TIMEOUT_MS,
6064
enableOfflineQueue: true,
6165
...(tls ? { tls } : {}),
6266
}
@@ -211,7 +215,7 @@ export function describeRedisConnection(
211215

212216
const PING_INTERVAL_MS = 15_000
213217
const MAX_PING_FAILURES = 2
214-
const SHARED_COMMAND_TIMEOUT_MS = 5_000
218+
export const SHARED_COMMAND_TIMEOUT_MS = 5_000
215219
const RECONNECT_BASE_MS = 1_000
216220
const RECONNECT_MAX_BASE_MS = 10_000
217221
const RECONNECT_JITTER_RATIO = 0.3
@@ -236,6 +240,7 @@ export function sharedReconnectDelayMs(times: number, jitter: number): number {
236240
const REDIS_WARMUP_TIMEOUT_MS = coldConnectionBudgetMs({
237241
connectTimeoutMs: CONNECT_TIMEOUT_MS,
238242
commandTimeoutMs: SHARED_COMMAND_TIMEOUT_MS,
243+
disconnectTimeoutMs: DISCONNECT_TIMEOUT_MS,
239244
reconnectDelayMs: sharedReconnectDelayMs(1, 1),
240245
})
241246

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

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

87
const { connection, mockRedisUrl, mockSubscribe, mockUnsubscribe } = vi.hoisted(() => ({
@@ -41,26 +40,30 @@ vi.mock('@/lib/core/config/redis', () => ({
4140
if (mockRedisUrl.error) throw mockRedisUrl.error
4241
return mockRedisUrl.value
4342
},
44-
// Realistic defaults: the readiness budget has a connect-deadline term.
45-
getRedisConnectionDefaults: () => ({ connectTimeout: 10_000 }),
43+
// Realistic defaults: the readiness budget derives from these. The literals
44+
// are inherent to mocking the module that exports the real constants.
45+
getRedisConnectionDefaults: () => ({ connectTimeout: 10_000, disconnectTimeout: 2_000 }),
4646
}))
4747

48+
import { coldConnectionBudgetMs } from '@/lib/core/config/redis-budget'
4849
import {
50+
connectExecutionSignalHub,
4951
getExecutionSignalHub,
5052
publishLocalExecutionSignal,
51-
warmExecutionSignalHub,
5253
} from '@/lib/execution/execution-signal'
5354

5455
/** The readiness budget production derives from the options the subscriber was built with. */
5556
function readyBudgetMs(): number {
5657
const options = connection.options as {
5758
connectTimeout: number
5859
commandTimeout: number
60+
disconnectTimeout: number
5961
retryStrategy: (attempt: number) => number
6062
}
6163
return coldConnectionBudgetMs({
6264
connectTimeoutMs: options.connectTimeout,
6365
commandTimeoutMs: options.commandTimeout,
66+
disconnectTimeoutMs: options.disconnectTimeout,
6467
reconnectDelayMs: options.retryStrategy(1),
6568
})
6669
}
@@ -401,27 +404,33 @@ describe('ExecutionSignalHub', () => {
401404
}
402405
})
403406

404-
it('gives a subscribe that joins an in-flight warm-up its own full budget', async () => {
407+
it('gives a subscribe that joins another in-flight wait its own full budget', async () => {
405408
vi.useFakeTimers()
406409
try {
407410
connection.status = 'connect'
408-
const warm = warmExecutionSignalHub()
409-
// Late joiner: the warm-up has almost spent its budget when this subscribe begins.
411+
const hub = getExecutionSignalHub()
412+
const first = hub.subscribe('execution-first', vi.fn())
413+
const firstSettled = vi.fn()
414+
void first.then(firstSettled, firstSettled)
415+
// Late joiner: the first wait has almost spent its budget when this one begins.
410416
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)
417+
const second = hub.subscribe('execution-second', vi.fn())
418+
const secondSettled = vi.fn()
419+
void second.then(secondSettled, secondSettled)
414420

415421
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()
422+
await expect(first).rejects.toThrow('Timed out waiting for Redis subscriber readiness')
423+
// The first deadline was its own; the second is still waiting on the shared signal.
424+
expect(secondSettled).not.toHaveBeenCalled()
419425
expect(connection.client?.listenerCount('ready')).toBe(2)
420426

421427
connection.status = 'ready'
422428
connection.client?.emit('ready')
423-
await subscription
424-
expect(mockSubscribe).toHaveBeenCalledOnce()
429+
await second
430+
expect(mockSubscribe).toHaveBeenCalledWith(
431+
'execution:signal:execution-second',
432+
'execution:cancel'
433+
)
425434
} finally {
426435
vi.useRealTimers()
427436
}
@@ -472,42 +481,21 @@ describe('ExecutionSignalHub', () => {
472481
}
473482
})
474483

475-
it('warms to true once the subscriber becomes ready', async () => {
484+
it('begins connecting when asked to connect ahead of a subscription', () => {
476485
connection.status = 'connecting'
477-
const warm = warmExecutionSignalHub()
478-
479-
connection.status = 'ready'
480-
connection.client?.emit('ready')
481-
482-
await expect(warm).resolves.toBe(true)
483-
})
484-
485-
it('warms to false, not a rejection, when readiness never arrives', async () => {
486-
vi.useFakeTimers()
487-
try {
488-
connection.status = 'connect'
489-
const warm = warmExecutionSignalHub()
490486

491-
await vi.advanceTimersByTimeAsync(readyBudgetMs())
487+
connectExecutionSignalHub()
492488

493-
await expect(warm).resolves.toBe(false)
494-
expect(vi.getTimerCount()).toBe(0)
495-
} finally {
496-
vi.useRealTimers()
497-
}
489+
// Constructing the hub is what dials; the client exists before any subscribe.
490+
expect(connection.client).toBeDefined()
491+
expect(mockSubscribe).not.toHaveBeenCalled()
498492
})
499493

500-
it('reports not-warm instead of throwing when Redis is misconfigured', async () => {
501-
// Start-up hooks call this; a throw there would fail the run attempt.
494+
it('does not throw when Redis is misconfigured, leaving that to the first subscriber', () => {
502495
mockRedisUrl.error = new Error('Cache capability selected Redis but REDIS_URL is missing')
503496

504-
await expect(warmExecutionSignalHub()).resolves.toBe(false)
505-
})
506-
507-
it('is trivially warm when signals are process-local', async () => {
508-
mockRedisUrl.value = undefined
509-
510-
await expect(warmExecutionSignalHub()).resolves.toBe(true)
497+
expect(() => connectExecutionSignalHub()).not.toThrow()
498+
expect(connection.client).toBeUndefined()
511499
})
512500

513501
it('uses a process-local signal hub when Redis is not configured', async () => {

0 commit comments

Comments
 (0)