From 79b9617045cbe202804da5748276cdad12100aba Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 6 Mar 2026 09:16:33 -0600 Subject: [PATCH] feat(queue): add redis connection module with health check (MQ-002) --- packages/queue/package.json | 3 + packages/queue/src/index.ts | 13 +++ packages/queue/src/redis-connection.ts | 95 +++++++++++++++++++ packages/queue/tests/redis-connection.test.ts | 76 +++++++++++++++ pnpm-lock.yaml | 70 +++++++++++++- 5 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 packages/queue/src/redis-connection.ts create mode 100644 packages/queue/tests/redis-connection.test.ts diff --git a/packages/queue/package.json b/packages/queue/package.json index 1790fc7..5aa258b 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -12,5 +12,8 @@ "lint": "eslint \"src/**/*.ts\" \"tests/**/*.ts\" \"vitest.config.ts\"", "build": "tsc -p tsconfig.build.json", "test": "vitest run" + }, + "dependencies": { + "ioredis": "^5.10.0" } } diff --git a/packages/queue/src/index.ts b/packages/queue/src/index.ts index 382b256..74a9354 100644 --- a/packages/queue/src/index.ts +++ b/packages/queue/src/index.ts @@ -1 +1,14 @@ export const packageVersion = '0.0.1'; + +export { + assertRedisHealthy, + createRedisClient, + resolveRedisUrl, + runRedisHealthCheck, +} from './redis-connection.js'; +export type { + CreateRedisClientOptions, + RedisClientConstructor, + RedisHealthCheck, + RedisPingClient, +} from './redis-connection.js'; diff --git a/packages/queue/src/redis-connection.ts b/packages/queue/src/redis-connection.ts new file mode 100644 index 0000000..b7612b6 --- /dev/null +++ b/packages/queue/src/redis-connection.ts @@ -0,0 +1,95 @@ +import Redis, { type RedisOptions } from 'ioredis'; + +const ERR_MISSING_REDIS_URL = + 'Missing required Valkey/Redis connection URL. Set VALKEY_URL or REDIS_URL.'; + +export interface RedisHealthCheck { + readonly checkedAt: number; + readonly latencyMs: number; + readonly ok: boolean; + readonly response?: string; + readonly error?: string; +} + +export interface RedisPingClient { + ping(): Promise; +} + +export type RedisClientConstructor = new ( + url: string, + options?: RedisOptions, +) => TClient; + +export interface CreateRedisClientOptions { + readonly env?: NodeJS.ProcessEnv; + readonly redisConstructor?: RedisClientConstructor; + readonly redisOptions?: RedisOptions; +} + +export function resolveRedisUrl(env: NodeJS.ProcessEnv = process.env): string { + const resolvedUrl = env.VALKEY_URL ?? env.REDIS_URL; + + if (typeof resolvedUrl !== 'string' || resolvedUrl.trim().length === 0) { + throw new Error(ERR_MISSING_REDIS_URL); + } + + return resolvedUrl; +} + +export function createRedisClient( + options: CreateRedisClientOptions = {}, +): TClient { + const redisUrl = resolveRedisUrl(options.env); + + const RedisCtor = + options.redisConstructor ?? + (Redis as unknown as RedisClientConstructor); + + return new RedisCtor(redisUrl, { + maxRetriesPerRequest: null, + ...options.redisOptions, + }); +} + +export async function runRedisHealthCheck( + client: RedisPingClient, +): Promise { + const startedAt = process.hrtime.bigint(); + + try { + const response = await client.ping(); + const elapsedMs = Number((process.hrtime.bigint() - startedAt) / 1_000_000n); + + return { + checkedAt: Date.now(), + latencyMs: elapsedMs, + ok: true, + response, + }; + } catch (error) { + const elapsedMs = Number((process.hrtime.bigint() - startedAt) / 1_000_000n); + const message = + error instanceof Error ? error.message : 'Unknown redis health check error'; + + return { + checkedAt: Date.now(), + latencyMs: elapsedMs, + ok: false, + error: message, + }; + } +} + +export async function assertRedisHealthy( + client: RedisPingClient, +): Promise { + const health = await runRedisHealthCheck(client); + + if (!health.ok) { + throw new Error( + `Redis health check failed after ${health.latencyMs}ms: ${health.error ?? 'unknown error'}`, + ); + } + + return health; +} diff --git a/packages/queue/tests/redis-connection.test.ts b/packages/queue/tests/redis-connection.test.ts new file mode 100644 index 0000000..3a4b072 --- /dev/null +++ b/packages/queue/tests/redis-connection.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +import { + createRedisClient, + resolveRedisUrl, + runRedisHealthCheck, +} from '../src/redis-connection.js'; + +describe('resolveRedisUrl', () => { + it('prefers VALKEY_URL when both env vars are present', () => { + const url = resolveRedisUrl({ + VALKEY_URL: 'redis://valkey.local:6379', + REDIS_URL: 'redis://redis.local:6379', + }); + + expect(url).toBe('redis://valkey.local:6379'); + }); + + it('falls back to REDIS_URL when VALKEY_URL is missing', () => { + const url = resolveRedisUrl({ + REDIS_URL: 'redis://redis.local:6379', + }); + + expect(url).toBe('redis://redis.local:6379'); + }); + + it('throws loudly when no redis environment variable exists', () => { + expect(() => resolveRedisUrl({})).toThrowError( + /Missing required Valkey\/Redis connection URL/i, + ); + }); +}); + +describe('createRedisClient', () => { + it('uses env URL for client creation with no hardcoded defaults', () => { + class FakeRedis { + public readonly url: string; + + public constructor(url: string) { + this.url = url; + } + } + + const client = createRedisClient({ + env: { + VALKEY_URL: 'redis://queue.local:6379', + }, + redisConstructor: FakeRedis, + }); + + expect(client.url).toBe('redis://queue.local:6379'); + }); +}); + +describe('runRedisHealthCheck', () => { + it('returns healthy status when ping succeeds', async () => { + const health = await runRedisHealthCheck({ + ping: () => Promise.resolve('PONG'), + }); + + expect(health.ok).toBe(true); + expect(health.response).toBe('PONG'); + expect(health.latencyMs).toBeTypeOf('number'); + expect(health.latencyMs).toBeGreaterThanOrEqual(0); + }); + + it('returns unhealthy status when ping fails', async () => { + const health = await runRedisHealthCheck({ + ping: () => Promise.reject(new Error('connection refused')), + }); + + expect(health.ok).toBe(false); + expect(health.error).toMatch(/connection refused/i); + expect(health.latencyMs).toBeTypeOf('number'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 103c155..acb4068 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,7 +27,11 @@ importers: specifier: ^3.0.8 version: 3.2.4(@types/node@22.19.15) - packages/queue: {} + packages/queue: + dependencies: + ioredis: + specifier: ^5.10.0 + version: 5.10.0 packages: @@ -241,6 +245,9 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -530,6 +537,10 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -560,6 +571,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -691,6 +706,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + ioredis@5.10.0: + resolution: {integrity: sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==} + engines: {node: '>=12.22.0'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -729,6 +748,12 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -806,6 +831,14 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -838,6 +871,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -1123,6 +1159,8 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@ioredis/commands@1.5.1': {} + '@jridgewell/sourcemap-codec@1.5.5': {} '@rollup/rollup-android-arm-eabi@4.59.0': @@ -1401,6 +1439,8 @@ snapshots: check-error@2.1.3: {} + cluster-key-slot@1.1.2: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -1423,6 +1463,8 @@ snapshots: deep-is@0.1.4: {} + denque@2.1.0: {} + es-module-lexer@1.7.0: {} esbuild@0.27.3: @@ -1578,6 +1620,20 @@ snapshots: imurmurhash@0.1.4: {} + ioredis@5.10.0: + dependencies: + '@ioredis/commands': 1.5.1 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -1611,6 +1667,10 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + lodash.merge@4.6.2: {} loupe@3.2.1: {} @@ -1676,6 +1736,12 @@ snapshots: punycode@2.3.1: {} + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + resolve-from@4.0.0: {} rollup@4.59.0: @@ -1723,6 +1789,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + std-env@3.10.0: {} strip-json-comments@3.1.1: {}