CalDAV: Support Basic auth; trim+validate UUID; deprecate query token via headers; ICS end time helper; docs+instructions updated
This commit is contained in:
355
docs/redis-migration.md
Normal file
355
docs/redis-migration.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# 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
|
||||
```bash
|
||||
# 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`:
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
pnpm add ioredis
|
||||
pnpm add -D @types/ioredis
|
||||
```
|
||||
|
||||
### 2. Create Redis KV Adapter
|
||||
|
||||
Create `src/server/lib/create-redis-kv.ts`:
|
||||
|
||||
```typescript
|
||||
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`:
|
||||
|
||||
```typescript
|
||||
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`:
|
||||
|
||||
```typescript
|
||||
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`:
|
||||
|
||||
```bash
|
||||
# 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**:
|
||||
```bash
|
||||
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
|
||||
|
||||
- [Session Management Guide](session-management.md)
|
||||
- [Rate Limiting Documentation](rate-limiting.md)
|
||||
- [Redis Documentation](https://redis.io/documentation)
|
||||
- [ioredis Library](https://github.com/luin/ioredis)
|
||||
- [Redis Best Practices](https://redis.io/docs/manual/admin/)
|
Reference in New Issue
Block a user