Skip to content

Commit 721211c

Browse files
committed
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 a620940 commit 721211c

2 files changed

Lines changed: 38 additions & 7 deletions

File tree

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,28 @@ describe('redis config', () => {
440440
)
441441
})
442442

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+
443465
it('stays quiet on the taken and contended paths, which poll routes run constantly', async () => {
444466
mockRedisInstance.set.mockResolvedValueOnce('OK')
445467
await acquireLock(lockKey, value, ttlSeconds)

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

Lines changed: 16 additions & 7 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',
@@ -418,7 +427,7 @@ export async function acquireLock(
418427
logger.error('Redis lock acquire failed', {
419428
lockKey,
420429
error: toError(error).message,
421-
redis: describeRedisConnection(),
430+
redis: describeRedisConnection(redis),
422431
})
423432
// Best effort, and the same compare-and-delete `releaseLock` runs on the
424433
// success path: it deletes only while `value` still owns the key. If Redis

0 commit comments

Comments
 (0)