improvement(redis): warm the shared connection at process start

Merged
WA
1mo ago
+277-16

Summary

  • 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
  • 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

This is the standard serverless pattern — separate connection establishment from command execution so the handshake completes before business logic runs inside its own timeout window.

Safety properties

  • Never throws, never rejects. A Trigger.dev init hook that throws fails the whole run attempt, and warming is an optimization — failing to warm must cost nothing beyond the connection staying cold. An unconfigured or malformed REDIS_URL resolves false and leaves the error to the first real caller, which can report it against the operation that actually needed Redis
  • Bounded. Its own deadline, independent of any command budget, and the timer is unref'd so a pending warm-up can never hold a process open
  • Re-warms after replacement. The in-flight warm-up is keyed on the client it is warming. 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

Deliberately not included: enabling processKeepAlive, which would let the client persist across runs and turn per-run handshakes into per-process ones. That is the larger lever but it changes isolation and memory behavior between runs, so it deserves its own change.

Type of Change

  • Improvement

Testing

Seven tests covering the safety properties: already-ready, resolves on ready, gives up at the deadline, shares one warm-up across concurrent callers, re-warms after replacement, and no-throw for both unconfigured and malformed URLs. Each verified to fail when its behavior is reverted — the replacement test initially could not fail, which is what surfaced that the manual memo clears were duplicating the keying, so they were removed in favor of the single mechanism.

Gives the shared Redis mock a real listener registry so lifecycle events can be driven in tests; on stays a spy since tests read on.mock.calls. Because that is shared infra, the full suite was run rather than a targeted subset: 47,497 tests pass in apps/sim, 381 in apps/realtime. bun run type-check clean in both apps/sim and packages/testing. bun run lint, docs-manifest:check, and all 46 audits in check:audits pass.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)