221 lines
5.7 KiB
TypeScript
221 lines
5.7 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|