Skip to content

Commit d2e1927

Browse files
authored
improvement(redis): warm the shared connection at process start (#7683)
* improvement(redis): warm the shared connection at process start Establishing a connection is far more expensive than the commands that run over it, so it should cost once per process rather than once per unit of work. It also needs its own budget: `commandTimeout` is armed before ioredis checks whether the socket is writable, so a first command issued against a client still shaking hands spends that budget waiting to connect and fails as a command timeout from a server that never received it. A run's first Redis call is typically a lock acquire, which is exactly where that surfaces. `warmRedisConnection` resolves once the connection is usable, or `false` when Redis is unconfigured or the wait ran out. It never throws and never rejects — a Trigger.dev `init` hook that throws fails the whole run attempt, and a warm-up is an optimization, so failing to warm must cost nothing beyond the connection staying cold. The deadline is its own, and its timer is unref'd so a pending warm-up can never hold a process open. The in-flight warm-up is keyed on the client it is warming, which is what makes a replacement re-warm. That keying is the only mechanism: clearing by hand at every site that drops the client is an invariant that rots the first time one forgets. Trigger.dev awaits it in the global `init` hook so the connection is up before `run()` issues anything; Next starts it without awaiting so boot never waits on Redis to serve requests that do not touch it. Gives the shared Redis mock a real listener registry so lifecycle events can be driven in tests. `on` stays a spy — tests read `on.mock.calls` to reach the handlers the client registered. * fix(testing): scope mock Redis listeners to the client that registered them The listener registry outlived the spies: `vi.clearAllMocks()` and `clearRedisMocks` reset call history but left handlers registered, so they accumulated across tests and a later `emit` could reach handlers belonging to a client the test under way never created. Adds `removeAllListeners`, which real clients have, and drops listeners in `clearRedisMocks` alongside spy history. Where one mock instance stands in for every client a module constructs, the registry is now emptied per construction — a real client starts with none, so binding listener lifetime to construction makes the isolation automatic rather than something each test has to remember. Covers the mock's event behavior in the testing package, where it lives.
1 parent 977a0d7 commit d2e1927

6 files changed

Lines changed: 277 additions & 16 deletions

File tree

apps/sim/instrumentation-node.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,4 +403,10 @@ export async function register() {
403403

404404
const { startMemoryTelemetry } = await import('./lib/monitoring/memory-telemetry')
405405
startMemoryTelemetry()
406+
407+
// Not awaited: the connection is warmed in the background so the first request
408+
// that needs Redis does not pay the handshake inside its own command deadline,
409+
// but boot never waits on Redis to serve requests that do not touch it.
410+
const { warmRedisConnection } = await import('./lib/core/config/redis')
411+
void warmRedisConnection()
406412
}

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

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@ const { mockEnv, MockRedisConstructor, mockLogger } = vi.hoisted(() => ({
1818
}))
1919

2020
const mockRedisInstance = createMockRedis()
21-
MockRedisConstructor.mockImplementation(
22-
class {
23-
constructor() {
24-
Object.assign(this, mockRedisInstance)
25-
}
26-
}
27-
)
21+
/** One mock instance stands in for every client the module constructs, so its
22+
* listener registry has to be emptied per construction — a real client starts
23+
* with none, and keeping them would let an `emit` reach handlers registered by
24+
* a client that no longer exists. */
25+
function newMockClient(this: object) {
26+
mockRedisInstance.removeAllListeners()
27+
Object.assign(this, mockRedisInstance)
28+
}
29+
30+
MockRedisConstructor.mockImplementation(newMockClient)
2831

2932
vi.unmock('@/lib/core/config/redis')
3033
vi.mock('@/lib/core/config/env', () => ({ env: mockEnv }))
@@ -49,6 +52,7 @@ import {
4952
getRedisClient,
5053
onRedisReconnect,
5154
resetForTesting,
55+
warmRedisConnection,
5256
} from '@/lib/core/config/redis'
5357

5458
describe('redis config', () => {
@@ -59,13 +63,7 @@ describe('redis config', () => {
5963
mockRedisInstance.status = 'ready'
6064
mockEnv.REDIS_URL = 'redis://localhost:6379'
6165
mockEnv.REDIS_TLS_SERVERNAME = undefined
62-
MockRedisConstructor.mockImplementation(
63-
class {
64-
constructor() {
65-
Object.assign(this, mockRedisInstance)
66-
}
67-
}
68-
)
66+
MockRedisConstructor.mockImplementation(newMockClient)
6967
})
7068

7169
afterEach(() => {
@@ -472,6 +470,64 @@ describe('redis config', () => {
472470
})
473471
})
474472

473+
describe('warmRedisConnection', () => {
474+
it('resolves immediately when the connection is already usable', async () => {
475+
mockRedisInstance.status = 'ready'
476+
477+
await expect(warmRedisConnection()).resolves.toBe(true)
478+
})
479+
480+
it('resolves once the connection becomes ready', async () => {
481+
mockRedisInstance.status = 'connecting'
482+
const warm = warmRedisConnection()
483+
const client = getRedisClient()
484+
485+
Object.assign(client ?? {}, { status: 'ready' })
486+
client?.emit('ready')
487+
488+
await expect(warm).resolves.toBe(true)
489+
})
490+
491+
it('gives up at the deadline rather than waiting on a connection that never lands', async () => {
492+
mockRedisInstance.status = 'connecting'
493+
const warm = warmRedisConnection(10_000)
494+
495+
await vi.advanceTimersByTimeAsync(10_000)
496+
497+
// False, not a rejection: a cold connection is the caller's normal case.
498+
await expect(warm).resolves.toBe(false)
499+
})
500+
501+
it('shares one warm-up across concurrent callers', async () => {
502+
mockRedisInstance.status = 'connecting'
503+
504+
expect(warmRedisConnection()).toBe(warmRedisConnection())
505+
})
506+
507+
it('warms again after the health check replaces the client', async () => {
508+
mockRedisInstance.status = 'connecting'
509+
const first = warmRedisConnection()
510+
resetForTesting()
511+
512+
// Keyed on the client, so a replacement is warmed on its own terms rather
513+
// than inheriting a settled promise describing a connection that is gone.
514+
expect(warmRedisConnection()).not.toBe(first)
515+
})
516+
517+
it('reports not-warm instead of throwing when Redis is unconfigured', async () => {
518+
mockEnv.REDIS_URL = undefined
519+
520+
await expect(warmRedisConnection()).resolves.toBe(false)
521+
})
522+
523+
it('reports not-warm instead of throwing when the URL is invalid', async () => {
524+
// A start-up hook that throws here would take the whole run attempt with it.
525+
mockEnv.REDIS_URL = 'https://cache.example.com'
526+
527+
await expect(warmRedisConnection()).resolves.toBe(false)
528+
})
529+
})
530+
475531
describe('capability validation', () => {
476532
it('rejects a non-Redis URL before constructing a client', () => {
477533
mockEnv.REDIS_URL = 'https://cache.example.com'

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@ interface RedisState {
6767
reconnects: number
6868
errors: number
6969
lastErrorMessage: string | null
70+
/**
71+
* In-flight warm-up, tied to the client it is warming. Keying on the client
72+
* is what makes a replacement re-warm, so this is never cleared by hand —
73+
* every site that drops the client would otherwise have to remember to, and
74+
* one that forgot would hand back a promise describing a connection that is
75+
* already gone.
76+
*/
77+
warmup: { client: Redis; promise: Promise<boolean> } | null
7078
}
7179

7280
const g = globalThis as typeof globalThis & { _redisState?: RedisState }
@@ -84,6 +92,7 @@ if (!g._redisState) {
8492
reconnects: 0,
8593
errors: 0,
8694
lastErrorMessage: null,
95+
warmup: null,
8796
}
8897
}
8998
const state = g._redisState
@@ -194,6 +203,12 @@ export function describeRedisConnection(
194203

195204
const PING_INTERVAL_MS = 15_000
196205
const MAX_PING_FAILURES = 2
206+
/**
207+
* Warm-up budget. Sized to outlast a slow handshake rather than a fast one,
208+
* because giving up early just returns the handshake to the first command's
209+
* deadline, which is the thing this exists to avoid.
210+
*/
211+
const REDIS_WARMUP_TIMEOUT_MS = 10_000
197212

198213
export function getConfiguredRedisUrl(): string | null {
199214
if (getConfiguredCacheProvider() === 'database') return null
@@ -343,6 +358,61 @@ export function getRedisClient(): Redis | null {
343358
}
344359
}
345360

361+
/**
362+
* Establishing a connection is far more expensive than the commands that run
363+
* over it, so it should cost once per process rather than once per unit of
364+
* work. Its own budget, too: `commandTimeout` is armed before ioredis checks
365+
* whether the socket is writable, so a first command issued against a client
366+
* still shaking hands spends that budget waiting to connect and fails as a
367+
* command timeout from a server that never received it.
368+
*
369+
* Resolves `true` once the shared connection is usable, `false` when Redis is
370+
* not configured or the wait ran out. It never throws and never rejects:
371+
* callers run at process start — a Trigger.dev `init` hook fails the whole run
372+
* attempt if it throws — and a warm-up is an optimization, so failing to warm
373+
* must cost nothing beyond the connection staying cold.
374+
*/
375+
export function warmRedisConnection(timeoutMs = REDIS_WARMUP_TIMEOUT_MS): Promise<boolean> {
376+
let client: Redis | null = null
377+
try {
378+
client = getRedisClient()
379+
} catch {
380+
// A misconfigured URL belongs to the first real caller, which can report it
381+
// against the operation that needed Redis. Warming must not turn it into a
382+
// start-up failure.
383+
return Promise.resolve(false)
384+
}
385+
if (!client) return Promise.resolve(false)
386+
if (client.status === 'ready') return Promise.resolve(true)
387+
if (state.warmup?.client === client) return state.warmup.promise
388+
389+
const warming = client
390+
const promise = new Promise<boolean>((resolve) => {
391+
let settled = false
392+
const finish = (warm: boolean) => {
393+
if (settled) return
394+
settled = true
395+
clearTimeout(timer)
396+
warming.removeListener('ready', onReady)
397+
resolve(warm)
398+
}
399+
const onReady = () => finish(true)
400+
const timer = setTimeout(() => {
401+
logger.warn('Redis warm-up timed out; first command will pay the handshake', {
402+
timeoutMs,
403+
redis: describeRedisConnection(warming),
404+
})
405+
finish(false)
406+
}, timeoutMs)
407+
// A pending warm-up must never be the reason a process stays alive.
408+
timer.unref?.()
409+
warming.on('ready', onReady)
410+
})
411+
412+
state.warmup = { client: warming, promise }
413+
return promise
414+
}
415+
346416
/**
347417
* Lua script for safe lock release.
348418
* Only deletes the key if the value matches (ownership verification).

apps/sim/trigger.config.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,18 @@ export default defineConfig({
102102
* environment variables whether Trigger.dev is available: a process that
103103
* Trigger.dev is executing has Trigger.dev available by definition.
104104
*
105+
* Also warms the shared Redis connection, because a run's first Redis call is
106+
* typically a lock acquire and would otherwise pay the handshake inside its
107+
* own command deadline. Awaited so the connection is up before `run()` issues
108+
* anything; imported dynamically so deploy-time evaluation of this config does
109+
* not pull the client, and never throwing because a throw here fails the run.
110+
*
105111
* @see https://trigger.dev/docs/config/config-file#lifecycle-functions
106112
*/
107-
init: () => {
113+
init: async () => {
108114
markInsideTriggerRun()
115+
const { warmRedisConnection } = await import('./lib/core/config/redis')
116+
await warmRedisConnection()
109117
},
110118
...(grafanaTelemetry ? { telemetry: grafanaTelemetry } : {}),
111119
build: {
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { clearRedisMocks, createMockRedis } from './redis.mock'
3+
4+
describe('createMockRedis events', () => {
5+
it('dispatches an emitted event to its registered listeners', () => {
6+
const redis = createMockRedis()
7+
const onReady = vi.fn()
8+
redis.on('ready', onReady)
9+
10+
expect(redis.emit('ready')).toBe(true)
11+
expect(onReady).toHaveBeenCalledOnce()
12+
})
13+
14+
it('reports no delivery when nothing is listening', () => {
15+
expect(createMockRedis().emit('ready')).toBe(false)
16+
})
17+
18+
it('stops delivering to a removed listener', () => {
19+
const redis = createMockRedis()
20+
const onReady = vi.fn()
21+
redis.on('ready', onReady)
22+
redis.removeListener('ready', onReady)
23+
24+
redis.emit('ready')
25+
expect(onReady).not.toHaveBeenCalled()
26+
})
27+
28+
it('lets a listener remove itself while the event is dispatching', () => {
29+
const redis = createMockRedis()
30+
const onReady = vi.fn(() => redis.removeListener('ready', onReady))
31+
redis.on('ready', onReady)
32+
33+
expect(() => redis.emit('ready')).not.toThrow()
34+
redis.emit('ready')
35+
expect(onReady).toHaveBeenCalledOnce()
36+
})
37+
38+
it('drops every listener on removeAllListeners', () => {
39+
const redis = createMockRedis()
40+
const onReady = vi.fn()
41+
const onError = vi.fn()
42+
redis.on('ready', onReady)
43+
redis.on('error', onError)
44+
45+
redis.removeAllListeners()
46+
47+
redis.emit('ready')
48+
redis.emit('error')
49+
expect(onReady).not.toHaveBeenCalled()
50+
expect(onError).not.toHaveBeenCalled()
51+
})
52+
53+
it('drops only the named event when one is given', () => {
54+
const redis = createMockRedis()
55+
const onReady = vi.fn()
56+
const onError = vi.fn()
57+
redis.on('ready', onReady)
58+
redis.on('error', onError)
59+
60+
redis.removeAllListeners('ready')
61+
62+
redis.emit('ready')
63+
redis.emit('error')
64+
expect(onReady).not.toHaveBeenCalled()
65+
expect(onError).toHaveBeenCalledOnce()
66+
})
67+
68+
it('clears listeners alongside spy history, not just spy history', () => {
69+
// Handlers left behind would be invoked by a later emit on behalf of a
70+
// client the test under way never created.
71+
const redis = createMockRedis()
72+
const onReady = vi.fn()
73+
redis.on('ready', onReady)
74+
75+
clearRedisMocks(redis)
76+
77+
expect(redis.emit('ready')).toBe(false)
78+
expect(onReady).not.toHaveBeenCalled()
79+
})
80+
81+
it('keeps listeners scoped to the instance that registered them', () => {
82+
const a = createMockRedis()
83+
const b = createMockRedis()
84+
const onA = vi.fn()
85+
a.on('ready', onA)
86+
87+
b.emit('ready')
88+
expect(onA).not.toHaveBeenCalled()
89+
})
90+
})

packages/testing/src/mocks/redis.mock.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import { vi } from 'vitest'
1414
* ```
1515
*/
1616
export function createMockRedis() {
17+
/** Per-instance listener registry, so `emit` can drive the lifecycle events
18+
* a real client emits. `on` stays a spy: tests read `on.mock.calls` to reach
19+
* the handlers the client registered. */
20+
const listeners = new Map<string, Set<(...args: unknown[]) => void>>()
21+
1722
return {
1823
// Hash operations
1924
hset: vi.fn().mockResolvedValue(1),
@@ -49,7 +54,28 @@ export function createMockRedis() {
4954
publish: vi.fn().mockResolvedValue(0),
5055
subscribe: vi.fn().mockResolvedValue(undefined),
5156
unsubscribe: vi.fn().mockResolvedValue(undefined),
52-
on: vi.fn(),
57+
on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
58+
const existing = listeners.get(event)
59+
if (existing) existing.add(listener)
60+
else listeners.set(event, new Set([listener]))
61+
}),
62+
removeListener: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
63+
listeners.get(event)?.delete(listener)
64+
}),
65+
/** Listeners belong to a client, so a caller reusing this instance as a new
66+
* client clears them the way a real one starts empty. */
67+
removeAllListeners: vi.fn((event?: string) => {
68+
if (event === undefined) listeners.clear()
69+
else listeners.delete(event)
70+
}),
71+
/** Drives the lifecycle events a real client emits (`connect`, `ready`, `error`). */
72+
emit: vi.fn((event: string, ...args: unknown[]) => {
73+
const registered = listeners.get(event)
74+
if (!registered?.size) return false
75+
// Copy first: a listener may remove itself while the event is dispatching.
76+
for (const listener of [...registered]) listener(...args)
77+
return true
78+
}),
5379

5480
// Transaction
5581
multi: vi.fn(() => ({
@@ -73,8 +99,13 @@ export type MockRedis = ReturnType<typeof createMockRedis>
7399

74100
/**
75101
* Clears all Redis mock calls.
102+
*
103+
* Also drops registered listeners: spy history and the listener registry are
104+
* separate state, and handlers left behind would be invoked by a later `emit`
105+
* on behalf of a client the test under way never created.
76106
*/
77107
export function clearRedisMocks(redis: MockRedis) {
108+
redis.removeAllListeners()
78109
Object.values(redis).forEach((value) => {
79110
if (typeof value === 'function' && 'mockClear' in value) {
80111
value.mockClear()

0 commit comments

Comments
 (0)