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 };
 | |
|   }
 | |
| }
 |