Skip to content

Commit 9c257ca

Browse files
@W-17286066 Remove forced garbage collection per invocation (#2285)
* Remove forced garbage collection on each invocation. * Allow FORCE_GC env-var to enable old behavior. * Added /memtest endpoint to mrt reference app. * updated CHANGELOG.md with link to PR.
1 parent a3dd4c0 commit 9c257ca

File tree

6 files changed

+191
-49
lines changed

6 files changed

+191
-49
lines changed

packages/pwa-kit-runtime/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
## v3.10.0-dev (Feb 18, 2025)
22

3+
- Remove forced garbage collection on each invocation. Set `FORCE_GC=true` for the old behavior. [#2285](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2285)
4+
35
## v3.9.0 (Feb 18, 2025)
46
- Fix stale service worker file that could cause requests to still use old Content-Security-Policy [#2191](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2191)
57
- Support Node 22 [#2218](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2218)

packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,8 @@ export const RemoteServerFactory = {
388388
const mixin = {
389389
options,
390390

391+
// Forcing a GC is no longer necessary, and will be
392+
// skipped by default (unless FORCE_GC env-var is set).
391393
_collectGarbage() {
392394
// Do global.gc in a separate 'then' handler so
393395
// that all major variables are out of scope and
@@ -1044,12 +1046,15 @@ export const RemoteServerFactory = {
10441046
context.callbackWaitsForEmptyEventLoop = false
10451047

10461048
if (lambdaContainerReused) {
1047-
// DESKTOP-434 If this Lambda container is being reused,
1048-
// clean up memory now, so that we start with low usage.
1049-
// These regular GC calls take about 80-100 mS each, as opposed
1050-
// to forced GC calls, which occur randomly and can take several
1051-
// hundred mS.
1052-
app._collectGarbage()
1049+
const forceGarbageCollection = process.env.FORCE_GC
1050+
if (forceGarbageCollection && forceGarbageCollection.toLowerCase() === 'true') {
1051+
// DESKTOP-434 If this Lambda container is being reused,
1052+
// clean up memory now, so that we start with low usage.
1053+
// These regular GC calls take about 80-100 mS each, as opposed
1054+
// to forced GC calls, which occur randomly and can take several
1055+
// hundred mS.
1056+
app._collectGarbage()
1057+
}
10531058
app.sendMetric('LambdaReused')
10541059
} else {
10551060
// This is the first use of this container, so set the

packages/pwa-kit-runtime/src/ssr/server/express.lambda.test.js

Lines changed: 73 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,48 @@ const httpsAgent = new https.Agent({
5050
rejectUnauthorized: false
5151
})
5252

53+
function createServerWithGCSpy() {
54+
const route = jest.fn((req, res) => {
55+
res.send('<html/>')
56+
})
57+
58+
const options = {
59+
buildDir: testFixtures,
60+
mobify: testPackageMobify,
61+
sslFilePath: path.join(testFixtures, 'localhost.pem'),
62+
quiet: true,
63+
port: TEST_PORT,
64+
fetchAgents: {
65+
https: httpsAgent
66+
}
67+
}
68+
69+
const {handler, server, app} = RemoteServerFactory.createHandler(options, (app) => {
70+
app.get('/*', route)
71+
})
72+
73+
const collectGarbage = jest.spyOn(app, '_collectGarbage')
74+
const sendMetric = jest.spyOn(app, 'sendMetric')
75+
return {route, handler, collectGarbage, sendMetric, server}
76+
}
77+
78+
function createApiGatewayEvent() {
79+
// Set up a fake event and a fake context for the Lambda call
80+
const event = createEvent('aws:apiGateway', {
81+
path: '/',
82+
body: undefined
83+
})
84+
85+
if (event.queryStringParameters) {
86+
delete event.queryStringParameters
87+
}
88+
89+
const context = AWSMockContext({
90+
functionName: 'SSRTest'
91+
})
92+
return {event, context}
93+
}
94+
5395
describe('SSRServer Lambda integration', () => {
5496
let savedEnvironment
5597
let server
@@ -371,47 +413,40 @@ describe('SSRServer Lambda integration', () => {
371413
})
372414
})
373415

374-
test('Lambda reuse behaviour', () => {
375-
const route = jest.fn((req, res) => {
376-
res.send('<html/>')
377-
})
378-
379-
const options = {
380-
buildDir: testFixtures,
381-
mobify: testPackageMobify,
382-
sslFilePath: path.join(testFixtures, 'localhost.pem'),
383-
quiet: true,
384-
port: TEST_PORT,
385-
fetchAgents: {
386-
https: httpsAgent
387-
}
388-
}
389-
390-
const {
391-
app,
392-
handler,
393-
server: srv
394-
} = RemoteServerFactory.createHandler(options, (app) => {
395-
app.get('/*', route)
396-
})
397-
398-
const collectGarbage = jest.spyOn(app, '_collectGarbage')
399-
const sendMetric = jest.spyOn(app, 'sendMetric')
400-
server = srv
416+
test('Lambda reuse -- Default Behavior', () => {
417+
const {route, handler, collectGarbage, sendMetric, new_server} = createServerWithGCSpy()
418+
const {event, context} = createApiGatewayEvent()
419+
server = new_server
401420

402-
// Set up a fake event and a fake context for the Lambda call
403-
const event = createEvent('aws:apiGateway', {
404-
path: '/',
405-
body: undefined
406-
})
421+
const call = (event) =>
422+
new Promise((resolve) => handler(event, context, (err, response) => resolve(response)))
407423

408-
if (event.queryStringParameters) {
409-
delete event.queryStringParameters
410-
}
424+
return Promise.resolve()
425+
.then(() => call(event))
426+
.then((response) => {
427+
// First request - Lambda container created
428+
expect(response.statusCode).toBe(200)
429+
expect(collectGarbage.mock.calls).toHaveLength(0)
430+
expect(route.mock.calls).toHaveLength(1)
431+
expect(sendMetric).toHaveBeenCalledWith('LambdaCreated')
432+
expect(sendMetric).not.toHaveBeenCalledWith('LambdaReused')
433+
})
434+
.then(() => call(event))
435+
.then((response) => {
436+
// Second call - Lambda container reused
437+
expect(response.statusCode).toBe(200)
438+
expect(collectGarbage.mock.calls).toHaveLength(0)
439+
expect(route.mock.calls).toHaveLength(2)
440+
expect(sendMetric).toHaveBeenCalledWith('LambdaCreated')
441+
expect(sendMetric).toHaveBeenCalledWith('LambdaReused')
442+
})
443+
})
411444

412-
const context = AWSMockContext({
413-
functionName: 'SSRTest'
414-
})
445+
test('Lambda reuse -- with Forced Garbage Collection Enabled', () => {
446+
process.env.FORCE_GC = 'true'
447+
const {event, context} = createApiGatewayEvent()
448+
const {route, handler, collectGarbage, sendMetric, new_server} = createServerWithGCSpy()
449+
server = new_server
415450

416451
const call = (event) =>
417452
new Promise((resolve) => handler(event, context, (err, response) => resolve(response)))

packages/template-mrt-reference-app/app/ssr.js

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ const filterAndSortObjectKeys = (o, whitelist) =>
124124
/**
125125
* Return a JSON-serializable object with key diagnostic values from a request
126126
*/
127-
const jsonFromRequest = (req) => {
127+
const jsonFromRequest = (req, additional_info) => {
128128
return {
129129
args: req.query,
130130
protocol: req.protocol,
@@ -136,7 +136,8 @@ const jsonFromRequest = (req) => {
136136
headers: sortObjectKeys(req.headers),
137137
ip: req.ip,
138138
env: filterAndSortObjectKeys(process.env, ENVS_TO_EXPOSE),
139-
timestamp: new Date().toISOString()
139+
timestamp: new Date().toISOString(),
140+
...(typeof additional_info === 'object' ? {additional_info} : {})
140141
}
141142
}
142143

@@ -207,6 +208,98 @@ const responseHeadersTest = async (req, res) => {
207208
res.json(jsonFromRequest(req))
208209
}
209210

211+
/**
212+
* Express handler that allocates a lot of memory, and then removes
213+
* a reference to the objects, such that they may be garbage collected.
214+
*/
215+
const memoryTest = async (req, res) => {
216+
const memory_before = process.memoryUsage()
217+
const test_count = req?.query?.count ? parseInt(req.query.count) : 1
218+
const test_size = req?.query?.size ? parseInt(req.query.size) : 1024
219+
const force_gc =
220+
global?.gc && req?.query && (parseBoolean(req.query.forcegc) || parseBoolean(req.query.gc))
221+
222+
// allocate temporary memory blocks
223+
const malloc_time_start = Date.now()
224+
allocateMemory(test_count, test_size)
225+
const malloc_time_ms = Date.now() - malloc_time_start
226+
227+
const gc_time_start = Date.now()
228+
if (force_gc) {
229+
global.gc()
230+
}
231+
const gc_time_ms = Date.now() - gc_time_start
232+
233+
const memory_after = process.memoryUsage()
234+
const memory_delta = calculateNumericDeltas(memory_after, memory_before)
235+
const factor = 10
236+
const test_total_alloc_mb =
237+
Math.round(((test_count * test_size) / 1024 / 1024) * factor) / factor
238+
const additional_info = {
239+
memory_end: memory_after,
240+
memory_delta: memory_delta,
241+
malloc_time: malloc_time_ms,
242+
gc_time: gc_time_ms,
243+
force_gc: force_gc,
244+
test_count: test_count,
245+
test_size: test_size,
246+
test_total_alloc_mb: test_total_alloc_mb
247+
}
248+
res.json(jsonFromRequest(req, additional_info))
249+
}
250+
251+
/**
252+
* Allocate memory and lose the reference to it (so it can be cleaned up).
253+
*/
254+
function allocateMemory(test_count, test_size) {
255+
const buffer = []
256+
for (let index = 0; index < test_count; index++) {
257+
buffer.push(Buffer.alloc(test_size, 0, 'ascii'))
258+
}
259+
return buffer.length
260+
}
261+
262+
/**
263+
* Calculate the numeric differences between two objects.
264+
*/
265+
function calculateNumericDeltas(obj1, obj2) {
266+
const delta = {}
267+
for (const key in obj1) {
268+
if (
269+
Object.prototype.hasOwnProperty.call(obj1, key) &&
270+
Object.prototype.hasOwnProperty.call(obj2, key)
271+
) {
272+
if (typeof obj1[key] === 'number' && typeof obj2[key] === 'number') {
273+
delta[key] = obj1[key] - obj2[key]
274+
}
275+
}
276+
}
277+
return delta
278+
}
279+
280+
/**
281+
* Parse boolean value from string
282+
*/
283+
function parseBoolean(string_value) {
284+
if (
285+
string_value === null ||
286+
string_value === undefined ||
287+
typeof string_value !== 'string' ||
288+
string_value.trim() === ''
289+
) {
290+
return false
291+
}
292+
const lowerCaseValue = string_value.toLowerCase()
293+
return (
294+
lowerCaseValue === 'true' ||
295+
lowerCaseValue === '1' ||
296+
lowerCaseValue === 'on' ||
297+
lowerCaseValue === 'enable' ||
298+
lowerCaseValue === 'enabled' ||
299+
lowerCaseValue === 'yes'
300+
)
301+
}
302+
210303
/**
211304
* Express handler that echos back a JSON response with
212305
* headers supplied in the request.
@@ -273,6 +366,7 @@ const {handler, app, server} = runtime.createHandler(options, (app) => {
273366
app.get('/tls', tlsVersionTest)
274367
app.get('/cache', cacheTest)
275368
app.get('/cache/:duration(\\d+)', cacheTest)
369+
app.get('/memtest', memoryTest)
276370
app.get('/cookie', cookieTest)
277371
app.get('/headers', headerTest)
278372
app.get('/isolation', isolationTests)

packages/template-mrt-reference-app/app/ssr.test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ describe('server', () => {
5959
['/cache', 200, 'application/json; charset=utf-8'],
6060
['/cookie', 200, 'application/json; charset=utf-8'],
6161
['/set-response-headers', 200, 'application/json; charset=utf-8'],
62-
['/isolation', 200, 'application/json; charset=utf-8']
62+
['/isolation', 200, 'application/json; charset=utf-8'],
63+
['/memtest', 200, 'application/json; charset=utf-8']
6364
])('Path %p should render correctly', (path, expectedStatus, expectedContentType) => {
6465
return request(app)
6566
.get(path)

packages/template-mrt-reference-app/jest.config.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,18 @@ module.exports = {
1010
...base,
1111
coverageThreshold: {
1212
global: {
13-
branches: 64,
13+
branches: 50,
1414
functions: 77,
1515
lines: 85,
1616
statements: 85
1717
}
1818
},
19-
collectCoverageFrom: ['app/**'],
19+
collectCoverageFrom: [
20+
'app/**',
21+
'!app/request-processor.js',
22+
'!app/static/**',
23+
'!app/*.json'
24+
],
2025
// Increase to: 6 x default timeout of 5 seconds
2126
...(process.env.CI ? {testTimeout: 30000} : {})
2227
}

0 commit comments

Comments
 (0)