barsa cdfad9d036 Enhance caching infrastructure and improve SIM management features
- 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.
2025-11-18 18:18:25 +09:00

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/cache
  • GET /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:001xx000003EgI1AAK
  • orders:detail:80122000000D4UGAA0
  • catalog:internet:acc_001:jp
  • catalog:deps:product:01t22000003xABCAA2
  • mapping: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 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

// Check memory usage
const usage = await cacheService.memoryUsageByPattern("orders:*");
const count = await cacheService.countByPattern("orders:*");

console.log(`${count} keys using ${usage} bytes`);