mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-27 07:51:52 +00:00
Add scoped runtime memory cache with LRU and metrics
This commit is contained in:
1 parent
bb5611256b
commit
3c24b29799
2 files changed
+269
-2
No files matched your search
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user