Files
beauty-bookings/docs/redis-migration.md

9.6 KiB
Raw Blame History

Redis Migration Guide

Overview

This guide covers migrating from in-memory KV storage to Redis for multi-instance SaaS deployments. The current in-memory session storage works well for single-instance deployments but requires centralized storage for horizontal scaling.

When to Migrate

Current Setup (Sufficient For):

  • Single-instance deployments
  • Development environments
  • Small-scale production deployments

Redis Required For:

  • Multiple server instances behind load balancer
  • Container orchestration (Kubernetes, Docker Swarm)
  • High-availability SaaS deployments
  • Session persistence across server restarts

What Needs Migration

Critical Data (Must Migrate)

  • Sessions (sessionsKV): Essential for authentication across instances
  • CSRF tokens: Stored within session objects
  • Rate limiting data: Currently in-memory Map in rate-limiter.ts

Optional Data (Can Remain File-based)

  • Bookings, treatments, reviews, gallery photos
  • Can stay in file-based KV for now
  • Migrate later if performance becomes an issue

Redis Setup

Installation Options

Self-hosted Redis

# Docker
docker run -d -p 6379:6379 redis:alpine

# Ubuntu/Debian
sudo apt update
sudo apt install redis-server

# macOS (Homebrew)
brew install redis
brew services start redis

Managed Services

  • Redis Cloud: Managed Redis service
  • AWS ElastiCache: Redis-compatible cache
  • Azure Cache for Redis: Microsoft's managed service
  • DigitalOcean Managed Databases: Redis option available

Configuration

Add to .env:

# Redis Configuration (required for multi-instance deployments)
REDIS_URL=redis://localhost:6379
REDIS_TLS_ENABLED=false  # Set to true for production
REDIS_PASSWORD=your_redis_password  # Optional

Code Changes

1. Install Redis Client

pnpm add ioredis
pnpm add -D @types/ioredis

2. Create Redis KV Adapter

Create src/server/lib/create-redis-kv.ts:

import Redis from 'ioredis';

interface RedisKVStore<T> {
  getItem(key: string): Promise<T | null>;
  setItem(key: string, value: T): Promise<void>;
  removeItem(key: string): Promise<void>;
  getAllItems(): Promise<T[]>;
  subscribe(): AsyncIterable<void>;
}

export function createRedisKV<T>(namespace: string): RedisKVStore<T> {
  const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
    retryDelayOnFailover: 100,
    enableReadyCheck: false,
    maxRetriesPerRequest: null,
    lazyConnect: true,
  });

  const prefix = `${namespace}:`;

  return {
    async getItem(key: string): Promise<T | null> {
      const value = await redis.get(`${prefix}${key}`);
      return value ? JSON.parse(value) : null;
    },

    async setItem(key: string, value: T): Promise<void> {
      await redis.set(`${prefix}${key}`, JSON.stringify(value));
    },

    async removeItem(key: string): Promise<void> {
      await redis.del(`${prefix}${key}`);
    },

    async getAllItems(): Promise<T[]> {
      const keys = await redis.keys(`${prefix}*`);
      if (keys.length === 0) return [];
      
      const values = await redis.mget(...keys);
      return values
        .filter(v => v !== null)
        .map(v => JSON.parse(v!));
    },

    async* subscribe(): AsyncIterable<void> {
      const pubsub = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
      await pubsub.subscribe(`${namespace}:changes`);
      
      for await (const message of pubsub) {
        if (message[0] === 'message' && message[1] === `${namespace}:changes`) {
          yield;
        }
      }
    }
  };
}

// Helper to notify subscribers of changes
export async function notifyRedisChanges(namespace: string): Promise<void> {
  const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
  await redis.publish(`${namespace}:changes`, 'update');
  await redis.quit();
}

3. Update Session Storage

In src/server/lib/auth.ts:

import { createRedisKV } from './create-redis-kv.js';

// Replace this line:
// export const sessionsKV = createKV<Session>("sessions");

// With this:
export const sessionsKV = createRedisKV<Session>("sessions");

4. Update Rate Limiter

In src/server/lib/rate-limiter.ts:

import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
  retryDelayOnFailover: 100,
  enableReadyCheck: false,
  maxRetriesPerRequest: null,
  lazyConnect: true,
});

export async function checkBookingRateLimit(
  email?: string,
  ip?: string
): Promise<{ allowed: boolean; resetTime?: number }> {
  const now = Date.now();
  const windowMs = 15 * 60 * 1000; // 15 minutes
  const maxRequests = 3;

  const results = await Promise.all([
    email ? checkRateLimit(`booking:email:${email}`, maxRequests, windowMs) : { allowed: true },
    ip ? checkRateLimit(`booking:ip:${ip}`, maxRequests, windowMs) : { allowed: true }
  ]);

  return {
    allowed: results.every(r => r.allowed),
    resetTime: results.find(r => !r.allowed)?.resetTime
  };
}

