Skip to content

Commit

Permalink
fix: stoplightio#1881 fixed memory leak for validation
Browse files Browse the repository at this point in the history
  • Loading branch information
lukasz-kuzynski-11sigma committed Sep 28, 2021
2 parents 1c62191 + c223192 commit 931fc0f
Show file tree
Hide file tree
Showing 8 changed files with 117 additions and 13 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"lint.fix": "yarn lint --fix",
"build": "ttsc --build ./packages/tsconfig.build.json",
"posttest": "yarn lint",
"test": "jest",
"test": "node --expose-gc ./node_modules/.bin/jest",
"release": "lerna version",
"prebuild.binary": "yarn build",
"build.binary": "npx pkg --output ./cli-binaries/prism-cli --options max_old_space_size=4096 ./packages/cli/",
Expand Down
43 changes: 43 additions & 0 deletions packages/http/src/__tests__/memory-leak-prevention.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { createClientFromOperations, PrismHttp } from '../client';
import { httpOperations } from './fixtures';
import { createTestLogger } from './test-helpers';

describe('Checks if memory leaks', () => {
function round(client: PrismHttp) {
return client.post(
'/todos?overwrite=yes',
{
name: 'some name',
completed: false,
},
{ headers: { 'x-todos-publish': '2021-09-21T09:48:48.108Z' } }
);
}

it('when handling 5k of requests', () => {
const logger = createTestLogger();
const client = createClientFromOperations(httpOperations, {
validateRequest: true,
validateResponse: true,
checkSecurity: true,
errors: true,
mock: {
dynamic: false,
},
logger,
});

round(client);
const baseMemoryUsage = process.memoryUsage().heapUsed;

for (let i = 0; i < 5000; i++) {
round(client);
if (i % 100 === 0) {
global.gc();
}
}

global.gc();
expect(process.memoryUsage().heapUsed).toBeLessThanOrEqual(baseMemoryUsage * 1.02);
});
});
14 changes: 14 additions & 0 deletions packages/http/src/__tests__/test-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as pino from 'pino';

/**
* Creates special instance of pino logger that prevent collecting or logging anything
* Unfortunately disabled logger didn't work
*/
export function createTestLogger() {
const logger = pino({
enabled: false,
});

logger.success = logger.info;
return logger;
}
6 changes: 5 additions & 1 deletion packages/http/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ logger.success = logger.info;

type IClientConfig = IHttpConfig & {
baseUrl?: string;
logger?: pino.Logger;
};

export function createClientFromOperations(resources: IHttpOperation[], defaultConfig: IClientConfig): PrismHttp {
const obj = createInstance(defaultConfig, { logger });
const finalLogger = defaultConfig.logger ?? logger;
const obj = createInstance(defaultConfig, { logger: finalLogger });

type headersFromRequest = Required<Pick<IHttpRequest, 'headers'>>;

Expand Down Expand Up @@ -141,6 +143,7 @@ interface IRequestFunctionWithMethod {
input: Required<Pick<IHttpRequest, 'headers'>>,
config?: Partial<IClientConfig>
): Promise<PrismOutput>;

(this: PrismHttp, url: string, config?: Partial<IClientConfig>): Promise<PrismOutput>;
}

Expand All @@ -152,6 +155,7 @@ interface IRequestFunctionWithMethodWithBody {
input: Required<Pick<IHttpRequest, 'headers'>>,
config?: Partial<IClientConfig>
): Promise<PrismOutput>;

(this: PrismHttp, url: string, body: unknown, config?: Partial<IClientConfig>): Promise<PrismOutput>;
}

Expand Down
21 changes: 18 additions & 3 deletions packages/http/src/validator/validators/body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { validateAgainstSchema } from './utils';
import { ValidationContext, validateFn } from './types';

import { stripReadOnlyProperties, stripWriteOnlyProperties } from '../../utils/filterRequiredProperties';
import { JSONSchema7 } from 'json-schema';

export function deserializeFormBody(
schema: JSONSchema,
Expand Down Expand Up @@ -84,9 +85,23 @@ function deserializeAndValidate(content: IMediaTypeContent, schema: JSONSchema,
);
}

