195 lines
5.4 KiB
TypeScript
195 lines
5.4 KiB
TypeScript
import { logger } from '@goodgo/logger';
|
|
import NodeCache from 'node-cache';
|
|
|
|
import { getRedisClient } from '../../config/redis.config';
|
|
|
|
/**
|
|
* EN: Multi-layer cache implementation (L1: Memory, L2: Redis)
|
|
* VI: Triển khai cache đa tầng (L1: Bộ nhớ, L2: Redis)
|
|
*/
|
|
export class MultiLayerCache {
|
|
private l1Cache: NodeCache; // In-memory cache
|
|
private redis: ReturnType<typeof getRedisClient> | null = null; // Disabled for testing
|
|
|
|
constructor() {
|
|
// L1: In-memory cache (10MB default, 60s default TTL)
|
|
this.l1Cache = new NodeCache({
|
|
stdTTL: 60, // Default TTL 60 seconds
|
|
maxKeys: 10000, // Max 10k keys
|
|
useClones: false, // Better performance
|
|
});
|
|
|
|
// EN: Temporarily disable L2 Redis cache to test performance
|
|
// VI: Tạm thời tắt L2 Redis cache để test hiệu suất
|
|
// TODO: Re-enable after fixing Redis EPIPE errors
|
|
// this.redis = getRedisClient();
|
|
}
|
|
|
|
/**
|
|
* EN: Get value from cache (L1 -> L2)
|
|
* VI: Lấy giá trị từ cache (L1 -> L2)
|
|
*/
|
|
async get<T>(key: string): Promise<T | null> {
|
|
try {
|
|
// L1: Check in-memory cache first (< 1ms)
|
|
const l1Value = this.l1Cache.get<T>(key);
|
|
if (l1Value !== undefined) {
|
|
return l1Value;
|
|
}
|
|
|
|
// L2: Check Redis cache (< 5ms) - only if Redis is enabled
|
|
if (this.redis) {
|
|
const l2Value = await this.redis.get(key);
|
|
if (l2Value) {
|
|
const parsed = JSON.parse(l2Value) as T;
|
|
// Warm up L1 cache
|
|
this.l1Cache.set(key, parsed, 60); // Cache 1 minute in L1
|
|
return parsed;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
logger.error('Multi-layer cache get error', { key, error });
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* EN: Set value in cache (both L1 and L2)
|
|
* VI: Lưu giá trị vào cache (cả L1 và L2)
|
|
*/
|
|
async set(key: string, value: any, ttlSeconds?: number): Promise<void> {
|
|
try {
|
|
// L1: Set in-memory cache
|
|
const l1Ttl = ttlSeconds ? Math.min(ttlSeconds, 300) : 60; // Max 5 min in L1
|
|
this.l1Cache.set(key, value, l1Ttl);
|
|
|
|
// EN: Temporarily disable L2 Redis cache to test performance
|
|
// VI: Tạm thời tắt L2 Redis cache để test hiệu suất
|
|
// TODO: Re-enable after fixing Redis EPIPE errors
|
|
/*
|
|
// L2: Set Redis cache
|
|
if (this.redis) {
|
|
const stringValue = JSON.stringify(value);
|
|
if (ttlSeconds) {
|
|
await this.redis.setex(key, ttlSeconds, stringValue);
|
|
} else {
|
|
await this.redis.set(key, stringValue);
|
|
}
|
|
}
|
|
*/
|
|
} catch (error) {
|
|
logger.error('Multi-layer cache set error', { key, error });
|
|
// Continue even if Redis fails (L1 still works)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* EN: Get from cache or fetch from source if missing
|
|
* VI: Lấy từ cache hoặc lấy từ nguồn nếu không có
|
|
*/
|
|
async getOrSet<T>(
|
|
key: string,
|
|
fetchFn: () => Promise<T>,
|
|
ttlSeconds: number = 300
|
|
): Promise<T> {
|
|
const cached = await this.get<T>(key);
|
|
if (cached !== null) {
|
|
return cached;
|
|
}
|
|
|
|
const data = await fetchFn();
|
|
await this.set(key, data, ttlSeconds);
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* EN: Delete from cache (both L1 and L2)
|
|
* VI: Xóa khỏi cache (cả L1 và L2)
|
|
*/
|
|
async del(key: string): Promise<void> {
|
|
try {
|
|
// L1: Delete from memory
|
|
this.l1Cache.del(key);
|
|
|
|
// L2: Delete from Redis
|
|
if (this.redis) {
|
|
await this.redis.del(key);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Multi-layer cache del error', { key, error });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* EN: Delete multiple keys from cache
|
|
* VI: Xóa nhiều keys khỏi cache
|
|
*/
|
|
async delMany(keys: string[]): Promise<void> {
|
|
try {
|
|
// L1: Delete from memory
|
|
keys.forEach(key => this.l1Cache.del(key));
|
|
|
|
// L2: Delete from Redis
|
|
if (this.redis && keys.length > 0) {
|
|
await this.redis.del(...keys);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Multi-layer cache delMany error', { keys, error });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* EN: Invalidate cache by pattern (for cache invalidation)
|
|
* VI: Làm mất hiệu lực cache theo pattern
|
|
*/
|
|
async invalidatePattern(pattern: string): Promise<void> {
|
|
try {
|
|
// L1: Clear all (pattern matching is expensive, so clear all)
|
|
// In production, you might want to implement pattern matching
|
|
this.l1Cache.flushAll();
|
|
|
|
// L2: Scan and delete matching keys in Redis
|
|
if (this.redis) {
|
|
const stream = this.redis.scanStream({
|
|
match: pattern,
|
|
count: 100,
|
|
});
|
|
|
|
const keys: string[] = [];
|
|
stream.on('data', (resultKeys: string[]) => {
|
|
keys.push(...resultKeys);
|
|
});
|
|
|
|
const redis = this.redis; // Capture for closure
|
|
stream.on('end', async () => {
|
|
if (keys.length > 0) {
|
|
await redis.del(...keys);
|
|
}
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logger.error('Multi-layer cache invalidatePattern error', { pattern, error });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* EN: Get cache statistics
|
|
* VI: Lấy thống kê cache
|
|
*/
|
|
getStats() {
|
|
const l1Stats = this.l1Cache.getStats();
|
|
return {
|
|
l1: {
|
|
keys: l1Stats.keys,
|
|
hits: l1Stats.hits,
|
|
misses: l1Stats.misses,
|
|
hitRate: l1Stats.hits / (l1Stats.hits + l1Stats.misses) || 0,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
export const multiLayerCache = new MultiLayerCache();
|