restructured libs to be more aligned with core components
This commit is contained in:
parent
947b1d748d
commit
0d1be9e3cb
50 changed files with 73 additions and 67 deletions
33
libs/data/cache/package.json
vendored
33
libs/data/cache/package.json
vendored
|
|
@ -1,33 +0,0 @@
|
|||
{
|
||||
"name": "@stock-bot/cache",
|
||||
"version": "1.0.0",
|
||||
"description": "Caching library for Redis and in-memory providers",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rimraf dist",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stock-bot/logger": "*",
|
||||
"ioredis": "^5.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"typescript": "^5.3.0",
|
||||
"bun-types": "^1.2.15"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
23
libs/data/cache/src/cache-factory.ts
vendored
23
libs/data/cache/src/cache-factory.ts
vendored
|
|
@ -1,23 +0,0 @@
|
|||
import { NamespacedCache } from './namespaced-cache';
|
||||
import type { CacheProvider } from './types';
|
||||
|
||||
/**
|
||||
* Factory function to create namespaced caches
|
||||
* Provides a clean API for services to get their own namespaced cache
|
||||
*/
|
||||
export function createNamespacedCache(
|
||||
cache: CacheProvider | null | undefined,
|
||||
namespace: string
|
||||
): CacheProvider | null {
|
||||
if (!cache) {
|
||||
return null;
|
||||
}
|
||||
return new NamespacedCache(cache, namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if cache is available
|
||||
*/
|
||||
export function isCacheAvailable(cache: any): cache is CacheProvider {
|
||||
return cache !== null && cache !== undefined && typeof cache.get === 'function';
|
||||
}
|
||||
279
libs/data/cache/src/connection-manager.ts
vendored
279
libs/data/cache/src/connection-manager.ts
vendored
|
|
@ -1,279 +0,0 @@
|
|||
import Redis from 'ioredis';
|
||||
import type { RedisConfig } from './types';
|
||||
|
||||
interface ConnectionConfig {
|
||||
name: string;
|
||||
singleton?: boolean;
|
||||
db?: number;
|
||||
redisConfig: RedisConfig;
|
||||
logger?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis Connection Manager for managing shared and unique connections
|
||||
*/
|
||||
export class RedisConnectionManager {
|
||||
private connections = new Map<string, Redis>();
|
||||
private static sharedConnections = new Map<string, Redis>();
|
||||
private static instance: RedisConnectionManager;
|
||||
private logger: any = console;
|
||||
private static readyConnections = new Set<string>();
|
||||
|
||||
// Singleton pattern for the manager itself
|
||||
static getInstance(): RedisConnectionManager {
|
||||
if (!this.instance) {
|
||||
this.instance = new RedisConnectionManager();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a Redis connection
|
||||
* @param config Connection configuration
|
||||
* @returns Redis connection instance
|
||||
*/
|
||||
getConnection(config: ConnectionConfig): Redis {
|
||||
const { name, singleton = false, db, redisConfig, logger } = config;
|
||||
if (logger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
if (singleton) {
|
||||
// Use shared connection across all instances
|
||||
if (!RedisConnectionManager.sharedConnections.has(name)) {
|
||||
const connection = this.createConnection(name, redisConfig, db, logger);
|
||||
RedisConnectionManager.sharedConnections.set(name, connection);
|
||||
this.logger.info(`Created shared Redis connection: ${name}`);
|
||||
}
|
||||
const connection = RedisConnectionManager.sharedConnections.get(name);
|
||||
if (!connection) {
|
||||
throw new Error(`Expected connection ${name} to exist in shared connections`);
|
||||
}
|
||||
return connection;
|
||||
} else {
|
||||
// Create unique connection per instance
|
||||
const uniqueName = `${name}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const connection = this.createConnection(uniqueName, redisConfig, db, logger);
|
||||
this.connections.set(uniqueName, connection);
|
||||
this.logger.debug(`Created unique Redis connection: ${uniqueName}`);
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Redis connection with configuration
|
||||
*/
|
||||
private createConnection(name: string, config: RedisConfig, db?: number, logger?: any): Redis {
|
||||
const redisOptions = {
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
password: config.password || undefined,
|
||||
username: config.username || undefined,
|
||||
db: db ?? config.db ?? 0,
|
||||
maxRetriesPerRequest: config.maxRetriesPerRequest ?? 3,
|
||||
retryDelayOnFailover: config.retryDelayOnFailover ?? 100,
|
||||
connectTimeout: config.connectTimeout ?? 10000,
|
||||
commandTimeout: config.commandTimeout ?? 5000,
|
||||
keepAlive: config.keepAlive ?? 0,
|
||||
connectionName: name,
|
||||
lazyConnect: false, // Connect immediately instead of waiting for first command
|
||||
...(config.tls && {
|
||||
tls: {
|
||||
cert: config.tls.cert || undefined,
|
||||
key: config.tls.key || undefined,
|
||||
ca: config.tls.ca || undefined,
|
||||
rejectUnauthorized: config.tls.rejectUnauthorized ?? true,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const redis = new Redis(redisOptions);
|
||||
|
||||
// Use the provided logger or fall back to instance logger
|
||||
const log = logger || this.logger;
|
||||
|
||||
// Setup event handlers
|
||||
redis.on('connect', () => {
|
||||
log.info(`Redis connection established: ${name}`);
|
||||
});
|
||||
|
||||
redis.on('ready', () => {
|
||||
log.info(`Redis connection ready: ${name}`);
|
||||
});
|
||||
|
||||
redis.on('error', err => {
|
||||
log.error(`Redis connection error for ${name}:`, err);
|
||||
});
|
||||
|
||||
redis.on('close', () => {
|
||||
log.warn(`Redis connection closed: ${name}`);
|
||||
});
|
||||
|
||||
redis.on('reconnecting', () => {
|
||||
log.warn(`Redis reconnecting: ${name}`);
|
||||
});
|
||||
|
||||
return redis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a specific connection
|
||||
*/
|
||||
async closeConnection(connection: Redis): Promise<void> {
|
||||
try {
|
||||
await connection.quit();
|
||||
} catch (error) {
|
||||
this.logger.warn('Error closing Redis connection:', error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all connections managed by this instance
|
||||
*/
|
||||
async closeAllConnections(): Promise<void> {
|
||||
// Close instance-specific connections
|
||||
const instancePromises = Array.from(this.connections.values()).map(conn =>
|
||||
this.closeConnection(conn)
|
||||
);
|
||||
await Promise.all(instancePromises);
|
||||
this.connections.clear();
|
||||
|
||||
// Close shared connections (only if this is the last instance)
|
||||
if (RedisConnectionManager.instance === this) {
|
||||
const sharedPromises = Array.from(RedisConnectionManager.sharedConnections.values()).map(
|
||||
conn => this.closeConnection(conn)
|
||||
);
|
||||
await Promise.all(sharedPromises);
|
||||
RedisConnectionManager.sharedConnections.clear();
|
||||
}
|
||||
|
||||
this.logger.info('All Redis connections closed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection statistics
|
||||
*/
|
||||
getConnectionCount(): { shared: number; unique: number } {
|
||||
return {
|
||||
shared: RedisConnectionManager.sharedConnections.size,
|
||||
unique: this.connections.size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all connection names for monitoring
|
||||
*/
|
||||
getConnectionNames(): { shared: string[]; unique: string[] } {
|
||||
return {
|
||||
shared: Array.from(RedisConnectionManager.sharedConnections.keys()),
|
||||
unique: Array.from(this.connections.keys()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for all connections
|
||||
*/
|
||||
async healthCheck(): Promise<{ healthy: boolean; details: Record<string, boolean> }> {
|
||||
const details: Record<string, boolean> = {};
|
||||
let allHealthy = true;
|
||||
|
||||
// Check shared connections
|
||||
for (const [name, connection] of RedisConnectionManager.sharedConnections) {
|
||||
try {
|
||||
await connection.ping();
|
||||
details[`shared:${name}`] = true;
|
||||
} catch {
|
||||
details[`shared:${name}`] = false;
|
||||
allHealthy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check instance connections
|
||||
for (const [name, connection] of this.connections) {
|
||||
try {
|
||||
await connection.ping();
|
||||
details[`unique:${name}`] = true;
|
||||
} catch {
|
||||
details[`unique:${name}`] = false;
|
||||
allHealthy = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { healthy: allHealthy, details };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all created connections to be ready
|
||||
* @param timeout Maximum time to wait in milliseconds
|
||||
* @returns Promise that resolves when all connections are ready
|
||||
*/
|
||||
static async waitForAllConnections(timeout: number = 30000): Promise<void> {
|
||||
const instance = this.getInstance();
|
||||
const allConnections = new Map([...instance.connections, ...this.sharedConnections]);
|
||||
|
||||
if (allConnections.size === 0) {
|
||||
instance.logger.debug('No Redis connections to wait for');
|
||||
return;
|
||||
}
|
||||
|
||||
instance.logger.info(`Waiting for ${allConnections.size} Redis connections to be ready...`);
|
||||
|
||||
const connectionPromises = Array.from(allConnections.entries()).map(([name, redis]) =>
|
||||
instance.waitForConnection(redis, name, timeout)
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.all(connectionPromises);
|
||||
instance.logger.info('All Redis connections are ready');
|
||||
} catch (error) {
|
||||
instance.logger.error('Failed to establish all Redis connections:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a specific connection to be ready
|
||||
*/
|
||||
private async waitForConnection(redis: Redis, name: string, timeout: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error(`Redis connection ${name} failed to be ready within ${timeout}ms`));
|
||||
}, timeout);
|
||||
|
||||
const onReady = () => {
|
||||
clearTimeout(timeoutId);
|
||||
RedisConnectionManager.readyConnections.add(name);
|
||||
this.logger.debug(`Redis connection ready: ${name}`);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
clearTimeout(timeoutId);
|
||||
this.logger.error(`Redis connection failed for ${name}:`, err);
|
||||
reject(err);
|
||||
};
|
||||
|
||||
if (redis.status === 'ready') {
|
||||
onReady();
|
||||
} else {
|
||||
redis.once('ready', onReady);
|
||||
redis.once('error', onError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all connections are ready
|
||||
*/
|
||||
static areAllConnectionsReady(): boolean {
|
||||
const instance = this.getInstance();
|
||||
const allConnections = new Map([...instance.connections, ...this.sharedConnections]);
|
||||
|
||||
return (
|
||||
allConnections.size > 0 &&
|
||||
Array.from(allConnections.keys()).every(name => this.readyConnections.has(name))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default RedisConnectionManager;
|
||||
55
libs/data/cache/src/index.ts
vendored
55
libs/data/cache/src/index.ts
vendored
|
|
@ -1,55 +0,0 @@
|
|||
import { RedisCache } from './redis-cache';
|
||||
import type { CacheOptions, CacheProvider } from './types';
|
||||
|
||||
// Cache instances registry to prevent multiple instances with same prefix
|
||||
const cacheInstances = new Map<string, CacheProvider>();
|
||||
|
||||
/**
|
||||
* Create a Redis cache instance with trading-optimized defaults
|
||||
*/
|
||||
export function createCache(options: CacheOptions): CacheProvider {
|
||||
const defaultOptions: CacheOptions = {
|
||||
keyPrefix: 'cache:',
|
||||
ttl: 3600, // 1 hour default
|
||||
enableMetrics: true,
|
||||
shared: true, // Default to shared connections
|
||||
...options,
|
||||
};
|
||||
|
||||
// For shared connections, reuse cache instances with the same key prefix
|
||||
if (defaultOptions.shared) {
|
||||
const cacheKey = `${defaultOptions.keyPrefix}-${defaultOptions.ttl}`;
|
||||
|
||||
if (cacheInstances.has(cacheKey)) {
|
||||
const cachedInstance = cacheInstances.get(cacheKey);
|
||||
if (!cachedInstance) {
|
||||
throw new Error(`Expected cache instance ${cacheKey} to exist`);
|
||||
}
|
||||
return cachedInstance;
|
||||
}
|
||||
|
||||
const cache = new RedisCache(defaultOptions);
|
||||
cacheInstances.set(cacheKey, cache);
|
||||
return cache;
|
||||
}
|
||||
|
||||
// For non-shared connections, always create new instances
|
||||
return new RedisCache(defaultOptions);
|
||||
}
|
||||
|
||||
// Export types and classes
|
||||
export type {
|
||||
CacheConfig,
|
||||
CacheKey,
|
||||
CacheOptions,
|
||||
CacheProvider,
|
||||
CacheStats,
|
||||
RedisConfig,
|
||||
SerializationOptions,
|
||||
} from './types';
|
||||
|
||||
export { RedisConnectionManager } from './connection-manager';
|
||||
export { CacheKeyGenerator } from './key-generator';
|
||||
export { RedisCache } from './redis-cache';
|
||||
export { NamespacedCache } from './namespaced-cache';
|
||||
export { createNamespacedCache, isCacheAvailable } from './cache-factory';
|
||||
73
libs/data/cache/src/key-generator.ts
vendored
73
libs/data/cache/src/key-generator.ts
vendored
|
|
@ -1,73 +0,0 @@
|
|||
export class CacheKeyGenerator {
|
||||
/**
|
||||
* Generate cache key for market data
|
||||
*/
|
||||
static marketData(symbol: string, timeframe: string, date?: Date): string {
|
||||
const dateStr = date ? date.toISOString().split('T')[0] : 'latest';
|
||||
return `market:${symbol.toLowerCase()}:${timeframe}:${dateStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for technical indicators
|
||||
*/
|
||||
static indicator(symbol: string, indicator: string, period: number, dataHash: string): string {
|
||||
return `indicator:${symbol.toLowerCase()}:${indicator}:${period}:${dataHash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for backtest results
|
||||
*/
|
||||
static backtest(strategyName: string, params: Record<string, unknown>): string {
|
||||
const paramHash = this.hashObject(params);
|
||||
return `backtest:${strategyName}:${paramHash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for strategy results
|
||||
*/
|
||||
static strategy(strategyName: string, symbol: string, timeframe: string): string {
|
||||
return `strategy:${strategyName}:${symbol.toLowerCase()}:${timeframe}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for user sessions
|
||||
*/
|
||||
static userSession(userId: string): string {
|
||||
return `session:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for portfolio data
|
||||
*/
|
||||
static portfolio(userId: string, portfolioId: string): string {
|
||||
return `portfolio:${userId}:${portfolioId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for real-time prices
|
||||
*/
|
||||
static realtimePrice(symbol: string): string {
|
||||
return `price:realtime:${symbol.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for order book data
|
||||
*/
|
||||
static orderBook(symbol: string, depth: number = 10): string {
|
||||
return `orderbook:${symbol.toLowerCase()}:${depth}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple hash from object for cache keys
|
||||
*/
|
||||
private static hashObject(obj: Record<string, unknown>): string {
|
||||
const str = JSON.stringify(obj, Object.keys(obj).sort());
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
}
|
||||
89
libs/data/cache/src/namespaced-cache.ts
vendored
89
libs/data/cache/src/namespaced-cache.ts
vendored
|
|
@ -1,89 +0,0 @@
|
|||
import type { CacheProvider } from './types';
|
||||
|
||||
/**
|
||||
* A cache wrapper that automatically prefixes all keys with a namespace
|
||||
* Used to provide isolated cache spaces for different services
|
||||
*/
|
||||
export class NamespacedCache implements CacheProvider {
|
||||
private readonly prefix: string;
|
||||
|
||||
constructor(
|
||||
private readonly cache: CacheProvider,
|
||||
private readonly namespace: string
|
||||
) {
|
||||
this.prefix = `cache:${namespace}:`;
|
||||
}
|
||||
|
||||
async get<T = any>(key: string): Promise<T | null> {
|
||||
return this.cache.get(`${this.prefix}${key}`);
|
||||
}
|
||||
|
||||
async set<T>(
|
||||
key: string,
|
||||
value: T,
|
||||
options?:
|
||||
| number
|
||||
| {
|
||||
ttl?: number;
|
||||
preserveTTL?: boolean;
|
||||
onlyIfExists?: boolean;
|
||||
onlyIfNotExists?: boolean;
|
||||
getOldValue?: boolean;
|
||||
}
|
||||
): Promise<T | null> {
|
||||
return this.cache.set(`${this.prefix}${key}`, value, options);
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
return this.cache.del(`${this.prefix}${key}`);
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
return this.cache.exists(`${this.prefix}${key}`);
|
||||
}
|
||||
|
||||
async keys(pattern: string = '*'): Promise<string[]> {
|
||||
const fullPattern = `${this.prefix}${pattern}`;
|
||||
const keys = await this.cache.keys(fullPattern);
|
||||
// Remove the prefix from returned keys for cleaner API
|
||||
return keys.map(k => k.substring(this.prefix.length));
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
// Clear only keys with this namespace prefix
|
||||
const keys = await this.cache.keys(`${this.prefix}*`);
|
||||
if (keys.length > 0) {
|
||||
await Promise.all(keys.map(key => this.cache.del(key)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getStats() {
|
||||
return this.cache.getStats();
|
||||
}
|
||||
|
||||
async health(): Promise<boolean> {
|
||||
return this.cache.health();
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.cache.isReady();
|
||||
}
|
||||
|
||||
async waitForReady(timeout?: number): Promise<void> {
|
||||
return this.cache.waitForReady(timeout);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// Namespaced cache doesn't own the connection, so we don't close it
|
||||
// The underlying cache instance should be closed by its owner
|
||||
}
|
||||
|
||||
getNamespace(): string {
|
||||
return this.namespace;
|
||||
}
|
||||
|
||||
getFullPrefix(): string {
|
||||
return this.prefix;
|
||||
}
|
||||
}
|
||||
438
libs/data/cache/src/redis-cache.ts
vendored
438
libs/data/cache/src/redis-cache.ts
vendored
|
|
@ -1,438 +0,0 @@
|
|||
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'
|
||||
);
|
||||
}
|
||||
|
||||
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'
|
||||
);
|
||||
}
|
||||
}
|
||||
113
libs/data/cache/src/types.ts
vendored
113
libs/data/cache/src/types.ts
vendored
|
|
@ -1,113 +0,0 @@
|
|||
export interface RedisConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
password?: string;
|
||||
username?: string;
|
||||
db?: number;
|
||||
keyPrefix?: string;
|
||||
maxRetriesPerRequest?: number;
|
||||
retryDelayOnFailover?: number;
|
||||
connectTimeout?: number;
|
||||
commandTimeout?: number;
|
||||
keepAlive?: number;
|
||||
tls?: {
|
||||
cert?: string;
|
||||
key?: string;
|
||||
ca?: string;
|
||||
rejectUnauthorized?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CacheProvider {
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set<T>(
|
||||
key: string,
|
||||
value: T,
|
||||
options?:
|
||||
| number
|
||||
| {
|
||||
ttl?: number;
|
||||
preserveTTL?: boolean;
|
||||
onlyIfExists?: boolean;
|
||||
onlyIfNotExists?: boolean;
|
||||
getOldValue?: boolean;
|
||||
}
|
||||
): Promise<T | null>;
|
||||
del(key: string): Promise<void>;
|
||||
exists(key: string): Promise<boolean>;
|
||||
clear(): Promise<void>;
|
||||
keys(pattern: string): Promise<string[]>;
|
||||
getStats(): CacheStats;
|
||||
health(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Wait for the cache to be ready and connected
|
||||
* @param timeout Maximum time to wait in milliseconds (default: 5000)
|
||||
* @returns Promise that resolves when cache is ready
|
||||
*/
|
||||
waitForReady(timeout?: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if the cache is currently ready
|
||||
*/
|
||||
isReady(): boolean;
|
||||
|
||||
// Enhanced cache methods
|
||||
/**
|
||||
* Update value preserving existing TTL
|
||||
*/
|
||||
update?<T>(key: string, value: T): Promise<T | null>;
|
||||
|
||||
/**
|
||||
* Set value only if key exists
|
||||
*/
|
||||
setIfExists?<T>(key: string, value: T, ttl?: number): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Set value only if key doesn't exist
|
||||
*/
|
||||
setIfNotExists?<T>(key: string, value: T, ttl?: number): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Replace existing key's value and TTL
|
||||
*/
|
||||
replace?<T>(key: string, value: T, ttl?: number): Promise<T | null>;
|
||||
/**
|
||||
* Atomically update field with transformation function
|
||||
*/
|
||||
updateField?<T>(key: string, updater: (current: T | null) => T, ttl?: number): Promise<T | null>;
|
||||
}
|
||||
|
||||
export interface CacheOptions {
|
||||
ttl?: number;
|
||||
keyPrefix?: string;
|
||||
enableMetrics?: boolean;
|
||||
name?: string; // Name for connection identification
|
||||
shared?: boolean; // Whether to use shared connection
|
||||
redisConfig: RedisConfig;
|
||||
logger?: any; // Optional logger instance
|
||||
}
|
||||
|
||||
export interface CacheStats {
|
||||
hits: number;
|
||||
misses: number;
|
||||
errors: number;
|
||||
hitRate: number;
|
||||
total: number;
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
export interface CacheConfig {
|
||||
type: 'redis';
|
||||
keyPrefix?: string;
|
||||
defaultTTL?: number;
|
||||
enableMetrics?: boolean;
|
||||
compression?: boolean;
|
||||
}
|
||||
|
||||
export type CacheKey = string | (() => string);
|
||||
|
||||
export interface SerializationOptions {
|
||||
compress?: boolean;
|
||||
binary?: boolean;
|
||||
}
|
||||
10
libs/data/cache/tsconfig.json
vendored
10
libs/data/cache/tsconfig.json
vendored
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"references": [{ "path": "../../core/logger" }]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue