From dbfd513cd0f256c4868cbe6eb52857237e324c6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Repe=C4=87?= Date: Tue, 2 Jun 2026 10:26:21 +0200 Subject: [PATCH] chore(core): Register ExpressionEngineConfig in @n8n/config (no-changelog) (#31383) Co-authored-by: Claude Opus 4.7 (1M context) --- .../expression-engine.config.test.ts | 123 ++++++++++++++++++ .../src/configs/expression-engine.config.ts | 64 +++++++++ packages/@n8n/config/src/index.ts | 5 + packages/@n8n/config/test/config.test.ts | 11 ++ 4 files changed, 203 insertions(+) create mode 100644 packages/@n8n/config/src/configs/__tests__/expression-engine.config.test.ts create mode 100644 packages/@n8n/config/src/configs/expression-engine.config.ts diff --git a/packages/@n8n/config/src/configs/__tests__/expression-engine.config.test.ts b/packages/@n8n/config/src/configs/__tests__/expression-engine.config.test.ts new file mode 100644 index 00000000000..9c9c11ae6e8 --- /dev/null +++ b/packages/@n8n/config/src/configs/__tests__/expression-engine.config.test.ts @@ -0,0 +1,123 @@ +import { Container } from '@n8n/di'; + +import { ExpressionEngineConfig } from '../expression-engine.config'; + +describe('ExpressionEngineConfig', () => { + const originalEnv = process.env; + + beforeEach(() => { + Container.reset(); + jest.resetAllMocks(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('defaults', () => { + test('bridgeTimeout defaults to 5000', () => { + expect(Container.get(ExpressionEngineConfig).bridgeTimeout).toBe(5000); + }); + + test('bridgeMemoryLimit defaults to 128', () => { + expect(Container.get(ExpressionEngineConfig).bridgeMemoryLimit).toBe(128); + }); + }); + + describe('N8N_EXPRESSION_ENGINE_IDLE_TIMEOUT', () => { + test('overrides idleTimeout', () => { + process.env.N8N_EXPRESSION_ENGINE_IDLE_TIMEOUT = '60'; + const config = Container.get(ExpressionEngineConfig); + expect(config.idleTimeout).toBe(60); + }); + + test('parses "0" as the number 0 (distinct from undefined/unset)', () => { + process.env.N8N_EXPRESSION_ENGINE_IDLE_TIMEOUT = '0'; + const config = Container.get(ExpressionEngineConfig); + expect(config.idleTimeout).toBe(0); + expect(config.idleTimeout).not.toBeUndefined(); + }); + }); + + describe('N8N_EXPRESSION_ENGINE_TIMEOUT', () => { + test('overrides bridgeTimeout', () => { + process.env.N8N_EXPRESSION_ENGINE_TIMEOUT = '1000'; + expect(Container.get(ExpressionEngineConfig).bridgeTimeout).toBe(1000); + }); + + test('falls back to default on non-numeric value', () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + process.env.N8N_EXPRESSION_ENGINE_TIMEOUT = 'not-a-number'; + expect(Container.get(ExpressionEngineConfig).bridgeTimeout).toBe(5000); + expect(consoleWarnSpy).toHaveBeenCalled(); + }); + }); + + describe('N8N_EXPRESSION_ENGINE_MEMORY_LIMIT', () => { + test('overrides bridgeMemoryLimit', () => { + process.env.N8N_EXPRESSION_ENGINE_MEMORY_LIMIT = '64'; + expect(Container.get(ExpressionEngineConfig).bridgeMemoryLimit).toBe(64); + }); + + test('falls back to default on non-numeric value', () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + process.env.N8N_EXPRESSION_ENGINE_MEMORY_LIMIT = 'not-a-number'; + expect(Container.get(ExpressionEngineConfig).bridgeMemoryLimit).toBe(128); + expect(consoleWarnSpy).toHaveBeenCalled(); + }); + }); + + describe('observability defaults', () => { + test('observabilityEnabled defaults to true', () => { + expect(Container.get(ExpressionEngineConfig).observabilityEnabled).toBe(true); + }); + + test('tracesEnabled defaults to true', () => { + expect(Container.get(ExpressionEngineConfig).tracesEnabled).toBe(true); + }); + + test('slowEvaluationThresholdMs defaults to 50', () => { + expect(Container.get(ExpressionEngineConfig).slowEvaluationThresholdMs).toBe(50); + }); + + test('tracesSampleRate defaults to 0', () => { + expect(Container.get(ExpressionEngineConfig).tracesSampleRate).toBe(0); + }); + }); + + describe('observability overrides', () => { + test('N8N_EXPRESSION_ENGINE_OBSERVABILITY_ENABLED disables observability', () => { + process.env.N8N_EXPRESSION_ENGINE_OBSERVABILITY_ENABLED = 'false'; + expect(Container.get(ExpressionEngineConfig).observabilityEnabled).toBe(false); + }); + + test('N8N_EXPRESSION_ENGINE_SLOW_EVAL_THRESHOLD_MS overrides threshold', () => { + process.env.N8N_EXPRESSION_ENGINE_SLOW_EVAL_THRESHOLD_MS = '100'; + expect(Container.get(ExpressionEngineConfig).slowEvaluationThresholdMs).toBe(100); + }); + + test('N8N_EXPRESSION_ENGINE_SLOW_EVAL_THRESHOLD_MS falls back to default on non-positive value', () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + process.env.N8N_EXPRESSION_ENGINE_SLOW_EVAL_THRESHOLD_MS = '0'; + expect(Container.get(ExpressionEngineConfig).slowEvaluationThresholdMs).toBe(50); + expect(consoleWarnSpy).toHaveBeenCalled(); + }); + + test('N8N_EXPRESSION_ENGINE_TRACES_SAMPLE_RATE overrides sample rate', () => { + process.env.N8N_EXPRESSION_ENGINE_TRACES_SAMPLE_RATE = '0.25'; + expect(Container.get(ExpressionEngineConfig).tracesSampleRate).toBe(0.25); + }); + + test('N8N_EXPRESSION_ENGINE_TRACES_SAMPLE_RATE falls back to default on out-of-range value', () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + process.env.N8N_EXPRESSION_ENGINE_TRACES_SAMPLE_RATE = '1.5'; + expect(Container.get(ExpressionEngineConfig).tracesSampleRate).toBe(0); + expect(consoleWarnSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/@n8n/config/src/configs/expression-engine.config.ts b/packages/@n8n/config/src/configs/expression-engine.config.ts new file mode 100644 index 00000000000..6a92b5b6eff --- /dev/null +++ b/packages/@n8n/config/src/configs/expression-engine.config.ts @@ -0,0 +1,64 @@ +import z from 'zod'; + +import { Config, Env } from '../decorators'; + +const expressionEngineSchema = z.enum(['legacy', 'vm']); + +@Config +export class ExpressionEngineConfig { + /** + * Which expression engine to use. + * - `legacy` runs expressions without isolation. + * - `vm` runs expressions in a V8 isolate. + * + * `vm` is currently **experimental**. Use at your own risk. + */ + @Env('N8N_EXPRESSION_ENGINE', expressionEngineSchema) + engine: 'legacy' | 'vm' = 'legacy'; + + /** Number of V8 isolates ready in the pool. */ + @Env('N8N_EXPRESSION_ENGINE_POOL_SIZE') + poolSize: number = 1; + + /** Max number of AST-transformed expressions to cache. */ + @Env('N8N_EXPRESSION_ENGINE_MAX_CODE_CACHE_SIZE') + maxCodeCacheSize: number = 1024; + + /** + * Execution timeout in milliseconds for each expression evaluation in the VM bridge. + */ + @Env('N8N_EXPRESSION_ENGINE_TIMEOUT') + bridgeTimeout: number = 5000; + + /** Memory limit in MB for the V8 isolate used by the VM bridge. */ + @Env('N8N_EXPRESSION_ENGINE_MEMORY_LIMIT') + bridgeMemoryLimit: number = 128; + + /** + * Whether to emit observability signals (metrics, traces, logs) for the VM evaluator. + * Only takes effect when `engine === 'vm'`; legacy mode never emits expression metrics + * regardless of this setting. + */ + @Env('N8N_EXPRESSION_ENGINE_OBSERVABILITY_ENABLED') + observabilityEnabled: boolean = true; + + /** + * Whether to emit OpenTelemetry spans for expression evaluation. + * Slow evaluations (>slowEvaluationThresholdMs) and errors always emit a span. + * Healthy-path evaluations are sampled at tracesSampleRate. + */ + @Env('N8N_EXPRESSION_ENGINE_TRACES_ENABLED') + tracesEnabled: boolean = true; + + /** Threshold in ms above which an evaluation is considered "slow" and gets a span. */ + @Env('N8N_EXPRESSION_ENGINE_SLOW_EVAL_THRESHOLD_MS', z.number({ coerce: true }).positive()) + slowEvaluationThresholdMs: number = 50; + + /** Head-based sampling rate (0.0–1.0) for healthy-path spans. Slow and erroring expressions always emit. */ + @Env('N8N_EXPRESSION_ENGINE_TRACES_SAMPLE_RATE', z.number({ coerce: true }).min(0).max(1)) + tracesSampleRate: number = 0.0; + + /** If set, scale the pool to 0 warm isolates after this many seconds with no acquire. */ + @Env('N8N_EXPRESSION_ENGINE_IDLE_TIMEOUT') + idleTimeout?: number; +} diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts index 3347ee5eab3..e7fa38a1a24 100644 --- a/packages/@n8n/config/src/index.ts +++ b/packages/@n8n/config/src/index.ts @@ -14,6 +14,7 @@ import { DynamicBannersConfig } from './configs/dynamic-banners.config'; import { EndpointsConfig } from './configs/endpoints.config'; import { EventBusConfig } from './configs/event-bus.config'; import { ExecutionsConfig } from './configs/executions.config'; +import { ExpressionEngineConfig } from './configs/expression-engine.config'; import { ExternalHooksConfig } from './configs/external-hooks.config'; import { GenericConfig } from './configs/generic.config'; import { HiringBannerConfig } from './configs/hiring-banner.config'; @@ -45,6 +46,7 @@ export type { TaskRunnerMode } from './configs/runners.config'; export { TaskRunnersConfig } from './configs/runners.config'; export { SecurityConfig } from './configs/security.config'; export { ExecutionsConfig } from './configs/executions.config'; +export { ExpressionEngineConfig } from './configs/expression-engine.config'; export { LOG_SCOPES } from './configs/logging.config'; export type { LogScope } from './configs/logging.config'; export { WorkflowsConfig } from './configs/workflows.config'; @@ -83,6 +85,9 @@ export class GlobalConfig { @Nested publicApi: PublicApiConfig; + @Nested + expressionEngine: ExpressionEngineConfig; + @Nested externalHooks: ExternalHooksConfig; diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index f5b15457e25..d8653d9b4cf 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -152,6 +152,17 @@ describe('GlobalConfig', () => { maxFileSizeInKB: 10240, }, }, + expressionEngine: { + engine: 'legacy', + poolSize: 1, + maxCodeCacheSize: 1024, + bridgeTimeout: 5000, + bridgeMemoryLimit: 128, + observabilityEnabled: true, + tracesEnabled: true, + slowEvaluationThresholdMs: 50, + tracesSampleRate: 0, + }, externalHooks: { files: [], },