Skip to content

Commit

Permalink
fix(NODE-4977): load snappy lazily (#3726)
Browse files Browse the repository at this point in the history
Co-authored-by: Durran Jordan <durran@gmail.com>
  • Loading branch information
nbbeeken and durran authored Jul 3, 2023
1 parent dda4ec0 commit 865e658
Show file tree
Hide file tree
Showing 6 changed files with 187 additions and 20 deletions.
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;
function loadSnappy() {
if (Snappy == null) {
const snappyImport = 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 ??= 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 ??= loadSnappy();
return Snappy.uncompress(compressedData, { asBuffer: true });
}
case Compressor.zstd: {
Expand Down
26 changes: 15 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,19 @@ 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 function getSnappy(): 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 = 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 = 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

0 comments on commit 865e658

Please sign in to comment.