Files
elasticsearch/ts/core/plugins/built-in/cache-plugin.ts

258 lines
5.9 KiB
TypeScript
Raw Normal View History

/**
* Cache Plugin
*
* Caches GET request responses to reduce load on Elasticsearch
*/
import { defaultLogger } from '../../observability/logger.js';
import type { Plugin, PluginContext, PluginResponse, CachePluginConfig } from '../types.js';
/**
* Cache entry
*/
interface CacheEntry<T = unknown> {
response: PluginResponse<T>;
cachedAt: number;
expiresAt: number;
hits: number;
}
/**
* Default configuration
*/
const DEFAULT_CONFIG: Required<CachePluginConfig> = {
enabled: true,
maxEntries: 1000,
defaultTTL: 60, // 60 seconds
keyGenerator: (context: PluginContext) => {
const query = context.request.querystring
? JSON.stringify(context.request.querystring)
: '';
const body = context.request.body ? JSON.stringify(context.request.body) : '';
return `${context.request.method}:${context.request.path}:${query}:${body}`;
},
methods: ['GET'],
};
/**
* Create cache plugin
*/
export function createCachePlugin(config: CachePluginConfig = {}): Plugin {
const pluginConfig: Required<CachePluginConfig> = {
...DEFAULT_CONFIG,
...config,
keyGenerator: config.keyGenerator || DEFAULT_CONFIG.keyGenerator,
};
const logger = defaultLogger;
const cache = new Map<string, CacheEntry>();
let cacheHits = 0;
let cacheMisses = 0;
/**
* Get from cache
*/
function getFromCache<T>(key: string): PluginResponse<T> | null {
const entry = cache.get(key) as CacheEntry<T> | undefined;
if (!entry) {
cacheMisses++;
return null;
}
// Check expiration
const now = Date.now();
if (now >= entry.expiresAt) {
cache.delete(key);
cacheMisses++;
return null;
}
// Update stats
entry.hits++;
cacheHits++;
logger.debug('Cache hit', {
key,
age: now - entry.cachedAt,
hits: entry.hits,
});
return entry.response;
}
/**
* Set in cache
*/
function setInCache<T>(key: string, response: PluginResponse<T>, ttl: number): void {
// Check if cache is full
if (cache.size >= pluginConfig.maxEntries && !cache.has(key)) {
evictOldest();
}
const now = Date.now();
cache.set(key, {
response,
cachedAt: now,
expiresAt: now + ttl * 1000,
hits: 0,
});
logger.debug('Cache set', { key, ttl });
}
/**
* Evict oldest entry
*/
function evictOldest(): void {
let oldestKey: string | null = null;
let oldestTime = Infinity;
for (const [key, entry] of cache) {
if (entry.cachedAt < oldestTime) {
oldestTime = entry.cachedAt;
oldestKey = key;
}
}
if (oldestKey) {
cache.delete(oldestKey);
logger.debug('Cache evicted', { key: oldestKey });
}
}
/**
* Clear cache
*/
function clearCache(): void {
cache.clear();
cacheHits = 0;
cacheMisses = 0;
logger.info('Cache cleared');
}
/**
* Clean expired entries
*/
function cleanExpired(): void {
const now = Date.now();
let cleaned = 0;
for (const [key, entry] of cache) {
if (now >= entry.expiresAt) {
cache.delete(key);
cleaned++;
}
}
if (cleaned > 0) {
logger.debug('Cache cleaned', { expired: cleaned });
}
}
// Periodic cleanup
let cleanupTimer: NodeJS.Timeout;
return {
name: 'cache',
version: '1.0.0',
priority: 50, // Execute in the middle
initialize: () => {
// Start periodic cleanup
cleanupTimer = setInterval(cleanExpired, 60000); // Every minute
logger.info('Cache plugin initialized', {
maxEntries: pluginConfig.maxEntries,
defaultTTL: pluginConfig.defaultTTL,
methods: pluginConfig.methods,
});
},
beforeRequest: <T>(context: PluginContext): PluginContext | null => {
if (!pluginConfig.enabled) {
return context;
}
// Only cache configured methods
if (!pluginConfig.methods.includes(context.request.method)) {
return context;
}
// Generate cache key
const cacheKey = pluginConfig.keyGenerator(context);
// Check cache
const cachedResponse = getFromCache<T>(cacheKey);
if (cachedResponse) {
// Store cached response in shared data for afterResponse to use
context.shared.set('cache_hit', true);
context.shared.set('cached_response', cachedResponse);
context.shared.set('cache_key', cacheKey);
} else {
context.shared.set('cache_hit', false);
context.shared.set('cache_key', cacheKey);
}
return context;
},
afterResponse: <T>(context: PluginContext, response: PluginResponse<T>) => {
if (!pluginConfig.enabled) {
return response;
}
const cacheHit = context.shared.get('cache_hit');
// If it was a cache hit, return the cached response
if (cacheHit) {
return context.shared.get('cached_response') as PluginResponse<T>;
}
// Otherwise, cache this response
const cacheKey = context.shared.get('cache_key') as string;
if (cacheKey && pluginConfig.methods.includes(context.request.method)) {
// Only cache successful responses
if (response.statusCode >= 200 && response.statusCode < 300) {
setInCache(cacheKey, response, pluginConfig.defaultTTL);
}
}
return response;
},
destroy: () => {
if (cleanupTimer) {
clearInterval(cleanupTimer);
}
clearCache();
logger.info('Cache plugin destroyed', {
totalHits: cacheHits,
totalMisses: cacheMisses,
hitRatio: cacheHits / (cacheHits + cacheMisses) || 0,
});
},
};
}
/**
* Get cache statistics
*/
export function getCacheStats(plugin: Plugin): {
size: number;
hits: number;
misses: number;
hitRatio: number;
} | null {
if (plugin.name !== 'cache') {
return null;
}
// This would require exposing stats from the plugin
// For now, return null
return null;
}