Files
cloudflare__vinext/tests/prerender-server-pool.test.ts
T
Yunfei He 13d36378f8 perf(build): parallelize prerender across a pool of render processes (#2437)
* perf(build): parallelize prerender across a pool of render processes

Build-time prerender rendered every static route by fetching it from a
single in-process production server driven by a promise pool. React
SSR/RSC rendering is CPU-bound JS, so the pool only overlaps I/O — every
render serialized on one core, and raising --prerender-concurrency did
nothing. Next.js forks a worker pool and saturates every core.

Fork a pool of production-server child processes (one per core, capped)
and round-robin the per-route render fetch across them, keeping route
scanning, getStaticPaths/static-params resolution, file writing and the
manifest on the main process. Pool size scales by cores AND routes, so
small apps, low-memory machines, and --prerender-concurrency 1 keep the
single in-process server (no fork, no regression); running from source
(no built .js worker entry) also falls back to single-process.

child_process, not worker_threads: worker threads contend for CPU on this
workload (measured ~2x slower per route and non-scaling), which is also
why Next.js uses processes.

react.dev (809 routes, cold cache, same machine): 29.9s -> 15.1s, now
faster than its own Next.js build (~19.3s). An 801-route static fixture:
~22s -> ~2-6s. Prerender output is byte-identical to the single-process
path on deterministic renders; workers install the same NoOp cache
handler the in-process path uses. A worker that exits unexpectedly fails
the build loudly instead of shipping partial output.

* fix(build): harden prerender worker pool

---------

Co-authored-by: James <james@eli.cx>
2026-06-30 23:04:02 +01:00

131 lines
4.1 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
resolvePrerenderPoolSize,
startPrerenderServerPool,
} from "../packages/vinext/src/build/prerender-server-pool.js";
describe("prerender server pool sizing", () => {
afterEach(() => {
vi.restoreAllMocks();
});
function mockMachine(cores: number, totalmem: number): void {
vi.spyOn(os, "availableParallelism").mockReturnValue(cores);
vi.spyOn(os, "totalmem").mockReturnValue(totalmem);
}
it("keeps small prerenders in the in-process server", () => {
mockMachine(16, 64 * 1024 * 1024 * 1024);
expect(resolvePrerenderPoolSize(47)).toBe(1);
});
it("caps by routes, cores, memory, and the explicit worker override", () => {
mockMachine(16, 64 * 1024 * 1024 * 1024);
expect(resolvePrerenderPoolSize(96, 2)).toBe(2);
expect(resolvePrerenderPoolSize(384)).toBe(8);
mockMachine(4, 64 * 1024 * 1024 * 1024);
expect(resolvePrerenderPoolSize(384)).toBe(3);
mockMachine(16, 2 * 1024 * 1024 * 1024);
expect(resolvePrerenderPoolSize(384)).toBe(1);
mockMachine(16, 2.5 * 1024 * 1024 * 1024);
expect(resolvePrerenderPoolSize(384)).toBe(2);
});
it("treats an explicit concurrency of 1 as opting out of the process pool", () => {
mockMachine(16, 64 * 1024 * 1024 * 1024);
expect(resolvePrerenderPoolSize(384, 1)).toBe(1);
});
function writeWorkerEntry(source: string): { dir: string; entry: string } {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-prerender-pool-worker-"));
const entry = path.join(dir, "worker.js");
fs.writeFileSync(entry, source, "utf-8");
return { dir, entry };
}
it("starts and closes stub render workers", async () => {
const { dir, entry } = writeWorkerEntry(`
process.send({ type: "ready", port: 30000 + (process.pid % 10000) });
setInterval(() => {}, 1000);
`);
try {
const pool = await startPrerenderServerPool(dir, 2, entry);
try {
expect(pool.ports).toHaveLength(2);
expect(pool.ports.every((port) => typeof port === "number")).toBe(true);
expect(() => pool.assertHealthy()).not.toThrow();
} finally {
await pool.close();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("fails assertHealthy immediately after a worker transport error", async () => {
const { dir, entry } = writeWorkerEntry(`
process.send({ type: "ready", port: 4124 });
setInterval(() => {}, 1000);
`);
try {
const pool = await startPrerenderServerPool(dir, 1, entry);
try {
pool.recordRenderError(new Error("renderPage returned 500"));
expect(() => pool.assertHealthy()).not.toThrow();
const transportError = Object.assign(new TypeError("fetch failed"), {
cause: { code: "ECONNRESET" },
});
pool.recordRenderError(transportError);
expect(() => pool.assertHealthy()).toThrow(/request failed \(ECONNRESET\)/);
} finally {
await pool.close();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("fails assertHealthy when a ready worker exits unexpectedly", async () => {
const { dir, entry } = writeWorkerEntry(`
process.send({ type: "ready", port: 4123 });
setTimeout(() => process.exit(42), 25);
`);
try {
const pool = await startPrerenderServerPool(dir, 1, entry);
try {
await new Promise((resolve) => setTimeout(resolve, 100));
expect(() => pool.assertHealthy()).toThrow(/exited unexpectedly .*code 42/);
} finally {
await pool.close();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("rejects when a worker exits before reporting ready", async () => {
const { dir, entry } = writeWorkerEntry("process.exit(0);");
try {
await expect(startPrerenderServerPool(dir, 1, entry)).rejects.toThrow(
"exited during startup (code 0, signal null)",
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});