Skip to content

Commit 36db45a

Browse files
wagnermacielvikerman
authored andcommitted
feat(builders): parallelize renders in prerender builder (#1396)
## Goal Increase the performance of the prerender builder. I tested against 800 routes that each make a single http.get request and observed approximately a 5 times speedup. ## Child Process We chose to fork child processes using the child_process API because: We are not sure if these operations are thread safe fork is used specifically to spawn new Node.js processes, which is our exact use case Implementation render.ts This file is specifically designed to be called as a child process. It expects specific arguments to be passed and expects process.send to exist (which only happens when a parent process exists). The args that this file expects are: indexHtml: The base html for rendering routes serverBundlePath: The path to the server module bundle browserOutputPath: The path to the browser builders output allRoutes: The rest of the arguments passed are the routes to be rendered index.ts Calling _renderUniversal now forks child processes that run render.ts to render the routes in parallel. There is an issue with forking processes within an angular builder where the parent process will exit before the child processes finish. This is likely because somewhere in our builder logic we call process.exit or something like that which kills the parent process early. To fix this issue, we wrap child processes in promises that resolve when the process finishes (either by exiting or erring). ## Tests These changes completely break existing unit tests. Although helper functions such as getRange in render.ts can still be unit tested, integration tests should be what we really depend on. It might be worth it to move unit testable helper functions to a utils.ts file.
1 parent 06398b5 commit 36db45a

File tree

6 files changed

+190
-78
lines changed

6 files changed

+190
-78
lines changed

modules/builders/src/prerender/index.ts

+52-77
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@
88

99
import { BuilderContext, BuilderOutput, createBuilder, targetFromTargetString } from '@angular-devkit/architect';
1010
import { json } from '@angular-devkit/core';
11-
import { Buffer } from 'buffer';
11+
import { fork } from 'child_process';
12+
1213
import * as fs from 'fs';
1314
import * as path from 'path';
1415

1516
import { Schema } from './schema';
16-
import { getRoutes } from './utils';
17+
import { getRoutes, shardArray } from './utils';
1718

1819
export type PrerenderBuilderOptions = Schema & json.JsonObject;
1920

@@ -67,6 +68,38 @@ async function _scheduleBuilds(
6768
}
6869
}
6970

