Skip to content

Commit 11d4357

Browse files
committed
fix(kv): fail fast and rebuild singleton on Redis disconnect
1 parent e7cb01a commit 11d4357

2 files changed

Lines changed: 64 additions & 1 deletion

File tree

src/core/shared/clients/kv.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ function getKvConfigStatus(): KvConfigStatus {
4949
function createRedisClient() {
5050
const client = createClient({
5151
url: process.env.REDIS_URL,
52+
// Fail commands fast when the client isn't ready instead of queueing them
53+
// behind the auto-reconnect. Preserves the {status: 'error'} envelope on
54+
// pingKv() and keeps /api/health snappy during Redis outages.
55+
disableOfflineQueue: true,
5256
})
5357

5458
client.on('error', (error) => {
@@ -61,6 +65,17 @@ function createRedisClient() {
6165
)
6266
})
6367

68+
// When the socket fully ends (TCP close, retries exhausted, manual close),
69+
// drop the cached singletons so the next getRedisClient() call rebuilds the
70+
// client instead of returning the stale resolved promise. The identity guard
71+
// protects against a late 'end' from a previous client clobbering a fresh one.
72+
client.on('end', () => {
73+
if (redisClient === client) {
74+
redisClient = null
75+
redisConnectPromise = null
76+
}
77+
})
78+
6479
return client
6580
}
6681

tests/unit/kv.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
22

33
const { createClient, redisClient } = vi.hoisted(() => {
4+
const handlers: Record<string, (arg?: unknown) => void> = {}
45
const client = {
56
isReady: false,
6-
on: vi.fn(),
7+
on: vi.fn((event: string, handler: (arg?: unknown) => void) => {
8+
handlers[event] = handler
9+
return client
10+
}),
11+
emit: (event: string, arg?: unknown) => handlers[event]?.(arg),
712
connect: vi.fn(),
813
ping: vi.fn(),
914
get: vi.fn(),
@@ -97,12 +102,55 @@ describe('optional KV client', () => {
97102
})
98103
expect(createClient).toHaveBeenCalledWith({
99104
url: 'redis://localhost:6379',
105+
disableOfflineQueue: true,
100106
})
101107
expect(redisClient.on).toHaveBeenCalledWith('error', expect.any(Function))
108+
expect(redisClient.on).toHaveBeenCalledWith('end', expect.any(Function))
102109
expect(redisClient.connect).toHaveBeenCalledTimes(1)
103110
expect(redisClient.ping).toHaveBeenCalledTimes(1)
104111
})
105112

113+
it('fails fast when the client disconnects mid-session', async () => {
114+
process.env.REDIS_URL = 'redis://localhost:6379'
115+
redisClient.ping.mockResolvedValueOnce('PONG')
116+
const { pingKv } = await loadKvClient()
117+
118+
// First ping: healthy
119+
await expect(pingKv()).resolves.toMatchObject({ status: 'ok' })
120+
121+
// Simulate disconnect: isReady flips and the next ping rejects fast
122+
// because the singleton was created with disableOfflineQueue: true.
123+
redisClient.isReady = false
124+
const closedError = new Error('The client is closed')
125+
redisClient.ping.mockRejectedValueOnce(closedError)
126+
127+
await expect(pingKv()).resolves.toEqual({
128+
configured: true,
129+
available: false,
130+
status: 'error',
131+
error: closedError,
132+
})
133+
})
134+
135+
it('rebuilds the singleton after the client emits end', async () => {
136+
process.env.REDIS_URL = 'redis://localhost:6379'
137+
redisClient.ping.mockResolvedValue('PONG')
138+
const { pingKv } = await loadKvClient()
139+
140+
await pingKv()
141+
expect(createClient).toHaveBeenCalledTimes(1)
142+
expect(redisClient.connect).toHaveBeenCalledTimes(1)
143+
144+
// Permanent disconnect — drives the 'end' handler, which should null out
145+
// the cached singletons so the next call rebuilds from scratch.
146+
redisClient.isReady = false
147+
redisClient.emit('end')
148+
149+
await pingKv()
150+
expect(createClient).toHaveBeenCalledTimes(2)
151+
expect(redisClient.connect).toHaveBeenCalledTimes(2)
152+
})
153+
106154
it('reports KV errors when Redis connection fails', async () => {
107155
process.env.REDIS_URL = 'redis://localhost:6379'
108156
const error = new Error('redis unavailable')

0 commit comments

Comments
 (0)