Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 82 additions & 14 deletions backend/src/modules/cache/cache-strategy.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,26 @@ interface CacheMetrics {
misses: number;
sets: number;
deletes: number;
keyMetrics: Map<string, { hits: number; misses: number; sets: number }>;
}

@Injectable()
export class CacheStrategyService {
private readonly logger = new Logger(CacheStrategyService.name);
private metrics: CacheMetrics = { hits: 0, misses: 0, sets: 0, deletes: 0 };
private metrics: CacheMetrics = {
hits: 0,
misses: 0,
sets: 0,
deletes: 0,
keyMetrics: new Map()
};
private resourceTTLs = new Map<string, number>([
['user', 5 * 60 * 1000], // 5 minutes
['savings', 10 * 60 * 1000], // 10 minutes
['analytics', 30 * 60 * 1000], // 30 minutes
['blockchain', 2 * 60 * 1000], // 2 minutes
]);
private tagKeys = new Map<string, Set<string>>();

constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}

Expand All @@ -34,9 +42,11 @@ export class CacheStrategyService {
const value = await this.cacheManager.get<T>(key);
if (value) {
this.metrics.hits++;
this.updateKeyMetrics(key, 'hits');
this.logger.debug(`Cache hit: ${key}`);
} else {
this.metrics.misses++;
this.updateKeyMetrics(key, 'misses');
}
return value;
} catch (error) {
Expand All @@ -45,11 +55,22 @@ export class CacheStrategyService {
}
}