71+
async function _parallelRenderRoutes(
72+
shardedRoutes: string[][],
73+
context: BuilderContext,
74+
indexHtml: string,
75+
outputPath: string,
76+
serverBundlePath: string,
77+
): Promise<void> {
78+
const workerFile = path.join(__dirname, 'render.js');
79+
const childProcesses = shardedRoutes.map(routes =>
80+
new Promise((resolve, reject) => {
81+
fork(workerFile, [
82+
indexHtml,
83+
serverBundlePath,
84+
outputPath,
85+
...routes,
86+
])
87+
.on('message', data => {
88+
if (data.success) {
89+
context.logger.info(`CREATE ${data.outputIndexPath} (${data.bytes} bytes)`);
90+
} else {
91+
context.logger.error(`Error: ${data.error.message}`);
92+
context.logger.error(`Unable to render ${data.outputIndexPath}`);
93+
}
94+
})
95+
.on('exit', resolve)
96+
.on('error', reject);
97+
})
98+
);
99+
100+
await Promise.all(childProcesses);
101+
}
102+
70103
/**
71104
* Renders each route in options.routes and writes them to
72105
* <route>/index.html for each output path in the browser result.
@@ -76,91 +109,33 @@ async function _renderUniversal(
76109
context: BuilderContext,
77110
browserResult: BuildBuilderOutput,
78111
serverResult: BuildBuilderOutput,
112+
numProcesses?: number,
79113
): Promise<BuildBuilderOutput> {
80114
// We need to render the routes for each locale from the browser output.
81115
for (const outputPath of browserResult.outputPaths) {
82-
const localeDirectory = path.relative(browserResult.baseOutputPath, outputPath);
83116
const browserIndexOutputPath = path.join(outputPath, 'index.html');
84117
const indexHtml = fs.readFileSync(browserIndexOutputPath, 'utf8');
85-
const { AppServerModuleDef, renderModuleFn } =
86-
await _getServerModuleBundle(serverResult, localeDirectory);
87118

88-
context.logger.info(`\nPrerendering ${routes.length} route(s) to ${outputPath}`);
89-
90-
// Render each route and write them to <route>/index.html.
91-
for (const route of routes) {
92-
const renderOpts = {
93-
document: indexHtml + '<!-- This page was prerendered with Angular Universal -->',
94-
url: route,
95-
};
96-
const html = await renderModuleFn(AppServerModuleDef, renderOpts);
97-
98-
const outputFolderPath = path.join(outputPath, route);
99-
const outputIndexPath = path.join(outputFolderPath, 'index.html');
100-
101-
// This case happens when we are prerendering "/".
102-
if (browserIndexOutputPath === outputIndexPath) {
103-
const browserIndexOutputPathOriginal = path.join(outputPath, 'index.original.html');
104-
fs.writeFileSync(browserIndexOutputPathOriginal, indexHtml);
105-
}
106-
107-
try {
108-
fs.mkdirSync(outputFolderPath, { recursive: true });
109-
fs.writeFileSync(outputIndexPath, html);
110-
const bytes = Buffer.byteLength(html).toFixed(0);
111-
context.logger.info(
112-
`CREATE ${outputIndexPath} (${bytes} bytes)`
113-
);
114-
} catch {
115-
context.logger.error(`Unable to render ${outputIndexPath}`);
116-
}
119+
const { baseOutputPath = '' } = serverResult;
120+
const localeDirectory = path.relative(browserResult.baseOutputPath, outputPath);
121+
const serverBundlePath = path.join(baseOutputPath, localeDirectory, 'main.js');
122+
if (!fs.existsSync(serverBundlePath)) {
123+
throw new Error(`Could not find the main bundle: ${serverBundlePath}`);
117124
}
118-
}
119125

120-
return browserResult;
121-
}
122-
123-
/**
124-
* If the app module bundle path is not specified in options.appModuleBundle,
125-
* this method searches for what is usually the app module bundle file and
126-
* returns its server module bundle.
127-
*
128-
* Throws if no app module bundle is found.
129-
*/
130-
async function _getServerModuleBundle(
131-
serverResult: BuildBuilderOutput,
132-
browserLocaleDirectory: string,
133-
) {
134-
const { baseOutputPath = '' } = serverResult;
135-
const serverBundlePath = path.join(baseOutputPath, browserLocaleDirectory, 'main.js');
136-
137-
if (!fs.existsSync(serverBundlePath)) {
138-
throw new Error(`Could not find the main bundle: ${serverBundlePath}`);
139-
}
126+
const shardedRoutes = shardArray(routes, numProcesses);
127+
context.logger.info(`\nPrerendering ${routes.length} route(s) to ${outputPath}`);
140128

141-
const {
142-
AppServerModule,
143-
AppServerModuleNgFactory,
144-
renderModule,
145-
renderModuleFactory,
146-
} = await import(serverBundlePath);
147-
148-
if (renderModuleFactory && AppServerModuleNgFactory) {
149-
// Happens when in ViewEngine mode.
150-
return {
151-
renderModuleFn: renderModuleFactory,
152-
AppServerModuleDef: AppServerModuleNgFactory,
153-
};
129+
await _parallelRenderRoutes(
130+
shardedRoutes,
131+
context,
132+
indexHtml,
133+
outputPath,
134+
serverBundlePath,
135+
);
154136
}
155137

156-
if (renderModule && AppServerModule) {
157-
// Happens when in Ivy mode.
158-
return {
159-
renderModuleFn: renderModule,
160-
AppServerModuleDef: AppServerModule,
161-
};
162-
}
163-
throw new Error(`renderModule method and/or AppServerModule were not exported from: ${serverBundlePath}.`);
138+
return browserResult;
164139
}
165140

166141
/**
@@ -182,7 +157,7 @@ export async function execute(
182157
return { success, error } as BuilderOutput;
183158
}
184159

185-
return _renderUniversal(routes, context, browserResult, serverResult);
160+
return _renderUniversal(routes, context, browserResult, serverResult, options.numProcesses);
186161
}
187162

188163
export default createBuilder(execute);
+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import * as fs from 'fs';
10+
import * as path from 'path';
11+
12+
const [
13+
indexHtml,
14+
serverBundlePath,
15+
browserOutputPath,
16+
...routes
17+
] = process.argv.slice(2);
18+
19+
/**
20+
* Handles importing the server bundle.
21+
*/
22+
async function getServerBundle(bundlePath: string) {
23+
const {
24+
AppServerModule,
25+
AppServerModuleNgFactory,
26+
renderModule,
27+
renderModuleFactory,
28+
} = await import(bundlePath);
29+
30+
if (renderModuleFactory && AppServerModuleNgFactory) {
31+
// Happens when in ViewEngine mode.
32+
return {
33+
renderModuleFn: renderModuleFactory,
34+
AppServerModuleDef: AppServerModuleNgFactory,
35+
};
36+
}
37+
38+
if (renderModule && AppServerModule) {
39+
// Happens when in Ivy mode.
40+
return {
41+
renderModuleFn: renderModule,
42+
AppServerModuleDef: AppServerModule,
43+
};
44+
}
45+
throw new Error(`renderModule method and/or AppServerModule were not exported from: ${serverBundlePath}.`);
46+
}
47+
48+
/**
49+
* Renders each route in routes and writes them to <outputPath>/<route>/index.html.
50+
*/
51+
(async () => {
52+
const { renderModuleFn, AppServerModuleDef } = await getServerBundle(serverBundlePath);
53+
const browserIndexOutputPath = path.join(browserOutputPath, 'index.html');
54+
for (const route of routes) {
55+
const renderOpts = {
56+
document: indexHtml + '<!-- This page was prerendered with Angular Universal -->',
57+
url: route,
58+
};
59+
const html = await renderModuleFn(AppServerModuleDef, renderOpts);
60+
61+
const outputFolderPath = path.join(browserOutputPath, route);
62+
const outputIndexPath = path.join(outputFolderPath, 'index.html');
63+
64+
// This case happens when we are prerendering "/".
65+
if (browserIndexOutputPath === outputIndexPath) {
66+
const browserIndexOutputPathOriginal = path.join(browserOutputPath, 'index.original.html');
67+
fs.writeFileSync(browserIndexOutputPathOriginal, indexHtml);
68+
}
69+
70+
try {
71+
fs.mkdirSync(outputFolderPath, { recursive: true });
72+
fs.writeFileSync(outputIndexPath, html);
73+
const bytes = Buffer.byteLength(html).toFixed(0);
74+
if (process.send) {
75+
process.send({ success: true, outputIndexPath, bytes });
76+
}
77+
} catch (e) {
78+
if (process.send) {
79+
process.send({ success: false, error: e, outputIndexPath });
80+
}
81+
}
82+
}
83+
})().then().catch();

