diff --git a/agents/metadata-agent/__tests__/cache/cache-metrics.test.js b/agents/metadata-agent/__tests__/cache/cache-metrics.test.js new file mode 100644 index 0000000000..1fa526f7b5 --- /dev/null +++ b/agents/metadata-agent/__tests__/cache/cache-metrics.test.js @@ -0,0 +1,78 @@ +const CacheMetrics = require("../../lib/cache/cache-metrics"); + +describe("CacheMetrics", () => { + let metrics; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + metrics = new CacheMetrics(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test("starts with empty counters", () => { + const snapshot = metrics.getSnapshot(); + expect(snapshot.hits).toBe(0); + expect(snapshot.misses).toBe(0); + expect(snapshot.sets).toBe(0); + expect(snapshot.hitRate).toBe(0); + }); + + test("tracks hits and misses", () => { + metrics.recordHit(); + metrics.recordHit(); + metrics.recordMiss(); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.hits).toBe(2); + expect(snapshot.misses).toBe(1); + expect(snapshot.hitRate).toBeCloseTo(2 / 3, 5); + }); + + test("tracks sets, evictions, and expirations", () => { + metrics.recordSet(); + metrics.recordSet(); + metrics.recordEviction(); + metrics.recordExpiration(); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.sets).toBe(2); + expect(snapshot.evictions).toBe(1); + expect(snapshot.expirations).toBe(1); + }); + + test("tracks invalidations by reason", () => { + metrics.recordInvalidation("manual-clear", 3); + metrics.recordInvalidation("manual-clear", 1); + metrics.recordInvalidation("repo-refresh", 2); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.invalidations).toBe(6); + expect(snapshot.invalidationReasons["manual-clear"]).toBe(4); + expect(snapshot.invalidationReasons["repo-refresh"]).toBe(2); + }); + + test("ignores invalid invalidation counts", () => { + metrics.recordInvalidation("manual", 0); + metrics.recordInvalidation("manual", -1); + metrics.recordInvalidation("manual", 1.5); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.invalidations).toBe(0); + expect(snapshot.invalidationReasons.manual).toBeUndefined(); + }); + + test("reports cache size in snapshot", () => { + const snapshot = metrics.getSnapshot(12); + expect(snapshot.size).toBe(12); + }); + + test("reports increasing uptime", () => { + jest.advanceTimersByTime(250); + const snapshot = metrics.getSnapshot(); + expect(snapshot.uptimeMs).toBe(250); + }); +}); diff --git a/agents/metadata-agent/__tests__/cache/session-cache.test.js b/agents/metadata-agent/__tests__/cache/session-cache.test.js new file mode 100644 index 0000000000..d98b7573c9 --- /dev/null +++ b/agents/metadata-agent/__tests__/cache/session-cache.test.js @@ -0,0 +1,185 @@ +const SessionCache = require("../../lib/cache/session-cache"); +const { resolveCacheConfig } = require("../../lib/cache/cache-config"); + +describe("SessionCache", () => { + describe("resolveCacheConfig", () => { + test("returns defaults when config is empty", () => { + const config = resolveCacheConfig(); + expect(config.ttlMs).toBe(300000); + expect(config.maxEntries).toBe(1000); + expect(config.evictionPolicy).toBe("lru"); + }); + + test("throws on invalid ttlMs", () => { + expect(() => resolveCacheConfig({ ttlMs: 0 })).toThrow( + "ttlMs must be a positive integer", + ); + }); + + test("throws on invalid maxEntries", () => { + expect(() => resolveCacheConfig({ maxEntries: -1 })).toThrow( + "maxEntries must be a positive integer", + ); + }); + + test("throws on unsupported evictionPolicy", () => { + expect(() => resolveCacheConfig({ evictionPolicy: "fifo" })).toThrow( + "evictionPolicy must be one of: lru", + ); + }); + }); + + describe("basic operations", () => { + let cache; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + cache = new SessionCache({ ttlMs: 1000, maxEntries: 2 }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test("stores and retrieves values", () => { + cache.set("k1", { value: 1 }); + expect(cache.get("k1")).toEqual({ value: 1 }); + }); + + test("returns null for cache misses", () => { + expect(cache.get("missing")).toBe(null); + }); + + test("expires entries based on ttl", () => { + cache.set("k1", "value"); + jest.setSystemTime(new Date("2026-01-01T00:00:01.001Z")); + expect(cache.get("k1")).toBe(null); + }); + + test("supports custom ttl per entry", () => { + cache.set("k1", "value", { ttlMs: 50 }); + jest.advanceTimersByTime(60); + expect(cache.get("k1")).toBe(null); + }); + + test("throws when custom ttl is invalid", () => { + expect(() => cache.set("k1", "value", { ttlMs: 0 })).toThrow( + "ttlMs must be a positive integer", + ); + }); + + test("uses lru eviction when capacity is exceeded", () => { + cache.set("a", 1); + cache.set("b", 2); + cache.get("a"); + cache.set("c", 3); + + expect(cache.get("b")).toBe(null); + expect(cache.get("a")).toBe(1); + expect(cache.get("c")).toBe(3); + }); + + test("updates existing key without eviction", () => { + cache.set("a", 1); + cache.set("b", 2); + cache.set("a", 3); + + expect(cache.size()).toBe(2); + expect(cache.get("a")).toBe(3); + }); + + test("updating existing key refreshes lru recency", () => { + cache.set("a", 1); + cache.set("b", 2); + cache.set("a", 3); + cache.set("c", 4); + + expect(cache.get("b")).toBe(null); + expect(cache.get("a")).toBe(3); + expect(cache.get("c")).toBe(4); + }); + + test("delete removes existing key", () => { + cache.set("a", 1); + expect(cache.delete("a", "manual-delete")).toBe(true); + expect(cache.get("a")).toBe(null); + }); + + test("delete returns false for missing key", () => { + expect(cache.delete("missing")).toBe(false); + }); + + test("clear invalidates all entries", () => { + cache.set("a", 1); + cache.set("b", 2); + const removed = cache.clear("manual-clear"); + expect(removed).toBe(2); + expect(cache.size()).toBe(0); + }); + + test("invalidateByPrefix clears matching keys", () => { + cache = new SessionCache({ ttlMs: 1000, maxEntries: 5 }); + cache.set("repo:1", 1); + cache.set("repo:2", 2); + cache.set("user:1", 3); + const removed = cache.invalidateByPrefix("repo:", "repo-refresh"); + expect(removed).toBe(2); + expect(cache.get("repo:1")).toBe(null); + expect(cache.get("user:1")).toBe(3); + }); + + test("invalidateByPredicate removes matching entries", () => { + cache.set("one", { stale: true }); + cache.set("two", { stale: false }); + const removed = cache.invalidateByPredicate( + (key, value) => value.stale, + "stale", + ); + expect(removed).toBe(1); + expect(cache.get("one")).toBe(null); + expect(cache.get("two")).toEqual({ stale: false }); + }); + + test("invalidateByPredicate throws on invalid predicate", () => { + expect(() => cache.invalidateByPredicate("bad")).toThrow( + "predicate must be a function", + ); + }); + + test("invalidateExpired sweeps expired keys", () => { + cache.set("short", "v", { ttlMs: 100 }); + cache.set("long", "v", { ttlMs: 5000 }); + jest.advanceTimersByTime(101); + const removed = cache.invalidateExpired("sweep"); + + expect(removed).toBe(1); + expect(cache.get("short")).toBe(null); + expect(cache.get("long")).toBe("v"); + }); + + test("has reports key state", () => { + cache.set("a", 1); + expect(cache.has("a")).toBe(true); + expect(cache.has("b")).toBe(false); + }); + + test("has supports null values", () => { + cache.set("nullable", null); + expect(cache.has("nullable")).toBe(true); + }); + + test("metrics include hit rate and invalidation reasons", () => { + cache.set("a", 1); + cache.get("a"); + cache.get("missing"); + cache.delete("a", "manual-delete"); + + const metrics = cache.getMetrics(); + expect(metrics.hits).toBe(1); + expect(metrics.misses).toBe(1); + expect(metrics.hitRate).toBe(0.5); + expect(metrics.invalidationReasons["manual-delete"]).toBe(1); + }); + }); +}); diff --git a/agents/metadata-agent/lib/cache/cache-config.js b/agents/metadata-agent/lib/cache/cache-config.js new file mode 100644 index 0000000000..97c1aea7b1 --- /dev/null +++ b/agents/metadata-agent/lib/cache/cache-config.js @@ -0,0 +1,44 @@ +const DEFAULT_CACHE_CONFIG = { + ttlMs: 5 * 60 * 1000, + maxEntries: 1000, + evictionPolicy: "lru", +}; + +const SUPPORTED_EVICTION_POLICIES = new Set(["lru"]); + +function isPositiveInteger(value) { + return Number.isInteger(value) && value > 0; +} + +/** + * Normalise and validate cache configuration. + * + * @param {Object} config - Partial cache configuration. + * @returns {Object} Validated configuration. + */ +function resolveCacheConfig(config = {}) { + const resolved = { + ...DEFAULT_CACHE_CONFIG, + ...config, + }; + + if (!isPositiveInteger(resolved.ttlMs)) { + throw new Error("ttlMs must be a positive integer"); + } + + if (!isPositiveInteger(resolved.maxEntries)) { + throw new Error("maxEntries must be a positive integer"); + } + + if (!SUPPORTED_EVICTION_POLICIES.has(resolved.evictionPolicy)) { + throw new Error("evictionPolicy must be one of: lru"); + } + + return resolved; +} + +module.exports = { + DEFAULT_CACHE_CONFIG, + SUPPORTED_EVICTION_POLICIES, + resolveCacheConfig, +}; diff --git a/agents/metadata-agent/lib/cache/cache-metrics.js b/agents/metadata-agent/lib/cache/cache-metrics.js new file mode 100644 index 0000000000..4d5c2fa048 --- /dev/null +++ b/agents/metadata-agent/lib/cache/cache-metrics.js @@ -0,0 +1,62 @@ +/** + * Lightweight metrics tracker for in-memory cache behaviour. + */ +class CacheMetrics { + constructor() { + this.startedAt = Date.now(); + this.counters = { + hits: 0, + misses: 0, + sets: 0, + evictions: 0, + expirations: 0, + invalidations: 0, + }; + this.invalidationReasons = {}; + } + + recordHit() { + this.counters.hits += 1; + } + + recordMiss() { + this.counters.misses += 1; + } + + recordSet() { + this.counters.sets += 1; + } + + recordEviction() { + this.counters.evictions += 1; + } + + recordExpiration() { + this.counters.expirations += 1; + } + + recordInvalidation(reason = "manual", count = 1) { + if (!Number.isInteger(count) || count < 1) { + return; + } + + this.counters.invalidations += count; + this.invalidationReasons[reason] = + (this.invalidationReasons[reason] || 0) + count; + } + + getSnapshot(cacheSize = 0) { + const totalReads = this.counters.hits + this.counters.misses; + const hitRate = totalReads === 0 ? 0 : this.counters.hits / totalReads; + + return { + ...this.counters, + hitRate, + size: cacheSize, + uptimeMs: Date.now() - this.startedAt, + invalidationReasons: { ...this.invalidationReasons }, + }; + } +} + +module.exports = CacheMetrics; diff --git a/agents/metadata-agent/lib/cache/session-cache.js b/agents/metadata-agent/lib/cache/session-cache.js new file mode 100644 index 0000000000..b42d386a9c --- /dev/null +++ b/agents/metadata-agent/lib/cache/session-cache.js @@ -0,0 +1,174 @@ +const { resolveCacheConfig } = require("./cache-config"); +const CacheMetrics = require("./cache-metrics"); + +/** + * In-memory session cache with TTL and LRU eviction. + */ +class SessionCache { + constructor(config = {}) { + this.config = resolveCacheConfig(config); + this.metrics = new CacheMetrics(); + this.entries = new Map(); + } + + _isExpired(entry) { + return entry.expiresAt <= Date.now(); + } + + _touch(key, entry) { + this.entries.delete(key); + this.entries.set(key, entry); + } + + _ensureCapacity() { + while (this.entries.size > this.config.maxEntries - 1) { + const oldestKey = this.entries.keys().next().value; + if (oldestKey === undefined) { + return; + } + this.entries.delete(oldestKey); + this.metrics.recordEviction(); + } + } + + set(key, value, options = {}) { + const ttlMs = options.ttlMs ?? this.config.ttlMs; + if (!Number.isInteger(ttlMs) || ttlMs <= 0) { + throw new Error("ttlMs must be a positive integer"); + } + const expiresAt = Date.now() + ttlMs; + const entry = { value, expiresAt }; + + if (!this.entries.has(key)) { + this._ensureCapacity(); + this.entries.set(key, entry); + } else { + this._touch(key, entry); + } + this.metrics.recordSet(); + + return value; + } + + get(key) { + const entry = this.entries.get(key); + + if (entry === undefined) { + this.metrics.recordMiss(); + return null; + } + + if (this._isExpired(entry)) { + this.entries.delete(key); + this.metrics.recordExpiration(); + this.metrics.recordMiss(); + return null; + } + + this._touch(key, entry); + this.metrics.recordHit(); + return entry.value; + } + + has(key) { + const entry = this.entries.get(key); + if (entry === undefined) { + return false; + } + + if (this._isExpired(entry)) { + this.entries.delete(key); + this.metrics.recordExpiration(); + this.metrics.recordMiss(); + return false; + } + + this._touch(key, entry); + return true; + } + + delete(key, reason = "manual") { + const removed = this.entries.delete(key); + if (removed) { + this.metrics.recordInvalidation(reason); + } + return removed; + } + + clear(reason = "manual-clear") { + const removedCount = this.entries.size; + this.entries.clear(); + if (removedCount > 0) { + this.metrics.recordInvalidation(reason, removedCount); + } + return removedCount; + } + + invalidateByPrefix(prefix, reason = "prefix") { + if (prefix === undefined || prefix === null) { + return 0; + } + const prefixString = String(prefix); + + let removed = 0; + for (const key of this.entries.keys()) { + if (String(key).startsWith(prefixString)) { + this.entries.delete(key); + removed += 1; + } + } + + if (removed > 0) { + this.metrics.recordInvalidation(reason, removed); + } + + return removed; + } + + invalidateByPredicate(predicate, reason = "predicate") { + if (typeof predicate !== "function") { + throw new Error("predicate must be a function"); + } + + let removed = 0; + for (const [key, entry] of this.entries.entries()) { + if (predicate(key, entry.value)) { + this.entries.delete(key); + removed += 1; + } + } + + if (removed > 0) { + this.metrics.recordInvalidation(reason, removed); + } + + return removed; + } + + invalidateExpired(reason = "expired-sweep") { + let removed = 0; + for (const [key, entry] of this.entries.entries()) { + if (this._isExpired(entry)) { + this.entries.delete(key); + removed += 1; + this.metrics.recordExpiration(); + } + } + + if (removed > 0) { + this.metrics.recordInvalidation(reason, removed); + } + + return removed; + } + + size() { + return this.entries.size; + } + + getMetrics() { + return this.metrics.getSnapshot(this.entries.size); + } +} + +module.exports = SessionCache;