mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
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:
@@ -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>> {
|
||||
|
||||
Reference in New Issue
Block a user