modules/builders/src/prerender/schema.json

+5
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@
2626
"uniqueItems": true
2727
},
2828
"default": []
29+
},
30+
"numProcesses": {
31+
"type": "number",
32+
"description": "The number of cpus to use. Defaults to all but one.",
33+
"minimum": 1
2934
}
3035
},
3136
"required": [

modules/builders/src/prerender/schema.ts

+4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export interface Schema {
1111
* Target to build.
1212
*/
1313
browserTarget: string;
14+
/**
15+
* The number of cpus to use. Defaults to all but one.
16+
*/
17+
numProcesses?: number;
1418
/**
1519
* The routes to render.
1620
*/

modules/builders/src/prerender/utils.spec.ts

+29-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import * as fs from 'fs';
10-
import { getRoutes } from './utils';
10+
import { getRoutes, shardArray } from './utils';
1111

1212
describe('Prerender Builder Utils', () => {
1313
describe('#getRoutes', () => {
@@ -35,4 +35,32 @@ describe('Prerender Builder Utils', () => {
3535
expect(routes).toEqual(['/route1', '/route2', '/route3']);
3636
});
3737
});
38+
39+
describe('#shardArray', () => {
40+
const ARRAY = [0, 1, 2, 3, 4];
41+
it('Should shard an array into numshards shards', () => {
42+
const result1 = shardArray(ARRAY, 1);
43+
const result2 = shardArray(ARRAY, 2);
44+
const result3 = shardArray(ARRAY, 3);
45+
const result4 = shardArray(ARRAY, 4);
46+
const result5 = shardArray(ARRAY, 5);
47+
expect(result1).toEqual([[0, 1, 2, 3, 4]]);
48+
expect(result2).toEqual([[0, 2, 4], [1, 3]]);
49+
expect(result3).toEqual([[0, 3], [1, 4], [2]]);
50+
expect(result4).toEqual([[0, 4], [1], [2], [3]]);
51+
expect(result5).toEqual([[0], [1], [2], [3], [4]]);
52+
});
53+
54+
it('Should handle 0 or less numshards', () => {
55+
const result1 = shardArray(ARRAY, 0);
56+
const result2 = shardArray(ARRAY, -1);
57+
expect(result1).toEqual([]);
58+
expect(result2).toEqual([]);
59+
});
60+
61+
it('Should not shard more than the total number of items in the array', () => {
62+
const result = shardArray(ARRAY, 7);
63+
expect(result).toEqual([[0], [1], [2], [3], [4]]);
64+
});
65+
});
3866
});

modules/builders/src/prerender/utils.ts

+17
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import * as fs from 'fs';
10+
import * as os from 'os';
1011
import * as path from 'path';
1112

1213
/**
@@ -28,3 +29,19 @@ export function getRoutes(
2829

2930
return [...new Set([...routesFileResult, ...routes])];
3031
}
32+
33+
/**
34+
* Evenly shards items in an array.
35+
* e.g. shardArray([1, 2, 3, 4], 2) => [[1, 2], [3, 4]]
36+
*/
37+
export function shardArray<T>(items: T[], numProcesses: number = os.cpus().length - 1): T[][] {
38+
const shardedArray = [];
39+
const numShards = Math.min(numProcesses, items.length);
40+
for (let i = 0; i < numShards; i++) {
41+
shardedArray.push(
42+
items.filter((_, index) => index % numShards === i)
43+
);
44+
}
45+
46+
return shardedArray;
47+
}

0 commit comments

Comments
 (0)