Two further wins, both measured on a restored 28-widget board behind a real demo session (scripts/benchmarks/stress-restore.mts). --max-semi-space-size=4 in run.sh. --max-old-space-size only bounds the old generation; V8 also sizes the young generation generously (16 MB per semi-space) and that slack is pure resident memory. 4 MB cut idle anonymous memory 120.0 -> 97.2 MiB and peak 710.6 -> 560.2 MiB. It costs CPU, as any GC-space reduction does — 15.8 -> 17.1 CPU-seconds over the run, ~8% — with no board-load latency regression (median 594 -> 570 ms). Overridable via HOMARR_MAX_SEMI_SPACE_SIZE. serverExternalPackages for the remaining server-only dependencies. Bundling a package inlines its transitive graph into a chunk that is compiled at boot even when the code path never runs; leaving it external makes it a lazy require. Adding winston, drizzle-orm, ical.js, jszip, ldapts, node-unifi, @kubernetes/client-node and linkedom took idle to 90.3 MiB and board-loaded 209.6 -> 164.6 MiB, with slightly lower CPU (16.3s) and latency (549 ms). The trade-off is image size: 388 -> 401 MB, because those packages now ship in node_modules instead of being tree-shaken into chunks. Cumulative vs the merge-base: idle 141.7 -> 90.3 MiB (-36%), board loaded 257.8 -> 164.6 MiB (-36%), under load 392.7 -> 302.5 MiB (-23%). The harness now proves the session rather than inferring it: it asserts an anonymous request to the private board is denied, requires the Auth.js session cookie after sign-in, and reports whether it had to modify the restored data at all (now "none"). It also records cgroup CPU seconds and board-load latency, so a memory win bought with CPU cannot hide. pnpm test 144/144 files, 1795 tests; typecheck and lint clean.
211 lines
8.7 KiB
TypeScript
211 lines
8.7 KiB
TypeScript
/**
|
|
* Pure helpers for the dashboard stress benchmark (`stress-restore.mts`).
|
|
*
|
|
* Kept free of Docker/Playwright so the measurement maths and the container
|
|
* snapshot script stay unit-testable.
|
|
*/
|
|
|
|
export type ProcessSample = {
|
|
command: string;
|
|
parentPid: number;
|
|
pid: number;
|
|
pssBytes: number;
|
|
rssBytes: number;
|
|
};
|
|
|
|
export type StressCheckpoint = {
|
|
name: string;
|
|
capturedAt: string;
|
|
elapsedMs: number;
|
|
container: {
|
|
currentBytes: number;
|
|
peakBytes: number | null;
|
|
anonymousBytes: number;
|
|
fileBytes: number;
|
|
};
|
|
/**
|
|
* Cumulative cgroup CPU time. Needed because most ways of lowering memory
|
|
* (smaller GC spaces, tighter heap caps) buy it with CPU, and a win that just
|
|
* moves the cost somewhere unmeasured is not a win.
|
|
*/
|
|
cpu: { usageUsec: number; userUsec: number; systemUsec: number } | null;
|
|
processes: ProcessSample[];
|
|
redis: { usedMemoryBytes: number; peakMemoryBytes: number } | null;
|
|
};
|
|
|
|
/**
|
|
* Emitted inside the container and parsed by `parseStressSnapshot`.
|
|
*
|
|
* cgroup v2 `memory.current` is the number that matters for a container memory
|
|
* limit (it is what the OOM killer compares against), so it is the headline
|
|
* metric rather than the sum of process RSS, which double-counts shared pages.
|
|
*/
|
|
export const stressMemoryScript = String.raw`
|
|
echo "memory_current=$(cat /sys/fs/cgroup/memory.current 2>/dev/null || echo 0)"
|
|
memory_peak="$(cat /sys/fs/cgroup/memory.peak 2>/dev/null || true)"
|
|
case "$memory_peak" in ''|'max') ;; *) echo "memory_peak=$memory_peak" ;; esac
|
|
awk '/^anon / { print "cgroup_anon=" $2 } /^file / { print "cgroup_file=" $2 }' /sys/fs/cgroup/memory.stat 2>/dev/null
|
|
awk '/^usage_usec / { print "cpu_usage_usec=" $2 } /^user_usec / { print "cpu_user_usec=" $2 } /^system_usec / { print "cpu_system_usec=" $2 }' /sys/fs/cgroup/cpu.stat 2>/dev/null
|
|
for status_file in /proc/[0-9]*/status; do
|
|
[ -r "$status_file" ] || continue
|
|
process_dir="$(dirname "$status_file")"
|
|
process_values="$(awk '/^Name:/ { name=$2 } /^Pid:/ { pid=$2 } /^PPid:/ { ppid=$2 } /^VmRSS:/ { rss=$2 } END { if (rss == "") rss=0; print pid "|" ppid "|" rss "|" name }' "$status_file" 2>/dev/null)"
|
|
process_pss="$(awk '/^Pss:/ { print $2 }' "$process_dir/smaps_rollup" 2>/dev/null || echo 0)"
|
|
[ -n "$process_pss" ] || process_pss=0
|
|
[ -n "$process_values" ] && echo "process=$process_values|$process_pss"
|
|
done
|
|
redis_info="$(redis-cli INFO memory 2>/dev/null | tr -d '\r' || true)"
|
|
redis_used="$(printf '%s\n' "$redis_info" | awk -F: '/^used_memory:/ { print $2 }')"
|
|
redis_peak="$(printf '%s\n' "$redis_info" | awk -F: '/^used_memory_peak:/ { print $2 }')"
|
|
if [ -n "$redis_used" ] && [ -n "$redis_peak" ]; then
|
|
echo "redis_used_memory=$redis_used"
|
|
echo "redis_used_memory_peak=$redis_peak"
|
|
fi
|
|
`;
|
|
|
|
const readNumericLine = (input: string, key: string) => {
|
|
const match = new RegExp(`^${key}[=:]\\s*(\\d+)`, "m").exec(input);
|
|
return match?.[1] === undefined ? null : Number(match[1]);
|
|
};
|
|
|
|
export const parseStressSnapshot = (name: string, elapsedMs: number, output: string): StressCheckpoint => {
|
|
const currentBytes = readNumericLine(output, "memory_current");
|
|
if (currentBytes === null) throw new Error(`Snapshot "${name}" is missing memory_current`);
|
|
|
|
const processes = output
|
|
.split("\n")
|
|
.filter((line) => line.startsWith("process="))
|
|
.flatMap<ProcessSample>((line) => {
|
|
const [pid, parentPid, rssKiB, command, pssKiB] = line.slice("process=".length).split("|");
|
|
if (!pid || !parentPid || rssKiB === undefined || !command) return [];
|
|
return [
|
|
{
|
|
pid: Number(pid),
|
|
parentPid: Number(parentPid),
|
|
rssBytes: Number(rssKiB) * 1024,
|
|
pssBytes: Number(pssKiB ?? 0) * 1024,
|
|
command,
|
|
},
|
|
];
|
|
});
|
|
|
|
const redisUsed = readNumericLine(output, "redis_used_memory");
|
|
const redisPeak = readNumericLine(output, "redis_used_memory_peak");
|
|
const cpuUsage = readNumericLine(output, "cpu_usage_usec");
|
|
|
|
return {
|
|
name,
|
|
capturedAt: new Date().toISOString(),
|
|
elapsedMs,
|
|
container: {
|
|
currentBytes,
|
|
peakBytes: readNumericLine(output, "memory_peak"),
|
|
anonymousBytes: readNumericLine(output, "cgroup_anon") ?? 0,
|
|
fileBytes: readNumericLine(output, "cgroup_file") ?? 0,
|
|
},
|
|
cpu:
|
|
cpuUsage === null
|
|
? null
|
|
: {
|
|
usageUsec: cpuUsage,
|
|
userUsec: readNumericLine(output, "cpu_user_usec") ?? 0,
|
|
systemUsec: readNumericLine(output, "cpu_system_usec") ?? 0,
|
|
},
|
|
processes,
|
|
redis: redisUsed === null || redisPeak === null ? null : { usedMemoryBytes: redisUsed, peakMemoryBytes: redisPeak },
|
|
};
|
|
};
|
|
|
|
/** Node processes only — `next-server` plus any child it forks. */
|
|
export const getNodeProcesses = (checkpoint: StressCheckpoint) =>
|
|
checkpoint.processes.filter((process) => /node|next/i.test(process.command));
|
|
|
|
export const sumNodeRssBytes = (checkpoint: StressCheckpoint) =>
|
|
getNodeProcesses(checkpoint).reduce((total, process) => total + process.rssBytes, 0);
|
|
|
|
/** Largest single node process — the Next.js server in a normal container. */
|
|
export const getPrimaryNodeRssBytes = (checkpoint: StressCheckpoint) =>
|
|
getNodeProcesses(checkpoint).reduce((max, process) => Math.max(max, process.rssBytes), 0);
|
|
|
|
export const toMiB = (bytes: number) => Math.round((bytes / 1024 / 1024) * 10) / 10;
|
|
|
|
/**
|
|
* Least-squares slope over (elapsedMs, bytes), reported per hour. A large
|
|
* positive slope across the idle soak is the signature of a retention leak
|
|
* rather than of ordinary warm-up.
|
|
*/
|
|
export const getGrowthBytesPerHour = (samples: { elapsedMs: number; bytes: number }[]) => {
|
|
if (samples.length < 2) return null;
|
|
const n = samples.length;
|
|
const meanX = samples.reduce((sum, s) => sum + s.elapsedMs, 0) / n;
|
|
const meanY = samples.reduce((sum, s) => sum + s.bytes, 0) / n;
|
|
let numerator = 0;
|
|
let denominator = 0;
|
|
for (const sample of samples) {
|
|
const dx = sample.elapsedMs - meanX;
|
|
numerator += dx * (sample.bytes - meanY);
|
|
denominator += dx * dx;
|
|
}
|
|
if (denominator === 0) return null;
|
|
return Math.round((numerator / denominator) * 3_600_000);
|
|
};
|
|
|
|
export const summarizeStress = (checkpoints: StressCheckpoint[], soakPrefix = "soak") => {
|
|
const containerMiBByName = (name: string) => {
|
|
const checkpoint = checkpoints.find((candidate) => candidate.name === name);
|
|
return checkpoint ? toMiB(checkpoint.container.currentBytes) : null;
|
|
};
|
|
// Anonymous memory is the number to compare across runs: memory.current also
|
|
// counts reclaimable page cache, which swings tens of MiB with unrelated disk I/O.
|
|
const anonMiBByName = (name: string) => {
|
|
const checkpoint = checkpoints.find((candidate) => candidate.name === name);
|
|
return checkpoint ? toMiB(checkpoint.container.anonymousBytes) : null;
|
|
};
|
|
const soakSamples = checkpoints
|
|
.filter((checkpoint) => checkpoint.name.startsWith(soakPrefix))
|
|
.map((checkpoint) => ({ elapsedMs: checkpoint.elapsedMs, bytes: checkpoint.container.anonymousBytes }));
|
|
|
|
const peak = checkpoints.reduce(
|
|
(max, checkpoint) => Math.max(max, checkpoint.container.peakBytes ?? checkpoint.container.currentBytes),
|
|
0,
|
|
);
|
|
|
|
return {
|
|
checkpointCount: checkpoints.length,
|
|
containerMiB: Object.fromEntries(
|
|
checkpoints.map((checkpoint) => [checkpoint.name, toMiB(checkpoint.container.currentBytes)]),
|
|
),
|
|
nodeRssMiB: Object.fromEntries(
|
|
checkpoints.map((checkpoint) => [checkpoint.name, toMiB(sumNodeRssBytes(checkpoint))]),
|
|
),
|
|
anonMiB: Object.fromEntries(
|
|
checkpoints.map((checkpoint) => [checkpoint.name, toMiB(checkpoint.container.anonymousBytes)]),
|
|
),
|
|
// Primary comparison metric: anonymous (non-reclaimable) memory.
|
|
headline: {
|
|
bootIdleAnonMiB: anonMiBByName("01-boot-idle"),
|
|
boardLoadedAnonMiB: anonMiBByName("04-board-loaded"),
|
|
afterStressAnonMiB: anonMiBByName("05-after-stress"),
|
|
bootIdleMiB: containerMiBByName("01-boot-idle"),
|
|
boardLoadedMiB: containerMiBByName("04-board-loaded"),
|
|
afterStressMiB: containerMiBByName("05-after-stress"),
|
|
peakMiB: toMiB(peak),
|
|
soakAnonGrowthMiBPerHour: (() => {
|
|
const slope = getGrowthBytesPerHour(soakSamples);
|
|
return slope === null ? null : toMiB(slope);
|
|
})(),
|
|
},
|
|
redisPeakMiB: (() => {
|
|
const peaks = checkpoints.flatMap((c) => (c.redis ? [c.redis.peakMemoryBytes] : []));
|
|
return peaks.length === 0 ? null : toMiB(Math.max(...peaks));
|
|
})(),
|
|
// Total CPU the container burned over the run, so a memory reduction bought
|
|
// with extra GC work shows up instead of hiding.
|
|
cpuSeconds: (() => {
|
|
const samples = checkpoints.flatMap((c) => (c.cpu ? [c.cpu.usageUsec] : []));
|
|
if (samples.length < 2) return null;
|
|
return Math.round(((Math.max(...samples) - Math.min(...samples)) / 1_000_000) * 10) / 10;
|
|
})(),
|
|
};
|
|
};
|