Skip to content

Commit 3a8cae2

Browse files
committed
feat(perf): offload generators to worker threads
1 parent 9be11fd commit 3a8cae2

File tree

11 files changed

+125
-54
lines changed

11 files changed

+125
-54
lines changed

bin/cli.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ program
7777
.choices(Object.keys(reporters))
7878
.default('console')
7979
)
80+
.addOption(
81+
new Option(
82+
'--disable-parallelism',
83+
'Disable the use of multiple threads'
84+
).default(false)
85+
)
8086
.parse(process.argv);
8187

8288
/**
@@ -108,6 +114,7 @@ const {
108114
lintDryRun,
109115
gitRef,
110116
reporter,
117+
disableParallelism,
111118
} = program.opts();
112119

113120
const linter = createLinter(lintDryRun, disableRule);
@@ -142,6 +149,8 @@ if (target) {
142149
// An URL containing a git ref URL pointing to the commit or ref that was used
143150
// to generate the API docs. This is used to link to the source code of the
144151
gitRef,
152+
// Disable the use of parallel threads
153+
disableParallelism,
145154
});
146155
}
147156

src/generators.mjs

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
'use strict';
22

3+
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';
4+
import os from 'os';
5+
36
import publicGenerators from './generators/index.mjs';
47
import astJs from './generators/ast-js/index.mjs';
58
import oramaDb from './generators/orama-db/index.mjs';
@@ -12,6 +15,25 @@ const availableGenerators = {
1215
'orama-db': oramaDb,
1316
};
1417

18+
// Thread pool max limit
19+
const MAX_THREADS = Math.max(1, os.cpus().length - 1);
20+
21+
// If inside a worker thread, perform the generator logic here
22+
if (!isMainThread) {
23+
const { name, dependencyOutput, extra } = workerData;
24+
const generator = availableGenerators[name];
25+
26+
// Execute the generator and send the result back to the parent thread
27+
generator
28+
.generate(dependencyOutput, extra)
29+
.then(result => {
30+
parentPort.postMessage(result);
31+
})
32+
.catch(error => {
33+
parentPort.postMessage({ error });
34+
});
35+
}
36+
1537
/**
1638
* @typedef {{ ast: GeneratorMetadata<ApiDocMetadataEntry, ApiDocMetadataEntry>}} AstGenerator The AST "generator" is a facade for the AST tree and it isn't really a generator
1739
* @typedef {AvailableGenerators & AstGenerator} AllGenerators A complete set of the available generators, including the AST one
@@ -43,30 +65,103 @@ const createGenerator = markdownInput => {
4365
*/
4466
const cachedGenerators = { ast: Promise.resolve(markdownInput) };
4567

