Skip to content

Commit 85a3cd8

Browse files
fix(execution): wait for Redis readiness before subscribing (#7619)
* fix(execution): wait for Redis readiness before subscribing * fix(execution): preserve pending subscriptions through reconnects
1 parent 185e93e commit 85a3cd8

2 files changed

Lines changed: 321 additions & 35 deletions

File tree

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

Lines changed: 239 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,226 @@ 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('keeps waiting through recoverable connection errors', async () => {
128+
connection.status = 'connecting'
129+
const hub = getExecutionSignalHub()
130+
const handler = vi.fn()
131+
const subscription = hub.subscribe('execution-1', handler)
132+
const settled = vi.fn()
133+
void subscription.then(settled, settled)
134+
135+
connection.client?.emit('error', new Error('ECONNREFUSED'))
136+
connection.status = 'reconnecting'
137+
connection.client?.emit('close')
138+
await Promise.resolve()
139+
expect(settled).not.toHaveBeenCalled()
140+
expect(mockSubscribe).not.toHaveBeenCalled()
141+
142+
connection.status = 'ready'
143+
connection.client?.emit('ready')
144+
await subscription
145+
expect(mockSubscribe).toHaveBeenCalledOnce()
146+
connection.client?.emit('message', 'execution:signal:execution-1', 'cancelled')
147+
expect(handler).toHaveBeenCalledWith('cancelled')
148+
})
149+
150+
it('rejects readiness waiters when the subscriber stops reconnecting', async () => {
151+
connection.status = 'connect'
152+
const hub = getExecutionSignalHub()
153+
const subscription = hub.subscribe('execution-1', vi.fn())
154+
const rejected = expect(subscription).rejects.toThrow('Redis subscriber connection ended')
155+
156+
connection.status = 'end'
157+
connection.client?.emit('end')
158+
await rejected
159+
expect(mockSubscribe).not.toHaveBeenCalled()
160+
expect(connection.client?.listenerCount('ready')).toBe(1)
161+
expect(connection.client?.listenerCount('error')).toBe(1)
162+
expect(connection.client?.listenerCount('end')).toBe(0)
163+
})
164+
165+
it('keeps a new channel independent of an existing channel reconnect failure', async () => {
166+
const hub = getExecutionSignalHub()
167+
connection.client?.emit('ready')
168+
const existingHandler = vi.fn()
169+
await hub.subscribe('execution-existing', existingHandler)
170+
mockSubscribe.mockClear()
171+
connection.status = 'reconnecting'
172+
connection.client?.emit('close')
173+
const newHandler = vi.fn()
174+
const subscription = hub.subscribe('execution-new', newHandler)
175+
let rejectReconnect!: (error: Error) => void
176+
let acknowledgeNew!: (count: number) => void
177+
mockSubscribe.mockReturnValueOnce(
178+
new Promise<number>((_resolve, reject) => {
179+
rejectReconnect = reject
180+
})
181+
)
182+
mockSubscribe.mockReturnValueOnce(
183+
new Promise<number>((resolve) => {
184+
acknowledgeNew = resolve
185+
})
186+
)
187+
188+
connection.status = 'ready'
189+
connection.client?.emit('ready')
190+
await vi.waitFor(() => expect(mockSubscribe).toHaveBeenCalledTimes(2))
191+
expect(mockSubscribe).toHaveBeenNthCalledWith(
192+
1,
193+
'execution:signal:execution-existing',
194+
'execution:cancel'
195+
)
196+
expect(mockSubscribe).toHaveBeenNthCalledWith(
197+
2,
198+
'execution:signal:execution-new',
199+
'execution:cancel'
200+
)
201+
202+
rejectReconnect(new Error('Command timed out'))
203+
await vi.waitFor(() => expect(existingHandler).toHaveBeenCalledWith('unavailable'))
204+
expect(newHandler).not.toHaveBeenCalled()
205+
acknowledgeNew(3)
206+
await subscription
207+
connection.client?.emit('message', 'execution:signal:execution-new', 'cancelled')
208+
expect(newHandler).toHaveBeenCalledExactlyOnceWith('cancelled')
209+
})
210+
211+
it('preserves the pending acknowledgement when Redis reconnects before it arrives', async () => {
212+
const hub = getExecutionSignalHub()
213+
connection.client?.emit('ready')
214+
let acknowledge!: (count: number) => void
215+
mockSubscribe.mockReturnValueOnce(
216+
new Promise<number>((resolve) => {
217+
acknowledge = resolve
218+
})
219+
)
220+
const handler = vi.fn()
221+
const subscription = hub.subscribe('execution-new', handler)
222+
connection.status = 'reconnecting'
223+
connection.client?.emit('close')
224+
connection.status = 'ready'
225+
connection.client?.emit('ready')
226+
227+
expect(mockSubscribe).toHaveBeenCalledOnce()
228+
acknowledge(2)
229+
await subscription
230+
expect(handler).not.toHaveBeenCalled()
231+
})
232+
233+
it('bounds the readiness wait and removes failed handlers before a later ready event', async () => {
234+
vi.useFakeTimers()
235+
try {
236+
connection.status = 'connect'
237+
const hub = getExecutionSignalHub()
238+
const handler = vi.fn()
239+
const subscription = hub.subscribe('execution-1', handler)
240+
const rejected = expect(subscription).rejects.toThrow(
241+
'Timed out waiting for Redis subscriber readiness'
242+
)
243+
244+
const timeout = vi.advanceTimersByTimeAsync(4000).then(() => {
245+
connection.client?.emit('error', new Error('ECONNREFUSED'))
246+
return vi.advanceTimersByTimeAsync(1000)
247+
})
248+
await Promise.all([rejected, timeout])
249+
expect(mockSubscribe).not.toHaveBeenCalled()
250+
expect(connection.client?.listenerCount('ready')).toBe(1)
251+
expect(connection.client?.listenerCount('error')).toBe(1)
252+
expect(connection.client?.listenerCount('end')).toBe(0)
253+
expect(vi.getTimerCount()).toBe(0)
254+
255+
connection.status = 'ready'
256+
connection.client?.emit('ready')
257+
connection.client?.emit('message', 'execution:signal:execution-1', 'cancelled')
258+
expect(handler).not.toHaveBeenCalled()
259+
await hub.subscribe('execution-1', handler)
260+
expect(mockSubscribe).toHaveBeenCalledOnce()
261+
} finally {
262+
vi.useRealTimers()
263+
}
264+
})
265+
46266
it('waits for one shared subscription acknowledgement before resolving concurrent subscribers', async () => {
47267
let acknowledge: (() => void) | undefined
48268
mockSubscribe.mockReturnValueOnce(
@@ -69,12 +289,12 @@ describe('ExecutionSignalHub', () => {
69289

70290
it('marks every affected subscription unavailable when reconnect acknowledgement fails', async () => {
71291
const hub = getExecutionSignalHub()
72-
listeners.get('ready')?.()
292+
connection.client?.emit('ready')
73293
const handler = vi.fn()
74294
await hub.subscribe('execution-1', handler)
75295
mockSubscribe.mockRejectedValueOnce(new Error('Redis unavailable'))
76296

77-
listeners.get('ready')?.()
297+
connection.client?.emit('ready')
78298

79299
await vi.waitFor(() => expect(handler).toHaveBeenCalledWith('unavailable'))
80300
})
@@ -87,7 +307,11 @@ describe('ExecutionSignalHub', () => {
87307
await hub.subscribe('execution-2', secondHandler)
88308

89309
expect(mockSubscribe).toHaveBeenCalledWith('execution:signal:execution-1', 'execution:cancel')
90-
listeners.get('message')?.('execution:cancel', JSON.stringify({ executionId: 'execution-1' }))
310+
connection.client?.emit(
311+
'message',
312+
'execution:cancel',
313+
JSON.stringify({ executionId: 'execution-1' })
314+
)
91315

92316
expect(firstHandler).toHaveBeenCalledWith('cancelled')
93317
expect(secondHandler).not.toHaveBeenCalled()
@@ -98,19 +322,20 @@ describe('ExecutionSignalHub', () => {
98322
const handler = vi.fn()
99323
await hub.subscribe('execution-1', handler)
100324

101-
listeners.get('message')?.(
325+
connection.client?.emit(
326+
'message',
102327
'execution:cancel',
103328
JSON.stringify({ executionId: 'execution-1', executionSignalPublished: true })
104329
)
105-
listeners.get('message')?.('execution:signal:execution-1', 'cancelled')
330+
connection.client?.emit('message', 'execution:signal:execution-1', 'cancelled')
106331

107332
expect(handler).toHaveBeenCalledOnce()
108333
expect(handler).toHaveBeenCalledWith('cancelled')
109334
})
110335

111336
it('does not deliver a stale reconnect failure to a replacement subscriber', async () => {
112337
const hub = getExecutionSignalHub()
113-
listeners.get('ready')?.()
338+
connection.client?.emit('ready')
114339
const oldHandler = vi.fn()
115340
const unsubscribeOld = await hub.subscribe('execution-1', oldHandler)
116341
let rejectOldReconnect!: (error: Error) => void
@@ -120,7 +345,7 @@ describe('ExecutionSignalHub', () => {
120345
})
121346
)
122347

123-
listeners.get('ready')?.()
348+
connection.client?.emit('ready')
124349
unsubscribeOld()
125350
mockSubscribe.mockResolvedValueOnce(1)
126351
const replacement = vi.fn()

0 commit comments

Comments
 (0)