OS Trading Engine
Technical Documentation
Backend
Infrastructure
Caching (Redis)

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 warming

Cache Key Patterns

All keys use a consistent naming pattern: {entity}:{identifier}:{subkey}

Key PatternDataTTL
balance:{agentId}:{wallet}:{token}Balance recordNone (write-through)
position:{agentId}:{wallet}:{token}Position recordNone (write-through)
price:{tokenAddress}Price data (SOL, USD)60s
agent:{agentId}Agent record300s
config:{agentId}Trading config300s
idempotency:{key}Deduplication60s
lock:{resource}Distributed lock5s
token:blacklist:{jti}Revoked JWTUntil 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,
};