68+
// Keep track of how many threads are currently running
69+
let activeThreads = 0;
70+
const threadQueue = [];
71+
72+
/**
73+
*
74+
* @param name
75+
* @param dependencyOutput
76+
* @param extra
77+
*/
78+
const runInWorker = (name, dependencyOutput, extra) => {
79+
return new Promise((resolve, reject) => {
80+
/**
81+
*
82+
*/
83+
const run = () => {
84+
activeThreads++;
85+
86+
const worker = new Worker(new URL(import.meta.url), {
87+
workerData: { name, dependencyOutput, extra },
88+
});
89+
90+
worker.on('message', result => {
91+
activeThreads--;
92+
processQueue();
93+
94+
if (result && result.error) {
95+
reject(result.error);
96+
} else {
97+
resolve(result);
98+
}
99+
});
100+
101+
worker.on('error', err => {
102+
activeThreads--;
103+
processQueue();
104+
reject(err);
105+
});
106+
};
107+
108+
if (activeThreads >= MAX_THREADS) {
109+
threadQueue.push(run);
110+
} else {
111+
run();
112+
}
113+
});
114+
};
115+
116+
/**
117+
*
118+
*/
119+
const processQueue = () => {
120+
if (threadQueue.length > 0 && activeThreads < MAX_THREADS) {
121+
const next = threadQueue.shift();
122+
next();
123+
}
124+
};
125+
46126
/**
47127
* Runs the Generator engine with the provided top-level input and the given generator options
48128
*
49129
* @param {GeneratorOptions} options The options for the generator runtime
50130
*/
51-
const runGenerators = async ({ generators, ...extra }) => {
131+
const runGenerators = async ({
132+
generators,
133+
disableParallelism = false,
134+
...extra
135+
}) => {
52136
// Note that this method is blocking, and will only execute one generator per-time
53137
// but it ensures all dependencies are resolved, and that multiple bottom-level generators
54138
// can reuse the already parsed content from the top-level/dependency generators
55139
for (const generatorName of generators) {
56-
const { dependsOn, generate } = availableGenerators[generatorName];
140+
const {
141+
dependsOn,
142+
generate,
143+
parallizable = true,
144+
} = availableGenerators[generatorName];
57145

58146
// If the generator dependency has not yet been resolved, we resolve
59147
// the dependency first before running the current generator
60-
if (dependsOn && dependsOn in cachedGenerators === false) {
61-
await runGenerators({ ...extra, generators: [dependsOn] });
148+
if (dependsOn && !(dependsOn in cachedGenerators)) {
149+
await runGenerators({
150+
...extra,
151+
disableParallelism,
152+
generators: [dependsOn],
153+
});
62154
}
63155

64156
// Ensures that the dependency output gets resolved before we run the current
65157
// generator with its dependency output as the input
66158
const dependencyOutput = await cachedGenerators[dependsOn];
67159

68160
// Adds the current generator execution Promise to the cache
69-
cachedGenerators[generatorName] = generate(dependencyOutput, extra);
161+
cachedGenerators[generatorName] =
162+
disableParallelism || !parallizable
163+
? generate(dependencyOutput, extra) // Run in main thread
164+
: runInWorker(generatorName, dependencyOutput, extra); // Offload to worker thread
70165
}
71166

72167
// Returns the value of the last generator of the current pipeline

src/generators/json-simple/index.mjs

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { join } from 'node:path';
66
import { remove } from 'unist-util-remove';
77

88
import createQueries from '../../utils/queries/index.mjs';
9-
import { getRemark } from '../../utils/remark.mjs';
109

1110
/**
1211
* This generator generates a simplified JSON version of the API docs and returns it as a string
@@ -35,9 +34,6 @@ export default {
3534
* @param {Partial<GeneratorOptions>} options
3635
*/
3736
async generate(input, options) {
38-
// Gets a remark processor for stringifying the AST tree into JSON
39-
const remarkProcessor = getRemark();
40-
4137
// Iterates the input (ApiDocMetadataEntry) and performs a few changes
4238
const mappedInput = input.map(node => {
4339
// Deep clones the content nodes to avoid affecting upstream nodes
@@ -50,12 +46,6 @@ export default {
5046
createQueries.UNIST.isHeading,
5147
]);
5248

53-
/**
54-
* For the JSON generate we want to transform the whole content into JSON
55-
* @returns {string} The stringified JSON version of the content
56-
*/
57-
content.toJSON = () => remarkProcessor.stringify(content);
58-
5949
return { ...node, content };
6050
});
6151

src/generators/legacy-html-all/index.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export default {
8686
.replace('__ID__', 'all')
8787
.replace(/__FILENAME__/g, 'all')
8888
.replace('__SECTION__', 'All')
89-
.replace(/__VERSION__/g, `v${version.toString()}`)
89+
.replace(/__VERSION__/g, `v${version.version}`)
9090
.replace(/__TOC__/g, tableOfContents.wrapToC(aggregatedToC))
9191
.replace(/__GTOC__/g, parsedSideNav)
9292
.replace('__CONTENT__', aggregatedContent)

src/generators/legacy-html/index.mjs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ export default {
8484
*/
8585
const replaceTemplateValues = values => {
8686
const { api, added, section, version, toc, nav, content } = values;
87-
8887
return apiTemplate
8988
.replace('__ID__', api)
9089
.replace(/__FILENAME__/g, api)
@@ -139,7 +138,7 @@ export default {
139138
api: head.api,
140139
added: head.introduced_in ?? '',
141140
section: head.heading.data.name || apiAsHeading,
142-
version: `v${version.toString()}`,
141+
version: `v${version.version}`,
143142
toc: String(parsedToC),
144143
nav: String(activeSideNav),
145144
content: parsedContent,

src/generators/legacy-html/utils/buildDropdowns.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,9 @@ const buildNavigation = navigationContents =>
6060
const buildVersions = (api, added, versions) => {
6161
// All Node.js versions that support the current API; If there's no "introduced_at" field,
6262
// we simply show all versions, as we cannot pinpoint the exact version
63+
const coercedMajor = major(coerceSemVer(added));
6364
const compatibleVersions = versions.filter(({ version }) =>
64-
added ? major(version) >= major(coerceSemVer(added)) : true
65+
added ? version.major >= coercedMajor : true
6566
);
6667

6768
// Parses the SemVer version into something we use for URLs and to display the Node.js version

src/generators/legacy-json/utils/buildSection.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export const createSectionBuilder = () => {
5858
* @param {import('../types.d.ts').HierarchizedEntry} entry - The entry providing stability information.
5959
*/
6060
const parseStability = (section, nodes, { stability }) => {
61-
const stabilityInfo = stability.toJSON()?.[0];
61+
const stabilityInfo = stability.children.map(node => node.data);
6262

6363
if (stabilityInfo) {
6464
section.stability = stabilityInfo.index;

src/linter/tests/fixtures/entries.mjs

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,3 @@
1-
/**
2-
* Noop function.
3-
*
4-
* @returns {any}
5-
*/
6-
const noop = () => {};
7-
81
/**
92
* @type {ApiDocMetadataEntry}
103
*/
@@ -69,12 +62,10 @@ export const assertEntry = {
6962
slug: 'assert',
7063
type: 'property',
7164
},
72-
toJSON: noop,
7365
},
7466
stability: {
7567
type: 'root',
7668
children: [],
77-
toJSON: noop,
7869
},
7970
content: {
8071
type: 'root',

src/metadata.mjs

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -140,17 +140,6 @@ const createMetadata = slugger => {
140140
internalMetadata.heading.data.type =
141141
type ?? internalMetadata.heading.data.type;
142142

143-
/**
144-
* Defines the toJSON method for the Heading AST node to be converted as JSON
145-
*/
146-
internalMetadata.heading.toJSON = () => internalMetadata.heading.data;
147-
148-
/**
149-
* Maps the Stability Index AST nodes into a JSON objects from their data properties
150-
*/
151-
internalMetadata.stability.toJSON = () =>
152-
internalMetadata.stability.children.map(node => node.data);
153-
154143
// Returns the Metadata entry for the API doc
155144
return {
156145
api: apiDoc.stem,

src/test/metadata.test.mjs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ describe('createMetadata', () => {
3333
};
3434
metadata.addStability(stability);
3535
const actual = metadata.create(new VFile(), {}).stability;
36-
delete actual.toJSON;
3736
deepStrictEqual(actual, {
3837
children: [stability],
3938
type: 'root',
@@ -82,8 +81,15 @@ describe('createMetadata', () => {
8281
yaml_position: {},
8382
};
8483
const actual = metadata.create(apiDoc, section);
85-
delete actual.stability.toJSON;
86-
delete actual.heading.toJSON;
8784
deepStrictEqual(actual, expected);
8885
});
86+
87+
it('should be serializable', () => {
88+
const { create } = createMetadata(new GitHubSlugger());
89+
const actual = create(new VFile({ path: 'test.md' }), {
90+
type: 'root',
91+
children: [],
92+
});
93+
deepStrictEqual(structuredClone(actual), actual);
94+
});
8995
});

0 commit comments

Comments
 (0)