perf: bound V8's young generation and unbundle server-only deps

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.
This commit is contained in:
Thomas Camlong committed 2026-08-09 01:43:06 +02:00
1 parent 9826000722
commit 79ea987761
4 files changed
+134 -16

No files matched your search

+21 -1
View File
@@ -30,8 +30,28 @@ const nextConfig: NextConfig = {
typescript: { ignoreBuildErrors: true },
/**
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
*
* Everything else here is a server-only dependency that is cheaper left as a
* runtime require than inlined into the server bundle. Bundling pulls a package's
* whole transitive graph into a chunk that is loaded and compiled at boot even
* when the code path never runs — measurably so for the DB drivers: only
* better-sqlite3 is ever used, yet inlining mysql2 and pg cost ~21 MiB of
* resident memory at idle.
*/
serverExternalPackages: ["dockerode", "better-sqlite3", "mysql2", "pg"],
serverExternalPackages: [
"dockerode",
"better-sqlite3",
"mysql2",
"pg",
"winston",
"drizzle-orm",
"ical.js",
"jszip",
"ldapts",
"node-unifi",
"@kubernetes/client-node",
"linkedom",
],
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForBuild: true,
+23
View File
@@ -23,6 +23,12 @@ export type StressCheckpoint = {
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;
};
@@ -39,6 +45,7 @@ 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")"
@@ -84,6 +91,7 @@ export const parseStressSnapshot = (name: string, elapsedMs: number, output: str
const redisUsed = readNumericLine(output, "redis_used_memory");
const redisPeak = readNumericLine(output, "redis_used_memory_peak");
const cpuUsage = readNumericLine(output, "cpu_usage_usec");
return {
name,
@@ -95,6 +103,14 @@ export const parseStressSnapshot = (name: string, elapsedMs: number, output: str
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 },
};
@@ -183,5 +199,12 @@ export const summarizeStress = (checkpoints: StressCheckpoint[], soakPrefix = "s
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;
})(),
};
};
+83 -14
View File
@@ -21,7 +21,7 @@ import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { chromium } from "@playwright/test";
import type { Page } from "@playwright/test";
import type { Browser, Page } from "@playwright/test";
import { parseStressSnapshot, stressMemoryScript, summarizeStress, toMiB } from "./stress-restore-lib.mts";
import type { StressCheckpoint } from "./stress-restore-lib.mts";
@@ -83,29 +83,42 @@ const waitForReadyAsync = async (baseUrl: string, timeoutMs = 180_000) => {
};
/**
* The restored backup's boards are private and owned by another account, and
* the demo user has no home board. Grant access explicitly so the measurement
* targets a fully-populated board instead of an empty state.
* Ensures the benchmark user can open the target board, and reports what it had
* to change. A backup where that user is already an admin with the board as
* their home board reports "none" — which keeps the run honest about how much
* the harness touched the restored state.
*/
const applyAccessFixtureAsync = async () => {
const script = `
const Database = require("/app/node_modules/better-sqlite3");
const db = new Database("/appdata/db/db.sqlite");
const user = db.prepare("SELECT id FROM user WHERE name = ?").get(process.argv[1]);
const user = db.prepare("SELECT id, home_board_id FROM user WHERE name = ?").get(process.argv[1]);
if (!user) throw new Error("user not found: " + process.argv[1]);
const board = db.prepare("SELECT id FROM board WHERE name = ?").get(process.argv[2]);
if (!board) throw new Error("board not found: " + process.argv[2]);
const adminGroup = db.prepare("SELECT group_id AS id FROM groupPermission WHERE permission = 'admin' LIMIT 1").get();
const changes = [];
if (adminGroup) {
db.prepare("INSERT OR IGNORE INTO groupMember (group_id, user_id) VALUES (?, ?)").run(adminGroup.id, user.id);
const already = db
.prepare("SELECT 1 AS ok FROM groupMember WHERE group_id = ? AND user_id = ?")
.get(adminGroup.id, user.id);
if (!already) {
db.prepare("INSERT INTO groupMember (group_id, user_id) VALUES (?, ?)").run(adminGroup.id, user.id);
changes.push("granted-admin");
}
}
if (user.home_board_id !== board.id) {
db.prepare("UPDATE user SET home_board_id = ?, mobile_home_board_id = ? WHERE id = ?").run(board.id, board.id, user.id);
changes.push("set-home-board");
}
db.prepare("UPDATE user SET home_board_id = ?, mobile_home_board_id = ? WHERE id = ?").run(board.id, board.id, user.id);
db.close();
console.log("fixture-applied");
console.log("FIXTURE:" + (changes.length ? changes.join(",") : "none"));
`;
const { stdout } = await execFileAsync("docker", ["exec", containerName, "node", "-e", script, username, boardName]);
if (!stdout.includes("fixture-applied")) throw new Error(`Access fixture failed: ${stdout}`);
log("access fixture applied (admin group + home board)");
const applied = /FIXTURE:(.*)/.exec(stdout)?.[1]?.trim();
if (!applied) throw new Error(`Access fixture failed: ${stdout}`);
log(`access fixture changes: ${applied}`);
return applied;
};
const restoreBackupAsync = async (page: Page, baseUrl: string) => {
@@ -145,6 +158,34 @@ const restoreBackupAsync = async (page: Page, baseUrl: string) => {
log("restore submitted; server will exit and be restarted by run.sh");
};
/**
* Control: the restored boards are private, so an anonymous visitor must not see
* one. Without this, "28 widgets rendered" could mean the board was public and
* the sign-in step never actually authenticated anything.
*/
const verifyAnonymousAccessDeniedAsync = async (browser: Browser, baseUrl: string) => {
const context = await browser.newContext();
try {
const page = await context.newPage();
await page.goto(`${baseUrl}/boards/${boardName}`, { waitUntil: "domcontentloaded" });
const boardVisible = await page
.locator("[data-homarr-dev-benchmark-board]")
.waitFor({ state: "visible", timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (boardVisible) {
throw new Error(
`Anonymous request to /boards/${boardName} rendered the board — it is public, so the ` +
`authenticated measurement would prove nothing about sign-in.`,
);
}
log(`control: anonymous access to /boards/${boardName} denied (landed on ${new URL(page.url()).pathname})`);
return new URL(page.url()).pathname;
} finally {
await context.close();
}
};
const loginAsync = async (page: Page, baseUrl: string) => {
// networkidle: submitting before the form hydrates is a no-op click, which then
// looks exactly like an authentication failure.
@@ -162,8 +203,14 @@ const loginAsync = async (page: Page, baseUrl: string) => {
await page.waitForFunction(() => !window.location.pathname.includes("/auth/login"), undefined, {
timeout: 20_000,
});
log(`signed in as ${username}`);
return;
// Leaving /auth/login is necessary but not sufficient — require the session
// cookie Auth.js sets, so a redirect for any other reason cannot pass as auth.
const sessionCookie = (await page.context().cookies()).find((cookie) => cookie.name.endsWith("session-token"));
if (!sessionCookie) {
throw new Error(`Navigated away from /auth/login but no session-token cookie was set for ${username}`);
}
log(`signed in as ${username} (session cookie "${sessionCookie.name}" present)`);
return sessionCookie.name;
} catch {
if (attempt === 3) {
const error = await page
@@ -276,13 +323,15 @@ const main = async () => {
await waitForReadyAsync(baseUrl);
await captureAsync("02-post-restore");
await applyAccessFixtureAsync();
const fixtureChanges = await applyAccessFixtureAsync();
await context.close();
const anonymousLanding = await verifyAnonymousAccessDeniedAsync(browser, baseUrl);
const freshContext = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const freshPage = await freshContext.newPage();
await loginAsync(freshPage, baseUrl);
const sessionCookieName = await loginAsync(freshPage, baseUrl);
await captureAsync("03-post-login");
await freshPage.goto(`${baseUrl}/boards/${boardName}`, { waitUntil: "domcontentloaded" });
@@ -290,11 +339,22 @@ const main = async () => {
await captureAsync("04-board-loaded");
log(`stress: ${navIterations} reload iterations`);
const boardLoadMs: number[] = [];
for (let iteration = 0; iteration < navIterations; iteration++) {
const startedNav = Date.now();
await freshPage.goto(`${baseUrl}/boards/${boardName}`, { waitUntil: "domcontentloaded" });
await waitForBoardAsync(freshPage);
// Wall time from navigation start to every widget having mounted — the
// latency side of any memory/CPU trade-off.
boardLoadMs.push(Date.now() - startedNav);
await captureAsync(`nav-${String(iteration + 1).padStart(2, "0")}`);
}
const sortedLoads = [...boardLoadMs].sort((left, right) => left - right);
const lowerMedian = sortedLoads[Math.floor((sortedLoads.length - 1) / 2)];
const upperMedian = sortedLoads[Math.ceil((sortedLoads.length - 1) / 2)];
const medianBoardLoadMs =
lowerMedian === undefined || upperMedian === undefined ? null : (lowerMedian + upperMedian) / 2;
log(`board load ms: [${boardLoadMs.join(", ")}] median=${medianBoardLoadMs}`);
await captureAsync("05-after-stress");
log(`soak: ${soakMs}ms idle with board open`);
@@ -333,6 +393,15 @@ const main = async () => {
// Recorded so an A/B pair can be rejected if the two runs rendered
// different amounts of the board (e.g. more widgets errored on one side).
board: boardOutcome,
latency: { boardLoadMs, medianBoardLoadMs },
// Evidence that the board was measured behind a real session rather than
// because it happened to be publicly readable.
auth: {
username,
sessionCookieName,
anonymousBoardAccess: `denied (redirected to ${anonymousLanding})`,
fixtureChanges,
},
...summarizeStress(checkpoints),
};
await writeFile(path.join(outputDirectory, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`);
+7 -1
View File
@@ -6,7 +6,13 @@ mkdir -p /appdata/redis
mkdir -p /appdata/trusted-certificates
# Run migrations
export NODE_OPTIONS="--max-old-space-size=${HOMARR_MAX_OLD_SPACE_SIZE:-512} ${NODE_OPTIONS}"
# --max-old-space-size bounds the old generation; without it V8 sizes the heap from
# the *host's* RAM, so a big host lets RSS drift for a long time before collecting.
# --max-semi-space-size bounds the young generation, which V8 also sizes generously
# by default (16 MB per semi-space). Measured on a restored 28-widget board: 4 MB cut
# idle anonymous memory ~19% and peak ~21% for ~8% more CPU, with no board-load
# latency regression. Both are overridable for hosts that want to trade back.
export NODE_OPTIONS="--max-old-space-size=${HOMARR_MAX_OLD_SPACE_SIZE:-512} --max-semi-space-size=${HOMARR_MAX_SEMI_SPACE_SIZE:-4} ${NODE_OPTIONS}"
if [ "$DB_MIGRATIONS_DISABLED" = "true" ]; then
echo "DB migrations are disabled, skipping"
else