updated cache to support setting ttl
This commit is contained in:
parent
a49cb9ae98
commit
d6ca9fe93c
3 changed files with 336 additions and 7 deletions
148
docs/enhanced-cache-usage.md
Normal file
148
docs/enhanced-cache-usage.md
Normal file
|
|
@ -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<UserData>('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
|
||||||
162
libs/cache/src/redis-cache.ts
vendored
162
libs/cache/src/redis-cache.ts
vendored
|
|
@ -144,17 +144,92 @@ export class RedisCache implements CacheProvider {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
async set<T>(key: string, value: T, options?: number | {
|
||||||
await this.safeExecute(
|
ttl?: number;
|
||||||
|
preserveTTL?: boolean;
|
||||||
|
onlyIfExists?: boolean;
|
||||||
|
onlyIfNotExists?: boolean;
|
||||||
|
getOldValue?: boolean;
|
||||||
|
}): Promise<T | null> {
|
||||||
|
return this.safeExecute(
|
||||||
async () => {
|
async () => {
|
||||||
const fullKey = this.getKey(key);
|
const fullKey = this.getKey(key);
|
||||||
const expiry = ttl ?? this.defaultTTL;
|
|
||||||
const serialized = typeof value === 'string' ? value : JSON.stringify(value);
|
const serialized = typeof value === 'string' ? value : JSON.stringify(value);
|
||||||
|
|
||||||
await this.redis.setex(fullKey, expiry, serialized);
|
// Handle backward compatibility - if options is a number, treat as TTL
|
||||||
this.logger.debug('Cache set', { key, ttl: expiry });
|
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'
|
'set'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -251,4 +326,79 @@ export class RedisCache implements CacheProvider {
|
||||||
|
|
||||||
return 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
33
libs/cache/src/types.ts
vendored
33
libs/cache/src/types.ts
vendored
|
|
@ -1,6 +1,12 @@
|
||||||
export interface CacheProvider {
|
export interface CacheProvider {
|
||||||
get<T>(key: string): Promise<T | null>;
|
get<T>(key: string): Promise<T | null>;
|
||||||
set<T>(key: string, value: T, ttl?: number): Promise<void>;
|
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>;
|
del(key: string): Promise<void>;
|
||||||
exists(key: string): Promise<boolean>;
|
exists(key: string): Promise<boolean>;
|
||||||
clear(): Promise<void>;
|
clear(): Promise<void>;
|
||||||
|
|
@ -18,6 +24,31 @@ export interface CacheProvider {
|
||||||
* Check if the cache is currently ready
|
* Check if the cache is currently ready
|
||||||
*/
|
*/
|
||||||
isReady(): boolean;
|
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 {
|
export interface CacheOptions {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue