231 lines
5.8 KiB
TypeScript
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';
|
|
}
|
|
}
|