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.
This commit is contained in:
Anso
2026-04-10 10:05:05 -04:00
committed by GitHub
parent 7321267f28
commit c0c321227b
11 changed files with 1198 additions and 229 deletions
+29 -27
View File
@@ -7,6 +7,7 @@ import fs from 'fs/promises';
import * as yaml from 'yaml';
import { NodeRegistry } from './NodeRegistry';
import { CacheService } from './CacheService';
import { isPathWithinBase } from '../utils/validation';
const execAsync = promisify(exec);
@@ -17,7 +18,7 @@ const COMPOSE_FILE_NAMES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml'
/** Cached mapping from compose `name:` field to stack directory name. TTL-based to avoid re-parsing YAML on every poll. */
const PROJECT_NAME_CACHE_TTL_MS = 60_000;
let projectNameCache: { map: Record<string, string>; builtAt: number } | null = null;
const PROJECT_NAME_CACHE_KEY = 'project-name-map';
/** Common web-UI private ports, checked in priority order when detecting the main app port. */
const WEB_UI_PORTS = [32400, 8989, 7878, 9696, 5055, 8080, 80, 443, 3000, 9000];
@@ -573,36 +574,37 @@ class DockerController {
* Compose files with a top-level `name:` field override the default project name.
*/
private static async resolveProjectNameMap(stackNames: string[]): Promise<Record<string, string>> {
if (projectNameCache && Date.now() - projectNameCache.builtAt < PROJECT_NAME_CACHE_TTL_MS) {
return projectNameCache.map;
}
return CacheService.getInstance().getOrFetch(
PROJECT_NAME_CACHE_KEY,
PROJECT_NAME_CACHE_TTL_MS,
async () => {
const map: Record<string, string> = {};
const map: Record<string, string> = {};
await Promise.all(stackNames.map(async (stackDir) => {
map[stackDir] = stackDir;
await Promise.all(stackNames.map(async (stackDir) => {
map[stackDir] = stackDir;
for (const fileName of COMPOSE_FILE_NAMES) {
const filePath = path.join(COMPOSE_DIR, stackDir, fileName);
try {
const content = await fs.readFile(filePath, 'utf-8');
const parsed = yaml.parse(content);
if (parsed?.name && typeof parsed.name === 'string') {
map[parsed.name] = stackDir;
for (const fileName of COMPOSE_FILE_NAMES) {
const filePath = path.join(COMPOSE_DIR, stackDir, fileName);
try {
const content = await fs.readFile(filePath, 'utf-8');
const parsed = yaml.parse(content);
if (parsed?.name && typeof parsed.name === 'string') {
map[parsed.name] = stackDir;
}
break;
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException)?.code;
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
console.error(`[DockerController] Failed to read ${filePath}:`, err);
break;
}
}
}
break;
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException)?.code;
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
console.error(`[DockerController] Failed to read ${filePath}:`, err);
break;
}
}
}
}));
}));
projectNameCache = { map, builtAt: Date.now() };
return map;
return map;
},
);
}
public async getBulkStackStatuses(stackNames: string[]): Promise<Record<string, BulkStackInfo>> {