stock-bot/libs/core/cache/src/redis-cache.ts
2025-06-26 16:12:27 -04:00

231 lines
5.8 KiB
TypeScript

import Redis from 'ioredis';
import { RedisConnectionManager } from './connection-manager';
import { CACHE_DEFAULTS } from './constants';
import type { CacheOptions, CacheProvider, CacheStats } from './types';
/**
* Simplified Redis-based cache provider
*/
export class RedisCache implements CacheProvider {
private redis: Redis;
private logger: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void } =
console;
private defaultTTL: number;
private keyPrefix: string;
private stats: CacheStats = {
hits: 0,
misses: 0,
errors: 0,
hitRate: 0,
total: 0,
uptime: 0,
};
private startTime = Date.now();
constructor(options: CacheOptions) {
this.defaultTTL = options.ttl ?? CACHE_DEFAULTS.TTL;
this.keyPrefix = options.keyPrefix ?? CACHE_DEFAULTS.KEY_PREFIX;
this.logger = options.logger || console;
const manager = RedisConnectionManager.getInstance();
const name = options.name || 'CACHE';
this.redis = manager.getConnection({
name: `${name}-SERVICE`,
singleton: options.shared ?? true,
redisConfig: options.redisConfig,
logger: this.logger,
});
}
private getKey(key: string): string {
return `${this.keyPrefix}${key}`;
}
private updateStats(hit: boolean, error = false): void {
if (error) {
this.stats.errors++;
} else if (hit) {
this.stats.hits++;
} else {
this.stats.misses++;
}
this.stats.total = this.stats.hits + this.stats.misses;
this.stats.hitRate = this.stats.total > 0 ? this.stats.hits / this.stats.total : 0;
this.stats.uptime = Date.now() - this.startTime;
}
async get<T>(key: string): Promise<T | null> {
try {
const value = await this.redis.get(this.getKey(key));
if (value === null) {
this.updateStats(false);
return null;
}
this.updateStats(true);
return JSON.parse(value);
} catch {
this.updateStats(false, true);
return null;
}
}
async set<T>(
key: string,
value: T,
options?:
| number
| {
ttl?: number;
preserveTTL?: boolean;
onlyIfExists?: boolean;
onlyIfNotExists?: boolean;
getOldValue?: boolean;
}
): Promise<T | null> {
try {
const fullKey = this.getKey(key);
const serialized = JSON.stringify(value);
const opts = typeof options === 'number' ? { ttl: options } : options || {};
let oldValue: T | null = null;
if (opts.getOldValue) {
const existing = await this.redis.get(fullKey);
if (existing) {
oldValue = JSON.parse(existing);
}
}
const ttl = opts.ttl ?? this.defaultTTL;
if (opts.onlyIfExists) {
const result = await this.redis.set(fullKey, serialized, 'EX', ttl, 'XX');
if (!result) {
return oldValue;
}
} else if (opts.onlyIfNotExists) {
const result = await this.redis.set(fullKey, serialized, 'EX', ttl, 'NX');
if (!result) {
return oldValue;
}
} else if (opts.preserveTTL) {
const currentTTL = await this.redis.ttl(fullKey);
if (currentTTL > 0) {
await this.redis.setex(fullKey, currentTTL, serialized);
} else {
await this.redis.setex(fullKey, ttl, serialized);
}
} else {
await this.redis.setex(fullKey, ttl, serialized);
}
return oldValue;
} catch (error) {
this.updateStats(false, true);
throw error;
}
}
async del(key: string): Promise<void> {
try {
await this.redis.del(this.getKey(key));
} catch (error) {
this.updateStats(false, true);
throw error;
}
}
async exists(key: string): Promise<boolean> {
try {
return (await this.redis.exists(this.getKey(key))) === 1;
} catch {
this.updateStats(false, true);
return false;
}
}
async clear(): Promise<void> {
try {
const stream = this.redis.scanStream({
match: `${this.keyPrefix}*`,
count: CACHE_DEFAULTS.SCAN_COUNT,
});
const pipeline = this.redis.pipeline();
stream.on('data', (keys: string[]) => {
if (keys.length) {
keys.forEach(key => pipeline.del(key));
}
});
await new Promise((resolve, reject) => {
stream.on('end', resolve);
stream.on('error', reject);
});
await pipeline.exec();
} catch (error) {
this.updateStats(false, true);
throw error;
}
}
async keys(pattern: string): Promise<string[]> {
try {
const keys: string[] = [];
const stream = this.redis.scanStream({
match: `${this.keyPrefix}${pattern}`,
count: CACHE_DEFAULTS.SCAN_COUNT,
});
await new Promise((resolve, reject) => {
stream.on('data', (resultKeys: string[]) => {
keys.push(...resultKeys.map(k => k.replace(this.keyPrefix, '')));
});
stream.on('end', resolve);
stream.on('error', reject);
});
return keys;
} catch {
this.updateStats(false, true);
return [];
}
}
async health(): Promise<boolean> {
try {
return (await this.redis.ping()) === 'PONG';
} catch {
return false;
}
}
getStats(): CacheStats {
return {
...this.stats,
uptime: Date.now() - this.startTime,
};
}
async waitForReady(timeout = 5000): Promise<void> {
if (this.redis.status === 'ready') {
return;
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Redis connection timeout after ${timeout}ms`));
}, timeout);
this.redis.once('ready', () => {
clearTimeout(timer);
resolve();
});
});
}
isReady(): boolean {
return this.redis.status === 'ready';
}
}