import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; import Redis from "ioredis"; import { loadEnv } from "../config/env.js"; import { logger } from "./logger.js"; /** * Redis (ioredis) provider. Connection errors log and degrade (services * treat Redis as optional: rate limiting opens up, events still publish * in-process) — the API must not crash-loop when Redis blips. */ @Injectable() export class RedisService implements OnModuleInit, OnModuleDestroy { private readonly client: Redis; constructor() { this.client = new Redis(loadEnv().REDIS_URL, { maxRetriesPerRequest: 2, retryStrategy: (times: number) => Math.min(times * 500, 5000) }); } onModuleInit(): void { this.client.on("error", (err: Error) => { logger.warn(`redis error: ${err.message}`); }); this.client.on("connect", () => logger.info("redis connected")); } /** The underlying ioredis instance. */ getRedis(): Redis { return this.client; } /** * Run a callback on a duplicated connection with `subscribe` applied — * ioredis clients cannot mix pub/sub and regular commands on one socket. * Returns an unsubscribe() handle. */ async subscribe(channel: string, handler: (message: string) => void): Promise<() => Promise> { const sub = this.client.duplicate(); sub.on("error", () => undefined); // subscriber errors are non-fatal // ioredis types the subscribe callback as `Callback` // (`(err?, result?) => void`); the runtime forwards the reply through it. // The `as never` cast keeps this call runtime-identical to the original // `sub.subscribe(channel, handler)` (type-only reconciliation, no // behavioral change). await sub.subscribe(channel, handler as never); return async () => { await sub.unsubscribe(channel).catch(() => undefined); sub.disconnect(); }; } /** Best-effort PING (used by /health/deep). */ async ping(): Promise { try { return (await this.client.ping()) === "PONG"; } catch { return false; } } async onModuleDestroy(): Promise { await this.client.quit().catch(() => this.client.disconnect()); } }