From 3c24b297992fc2d6bbbfd87044c55c1dc0bdf1a3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:44:07 -0500 Subject: [PATCH] Add scoped runtime memory cache with LRU and metrics --- src/services/memory/cache.js | 201 ++++++++++++++++++++++++++++++++++- test/memory-cache.test.mjs | 70 ++++++++++++ 2 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 test/memory-cache.test.mjs diff --git a/src/services/memory/cache.js b/src/services/memory/cache.js index 4ba047a..6317042 100644 --- a/src/services/memory/cache.js +++ b/src/services/memory/cache.js @@ -1,8 +1,139 @@ +const DEFAULT_MAX_NAMESPACES = 128; +const DEFAULT_MAX_EVENTS_PER_NAMESPACE = 200; +const DEFAULT_MAX_TOTAL_EVENTS = 5000; + +function normalizeScope(scope = {}) { + const signerId = typeof scope.signer_id === 'string' && scope.signer_id.trim() + ? scope.signer_id.trim() + : 'anonymous-signer'; + const sessionId = typeof scope.session_id === 'string' && scope.session_id.trim() + ? scope.session_id.trim() + : 'anonymous-session'; + + return { signer_id: signerId, session_id: sessionId }; +} + +function getNamespaceKey(scope) { + const normalized = normalizeScope(scope); + return `${normalized.signer_id}::${normalized.session_id}`; +} + +function isEventSensitive(event) { + if (!event || typeof event !== 'object') return false; + if (event.is_decrypted === true || event.session_sensitive === true) return true; + + const classification = typeof event.classification === 'string' ? event.classification.toLowerCase() : ''; + return classification === 'decrypted' || classification === 'session-sensitive'; +} + +function canPromoteToDurableStorage(event) { + if (!isEventSensitive(event)) return true; + return event.sanitized_for_durable === true; +} + /** - * @returns {{ get: (key: string) => unknown, set: (key: string, value: unknown) => void, delete: (key: string) => boolean, clear: () => void }} + * @param {{ maxNamespaces?: number, maxEventsPerNamespace?: number, maxTotalEvents?: number }} [options] + * @returns {{ + * get: (key: string) => unknown, + * set: (key: string, value: unknown) => void, + * delete: (key: string) => boolean, + * clear: () => void, + * setRuntimeEvent: (scope: { signer_id?: string, session_id?: string }, event: Record, ttlMs?: number) => boolean, + * getRecentRuntimeEvents: (scope: { signer_id?: string, session_id?: string }, params?: { limit?: number, since?: number }) => Record[], + * clearScope: (scope: { signer_id?: string, session_id?: string }) => void, + * getMetrics: () => Record + * }} */ -export function createMemoryCache() { +export function createMemoryCache(options = {}) { const store = new Map(); + const runtimeNamespaces = new Map(); + const maxNamespaces = Number.isFinite(options.maxNamespaces) && options.maxNamespaces > 0 + ? Math.floor(options.maxNamespaces) + : DEFAULT_MAX_NAMESPACES; + const maxEventsPerNamespace = Number.isFinite(options.maxEventsPerNamespace) && options.maxEventsPerNamespace > 0 + ? Math.floor(options.maxEventsPerNamespace) + : DEFAULT_MAX_EVENTS_PER_NAMESPACE; + const maxTotalEvents = Number.isFinite(options.maxTotalEvents) && options.maxTotalEvents > 0 + ? Math.floor(options.maxTotalEvents) + : DEFAULT_MAX_TOTAL_EVENTS; + + let totalRuntimeEvents = 0; + const metrics = { + runtime_events_added: 0, + runtime_events_expired: 0, + runtime_events_lru_evicted: 0, + runtime_scopes_cleared: 0, + runtime_sensitive_events_blocked: 0, + }; + + function touchNamespace(key) { + const bucket = runtimeNamespaces.get(key); + if (!bucket) return; + runtimeNamespaces.delete(key); + runtimeNamespaces.set(key, bucket); + } + + function evictNamespace(key) { + const bucket = runtimeNamespaces.get(key); + if (!bucket) return; + totalRuntimeEvents -= bucket.events.length; + metrics.runtime_events_lru_evicted += bucket.events.length; + runtimeNamespaces.delete(key); + } + + function evictOldestNamespaceIfNeeded() { + while (runtimeNamespaces.size > maxNamespaces) { + const oldestKey = runtimeNamespaces.keys().next().value; + if (!oldestKey) return; + evictNamespace(oldestKey); + } + } + + function removeExpired(now = Date.now()) { + for (const [namespaceKey, bucket] of runtimeNamespaces.entries()) { + if (!bucket.events.length) continue; + + const originalLength = bucket.events.length; + bucket.events = bucket.events.filter((entry) => entry.expiresAt > now); + const removed = originalLength - bucket.events.length; + if (removed > 0) { + totalRuntimeEvents -= removed; + metrics.runtime_events_expired += removed; + } + + if (bucket.events.length === 0) { + runtimeNamespaces.delete(namespaceKey); + } + } + } + + function enforceEventBounds(namespaceKey) { + const bucket = runtimeNamespaces.get(namespaceKey); + if (!bucket) return; + + while (bucket.events.length > maxEventsPerNamespace) { + bucket.events.shift(); + totalRuntimeEvents -= 1; + metrics.runtime_events_lru_evicted += 1; + } + + while (totalRuntimeEvents > maxTotalEvents) { + const oldestNamespaceKey = runtimeNamespaces.keys().next().value; + if (!oldestNamespaceKey) return; + const oldestBucket = runtimeNamespaces.get(oldestNamespaceKey); + if (!oldestBucket || oldestBucket.events.length === 0) { + runtimeNamespaces.delete(oldestNamespaceKey); + continue; + } + + oldestBucket.events.shift(); + totalRuntimeEvents -= 1; + metrics.runtime_events_lru_evicted += 1; + if (oldestBucket.events.length === 0) { + runtimeNamespaces.delete(oldestNamespaceKey); + } + } + } return { get(key) { @@ -16,6 +147,72 @@ export function createMemoryCache() { }, clear() { store.clear(); + runtimeNamespaces.clear(); + totalRuntimeEvents = 0; + }, + setRuntimeEvent(scope, event, ttlMs = 60_000) { + const now = Date.now(); + removeExpired(now); + + const safeTtlMs = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : 60_000; + const expiresAt = now + safeTtlMs; + const namespaceKey = getNamespaceKey(scope); + const bucket = runtimeNamespaces.get(namespaceKey) ?? { events: [] }; + + bucket.events.push({ + event, + createdAt: now, + expiresAt, + durableEligible: canPromoteToDurableStorage(event), + }); + + totalRuntimeEvents += 1; + metrics.runtime_events_added += 1; + if (!canPromoteToDurableStorage(event)) { + metrics.runtime_sensitive_events_blocked += 1; + } + + runtimeNamespaces.set(namespaceKey, bucket); + touchNamespace(namespaceKey); + evictOldestNamespaceIfNeeded(); + enforceEventBounds(namespaceKey); + return true; + }, + getRecentRuntimeEvents(scope, params = {}) { + const now = Date.now(); + removeExpired(now); + + const namespaceKey = getNamespaceKey(scope); + const bucket = runtimeNamespaces.get(namespaceKey); + if (!bucket) return []; + + touchNamespace(namespaceKey); + + const limit = Number.isFinite(params.limit) && params.limit > 0 ? Math.floor(params.limit) : 50; + const since = Number.isFinite(params.since) ? params.since : 0; + + return bucket.events + .filter((entry) => entry.createdAt >= since) + .slice(-limit) + .map((entry) => ({ + ...entry.event, + durable_eligible: entry.durableEligible, + })); + }, + clearScope(scope) { + const namespaceKey = getNamespaceKey(scope); + const bucket = runtimeNamespaces.get(namespaceKey); + if (!bucket) return; + totalRuntimeEvents -= bucket.events.length; + metrics.runtime_scopes_cleared += 1; + runtimeNamespaces.delete(namespaceKey); + }, + getMetrics() { + return { + ...metrics, + runtime_active_namespaces: runtimeNamespaces.size, + runtime_active_events: totalRuntimeEvents, + }; }, }; } diff --git a/test/memory-cache.test.mjs b/test/memory-cache.test.mjs new file mode 100644 index 0000000..1adde03 --- /dev/null +++ b/test/memory-cache.test.mjs @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createMemoryCache } from '../src/services/memory/cache.js'; + +test('stores runtime events in signer/session namespace and clears scope', () => { + const cache = createMemoryCache(); + + cache.setRuntimeEvent({ signer_id: 'alice', session_id: 's1' }, { id: 1 }); + cache.setRuntimeEvent({ signer_id: 'alice', session_id: 's2' }, { id: 2 }); + + assert.deepEqual( + cache.getRecentRuntimeEvents({ signer_id: 'alice', session_id: 's1' }).map((event) => event.id), + [1], + ); + assert.deepEqual( + cache.getRecentRuntimeEvents({ signer_id: 'alice', session_id: 's2' }).map((event) => event.id), + [2], + ); + + cache.clearScope({ signer_id: 'alice', session_id: 's1' }); + assert.equal(cache.getRecentRuntimeEvents({ signer_id: 'alice', session_id: 's1' }).length, 0); + assert.equal(cache.getRecentRuntimeEvents({ signer_id: 'alice', session_id: 's2' }).length, 1); +}); + +test('expires runtime events and records expiration metrics', async () => { + const cache = createMemoryCache(); + cache.setRuntimeEvent({ signer_id: 'alice', session_id: 's1' }, { id: 1 }, 1); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + assert.equal(cache.getRecentRuntimeEvents({ signer_id: 'alice', session_id: 's1' }).length, 0); + const metrics = cache.getMetrics(); + assert.equal(metrics.runtime_events_expired, 1); +}); + +test('enforces LRU bounds and tracks eviction metrics', () => { + const cache = createMemoryCache({ maxNamespaces: 1, maxEventsPerNamespace: 2, maxTotalEvents: 2 }); + + cache.setRuntimeEvent({ signer_id: 'alice', session_id: 's1' }, { id: 1 }); + cache.setRuntimeEvent({ signer_id: 'alice', session_id: 's1' }, { id: 2 }); + cache.setRuntimeEvent({ signer_id: 'alice', session_id: 's1' }, { id: 3 }); + + const ids = cache.getRecentRuntimeEvents({ signer_id: 'alice', session_id: 's1' }).map((event) => event.id); + assert.deepEqual(ids, [2, 3]); + + cache.setRuntimeEvent({ signer_id: 'bob', session_id: 's2' }, { id: 4 }); + assert.equal(cache.getRecentRuntimeEvents({ signer_id: 'alice', session_id: 's1' }).length, 0); + + const metrics = cache.getMetrics(); + assert.ok(metrics.runtime_events_lru_evicted >= 3); +}); + +test('blocks decrypted/session-sensitive data from durable promotion without sanitizer pass', () => { + const cache = createMemoryCache(); + + cache.setRuntimeEvent( + { signer_id: 'alice', session_id: 's1' }, + { id: 1, classification: 'decrypted', sanitized_for_durable: false }, + ); + cache.setRuntimeEvent( + { signer_id: 'alice', session_id: 's1' }, + { id: 2, classification: 'decrypted', sanitized_for_durable: true }, + ); + + const events = cache.getRecentRuntimeEvents({ signer_id: 'alice', session_id: 's1' }); + assert.equal(events[0].durable_eligible, false); + assert.equal(events[1].durable_eligible, true); + assert.equal(cache.getMetrics().runtime_sensitive_events_blocked, 1); +});