// Optional Redis connection manager. // // Redis is entirely optional. When REDIS_URL is unset the app runs exactly as // before with in-memory backends. When set, this module owns a single shared // command connection plus a dedicated subscriber connection (a connection in // subscribe mode cannot run normal commands), with auto-reconnect, capped // backoff, an active PING probe, and a health flag that callers and the health // endpoint can read. // // TLS: use a rediss:// URL — ioredis enables TLS from the scheme. A /N path // selects a DB index (e.g. redis://host:6379/1) when sharing an instance. // We deliberately do not use ioredis's keyPrefix option: all keys are already // namespaced per subsystem (cache:, rl:, lock:, lockout:), and keyPrefix has a // pub/sub asymmetry (SUBSCRIBE channels get prefixed, PUBLISH channels do not) // that would silently break the payment SSE channel. import Redis from 'ioredis'; let client: Redis | null = null; let subscriber: Redis | null = null; let healthy = false; let initialized = false; let pingTimer: ReturnType | null = null; let lastPingOkAt: string | null = null; let lastPingMs: number | null = null; // Debounce repeated error logs during a sustained outage: transitions are // always logged, repeated per-retry errors at most once per LOG_EVERY_MS. const ERROR_LOG_EVERY_MS = 30_000; let lastErrorLogAt = 0; /** Whether Redis is configured via REDIS_URL. */ export function isRedisEnabled(): boolean { return !!process.env.REDIS_URL; } /** Whether the Redis connection is currently usable. */ export function isRedisHealthy(): boolean { return isRedisEnabled() && healthy; } /** Detail for the health endpoint: when the last successful PING happened. */ export function getRedisHealthDetail(): { lastPingOkAt: string | null; lastPingMs: number | null } { return { lastPingOkAt, lastPingMs }; } function setHealthy(next: boolean, context: string): void { if (next !== healthy) { if (next) { console.log(`[redis] healthy (${context})`); } else { console.warn(`[redis] unhealthy (${context})`); } } healthy = next; } function logErrorDebounced(label: string, err: unknown): void { const now = Date.now(); if (now - lastErrorLogAt >= ERROR_LOG_EVERY_MS) { lastErrorLogAt = now; console.error(`[redis] (${label}) error:`, (err as any)?.message || err); } } function buildClient(label: string): Redis { const url = process.env.REDIS_URL as string; const instance = new Redis(url, { // Keep the process responsive: fail fast on a per-command basis and let the // callers degrade to their in-memory fallback rather than hanging. Do not // queue commands while disconnected — with fail-open callers everywhere a // growing offline queue would only add latency and memory pressure. maxRetriesPerRequest: 1, enableOfflineQueue: false, lazyConnect: false, connectTimeout: 5000, retryStrategy(times) { // Capped exponential backoff for reconnects: 200ms, 400ms ... max 5s. const delay = Math.min(times * 200, 5000); return delay; }, }); instance.on('connect', () => { console.log(`[redis] (${label}) connecting`); }); instance.on('ready', () => { setHealthy(true, `${label} ready`); }); instance.on('error', (err) => { setHealthy(false, `${label} error`); logErrorDebounced(label, err); }); instance.on('reconnecting', () => { setHealthy(false, `${label} reconnecting`); }); instance.on('end', () => { setHealthy(false, `${label} connection closed`); }); return instance; } // Actively probe the command connection. The event-driven flag alone misses a // silently hung connection; a periodic PING with a hard timeout catches it. async function pingOnce(): Promise { if (!client) return; const started = Date.now(); try { await Promise.race([ client.ping(), new Promise((_, reject) => { const t = setTimeout(() => reject(new Error('ping timeout')), 2000); (t as any).unref?.(); }), ]); lastPingMs = Date.now() - started; lastPingOkAt = new Date().toISOString(); setHealthy(true, 'ping ok'); } catch (err) { setHealthy(false, 'ping failed'); logErrorDebounced('ping', err); } } function ensureInit(): void { if (initialized || !isRedisEnabled()) return; initialized = true; client = buildClient('commands'); subscriber = buildClient('subscriber'); pingTimer = setInterval(() => { void pingOnce(); }, 10_000); (pingTimer as any).unref?.(); } /** Shared command connection, or null when Redis is not configured. */ export function getRedis(): Redis | null { ensureInit(); return client; } /** Dedicated subscriber connection, or null when Redis is not configured. */ export function getSubscriber(): Redis | null { ensureInit(); return subscriber; } /** Close connections (used for graceful shutdown). */ export async function closeRedis(): Promise { if (pingTimer) { clearInterval(pingTimer); pingTimer = null; } const tasks: Promise[] = []; if (client) tasks.push(client.quit().catch(() => undefined)); if (subscriber) tasks.push(subscriber.quit().catch(() => undefined)); await Promise.all(tasks); client = null; subscriber = null; initialized = false; healthy = false; }