Caching (Redis)
Nexgent uses Redis for caching and distributed locking. The caching strategy prioritizes consistency through write-through patterns.
Redis Services
src/infrastructure/cache/
├── redis-client.ts # Core Redis client (ioredis)
├── redis-balance-service.ts # Balance caching
├── redis-position-service.ts# Position caching
├── redis-price-service.ts # Token price caching
├── redis-config-service.ts # Agent config caching
├── redis-agent-service.ts # Agent state caching
├── redis-token-service.ts # Token blacklist (auth)
├── idempotency-service.ts # Deduplication keys
└── cache-warmer.ts # Startup cache warmingCache Key Patterns
All keys use a consistent naming pattern: {entity}:{identifier}:{subkey}
| Key Pattern | Data | TTL |
|---|---|---|
balance:{agentId}:{wallet}:{token} | Balance record | None (write-through) |
position:{agentId}:{wallet}:{token} | Position record | None (write-through) |
price:{tokenAddress} | Price data (SOL, USD) | 60s |
agent:{agentId} | Agent record | 300s |
config:{agentId} | Trading config | 300s |
idempotency:{key} | Deduplication | 60s |
lock:{resource} | Distributed lock | 5s |
token:blacklist:{jti} | Revoked JWT | Until expiry |
// src/shared/constants/redis-keys.ts
export const REDIS_KEYS = {
BALANCE: (agentId: string, walletAddress: string, tokenAddress: string) =>
`balance:${agentId}:${walletAddress}:${tokenAddress}`,
POSITION: (agentId: string, walletAddress: string, tokenAddress: string) =>
`position:${agentId}:${walletAddress}:${tokenAddress}`,
PRICE: (tokenAddress: string) =>
`price:${tokenAddress}`,
AGENT: (agentId: string) =>
`agent:${agentId}`,
CONFIG: (agentId: string) =>
`config:${agentId}`,
IDEMPOTENCY: (key: string) =>
`idempotency:${key}`,
LOCK: (resource: string) =>
`lock:${resource}`,
};Write-Through Pattern
All critical data uses write-through caching to ensure consistency.
Write Request
│
▼
┌─────────────────┐
│ Database Write │ ← Source of truth
│ (Prisma) │
└────────┬────────┘
│ commit
▼
┌─────────────────┐
│ Redis Update │ ← Cache updated after DB commit
└────────┬────────┘
│
▼
┌─────────────────┐
│ WebSocket Event │
└─────────────────┘Balance Cache Example
// Write-through: DB first, then Redis
async upsertBalance(
walletAddress: string,
agentId: string,
tokenAddress: string,
tokenSymbol: string,
delta: Decimal,
tx?: Prisma.TransactionClient
): Promise<Decimal> {
// 1. Update database (source of truth)
const dbBalance = await this.balanceRepo.update(existingBalance.id, {
balance: newBalance.toString(),
}, tx);
// 2. Update Redis cache (only if not in transaction)
if (!tx) {
await redisBalanceService.setBalance({
id: dbBalance.id,
agentId: dbBalance.agentId,
walletAddress: dbBalance.walletAddress,
tokenAddress: dbBalance.tokenAddress,
tokenSymbol: dbBalance.tokenSymbol,
balance: dbBalance.balance,
lastUpdated: dbBalance.lastUpdated,
});
}
return newBalance;
}When inside a database transaction, Redis updates are deferred until after the transaction commits to prevent cache inconsistency on rollback.
Redis Client
Singleton client using ioredis with connection management:
// src/infrastructure/cache/redis-client.ts
export class RedisService {
private client: Redis;
constructor() {
this.client = new Redis({
host: redisConfig.host,
port: redisConfig.port,
password: redisConfig.password,
db: redisConfig.db,
keyPrefix: redisConfig.keyPrefix,
retryStrategy: (times) => {
if (times > redisConfig.maxRetries) return null;
return Math.min(times * 500, 2000); // Exponential backoff
},
lazyConnect: true,
});
}
async get(key: string): Promise<string | null>;
async set(key: string, value: string, ttlSeconds?: number): Promise<void>;
async del(key: string): Promise<void>;
async exists(key: string): Promise<boolean>;
}Distributed Locking
Redis provides distributed locks for preventing concurrent operations:
// Acquire lock with TTL
async acquireLock(key: string, ttlSeconds: number): Promise<string | null> {
const lockToken = randomUUID();
const result = await this.client.set(key, lockToken, 'EX', ttlSeconds, 'NX');
return result === 'OK' ? lockToken : null;
}
// Release lock (only if we own it)
async releaseLock(key: string, lockToken: string): Promise<void> {
const luaScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
await this.client.eval(luaScript, 1, key, lockToken);
}Lock Usage Example
// Stop loss evaluation uses distributed lock
async evaluateStopLoss(position, currentPrice, config) {
const lockKey = REDIS_KEYS.LOCK(`stop-loss:${position.id}`);
const lockToken = await redisService.acquireLock(lockKey, REDIS_TTL.LOCK);
if (!lockToken) {
// Another evaluation in progress, skip
return { shouldTrigger: false, updated: false };
}
try {
// Safe to evaluate - we have the lock
// ...
} finally {
await redisService.releaseLock(lockKey, lockToken);
}
}Idempotency Service
Prevents duplicate operations (like duplicate trade executions):
// src/infrastructure/cache/idempotency-service.ts
class IdempotencyService {
async checkAndSet(key: string, ttlSeconds: number): Promise<boolean> {
const fullKey = REDIS_KEYS.IDEMPOTENCY(key);
// SET NX returns OK only if key doesn't exist
const result = await redisService.getClient().set(
fullKey, '1', 'EX', ttlSeconds, 'NX'
);
return result === 'OK'; // true = can proceed, false = duplicate
}
async clear(key: string): Promise<void> {
await redisService.del(REDIS_KEYS.IDEMPOTENCY(key));
}
}Usage
// Prevent duplicate sales
const saleKey = `sale:${request.positionId}`;
const canProceed = await idempotencyService.checkAndSet(saleKey, 60);
if (!canProceed) {
throw new TradingExecutorError('Position is already being sold');
}Cache Warming
On startup, critical data is preloaded into Redis:
// src/infrastructure/cache/cache-warmer.ts
class CacheWarmer {
async warmup(): Promise<void> {
console.log('🔥 Warming up cache...');
// Load all agents
const agents = await prisma.agent.findMany({ include: { wallets: true } });
for (const agent of agents) {
// Cache agent
await redisAgentService.setAgent(agent);
// Cache agent config
const config = this.mergeWithDefaults(agent.tradingConfig);
await redisConfigService.setAgentConfig(agent.id, config);
// Cache balances for each wallet
for (const wallet of agent.wallets) {
const balances = await prisma.agentBalance.findMany({
where: { walletAddress: wallet.walletAddress },
});
for (const balance of balances) {
await redisBalanceService.setBalance(balance);
}
// Cache positions
const positions = await prisma.agentPosition.findMany({
where: { agentId: agent.id, walletAddress: wallet.walletAddress },
});
for (const position of positions) {
await redisPositionService.setPosition(position);
}
}
}
console.log('✅ Cache warmup complete');
}
}Price Caching
Token prices are cached with short TTL (60s) since they change frequently:
// src/infrastructure/cache/redis-price-service.ts
class RedisPriceService {
async setPrice(tokenAddress: string, price: { priceSol: number; priceUsd: number; lastUpdated: Date }): Promise<void> {
const key = REDIS_KEYS.PRICE(tokenAddress.toLowerCase());
await redisService.set(key, JSON.stringify(price), REDIS_TTL.PRICE);
}
async getPrice(tokenAddress: string): Promise<CachedPrice | null> {
const key = REDIS_KEYS.PRICE(tokenAddress.toLowerCase());
const data = await redisService.get(key);
return data ? JSON.parse(data) : null;
}
async getMultiplePrices(tokenAddresses: string[]): Promise<Map<string, CachedPrice | null>> {
const pipeline = redisService.getClient().pipeline();
const normalizedAddresses = tokenAddresses.map(a => a.toLowerCase());
for (const addr of normalizedAddresses) {
pipeline.get(REDIS_KEYS.PRICE(addr));
}
const results = await pipeline.exec();
// ... process results
}
}Configuration
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0// src/config/redis.config.ts
export const redisConfig = {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || undefined,
db: parseInt(process.env.REDIS_DB || '0', 10),
keyPrefix: 'nexgent:',
maxRetries: 3,
};