simplified a lot of stuff

This commit is contained in:
Boki 2025-06-26 15:34:48 -04:00
parent b845a8eade
commit 885b484a37
20 changed files with 360 additions and 1335 deletions

View file

@ -1,20 +1,16 @@
import Redis from 'ioredis';
import { RedisConnectionManager } from './connection-manager';
import type { CacheOptions, CacheProvider, CacheStats } from './types';
import { CACHE_DEFAULTS } from './constants';
/**
* Simplified Redis-based cache provider using connection manager
* Simplified Redis-based cache provider
*/
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,
@ -23,65 +19,22 @@ export class RedisCache implements CacheProvider {
total: 0,
uptime: 0,
};
private startTime = Date.now();
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
this.defaultTTL = options.ttl ?? CACHE_DEFAULTS.TTL;
this.keyPrefix = options.keyPrefix ?? CACHE_DEFAULTS.KEY_PREFIX;
this.logger = options.logger || console;
// 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
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,
});
// 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 {
@ -89,10 +42,6 @@ export class RedisCache implements CacheProvider {
}
private updateStats(hit: boolean, error = false): void {
if (!this.enableMetrics) {
return;
}
if (error) {
this.stats.errors++;
} else if (hit) {
@ -100,256 +49,145 @@ export class RedisCache implements CacheProvider {
} 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'
);
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 (error) {
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> {
// Validate options before safeExecute
const config = typeof options === 'number' ? { ttl: options } : options || {};
if (config.onlyIfExists && config.onlyIfNotExists) {
throw new Error('Cannot specify both onlyIfExists and onlyIfNotExists');
options?: number | {
ttl?: number;
preserveTTL?: boolean;
onlyIfExists?: boolean;
onlyIfNotExists?: boolean;
getOldValue?: boolean;
}
return this.safeExecute(
async () => {
const fullKey = this.getKey(key);
const serialized = typeof value === 'string' ? value : JSON.stringify(value);
// Config is already parsed and validated above
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 });
}
): 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 {
// Standard set logic with conditional operations
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 });
await this.redis.setex(fullKey, ttl, serialized);
}
return oldValue;
},
null,
'set'
);
} else {
await this.redis.setex(fullKey, ttl, serialized);
}
return oldValue;
} catch (error) {
this.updateStats(false, true);
throw error;
}
}
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'
);
try {
await this.redis.del(this.getKey(key));
} catch (error) {
this.updateStats(false, true);
throw error;
}
}
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'
);
try {
return (await this.redis.exists(this.getKey(key))) === 1;
} catch (error) {
this.updateStats(false, true);
return false;
}
}
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 });
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));
}
},
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'
);
});
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[]> {
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'
);
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 (error) {
this.updateStats(false, true);
return [];
}
}
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 (await this.redis.ping()) === 'PONG';
} catch {
return false;
}
}
@ -362,115 +200,21 @@ export class RedisCache implements CacheProvider {
}
async waitForReady(timeout = 5000): Promise<void> {
if (this.redis.status === 'ready') return;
return new Promise((resolve, reject) => {
if (this.redis.status === 'ready') {
resolve();
return;
}
const timeoutId = setTimeout(() => {
const timer = setTimeout(() => {
reject(new Error(`Redis connection timeout after ${timeout}ms`));
}, timeout);
this.redis.once('ready', () => {
clearTimeout(timeoutId);
clearTimeout(timer);
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;
return this.redis.status === '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'
);
}
}
}