- Updated CacheModule and CacheService with detailed documentation and new methods for better cache management, including pattern deletion and memory usage tracking. - Refactored CatalogCacheService and OrdersCacheService to utilize CDC-driven cache invalidation, improving data freshness and reducing unnecessary API calls. - Introduced SIM plan options and updated related components to leverage new domain utilities for better plan management and user experience. - Enhanced error handling and validation in TopUpModal for improved user feedback during SIM top-up operations. - Removed obsolete plan formatting utilities to streamline codebase and improve maintainability.
11 KiB
11 KiB
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:
@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:
@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.
private readonly VOLATILE_TTL = 60; // 1 minute
Key Features
Request Coalescing
Prevents "thundering herd" problem when cache is invalidated and multiple requests arrive simultaneously.
// 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.
// 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:
{
catalog: { hits: 1250, misses: 48 },
static: { hits: 890, misses: 12 },
volatile: { hits: 450, misses: 120 },
invalidations: 15
}
Access via health endpoints:
GET /health/catalog/cacheGET /health
Creating a New Cache Service
Step 1: Import Shared Types
import { CacheService } from "@bff/infra/cache/cache.service";
import type { CacheBucketMetrics } from "@bff/infra/cache/cache.types";
Step 2: Define Service Structure
@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
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
async invalidateMyData(id: string): Promise<void> {
this.metrics.invalidations++;
await this.cache.del(`mydomain:${id}`);
}
Step 5: Wire Up CDC Subscriber (if needed)
@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:001xx000003EgI1AAKorders:detail:80122000000D4UGAA0catalog:internet:acc_001:jpcatalog:deps:product:01t22000003xABCAA2mapping:userId:user_12345
Configuration
Environment Variables
# 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
# Overall system health (includes Redis check)
GET /health
# Catalog cache metrics
GET /health/catalog/cache
Response Format
{
"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
finallyblock) - Use overly generic cache keys (risk of collision)
Troubleshooting
High Cache Miss Rate
- Check CDC subscriber health:
GET /health/sf-events - Verify invalidation patterns match expected keys
- Review TTL settings (if applicable)
Stale Data Issues
- Confirm CDC events are being received
- Check invalidation logic in CDC subscribers
- Verify cache key construction matches between set/invalidate
Memory Issues
// Check memory usage
const usage = await cacheService.memoryUsageByPattern("orders:*");
const count = await cacheService.countByPattern("orders:*");
console.log(`${count} keys using ${usage} bytes`);