diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 00c79e87..101bf20f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -32,6 +32,7 @@ import { UsersModule } from './users/users.module'; import { DisputesModule } from './disputes/disputes.module'; import { ContractModule } from './contract/contract.module'; import { CreatorEventsModule } from './creator-events/creator-events.module'; +import { CacheWarmingModule } from './cache/cache-warming.module'; @Module({ imports: [ @@ -94,6 +95,7 @@ import { CreatorEventsModule } from './creator-events/creator-events.module'; IndexerModule, ContractModule, CreatorEventsModule, + CacheWarmingModule, ], controllers: [AppController], diff --git a/backend/src/cache/cache-warming.config.ts b/backend/src/cache/cache-warming.config.ts new file mode 100644 index 00000000..ff6a9d93 --- /dev/null +++ b/backend/src/cache/cache-warming.config.ts @@ -0,0 +1,47 @@ +import { ConfigService } from '@nestjs/config'; + +export interface CacheWarmingStrategy { + enabled: boolean; + ttlSeconds: number; + activeEventsLimit: number; + trendingEventsLimit: number; + popularEventDetailsLimit: number; +} + +function numberFromConfig( + configService: ConfigService, + key: string, + defaultValue: number, +): number { + const value = Number(configService.get(key)); + + return Number.isFinite(value) && value > 0 ? value : defaultValue; +} + +export function getCacheWarmingStrategy( + configService: ConfigService, +): CacheWarmingStrategy { + return { + enabled: configService.get('CACHE_WARMING_ENABLED') !== 'false', + ttlSeconds: numberFromConfig( + configService, + 'CACHE_WARMING_TTL_SECONDS', + 600, + ), + activeEventsLimit: numberFromConfig( + configService, + 'CACHE_WARMING_ACTIVE_EVENTS_LIMIT', + 20, + ), + trendingEventsLimit: numberFromConfig( + configService, + 'CACHE_WARMING_TRENDING_EVENTS_LIMIT', + 20, + ), + popularEventDetailsLimit: numberFromConfig( + configService, + 'CACHE_WARMING_POPULAR_EVENT_DETAILS_LIMIT', + 5, + ), + }; +} diff --git a/backend/src/cache/cache-warming.keys.ts b/backend/src/cache/cache-warming.keys.ts new file mode 100644 index 00000000..fb218ff1 --- /dev/null +++ b/backend/src/cache/cache-warming.keys.ts @@ -0,0 +1,7 @@ +export const CACHE_WARMING_KEYS = { + activeEvents: 'cache-warming:active-events', + trendingEvents: 'cache-warming:trending-events', + platformStatistics: 'cache-warming:platform-statistics', + popularEventDetail: (eventId: string) => + `cache-warming:popular-event:${eventId}`, +} as const; diff --git a/backend/src/cache/cache-warming.module.ts b/backend/src/cache/cache-warming.module.ts new file mode 100644 index 00000000..454a8536 --- /dev/null +++ b/backend/src/cache/cache-warming.module.ts @@ -0,0 +1,12 @@ +import { CacheModule } from '@nestjs/cache-manager'; +import { Module } from '@nestjs/common'; +import { AnalyticsModule } from '../analytics/analytics.module'; +import { MarketsModule } from '../markets/markets.module'; +import { CacheWarmingService } from './warming.service'; + +@Module({ + imports: [CacheModule.register(), MarketsModule, AnalyticsModule], + providers: [CacheWarmingService], + exports: [CacheWarmingService], +}) +export class CacheWarmingModule {} diff --git a/backend/src/cache/warming.service.spec.ts b/backend/src/cache/warming.service.spec.ts new file mode 100644 index 00000000..3f01db3f --- /dev/null +++ b/backend/src/cache/warming.service.spec.ts @@ -0,0 +1,136 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AnalyticsService } from '../analytics/analytics.service'; +import { MarketStatus } from '../markets/dto/list-markets.dto'; +import { MarketsService } from '../markets/markets.service'; +import { CACHE_WARMING_KEYS } from './cache-warming.keys'; +import { CacheWarmingService } from './warming.service'; + +describe('CacheWarmingService', () => { + let service: CacheWarmingService; + let cacheManager: { set: jest.Mock }; + let marketsService: { + findAllFiltered: jest.Mock; + getTrendingMarkets: jest.Mock; + findByIdOrOnChainId: jest.Mock; + }; + let analyticsService: { getCategoryAnalytics: jest.Mock }; + + beforeEach(async () => { + cacheManager = { set: jest.fn().mockResolvedValue(undefined) }; + marketsService = { + findAllFiltered: jest.fn().mockResolvedValue({ data: [], total: 0 }), + getTrendingMarkets: jest.fn().mockResolvedValue({ + data: [{ id: 'popular-1' }, { id: 'popular-2' }], + total: 2, + }), + findByIdOrOnChainId: jest + .fn() + .mockImplementation((id: string) => Promise.resolve({ id })), + }; + analyticsService = { + getCategoryAnalytics: jest.fn().mockResolvedValue({ categories: [] }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CacheWarmingService, + { provide: CACHE_MANAGER, useValue: cacheManager }, + { provide: MarketsService, useValue: marketsService }, + { provide: AnalyticsService, useValue: analyticsService }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + const values: Record = { + CACHE_WARMING_TTL_SECONDS: '300', + CACHE_WARMING_ACTIVE_EVENTS_LIMIT: '10', + CACHE_WARMING_TRENDING_EVENTS_LIMIT: '8', + CACHE_WARMING_POPULAR_EVENT_DETAILS_LIMIT: '2', + }; + return values[key]; + }), + }, + }, + ], + }).compile(); + + service = module.get(CacheWarmingService); + }); + + it('warms active events, trending events, platform statistics, and popular details', async () => { + const result = await service.warmFrequentlyAccessedData(); + + expect(marketsService.findAllFiltered).toHaveBeenCalledWith({ + page: 1, + limit: 10, + status: MarketStatus.Open, + is_public: true, + }); + expect(marketsService.getTrendingMarkets).toHaveBeenCalledWith({ + page: 1, + limit: 8, + }); + expect(analyticsService.getCategoryAnalytics).toHaveBeenCalled(); + expect(marketsService.findByIdOrOnChainId).toHaveBeenCalledWith( + 'popular-1', + ); + expect(marketsService.findByIdOrOnChainId).toHaveBeenCalledWith( + 'popular-2', + ); + expect(cacheManager.set).toHaveBeenCalledWith( + CACHE_WARMING_KEYS.activeEvents, + { data: [], total: 0 }, + 300000, + ); + expect(cacheManager.set).toHaveBeenCalledWith( + CACHE_WARMING_KEYS.popularEventDetail('popular-1'), + { id: 'popular-1' }, + 300000, + ); + expect(result.failed).toEqual([]); + expect(result.warmed).toEqual( + expect.arrayContaining([ + CACHE_WARMING_KEYS.activeEvents, + CACHE_WARMING_KEYS.trendingEvents, + CACHE_WARMING_KEYS.platformStatistics, + CACHE_WARMING_KEYS.popularEventDetail('popular-1'), + CACHE_WARMING_KEYS.popularEventDetail('popular-2'), + ]), + ); + }); + + it('continues warming other keys when one loader fails', async () => { + marketsService.findAllFiltered.mockRejectedValueOnce(new Error('db down')); + + const result = await service.warmFrequentlyAccessedData(); + + expect(result.failed).toEqual([ + { key: CACHE_WARMING_KEYS.activeEvents, reason: 'db down' }, + ]); + expect(result.warmed).toContain(CACHE_WARMING_KEYS.trendingEvents); + expect(result.warmed).toContain(CACHE_WARMING_KEYS.platformStatistics); + }); + + it('skips warming when disabled by config', async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CacheWarmingService, + { provide: CACHE_MANAGER, useValue: cacheManager }, + { provide: MarketsService, useValue: marketsService }, + { provide: AnalyticsService, useValue: analyticsService }, + { + provide: ConfigService, + useValue: { get: jest.fn(() => 'false') }, + }, + ], + }).compile(); + + const disabledService = module.get(CacheWarmingService); + const result = await disabledService.warmFrequentlyAccessedData(); + + expect(result).toEqual({ warmed: [], failed: [] }); + expect(cacheManager.set).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/cache/warming.service.ts b/backend/src/cache/warming.service.ts new file mode 100644 index 00000000..c429d5b1 --- /dev/null +++ b/backend/src/cache/warming.service.ts @@ -0,0 +1,139 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Cron } from '@nestjs/schedule'; +import type { Cache } from 'cache-manager'; +import { AnalyticsService } from '../analytics/analytics.service'; +import { MarketStatus } from '../markets/dto/list-markets.dto'; +import { MarketsService } from '../markets/markets.service'; +import { + CacheWarmingStrategy, + getCacheWarmingStrategy, +} from './cache-warming.config'; +import { CACHE_WARMING_KEYS } from './cache-warming.keys'; + +interface CacheWarmResult { + warmed: string[]; + failed: Array<{ key: string; reason: string }>; +} + +@Injectable() +export class CacheWarmingService { + private readonly logger = new Logger(CacheWarmingService.name); + private readonly strategy: CacheWarmingStrategy; + + constructor( + @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, + private readonly marketsService: MarketsService, + private readonly analyticsService: AnalyticsService, + configService: ConfigService, + ) { + this.strategy = getCacheWarmingStrategy(configService); + } + + @Cron('0 */10 * * * *') + async warmFrequentlyAccessedData(): Promise { + if (!this.strategy.enabled) { + this.logger.debug('Cache warming skipped because it is disabled'); + return { warmed: [], failed: [] }; + } + + this.logger.log('Cache warming started'); + const result: CacheWarmResult = { warmed: [], failed: [] }; + + const [activeEvents, trendingEvents, platformStatistics] = + await Promise.all([ + this.warmActiveEvents(result), + this.warmTrendingEvents(result), + this.warmPlatformStatistics(result), + ]); + + await this.warmPopularEventDetails(result, trendingEvents?.data ?? []); + + this.logger.log( + `Cache warming finished: warmed=${result.warmed.length}, failed=${result.failed.length}`, + ); + + return result; + } + + private async warmActiveEvents( + result: CacheWarmResult, + ): Promise> | null> { + return this.captureWarmOperation( + CACHE_WARMING_KEYS.activeEvents, + async () => + this.marketsService.findAllFiltered({ + page: 1, + limit: this.strategy.activeEventsLimit, + status: MarketStatus.Open, + is_public: true, + }), + result, + ); + } + + private async warmTrendingEvents( + result: CacheWarmResult, + ): Promise> | null> { + return this.captureWarmOperation( + CACHE_WARMING_KEYS.trendingEvents, + async () => + this.marketsService.getTrendingMarkets({ + page: 1, + limit: this.strategy.trendingEventsLimit, + }), + result, + ); + } + + private async warmPlatformStatistics( + result: CacheWarmResult, + ): Promise + > | null> { + return this.captureWarmOperation( + CACHE_WARMING_KEYS.platformStatistics, + () => this.analyticsService.getCategoryAnalytics(), + result, + ); + } + + private async warmPopularEventDetails( + result: CacheWarmResult, + trendingEvents: Array<{ id: string }>, + ): Promise { + const popularIds = trendingEvents + .slice(0, this.strategy.popularEventDetailsLimit) + .map((event) => event.id); + + await Promise.all( + popularIds.map((eventId) => + this.captureWarmOperation( + CACHE_WARMING_KEYS.popularEventDetail(eventId), + () => this.marketsService.findByIdOrOnChainId(eventId), + result, + ), + ), + ); + } + + private async captureWarmOperation( + key: string, + loader: () => Promise, + result: CacheWarmResult, + ): Promise { + try { + const value = await loader(); + await this.cacheManager.set(key, value, this.strategy.ttlSeconds * 1000); + result.warmed.push(key); + this.logger.debug(`Cache warmed: ${key}`); + return value; + } catch (error) { + const reason = error instanceof Error ? error.message : 'Unknown error'; + result.failed.push({ key, reason }); + this.logger.warn(`Cache warming failed for ${key}: ${reason}`); + return null; + } + } +}