async function checkRateLimit(
  key: string,
  maxRequests: number,
  windowMs: number
): Promise<{ allowed: boolean; resetTime?: number }> {
  const now = Date.now();
  const windowStart = now - windowMs;

  // Use Redis sorted set for sliding window
  const pipeline = redis.pipeline();
  
  // Remove old entries
  pipeline.zremrangebyscore(key, 0, windowStart);
  
  // Count current entries
  pipeline.zcard(key);
  
  // Add current request
  pipeline.zadd(key, now, `${now}-${Math.random()}`);
  
  // Set expiry
  pipeline.expire(key, Math.ceil(windowMs / 1000));
  
  const results = await pipeline.exec();
  const currentCount = results?.[1]?.[1] as number || 0;

  return {
    allowed: currentCount < maxRequests,
    resetTime: currentCount >= maxRequests ? windowStart + windowMs : undefined
  };
}

5. Environment Configuration

Update .env.example:

# Redis Configuration (optional - required for multi-instance SaaS deployments)
# For single-instance deployments, the default in-memory storage is sufficient
# See docs/redis-migration.md for migration guide
REDIS_URL=redis://localhost:6379
REDIS_TLS_ENABLED=false  # Enable for production
REDIS_PASSWORD=your_redis_password  # Optional

Migration Strategy

Development Testing

  1. Start Redis locally:

    docker run -d -p 6379:6379 redis:alpine
    
  2. Test session functionality:

    • Login/logout
    • Session validation
    • CSRF token generation/validation
    • Session rotation
  3. Test rate limiting:

    • Verify rate limit enforcement
    • Check sliding window behavior
  4. Simulate multi-instance:

    • Run multiple server instances
    • Verify session sharing works

Staging Deployment

  1. Deploy Redis in staging
  2. Run single instance first to verify functionality
  3. Scale to multiple instances and test session sharing
  4. Monitor Redis memory usage and connection pool

Production Rollout

Option A: Blue/Green Deployment

  1. Deploy new version with Redis to green environment
  2. Test thoroughly with production-like data
  3. Switch traffic to green environment
  4. Note: Existing sessions will be lost (users need to re-login)

Option B: Gradual Migration

  1. Deploy Redis alongside existing system
  2. Write to both stores temporarily (dual-write)
  3. Read from Redis first, fallback to in-memory
  4. After verification period, remove in-memory store

Monitoring & Maintenance

Key Metrics

  • Redis memory usage: Monitor used_memory and used_memory_peak
  • Connection pool: Track active connections and pool utilization
  • Session operations: Monitor session creation/validation latency
  • Rate limit hits: Track rate limit enforcement effectiveness

Backup Strategy

  • Enable Redis persistence: Configure RDB snapshots or AOF logging
  • Regular backups: Backup Redis data regularly
  • Session data: Ephemeral, but rate limit data should be backed up

Scaling Considerations

  • Redis Cluster: For horizontal scaling across multiple nodes
  • Redis Sentinel: For high availability with automatic failover
  • Connection pooling: Configure appropriate pool sizes

Rollback Plan

If issues arise, you can revert to in-memory storage:

  1. Remove Redis imports and revert to createKV
  2. Remove Redis environment variables
  3. Redeploy with in-memory storage
  4. Note: All sessions will be lost (users need to re-login)

Cost Considerations

Redis Hosting Costs

  • Self-hosted: Server costs + maintenance
  • Managed services:
    • Redis Cloud: ~$7/month for 30MB
    • AWS ElastiCache: ~$15/month for t3.micro
    • Azure Cache: ~$16/month for Basic tier

Memory Requirements

Session storage: Minimal

  • Example: 1000 concurrent users × 1KB per session = ~1MB
  • CSRF tokens add minimal overhead

Rate limiting: Negligible

  • Sliding window data is automatically cleaned up
  • Minimal memory footprint per IP/email

Alternative Solutions

Sticky Sessions

  • Load balancer routes user to same instance
  • Pros: Simpler implementation
  • Cons: Less resilient, harder to scale

Database-backed Sessions

  • Use PostgreSQL/MySQL instead of Redis
  • Pros: No additional infrastructure
  • Cons: Higher latency, more database load

JWT Tokens

  • Stateless authentication tokens
  • Pros: No server-side session storage needed
  • Cons: No server-side session invalidation, different security model

References