Skip to content

Commit b74f174

Browse files
committed
fix(execution): wait for Redis readiness before subscribing
1 parent a0b120c commit b74f174

2 files changed

Lines changed: 205 additions & 21 deletions

File tree

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

Lines changed: 153 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import { EventEmitter } from 'node:events'
45
import { beforeEach, describe, expect, it, vi } from 'vitest'
56

6-
const { listeners, mockRedisUrl, mockSubscribe, mockUnsubscribe } = vi.hoisted(() => ({
7-
listeners: new Map<string, (...args: unknown[]) => void>(),
7+
const { connection, mockRedisUrl, mockSubscribe, mockUnsubscribe } = vi.hoisted(() => ({
8+
connection: {
9+
status: 'ready',
10+
client: undefined as EventEmitter | undefined,
11+
},
812
mockRedisUrl: { value: 'redis://localhost:6379' as string | undefined },
913
mockSubscribe: vi.fn(),
1014
mockUnsubscribe: vi.fn(),
1115
}))
1216

1317
vi.mock('ioredis', () => ({
14-
default: class {
15-
on(event: string, handler: (...args: unknown[]) => void) {
16-
listeners.set(event, handler)
17-
return this
18+
default: class extends EventEmitter {
19+
constructor() {
20+
super()
21+
connection.client = this
22+
}
23+
24+
get status() {
25+
return connection.status
1826
}
1927

2028
subscribe = mockSubscribe
@@ -35,14 +43,140 @@ import {
3543
describe('ExecutionSignalHub', () => {
3644
beforeEach(() => {
3745
vi.clearAllMocks()
38-
listeners.clear()
46+
connection.status = 'ready'
47+
connection.client = undefined
3948
mockSubscribe.mockResolvedValue(1)
4049
mockUnsubscribe.mockResolvedValue(0)
4150
mockRedisUrl.value = 'redis://localhost:6379'
4251
const signalGlobal = globalThis as typeof globalThis & { _executionSignalHub?: unknown }
4352
signalGlobal._executionSignalHub = undefined
4453
})
4554

55+
it.each(['connecting', 'connect', 'reconnecting'])(
56+
'waits for Redis readiness while %s before subscribing concurrent execution channels',
57+
async (status) => {
58+
connection.status = status
59+
const hub = getExecutionSignalHub()
60+
const subscriptions = Array.from({ length: 12 }, (_, index) =>
61+
hub.subscribe(`execution-${index}`, vi.fn())
62+
)
63+
64+
await Promise.resolve()
65+
expect(mockSubscribe).not.toHaveBeenCalled()
66+
expect(connection.client?.listenerCount('ready')).toBeLessThanOrEqual(2)
67+
68+
connection.status = 'ready'
69+
connection.client?.emit('ready')
70+
await Promise.all(subscriptions)
71+
72+
expect(mockSubscribe).toHaveBeenCalledTimes(12)
73+
expect(connection.client?.listenerCount('ready')).toBe(1)
74+
expect(connection.client?.listenerCount('error')).toBe(1)
75+
expect(connection.client?.listenerCount('end')).toBe(0)
76+
}
77+
)
78+
79+
it('rechecks readiness when the connection closes before waiting subscriptions resume', async () => {
80+
connection.status = 'connect'
81+
const hub = getExecutionSignalHub()
82+
const subscription = hub.subscribe('execution-1', vi.fn())
83+
84+
connection.status = 'ready'
85+
connection.client?.emit('ready')
86+
connection.status = 'connect'
87+
connection.client?.emit('close')
88+
await vi.waitFor(() => expect(connection.client?.listenerCount('ready')).toBe(2))
89+
expect(mockSubscribe).not.toHaveBeenCalled()
90+
91+
connection.status = 'ready'
92+
connection.client?.emit('ready')
93+
await subscription
94+
expect(mockSubscribe).toHaveBeenCalled()
95+
})
96+
97+
it('rejects immediately when the subscriber has already ended', async () => {
98+
connection.status = 'end'
99+
100+
await expect(getExecutionSignalHub().subscribe('execution-1', vi.fn())).rejects.toThrow(
101+
'Redis subscriber connection ended'
102+
)
103+
expect(mockSubscribe).not.toHaveBeenCalled()
104+
expect(connection.client?.listenerCount('ready')).toBe(1)
105+
})
106+
107+
it('does not subscribe new channels while Redis is reconnecting', async () => {
108+
const hub = getExecutionSignalHub()
109+
connection.client?.emit('ready')
110+
const handler = vi.fn()
111+
await hub.subscribe('execution-existing', handler)
112+
mockSubscribe.mockClear()
113+
connection.status = 'connect'
114+
connection.client?.emit('close')
115+
const subscription = hub.subscribe('execution-new', vi.fn())
116+
117+
await Promise.resolve()
118+
expect(mockSubscribe).not.toHaveBeenCalled()
119+
120+
connection.status = 'ready'
121+
connection.client?.emit('ready')
122+
await subscription
123+
await vi.waitFor(() => expect(handler).toHaveBeenCalledWith('reconnected'))
124+
expect(mockSubscribe).toHaveBeenCalledWith('execution:signal:execution-new', 'execution:cancel')
125+
})
126+
127+
it.each(['error', 'end'])(
128+
'rejects readiness waiters on %s and allows a fresh attempt',
129+
async (event) => {
130+
connection.status = 'connect'
131+
const hub = getExecutionSignalHub()
132+
const handler = vi.fn()
133+
const subscription = hub.subscribe('execution-1', handler)
134+
const rejected = expect(subscription).rejects.toThrow('Execution signal subscription failed:')
135+
136+
connection.status = 'end'
137+
connection.client?.emit(event, new Error('connection failed'))
138+
await rejected
139+
expect(mockSubscribe).not.toHaveBeenCalled()
140+
expect(connection.client?.listenerCount('ready')).toBe(1)
141+
expect(connection.client?.listenerCount('error')).toBe(1)
142+
expect(connection.client?.listenerCount('end')).toBe(0)
143+
144+
connection.status = 'ready'
145+
connection.client?.emit('ready')
146+
await hub.subscribe('execution-1', handler)
147+
expect(mockSubscribe).toHaveBeenCalledOnce()
148+
}
149+
)
150+
151+
it('bounds the readiness wait and removes failed handlers before a later ready event', async () => {
152+
vi.useFakeTimers()
153+
try {
154+
connection.status = 'connect'
155+
const hub = getExecutionSignalHub()
156+
const handler = vi.fn()
157+
const subscription = hub.subscribe('execution-1', handler)
158+
const rejected = expect(subscription).rejects.toThrow(
159+
'Timed out waiting for Redis subscriber readiness'
160+
)
161+
162+
await Promise.all([rejected, vi.advanceTimersByTimeAsync(5000)])
163+
expect(mockSubscribe).not.toHaveBeenCalled()
164+
expect(connection.client?.listenerCount('ready')).toBe(1)
165+
expect(connection.client?.listenerCount('error')).toBe(1)
166+
expect(connection.client?.listenerCount('end')).toBe(0)
167+
expect(vi.getTimerCount()).toBe(0)
168+
169+
connection.status = 'ready'
170+
connection.client?.emit('ready')
171+
connection.client?.emit('message', 'execution:signal:execution-1', 'cancelled')
172+
expect(handler).not.toHaveBeenCalled()
173+
await hub.subscribe('execution-1', handler)
174+
expect(mockSubscribe).toHaveBeenCalledOnce()
175+
} finally {
176+
vi.useRealTimers()
177+
}
178+
})
179+
46180
it('waits for one shared subscription acknowledgement before resolving concurrent subscribers', async () => {
47181
let acknowledge: (() => void) | undefined
48182
mockSubscribe.mockReturnValueOnce(
@@ -69,12 +203,12 @@ describe('ExecutionSignalHub', () => {
69203

70204
it('marks every affected subscription unavailable when reconnect acknowledgement fails', async () => {
71205
const hub = getExecutionSignalHub()
72-
listeners.get('ready')?.()
206+
connection.client?.emit('ready')
73207
const handler = vi.fn()
74208
await hub.subscribe('execution-1', handler)
75209
mockSubscribe.mockRejectedValueOnce(new Error('Redis unavailable'))
76210

77-
listeners.get('ready')?.()
211+
connection.client?.emit('ready')
78212

79213
await vi.waitFor(() => expect(handler).toHaveBeenCalledWith('unavailable'))
80214
})
@@ -87,7 +221,11 @@ describe('ExecutionSignalHub', () => {
87221
await hub.subscribe('execution-2', secondHandler)
88222

89223
expect(mockSubscribe).toHaveBeenCalledWith('execution:signal:execution-1', 'execution:cancel')
90-
listeners.get('message')?.('execution:cancel', JSON.stringify({ executionId: 'execution-1' }))
224+
connection.client?.emit(
225+
'message',
226+
'execution:cancel',
227+
JSON.stringify({ executionId: 'execution-1' })
228+
)
91229

92230
expect(firstHandler).toHaveBeenCalledWith('cancelled')
93231
expect(secondHandler).not.toHaveBeenCalled()
@@ -98,19 +236,20 @@ describe('ExecutionSignalHub', () => {
98236
const handler = vi.fn()
99237
await hub.subscribe('execution-1', handler)
100238

101-
listeners.get('message')?.(
239+
connection.client?.emit(
240+
'message',
102241
'execution:cancel',
103242
JSON.stringify({ executionId: 'execution-1', executionSignalPublished: true })
104243
)
105-
listeners.get('message')?.('execution:signal:execution-1', 'cancelled')
244+
connection.client?.emit('message', 'execution:signal:execution-1', 'cancelled')
106245

107246
expect(handler).toHaveBeenCalledOnce()
108247
expect(handler).toHaveBeenCalledWith('cancelled')
109248
})
110249

111250
it('does not deliver a stale reconnect failure to a replacement subscriber', async () => {
112251
const hub = getExecutionSignalHub()
113-
listeners.get('ready')?.()
252+
connection.client?.emit('ready')
114253
const oldHandler = vi.fn()
115254
const unsubscribeOld = await hub.subscribe('execution-1', oldHandler)
116255
let rejectOldReconnect!: (error: Error) => void
@@ -120,7 +259,7 @@ describe('ExecutionSignalHub', () => {
120259
})
121260
)
122261

123-
listeners.get('ready')?.()
262+
connection.client?.emit('ready')
124263
unsubscribeOld()
125264
mockSubscribe.mockResolvedValueOnce(1)
126265
const replacement = vi.fn()

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

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getConfiguredRedisUrl, getRedisConnectionDefaults } from '@/lib/core/co
66

77
const logger = createLogger('ExecutionSignalHub')
88
const EXECUTION_SIGNAL_PREFIX = 'execution:signal:'
9+
const SUBSCRIBER_TIMEOUT_MS = 5000
910
export const LEGACY_EXECUTION_CANCEL_CHANNEL = 'execution:cancel'
1011

1112
export type ExecutionSignalReason = 'event' | 'cancelled' | 'reconnected' | 'unavailable'
@@ -23,12 +24,13 @@ class RedisExecutionSignalHub implements ExecutionSignalHub {
2324
private readonly subscriber: Redis
2425
private readonly handlers = new Map<string, Set<ExecutionSignalHandler>>()
2526
private readonly subscriptionReady = new Map<string, Promise<void>>()
27+
private connectionReady: Promise<void> | undefined
2628
private connectedOnce = false
2729

2830
constructor(redisUrl: string) {
2931
const options = {
3032
...getRedisConnectionDefaults(redisUrl),
31-
commandTimeout: 5000,
33+
commandTimeout: SUBSCRIBER_TIMEOUT_MS,
3234
connectionName: 'execution-signal-hub',
3335
maxRetriesPerRequest: null,
3436
retryStrategy: (attempt: number) => Math.min(attempt * 500, 5000),
@@ -70,9 +72,7 @@ class RedisExecutionSignalHub implements ExecutionSignalHub {
7072

7173
let ready = this.subscriptionReady.get(channel)
7274
if (!ready) {
73-
ready = this.subscriber
74-
.subscribe(channel, LEGACY_EXECUTION_CANCEL_CHANNEL)
75-
.then(() => undefined)
75+
ready = this.subscribeChannels(channel, LEGACY_EXECUTION_CANCEL_CHANNEL)
7676
this.subscriptionReady.set(channel, ready)
7777
}
7878
try {
@@ -104,15 +104,60 @@ class RedisExecutionSignalHub implements ExecutionSignalHub {
104104
}
105105
}
106106

107+
/**
108+
* ioredis can send SUBSCRIBE during its handshake because Redis permits it
109+
* while loading. Wait until the handshake's INFO completes before entering
110+
* subscriber mode, including when new executions arrive during reconnect.
111+
*/
112+
private async subscribeChannels(...channels: string[]): Promise<void> {
113+
while (this.subscriber.status !== 'ready') {
114+
await this.waitForConnectionReady()
115+
}
116+
await this.subscriber.subscribe(...channels)
117+
}
118+
119+
private waitForConnectionReady(): Promise<void> {
120+
if (this.connectionReady) return this.connectionReady
121+
if (this.subscriber.status === 'end') {
122+
return Promise.reject(new Error('Redis subscriber connection ended'))
123+
}
124+
125+
this.connectionReady = new Promise<void>((resolve, reject) => {
126+
const cleanup = () => {
127+
clearTimeout(timeout)
128+
this.subscriber.removeListener('ready', onReady)
129+
this.subscriber.removeListener('error', onError)
130+
this.subscriber.removeListener('end', onEnd)
131+
}
132+
const onReady = () => {
133+
cleanup()
134+
resolve()
135+
}
136+
const onError = (error: Error) => {
137+
cleanup()
138+
reject(error)
139+
}
140+
const onEnd = () => onError(new Error('Redis subscriber connection ended'))
141+
const timeout = setTimeout(
142+
() => onError(new Error('Timed out waiting for Redis subscriber readiness')),
143+
SUBSCRIBER_TIMEOUT_MS
144+
)
145+
this.subscriber.once('ready', onReady)
146+
this.subscriber.once('error', onError)
147+
this.subscriber.once('end', onEnd)
148+
}).finally(() => {
149+
this.connectionReady = undefined
150+
})
151+
return this.connectionReady
152+
}
153+
107154
private async handleReady(): Promise<void> {
108155
const reconnect = this.connectedOnce
109156
this.connectedOnce = true
110157
if (!reconnect || this.handlers.size === 0) return
111158

112159
const channels = [...this.handlers.keys()]
113-
const ready = this.subscriber
114-
.subscribe(...channels, LEGACY_EXECUTION_CANCEL_CHANNEL)
115-
.then(() => undefined)
160+
const ready = this.subscribeChannels(...channels, LEGACY_EXECUTION_CANCEL_CHANNEL)
116161
for (const channel of channels) {
117162
if (this.handlers.has(channel)) this.subscriptionReady.set(channel, ready)
118163
}

0 commit comments

Comments
 (0)