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
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -94,6 +95,7 @@ import { CreatorEventsModule } from './creator-events/creator-events.module';
IndexerModule,
ContractModule,
CreatorEventsModule,
CacheWarmingModule,
],

controllers: [AppController],
Expand Down
47 changes: 47 additions & 0 deletions backend/src/cache/cache-warming.config.ts
Original file line number Diff line number Diff line change
@@ -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<string | number>(key));

return Number.isFinite(value) && value > 0 ? value : defaultValue;
}

export function getCacheWarmingStrategy(
configService: ConfigService,
): CacheWarmingStrategy {
return {
enabled: configService.get<string>('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,
),
};
}
7 changes: 7 additions & 0 deletions backend/src/cache/cache-warming.keys.ts
Original file line number Diff line number Diff line change
@@ -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;
12 changes: 12 additions & 0 deletions backend/src/cache/cache-warming.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
136 changes: 136 additions & 0 deletions backend/src/cache/warming.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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();
});
});
139 changes: 139 additions & 0 deletions backend/src/cache/warming.service.ts
Original file line number Diff line number Diff line change
@@ -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<CacheWarmResult> {
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] =

Check failure on line 44 in backend/src/cache/warming.service.ts

View workflow job for this annotation

GitHub Actions / Lint

'platformStatistics' is assigned a value but never used

Check failure on line 44 in backend/src/cache/warming.service.ts

View workflow job for this annotation

GitHub Actions / Lint

'activeEvents' is assigned a value but never used
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<Awaited<ReturnType<MarketsService['findAllFiltered']>> | 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<Awaited<ReturnType<MarketsService['getTrendingMarkets']>> | 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<Awaited<
ReturnType<AnalyticsService['getCategoryAnalytics']>
> | null> {
return this.captureWarmOperation(
CACHE_WARMING_KEYS.platformStatistics,
() => this.analyticsService.getCategoryAnalytics(),
result,
);
}

private async warmPopularEventDetails(
result: CacheWarmResult,
trendingEvents: Array<{ id: string }>,
): Promise<void> {
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<T>(
key: string,
loader: () => Promise<T>,
result: CacheWarmResult,
): Promise<T | null> {
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;
}
}
}
Loading