361 lines
11 KiB
Markdown
361 lines
11 KiB
Markdown
|
|
# Cache Infrastructure
|
||
|
|
|
||
|
|
Redis-backed caching system with CDC (Change Data Capture) event-driven invalidation.
|
||
|
|
|
||
|
|
## Architecture
|
||
|
|
|
||
|
|
### Core Components
|
||
|
|
|
||
|
|
```
|
||
|
|
┌─────────────────────────────────────────────────────────────┐
|
||
|
|
│ Application Layer │
|
||
|
|
│ (Orders, Catalog, Invoices, etc. - Domain Logic) │
|
||
|
|
└───────────────────────────┬─────────────────────────────────┘
|
||
|
|
│
|
||
|
|
┌───────────────────────────▼─────────────────────────────────┐
|
||
|
|
│ Domain-Specific Cache Services │
|
||
|
|
│ - OrdersCacheService (CDC-driven, no TTL) │
|
||
|
|
│ - CatalogCacheService (CDC-driven, no TTL) │
|
||
|
|
│ - WhmcsCacheService (TTL-based) │
|
||
|
|
│ │
|
||
|
|
│ Features: │
|
||
|
|
│ • Request coalescing (prevents thundering herd) │
|
||
|
|
│ • Metrics tracking (hits/misses/invalidations) │
|
||
|
|
│ • Dependency tracking (granular invalidation) │
|
||
|
|
└───────────────────────────┬─────────────────────────────────┘
|
||
|
|
│
|
||
|
|
┌───────────────────────────▼─────────────────────────────────┐
|
||
|
|
│ CacheService │
|
||
|
|
│ (Low-level Redis operations) │
|
||
|
|
│ │
|
||
|
|
│ • get/set with JSON serialization │
|
||
|
|
│ • Pattern-based operations (scan/delete/count) │
|
||
|
|
│ • Memory usage tracking │
|
||
|
|
│ • Automatic error handling │
|
||
|
|
└───────────────────────────┬─────────────────────────────────┘
|
||
|
|
│
|
||
|
|
┌───────────────────────────▼─────────────────────────────────┐
|
||
|
|
│ Redis (ioredis) │
|
||
|
|
│ In-memory data store │
|
||
|
|
└─────────────────────────────────────────────────────────────┘
|
||
|
|
```
|
||
|
|
|
||
|
|
### CDC Event-Driven Invalidation
|
||
|
|
|
||
|
|
```
|
||
|
|
┌─────────────────┐ ┌──────────────────┐
|
||
|
|
│ Salesforce │ CDC │ CDC Subscriber │
|
||
|
|
│ (Data Source) │───────▶ │ (order-cdc, │
|
||
|
|
│ │ Events │ catalog-cdc) │
|
||
|
|
└─────────────────┘ └─────────┬────────┘
|
||
|
|
│
|
||
|
|
│ Invalidate
|
||
|
|
▼
|
||
|
|
┌──────────────────┐
|
||
|
|
│ Cache Service │
|
||
|
|
│ (Redis) │
|
||
|
|
└──────────────────┘
|
||
|
|
```
|
||
|
|
|
||
|
|
## Cache Strategies
|
||
|
|
|
||
|
|
### 1. CDC-Driven (Orders, Catalog)
|
||
|
|
|
||
|
|
**No TTL** - Cache persists indefinitely until CDC event triggers invalidation.
|
||
|
|
|
||
|
|
**Pros:**
|
||
|
|
- Real-time invalidation when data changes
|
||
|
|
- Zero stale data for customer-visible fields
|
||
|
|
- Optimal for frequently read, infrequently changed data
|
||
|
|
|
||
|
|
**Example:**
|
||
|
|
```typescript
|
||
|
|
@Injectable()
|
||
|
|
export class OrdersCacheService {
|
||
|
|
// No TTL = CDC-only invalidation
|
||
|
|
async getOrderSummaries(
|
||
|
|
sfAccountId: string,
|
||
|
|
fetcher: () => Promise<OrderSummary[]>
|
||
|
|
): Promise<OrderSummary[]> {
|
||
|
|
const key = this.buildAccountKey(sfAccountId);
|
||
|
|
return this.getOrSet("summaries", key, fetcher, false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. TTL-Based (WHMCS, External APIs)
|
||
|
|
|
||
|
|
**Fixed TTL** - Cache expires after a set duration.
|
||
|
|
|
||
|
|
**Pros:**
|
||
|
|
- Simple, predictable behavior
|
||
|
|
- Good for external systems without CDC
|
||
|
|
- Automatic cleanup of stale data
|
||
|
|
|
||
|
|
**Example:**
|
||
|
|
```typescript
|
||
|
|
@Injectable()
|
||
|
|
export class WhmcsCacheService {
|
||
|
|
private readonly INVOICES_TTL = 90; // 90 seconds
|
||
|
|
|
||
|
|
async getInvoicesList(...): Promise<InvoiceList | null> {
|
||
|
|
return this.cacheService.getOrSet(key, fetcher, this.INVOICES_TTL);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3. Hybrid (Volatile Data)
|
||
|
|
|
||
|
|
**Short TTL + CDC** - Combines both approaches.
|
||
|
|
|
||
|
|
**Use case:** Frequently changing data that also needs CDC invalidation.
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
private readonly VOLATILE_TTL = 60; // 1 minute
|
||
|
|
```
|
||
|
|
|
||
|
|
## Key Features
|
||
|
|
|
||
|
|
### Request Coalescing
|
||
|
|
|
||
|
|
Prevents "thundering herd" problem when cache is invalidated and multiple requests arrive simultaneously.
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// When 100 users request data after CDC invalidation:
|
||
|
|
// ❌ Without coalescing: 100 API calls
|
||
|
|
// ✅ With coalescing: 1 API call, shared by all 100 users
|
||
|
|
|
||
|
|
private readonly inflightRequests = new Map<string, Promise<unknown>>();
|
||
|
|
|
||
|
|
// Check if another request is already fetching this data
|
||
|
|
const existingRequest = this.inflightRequests.get(key);
|
||
|
|
if (existingRequest) {
|
||
|
|
return existingRequest; // Wait for and share the existing request
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Dependency Tracking (Catalog Only)
|
||
|
|
|
||
|
|
Enables granular invalidation by tracking relationships between cached data and source entities.
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// When Product A changes:
|
||
|
|
// ✅ Invalidate only catalog entries that depend on Product A
|
||
|
|
// ❌ Don't invalidate unrelated Product B entries
|
||
|
|
|
||
|
|
await catalogCache.invalidateProducts(["Product_A_ID"]);
|
||
|
|
```
|
||
|
|
|
||
|
|
### Metrics Tracking
|
||
|
|
|
||
|
|
All cache services track performance metrics:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
{
|
||
|
|
catalog: { hits: 1250, misses: 48 },
|
||
|
|
static: { hits: 890, misses: 12 },
|
||
|
|
volatile: { hits: 450, misses: 120 },
|
||
|
|
invalidations: 15
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Access via health endpoints:
|
||
|
|
- `GET /health/catalog/cache`
|
||
|
|
- `GET /health`
|
||
|
|
|
||
|
|
## Creating a New Cache Service
|
||
|
|
|
||
|
|
### Step 1: Import Shared Types
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { CacheService } from "@bff/infra/cache/cache.service";
|
||
|
|
import type { CacheBucketMetrics } from "@bff/infra/cache/cache.types";
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 2: Define Service Structure
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
@Injectable()
|
||
|
|
export class MyDomainCacheService {
|
||
|
|
private readonly metrics = {
|
||
|
|
data: { hits: 0, misses: 0 },
|
||
|
|
invalidations: 0,
|
||
|
|
};
|
||
|
|
|
||
|
|
// Request coalescing map
|
||
|
|
private readonly inflightRequests = new Map<string, Promise<unknown>>();
|
||
|
|
|
||
|
|
constructor(private readonly cache: CacheService) {}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 3: Implement Get/Set Pattern
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
async getMyData(id: string, fetcher: () => Promise<MyData>): Promise<MyData> {
|
||
|
|
const key = `mydomain:${id}`;
|
||
|
|
|
||
|
|
// Check cache
|
||
|
|
const cached = await this.cache.get<MyData>(key);
|
||
|
|
if (cached) {
|
||
|
|
this.metrics.data.hits++;
|
||
|
|
return cached;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for in-flight request
|
||
|
|
const existing = this.inflightRequests.get(key);
|
||
|
|
if (existing) return existing as Promise<MyData>;
|
||
|
|
|
||
|
|
// Fetch fresh data
|
||
|
|
this.metrics.data.misses++;
|
||
|
|
const fetchPromise = (async () => {
|
||
|
|
try {
|
||
|
|
const fresh = await fetcher();
|
||
|
|
await this.cache.set(key, fresh); // No TTL = CDC-driven
|
||
|
|
return fresh;
|
||
|
|
} finally {
|
||
|
|
this.inflightRequests.delete(key);
|
||
|
|
}
|
||
|
|
})();
|
||
|
|
|
||
|
|
this.inflightRequests.set(key, fetchPromise);
|
||
|
|
return fetchPromise;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 4: Implement Invalidation
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
async invalidateMyData(id: string): Promise<void> {
|
||
|
|
this.metrics.invalidations++;
|
||
|
|
await this.cache.del(`mydomain:${id}`);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Step 5: Wire Up CDC Subscriber (if needed)
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
@Injectable()
|
||
|
|
export class MyDomainCdcSubscriber implements OnModuleInit {
|
||
|
|
constructor(private readonly myCache: MyDomainCacheService) {}
|
||
|
|
|
||
|
|
private async handleEvent(data: unknown): Promise<void> {
|
||
|
|
const id = extractId(data);
|
||
|
|
await this.myCache.invalidateMyData(id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Cache Key Conventions
|
||
|
|
|
||
|
|
Use colon-separated hierarchical keys:
|
||
|
|
|
||
|
|
```
|
||
|
|
domain:type:identifier[:subkey]
|
||
|
|
```
|
||
|
|
|
||
|
|
Examples:
|
||
|
|
- `orders:account:001xx000003EgI1AAK`
|
||
|
|
- `orders:detail:80122000000D4UGAA0`
|
||
|
|
- `catalog:internet:acc_001:jp`
|
||
|
|
- `catalog:deps:product:01t22000003xABCAA2`
|
||
|
|
- `mapping:userId:user_12345`
|
||
|
|
|
||
|
|
## Configuration
|
||
|
|
|
||
|
|
### Environment Variables
|
||
|
|
|
||
|
|
```env
|
||
|
|
# Redis connection
|
||
|
|
REDIS_URL=redis://localhost:6379
|
||
|
|
|
||
|
|
# Production (Docker Compose)
|
||
|
|
REDIS_URL=redis://cache:6379/0
|
||
|
|
```
|
||
|
|
|
||
|
|
### Redis Module
|
||
|
|
|
||
|
|
Location: `/infra/redis/redis.module.ts`
|
||
|
|
|
||
|
|
Provides global `REDIS_CLIENT` using ioredis.
|
||
|
|
|
||
|
|
## Monitoring
|
||
|
|
|
||
|
|
### Health Checks
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Overall system health (includes Redis check)
|
||
|
|
GET /health
|
||
|
|
|
||
|
|
# Catalog cache metrics
|
||
|
|
GET /health/catalog/cache
|
||
|
|
```
|
||
|
|
|
||
|
|
### Response Format
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"timestamp": "2025-11-18T10:30:00.000Z",
|
||
|
|
"metrics": {
|
||
|
|
"catalog": { "hits": 1250, "misses": 48 },
|
||
|
|
"static": { "hits": 890, "misses": 12 },
|
||
|
|
"invalidations": 15
|
||
|
|
},
|
||
|
|
"ttl": {
|
||
|
|
"catalogSeconds": null,
|
||
|
|
"staticSeconds": null,
|
||
|
|
"volatileSeconds": 60
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Best Practices
|
||
|
|
|
||
|
|
### ✅ DO
|
||
|
|
|
||
|
|
- Use CDC invalidation for Salesforce data
|
||
|
|
- Implement request coalescing for all cache services
|
||
|
|
- Track metrics (hits/misses/invalidations)
|
||
|
|
- Use structured, hierarchical cache keys
|
||
|
|
- Document TTL strategy in comments
|
||
|
|
- Add JSDoc comments to public methods
|
||
|
|
|
||
|
|
### ❌ DON'T
|
||
|
|
|
||
|
|
- Mix TTL and CDC strategies without clear reasoning
|
||
|
|
- Cache sensitive data without encryption
|
||
|
|
- Use cache as primary data store
|
||
|
|
- Forget to clean up in-flight requests (use `finally` block)
|
||
|
|
- Use overly generic cache keys (risk of collision)
|
||
|
|
|
||
|
|
## Troubleshooting
|
||
|
|
|
||
|
|
### High Cache Miss Rate
|
||
|
|
|
||
|
|
1. Check CDC subscriber health: `GET /health/sf-events`
|
||
|
|
2. Verify invalidation patterns match expected keys
|
||
|
|
3. Review TTL settings (if applicable)
|
||
|
|
|
||
|
|
### Stale Data Issues
|
||
|
|
|
||
|
|
1. Confirm CDC events are being received
|
||
|
|
2. Check invalidation logic in CDC subscribers
|
||
|
|
3. Verify cache key construction matches between set/invalidate
|
||
|
|
|
||
|
|
### Memory Issues
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// Check memory usage
|
||
|
|
const usage = await cacheService.memoryUsageByPattern("orders:*");
|
||
|
|
const count = await cacheService.countByPattern("orders:*");
|
||
|
|
|
||
|
|
console.log(`${count} keys using ${usage} bytes`);
|
||
|
|
```
|
||
|
|
|
||
|
|
## Related Documentation
|
||
|
|
|
||
|
|
- [Salesforce CDC Events](../../integrations/salesforce/events/README.md)
|
||
|
|
- [Order Fulfillment Flow](../../modules/orders/docs/FULFILLMENT.md)
|
||
|
|
- [Redis Configuration](../redis/README.md)
|
||
|
|
|