Add scoped runtime memory cache with LRU and metrics

This commit is contained in:
thePR0M3TH3AN committed 2026-02-14 14:44:07 -05:00
1 parent bb5611256b
commit 3c24b29799
2 files changed
+269 -2

No files matched your search

+199 -2
View File
@@ -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<string, unknown>, ttlMs?: number) => boolean,
* getRecentRuntimeEvents: (scope: { signer_id?: string, session_id?: string }, params?: { limit?: number, since?: number }) => Record<string, unknown>[],
* clearScope: (scope: { signer_id?: string, session_id?: string }) => void,
* getMetrics: () => Record<string, number>
* }}
*/
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,
};
},
};
}
+70
View File
@@ -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);
});