Files
teatea-pension/.trellis/spec/backend/performance.md

11 KiB

Performance Patterns

This document covers performance optimization patterns for backend development.

Parallel Execution with Promise.all

When operations are independent, execute them in parallel.

// BAD - Sequential execution (slow)
const user = await getUser(userId);
const orders = await getOrders(userId);
const preferences = await getPreferences(userId);

// GOOD - Parallel execution
const [user, orders, preferences] = await Promise.all([
  getUser(userId),
  getOrders(userId),
  getPreferences(userId),
]);

Promise.allSettled for Partial Failures

When some operations can fail without blocking others:

const results = await Promise.allSettled([
  processOrderA(),
  processOrderB(),
  processOrderC(),
]);

const successful = results
  .filter((r): r is PromiseFulfilledResult<Order> => r.status === "fulfilled")
  .map(r => r.value);

const failed = results
  .filter((r): r is PromiseRejectedResult => r.status === "rejected")
  .map(r => r.reason);

logger.info("Batch processing complete", {
  successful: successful.length,
  failed: failed.length,
});

Concurrency Control with p-limit

When calling external APIs, limit concurrent requests to avoid rate limiting.

import pLimit from "p-limit";

// Create limiter with max 20 concurrent requests
const limit = pLimit(20);

const orderIds = ["order1", "order2", /* ... hundreds more */];

// Process all with controlled concurrency
const results = await Promise.all(
  orderIds.map(orderId =>
    limit(() => fetchOrderDetails(orderId))
  )
);

Shared Limiter Pattern

For module-wide concurrency control:

// lib/api-client.ts
import pLimit from "p-limit";

// External API concurrency limit
const API_CONCURRENCY = 20;

export function createApiLimiter(): ReturnType<typeof pLimit> {
  return pLimit(API_CONCURRENCY);
}

// Usage in procedure
const limiter = createApiLimiter();

const results = await Promise.allSettled(
  items.map(item =>
    limiter(async () => {
      try {
        const result = await externalApi.process(item);
        return { itemId: item.id, success: true, result };
      } catch (error) {
        return {
          itemId: item.id,
          success: false,
          error: error instanceof Error ? error.message : "Unknown error"
        };
      }
    })
  )
);

Rate Limit Retry with Exponential Backoff

Handle rate limits gracefully with automatic retry.

const MAX_RETRIES = 3;

async function fetchWithRetry<T>(
  fn: () => Promise<T>,
  context: { operation: string; itemId: string }
): Promise<T> {
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
    try {
      return await fn();
    } catch (error: any) {
      const isRateLimited = error?.code === 429 || error?.status === 429;

      if (isRateLimited && attempt < MAX_RETRIES) {
        // Exponential backoff: 2^attempt seconds + random jitter
        const delay = 2 ** attempt * 1000 + Math.random() * 1000;

        logger.warn("Rate limited, retrying", {
          operation: context.operation,
          itemId: context.itemId,
          attempt,
          delay: Math.round(delay),
        });

        await new Promise(resolve => setTimeout(resolve, delay));
        continue;
      }

      throw error;
    }
  }

  throw new Error(`Failed after ${MAX_RETRIES} attempts`);
}

// Usage
const result = await fetchWithRetry(
  () => externalApi.getResource(resourceId),
  { operation: "getResource", itemId: resourceId }
);

Backoff Configuration

interface RetryConfig {
  maxRetries: number;
  baseDelay: number;      // Base delay in ms
  maxDelay: number;       // Maximum delay cap
  jitterFactor: number;   // Random jitter (0-1)
}

const defaultConfig: RetryConfig = {
  maxRetries: 3,
  baseDelay: 1000,
  maxDelay: 30000,
  jitterFactor: 0.5,
};

function calculateDelay(attempt: number, config: RetryConfig): number {
  const exponentialDelay = config.baseDelay * 2 ** (attempt - 1);
  const cappedDelay = Math.min(exponentialDelay, config.maxDelay);
  const jitter = cappedDelay * config.jitterFactor * Math.random();
  return cappedDelay + jitter;
}

Redis Caching (Cache-Aside Pattern)

Implement caching for expensive operations.

import { redis } from "../../../lib/redis";
import { SpanPrefix, span } from "../../../lib/tracer";

const CACHE_TTL = 3600; // 1 hour in seconds

interface CachedUserProfile {
  id: string;
  name: string;
  preferences: Record<string, unknown>;
}

async function getUserProfile(userId: string): Promise<CachedUserProfile> {
  const cacheKey = `user:profile:${userId}`;

  // 1. Try cache first
  const cached = await span(
    `${SpanPrefix.Redis}GetUserProfile`,
    async () => {
      const data = await redis.get<string>(cacheKey);
      return data ? JSON.parse(data) as CachedUserProfile : null;
    },
    { userId }
  );

  if (cached) {
    return cached;
  }

  // 2. Cache miss - fetch from database
  const profile = await span(
    `${SpanPrefix.DB}FetchUserProfile`,
    () => db.query.user.findFirst({
      where: eq(userTable.id, userId),
      with: { preferences: true },
    }),
    { userId }
  );

  if (!profile) {
    throw new ORPCError("NOT_FOUND", { message: "User not found" });
  }

  const cacheValue: CachedUserProfile = {
    id: profile.id,
    name: profile.name,
    preferences: profile.preferences,
  };

  // 3. Store in cache
  await span(
    `${SpanPrefix.Redis}SetUserProfile`,
    () => redis.set(cacheKey, JSON.stringify(cacheValue), { ex: CACHE_TTL }),
    { userId }
  );

  return cacheValue;
}

Cache Invalidation

