wiwiway / src /lib /cacheLayer.ts
Claude Code
Sync gateway codebase without binaries
116b4cb
Raw
History Blame Contribute Delete
5.81 kB
/**
* LRU Cache Layer β€” FASE-08 LLM Proxy Advanced
*
* In-memory LRU cache for LLM prompt/response pairs.
* Uses content hashing for cache keys to handle semantic deduplication.
* Memory-optimized with byte-based limits.
*
* @module lib/cacheLayer
*/
import crypto from "crypto";
/**
* @typedef {Object} CacheEntry
* @property {string} key - Cache key (hash)
* @property {*} value - Cached value
* @property {number} createdAt - Timestamp
* @property {number} ttl - TTL in ms
* @property {number} size - Approximate size in bytes
* @property {number} hits - Number of times this entry was accessed
*/
const DEFAULT_MAX_ENTRIES = 50;
const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
const DEFAULT_TTL = 300000;
export class LRUCache {
/** @type {Map<string, CacheEntry>} */
#cache = new Map();
#maxSize;
#maxBytes;
#defaultTTL;
#currentSize = 0;
#currentBytes = 0;
#stats = { hits: 0, misses: 0, evictions: 0 };
/**
* @param {Object} options
* @param {number} [options.maxSize=50] - Max number of entries (reduced for memory)
* @param {number} [options.maxBytes=2097152] - Max bytes (default: 2MB)
* @param {number} [options.defaultTTL=300000] - Default TTL in ms (5 min)
*/
constructor(options: { maxSize?: number; maxBytes?: number; defaultTTL?: number } = {}) {
this.#maxSize = options.maxSize ?? DEFAULT_MAX_ENTRIES;
this.#maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
this.#defaultTTL = options.defaultTTL ?? DEFAULT_TTL;
}
/**
* Generate a cache key from input.
* @param {Object} params - Parameters to hash
* @returns {string} Cache key
*/
static generateKey(params: Record<string, unknown>) {
const normalized = JSON.stringify(params, Object.keys(params).sort());
return crypto.createHash("sha256").update(normalized).digest("hex").slice(0, 16);
}
/**
* Get a value from the cache.
* @param {string} key
* @returns {*|undefined}
*/
get(key: string) {
const entry = this.#cache.get(key);
if (!entry) {
this.#stats.misses++;
return undefined;
}
if (Date.now() - entry.createdAt > entry.ttl) {
this.#deleteEntry(key, entry);
this.#stats.misses++;
return undefined;
}
this.#cache.delete(key);
entry.hits++;
this.#cache.set(key, entry);
this.#stats.hits++;
return entry.value;
}
/**
* Set a value in the cache.
* @param {string} key
* @param {*} value
* @param {number} [ttl] - Override default TTL
*/
set(key: string, value: unknown, ttl?: number) {
const entrySize = this.#estimateSize(value);
if (this.#cache.has(key)) {
const oldEntry = this.#cache.get(key)!;
this.#currentBytes -= oldEntry.size || 0;
this.#currentSize--;
this.#cache.delete(key);
}
while (
(this.#currentSize >= this.#maxSize || this.#currentBytes + entrySize > this.#maxBytes) &&
this.#cache.size > 0
) {
const oldestKey = this.#cache.keys().next().value;
const oldestEntry = this.#cache.get(oldestKey);
if (oldestEntry) {
this.#deleteEntry(oldestKey, oldestEntry);
}
this.#stats.evictions++;
}
const entry = {
key,
value,
createdAt: Date.now(),
ttl: ttl ?? this.#defaultTTL,
size: entrySize,
hits: 0,
};
this.#cache.set(key, entry);
this.#currentSize++;
this.#currentBytes += entrySize;
}
/**
* Estimate size of a value in bytes.
*/
#estimateSize(value: unknown): number {
try {
return JSON.stringify(value).length * 2;
} catch {
return 1024;
}
}
/**
* Delete an entry and update counters.
*/
#deleteEntry(key: string, entry: { size?: number }) {
this.#cache.delete(key);
this.#currentSize--;
this.#currentBytes -= entry.size || 0;
if (this.#currentBytes < 0) this.#currentBytes = 0;
}
/**
* Check if a key exists (without promoting it).
* @param {string} key
* @returns {boolean}
*/
has(key: string) {
const entry = this.#cache.get(key);
if (!entry) return false;
if (Date.now() - entry.createdAt > entry.ttl) {
this.#deleteEntry(key, entry);
return false;
}
return true;
}
/**
* Delete a specific key.
* @param {string} key
* @returns {boolean}
*/
delete(key: string) {
const entry = this.#cache.get(key);
if (entry) {
this.#deleteEntry(key, entry);
return true;
}
return false;
}
/** Clear the entire cache. */
clear() {
this.#cache.clear();
this.#currentSize = 0;
this.#currentBytes = 0;
}
/** @returns {{ size: number, maxSize: number, bytes: number, maxBytes: number, hits: number, misses: number, evictions: number, hitRate: number }} */
getStats() {
const total = this.#stats.hits + this.#stats.misses;
return {
size: this.#currentSize,
maxSize: this.#maxSize,
bytes: this.#currentBytes,
maxBytes: this.#maxBytes,
...this.#stats,
hitRate: total > 0 ? (this.#stats.hits / total) * 100 : 0,
};
}
}
// ─── Prompt Cache Singleton ─────────────────
let promptCache: LRUCache | null = null;
/**
* Get the global prompt cache instance.
* @param {Object} [options]
* @returns {LRUCache}
*/
export function getPromptCache(
options?: { maxSize?: number; maxBytes?: number; defaultTTL?: number } & Record<string, unknown>
) {
if (!promptCache) {
promptCache = new LRUCache({
maxSize: parseInt(process.env.PROMPT_CACHE_MAX_SIZE || "50", 10),
maxBytes: parseInt(process.env.PROMPT_CACHE_MAX_BYTES || String(2 * 1024 * 1024), 10),
defaultTTL: parseInt(process.env.PROMPT_CACHE_TTL_MS || "300000", 10),
...options,
});
}
return promptCache;
}