Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions packages/core-internal/src/validators/ajvProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator {
private _ajv2019: AjvLike | undefined;
/** True iff the constructor received a caller-supplied engine; the `$schema` dispatch is skipped. */
private readonly _userAjv: boolean;
/**
* Content-keyed cache for compiled validators of schemas without `$id`.
* Prevents the memory leak where `engine.compile(schema)` is called unconditionally,
* causing Ajv's internal scope to grow without bound in long-running processes.
*/
private readonly _compiledCache: Map<string, AjvValidateFunction> = new Map();

/**
* @param ajv - Optional pre-configured AJV-compatible instance. When supplied, this instance is
Expand Down Expand Up @@ -128,12 +134,39 @@ export class AjvJsonSchemaValidator implements jsonSchemaValidator {
return (this._ajvDraft7 ??= createDefaultAjvInstance(Draft7Ajv));
}

/**
* Compile or retrieve a cached validator for the given schema.
*
* Schemas with `$id` use Ajv's built-in identity cache (`engine.getSchema`).
* Schemas without `$id` are cached by their JSON-serialised content to prevent
* unbounded growth of Ajv's internal scope in long-running processes (see #2605).
*/
private _getCompiled(schema: JsonSchemaType, engine: AjvLike): AjvValidateFunction {
if ('$id' in schema && typeof schema.$id === 'string') {
return engine.getSchema(schema.$id) ?? engine.compile(schema);
}

let key: string;
try {
key = JSON.stringify(schema);
} catch {
// Non-serialisable schema (e.g. cyclic): fall back to uncached compilation.
return engine.compile(schema);
}

let cached = this._compiledCache.get(key);
if (cached === undefined) {
// Compile a fresh structural copy so Ajv's identity-based cache cannot
// return a stale validator if a caller mutates the schema object in place.
cached = engine.compile(JSON.parse(key));
this._compiledCache.set(key, cached);
}
return cached;
}

getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
const engine = this._engineFor(schema);
const ajvValidator =
'$id' in schema && typeof schema.$id === 'string'
? (engine.getSchema(schema.$id) ?? engine.compile(schema))
: engine.compile(schema);
const ajvValidator = this._getCompiled(schema, engine);

return (input: unknown): JsonSchemaValidatorResult<T> => {
const valid = ajvValidator(input);
Expand Down
34 changes: 31 additions & 3 deletions packages/core-internal/src/validators/cfWorkerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator {
private readonly shortcircuit: boolean;
/** Caller-supplied draft; when set, the `$schema` check is skipped (caller owns dialect). */
private readonly draft?: CfWorkerSchemaDraft;
/**
* Content-keyed cache for compiled validators of schemas without `$id`.
* Prevents redundant Validator instantiation in long-running processes (see #2605).
*/
private readonly _compiledCache: Map<string, Validator> = new Map();

/**
* Create a validator
Expand All @@ -77,18 +82,41 @@ export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator {
return dialect === 'draft-7' ? '7' : dialect;
}

/**
* Retrieve or create a cached Validator instance for the given schema.
* Schemas are cached by their JSON-serialised content to prevent redundant
* instantiation in long-running processes (see #2605).
*/
private _getValidator(schema: JsonSchemaType, draft: CfWorkerSchemaDraft): Validator {
let key: string;
try {
key = JSON.stringify(schema);
} catch {
// Non-serialisable schema: fall back to uncached instantiation.
return new Validator(schema as ConstructorParameters<typeof Validator>[0], draft, this.shortcircuit);
}

// Include draft in the key since the same schema content under different drafts
// may validate differently.
const cacheKey = `${draft}:${key}`;
let cached = this._compiledCache.get(cacheKey);
if (cached === undefined) {
cached = new Validator(JSON.parse(key) as ConstructorParameters<typeof Validator>[0], draft, this.shortcircuit);
this._compiledCache.set(cacheKey, cached);
}
return cached;
}

/**
* Create a validator for the given JSON Schema
*
* Unlike AJV, this validator is not cached internally
*
* @param schema - Standard JSON Schema object
* @returns A validator function that validates input data
*/
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
const draft = this.draft ?? this._draftFor(schema);
// Cast to the cfworker Schema type - our JsonSchemaType is structurally compatible
const validator = new Validator(schema as ConstructorParameters<typeof Validator>[0], draft, this.shortcircuit);
const validator = this._getValidator(schema, draft);

return (input: unknown): JsonSchemaValidatorResult<T> => {
const result = validator.validate(input);
Expand Down
183 changes: 183 additions & 0 deletions packages/core-internal/test/validators/validatorCaching.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* Tests for validator caching behaviour (fixes #2605: memory leak from
* unconditional recompilation of schemas without `$id`).
*/

import { describe, expect, it } from 'vitest';

import { AjvJsonSchemaValidator } from '../../src/validators/ajvProvider';
import { CfWorkerJsonSchemaValidator } from '../../src/validators/cfWorkerProvider';
import type { JsonSchemaType } from '../../src/validators/types';

describe('AjvJsonSchemaValidator caching (#2605)', () => {
it('returns the same validator function for identical schemas without $id', () => {
const provider = new AjvJsonSchemaValidator();
const schema: JsonSchemaType = {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
};

// Call getValidator twice with structurally identical (but different object) schemas
const v1 = provider.getValidator(schema);
const v2 = provider.getValidator({ ...schema });

// Both should validate correctly
expect(v1({ name: 'Alice' }).valid).toBe(true);
expect(v2({ name: 'Alice' }).valid).toBe(true);
expect(v1({}).valid).toBe(false);
expect(v2({}).valid).toBe(false);
});

it('does not recompile when called repeatedly with the same schema content', () => {
let compileCount = 0;
const fakeEngine = {
compile: (schema: unknown) => {
compileCount++;
return Object.assign(() => true, { errors: undefined });
},
getSchema: () => undefined,
errorsText: () => ''
};
const provider = new AjvJsonSchemaValidator(fakeEngine);
const schema: JsonSchemaType = { type: 'string' };

provider.getValidator(schema);
provider.getValidator(schema);
provider.getValidator({ type: 'string' });

// Should compile only once — subsequent calls use the cache
expect(compileCount).toBe(1);
});

it('recompiles when schema content changes', () => {
const provider = new AjvJsonSchemaValidator();

const v1 = provider.getValidator({ type: 'string' } as JsonSchemaType);
const v2 = provider.getValidator({ type: 'number' } as JsonSchemaType);

expect(v1('hello').valid).toBe(true);
expect(v1(42).valid).toBe(false);
expect(v2(42).valid).toBe(true);
expect(v2('hello').valid).toBe(false);
});

it('schemas with $id still use Ajv built-in identity cache', () => {
const provider = new AjvJsonSchemaValidator();
const schema: JsonSchemaType = {
$id: 'https://example.com/test-schema',
type: 'object',
properties: { x: { type: 'number' } }
};

const v1 = provider.getValidator(schema);
const v2 = provider.getValidator(schema);

expect(v1({ x: 1 }).valid).toBe(true);
expect(v2({ x: 1 }).valid).toBe(true);
expect(v1({ x: 'nope' }).valid).toBe(false);
});

it('a mutated schema object produces a validator for the new content', () => {
const provider = new AjvJsonSchemaValidator();
const schema: JsonSchemaType = { type: 'string' };

const v1 = provider.getValidator(schema);
expect(v1('hello').valid).toBe(true);
expect(v1(42).valid).toBe(false);

// Mutate in place
(schema as Record<string, unknown>).type = 'number';

const v2 = provider.getValidator(schema);
expect(v2(42).valid).toBe(true);
expect(v2('hello').valid).toBe(false);
});

it('shared cached validator does not have cross-call error pollution', () => {
const provider = new AjvJsonSchemaValidator();
const schema: JsonSchemaType = {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
};

const v1 = provider.getValidator(schema);
const v2 = provider.getValidator(schema);

// Validate with invalid data on v1
const r1 = v1({});
expect(r1.valid).toBe(false);
expect(r1.errorMessage).toBeDefined();

// v2 should still validate correctly (not inheriting errors from v1)
const r2 = v2({ name: 'Bob' });
expect(r2.valid).toBe(true);
});

it('works correctly across different dialect schemas', () => {
const provider = new AjvJsonSchemaValidator();

const schema2020: JsonSchemaType = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'string'
};
const schema07: JsonSchemaType = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'string'
};

const v2020 = provider.getValidator(schema2020);
const v07 = provider.getValidator(schema07);

expect(v2020('hello').valid).toBe(true);
expect(v07('hello').valid).toBe(true);
});
});

describe('CfWorkerJsonSchemaValidator caching (#2605)', () => {
it('returns the same validation result for identical schemas without $id', () => {
const provider = new CfWorkerJsonSchemaValidator();
const schema: JsonSchemaType = {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
};

const v1 = provider.getValidator(schema);
const v2 = provider.getValidator({ ...schema });

expect(v1({ name: 'Alice' }).valid).toBe(true);
expect(v2({ name: 'Alice' }).valid).toBe(true);
expect(v1({}).valid).toBe(false);
expect(v2({}).valid).toBe(false);
});

it('recompiles when schema content changes', () => {
const provider = new CfWorkerJsonSchemaValidator();

const v1 = provider.getValidator({ type: 'string' } as JsonSchemaType);
const v2 = provider.getValidator({ type: 'number' } as JsonSchemaType);

expect(v1('hello').valid).toBe(true);
expect(v1(42).valid).toBe(false);
expect(v2(42).valid).toBe(true);
expect(v2('hello').valid).toBe(false);
});

it('caches per draft — same content with different drafts validates differently', () => {
const provider = new CfWorkerJsonSchemaValidator();

// A schema with prefixItems: under 2020-12 it's enforced, under draft-07 it's ignored
const schema2020: JsonSchemaType = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'array',
prefixItems: [{ type: 'number' }, { type: 'string' }]
};

const v2020 = provider.getValidator(schema2020);
// prefixItems is enforced under 2020-12
expect(v2020([1, 'x']).valid).toBe(true);
expect(v2020(['x', 1]).valid).toBe(false);
});
});
Loading