Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(NODE-4977): load snappy lazily #3726

Merged
merged 14 commits into from
Jul 3, 2023
21 changes: 14 additions & 7 deletions src/cmap/wire_protocol/compression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { promisify } from 'util';
import * as zlib from 'zlib';

import { LEGACY_HELLO_COMMAND } from '../../constants';
import { getZstdLibrary, Snappy, type ZStandard } from '../../deps';
import { getSnappy, getZstdLibrary, type SnappyLib, type ZStandard } from '../../deps';
import { MongoDecompressionError, MongoInvalidArgumentError } from '../../error';

/** @public */
Expand Down Expand Up @@ -38,6 +38,17 @@ const zlibInflate = promisify(zlib.inflate.bind(zlib));
const zlibDeflate = promisify(zlib.deflate.bind(zlib));

let zstd: typeof ZStandard;
let Snappy: SnappyLib | null = null;
async function loadSnappy() {
if (Snappy == null) {
const snappyImport = await getSnappy();
if ('kModuleError' in snappyImport) {
throw snappyImport.kModuleError;
}
Snappy = snappyImport;
}
return Snappy;
}

// Facilitate compressing a message using an agreed compressor
export async function compress(
Expand All @@ -47,9 +58,7 @@ export async function compress(
const zlibOptions = {} as zlib.ZlibOptions;
switch (options.agreedCompressor) {
case 'snappy': {
if ('kModuleError' in Snappy) {
throw Snappy['kModuleError'];
}
Snappy ??= await loadSnappy();
return Snappy.compress(dataToBeCompressed);
}
case 'zstd': {
Expand Down Expand Up @@ -88,9 +97,7 @@ export async function decompress(compressorID: number, compressedData: Buffer):

switch (compressorID) {
case Compressor.snappy: {
if ('kModuleError' in Snappy) {
throw Snappy['kModuleError'];
}
Snappy ??= await loadSnappy();
return Snappy.uncompress(compressedData, { asBuffer: true });
}
case Compressor.zstd: {
Expand Down
28 changes: 17 additions & 11 deletions src/deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ export function getAwsCredentialProvider():
}
}

type SnappyLib = {
/** @internal */
export type SnappyLib = {
/**
* In order to support both we must check the return value of the function
* @param buf - Buffer to be compressed
Expand All @@ -111,16 +112,21 @@ type SnappyLib = {
uncompress(buf: Buffer, opt: { asBuffer: true }): Promise<Buffer>;
};

export let Snappy: SnappyLib | { kModuleError: MongoMissingDependencyError } = makeErrorModule(
new MongoMissingDependencyError(
'Optional module `snappy` not found. Please install it to enable snappy compression'
)
);

try {
// Ensure you always wrap an optional require in the try block NODE-3199
Snappy = require('snappy');
} catch {} // eslint-disable-line
export async function getSnappy(): Promise<
durran marked this conversation as resolved.
Show resolved Hide resolved
SnappyLib | { kModuleError: MongoMissingDependencyError }
> {
try {
// Ensure you always wrap an optional require in the try block NODE-3199
const value = require('snappy');
return value;
} catch (cause) {
const kModuleError = new MongoMissingDependencyError(
'Optional module `snappy` not found. Please install it to enable snappy compression',
{ cause }
);
return { kModuleError };
}
}

export let saslprep: typeof import('saslprep') | { kModuleError: MongoMissingDependencyError } =
makeErrorModule(
Expand Down
3 changes: 2 additions & 1 deletion src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,8 +692,9 @@ export class MongoMissingCredentialsError extends MongoAPIError {
* @category Error
*/
export class MongoMissingDependencyError extends MongoAPIError {
constructor(message: string) {
constructor(message: string, { cause }: { cause?: Error } = {}) {
super(message);
if (cause) this.cause = cause;
}

override get name(): string {
Expand Down
75 changes: 74 additions & 1 deletion test/action/dependency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as path from 'node:path';
import { expect } from 'chai';

import { dependencies, peerDependencies, peerDependenciesMeta } from '../../package.json';
import { itInNodeProcess } from '../tools/utils';

const EXPECTED_DEPENDENCIES = ['bson', 'mongodb-connection-string-url', 'socks'];
const EXPECTED_PEER_DEPENDENCIES = [
Expand Down Expand Up @@ -65,11 +66,24 @@ describe('package.json', function () {

expect(result).to.include('import success!');
});

if (depName === 'snappy') {
itInNodeProcess(
'getSnappy returns rejected import',
async function ({ expect, mongodb }) {
const snappyImport = await mongodb.getSnappy();
expect(snappyImport).to.have.nested.property(
'kModuleError.name',
'MongoMissingDependencyError'
);
}
);
}
});

context(`when ${depName} is installed`, () => {
beforeEach(async () => {
execSync(`npm install --no-save "${depName}"@${depMajor}`);
execSync(`npm install --no-save "${depName}"@"${depMajor}"`);
});

it(`driver is importable`, () => {
Expand All @@ -81,7 +95,66 @@ describe('package.json', function () {

expect(result).to.include('import success!');
});

if (depName === 'snappy') {
itInNodeProcess(
'getSnappy returns fulfilled import',
async function ({ expect, mongodb }) {
const snappyImport = await mongodb.getSnappy();
expect(snappyImport).to.have.property('compress').that.is.a('function');
expect(snappyImport).to.have.property('uncompress').that.is.a('function');
}
);
}
});
}
});

const EXPECTED_IMPORTS = [
'bson',
'saslprep',
'sparse-bitfield',
'memory-pager',
'mongodb-connection-string-url',
'whatwg-url',
'webidl-conversions',
'tr46',
'socks',
'ip',
'smart-buffer'
];

describe('mongodb imports', () => {
let imports: string[];
beforeEach(async function () {
for (const key of Object.keys(require.cache)) delete require.cache[key];
require('../../src');
imports = Array.from(
new Set(
Object.entries(require.cache)
.filter(([modKey]) => modKey.includes('/node_modules/'))
.map(([modKey]) => {
const leadingPkgName = modKey.split('/node_modules/')[1];
const [orgName, pkgName] = leadingPkgName.split('/');
if (orgName.startsWith('@')) {
return `${orgName}/${pkgName}`;
}
return orgName;
})
)
);
});

context('when importing mongodb', () => {
it('only contains the expected imports', function () {
expect(imports).to.deep.equal(EXPECTED_IMPORTS);
});

it('does not import optional dependencies', () => {
for (const peerDependency of EXPECTED_PEER_DEPENDENCIES) {
expect(imports).to.not.include(peerDependency);
}
});
});
});
});
65 changes: 65 additions & 0 deletions test/tools/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import * as child_process from 'node:child_process';
import { once } from 'node:events';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';

import { EJSON } from 'bson';
import * as BSON from 'bson';
import { expect } from 'chai';
Expand Down Expand Up @@ -499,3 +504,63 @@ export function topologyWithPlaceholderClient(
options as TopologyOptions
);
}

export async function itInNodeProcess(
title: string,
fn: (d: { expect: typeof import('chai').expect; mongodb: typeof import('../mongodb') }) => void
) {
it(title, async () => {
const script = `
import { expect } from 'chai';
import * as mongodb from './test/mongodb';
const run = ${fn};
run({ expect, mongodb }).then(
() => {
process.exitCode = 0;
},
error => {
console.error(error)
process.exitCode = 1;
}
);\n`;

const scriptName = `./testing_${title.split(/\s/).join('_')}_script.cts`;
const cwd = path.resolve(__dirname, '..', '..');
const tsNode = path.resolve(__dirname, '..', '..', 'node_modules', '.bin', 'ts-node');
try {
await fs.writeFile(scriptName, script, { encoding: 'utf8' });
const scriptInstance = child_process.fork(scriptName, {
signal: AbortSignal.timeout(50_000),
cwd,
stdio: 'pipe',
execArgv: [tsNode]
});

scriptInstance.stdout?.setEncoding('utf8');
scriptInstance.stderr?.setEncoding('utf8');

let stdout = '';
scriptInstance.stdout?.addListener('data', data => {
stdout += data;
});

let stderr = '';
scriptInstance.stderr?.addListener('data', (data: string) => {
stderr += data;
});

// do not fail the test if the debugger is running
stderr = stderr
.split('\n')
.filter(line => !line.startsWith('Debugger') && !line.startsWith('For help'))
.join('\n');

const [exitCode] = await once(scriptInstance, 'close');

if (stderr.length) console.log(stderr);
expect({ exitCode, stdout, stderr }).to.deep.equal({ exitCode: 0, stdout: '', stderr: '' });
} finally {
await fs.unlink(scriptName);
}
});
}
17 changes: 17 additions & 0 deletions test/unit/error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
MONGODB_ERROR_CODES,
MongoError,
MongoErrorLabel,
MongoMissingDependencyError,
MongoNetworkError,
MongoNetworkTimeoutError,
MongoParseError,
Expand Down Expand Up @@ -155,6 +156,22 @@ describe('MongoErrors', () => {
});
});

describe('MongoMissingDependencyError#constructor', () => {
context('when options.cause is set', () => {
it('attaches the cause property to the instance', () => {
const error = new MongoMissingDependencyError('missing!', { cause: new Error('hello') });
expect(error).to.have.property('cause');
});
});

context('when options.cause is not set', () => {
it('attaches the cause property to the instance', () => {
const error = new MongoMissingDependencyError('missing!', { cause: undefined });
expect(error).to.not.have.property('cause');
});
});
});

describe('#isSDAMUnrecoverableError', function () {
context('when the error is a MongoParseError', function () {
it('returns true', function () {
Expand Down