Skip to content

Commit a620940

Browse files
committed
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.
1 parent e508098 commit a620940

2 files changed

Lines changed: 80 additions & 1 deletion

File tree

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

Lines changed: 66 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,54 @@ 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('stays quiet on the taken and contended paths, which poll routes run constantly', async () => {
444+
mockRedisInstance.set.mockResolvedValueOnce('OK')
445+
await acquireLock(lockKey, value, ttlSeconds)
446+
mockRedisInstance.set.mockResolvedValueOnce(null)
447+
await acquireLock(lockKey, value, ttlSeconds)
448+
449+
expect(mockLogger.error).not.toHaveBeenCalled()
450+
})
386451
})
387452

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

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,20 @@ export async function acquireLock(
406406
const result = await redis.set(lockKey, value, 'EX', expirySeconds, 'NX')
407407
return result === 'OK'
408408
} catch (error) {
409+
/**
410+
* Read the connection state before the reclaim below, which awaits and so
411+
* would report the state it left behind rather than the one that failed.
412+
* A lock acquire is often a run's first Redis call, so it is where an
413+
* unusable connection surfaces — as an `Error: Command timed out` carrying
414+
* only ioredis timer frames, no app frame, and no way to tell a handshake
415+
* still in flight from a socket that died silently. `status` separates
416+
* them, which is what makes the next occurrence self-diagnosing.
417+
*/
418+
logger.error('Redis lock acquire failed', {
419+
lockKey,
420+
error: toError(error).message,
421+
redis: describeRedisConnection(),
422+
})
409423
// Best effort, and the same compare-and-delete `releaseLock` runs on the
410424
// success path: it deletes only while `value` still owns the key. If Redis
411425
// is still unreachable the TTL stays the backstop, which is the behavior

0 commit comments

Comments
 (0)