const normalizeSchemaProcessorMap: Record<ValidationContext, (schema: JSONSchema) => O.Option<JSONSchema>> = {
[ValidationContext.Input]: stripReadOnlyProperties,
[ValidationContext.Output]: stripWriteOnlyProperties,
function memoizeSchemaNormalizer(normalizer: SchemaNormalizer): SchemaNormalizer {
const cache = new WeakMap<JSONSchema7, O.Option<JSONSchema7>>();
return (schema: JSONSchema7) => {
const cached = cache.get(schema);
if (!cached) {
const newSchema = normalizer(schema);
cache.set(schema, newSchema);
return newSchema;
}
return cached;
};
}

type SchemaNormalizer = (schema: JSONSchema) => O.Option<JSONSchema>;
const normalizeSchemaProcessorMap: Record<ValidationContext, SchemaNormalizer> = {
[ValidationContext.Input]: memoizeSchemaNormalizer(stripReadOnlyProperties),
[ValidationContext.Output]: memoizeSchemaNormalizer(stripWriteOnlyProperties),
};

export const validate: validateFn<unknown, IMediaTypeContent> = (target, specs, context, mediaType, bundle) => {
Expand Down
8 changes: 7 additions & 1 deletion packages/http/src/validator/validators/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export type Deps<Target> = {
defaultStyle: HttpParamStyles;
};

const schemaCache = new WeakMap<IHttpParam[], JSONSchema>();

export const validateParams = <Target>(
target: Target,
specs: IHttpParam[],
Expand All @@ -38,7 +40,11 @@ export const validateParams = <Target>(
return pipe(
NEA.fromArray(specs),
O.map(specs => {
const schema = createJsonSchemaFromParams(specs);
const schema = schemaCache.get(specs) ?? createJsonSchemaFromParams(specs);
if (!schemaCache.has(specs)) {
schemaCache.set(specs, schema);
}

const parameterValues = pickBy(
mapValues(
keyBy(specs, s => s.name.toLowerCase()),
Expand Down
35 changes: 28 additions & 7 deletions packages/http/src/validator/validators/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { DiagnosticSeverity } from '@stoplight/types';
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';
import { NonEmptyArray, fromArray, map } from 'fp-ts/NonEmptyArray';
import Ajv, { ErrorObject, Logger, Options } from 'ajv';
import Ajv, { ErrorObject, Logger, Options, ValidateFunction } from 'ajv';
import type AjvCore from 'ajv/dist/core';
import Ajv2019 from 'ajv/dist/2019';
import Ajv2020 from 'ajv/dist/2020';
Expand Down Expand Up @@ -89,6 +89,32 @@ export const convertAjvErrors = (errors: NonEmptyArray<ErrorObject>, severity: D
})
);

const validationsFunctionsCache = new WeakMap<JSONSchema, WeakMap<object, ValidateFunction>>();
const EMPTY_BUNDLE = { _emptyBundle: true };

function getValidationFunction(ajvInstance: AjvCore, schema: JSONSchema, bundle?: unknown): ValidateFunction {
const bundledFunctionsCache = validationsFunctionsCache.get(schema);
const bundleKey = typeof bundle === 'object' && bundle !== null ? bundle : EMPTY_BUNDLE;
if (bundledFunctionsCache) {
const validationFunction = bundledFunctionsCache.get(bundleKey);
if (validationFunction) {
return validationFunction;
}
}

const validationFunction = ajvInstance.compile({
...schema,
__bundled__: bundle,
});

if (!bundledFunctionsCache) {
validationsFunctionsCache.set(schema, new WeakMap());
}

validationsFunctionsCache.get(schema)!.set(bundleKey, validationFunction);
return validationFunction;
}

export const validateAgainstSchema = (
value: unknown,
schema: JSONSchema,
Expand All @@ -97,12 +123,7 @@ export const validateAgainstSchema = (
bundle?: unknown
): O.Option<NonEmptyArray<IPrismDiagnostic>> =>
pipe(
O.tryCatch(() =>
assignAjvInstance(String(schema.$schema), coerce).compile({
...schema,
__bundled__: bundle,
})
),
O.tryCatch(() => getValidationFunction(assignAjvInstance(String(schema.$schema), coerce), schema, bundle)),
O.chainFirst(validateFn => O.tryCatch(() => validateFn(value))),
O.chain(validateFn => pipe(O.fromNullable(validateFn.errors), O.chain(fromArray))),
O.map(errors => convertAjvErrors(errors, DiagnosticSeverity.Error, prefix))
Expand Down
1 change: 1 addition & 0 deletions packages/tsconfig.test.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"noEmit": true,
"paths": {
"@stoplight/prism-core": ["./core/src"],
"@stoplight/prism-cli": ["./cli/src"],
"@stoplight/prism-http": ["./http/src"],
"@stoplight/prism-http-server": ["./http-server/src"]
}
Expand Down

0 comments on commit 931fc0f

Please sign in to comment.