async function updateUserProfile(
  userId: string,
  updates: Partial<UserProfile>
): Promise<void> {
  // 1. Update database
  await db.update(userTable)
    .set(updates)
    .where(eq(userTable.id, userId));

  // 2. Invalidate cache
  const cacheKey = `user:profile:${userId}`;
  await redis.del(cacheKey);

  logger.info("User profile updated and cache invalidated", { userId });
}

Cache Key Patterns

// User-specific data
`user:profile:${userId}`
`user:settings:${userId}`
`user:orders:${userId}:page:${page}`

// Resource-specific data
`product:${productId}`
`inventory:${warehouseId}:${productId}`

// Aggregated data
`stats:daily:${date}`
`leaderboard:${category}`

Background Tasks with Distributed Locks

Prevent duplicate processing in distributed environments.

const LOCK_KEY = "task:process-orders";
const LOCK_TTL = 300; // 5 minutes

async function processScheduledOrders(): Promise<void> {
  // 1. Try to acquire lock
  const lockResult = await redis.set(LOCK_KEY, Date.now(), {
    ex: LOCK_TTL,
    nx: true, // Only set if not exists
  });

  if (!lockResult) {
    logger.info("Another instance is processing orders, skipping");
    return;
  }

  try {
    // 2. Process with lock held
    logger.info("Acquired lock, processing scheduled orders");

    const pendingOrders = await db
      .select()
      .from(orderTable)
      .where(and(
        eq(orderTable.status, "SCHEDULED"),
        lte(orderTable.scheduledAt, new Date())
      ))
      .limit(100);

    for (const order of pendingOrders) {
      await processOrder(order);
    }

    logger.info("Scheduled orders processed", {
      count: pendingOrders.length
    });
  } finally {
    // 3. Release lock
    await redis.del(LOCK_KEY);
  }
}

Lock with Heartbeat

For long-running tasks, extend the lock periodically:

async function processLongRunningTask(): Promise<void> {
  const LOCK_KEY = "task:long-running";
  const LOCK_TTL = 30;
  const HEARTBEAT_INTERVAL = 10000; // 10 seconds

  const lockResult = await redis.set(LOCK_KEY, Date.now(), {
    ex: LOCK_TTL,
    nx: true,
  });

  if (!lockResult) {
    return;
  }

  // Heartbeat to extend lock
  const heartbeat = setInterval(async () => {
    await redis.expire(LOCK_KEY, LOCK_TTL);
  }, HEARTBEAT_INTERVAL);

  try {
    await doExpensiveWork();
  } finally {
    clearInterval(heartbeat);
    await redis.del(LOCK_KEY);
  }
}

Batch Processing Patterns

Chunked Processing

For large datasets, process in chunks:

const CHUNK_SIZE = 100;

async function processAllOrders(orderIds: string[]): Promise<void> {
  // Split into chunks
  const chunks: string[][] = [];
  for (let i = 0; i < orderIds.length; i += CHUNK_SIZE) {
    chunks.push(orderIds.slice(i, i + CHUNK_SIZE));
  }

  logger.info("Processing orders in chunks", {
    totalOrders: orderIds.length,
    chunkCount: chunks.length,
    chunkSize: CHUNK_SIZE,
  });

  for (let i = 0; i < chunks.length; i++) {
    const chunk = chunks[i];
    if (!chunk) continue;

    await processOrderChunk(chunk);

    logger.info("Chunk processed", {
      chunkIndex: i + 1,
      totalChunks: chunks.length,
    });
  }
}

async function processOrderChunk(orderIds: string[]): Promise<void> {
  // Batch database query
  const orders = await db
    .select()
    .from(orderTable)
    .where(inArray(orderTable.id, orderIds));

  // Parallel processing with concurrency limit
  const limiter = pLimit(10);

  await Promise.all(
    orders.map(order => limiter(() => processOrder(order)))
  );
}

Progress Reporting

Track and report progress for long operations:

interface ProgressTracker {
  total: number;
  processed: number;
  failed: number;
  startTime: number;
}

async function batchProcessWithProgress(
  items: string[],
  progressCallback?: (progress: ProgressTracker) => void
): Promise<void> {
  const progress: ProgressTracker = {
    total: items.length,
    processed: 0,
    failed: 0,
    startTime: Date.now(),
  };

  const UPDATE_INTERVAL = 20; // Report every 20 items

  for (const item of items) {
    try {
      await processItem(item);
      progress.processed++;
    } catch {
      progress.failed++;
    }

    // Report progress periodically
    if ((progress.processed + progress.failed) % UPDATE_INTERVAL === 0) {
      progressCallback?.(progress);

      logger.info("Batch progress", {
        processed: progress.processed,
        failed: progress.failed,
        total: progress.total,
        elapsedMs: Date.now() - progress.startTime,
      });
    }
  }
}

Memory Optimization

Streaming Large Datasets

For very large datasets, use streaming:

async function* streamOrders(userId: string): AsyncGenerator<Order> {
  let cursor: string | undefined;
  const PAGE_SIZE = 100;

  while (true) {
    const orders = await db
      .select()
      .from(orderTable)
      .where(and(
        eq(orderTable.userId, userId),
        cursor ? gt(orderTable.id, cursor) : undefined
      ))
      .orderBy(asc(orderTable.id))
      .limit(PAGE_SIZE);

    if (orders.length === 0) {
      break;
    }

    for (const order of orders) {
      yield order;
    }

    const lastOrder = orders[orders.length - 1];
    cursor = lastOrder?.id;

    if (orders.length < PAGE_SIZE) {
      break;
    }
  }
}

// Usage
for await (const order of streamOrders(userId)) {
  await processOrder(order);
}