chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user