307 lines
9.5 KiB
TypeScript
307 lines
9.5 KiB
TypeScript
import { LRUCache } from 'lru-cache';
|
|
import { GameService, GameSearchQuery, PaginatedResult, Game } from './GameService';
|
|
import { PerformanceMetrics } from './PerformanceMetrics';
|
|
import { FallbackCache } from './FallbackCache';
|
|
import { logger } from '../utils/logger';
|
|
import { config } from '../config';
|
|
|
|
/**
|
|
* LRU Cache for game search results
|
|
*
|
|
* Configuration:
|
|
* - Max entries: 1000 (supports ~1000 unique search queries)
|
|
* - TTL: 5 minutes (300,000ms)
|
|
* - Size calculation: Based on result count
|
|
*
|
|
* Cache invalidation: Automatic via TTL (no manual invalidation needed
|
|
* since Flashpoint database is read-only and changes are rare)
|
|
*/
|
|
export class GameSearchCache {
|
|
private static cache = new LRUCache<string, PaginatedResult<Game>>({
|
|
max: 1000, // Maximum 1000 cached search queries
|
|
maxSize: 10000, // Maximum total items across all cached results (10k games)
|
|
ttl: 1000 * 60 * 5, // 5 minutes TTL
|
|
updateAgeOnGet: true, // Reset TTL on cache hit
|
|
allowStale: false, // Never return stale data
|
|
|
|
// Calculate size based on number of results (larger result sets = more memory)
|
|
sizeCalculation: (value) => {
|
|
if (!value || !value.data || !Array.isArray(value.data)) {
|
|
logger.error('[GameSearchCache] Invalid value in sizeCalculation', { value });
|
|
return 1; // Return minimum valid size to prevent cache errors
|
|
}
|
|
// LRU cache requires positive integer (>= 1), so add 1 to handle empty arrays
|
|
return value.data.length + 1;
|
|
},
|
|
|
|
// Optional: Log evictions for monitoring
|
|
dispose: (value, key) => {
|
|
logger.debug(`[GameSearchCache] Evicted cache entry: ${key}`);
|
|
},
|
|
});
|
|
|
|
private static gameService = new GameService();
|
|
private static fallbackCache = new FallbackCache<PaginatedResult<Game>>(500);
|
|
|
|
/**
|
|
* Generate cache key from search query
|
|
* Ensures consistent key generation for same queries regardless of parameter order
|
|
*/
|
|
private static generateCacheKey(query: GameSearchQuery): string {
|
|
const normalizedQuery = {
|
|
search: query.search || '',
|
|
platforms: query.platforms?.sort().join(',') || '',
|
|
series: query.series?.sort().join(',') || '',
|
|
developers: query.developers?.sort().join(',') || '',
|
|
publishers: query.publishers?.sort().join(',') || '',
|
|
playModes: query.playModes?.sort().join(',') || '',
|
|
languages: query.languages?.sort().join(',') || '',
|
|
library: query.library || '',
|
|
tags: query.tags?.sort().join(',') || '',
|
|
yearFrom: query.yearFrom || '',
|
|
yearTo: query.yearTo || '',
|
|
dateAddedSince: query.dateAddedSince || '',
|
|
dateModifiedSince: query.dateModifiedSince || '',
|
|
sortBy: query.sortBy,
|
|
sortOrder: query.sortOrder,
|
|
page: query.page,
|
|
limit: query.limit,
|
|
showBroken: query.showBroken,
|
|
showExtreme: query.showExtreme,
|
|
fields: query.fields || 'detail',
|
|
};
|
|
|
|
return JSON.stringify(normalizedQuery);
|
|
}
|
|
|
|
/**
|
|
* Search games with caching and graceful degradation
|
|
* Checks cache first, falls back to database query if cache miss
|
|
* If database fails, returns stale data from fallback cache
|
|
*/
|
|
static async searchGames(
|
|
query: GameSearchQuery
|
|
): Promise<PaginatedResult<Game> & { fromCache?: boolean; cacheAge?: number }> {
|
|
const cacheKey = this.generateCacheKey(query);
|
|
|
|
// Try to get from primary cache
|
|
const cached = this.cache.get(cacheKey);
|
|
if (cached) {
|
|
logger.debug(`[GameSearchCache] Cache HIT: ${cacheKey.substring(0, 100)}...`);
|
|
PerformanceMetrics.recordCacheHit('gameSearch');
|
|
return cached;
|
|
}
|
|
|
|
// Cache miss - query database
|
|
logger.debug(`[GameSearchCache] Cache MISS: ${cacheKey.substring(0, 100)}...`);
|
|
PerformanceMetrics.recordCacheMiss('gameSearch');
|
|
const startTime = performance.now();
|
|
|
|
try {
|
|
const result = await this.gameService.searchGames(query);
|
|
|
|
const executionTime = Math.round(performance.now() - startTime);
|
|
logger.debug(`[GameSearchCache] Query executed in ${executionTime}ms, caching result`);
|
|
|
|
// Validate result before caching
|
|
if (!result || !result.data || !Array.isArray(result.data)) {
|
|
logger.error('[GameSearchCache] Invalid result from database query', {
|
|
result,
|
|
cacheKey: cacheKey.substring(0, 200),
|
|
});
|
|
throw new Error('Invalid database query result');
|
|
}
|
|
|
|
// Store in both primary and fallback cache
|
|
this.cache.set(cacheKey, result);
|
|
this.fallbackCache.set(cacheKey, result);
|
|
|
|
return result;
|
|
} catch (error) {
|
|
// Database query failed - try fallback cache
|
|
logger.warn('[GameSearchCache] Database query failed, attempting fallback cache', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
|
|
const fallback = this.fallbackCache.get(cacheKey);
|
|
if (fallback) {
|
|
logger.info('[GameSearchCache] Serving stale data from fallback cache', {
|
|
age: `${Math.round(fallback.age / 1000)}s`,
|
|
cachedAt: new Date(fallback.cachedAt).toISOString(),
|
|
});
|
|
|
|
// Return stale data with metadata
|
|
return {
|
|
...fallback.data,
|
|
fromCache: true,
|
|
cacheAge: fallback.age,
|
|
};
|
|
}
|
|
|
|
// No fallback available - rethrow error
|
|
logger.error('[GameSearchCache] No fallback data available, error will propagate');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear entire cache
|
|
* Use when Flashpoint database is updated externally
|
|
* Note: Fallback cache is preserved for graceful degradation
|
|
*/
|
|
static clearCache(): void {
|
|
const size = this.cache.size;
|
|
this.cache.clear();
|
|
logger.info(
|
|
`[GameSearchCache] Cleared primary cache (${size} entries), fallback cache preserved`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Clear both primary and fallback caches
|
|
*/
|
|
static clearAllCaches(): void {
|
|
const primarySize = this.cache.size;
|
|
const fallbackSize = this.fallbackCache.getStats().size;
|
|
|
|
this.cache.clear();
|
|
this.fallbackCache.clear();
|
|
|
|
logger.info(
|
|
`[GameSearchCache] Cleared all caches (primary: ${primarySize}, fallback: ${fallbackSize} entries)`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get cache statistics for monitoring
|
|
*/
|
|
static getStats(): {
|
|
primary: { size: number; max: number; ttl: number };
|
|
fallback: { size: number; max: number };
|
|
} {
|
|
return {
|
|
primary: {
|
|
size: this.cache.size,
|
|
max: this.cache.max,
|
|
ttl: 1000 * 60 * 5, // 5 minutes
|
|
},
|
|
fallback: this.fallbackCache.getStats(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if a query is cached
|
|
*/
|
|
static has(query: GameSearchQuery): boolean {
|
|
const cacheKey = this.generateCacheKey(query);
|
|
return this.cache.has(cacheKey);
|
|
}
|
|
|
|
/**
|
|
* Pre-warm cache with common queries
|
|
* Call this after database initialization for instant first page load
|
|
*
|
|
* Pre-warms:
|
|
* - Default home page query (first page, default sort)
|
|
* - Games library (first page)
|
|
* - Animations library (first page)
|
|
*/
|
|
static async prewarmCache(): Promise<void> {
|
|
if (!config.enableCachePrewarm) {
|
|
logger.info('[GameSearchCache] Cache pre-warming disabled');
|
|
return;
|
|
}
|
|
|
|
logger.info('[GameSearchCache] Starting cache pre-warming...');
|
|
const startTime = performance.now();
|
|
|
|
// Common queries to pre-warm
|
|
const commonQueries: Partial<GameSearchQuery>[] = [
|
|
// Default home page - games library
|
|
{ library: 'arcade', page: 1, limit: 50, sortBy: 'title', sortOrder: 'asc', fields: 'list' },
|
|
// Default home page - animations library
|
|
{ library: 'theatre', page: 1, limit: 50, sortBy: 'title', sortOrder: 'asc', fields: 'list' },
|
|
// Recently added games
|
|
{
|
|
library: 'arcade',
|
|
page: 1,
|
|
limit: 50,
|
|
sortBy: 'dateAdded',
|
|
sortOrder: 'desc',
|
|
fields: 'list',
|
|
},
|
|
// Recently added animations
|
|
{
|
|
library: 'theatre',
|
|
page: 1,
|
|
limit: 50,
|
|
sortBy: 'dateAdded',
|
|
sortOrder: 'desc',
|
|
fields: 'list',
|
|
},
|
|
];
|
|
|
|
let successCount = 0;
|
|
let failCount = 0;
|
|
|
|
for (const partialQuery of commonQueries) {
|
|
const query: GameSearchQuery = {
|
|
search: '',
|
|
page: 1,
|
|
limit: 50,
|
|
sortBy: 'title',
|
|
sortOrder: 'asc',
|
|
fields: 'list',
|
|
showBroken: false,
|
|
showExtreme: false,
|
|
...partialQuery,
|
|
};
|
|
|
|
try {
|
|
const queryStart = performance.now();
|
|
await this.searchGames(query);
|
|
const queryDuration = Math.round(performance.now() - queryStart);
|
|
|
|
logger.debug(`[GameSearchCache] Pre-warmed query in ${queryDuration}ms`, {
|
|
library: query.library,
|
|
sortBy: query.sortBy,
|
|
});
|
|
|
|
successCount++;
|
|
} catch (error) {
|
|
logger.warn('[GameSearchCache] Failed to pre-warm query', {
|
|
query: partialQuery,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
failCount++;
|
|
}
|
|
}
|
|
|
|
const totalDuration = Math.round(performance.now() - startTime);
|
|
logger.info(`[GameSearchCache] Cache pre-warming completed in ${totalDuration}ms`, {
|
|
success: successCount,
|
|
failed: failCount,
|
|
total: commonQueries.length,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get pre-warm status
|
|
*/
|
|
static isPrewarmed(): boolean {
|
|
// Check if at least the default games query is cached
|
|
const defaultQuery: GameSearchQuery = {
|
|
search: '',
|
|
library: 'arcade',
|
|
page: 1,
|
|
limit: 50,
|
|
sortBy: 'title',
|
|
sortOrder: 'asc',
|
|
fields: 'list',
|
|
showBroken: false,
|
|
showExtreme: false,
|
|
};
|
|
return this.has(defaultQuery);
|
|
}
|
|
}
|