Files
sencho/backend/src/services/CacheService.ts
T
Anso c0c321227b perf: unify caching behind a single CacheService and enable HTTP compression (#468)
Replaces five ad-hoc in-process caches (project name map, templates, latest
version, fleet update status, remote node meta) with a single internal
CacheService that provides TTL, inflight-promise deduplication to protect
against thundering herd, stale-on-error fallback, and per-namespace
hit/miss/stale/size counters for observability.

Wraps the hot-path dashboard endpoints in the cache with write-path
invalidation: /api/stats (2s), /api/system/stats (3s), and
/api/stacks/statuses (3s). Keys are namespaced by nodeId so switching nodes
never serves another node's data. Every route that mutates container or
stack state calls invalidateNodeCaches(nodeId), which also drops the global
project-name-map, so user actions stay instantly reflected in the UI.

For /api/system/stats the cheap per-request network rx/tx block is kept
outside the cache so live-updating charts stay smooth while the expensive
systeminformation.currentLoad() CPU sample (~200ms) is reused across the
TTL.

Adds admin-only GET /api/system/cache-stats returning per-namespace
counters for operators who want to observe cache effectiveness.

Enables the compression middleware site-wide for JSON responses. Large
payloads like /api/templates shrink roughly 5x on the wire. SSE endpoints
are explicitly excluded via a Content-Type filter so live log tails and
metric streams are not buffered.

Bumps vitest hookTimeout to match testTimeout (15s) so parallel fork
workers do not hit the default 10s hook limit under CPU contention.

Adds 35 new tests (26 unit for CacheService, 9 integration for cached
endpoints) covering TTL expiry, inflight dedup, stale-on-error,
namespace invalidation, entry-cap safety guard, and write-path
invalidation end-to-end through Express routes.
2026-04-10 10:05:05 -04:00

218 lines
6.6 KiB
TypeScript

/**
* CacheService: a single in-process cache for the entire backend.
*
* Design goals (see plans/caching-strategy-audit.md for full context):
* - TTL-based entries. Entries expire after a configured ms window.
* - Inflight deduplication: concurrent getOrFetch() calls for the same key
* share one in-flight Promise, preventing thundering-herd on cache miss.
* - Stale-on-error fallback: if fetcher() rejects while a stale value
* exists, return the stale value (and count it as a "stale" hit).
* - Namespaced stats: per-namespace hit / miss / stale counters for
* observability, surfaced via /api/system/cache-stats.
* - Key conventions: use "namespace:subkey" for per-entity caches
* (e.g. "stats:1" for nodeId=1). Stats are aggregated by namespace.
* - Safety cap: a hard limit on total entries as a defense-in-depth
* guard against unbounded growth. Default 1000 entries; each cache
* used in Sencho is bounded by construction (singleton or per-nodeId).
*
* Non-goals:
* - LRU eviction. All current callers have bounded keyspaces.
* - Persistence. Caches are rebuilt on process restart.
* - Cross-process sync. Sencho runs single-process per instance.
*/
interface CacheEntry<T> {
value: T;
expiresAt: number;
}
interface NamespaceStats {
hits: number;
misses: number;
stale: number;
size: number;
}
const MAX_ENTRIES = 1000;
/** Extract the namespace (part before first colon) from a key. */
function namespaceOf(key: string): string {
const idx = key.indexOf(':');
return idx === -1 ? key : key.slice(0, idx);
}
export class CacheService {
private static instance: CacheService;
private readonly store = new Map<string, CacheEntry<unknown>>();
private readonly inflight = new Map<string, Promise<unknown>>();
private readonly stats = new Map<string, NamespaceStats>();
public static getInstance(): CacheService {
if (!CacheService.instance) {
CacheService.instance = new CacheService();
}
return CacheService.instance;
}
/**
* Retrieve a cached value or compute it via `fetcher`. Concurrent callers
* for the same key await the same in-flight promise.
*
* On fetcher rejection: if a stale entry exists, return it (counted as
* `stale`); otherwise propagate the error.
*/
public async getOrFetch<T>(
key: string,
ttlMs: number,
fetcher: () => Promise<T>,
): Promise<T> {
const ns = namespaceOf(key);
const now = Date.now();
const existing = this.store.get(key) as CacheEntry<T> | undefined;
if (existing && existing.expiresAt > now) {
this.recordHit(ns);
return existing.value;
}
this.recordMiss(ns);
const inflight = this.inflight.get(key) as Promise<T> | undefined;
if (inflight) return inflight;
const promise = (async () => {
try {
const value = await fetcher();
this.set(key, value, ttlMs);
return value;
} catch (err) {
if (existing) {
this.recordStale(ns);
return existing.value;
}
throw err;
} finally {
this.inflight.delete(key);
}
})();
this.inflight.set(key, promise);
return promise;
}
/**
* Synchronous get: returns undefined if the key is absent or expired.
* Updates hit/miss counters.
*/
public get<T>(key: string): T | undefined {
const ns = namespaceOf(key);
const entry = this.store.get(key) as CacheEntry<T> | undefined;
if (!entry || entry.expiresAt <= Date.now()) {
this.recordMiss(ns);
return undefined;
}
this.recordHit(ns);
return entry.value;
}
/**
* Set a value with a TTL. If the total number of entries exceeds
* MAX_ENTRIES, the oldest expired entries are purged first; if still
* over cap, the insertion is rejected with a warning (defense in depth).
*/
public set<T>(key: string, value: T, ttlMs: number): void {
if (this.store.size >= MAX_ENTRIES && !this.store.has(key)) {
this.purgeExpired();
if (this.store.size >= MAX_ENTRIES) {
console.warn(`[CacheService] Entry cap reached (${MAX_ENTRIES}); refusing to cache "${key}"`);
return;
}
}
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
}
/** Invalidate a single key. */
public invalidate(key: string): void {
this.store.delete(key);
}
/** Invalidate every key whose namespace matches `namespace`. */
public invalidateNamespace(namespace: string): void {
const prefix = `${namespace}:`;
for (const key of this.store.keys()) {
if (key === namespace || key.startsWith(prefix)) {
this.store.delete(key);
}
}
}
/** Reset all state. Intended for tests and admin "flush" actions. */
public flush(): void {
this.store.clear();
this.inflight.clear();
this.stats.clear();
}
/**
* Per-namespace statistics snapshot. Size counts live (non-expired) entries
* currently in the store for each namespace at call time.
*/
public getStats(): Record<string, NamespaceStats> {
// Recompute live sizes at snapshot time; counters are kept incrementally.
const sizes = new Map<string, number>();
const now = Date.now();
for (const [key, entry] of this.store) {
if (entry.expiresAt <= now) continue;
const ns = namespaceOf(key);
sizes.set(ns, (sizes.get(ns) ?? 0) + 1);
}
const result: Record<string, NamespaceStats> = {};
const allNs = new Set<string>([...this.stats.keys(), ...sizes.keys()]);
for (const ns of allNs) {
const base = this.stats.get(ns) ?? { hits: 0, misses: 0, stale: 0, size: 0 };
result[ns] = {
hits: base.hits,
misses: base.misses,
stale: base.stale,
size: sizes.get(ns) ?? 0,
};
}
return result;
}
// ─── internals ────────────────────────────────────────────────────────
private recordHit(namespace: string): void {
const s = this.getOrCreateNs(namespace);
s.hits += 1;
}
private recordMiss(namespace: string): void {
const s = this.getOrCreateNs(namespace);
s.misses += 1;
}
private recordStale(namespace: string): void {
const s = this.getOrCreateNs(namespace);
s.stale += 1;
}
private getOrCreateNs(namespace: string): NamespaceStats {
let s = this.stats.get(namespace);
if (!s) {
s = { hits: 0, misses: 0, stale: 0, size: 0 };
this.stats.set(namespace, s);
}
return s;
}
private purgeExpired(): void {
const now = Date.now();
for (const [key, entry] of this.store) {
if (entry.expiresAt <= now) this.store.delete(key);
}
}
}