chore(core): Register ExpressionEngineConfig in @n8n/config (no-changelog) (#31383)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mike Repeć 2026-06-02 10:26:21 +02:00 committed by GitHub
parent 7975b19ced
commit dbfd513cd0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 203 additions and 0 deletions

View File

@ -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();
});
});
});

View File

@ -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.01.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;
}

View File

@ -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;

View File

@ -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: [],
},