import { Inject, Injectable } from "@nestjs/common"; import { Redis } from "ioredis"; import { Logger } from "nestjs-pino"; /** * Core cache service * * Provides Redis-backed caching infrastructure for the entire application. * This is a low-level service - domain-specific cache services should wrap this * to provide higher-level caching patterns (CDC invalidation, request coalescing, etc). * * Features: * - JSON serialization/deserialization * - TTL support (optional) * - Pattern-based operations (scan, delete, count) * - Memory usage tracking * - Automatic error handling and logging */ @Injectable() export class CacheService { constructor( @Inject("REDIS_CLIENT") private readonly redis: Redis, @Inject(Logger) private readonly logger: Logger ) {} /** * Get a cached value by key * Returns null if key doesn't exist or value cannot be parsed */ async get(key: string): Promise { const value = await this.redis.get(key); if (!value) { return null; } try { return JSON.parse(value) as T; } catch (error) { this.logger.warn({ key, error }, "Failed to parse cached value; evicting entry"); await this.redis.del(key); return null; } } /** * Set a cached value * @param key Cache key * @param value Value to cache (will be JSON serialized) * @param ttlSeconds Optional TTL in seconds (null = no expiry) */ async set(key: string, value: unknown, ttlSeconds?: number): Promise { const serialized = JSON.stringify(value); if (ttlSeconds !== undefined) { const ttl = Math.max(0, Math.floor(ttlSeconds)); if (ttl > 0) { await this.redis.set(key, serialized, "EX", ttl); } else { // Allow callers to request immediate expiry without leaking stale values await this.redis.set(key, serialized, "PX", 1); } return; } await this.redis.set(key, serialized); } /** * Delete a single cache key */ async del(key: string): Promise { await this.redis.del(key); } /** * Delete all keys matching a pattern * Uses SCAN for safe operation on large datasets * @param pattern Redis pattern (e.g., "orders:*", "services:product:*") */ async delPattern(pattern: string): Promise { const pipeline = this.redis.pipeline(); let pending = 0; const flush = async () => { if (pending === 0) { return; } await pipeline.exec(); pending = 0; }; await this.scanPattern(pattern, async keys => { keys.forEach(key => { pipeline.del(key); pending += 1; }); if (pending >= 1000) { await flush(); } }); await flush(); } /** * Count keys matching a pattern * @param pattern Redis pattern (e.g., "orders:*") */ async countByPattern(pattern: string): Promise { let total = 0; await this.scanPattern(pattern, keys => { total += keys.length; }); return total; } /** * Get total memory usage of keys matching a pattern * @param pattern Redis pattern (e.g., "orders:*") * @returns Total memory usage in bytes */ async memoryUsageByPattern(pattern: string): Promise { let total = 0; await this.scanPattern(pattern, async keys => { const pipeline = this.redis.pipeline(); keys.forEach(key => { pipeline.memory("USAGE", key); }); const results = await pipeline.exec(); if (!results) { return; } results.forEach((result: [Error | null, unknown] | null) => { if (!result) { return; } const [error, usage] = result; if (!error && typeof usage === "number") { total += usage; } }); }); return total; } /** * Check if a key exists */ async exists(key: string): Promise { return (await this.redis.exists(key)) === 1; } /** * Build a structured cache key * @param prefix Key prefix (e.g., "orders", "catalog") * @param userId User/account identifier * @param parts Additional key parts * @returns Colon-separated key (e.g., "orders:user123:summary") */ buildKey(prefix: string, userId: string, ...parts: string[]): string { return [prefix, userId, ...parts].join(":"); } /** * Get or set pattern: Fetch from cache, or call fetcher and cache result * @param key Cache key * @param fetcher Function to fetch fresh data on cache miss * @param ttlSeconds TTL in seconds (default: 300) */ async getOrSet(key: string, fetcher: () => Promise, ttlSeconds: number = 300): Promise { const cached = await this.get(key); if (cached !== null) { return cached; } const fresh = await fetcher(); await this.set(key, fresh, ttlSeconds); return fresh; } /** * Scan keys matching a pattern and invoke callback for each batch * Uses cursor-based iteration for safe operation on large datasets * @param pattern Redis pattern * @param onKeys Callback invoked for each batch of matching keys */ private async scanPattern( pattern: string, onKeys: (keys: string[]) => Promise | void ): Promise { let cursor = "0"; do { const [next, keys] = (await this.redis.scan( cursor, "MATCH", pattern, "COUNT", 1000 )) as unknown as [string, string[]]; cursor = next; if (keys && keys.length) { await onKeys(keys); } } while (cursor !== "0"); } }