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:
4 files changed
+134
-16
No files matched your search
@@ -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,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;
|
||||
})(),
|
||||
};
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user