Skip to content

Commit a916f23

Browse files
authored
perf: speed up all commands by 75+ ms by enabling V8 code cache when available (node>=22.8.0) (#7173)
* build(deps): use correct version of @types/node Our minimum supported version is 18, so we need to rely on node 18 types. * refactor: wrap main program in a main function * chore(devDeps): remove unused is-ci dev dep * refactor: allow calling `setAnalyticsPayload` earlier The `preAction` hook was overwriting the whole payload, so any prior calls to `setAnalyticsPayload` would have their data thrown away. This didn't matter, but I'd like to introduce an early call. * perf: enable V8 code cache when available See https://nodejs.org/api/module.html#module-compile-cache. In local benchmarking on various machines, this led to a 75-400 ms improvement to command run time across the board, at the expense of a moderate increase in first-time run time. See inline - to account for the common CI single-run use case, this is disabled when we detect a CI environment. * fix: log telemetry track errors in debug mode * refactor: extract compile cache module This also removes the child process handling. * build: disable compile cache when running tests * perf: defer more imports in bin/run * docs: add another clarification to compile cache
1 parent cef9426 commit a916f23

File tree

9 files changed

+125
-91
lines changed

9 files changed

+125
-91
lines changed

bin/run.js

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,41 @@
11
#!/usr/bin/env node
22
import { argv } from 'process'
33

4-
import updateNotifier from 'update-notifier'
5-
6-
import { createMainCommand } from '../dist/commands/main.js'
7-
import { logError } from '../dist/utils/command-helpers.js'
8-
import getPackageJson from '../dist/utils/get-cli-package-json.js'
9-
import { runProgram } from '../dist/utils/run-program.js'
4+
import { maybeEnableCompileCache } from '../dist/utils/nodejs-compile-cache.js'
105

116
// 12 hours
127
const UPDATE_CHECK_INTERVAL = 432e5
13-
const pkg = await getPackageJson()
14-
15-
try {
16-
updateNotifier({
17-
pkg,
18-
updateCheckInterval: UPDATE_CHECK_INTERVAL,
19-
}).notify()
20-
} catch (error) {
21-
logError(`Error checking for updates: ${error?.toString()}`)
22-
}
238

24-
const program = createMainCommand()
9+
const main = async () => {
10+
const { default: updateNotifier } = await import('update-notifier')
11+
const { createMainCommand } = await import('../dist/commands/main.js')
12+
const { logError } = await import('../dist/utils/command-helpers.js')
13+
const { default: getPackageJson } = await import('../dist/utils/get-cli-package-json.js')
14+
const { runProgram } = await import('../dist/utils/run-program.js')
15+
16+
const pkg = await getPackageJson()
17+
18+
try {
19+
updateNotifier({
20+
pkg,
21+
updateCheckInterval: UPDATE_CHECK_INTERVAL,
22+
}).notify()
23+
} catch (error) {
24+
logError(`Error checking for updates: ${error?.toString()}`)
25+
}
2526

26-
try {
27-
await runProgram(program, argv)
27+
const program = createMainCommand()
2828

29-
program.onEnd()
30-
} catch (error) {
31-
program.onEnd(error)
29+
try {
30+
await runProgram(program, argv)
31+
32+
program.onEnd()
33+
} catch (error) {
34+
program.onEnd(error)
35+
}
3236
}
37+
38+
// Must come first, including before any imports
39+
maybeEnableCompileCache()
40+
41+
await main()

eslint_temporary_suppressions.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1441,7 +1441,6 @@ export default [
14411441
{
14421442
files: ['tests/integration/telemetry.test.ts'],
14431443
rules: {
1444-
'@typescript-eslint/no-unused-vars': 'off',
14451444
'@typescript-eslint/no-non-null-assertion': 'off',
14461445
'@typescript-eslint/no-unsafe-assignment': 'off',
14471446
},

package-lock.json

Lines changed: 17 additions & 43 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@
181181
"@types/jsonwebtoken": "9.0.9",
182182
"@types/lodash": "4.17.16",
183183
"@types/multiparty": "4.2.1",
184-
"@types/node": "22.13.10",
184+
"@types/node": "18.19.86",
185185
"@types/node-fetch": "2.6.12",
186186
"@types/parallel-transform": "1.1.4",
187187
"@types/parse-github-url": "1.0.3",
@@ -203,7 +203,6 @@
203203
"eslint-config-prettier": "^10.1.1",
204204
"eslint-plugin-n": "^17.16.1",
205205
"form-data": "4.0.2",
206-
"is-ci": "4.1.0",
207206
"memfs": "^4.17.0",
208207
"nock": "14.0.1",
209208
"npm-run-all2": "^7.0.2",

src/commands/base-command.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ async function getRepositoryRoot(cwd?: string): Promise<string | undefined> {
152152
export default class BaseCommand extends Command {
153153
/** The netlify object inside each command with the state */
154154
netlify!: NetlifyOptions
155+
// TODO(serhalp) We set `startTime` here and then overwrite it in a `preAction` hook. This is
156+
// just asking for latent bugs. Remove this one?
155157
analytics: Analytics = { startTime: process.hrtime.bigint() }
156158
project!: Project
157159

@@ -213,7 +215,7 @@ export default class BaseCommand extends Command {
213215
process.env.DEBUG = '*'
214216
}
215217
debug(`${name}:preAction`)('start')
216-
this.analytics = { startTime: process.hrtime.bigint() }
218+
this.analytics.startTime = process.hrtime.bigint()
217219
await this.init(actionCommand as BaseCommand)
218220
debug(`${name}:preAction`)('end')
219221
})
@@ -378,7 +380,11 @@ export default class BaseCommand extends Command {
378380
duration,
379381
status,
380382
})
381-
} catch {}
383+
} catch (err) {
384+
debug(`${this.name()}:onEnd`)(
385+
`Command: ${command}. Telemetry tracking failed: ${err instanceof Error ? err.message : err?.toString()}`,
386+
)
387+
}
382388

383389
if (error_ !== undefined) {
384390
logError(error_ instanceof Error ? error_ : format(error_))

src/commands/main.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import execa from '../utils/execa.js'
2020
import getGlobalConfigStore from '../utils/get-global-config-store.js'
2121
import getCLIPackageJson from '../utils/get-cli-package-json.js'
22+
import { didEnableCompileCache } from '../utils/nodejs-compile-cache.js'
2223
import { track, reportError } from '../utils/telemetry/index.js'
2324

2425
import { createApiCommand } from './api/index.js'
@@ -211,11 +212,10 @@ const mainCommand = async function (options, command) {
211212

212213
/**
213214
* Creates the `netlify-cli` command
214-
* Promise is needed as the envinfo is a promise
215-
* @returns {import('./base-command.js').default}
216215
*/
217-
export const createMainCommand = () => {
216+
export const createMainCommand = (): BaseCommand => {
218217
const program = new BaseCommand('netlify')
218+
219219
// register all the commands
220220
createApiCommand(program)
221221
createBlobsCommand(program)
@@ -239,6 +239,8 @@ export const createMainCommand = () => {
239239
createWatchCommand(program)
240240
createLogsCommand(program)
241241

242+
program.setAnalyticsPayload({ didEnableCompileCache })
243+
242244
program
243245
.version(USER_AGENT, '-V')
244246
.showSuggestionAfterError(true)

src/utils/live-tunnel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ const connectTunnel = function ({
7474
}
7575

7676
const ps = execa(execPath, args, { stdio: 'inherit' })
77-
ps.on('close', (code) => process.exit(code))
77+
ps.on('close', (code) => process.exit(code ?? undefined))
7878
ps.on('SIGINT', process.exit)
7979
ps.on('SIGTERM', process.exit)
8080
}

src/utils/nodejs-compile-cache.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import * as module from 'module'
2+
3+
import { isCI } from 'ci-info'
4+
5+
export let didEnableCompileCache = false
6+
7+
/**
8+
* This enables the Node.js compile cache (aka the V8 code cache or bytecode cache), if it is available and if we
9+
* detect that we are not running in a CI environment.
10+
*
11+
* This feature is a performance optimization that allows the V8 JS engine to cache some (parse/compile) work from one
12+
* execution on disk and reuse it on subsequent executions. There's a performance hit on the first (cold) run, but all
13+
* subsequent (warm) runs get performance savings. As the CLI is generally run hundreds of times, it is worth a small
14+
* overhead on the occasional cold run to shave tens to hundreds of milliseconds on several subsequent warm runs.
15+
*
16+
* Keep in mind that the cache is specific to a version of netlify-cli and a version of node.js and it is stored on the
17+
* user's disk in a temp dir. If any of these changes or the temp dir is cleared, the next run is a cold run.
18+
*
19+
* The programmatic API to enable this (`enableCompileCache()`) was added in node 22.8.0, but we currently support
20+
* >=18.14.0, hence the conditional below. (For completeness, note that support via the env var was added in 22.1.0.)
21+
*
22+
* The Netlify CLI is often used in CI workflows. In this context, we wouldn't want the overhead of the first run
23+
* because we almost certainly would not get any benefits on "subsequent runs". Even if the user has configured caching
24+
* of the CLI itself, there's no chance they've configured the V8 compile cache directory to be cached.
25+
*
26+
* @see https://nodejs.org/api/module.html#moduleenablecompilecachecachedir
27+
*/
28+
export const maybeEnableCompileCache = (): void => {
29+
if (isCI) return
30+
// The docs recommend turning this off when running tests to generate precise coverage
31+
if (process.env.NODE_ENV === 'test') return
32+
33+
// eslint-disable-next-line n/no-unsupported-features/node-builtins
34+
if ('enableCompileCache' in module && typeof module.enableCompileCache === 'function') {
35+
// eslint-disable-next-line n/no-unsupported-features/node-builtins
36+
const { directory } = (module.enableCompileCache as () => { directory: string | undefined })()
37+
38+
if (directory == null) return
39+
didEnableCompileCache = true
40+
41+
// TODO(serhalp): Investigate enabling the compile cache for spawned subprocesses by passing
42+
// NODE_COMPILE_CACHE=directory.
43+
44+
return
45+
}
46+
return
47+
}

tests/integration/telemetry.test.ts

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { env as _env, version as nodejsVersion } from 'process'
22

33
import type { Options } from 'execa'
44
import execa from 'execa'
5-
import { version as uuidVersion } from 'uuid'
65
import { expect, test } from 'vitest'
76

87
import pkg from '../../package.json'
@@ -58,7 +57,7 @@ await withMockApi(routes, () => {
5857
expect(userAgent!.startsWith(`${pkg.name}/${pkg.version}`)).toBe(true)
5958
})
6059

61-
test<MockApiTestContext>('should send correct command on success', async ({ apiUrl, requests }) => {
60+
test<MockApiTestContext>('should send invoked command on success', async ({ apiUrl, requests }) => {
6261
await callCli(['api', 'listSites'], getCLIOptions(apiUrl))
6362
const request = requests.find(({ path }) => path === '/api/v1/track')
6463
expect(request).toBeDefined()
@@ -71,14 +70,16 @@ await withMockApi(routes, () => {
7170
buildSystem: [],
7271
cliVersion: pkg.version,
7372
command: 'api',
73+
// Varies depending on node.js version tested
74+
didEnableCompileCache: expect.any(Boolean),
7475
monorepo: false,
7576
nodejsVersion,
7677
// TODO: this should be NPM however some CI tests are using pnpm which changes the value
7778
packageManager: expect.stringMatching(/npm|pnpm/),
7879
})
7980
})
8081

81-
test<MockApiTestContext>('should send correct command on failure', async ({ apiUrl, requests }) => {
82+
test<MockApiTestContext>('should send invoked command on failure', async ({ apiUrl, requests }) => {
8283
await expect(callCli(['dev:exec', 'exit 1'], getCLIOptions(apiUrl))).rejects.toThrowError()
8384
const request = requests.find(({ path }) => path === '/api/v1/track')
8485
expect(request).toBeDefined()
@@ -91,6 +92,8 @@ await withMockApi(routes, () => {
9192
buildSystem: [],
9293
cliVersion: pkg.version,
9394
command: 'dev:exec',
95+
// Varies depending on node.js version tested
96+
didEnableCompileCache: expect.any(Boolean),
9497
monorepo: false,
9598
nodejsVersion,
9699
// TODO: this should be NPM however some CI tests are using pnpm which changes the value
@@ -110,20 +113,15 @@ await withMockApi(routes, () => {
110113
const request = t.requests.find(({ path }) => path === '/api/v1/track')
111114
expect(request).toBeDefined()
112115

113-
expect(request!.body).toHaveProperty('anonymousId', expect.any(String))
114-
expect(request!.body).toHaveProperty('duration', expect.any(Number))
115-
expect(request!.body).toHaveProperty('event', 'cli:command')
116-
expect(request!.body).toHaveProperty('status', 'success')
117-
expect(request!.body).toHaveProperty('properties', {
118-
frameworks: ['next'],
119-
buildSystem: [],
120-
cliVersion: pkg.version,
121-
command: 'api',
122-
monorepo: false,
123-
nodejsVersion,
124-
// TODO: this should be NPM however some CI tests are using pnpm which changes the value
125-
packageManager: expect.stringMatching(/npm|pnpm/),
126-
})
116+
expect(request!.body).toHaveProperty(
117+
'properties',
118+
expect.objectContaining({
119+
frameworks: ['next'],
120+
buildSystem: [],
121+
// TODO: this should be NPM however some CI tests are using pnpm which changes the value
122+
packageManager: expect.stringMatching(/npm|pnpm/),
123+
}),
124+
)
127125
})
128126
})
129127
})

0 commit comments

Comments
 (0)