- Introduced ServicesCdcSubscriber for handling Salesforce Change Data Capture events, enabling real-time updates for product and account eligibility changes. - Developed utility functions for building SOQL queries related to services, enhancing data retrieval capabilities. - Created base service classes for internet, SIM, and VPN offerings, improving code organization and maintainability. - Implemented ServicesCacheService for efficient caching and invalidation of service data, leveraging CDC for real-time updates. - Enhanced existing service components to utilize new caching mechanisms and ensure data consistency across the application.
206 lines
5.5 KiB
TypeScript
206 lines
5.5 KiB
TypeScript
import { Inject, Injectable } from "@nestjs/common";
|
|
import { Redis } from "ioredis";
|
|
import { Logger } from "nestjs-pino";
|
|
|
|
/**
|
|
* Core cache service
|
|
*
|
|
* Provides Redis-backed caching infrastructure for the entire application.
|
|
* This is a low-level service - domain-specific cache services should wrap this
|
|
* to provide higher-level caching patterns (CDC invalidation, request coalescing, etc).
|
|
*
|
|
* Features:
|
|
* - JSON serialization/deserialization
|
|
* - TTL support (optional)
|
|
* - Pattern-based operations (scan, delete, count)
|
|
* - Memory usage tracking
|
|
* - Automatic error handling and logging
|
|
*/
|
|
@Injectable()
|
|
export class CacheService {
|
|
constructor(
|
|
@Inject("REDIS_CLIENT") private readonly redis: Redis,
|
|
@Inject(Logger) private readonly logger: Logger
|
|
) {}
|
|
|
|
/**
|
|
* Get a cached value by key
|
|
* Returns null if key doesn't exist or value cannot be parsed
|
|
*/
|
|
async get<T>(key: string): Promise<T | null> {
|
|
const value = await this.redis.get(key);
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(value) as T;
|
|
} catch (error) {
|
|
this.logger.warn({ key, error }, "Failed to parse cached value; evicting entry");
|
|
await this.redis.del(key);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set a cached value
|
|
* @param key Cache key
|
|
* @param value Value to cache (will be JSON serialized)
|
|
* @param ttlSeconds Optional TTL in seconds (null = no expiry)
|
|
*/
|
|
async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
|
|
const serialized = JSON.stringify(value);
|
|
if (ttlSeconds !== undefined) {
|
|
const ttl = Math.max(0, Math.floor(ttlSeconds));
|
|
if (ttl > 0) {
|
|
await this.redis.set(key, serialized, "EX", ttl);
|
|
} else {
|
|
// Allow callers to request immediate expiry without leaking stale values
|
|
await this.redis.set(key, serialized, "PX", 1);
|
|
}
|
|
return;
|
|
}
|
|
|
|
await this.redis.set(key, serialized);
|
|
}
|
|
|
|
/**
|
|
* Delete a single cache key
|
|
*/
|
|
async del(key: string): Promise<void> {
|
|
await this.redis.del(key);
|
|
}
|
|
|
|
/**
|
|
* Delete all keys matching a pattern
|
|
* Uses SCAN for safe operation on large datasets
|
|
* @param pattern Redis pattern (e.g., "orders:*", "services:product:*")
|
|
*/
|
|
async delPattern(pattern: string): Promise<void> {
|
|
const pipeline = this.redis.pipeline();
|
|
let pending = 0;
|
|
|
|
const flush = async () => {
|
|
if (pending === 0) {
|
|
return;
|
|
}
|
|
await pipeline.exec();
|
|
pending = 0;
|
|
};
|
|
|
|
await this.scanPattern(pattern, async keys => {
|
|
keys.forEach(key => {
|
|
pipeline.del(key);
|
|
pending += 1;
|
|
});
|
|
if (pending >= 1000) {
|
|
await flush();
|
|
}
|
|
});
|
|
|
|
await flush();
|
|
}
|
|
|
|
/**
|
|
* Count keys matching a pattern
|
|
* @param pattern Redis pattern (e.g., "orders:*")
|
|
*/
|
|
async countByPattern(pattern: string): Promise<number> {
|
|
let total = 0;
|
|
await this.scanPattern(pattern, keys => {
|
|
total += keys.length;
|
|
});
|
|
return total;
|
|
}
|
|
|
|
/**
|
|
* Get total memory usage of keys matching a pattern
|
|
* @param pattern Redis pattern (e.g., "orders:*")
|
|
* @returns Total memory usage in bytes
|
|
*/
|
|
async memoryUsageByPattern(pattern: string): Promise<number> {
|
|
let total = 0;
|
|
await this.scanPattern(pattern, async keys => {
|
|
const pipeline = this.redis.pipeline();
|
|
keys.forEach(key => {
|
|
pipeline.memory("USAGE", key);
|
|
});
|
|
const results = await pipeline.exec();
|
|
if (!results) {
|
|
return;
|
|
}
|
|
results.forEach((result: [Error | null, unknown] | null) => {
|
|
if (!result) {
|
|
return;
|
|
}
|
|
const [error, usage] = result;
|
|
if (!error && typeof usage === "number") {
|
|
total += usage;
|
|
}
|
|
});
|
|
});
|
|
return total;
|
|
}
|
|
|
|
/**
|
|
* Check if a key exists
|
|
*/
|
|
async exists(key: string): Promise<boolean> {
|
|
return (await this.redis.exists(key)) === 1;
|
|
}
|
|
|
|
/**
|
|
* Build a structured cache key
|
|
* @param prefix Key prefix (e.g., "orders", "catalog")
|
|
* @param userId User/account identifier
|
|
* @param parts Additional key parts
|
|
* @returns Colon-separated key (e.g., "orders:user123:summary")
|
|
*/
|
|
buildKey(prefix: string, userId: string, ...parts: string[]): string {
|
|
return [prefix, userId, ...parts].join(":");
|
|
}
|
|
|
|
/**
|
|
* Get or set pattern: Fetch from cache, or call fetcher and cache result
|
|
* @param key Cache key
|
|
* @param fetcher Function to fetch fresh data on cache miss
|
|
* @param ttlSeconds TTL in seconds (default: 300)
|
|
*/
|
|
async getOrSet<T>(key: string, fetcher: () => Promise<T>, ttlSeconds: number = 300): Promise<T> {
|
|
const cached = await this.get<T>(key);
|
|
if (cached !== null) {
|
|
return cached;
|
|
}
|
|
|
|
const fresh = await fetcher();
|
|
await this.set(key, fresh, ttlSeconds);
|
|
return fresh;
|
|
}
|
|
|
|
/**
|
|
* Scan keys matching a pattern and invoke callback for each batch
|
|
* Uses cursor-based iteration for safe operation on large datasets
|
|
* @param pattern Redis pattern
|
|
* @param onKeys Callback invoked for each batch of matching keys
|
|
*/
|
|
private async scanPattern(
|
|
pattern: string,
|
|
onKeys: (keys: string[]) => Promise<void> | void
|
|
): Promise<void> {
|
|
let cursor = "0";
|
|
do {
|
|
const [next, keys] = (await this.redis.scan(
|
|
cursor,
|
|
"MATCH",
|
|
pattern,
|
|
"COUNT",
|
|
1000
|
|
)) as unknown as [string, string[]];
|
|
cursor = next;
|
|
if (keys && keys.length) {
|
|
await onKeys(keys);
|
|
}
|
|
} while (cursor !== "0");
|
|
}
|
|
}
|