Skip to content

Commit 9fd3177

Browse files
authored
improvement(redis): pair lock acquire failures with connection state (#7669)
* improvement(redis): pair lock acquire failures with connection state A lock acquire is often a process's first Redis call, so an unusable connection surfaces there as `Error: Command timed out` — a rejection carrying only ioredis timer frames, no app frame, and nothing to separate a handshake still in flight from a socket that died silently. `status` is what separates them, so log it alongside the failure. Read before the reclaim, which awaits and would otherwise report the state it left behind rather than the one that failed. * fix(redis): describe the client that ran the failed command A command can outlive the client that issued it: the PING health check drops `state.client` after consecutive failures, which is the same unhealthy stretch in which that command is timing out. Reading the global in the failure path then described the replacement — reporting `no-client` or a fresh `connecting` for a failure belonging to the connection before it, misclassifying the very timeout the diagnostic exists to explain. Take the client as an argument, and withhold the ages when `state` no longer holds it rather than dating a connection its timestamps never measured.
1 parent e508098 commit 9fd3177

2 files changed

Lines changed: 117 additions & 7 deletions

File tree

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

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
import { createMockRedis } from '@sim/testing'
22
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
33

4-
const { mockEnv, MockRedisConstructor } = vi.hoisted(() => ({
4+
const { mockEnv, MockRedisConstructor, mockLogger } = vi.hoisted(() => ({
55
mockEnv: {
66
REDIS_URL: 'redis://localhost:6379' as string | undefined,
77
REDIS_TLS_SERVERNAME: undefined as string | undefined,
88
},
99
MockRedisConstructor: vi.fn(),
10+
mockLogger: {
11+
info: vi.fn(),
12+
warn: vi.fn(),
13+
error: vi.fn(),
14+
debug: vi.fn(),
15+
trace: vi.fn(),
16+
fatal: vi.fn(),
17+
},
1018
}))
1119

1220
const mockRedisInstance = createMockRedis()
@@ -20,6 +28,15 @@ MockRedisConstructor.mockImplementation(
2028

2129
vi.unmock('@/lib/core/config/redis')
2230
vi.mock('@/lib/core/config/env', () => ({ env: mockEnv }))
31+
/** Overrides the global mock, whose `createLogger` returns a fresh spy per call,
32+
* so assertions can reach the instance this module captured at import. */
33+
vi.mock('@sim/logger', () => ({
34+
createLogger: () => mockLogger,
35+
logger: mockLogger,
36+
runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(),
37+
getRequestContext: () => undefined,
38+
setRequestTraceId: () => {},
39+
}))
2340
vi.mock('ioredis', () => ({
2441
default: MockRedisConstructor,
2542
}))
@@ -383,6 +400,76 @@ describe('redis config', () => {
383400
expect(await acquireLock(lockKey, value, ttlSeconds)).toBe(true)
384401
expect(mockRedisInstance.set).not.toHaveBeenCalled()
385402
})
403+
404+
it('pairs the failure with connection state so the cause is not left to timing', async () => {
405+
// The bare rejection carries only ioredis timer frames, so without this
406+
// there is nothing to separate a handshake still in flight from a socket
407+
// that died silently.
408+
mockRedisInstance.status = 'connecting'
409+
mockRedisInstance.set.mockRejectedValueOnce(new Error('Command timed out'))
410+
411+
await expect(acquireLock(lockKey, value, ttlSeconds)).rejects.toThrow('Command timed out')
412+
expect(mockLogger.error).toHaveBeenCalledWith(
413+
'Redis lock acquire failed',
414+
expect.objectContaining({
415+
lockKey,
416+
error: 'Command timed out',
417+
redis: expect.objectContaining({ status: 'connecting' }),
418+
})
419+
)
420+
})
421+
422+
it('reads connection state before the reclaim, which resolves against a live socket', async () => {
423+
// The reclaim awaits, so a connection that completes inside that window
424+
// would leave a diagnostic read after it reporting `ready` — hiding the
425+
// very handshake that failed.
426+
mockRedisInstance.status = 'connecting'
427+
mockRedisInstance.set.mockRejectedValueOnce(new Error('Command timed out'))
428+
// Mutates the constructed client, not the shared instance it was copied from.
429+
mockRedisInstance.eval.mockImplementationOnce(async () => {
430+
Object.assign(getRedisClient() ?? {}, { status: 'ready' })
431+
return 1
432+
})
433+
434+
await expect(
435+
acquireLock(lockKey, value, ttlSeconds, { reclaimOnFailure: true })
436+
).rejects.toThrow('Command timed out')
437+
expect(mockLogger.error).toHaveBeenCalledWith(
438+
'Redis lock acquire failed',
439+
expect.objectContaining({ redis: expect.objectContaining({ status: 'connecting' }) })
440+
)
441+
})
442+
443+
it('describes the client that ran the command, not one that replaced it mid-flight', async () => {
444+
// The PING check drops `state.client` after consecutive failures — the same
445+
// unhealthy stretch in which the command is timing out. Reading the global
446+
// then would report the replacement and misclassify the very failure this
447+
// diagnostic exists to explain.
448+
mockRedisInstance.status = 'connecting'
449+
mockRedisInstance.set.mockImplementationOnce(async () => {
450+
resetForTesting()
451+
throw new Error('Command timed out')
452+
})
453+
454+
await expect(acquireLock(lockKey, value, ttlSeconds)).rejects.toThrow('Command timed out')
455+
expect(mockLogger.error).toHaveBeenCalledWith(
456+
'Redis lock acquire failed',
457+
expect.objectContaining({
458+
// Timestamps belong to whatever `state` holds now, so they are withheld
459+
// rather than dated against a connection they never measured.
460+
redis: expect.objectContaining({ status: 'connecting', clientAgeMs: null }),
461+
})
462+
)
463+
})
464+
465+
it('stays quiet on the taken and contended paths, which poll routes run constantly', async () => {
466+
mockRedisInstance.set.mockResolvedValueOnce('OK')
467+
await acquireLock(lockKey, value, ttlSeconds)
468+
mockRedisInstance.set.mockResolvedValueOnce(null)
469+
await acquireLock(lockKey, value, ttlSeconds)
470+
471+
expect(mockLogger.error).not.toHaveBeenCalled()
472+
})
386473
})
387474

388475
describe('capability validation', () => {

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

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -153,22 +153,31 @@ function describeRedisUrl(
153153
*
154154
* Derives only non-sensitive facts from REDIS_URL — never the URL itself, which
155155
* carries the AUTH token.
156+
*
157+
* Pass the client whose command is being diagnosed when it may not be the one
158+
* `state` still holds. A command can outlive its client — the PING health check
159+
* drops `state.client` after consecutive failures, which is the same unhealthy
160+
* stretch in which that command is timing out — and reading the global then
161+
* describes the replacement, reporting `no-client` or a fresh `connecting` for a
162+
* failure that belongs to the connection before it.
156163
*/
157-
export function describeRedisConnection(): RedisConnectionDiagnostics {
164+
export function describeRedisConnection(
165+
client: Redis | null = state.client
166+
): RedisConnectionDiagnostics {
158167
let url: string | null = null
159168
try {
160169
url = getConfiguredRedisUrl()
161170
} catch {
162171
url = null
163172
}
164173

165-
const client = state.client
166-
167174
// Ages describe the client currently held. A discarded client leaves its
168175
// timestamps behind until the next `getRedisClient()` rebuilds them, and
169-
// reporting those against `no-client` would date a connection that no longer
170-
// exists. The counters below are deliberately cumulative for the process.
171-
const ageOf = (at: number | null) => (client === null ? null : elapsedSince(at))
176+
// reporting those against `no-client` — or against a client that has since
177+
// been replaced — would date a connection these timestamps never measured.
178+
// The counters below are deliberately cumulative for the process.
179+
const timestampsDescribeClient = client !== null && client === state.client
180+
const ageOf = (at: number | null) => (timestampsDescribeClient ? elapsedSince(at) : null)
172181

173182
return {
174183
status: client?.status ?? 'no-client',
@@ -406,6 +415,20 @@ export async function acquireLock(
406415
const result = await redis.set(lockKey, value, 'EX', expirySeconds, 'NX')
407416
return result === 'OK'
408417
} catch (error) {
418+
/**
419+
* Read the connection state before the reclaim below, which awaits and so
420+
* would report the state it left behind rather than the one that failed.
421+
* A lock acquire is often a run's first Redis call, so it is where an
422+
* unusable connection surfaces — as an `Error: Command timed out` carrying
423+
* only ioredis timer frames, no app frame, and no way to tell a handshake
424+
* still in flight from a socket that died silently. `status` separates
425+
* them, which is what makes the next occurrence self-diagnosing.
426+
*/
427+
logger.error('Redis lock acquire failed', {
428+
lockKey,
429+
error: toError(error).message,
430+
redis: describeRedisConnection(redis),
431+
})
409432
// Best effort, and the same compare-and-delete `releaseLock` runs on the
410433
// success path: it deletes only while `value` still owns the key. If Redis
411434
// is still unreachable the TTL stays the backstop, which is the behavior

0 commit comments

Comments
 (0)