135 lines
2.9 KiB
JavaScript
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 };
|
|
}
|
|
}
|