474 lines
14 KiB
TypeScript
474 lines
14 KiB
TypeScript
import Redis from 'ioredis';
|
|
import { RedisConnectionManager } from './connection-manager';
|
|
import type { CacheOptions, CacheProvider, CacheStats } from './types';
|
|
|
|
/**
|
|
* Simplified Redis-based cache provider using connection manager
|
|
*/
|
|
export class RedisCache implements CacheProvider {
|
|
private redis: Redis;
|
|
private logger: any;
|
|
private defaultTTL: number;
|
|
private keyPrefix: string;
|
|
private enableMetrics: boolean;
|
|
private isConnected = false;
|
|
private startTime = Date.now();
|
|
private connectionManager: RedisConnectionManager;
|
|
|
|
private stats: CacheStats = {
|
|
hits: 0,
|
|
misses: 0,
|
|
errors: 0,
|
|
hitRate: 0,
|
|
total: 0,
|
|
uptime: 0,
|
|
};
|
|
|
|
constructor(options: CacheOptions) {
|
|
this.defaultTTL = options.ttl ?? 3600; // 1 hour default
|
|
this.keyPrefix = options.keyPrefix ?? 'cache:';
|
|
this.enableMetrics = options.enableMetrics ?? true;
|
|
this.logger = options.logger || console; // Use provided logger or console as fallback
|
|
|
|
// Get connection manager instance
|
|
this.connectionManager = RedisConnectionManager.getInstance();
|
|
|
|
// Generate connection name based on cache type
|
|
const baseName =
|
|
options.name ||
|
|
this.keyPrefix
|
|
.replace(':', '')
|
|
.replace(/[^a-zA-Z0-9]/g, '')
|
|
.toUpperCase() ||
|
|
'CACHE';
|
|
|
|
// Get Redis connection (shared by default for cache)
|
|
this.redis = this.connectionManager.getConnection({
|
|
name: `${baseName}-SERVICE`,
|
|
singleton: options.shared ?? true, // Default to shared connection for cache
|
|
redisConfig: options.redisConfig,
|
|
logger: this.logger,
|
|
});
|
|
|
|
// Only setup event handlers for non-shared connections to avoid memory leaks
|
|
if (!(options.shared ?? true)) {
|
|
this.setupEventHandlers();
|
|
} else {
|
|
// For shared connections, just monitor the connection status without adding handlers
|
|
this.isConnected = this.redis.status === 'ready';
|
|
}
|
|
}
|
|
|
|
private setupEventHandlers(): void {
|
|
this.redis.on('connect', () => {
|
|
this.logger.info('Redis cache connected');
|
|
});
|
|
|
|
this.redis.on('ready', () => {
|
|
this.isConnected = true;
|
|
this.logger.info('Redis cache ready');
|
|
});
|
|
|
|
this.redis.on('error', (error: Error) => {
|
|
this.isConnected = false;
|
|
this.logger.error('Redis cache connection error', { error: error.message });
|
|
});
|
|
|
|
this.redis.on('close', () => {
|
|
this.isConnected = false;
|
|
this.logger.warn('Redis cache connection closed');
|
|
});
|
|
|
|
this.redis.on('reconnecting', () => {
|
|
this.logger.warn('Redis cache reconnecting...');
|
|
});
|
|
}
|
|
|
|
private getKey(key: string): string {
|
|
return `${this.keyPrefix}${key}`;
|
|
}
|
|
|
|
private updateStats(hit: boolean, error = false): void {
|
|
if (!this.enableMetrics) {
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private async safeExecute<T>(
|
|
operation: () => Promise<T>,
|
|
fallback: T,
|
|
operationName: string
|
|
): Promise<T> {
|
|
try {
|
|
if (!this.isReady()) {
|
|
this.logger.warn(`Redis not ready for ${operationName}, using fallback`);
|
|
this.updateStats(false, true);
|
|
return fallback;
|
|
}
|
|
return await operation();
|
|
} catch (error) {
|
|
this.logger.error(`Redis ${operationName} failed`, {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
this.updateStats(false, true);
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
async get<T>(key: string): Promise<T | null> {
|
|
return this.safeExecute(
|
|
async () => {
|
|
const fullKey = this.getKey(key);
|
|
const value = await this.redis.get(fullKey);
|
|
|
|
if (value === null) {
|
|
this.updateStats(false);
|
|
this.logger.debug('Cache miss', { key });
|
|
return null;
|
|
}
|
|
|
|
this.updateStats(true);
|
|
this.logger.debug('Cache hit', { key });
|
|
|
|
try {
|
|
return JSON.parse(value) as T;
|
|
} catch {
|
|
// If parsing fails, return as string
|
|
return value as unknown as T;
|
|
}
|
|
},
|
|
null,
|
|
'get'
|
|
);
|
|
}
|
|
|
|
async set<T>(
|
|
key: string,
|
|
value: T,
|
|
options?:
|
|
| number
|
|
| {
|
|
ttl?: number;
|
|
preserveTTL?: boolean;
|
|
onlyIfExists?: boolean;
|
|
onlyIfNotExists?: boolean;
|
|
getOldValue?: boolean;
|
|
}
|
|
): Promise<T | null> {
|
|
return this.safeExecute(
|
|
async () => {
|
|
const fullKey = this.getKey(key);
|
|
const serialized = typeof value === 'string' ? value : JSON.stringify(value);
|
|
|
|
// Handle backward compatibility - if options is a number, treat as TTL
|
|
const config = typeof options === 'number' ? { ttl: options } : options || {};
|
|
|
|
let oldValue: T | null = null;
|
|
|
|
// Get old value if requested
|
|
if (config.getOldValue) {
|
|
const existingValue = await this.redis.get(fullKey);
|
|
if (existingValue !== null) {
|
|
try {
|
|
oldValue = JSON.parse(existingValue) as T;
|
|
} catch {
|
|
oldValue = existingValue as unknown as T;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle preserveTTL logic
|
|
if (config.preserveTTL) {
|
|
const currentTTL = await this.redis.ttl(fullKey);
|
|
|
|
if (currentTTL === -2) {
|
|
// Key doesn't exist
|
|
if (config.onlyIfExists) {
|
|
this.logger.debug('Set skipped - key does not exist and onlyIfExists is true', {
|
|
key,
|
|
});
|
|
return oldValue;
|
|
}
|
|
// Set with default or specified TTL
|
|
const ttl = config.ttl ?? this.defaultTTL;
|
|
await this.redis.setex(fullKey, ttl, serialized);
|
|
this.logger.debug('Cache set with new TTL (key did not exist)', { key, ttl });
|
|
} else if (currentTTL === -1) {
|
|
// Key exists but has no expiry - preserve the no-expiry state
|
|
await this.redis.set(fullKey, serialized);
|
|
this.logger.debug('Cache set preserving no-expiry', { key });
|
|
} else {
|
|
// Key exists with TTL - preserve it
|
|
await this.redis.setex(fullKey, currentTTL, serialized);
|
|
this.logger.debug('Cache set preserving existing TTL', { key, ttl: currentTTL });
|
|
}
|
|
} else {
|
|
// Standard set logic with conditional operations
|
|
if (config.onlyIfExists && config.onlyIfNotExists) {
|
|
throw new Error('Cannot specify both onlyIfExists and onlyIfNotExists');
|
|
}
|
|
|
|
if (config.onlyIfExists) {
|
|
// Only set if key exists (XX flag)
|
|
const ttl = config.ttl ?? this.defaultTTL;
|
|
const result = await this.redis.set(fullKey, serialized, 'EX', ttl, 'XX');
|
|
if (result === null) {
|
|
this.logger.debug('Set skipped - key does not exist', { key });
|
|
return oldValue;
|
|
}
|
|
} else if (config.onlyIfNotExists) {
|
|
// Only set if key doesn't exist (NX flag)
|
|
const ttl = config.ttl ?? this.defaultTTL;
|
|
const result = await this.redis.set(fullKey, serialized, 'EX', ttl, 'NX');
|
|
if (result === null) {
|
|
this.logger.debug('Set skipped - key already exists', { key });
|
|
return oldValue;
|
|
}
|
|
} else {
|
|
// Standard set
|
|
const ttl = config.ttl ?? this.defaultTTL;
|
|
await this.redis.setex(fullKey, ttl, serialized);
|
|
}
|
|
|
|
this.logger.debug('Cache set', { key, ttl: config.ttl ?? this.defaultTTL });
|
|
}
|
|
|
|
return oldValue;
|
|
},
|
|
null,
|
|
'set'
|
|
);
|
|
}
|
|
|
|
async del(key: string): Promise<void> {
|
|
await this.safeExecute(
|
|
async () => {
|
|
const fullKey = this.getKey(key);
|
|
await this.redis.del(fullKey);
|
|
this.logger.debug('Cache delete', { key });
|
|
},
|
|
undefined,
|
|
'del'
|
|
);
|
|
}
|
|
|
|
async exists(key: string): Promise<boolean> {
|
|
return this.safeExecute(
|
|
async () => {
|
|
const fullKey = this.getKey(key);
|
|
const result = await this.redis.exists(fullKey);
|
|
return result === 1;
|
|
},
|
|
false,
|
|
'exists'
|
|
);
|
|
}
|
|
|
|
async clear(): Promise<void> {
|
|
await this.safeExecute(
|
|
async () => {
|
|
const pattern = `${this.keyPrefix}*`;
|
|
const keys = await this.redis.keys(pattern);
|
|
if (keys.length > 0) {
|
|
await this.redis.del(...keys);
|
|
this.logger.warn('Cache cleared', { keysDeleted: keys.length });
|
|
}
|
|
},
|
|
undefined,
|
|
'clear'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get a value using a raw Redis key (bypassing the keyPrefix)
|
|
* Useful for accessing cache data from other services with different prefixes
|
|
*/
|
|
async getRaw<T = unknown>(key: string): Promise<T | null> {
|
|
return this.safeExecute(
|
|
async () => {
|
|
// Use the key directly without adding our prefix
|
|
const value = await this.redis.get(key);
|
|
if (!value) {
|
|
this.updateStats(false);
|
|
return null;
|
|
}
|
|
this.updateStats(true);
|
|
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
this.logger.debug('Cache raw get hit', { key });
|
|
return parsed;
|
|
} catch (error) {
|
|
// If JSON parsing fails, log the error with more context
|
|
this.logger.warn('Cache getRaw JSON parse failed', {
|
|
key,
|
|
valueLength: value.length,
|
|
valuePreview: value.substring(0, 100),
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
// Return the raw value as-is if it can't be parsed
|
|
return value as unknown as T;
|
|
}
|
|
},
|
|
null,
|
|
'getRaw'
|
|
);
|
|
}
|
|
|
|
async keys(pattern: string): Promise<string[]> {
|
|
return this.safeExecute(
|
|
async () => {
|
|
const fullPattern = `${this.keyPrefix}${pattern}`;
|
|
const keys = await this.redis.keys(fullPattern);
|
|
// Remove the prefix from returned keys to match the interface expectation
|
|
return keys.map(key => key.replace(this.keyPrefix, ''));
|
|
},
|
|
[],
|
|
'keys'
|
|
);
|
|
}
|
|
|
|
async health(): Promise<boolean> {
|
|
try {
|
|
const pong = await this.redis.ping();
|
|
return pong === 'PONG';
|
|
} catch (error) {
|
|
this.logger.error('Redis health check failed', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
|
|
getStats(): CacheStats {
|
|
return {
|
|
...this.stats,
|
|
uptime: Date.now() - this.startTime,
|
|
};
|
|
}
|
|
|
|
async waitForReady(timeout = 5000): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
if (this.redis.status === 'ready') {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
reject(new Error(`Redis connection timeout after ${timeout}ms`));
|
|
}, timeout);
|
|
|
|
this.redis.once('ready', () => {
|
|
clearTimeout(timeoutId);
|
|
resolve();
|
|
});
|
|
|
|
this.redis.once('error', error => {
|
|
clearTimeout(timeoutId);
|
|
reject(error);
|
|
});
|
|
});
|
|
}
|
|
|
|
isReady(): boolean {
|
|
// Always check the actual Redis connection status
|
|
const ready = this.redis.status === 'ready';
|
|
|
|
// Update local flag if we're not using shared connection
|
|
if (this.isConnected !== ready) {
|
|
this.isConnected = ready;
|
|
}
|
|
|
|
return ready;
|
|
}
|
|
|
|
// Enhanced convenience methods
|
|
async update<T>(key: string, value: T): Promise<T | null> {
|
|
return this.set(key, value, { preserveTTL: true, getOldValue: true });
|
|
}
|
|
|
|
async setIfExists<T>(key: string, value: T, ttl?: number): Promise<boolean> {
|
|
const result = await this.set(key, value, { ttl, onlyIfExists: true });
|
|
return result !== null || (await this.exists(key));
|
|
}
|
|
|
|
async setIfNotExists<T>(key: string, value: T, ttl?: number): Promise<boolean> {
|
|
const oldValue = await this.set(key, value, { ttl, onlyIfNotExists: true, getOldValue: true });
|
|
return oldValue === null; // Returns true if key didn't exist before
|
|
}
|
|
|
|
async replace<T>(key: string, value: T, ttl?: number): Promise<T | null> {
|
|
return this.set(key, value, { ttl, onlyIfExists: true, getOldValue: true });
|
|
}
|
|
|
|
// Atomic update with transformation
|
|
async updateField<T>(
|
|
key: string,
|
|
updater: (current: T | null) => T,
|
|
ttl?: number
|
|
): Promise<T | null> {
|
|
return this.safeExecute(
|
|
async () => {
|
|
const fullKey = this.getKey(key);
|
|
|
|
// Use Lua script for atomic read-modify-write
|
|
const luaScript = `
|
|
local key = KEYS[1]
|
|
|
|
-- Get current value and TTL
|
|
local current_value = redis.call('GET', key)
|
|
local current_ttl = redis.call('TTL', key)
|
|
|
|
-- Return current value for processing
|
|
return {current_value, current_ttl}
|
|
`;
|
|
|
|
const [currentValue, currentTTL] = (await this.redis.eval(luaScript, 1, fullKey)) as [
|
|
string | null,
|
|
number,
|
|
];
|
|
|
|
// Parse current value
|
|
let parsed: T | null = null;
|
|
if (currentValue !== null) {
|
|
try {
|
|
parsed = JSON.parse(currentValue) as T;
|
|
} catch {
|
|
parsed = currentValue as unknown as T;
|
|
}
|
|
}
|
|
|
|
// Apply updater function
|
|
const newValue = updater(parsed);
|
|
|
|
// Set the new value with appropriate TTL logic
|
|
if (ttl !== undefined) {
|
|
// Use specified TTL
|
|
await this.set(key, newValue, ttl);
|
|
} else if (currentTTL === -2) {
|
|
// Key didn't exist, use default TTL
|
|
await this.set(key, newValue);
|
|
} else {
|
|
// Preserve existing TTL
|
|
await this.set(key, newValue, { preserveTTL: true });
|
|
}
|
|
|
|
return parsed;
|
|
},
|
|
null,
|
|
'updateField'
|
|
);
|
|
}
|
|
}
|