Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ describe('Integration: ExpressionEvaluator + IsolatedVmBridge', () => {

beforeAll(async () => {
const bridge = new IsolatedVmBridge({ timeout: 5000 });
evaluator = new ExpressionEvaluator({ bridge });
evaluator = new ExpressionEvaluator({ bridge, maxCodeCacheSize: 1024 });
await evaluator.initialize();
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ExpressionEvaluator } from '../expression-evaluator';
import type { RuntimeBridge, ObservabilityProvider } from '../../types';

function createMockBridge(): RuntimeBridge {
return {
initialize: vi.fn().mockResolvedValue(undefined),
execute: vi.fn().mockReturnValue('result'),
dispose: vi.fn().mockResolvedValue(undefined),
isDisposed: vi.fn().mockReturnValue(false),
};
}

function createMockObservability(): ObservabilityProvider {
return {
metrics: {
counter: vi.fn(),
gauge: vi.fn(),
histogram: vi.fn(),
},
traces: {
startSpan: vi.fn().mockReturnValue({
setStatus: vi.fn(),
setAttribute: vi.fn(),
recordException: vi.fn(),
end: vi.fn(),
}),
},
logs: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
};
}

describe('ExpressionEvaluator cache', () => {
let bridge: RuntimeBridge;
let observability: ObservabilityProvider;

beforeEach(() => {
vi.clearAllMocks();
bridge = createMockBridge();
observability = createMockObservability();
});

it('should emit cache miss on first evaluation', async () => {
const evaluator = new ExpressionEvaluator({ bridge, observability, maxCodeCacheSize: 1024 });
await evaluator.initialize();
evaluator.evaluate('={{ $json.email }}', {});
expect(observability.metrics.counter).toHaveBeenCalledWith('expression.code_cache.miss', 1);
});

it('should emit cache hit on repeated evaluation', async () => {
const evaluator = new ExpressionEvaluator({ bridge, observability, maxCodeCacheSize: 1024 });
await evaluator.initialize();
evaluator.evaluate('={{ $json.email }}', {});
evaluator.evaluate('={{ $json.email }}', {});
expect(observability.metrics.counter).toHaveBeenCalledWith('expression.code_cache.hit', 1);
});

it('should emit eviction when cache is full', async () => {
const evaluator = new ExpressionEvaluator({
bridge,
observability,
maxCodeCacheSize: 2,
});
await evaluator.initialize();
evaluator.evaluate('={{ $json.a }}', {});
evaluator.evaluate('={{ $json.b }}', {});
evaluator.evaluate('={{ $json.c }}', {}); // evicts first
expect(observability.metrics.counter).toHaveBeenCalledWith('expression.code_cache.eviction', 1);
});

it('should work without observability', async () => {
const evaluator = new ExpressionEvaluator({ bridge, maxCodeCacheSize: 1024 });
await evaluator.initialize();
expect(() => {
evaluator.evaluate('={{ $json.email }}', {});
evaluator.evaluate('={{ $json.email }}', {});
}).not.toThrow();
});

it('should emit cache size gauge on cache miss', async () => {
const evaluator = new ExpressionEvaluator({ bridge, observability, maxCodeCacheSize: 1024 });
await evaluator.initialize();
evaluator.evaluate('={{ $json.email }}', {});
expect(observability.metrics.gauge).toHaveBeenCalledWith('expression.code_cache.size', 1);
});

it('should emit cache size gauge of 0 on dispose', async () => {
const evaluator = new ExpressionEvaluator({ bridge, observability, maxCodeCacheSize: 1024 });
await evaluator.initialize();
evaluator.evaluate('={{ $json.email }}', {});
vi.clearAllMocks();
await evaluator.dispose();
expect(observability.metrics.gauge).toHaveBeenCalledWith('expression.code_cache.size', 0);
});

it('should evict least recently used and report miss on re-access', async () => {
const evaluator = new ExpressionEvaluator({ bridge, observability, maxCodeCacheSize: 2 });
await evaluator.initialize();
evaluator.evaluate('={{ $json.a }}', {});
evaluator.evaluate('={{ $json.b }}', {});
evaluator.evaluate('={{ $json.c }}', {});
expect(observability.metrics.counter).toHaveBeenCalledWith('expression.code_cache.eviction', 1);
vi.clearAllMocks();
evaluator.evaluate('={{ $json.a }}', {});
expect(observability.metrics.counter).toHaveBeenCalledWith('expression.code_cache.miss', 1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, it, expect, vi } from 'vitest';
import { LruCache } from '../lru-cache';

describe('LruCache', () => {
it('should store and retrieve values', () => {
const cache = new LruCache<string, number>(3);
cache.set('a', 1);
cache.set('b', 2);
expect(cache.get('a')).toBe(1);
expect(cache.get('b')).toBe(2);
});

it('should return undefined for missing keys', () => {
const cache = new LruCache<string, number>(3);
expect(cache.get('missing')).toBeUndefined();
});

it('should evict the least recently used entry when over capacity', () => {
const cache = new LruCache<string, number>(2);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3); // evicts 'a'
expect(cache.get('a')).toBeUndefined();
expect(cache.get('b')).toBe(2);
expect(cache.get('c')).toBe(3);
});

it('should refresh recency on get', () => {
const cache = new LruCache<string, number>(2);
cache.set('a', 1);
cache.set('b', 2);
cache.get('a'); // refreshes 'a', now 'b' is oldest
cache.set('c', 3); // evicts 'b'
expect(cache.get('a')).toBe(1);
expect(cache.get('b')).toBeUndefined();
expect(cache.get('c')).toBe(3);
});

it('should refresh recency on get even when below capacity', () => {
const cache = new LruCache<string, number>(3);
cache.set('a', 1);
cache.set('b', 2);
cache.get('a'); // refreshes 'a' while cache is below capacity
cache.set('c', 3); // fills cache
cache.set('d', 4); // evicts 'b' (oldest), not 'a'
expect(cache.get('a')).toBe(1);
expect(cache.get('b')).toBeUndefined();
});

it('should refresh recency on set (update)', () => {
const cache = new LruCache<string, number>(2);
cache.set('a', 1);
cache.set('b', 2);
cache.set('a', 10); // refreshes 'a', now 'b' is oldest
cache.set('c', 3); // evicts 'b'
expect(cache.get('a')).toBe(10);
expect(cache.get('b')).toBeUndefined();
expect(cache.get('c')).toBe(3);
});

it('should call onEvict with evicted key and value', () => {
const onEvict = vi.fn();
const cache = new LruCache<string, number>(2, onEvict);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3); // evicts 'a'
expect(onEvict).toHaveBeenCalledWith('a', 1);
});

it('should not evict when updating existing keys', () => {
const onEvict = vi.fn();
const cache = new LruCache<string, number>(3, onEvict);
cache.set('a', 1);
cache.set('b', 2);
cache.set('a', 10); // update, not a new entry
cache.set('c', 3);
expect(onEvict).not.toHaveBeenCalled();
expect(cache.get('a')).toBe(10);
expect(cache.get('b')).toBe(2);
expect(cache.get('c')).toBe(3);
});

it('should clear all entries', () => {
const cache = new LruCache<string, number>(3);
cache.set('a', 1);
cache.set('b', 2);
cache.clear();
expect(cache.get('a')).toBeUndefined();
expect(cache.get('b')).toBeUndefined();
});

it('should work with capacity 1', () => {
const cache = new LruCache<string, number>(1);
cache.set('a', 1);
expect(cache.get('a')).toBe(1);
cache.set('b', 2); // evicts 'a'
expect(cache.get('a')).toBeUndefined();
expect(cache.get('b')).toBe(2);
});

it('should throw for capacity less than 1', () => {
expect(() => new LruCache<string, number>(0)).toThrow('capacity must be at least 1');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
WorkflowData,
EvaluateOptions,
} from '../types';
import { LruCache } from './lru-cache';

export class ExpressionEvaluator implements IExpressionEvaluator {
private config: EvaluatorConfig;
Expand All @@ -16,10 +17,13 @@ export class ExpressionEvaluator implements IExpressionEvaluator {

// Cache: template expression → tournament-transformed JavaScript code
// Cache hit rate in production: ~99.9% (same expressions repeat within a workflow)
private codeCache = new Map<string, string>();
private codeCache: LruCache<string, string>;

constructor(config: EvaluatorConfig) {
this.config = config;
this.codeCache = new LruCache<string, string>(config.maxCodeCacheSize, () => {
this.config.observability?.metrics.counter('expression.code_cache.eviction', 1);
});
}

async initialize(): Promise<void> {
Expand Down Expand Up @@ -62,9 +66,12 @@ export class ExpressionEvaluator implements IExpressionEvaluator {
private getTransformedCode(expression: string): string {
const cached = this.codeCache.get(expression);
if (cached !== undefined) {
this.config.observability?.metrics.counter('expression.code_cache.hit', 1);
return cached;
}

this.config.observability?.metrics.counter('expression.code_cache.miss', 1);

if (!this.tournament) {
// Tournament requires an errorHandler but we only use getExpressionCode()
// for AST transformation — we never call tournament.execute(), so this
Expand All @@ -79,12 +86,14 @@ export class ExpressionEvaluator implements IExpressionEvaluator {

const [transformedCode] = this.tournament.getExpressionCode(expression);
this.codeCache.set(expression, transformedCode);
this.config.observability?.metrics.gauge('expression.code_cache.size', this.codeCache.size);
return transformedCode;
}

async dispose(): Promise<void> {
this.disposed = true;
this.codeCache.clear();
this.config.observability?.metrics.gauge('expression.code_cache.size', 0);
await this.config.bridge.dispose();
}

Expand Down
38 changes: 38 additions & 0 deletions packages/@n8n/expression-runtime/src/evaluator/lru-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
export class LruCache<K, V> {
private map = new Map<K, V>();

constructor(
private readonly capacity: number,
private readonly onEvict?: (key: K, value: V) => void,
) {
if (capacity < 1) {
throw new Error('LruCache capacity must be at least 1');
}
}

get(key: K): V | undefined {
const value = this.map.get(key);
if (value === undefined) return undefined;
this.map.delete(key);
this.map.set(key, value);
return value;
}

set(key: K, value: V): void {
this.map.delete(key);
this.map.set(key, value);
if (this.map.size > this.capacity) {
const [oldestKey, oldestValue] = this.map.entries().next().value!;
this.map.delete(oldestKey);
this.onEvict?.(oldestKey, oldestValue);
}
}

get size(): number {
return this.map.size;
}

clear(): void {
this.map.clear();
}
}
5 changes: 5 additions & 0 deletions packages/@n8n/expression-runtime/src/types/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ export interface EvaluatorConfig {
* If omitted, expressions are transformed with no security hooks (dev/testing use).
*/
hooks?: TournamentHooks;

/**
* Maximum number of tournament-transformed expressions to cache (LRU).
*/
maxCodeCacheSize: number;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,5 +144,8 @@ export async function useCurrentEngine(): Promise<void> {

export async function useVmEngine(): Promise<void> {
Expression.setExpressionEngine('vm');
await Expression.initializeVmEvaluator();
// Use a higher timeout for benchmarks — CodSpeed's instruction-counting
// instrumentation adds significant wall-clock overhead that can cause the
// default 5s timeout to fire on larger data set benchmarks (e.g. 10k items).
await Expression.initializeVmEvaluator({ timeout: 60_000 });
}
8 changes: 6 additions & 2 deletions packages/workflow/src/expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,15 +204,19 @@ export class Expression {
* Should be called once during application startup.
* Only available in Node.js environments (not in browser).
*/
static async initializeVmEvaluator(): Promise<void> {
static async initializeVmEvaluator(options?: { timeout?: number }): Promise<void> {
if (this.expressionEngine !== 'vm' || IS_FRONTEND) return;

if (!this.vmEvaluator) {
// Dynamic import to avoid loading expression-runtime in browser environments
const { ExpressionEvaluator, IsolatedVmBridge } = await import('@n8n/expression-runtime');
const bridge = new IsolatedVmBridge({ timeout: 5000 });
const bridge = new IsolatedVmBridge({ timeout: options?.timeout ?? 5000 });
const DEFAULT_MAX_CODE_CACHE_SIZE = 1024;
const parsed = parseInt(process.env.N8N_EXPRESSION_ENGINE_MAX_CODE_CACHE_SIZE ?? '', 10);
Comment thread
despairblue marked this conversation as resolved.
const maxCodeCacheSize = parsed || DEFAULT_MAX_CODE_CACHE_SIZE;
Comment thread
despairblue marked this conversation as resolved.
this.vmEvaluator = new ExpressionEvaluator({
bridge,
maxCodeCacheSize,
hooks: {
before: [ThisSanitizer],
after: [PrototypeSanitizer, DollarSignValidator],
Expand Down
Loading