async set<T>(key: string, value: T, ttl?: number): Promise<void> {
async set<T>(key: string, value: T, ttl?: number, tags?: string[]): Promise<void> {
try {
const finalTTL = ttl || this.getDefaultTTL(key);
await this.cacheManager.set(key, value, finalTTL);
this.metrics.sets++;
this.updateKeyMetrics(key, 'sets');

if (tags) {
for (const tag of tags) {
if (!this.tagKeys.has(tag)) {
this.tagKeys.set(tag, new Set());
}
this.tagKeys.get(tag)!.add(key);
}
}

this.logger.debug(`Cache set: ${key} (TTL: ${finalTTL}ms)`);
} catch (error) {
this.logger.error(`Cache set error for key ${key}:`, error);
Expand All @@ -60,6 +81,12 @@ export class CacheStrategyService {
try {
await this.cacheManager.del(key);
this.metrics.deletes++;

// Remove key from all tag sets
for (const [, keys] of this.tagKeys) {
keys.delete(key);
}

this.logger.debug(`Cache deleted: ${key}`);
} catch (error) {
this.logger.error(`Cache delete error for key ${key}:`, error);
Expand All @@ -68,29 +95,52 @@ export class CacheStrategyService {

async invalidateByTag(tag: string): Promise<void> {
try {
const keys = Array.from(this.cacheManager.stores.keys());
const keysToDelete = keys.filter((k) => k.toString().includes(tag));

const keysToDelete = this.tagKeys.get(tag) || new Set();

for (const key of keysToDelete) {
await this.del(key.toString());
await this.del(key);
}


this.tagKeys.delete(tag);

this.logger.debug(
`Invalidated ${keysToDelete.length} keys with tag: ${tag}`,
`Invalidated ${keysToDelete.size} keys with tag: ${tag}`,
);
} catch (error) {
this.logger.error(`Cache invalidation error for tag ${tag}:`, error);
}
}

async invalidateByPattern(pattern: string): Promise<void> {
try {
// This is a fallback for implementations that don't support pattern matching
// For Redis, we'd use KEYS or SCAN
const allKeys = Array.from(this.tagKeys.values()).flatMap(set => Array.from(set));
const uniqueKeys = new Set(allKeys);

const keysToDelete = Array.from(uniqueKeys).filter(k => k.includes(pattern));

for (const key of keysToDelete) {
await this.del(key);
}

this.logger.debug(
`Invalidated ${keysToDelete.length} keys with pattern: ${pattern}`,
);
} catch (error) {
this.logger.error(`Cache invalidation error for pattern ${pattern}:`, error);
}
}

async warmCache(
key: string,
loader: () => Promise<any>,
ttl?: number,
tags?: string[],
): Promise<void> {
try {
const data = await loader();
await this.set(key, data, ttl);
await this.set(key, data, ttl, tags);
this.logger.log(`Cache warmed: ${key}`);
} catch (error) {
this.logger.error(`Cache warming error for key ${key}:`, error);
Expand All @@ -101,12 +151,13 @@ export class CacheStrategyService {
key: string,
loader: () => Promise<T>,
ttl?: number,
tags?: string[],
): Promise<T> {
const cached = await this.get<T>(key);
if (cached) return cached;

const data = await loader();
await this.set(key, data, ttl);
await this.set(key, data, ttl, tags);
return data;
}

Expand All @@ -115,26 +166,35 @@ export class CacheStrategyService {
loader: () => Promise<T>,
ttl: number,
staleTime: number,
tags?: string[],
): Promise<T> {
const cached = await this.get<T>(key);
if (cached) return cached;

const data = await loader();
await this.set(key, data, ttl + staleTime);
await this.set(key, data, ttl + staleTime, tags);
return data;
}

getMetrics() {
const total = this.metrics.hits + this.metrics.misses;
const keyMetricsArray = Array.from(this.metrics.keyMetrics.entries()).map(([key, km]) => ({
key,
...km,
hitRate: (km.hits + km.misses) > 0
? ((km.hits / (km.hits + km.misses)) * 100).toFixed(2) + '%'
: '0%',
}));

return {
...this.metrics,
hitRate:
total > 0 ? ((this.metrics.hits / total) * 100).toFixed(2) + '%' : '0%',
keyMetrics: keyMetricsArray,
hitRate: total > 0 ? ((this.metrics.hits / total) * 100).toFixed(2) + '%' : '0%',
};
}

resetMetrics() {
this.metrics = { hits: 0, misses: 0, sets: 0, deletes: 0 };
this.metrics = { hits: 0, misses: 0, sets: 0, deletes: 0, keyMetrics: new Map() };
}

setResourceTTL(resource: string, ttl: number): void {
Expand All @@ -149,4 +209,12 @@ export class CacheStrategyService {
}
return 5 * 60 * 1000; // default 5 minutes
}

private updateKeyMetrics(key: string, type: 'hits' | 'misses' | 'sets') {
if (!this.metrics.keyMetrics.has(key)) {
this.metrics.keyMetrics.set(key, { hits: 0, misses: 0, sets: 0 });
}
const km = this.metrics.keyMetrics.get(key)!;
km[type]++;
}
}
92 changes: 92 additions & 0 deletions backend/src/modules/cache/cache-warming.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Injectable, Logger } from '@nestjs/common';
import { CacheStrategyService } from './cache-strategy.service';
import { Cron, CronExpression } from '@nestjs/schedule';

export enum CachePriority {
LOW = 1,
MEDIUM = 2,
HIGH = 3,
CRITICAL = 4,
}

export interface CacheableEndpoint {
key: string;
priority: CachePriority;
loader: () => Promise<any>;
ttl?: number;
}

@Injectable()
export class CacheWarmingService {
private readonly logger = new Logger(CacheWarmingService.name);
private cacheableEndpoints: CacheableEndpoint[] = [];
private warmingMetrics = {
totalWarmed: 0,
successCount: 0,
failureCount: 0,
lastWarmedAt: null as Date | null,
warmingDuration: 0,
};

constructor(private readonly cacheStrategy: CacheStrategyService) {}

registerCacheableEndpoint(endpoint: CacheableEndpoint): void {
this.cacheableEndpoints.push(endpoint);
this.logger.log(`Registered cacheable endpoint: ${endpoint.key}`);
}

async warmAllEndpoints(): Promise<void> {
const startTime = Date.now();
this.logger.log('Starting cache warming...');

try {
const sortedEndpoints = [...this.cacheableEndpoints].sort(
(a, b) => b.priority - a.priority,
);

for (const endpoint of sortedEndpoints) {
await this.warmEndpoint(endpoint);
}

const duration = Date.now() - startTime;
this.warmingMetrics.warmingDuration = duration;
this.warmingMetrics.lastWarmedAt = new Date();
this.warmingMetrics.totalWarmed += sortedEndpoints.length;
this.logger.log(`Cache warming completed in ${duration}ms`);
} catch (error) {
this.logger.error('Cache warming failed:', error);
}
}

async warmEndpoint(endpoint: CacheableEndpoint): Promise<void> {
try {
await this.cacheStrategy.warmCache(
endpoint.key,
endpoint.loader,
endpoint.ttl,
);
this.warmingMetrics.successCount++;
} catch (error) {
this.warmingMetrics.failureCount++;
this.logger.error(`Failed to warm endpoint ${endpoint.key}:`, error);
}
}

@Cron(CronExpression.EVERY_DAY_AT_2AM)
handleScheduledWarmup(): void {
this.warmAllEndpoints();
}

getWarmingMetrics() {
return {
...this.warmingMetrics,
successRate: this.warmingMetrics.totalWarmed > 0
? ((this.warmingMetrics.successCount / this.warmingMetrics.totalWarmed) * 100).toFixed(2) + '%'
: '0%',
};
}

getRegisteredEndpoints(): CacheableEndpoint[] {
return this.cacheableEndpoints;
}
}
41 changes: 39 additions & 2 deletions backend/src/modules/cache/cache.controller.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { Controller, Get, Post, Delete, UseGuards, Param } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { CacheStrategyService } from './cache-strategy.service';
import { CacheWarmingService } from './cache-warming.service';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';

@ApiTags('Cache')
@Controller('cache')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class CacheController {
constructor(private readonly cacheStrategy: CacheStrategyService) {}
constructor(
private readonly cacheStrategy: CacheStrategyService,
private readonly cacheWarming: CacheWarmingService,
) {}

@Get('metrics')
@ApiOperation({ summary: 'Get cache hit/miss metrics' })
Expand All @@ -22,4 +26,37 @@ export class CacheController {
this.cacheStrategy.resetMetrics();
return { message: 'Cache metrics reset' };
}

@Get('warming-metrics')
@ApiOperation({ summary: 'Get cache warming metrics' })
getWarmingMetrics() {
return this.cacheWarming.getWarmingMetrics();
}

@Get('registered-endpoints')
@ApiOperation({ summary: 'Get registered cacheable endpoints' })
getRegisteredEndpoints() {
return this.cacheWarming.getRegisteredEndpoints();
}

@Post('warm-all')
@ApiOperation({ summary: 'Warm all cacheable endpoints manually' })
async warmAllEndpoints() {
await this.cacheWarming.warmAllEndpoints();
return { message: 'Cache warming initiated' };
}

@Delete('invalidate/tag/:tag')
@ApiOperation({ summary: 'Invalidate all cache entries with the given tag' })
async invalidateByTag(@Param('tag') tag: string) {
await this.cacheStrategy.invalidateByTag(tag);
return { message: `Invalidated all keys with tag: ${tag}` };
}

@Delete('invalidate/pattern/:pattern')
@ApiOperation({ summary: 'Invalidate all cache entries matching the given pattern' })
async invalidateByPattern(@Param('pattern') pattern: string) {
await this.cacheStrategy.invalidateByPattern(pattern);
return { message: `Invalidated all keys matching pattern: ${pattern}` };
}
}
7 changes: 5 additions & 2 deletions backend/src/modules/cache/cache.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import * as redisStore from 'cache-manager-redis-store';
import { ConfigService } from '@nestjs/config';
import { CacheStrategyService } from './cache-strategy.service';
import { CacheController } from './cache.controller';
import { CacheWarmingService } from './cache-warming.service';
import { ScheduleModule } from '@nestjs/schedule';

@Module({
imports: [
ScheduleModule.forRoot(),
NestCacheModule.registerAsync({
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
Expand All @@ -27,8 +30,8 @@ import { CacheController } from './cache.controller';
},
}),
],
providers: [CacheStrategyService],
providers: [CacheStrategyService, CacheWarmingService],
controllers: [CacheController],
exports: [CacheStrategyService, NestCacheModule],
exports: [CacheStrategyService, NestCacheModule, CacheWarmingService],
})
export class CacheModule {}