Skip to content

Commit 4bac63c

Browse files
authored
test(NODE-5028): assert MongoClients are garbage collectable (#3553)
1 parent 14ace66 commit 4bac63c

File tree

6 files changed

+325
-6
lines changed

6 files changed

+325
-6
lines changed

package-lock.json

+77-6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
"tsd": "^0.25.0",
9595
"typescript": "^4.9.4",
9696
"typescript-cached-transpile": "^0.0.6",
97+
"v8-heapsnapshot": "^1.2.0",
9798
"xml2js": "^0.4.23",
9899
"yargs": "^17.6.0"
99100
},
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { expect } from 'chai';
2+
3+
import { runScript } from './resource_tracking_script_builder';
4+
5+
/**
6+
* This 5MB range is selected arbitrarily and should likely be raised if failures are seen intermittently.
7+
*
8+
* The goal here is to catch unbounded memory growth, currently runScript defaults to 100 iterations of whatever test code is passed in
9+
* More than a 5MB growth in memory usage after the script has finished and _should have_ cleaned up all its resources likely indicates that
10+
* the growth will continue if the script is changed to iterate the the test code more.
11+
*/
12+
const MB_PERMITTED_OFFSET = 5;
13+
14+
describe('Driver Resources', () => {
15+
let startingMemoryUsed;
16+
let endingMemoryUsed;
17+
let heap;
18+
19+
beforeEach(function () {
20+
if (globalThis.AbortController == null) {
21+
if (this.currentTest) this.currentTest.skipReason = 'Need AbortController to run this test';
22+
this.currentTest?.skip();
23+
}
24+
if (typeof this.configuration.serverApi === 'string') {
25+
if (this.currentTest) {
26+
this.currentTest.skipReason = 'runScript does not support serverApi settings';
27+
}
28+
this.currentTest?.skip();
29+
}
30+
});
31+
32+
context('on MongoClient.close()', () => {
33+
before('create leak reproduction script', async function () {
34+
if (globalThis.AbortController == null || typeof this.configuration.serverApi === 'string') {
35+
return;
36+
}
37+
try {
38+
const res = await runScript(
39+
'no_resource_leak_connect_close',
40+
this.configuration,
41+
async function run({ MongoClient, uri }) {
42+
const mongoClient = new MongoClient(uri, { minPoolSize: 100 });
43+
await mongoClient.connect();
44+
// Any operations will reproduce the issue found in v5.0.0/v4.13.0
45+
// it would seem the MessageStream has to be used?
46+
await mongoClient.db().command({ ping: 1 });
47+
await mongoClient.close();
48+
}
49+
);
50+
startingMemoryUsed = res.startingMemoryUsed;
51+
endingMemoryUsed = res.endingMemoryUsed;
52+
heap = res.heap;
53+
} catch (error) {
54+
// We don't expect the process execution to ever fail,
55+
// leaving helpful debugging if we see this in CI
56+
console.log(`runScript error message: ${error.message}`);
57+
console.log(`runScript error stack: ${error.stack}`);
58+
console.log(`runScript error cause: ${error.cause}`);
59+
// Fail the test
60+
this.test?.error(error);
61+
}
62+
});
63+
64+
describe('ending memory usage', () => {
65+
it(`is within ${MB_PERMITTED_OFFSET}MB of starting amount`, async () => {
66+
// Why check the lower bound? No reason, but it would be very surprising if we managed to free MB_PERMITTED_OFFSET MB of memory
67+
// I expect us to **never** be below the lower bound, but I'd want to know if it happened
68+
expect(
69+
endingMemoryUsed,
70+
`script started with ${startingMemoryUsed}MB heap but ended with ${endingMemoryUsed}MB heap used`
71+
).to.be.within(
72+
startingMemoryUsed - MB_PERMITTED_OFFSET,
73+
startingMemoryUsed + MB_PERMITTED_OFFSET
74+
);
75+
});
76+
});
77+
78+
describe('ending heap snapshot', () => {
79+
it('has 0 MongoClients in memory', async () => {
80+
const clients = heap.nodes.filter(n => n.name === 'MongoClient' && n.type === 'object');
81+
// lengthOf crashes chai b/c it tries to print out a gigantic diff
82+
expect(
83+
clients.length,
84+
`expected no MongoClients in the heapsnapshot, found ${clients.length}`
85+
).to.equal(0);
86+
});
87+
});
88+
});
89+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { fork } from 'node:child_process';
2+
import { on, once } from 'node:events';
3+
import { readFile, unlink, writeFile } from 'node:fs/promises';
4+
import * as path from 'node:path';
5+
6+
import { expect } from 'chai';
7+
import { parseSnapshot } from 'v8-heapsnapshot';
8+
9+
import { MongoClient } from '../../mongodb';
10+
import { TestConfiguration } from '../../tools/runner/config';
11+
12+
export type ResourceTestFunction = (options: {
13+
MongoClient: typeof MongoClient;
14+
uri: string;
15+
iteration: number;
16+
}) => Promise<void>;
17+
18+
const RESOURCE_SCRIPT_PATH = path.resolve(__dirname, '../../tools/fixtures/resource_script.in.js');
19+
const DRIVER_SRC_PATH = JSON.stringify(path.resolve(__dirname, '../../../lib'));
20+
21+
export async function testScriptFactory(
22+
name: string,
23+
uri: string,
24+
iterations: number,
25+
func: ResourceTestFunction
26+
) {
27+
let resourceScript = await readFile(RESOURCE_SCRIPT_PATH, { encoding: 'utf8' });
28+
29+
resourceScript = resourceScript.replace('DRIVER_SOURCE_PATH', DRIVER_SRC_PATH);
30+
resourceScript = resourceScript.replace('FUNCTION_STRING', `(${func.toString()})`);
31+
resourceScript = resourceScript.replace('NAME_STRING', JSON.stringify(name));
32+
resourceScript = resourceScript.replace('URI_STRING', JSON.stringify(uri));
33+
resourceScript = resourceScript.replace('ITERATIONS_STRING', `${iterations}`);
34+
35+
return resourceScript;
36+
}
37+
38+
/**
39+
* A helper for running arbitrary MongoDB Driver scripting code in a resource information collecting script
40+
*
41+
* **The provided function is run in an isolated Node.js process**
42+
*
43+
* A user of this function will likely need to familiarize themselves with the surrounding scripting, but briefly:
44+
* - Every MongoClient you construct should have an asyncResource attached to it like so:
45+
* ```js
46+
* mongoClient.asyncResource = new this.async_hooks.AsyncResource('MongoClient');
47+
* ```
48+
* - You can perform any number of operations and connects/closes of MongoClients
49+
* - The result of this function will be:
50+
* - the startup and teardown memory usage
51+
* - the number of AsyncResources with type === 'MongoClient' that did not get cleaned up by a destroy hook
52+
* - the heap snapshot parsed by 'v8-heapsnapshot'
53+
*
54+
* @param name - the name of the script, this defines the name of the file, it will be cleaned up if the function returns successfully
55+
* @param config - `this.configuration` from your mocha config
56+
* @param func - your javascript function, you can write it inline! this will stringify the function, use the references on the `this` context to get typechecking
57+
* @param options - settings for the script
58+
* @throws Error - if the process exits with failure
59+
*/
60+
export async function runScript(
61+
name: string,
62+
config: TestConfiguration,
63+
func: ResourceTestFunction,
64+
{ iterations = 100 } = {}
65+
) {
66+
const scriptName = `${name}.cjs`;
67+
const heapsnapshotFile = `${name}.heapsnapshot.json`;
68+
69+
const scriptContent = await testScriptFactory(name, config.url(), iterations, func);
70+
await writeFile(scriptName, scriptContent, { encoding: 'utf8' });
71+
72+
const processDiedController = new AbortController();
73+
const script = fork(scriptName, { execArgv: ['--expose-gc'] });
74+
// Interrupt our awaiting of messages if the process crashed
75+
script.once('close', exitCode => {
76+
if (exitCode !== 0) {
77+
processDiedController.abort(new Error(`process exited with: ${exitCode}`));
78+
}
79+
});
80+
81+
const messages = on(script, 'message', { signal: processDiedController.signal });
82+
const willClose = once(script, 'close');
83+
84+
const starting = await messages.next();
85+
const ending = await messages.next();
86+
87+
const startingMemoryUsed = starting.value[0].startingMemoryUsed;
88+
const endingMemoryUsed = ending.value[0].endingMemoryUsed;
89+
90+
// make sure the process ended
91+
const [exitCode] = await willClose;
92+
expect(exitCode, 'process should have exited with zero').to.equal(0);
93+
94+
const heap = await readFile(heapsnapshotFile, { encoding: 'utf8' }).then(c =>
95+
parseSnapshot(JSON.parse(c))
96+
);
97+
98+
// If any of the above throws we won't reach these unlinks that clean up the created files.
99+
// This is intentional so that when debugging the file will still be present to check it for errors
100+
await unlink(scriptName);
101+
await unlink(heapsnapshotFile);
102+
103+
return {
104+
startingMemoryUsed,
105+
endingMemoryUsed,
106+
heap
107+
};
108+
}

0 commit comments

Comments
 (0)