ObsiViewer/server/perf/metadata-cache.js

135 lines
2.9 KiB
JavaScript

/**
* MetadataCache - TTL + simple LRU cache
*
* Features:
* - TTL-based expiration
* - Simple FIFO eviction when maxItems exceeded
* - read-through helper for async producers
* - Pseudo-LRU via Map re-insertion
*/
export class MetadataCache {
constructor({ ttlMs = 5 * 60 * 1000, maxItems = 10_000 } = {}) {
this.ttlMs = ttlMs;
this.maxItems = maxItems;
this.store = new Map(); // key -> { value, exp }
this.stats = {
hits: 0,
misses: 0,
evictions: 0,
sets: 0
};
}
_now() {
return Date.now();
}
/**
* Evict oldest entries if cache exceeds maxItems
*/
_evictIfNeeded() {
if (this.store.size <= this.maxItems) return;
const toEvict = this.store.size - this.maxItems + Math.floor(this.maxItems * 0.1);
let evicted = 0;
for (const k of this.store.keys()) {
this.store.delete(k);
evicted++;
if (evicted >= toEvict) break;
}
this.stats.evictions += evicted;
}
/**
* Get value from cache
* Returns { hit: boolean, value: any }
*/
get(key) {
const entry = this.store.get(key);
if (!entry) {
this.stats.misses++;
return { hit: false, value: undefined };
}
// Check expiration
if (entry.exp < this._now()) {
this.store.delete(key);
this.stats.misses++;
return { hit: false, value: undefined };
}
// Touch for pseudo-LRU: re-insert at end
this.store.delete(key);
this.store.set(key, entry);
this.stats.hits++;
return { hit: true, value: entry.value };
}
/**
* Set value in cache with optional TTL override
*/
set(key, value, { ttlMs = this.ttlMs } = {}) {
const exp = this._now() + ttlMs;
this.store.set(key, { value, exp });
this.stats.sets++;
this._evictIfNeeded();
}
/**
* Delete specific key
*/
delete(key) {
this.store.delete(key);
}
/**
* Clear entire cache
*/
clear() {
this.store.clear();
}
/**
* Read-through helper: get from cache or call producer
* Returns { value, hit: boolean }
*/
async remember(key, producer, { ttlMs = this.ttlMs } = {}) {
const { hit, value } = this.get(key);
if (hit) return { value, hit: true };
const fresh = await producer();
this.set(key, fresh, { ttlMs });
return { value: fresh, hit: false };
}
/**
* Get cache statistics
*/
getStats() {
const total = this.stats.hits + this.stats.misses;
const hitRate = total > 0 ? (this.stats.hits / total) * 100 : 0;
return {
size: this.store.size,
maxItems: this.maxItems,
ttlMs: this.ttlMs,
hits: this.stats.hits,
misses: this.stats.misses,
hitRate: Math.round(hitRate * 10) / 10,
evictions: this.stats.evictions,
sets: this.stats.sets
};
}
/**
* Reset statistics
*/
resetStats() {
this.stats = { hits: 0, misses: 0, evictions: 0, sets: 0 };
}
}