Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled
- Remove middleware reference from Gitea Traefik labels (caused routing issues) - Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s) - Add explicit service reference in Traefik labels - Fix intermittent 504 timeouts by improving PostgreSQL connection handling Fixes Gitea unreachability via git.michaelschiemer.de
400 lines
11 KiB
JavaScript
400 lines
11 KiB
JavaScript
/**
|
|
* Cache Manager Module
|
|
*
|
|
* Provides intelligent caching for API responses and computed values.
|
|
* Features:
|
|
* - Memory cache
|
|
* - IndexedDB cache
|
|
* - Cache invalidation strategies
|
|
* - Cache warming
|
|
* - Cache analytics
|
|
* - Integration with RequestDeduplicator
|
|
*/
|
|
|
|
import { Logger } from '../../core/logger.js';
|
|
|
|
/**
|
|
* Cache strategies
|
|
*/
|
|
export const CacheStrategy = {
|
|
NO_CACHE: 'no-cache',
|
|
CACHE_FIRST: 'cache-first',
|
|
NETWORK_FIRST: 'network-first',
|
|
STALE_WHILE_REVALIDATE: 'stale-while-revalidate',
|
|
NETWORK_ONLY: 'network-only',
|
|
CACHE_ONLY: 'cache-only'
|
|
};
|
|
|
|
/**
|
|
* CacheManager - Intelligent caching system
|
|
*/
|
|
export class CacheManager {
|
|
constructor(config = {}) {
|
|
this.config = {
|
|
defaultStrategy: config.defaultStrategy || CacheStrategy.STALE_WHILE_REVALIDATE,
|
|
defaultTTL: config.defaultTTL || 3600000, // 1 hour
|
|
maxMemorySize: config.maxMemorySize || 50, // Max 50 items in memory
|
|
enableIndexedDB: config.enableIndexedDB ?? true,
|
|
indexedDBName: config.indexedDBName || 'app-cache',
|
|
indexedDBVersion: config.indexedDBVersion || 1,
|
|
enableAnalytics: config.enableAnalytics ?? false,
|
|
...config
|
|
};
|
|
|
|
this.memoryCache = new Map(); // Map<key, CacheEntry>
|
|
this.indexedDBCache = null;
|
|
this.analytics = {
|
|
hits: 0,
|
|
misses: 0,
|
|
sets: 0,
|
|
deletes: 0
|
|
};
|
|
|
|
// Initialize
|
|
this.init();
|
|
}
|
|
|
|
/**
|
|
* Create a new CacheManager instance
|
|
*/
|
|
static create(config = {}) {
|
|
return new CacheManager(config);
|
|
}
|
|
|
|
/**
|
|
* Initialize cache manager
|
|
*/
|
|
async init() {
|
|
// Initialize IndexedDB if enabled
|
|
if (this.config.enableIndexedDB && 'indexedDB' in window) {
|
|
try {
|
|
await this.initIndexedDB();
|
|
} catch (error) {
|
|
Logger.error('[CacheManager] Failed to initialize IndexedDB', error);
|
|
this.config.enableIndexedDB = false;
|
|
}
|
|
}
|
|
|
|
Logger.info('[CacheManager] Initialized', {
|
|
strategy: this.config.defaultStrategy,
|
|
memoryCache: true,
|
|
indexedDB: this.config.enableIndexedDB
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Initialize IndexedDB
|
|
*/
|
|
async initIndexedDB() {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(this.config.indexedDBName, this.config.indexedDBVersion);
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => {
|
|
this.indexedDBCache = request.result;
|
|
resolve();
|
|
};
|
|
|
|
request.onupgradeneeded = (event) => {
|
|
const db = event.target.result;
|
|
if (!db.objectStoreNames.contains('cache')) {
|
|
db.createObjectStore('cache', { keyPath: 'key' });
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get value from cache
|
|
*/
|
|
async get(key, options = {}) {
|
|
const strategy = options.strategy || this.config.defaultStrategy;
|
|
const ttl = options.ttl || this.config.defaultTTL;
|
|
|
|
// Check memory cache first
|
|
const memoryEntry = this.memoryCache.get(key);
|
|
if (memoryEntry && !this.isExpired(memoryEntry, ttl)) {
|
|
this.analytics.hits++;
|
|
Logger.debug('[CacheManager] Cache hit (memory)', key);
|
|
return memoryEntry.value;
|
|
}
|
|
|
|
// Check IndexedDB if enabled
|
|
if (this.config.enableIndexedDB && this.indexedDBCache) {
|
|
try {
|
|
const indexedDBEntry = await this.getFromIndexedDB(key);
|
|
if (indexedDBEntry && !this.isExpired(indexedDBEntry, ttl)) {
|
|
// Promote to memory cache
|
|
this.memoryCache.set(key, indexedDBEntry);
|
|
this.analytics.hits++;
|
|
Logger.debug('[CacheManager] Cache hit (IndexedDB)', key);
|
|
return indexedDBEntry.value;
|
|
}
|
|
} catch (error) {
|
|
Logger.error('[CacheManager] IndexedDB get error', error);
|
|
}
|
|
}
|
|
|
|
this.analytics.misses++;
|
|
Logger.debug('[CacheManager] Cache miss', key);
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Set value in cache
|
|
*/
|
|
async set(key, value, options = {}) {
|
|
const ttl = options.ttl || this.config.defaultTTL;
|
|
const strategy = options.strategy || this.config.defaultStrategy;
|
|
|
|
const entry = {
|
|
key,
|
|
value,
|
|
timestamp: Date.now(),
|
|
ttl,
|
|
strategy
|
|
};
|
|
|
|
// Store in memory cache
|
|
this.memoryCache.set(key, entry);
|
|
|
|
// Limit memory cache size
|
|
if (this.memoryCache.size > this.config.maxMemorySize) {
|
|
const firstKey = this.memoryCache.keys().next().value;
|
|
this.memoryCache.delete(firstKey);
|
|
}
|
|
|
|
// Store in IndexedDB if enabled
|
|
if (this.config.enableIndexedDB && this.indexedDBCache) {
|
|
try {
|
|
await this.setInIndexedDB(entry);
|
|
} catch (error) {
|
|
Logger.error('[CacheManager] IndexedDB set error', error);
|
|
}
|
|
}
|
|
|
|
this.analytics.sets++;
|
|
Logger.debug('[CacheManager] Cache set', key);
|
|
}
|
|
|
|
/**
|
|
* Get or compute value (cache-aside pattern)
|
|
*/
|
|
async getOrSet(key, computeFn, options = {}) {
|
|
const strategy = options.strategy || this.config.defaultStrategy;
|
|
|
|
// Try cache first (unless network-only)
|
|
if (strategy !== CacheStrategy.NETWORK_ONLY) {
|
|
const cached = await this.get(key, options);
|
|
if (cached !== null) {
|
|
return cached;
|
|
}
|
|
}
|
|
|
|
// Compute value
|
|
const value = await computeFn();
|
|
|
|
// Store in cache (unless no-cache)
|
|
if (strategy !== CacheStrategy.NO_CACHE) {
|
|
await this.set(key, value, options);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Delete value from cache
|
|
*/
|
|
async delete(key) {
|
|
// Delete from memory
|
|
this.memoryCache.delete(key);
|
|
|
|
// Delete from IndexedDB
|
|
if (this.config.enableIndexedDB && this.indexedDBCache) {
|
|
try {
|
|
await this.deleteFromIndexedDB(key);
|
|
} catch (error) {
|
|
Logger.error('[CacheManager] IndexedDB delete error', error);
|
|
}
|
|
}
|
|
|
|
this.analytics.deletes++;
|
|
Logger.debug('[CacheManager] Cache delete', key);
|
|
}
|
|
|
|
/**
|
|
* Clear all cache
|
|
*/
|
|
async clear() {
|
|
this.memoryCache.clear();
|
|
|
|
if (this.config.enableIndexedDB && this.indexedDBCache) {
|
|
try {
|
|
await this.clearIndexedDB();
|
|
} catch (error) {
|
|
Logger.error('[CacheManager] IndexedDB clear error', error);
|
|
}
|
|
}
|
|
|
|
Logger.info('[CacheManager] Cache cleared');
|
|
}
|
|
|
|
/**
|
|
* Invalidate cache by pattern
|
|
*/
|
|
async invalidate(pattern) {
|
|
const keysToDelete = [];
|
|
|
|
// Find matching keys in memory
|
|
for (const key of this.memoryCache.keys()) {
|
|
if (this.matchesPattern(key, pattern)) {
|
|
keysToDelete.push(key);
|
|
}
|
|
}
|
|
|
|
// Delete matching keys
|
|
for (const key of keysToDelete) {
|
|
await this.delete(key);
|
|
}
|
|
|
|
Logger.info('[CacheManager] Invalidated cache', { pattern, count: keysToDelete.length });
|
|
}
|
|
|
|
/**
|
|
* Check if key matches pattern
|
|
*/
|
|
matchesPattern(key, pattern) {
|
|
if (typeof pattern === 'string') {
|
|
return key.includes(pattern);
|
|
}
|
|
if (pattern instanceof RegExp) {
|
|
return pattern.test(key);
|
|
}
|
|
if (typeof pattern === 'function') {
|
|
return pattern(key);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if cache entry is expired
|
|
*/
|
|
isExpired(entry, ttl) {
|
|
if (!entry || !entry.timestamp) {
|
|
return true;
|
|
}
|
|
|
|
const age = Date.now() - entry.timestamp;
|
|
return age > (entry.ttl || ttl);
|
|
}
|
|
|
|
/**
|
|
* Get from IndexedDB
|
|
*/
|
|
async getFromIndexedDB(key) {
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.indexedDBCache.transaction(['cache'], 'readonly');
|
|
const store = transaction.objectStore('cache');
|
|
const request = store.get(key);
|
|
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set in IndexedDB
|
|
*/
|
|
async setInIndexedDB(entry) {
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.indexedDBCache.transaction(['cache'], 'readwrite');
|
|
const store = transaction.objectStore('cache');
|
|
const request = store.put(entry);
|
|
|
|
request.onsuccess = () => resolve();
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete from IndexedDB
|
|
*/
|
|
async deleteFromIndexedDB(key) {
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.indexedDBCache.transaction(['cache'], 'readwrite');
|
|
const store = transaction.objectStore('cache');
|
|
const request = store.delete(key);
|
|
|
|
request.onsuccess = () => resolve();
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clear IndexedDB
|
|
*/
|
|
async clearIndexedDB() {
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.indexedDBCache.transaction(['cache'], 'readwrite');
|
|
const store = transaction.objectStore('cache');
|
|
const request = store.clear();
|
|
|
|
request.onsuccess = () => resolve();
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Warm cache (preload values)
|
|
*/
|
|
async warm(keys, computeFn) {
|
|
Logger.info('[CacheManager] Warming cache', { count: keys.length });
|
|
|
|
for (const key of keys) {
|
|
try {
|
|
await this.getOrSet(key, () => computeFn(key));
|
|
} catch (error) {
|
|
Logger.error(`[CacheManager] Failed to warm cache for ${key}`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get cache analytics
|
|
*/
|
|
getAnalytics() {
|
|
const total = this.analytics.hits + this.analytics.misses;
|
|
const hitRate = total > 0 ? (this.analytics.hits / total) * 100 : 0;
|
|
|
|
return {
|
|
...this.analytics,
|
|
total,
|
|
hitRate: Math.round(hitRate * 100) / 100,
|
|
memorySize: this.memoryCache.size,
|
|
memoryMaxSize: this.config.maxMemorySize
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Reset analytics
|
|
*/
|
|
resetAnalytics() {
|
|
this.analytics = {
|
|
hits: 0,
|
|
misses: 0,
|
|
sets: 0,
|
|
deletes: 0
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Destroy cache manager
|
|
*/
|
|
destroy() {
|
|
this.memoryCache.clear();
|
|
this.indexedDBCache = null;
|
|
Logger.info('[CacheManager] Destroyed');
|
|
}
|
|
}
|
|
|