Skip to content

Commit cae940c

Browse files
sharmaharisamcopybara-github
authored andcommitted
nodejs coldstart improvements experiment
PiperOrigin-RevId: 796934154 Change-Id: I1c97bc7c1238426aad98dfcae976da66b5cd963d
1 parent 5333325 commit cae940c

File tree

6 files changed

+302
-0
lines changed

6 files changed

+302
-0
lines changed

cmd/nodejs/functions_framework/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ licenses(["notice"])
88
buildpack(
99
name = "functions_framework",
1010
srcs = [
11+
"bytecode_cache/main.js",
1112
"converter/without-framework-compat/package.json",
1213
"converter/without-framework-compat/package-lock.json",
1314
"converter/without-framework/package.json",
@@ -33,6 +34,7 @@ go_binary(
3334
],
3435
deps = [
3536
"//pkg/ar",
37+
"//pkg/buildermetrics",
3638
"//pkg/cache",
3739
"//pkg/cloudfunctions",
3840
"//pkg/env",
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// This script is used to generate bytecode cache for a Node.js application.
16+
// It is intended to be run by the Node.js functions-framework buildpack.
17+
//
18+
// The script takes the absolute path to the user's entrypoint as an argument.
19+
// It then enables bytecode caching, requires the entrypoint, and flushes the
20+
// cache to disk.
21+
//
22+
// The script is designed to be run in a Node.js environment with the
23+
// functions-framework installed. Any dependencies of the entrypoint must be
24+
// installed in the Node.js environment.
25+
26+
const { enableCompileCache, flushCompileCache } = require('node:module');
27+
const path = require('node:path');
28+
const fs = require('node:fs');
29+
30+
// The buildpack passes the absolute path to the user's entrypoint as the first argument.
31+
const entrypoint = process.argv[2];
32+
if (!entrypoint) {
33+
console.error('Internal error: Application entrypoint not provided to cache generator.');
34+
process.exit(1);
35+
}
36+
37+
// The buildpack passes the cache directory name as the second argument.
38+
const cacheDirName = process.argv[3];
39+
if (!cacheDirName) {
40+
console.error('Internal error: Cache directory name not provided to cache generator.');
41+
process.exit(1);
42+
}
43+
44+
45+
console.log('--- Starting bytecode cache generation ---');
46+
47+
try {
48+
const cachePath = path.join(process.cwd(), cacheDirName);
49+
50+
if (fs.existsSync(cachePath)) {
51+
fs.rmSync(cachePath, { recursive: true, force: true });
52+
}
53+
fs.mkdirSync(cachePath);
54+
55+
enableCompileCache(cachePath);
56+
57+
console.log('Requiring application entrypoint to trigger compilation...');
58+
require(entrypoint);
59+
60+
console.log('Flushing compile cache to disk...');
61+
flushCompileCache();
62+
63+
console.log('--- Cache generation complete. ---');
64+
} catch (error) {
65+
console.error(' Warning: Error during cache generation, build will continue without it.', error);
66+
process.exit(1);
67+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* @fileoverview Tests for the bytecode cache generation script.
3+
*/
4+
5+
const fs = require('node:fs');
6+
const path = require('node:path');
7+
const Module = require('node:module');
8+
9+
// Mock the functions used from node:module
10+
const mockEnableCompileCache = jasmine.createSpy('enableCompileCache');
11+
const mockFlushCompileCache = jasmine.createSpy('flushCompileCache');
12+
Module.enableCompileCache = mockEnableCompileCache;
13+
Module.flushCompileCache = mockFlushCompileCache;
14+
15+
/**
16+
* Helper to run the script.
17+
*/
18+
const runScript = () => {
19+
require('./main.js');
20+
};
21+
22+
describe('Bytecode Cache Generation Script', () => {
23+
let originalArgv;
24+
let originalCwd;
25+
let processExitSpy;
26+
let consoleErrorSpy;
27+
let fsExistsSyncSpy;
28+
let fsRmSyncSpy;
29+
let fsMkdirSyncSpy;
30+
let requireSpy;
31+
const originalModuleLoad = Module._load;
32+
33+
const entrypoint = 'test_entrypoint.js';
34+
const cacheDirName = '.test_cache';
35+
const cachePath = path.join('/tmp', cacheDirName);
36+
37+
beforeEach(() => {
38+
// Backup original values
39+
originalArgv = process.argv;
40+
originalCwd = process.cwd;
41+
42+
// Mock process.argv
43+
process.argv = ['node', 'main.js', entrypoint, cacheDirName];
44+
45+
// Mock process.cwd
46+
process.cwd = () => '/tmp';
47+
48+
// Spy on process.exit
49+
processExitSpy = spyOn(process, 'exit').and.stub(); // Prevent test runner exit
50+
51+
// Spy on console.error
52+
consoleErrorSpy = spyOn(console, 'error');
53+
54+
// Spy on fs methods
55+
fsExistsSyncSpy = spyOn(fs, 'existsSync');
56+
fsRmSyncSpy = spyOn(fs, 'rmSync');
57+
fsMkdirSyncSpy = spyOn(fs, 'mkdirSync');
58+
59+
// Spy on Module._load
60+
requireSpy = spyOn(Module, '_load').and.callFake((request, parent, isMain) => {
61+
if (request === entrypoint) {
62+
// Simulate successful require of the entrypoint
63+
return { exports: {} };
64+
}
65+
// For other requires (like node:fs, etc.), use the original loader
66+
return originalModuleLoad(request, parent, isMain);
67+
});
68+
69+
// Reset mock module spies
70+
mockEnableCompileCache.calls.reset();
71+
mockFlushCompileCache.calls.reset();
72+
});
73+
74+
afterEach(() => {
75+
// Restore original values
76+
process.argv = originalArgv;
77+
process.cwd = originalCwd;
78+
79+
// Clear require cache for the main script
80+
delete require.cache[require.resolve('./main.js')];
81+
});
82+
83+
it('should generate cache successfully when cache dir does not exist', () => {
84+
fsExistsSyncSpy.withArgs(cachePath).and.returnValue(false);
85+
86+
runScript();
87+
88+
expect(fsExistsSyncSpy).toHaveBeenCalledWith(cachePath);
89+
expect(fsRmSyncSpy).not.toHaveBeenCalled();
90+
expect(fsMkdirSyncSpy).toHaveBeenCalledWith(cachePath);
91+
expect(mockEnableCompileCache).toHaveBeenCalledWith(cachePath);
92+
// Check that the entrypoint was required by the script
93+
expect(requireSpy).toHaveBeenCalledWith(entrypoint, jasmine.anything(), false);
94+
expect(mockFlushCompileCache).toHaveBeenCalled();
95+
expect(processExitSpy).not.toHaveBeenCalled();
96+
expect(consoleErrorSpy).not.toHaveBeenCalled();
97+
});
98+
99+
it('should populate cache dir if it exists', () => {
100+
fsExistsSyncSpy.withArgs(cachePath).and.returnValue(true);
101+
102+
runScript();
103+
104+
expect(fsExistsSyncSpy).toHaveBeenCalledWith(cachePath);
105+
expect(mockEnableCompileCache).toHaveBeenCalledWith(cachePath);
106+
// Check that the entrypoint was required by the script
107+
expect(requireSpy).toHaveBeenCalledWith(entrypoint, jasmine.anything(), false);
108+
expect(mockFlushCompileCache).toHaveBeenCalled();
109+
expect(processExitSpy).not.toHaveBeenCalled();
110+
expect(consoleErrorSpy).not.toHaveBeenCalled();
111+
});
112+
113+
it('should exit with error if entrypoint is not provided', () => {
114+
process.argv = ['node', 'main.js'];
115+
runScript();
116+
expect(consoleErrorSpy).toHaveBeenCalledWith(jasmine.stringMatching(/Application entrypoint not provided/));
117+
expect(processExitSpy).toHaveBeenCalledWith(1);
118+
});
119+
120+
it('should exit with error if cache directory name is not provided', () => {
121+
process.argv = ['node', 'main.js', entrypoint];
122+
runScript();
123+
expect(consoleErrorSpy).toHaveBeenCalledWith(jasmine.stringMatching(/Cache directory name not provided/));
124+
expect(processExitSpy).toHaveBeenCalledWith(1);
125+
});
126+
127+
it('should exit with error if require fails inside the script', () => {
128+
fsExistsSyncSpy.and.returnValue(false);
129+
const requireError = new Error('Module not found');
130+
131+
// Override the default fake for this test to make entrypoint require fail
132+
requireSpy.and.callFake((request, parent, isMain) => {
133+
if (request === entrypoint) {
134+
throw requireError;
135+
}
136+
return originalModuleLoad(request, parent, isMain);
137+
});
138+
139+
runScript();
140+
141+
expect(mockEnableCompileCache).toHaveBeenCalledWith(cachePath);
142+
expect(consoleErrorSpy).toHaveBeenCalledWith(' Warning: Error during cache generation, build will continue without it.', requireError);
143+
expect(processExitSpy).toHaveBeenCalledWith(1);
144+
expect(mockFlushCompileCache).not.toHaveBeenCalled();
145+
});
146+
147+
it('should exit with error if fs.mkdirSync fails', () => {
148+
fsExistsSyncSpy.and.returnValue(false);
149+
const mkdirError = new Error('Permission denied');
150+
fsMkdirSyncSpy.and.throwError(mkdirError);
151+
152+
runScript();
153+
154+
expect(consoleErrorSpy).toHaveBeenCalledWith(' Warning: Error during cache generation, build will continue without it.', mkdirError);
155+
expect(processExitSpy).toHaveBeenCalledWith(1);
156+
expect(mockEnableCompileCache).not.toHaveBeenCalled();
157+
});
158+
});

cmd/nodejs/functions_framework/main.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"strconv"
2525

2626
"github.com/GoogleCloudPlatform/buildpacks/pkg/ar"
27+
"github.com/GoogleCloudPlatform/buildpacks/pkg/buildermetrics"
2728
"github.com/GoogleCloudPlatform/buildpacks/pkg/cache"
2829
"github.com/GoogleCloudPlatform/buildpacks/pkg/cloudfunctions"
2930
"github.com/GoogleCloudPlatform/buildpacks/pkg/env"
@@ -38,6 +39,9 @@ const (
3839

3940
// nodeJSHeadroomMB is the amount of memory we'll set aside before computing the max memory size.
4041
nodeJSHeadroomMB int = 64
42+
43+
bytecodeCacheLayer = "compile-cache"
44+
cacheDirName = ".google_node_compile_cache"
4145
)
4246

4347
var functionsFrameworkNodeModulePath = path.Join("node_modules", functionsFrameworkPackage)
@@ -191,6 +195,18 @@ func buildFn(ctx *gcp.Context) error {
191195
}
192196
}
193197

198+
if env.ColdStartImprovementsBuildStudy == "byte_code_caching" {
199+
// Generate the bytecode cache. This step is best-effort and will not fail the build.
200+
if err := generateBytecodeCache(ctx, fnFile); err != nil {
201+
ctx.Logf("WARNING: Bytecode cache generation failed, skipping cache layer setup: %v", err)
202+
} else {
203+
// Create a layer for the cache and set the environment variable.
204+
if err := setupCacheLayer(ctx); err != nil {
205+
return err
206+
}
207+
}
208+
}
209+
194210
// Get and set the valid value for --max-old-space-size node_options.
195211
// Keep the existing behaviour if the value is not provided or invalid
196212
if size, err := getMaxOldSpaceSize(); err != nil {
@@ -303,3 +319,53 @@ func usingYarnModuleResolution(ctx *gcp.Context) (bool, error) {
303319
linker := result.Stdout
304320
return linker == "pnp", nil
305321
}
322+
323+
// generateBytecodeCache executes a script to generate a bytecode cache for the user's function.
324+
func generateBytecodeCache(ctx *gcp.Context, fnFile string) error {
325+
ctx.Logf("Attempting to generate bytecode cache to improve cold start performance.")
326+
327+
scriptPath := filepath.Join(ctx.BuildpackRoot(), "bytecode_cache", "main.js")
328+
absFnFile := filepath.Join(ctx.ApplicationRoot(), fnFile)
329+
execArgs := []string{"node", scriptPath, absFnFile, cacheDirName}
330+
331+
// We execute the script, which in turn 'requires' user code.
332+
if result, err := ctx.Exec(execArgs); err != nil {
333+
if result != nil && result.Combined != "" {
334+
return fmt.Errorf("bytecode cache generation script failed: %v, output: %s", err, result.Combined)
335+
}
336+
return fmt.Errorf("bytecode cache generation script failed: %v", err)
337+
}
338+
return nil
339+
}
340+
341+
// setupCacheLayer checks for a generated cache and, if found, moves it into a launch layer
342+
// and sets the NODE_COMPILE_CACHE environment variable.
343+
func setupCacheLayer(ctx *gcp.Context) error {
344+
cacheDir := filepath.Join(ctx.ApplicationRoot(), cacheDirName)
345+
cacheDirExists, err := ctx.FileExists(cacheDir)
346+
if err != nil {
347+
return fmt.Errorf("checking for cache directory: %w", err)
348+
}
349+
350+
if !cacheDirExists {
351+
ctx.Logf("Bytecode cache not found, skipping layer creation.")
352+
return nil
353+
}
354+
355+
ctx.Logf("Bytecode cache found, adding it to the application image.")
356+
l, err := ctx.Layer(bytecodeCacheLayer, gcp.LaunchLayer)
357+
if err != nil {
358+
return fmt.Errorf("creating %v layer: %w", bytecodeCacheLayer, err)
359+
}
360+
361+
// Copy the generated cache directory into the new layer.
362+
if _, err := ctx.Exec([]string{"cp", "-a", cacheDir + "/.", l.Path}); err != nil {
363+
return fmt.Errorf("copying cache directory: %w", err)
364+
}
365+
buildermetrics.GlobalBuilderMetrics().GetCounter(buildermetrics.NodejsBytecodeCacheGeneratedCounterID).Increment(1)
366+
// Set the environment variable so Node.js uses the cache at runtime.
367+
l.LaunchEnvironment.Default(env.NodeCompileCache, l.Path)
368+
ctx.Logf("NODE_COMPILE_CACHE will be set to %s at runtime.", l.Path)
369+
370+
return nil
371+
}

pkg/buildermetrics/descriptors.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const (
4242
PipInstallLatencyID MetricID = "10"
4343
JavaGAEWebXMLConfigUsageCounterID MetricID = "11"
4444
JavaGAESessionsEnabledCounterID MetricID = "12"
45+
NodejsBytecodeCacheGeneratedCounterID MetricID = "13"
4546
)
4647

4748
var (
@@ -106,5 +107,10 @@ var (
106107
"java_gae_session_handler_uses",
107108
"The number of times the session handler is used by developers",
108109
),
110+
NodejsBytecodeCacheGeneratedCounterID: newDescriptor(
111+
NodejsBytecodeCacheGeneratedCounterID,
112+
"nodejs_bytecode_cache_generated",
113+
"The number of times the bytecode cache is generated for Node.js applications",
114+
),
109115
}
110116
)

pkg/env/env.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,9 @@ const (
158158
// ColdStartImprovementsBuildStudy is an experiment flag to enable cold start improvements build study.
159159
ColdStartImprovementsBuildStudy = "EXPERIMENTAL_RUNTIMES_COLD_START_BUILD"
160160

161+
// NodeCompileCache is an env var used to enable bytecode caching for Node.js applications.
162+
NodeCompileCache = "NODE_COMPILE_CACHE"
163+
161164
// FastAPISmartDefaults is an experiment flag to enable fastapi smart defaults with uvicorn.
162165
FastAPISmartDefaults = "X_GOOGLE_FASTAPI_SMART_DEFAULTS"
163166

0 commit comments

Comments
 (0)