diff --git a/docs/enhanced-cache-usage.md b/docs/enhanced-cache-usage.md new file mode 100644 index 0000000..c01a13f --- /dev/null +++ b/docs/enhanced-cache-usage.md @@ -0,0 +1,148 @@ +# Enhanced Cache Provider Usage + +The Redis cache provider now supports advanced TTL handling and conditional operations. + +## Basic Usage (Backward Compatible) + +```typescript +import { RedisCache } from '@stock-bot/cache'; + +const cache = new RedisCache({ + keyPrefix: 'trading:', + defaultTTL: 3600 // 1 hour +}); + +// Simple set with TTL (old way - still works) +await cache.set('user:123', userData, 1800); // 30 minutes + +// Simple get +const user = await cache.get('user:123'); +``` + +## Enhanced Set Options + +```typescript +// Preserve existing TTL when updating +await cache.set('user:123', updatedUserData, { preserveTTL: true }); + +// Only set if key exists (update operation) +const oldValue = await cache.set('user:123', newData, { + onlyIfExists: true, + getOldValue: true +}); + +// Only set if key doesn't exist (create operation) +await cache.set('user:456', newUser, { + onlyIfNotExists: true, + ttl: 7200 // 2 hours +}); + +// Get old value when setting new one +const previousData = await cache.set('session:abc', sessionData, { + getOldValue: true, + ttl: 1800 +}); +``` + +## Convenience Methods + +```typescript +// Update value preserving TTL +await cache.update('user:123', updatedUserData); + +// Set only if exists +const updated = await cache.setIfExists('user:123', newData, 3600); + +// Set only if not exists (returns true if created) +const created = await cache.setIfNotExists('user:456', userData); + +// Replace existing key with new TTL +const oldData = await cache.replace('user:123', newData, 7200); + +// Atomic field updates +await cache.updateField('counter:views', (current) => (current || 0) + 1); + +await cache.updateField('user:123', (user) => ({ + ...user, + lastSeen: new Date().toISOString(), + loginCount: (user?.loginCount || 0) + 1 +})); +``` + +## Stock Bot Use Cases + +### 1. Rate Limiting +```typescript +// Only create rate limit if not exists +const rateLimited = await cache.setIfNotExists( + `ratelimit:${userId}:${endpoint}`, + { count: 1, resetTime: Date.now() + 60000 }, + 60 // 1 minute +); + +if (!rateLimited) { + // Increment existing counter + await cache.updateField(`ratelimit:${userId}:${endpoint}`, (data) => ({ + ...data, + count: data.count + 1 + })); +} +``` + +### 2. Session Management +```typescript +// Update session data without changing expiration +await cache.update(`session:${sessionId}`, { + ...sessionData, + lastActivity: Date.now() +}); +``` + +### 3. Cache Warming +```typescript +// Only update existing cached data, don't create new entries +const warmed = await cache.setIfExists(`stock:${symbol}:price`, latestPrice); +if (warmed) { + console.log(`Warmed cache for ${symbol}`); +} +``` + +### 4. Atomic Counters +```typescript +// Thread-safe counter increments +await cache.updateField('metrics:api:calls', (count) => (count || 0) + 1); +await cache.updateField('metrics:errors:500', (count) => (count || 0) + 1); +``` + +### 5. TTL Preservation for Frequently Updated Data +```typescript +// Keep original expiration when updating frequently changing data +await cache.set(`portfolio:${userId}:positions`, positions, { preserveTTL: true }); +``` + +## Error Handling + +The cache provider includes robust error handling: + +```typescript +try { + await cache.set('key', value); +} catch (error) { + // Errors are logged and fallback values returned + // The cache operations are non-blocking +} + +// Check cache health +const isHealthy = await cache.health(); + +// Wait for cache to be ready +await cache.waitForReady(10000); // 10 second timeout +``` + +## Performance Benefits + +1. **Atomic Operations**: `updateField` uses Lua scripts to prevent race conditions +2. **TTL Preservation**: Avoids unnecessary TTL resets on updates +3. **Conditional Operations**: Reduces network round trips +4. **Shared Connections**: Efficient connection pooling +5. **Error Recovery**: Graceful degradation when Redis is unavailable diff --git a/libs/cache/src/redis-cache.ts b/libs/cache/src/redis-cache.ts index b4ed4c0..12cb206 100644 --- a/libs/cache/src/redis-cache.ts +++ b/libs/cache/src/redis-cache.ts @@ -144,17 +144,92 @@ export class RedisCache implements CacheProvider { ); } - async set(key: string, value: T, ttl?: number): Promise { - await this.safeExecute( + async set(key: string, value: T, options?: number | { + ttl?: number; + preserveTTL?: boolean; + onlyIfExists?: boolean; + onlyIfNotExists?: boolean; + getOldValue?: boolean; + }): Promise { + return this.safeExecute( async () => { const fullKey = this.getKey(key); - const expiry = ttl ?? this.defaultTTL; const serialized = typeof value === 'string' ? value : JSON.stringify(value); - await this.redis.setex(fullKey, expiry, serialized); - this.logger.debug('Cache set', { key, ttl: expiry }); + // 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; }, - undefined, + null, 'set' ); } @@ -251,4 +326,79 @@ export class RedisCache implements CacheProvider { return ready; } + + // Enhanced convenience methods + async update(key: string, value: T): Promise { + return this.set(key, value, { preserveTTL: true, getOldValue: true }); + } + + async setIfExists(key: string, value: T, ttl?: number): Promise { + const result = await this.set(key, value, { ttl, onlyIfExists: true }); + return result !== null || await this.exists(key); + } + + async setIfNotExists(key: string, value: T, ttl?: number): Promise { + 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(key: string, value: T, ttl?: number): Promise { + return this.set(key, value, { ttl, onlyIfExists: true, getOldValue: true }); + } + + // Atomic update with transformation + async updateField(key: string, updater: (current: T | null) => T, ttl?: number): Promise { + 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' + ); + } } diff --git a/libs/cache/src/types.ts b/libs/cache/src/types.ts index bfebc10..9e9060c 100644 --- a/libs/cache/src/types.ts +++ b/libs/cache/src/types.ts @@ -1,6 +1,12 @@ export interface CacheProvider { get(key: string): Promise; - set(key: string, value: T, ttl?: number): Promise; + set(key: string, value: T, options?: number | { + ttl?: number; + preserveTTL?: boolean; + onlyIfExists?: boolean; + onlyIfNotExists?: boolean; + getOldValue?: boolean; + }): Promise; del(key: string): Promise; exists(key: string): Promise; clear(): Promise; @@ -18,6 +24,31 @@ export interface CacheProvider { * Check if the cache is currently ready */ isReady(): boolean; + + // Enhanced cache methods + /** + * Update value preserving existing TTL + */ + update?(key: string, value: T): Promise; + + /** + * Set value only if key exists + */ + setIfExists?(key: string, value: T, ttl?: number): Promise; + + /** + * Set value only if key doesn't exist + */ + setIfNotExists?(key: string, value: T, ttl?: number): Promise; + + /** + * Replace existing key's value and TTL + */ + replace?(key: string, value: T, ttl?: number): Promise; + /** + * Atomically update field with transformation function + */ + updateField?(key: string, updater: (current: T | null) => T, ttl?: number): Promise; } export interface CacheOptions {