feat(core): Acquire expression isolates lazily on first engine evaluation (no-changelog) (#37614)

Co-authored-by: Claude Fable 5 <[email protected]>
This commit is contained in:
Mike Repeć
2026-09-11 12:02:19 +00:00
committed by GitHub
co-authored by Claude Fable 5
parent f8876ae2ff
commit 357b2a03d9
17 changed files with 713 additions and 82 deletions
@@ -75,4 +75,24 @@ export class ExpressionEngineConfig {
*/
@Env('N8N_EXPRESSION_ENGINE_ALLOW_WEBHOOK_ISOLATE_SKIP')
allowWebhookIsolateSkip: boolean = true;
/**
* Experimental: lazy isolate acquisition. Acquire calls only open a scope,
* and an isolate is created on the first expression that actually needs the
* engine. Scopes that evaluate no expression never consume an isolate. When
* the pool is exhausted, the isolate is built synchronously on the request
* path (see the `expression.pool.cold_start_sync` metrics).
*/
@Env('N8N_EXPRESSION_ENGINE_LAZY_ACQUIRE')
lazyAcquire: boolean = false;
/**
* Experimental: reuse the V8 compile cache for the expression runtime
* bundle. The first bundle compile produces V8 cached data, and every
* later isolate build consumes it, skipping re-parsing the bundle. V8
* validates the cached data and silently recompiles when it is stale.
* Only affects the `vm` engine.
*/
@Env('N8N_EXPRESSION_ENGINE_COMPILE_CACHE')
compileCache: boolean = false;
}
+2
View File
@@ -679,6 +679,8 @@ describe('GlobalConfig', () => {
slowEvaluationThresholdMs: 50,
tracesSampleRate: 0.0,
allowWebhookIsolateSkip: true,
lazyAcquire: false,
compileCache: false,
},
instanceSettingsLoader: {
ownerManagedByEnv: false,
@@ -200,6 +200,10 @@ N8N_EXPRESSION_ENGINE_POOL_SIZE=1
N8N_EXPRESSION_ENGINE_MAX_CODE_CACHE_SIZE=1024
N8N_EXPRESSION_ENGINE_IDLE_TIMEOUT= # seconds; unset = pool never scales to 0
# Experimental
N8N_EXPRESSION_ENGINE_LAZY_ACQUIRE=false # create the isolate on the first evaluation that needs it
N8N_EXPRESSION_ENGINE_COMPILE_CACHE=false # reuse the V8 compile cache for the runtime bundle ('vm' only)
# Bridge limits
N8N_EXPRESSION_ENGINE_TIMEOUT=5000 # ms; positive integer
N8N_EXPRESSION_ENGINE_MEMORY_LIMIT=128 # MB; minimum 8
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { createBridge, engineName } from './test-bridge';
import { ExpressionEvaluator } from '../evaluator/expression-evaluator';
// The empirical question behind lazy acquisition: when the pool is exhausted,
// can a real bridge be built from inside the synchronous evaluate() path?
// isolated-vm uses its sync APIs directly; QuickJS relies on the WASM module
// cached by pool warmup (an earlier async initialize). The cold start's cost
// is tracked in packages/testing/performance (cold-start.bench.ts).
describe(`Lazy acquisition: synchronous cold start (${engineName})`, () => {
it('builds a working bridge synchronously when the pool is exhausted', async () => {
const evaluator = new ExpressionEvaluator({
createBridge,
maxCodeCacheSize: 100,
poolSize: 1,
lazyAcquire: true,
});
await evaluator.initialize();
const callerA = {};
const callerB = {};
await evaluator.acquire(callerA);
await evaluator.acquire(callerB);
// A pops the warm bridge. B evaluates in the same synchronous block, so
// replenishment cannot have completed: B must cold-start synchronously.
const resultA = evaluator.evaluate('{{ $json.x + 1 }}', { $json: { x: 1 } }, callerA);
const resultB = evaluator.evaluate('{{ $json.x * 2 }}', { $json: { x: 21 } }, callerB);
expect(resultA).toBe(2);
expect(resultB).toBe(42);
await evaluator.release(callerA);
await evaluator.release(callerB);
await evaluator.dispose();
});
it('a scope whose expressions never reach the engine consumes no bridge', async () => {
const evaluator = new ExpressionEvaluator({
createBridge,
maxCodeCacheSize: 100,
poolSize: 1,
lazyAcquire: true,
});
await evaluator.initialize();
// Many sequential scopes, none evaluating: with eager acquisition this
// would have built and disposed a bridge per scope.
for (let i = 0; i < 25; i++) {
const caller = {};
await evaluator.acquire(caller);
await evaluator.release(caller);
}
// The pool still holds its single warm bridge: an evaluation now must
// not need a cold start (it pops the warm one).
const caller = {};
await evaluator.acquire(caller);
const start = performance.now();
const result = evaluator.evaluate('{{ $json.x }}', { $json: { x: 'warm' } }, caller);
const elapsed = performance.now() - start;
expect(result).toBe('warm');
// A warm pop plus one evaluation is far below any cold start; generous
// bound to stay unflaky on CI.
expect(elapsed).toBeLessThan(500);
await evaluator.release(caller);
await evaluator.dispose();
});
});
@@ -1,7 +1,31 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import { IsolatedVmBridge } from '../isolated-vm-bridge';
import type { Logger } from '../../types';
type Isolate = IsolatedVmBridge['isolate'];
/**
* isolated-vm's native Isolate is frozen, so vi.spyOn cannot patch it. Swap
* the bridge's isolate for a Proxy that records calls to one method and
* forwards everything else to the real isolate.
*/
function spyOnIsolate<M extends 'compileScript' | 'compileScriptSync'>(
bridge: IsolatedVmBridge,
method: M,
) {
const isolate = bridge['isolate'];
const spy = vi.fn((...args: unknown[]) => Reflect.apply(isolate[method], isolate, args));
bridge['isolate'] = new Proxy(isolate, {
get(target, key) {
if (key === method) return spy;
// Native getters such as isDisposed reject a Proxy receiver.
const value: unknown = Reflect.get(target, key, target);
return typeof value === 'function' ? value.bind(target) : value;
},
});
return spy;
}
describe('IsolatedVmBridge', () => {
describe('logger integration', () => {
afterEach(() => {
@@ -25,4 +49,43 @@ describe('IsolatedVmBridge', () => {
expect(consoleSpy).not.toHaveBeenCalled();
});
});
describe('compile cache', () => {
// The cached data lives in module state. Re-import the bridge per test so
// each one starts with an empty cache and the produce path is exercised.
let Bridge: typeof IsolatedVmBridge;
beforeEach(async () => {
vi.resetModules();
({ IsolatedVmBridge: Bridge } = await import('../isolated-vm-bridge'));
});
it('produces cached data on the first compile and consumes it on the next', async () => {
const first = new Bridge({ compileCache: true });
const firstCompile = spyOnIsolate(first, 'compileScript');
await first.initialize();
expect(firstCompile).toHaveBeenCalledWith(expect.any(String), { produceCachedData: true });
expect(first.execute('return typeof DateTime !== "undefined" && 40 + 2', {})).toBe(42);
await first.dispose();
const second = new Bridge({ compileCache: true });
const secondCompile = spyOnIsolate(second, 'compileScript');
await second.initialize();
expect(secondCompile).toHaveBeenCalledWith(expect.any(String), {
cachedData: expect.anything(),
});
expect(second.execute('return typeof DateTime !== "undefined" && 40 + 2', {})).toBe(42);
await second.dispose();
});
it('initializes synchronously with compileCache on and an empty cache', () => {
const bridge = new Bridge({ compileCache: true });
const compile = spyOnIsolate(bridge, 'compileScriptSync');
bridge.initializeSync!();
// Proves the module reset: a populated cache would pass cachedData here.
expect(compile).toHaveBeenCalledWith(expect.any(String), { produceCachedData: true });
const result = bridge.execute('return typeof extend === "function"', {});
expect(result).toBe(true);
void bridge.dispose();
});
});
});
@@ -1,5 +1,5 @@
import type ivm from 'isolated-vm';
import { readFile } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import * as path from 'node:path';
import type { RuntimeBridge, BridgeConfig, ExecuteOptions, WorkflowData } from '../types';
import { DEFAULT_BRIDGE_CONFIG, TimeoutError, MemoryLimitError } from '../types';
@@ -12,6 +12,8 @@ import { bridgeMessageSchema, type BridgeMessage } from './bridge-messages';
// only loaded when IsolatedVmBridge is actually constructed.
type IsolatedVm = typeof import('isolated-vm');
let _ivm: IsolatedVm | null = null;
/** Runtime bundle source, read once per process by loadRuntimeBundle(). */
let _runtimeBundle: string | null = null;
function getIvm(): IsolatedVm {
if (!_ivm) {
@@ -27,6 +29,58 @@ const BUNDLE_RELATIVE_PATH = path.join('dist', 'bundle', 'runtime.iife.js');
// even if the global is later replaced.
const safeStringify = JSON.stringify;
// V8 compile cache for the runtime bundle (BridgeConfig.compileCache): the
// first bundle compile produces it, later builds consume it and skip
// re-parsing. V8 validates the data and recompiles when it is stale.
let _bundleCachedData: ivm.ExternalCopy<ArrayBuffer> | null = null;
// The runtime sets Script.cachedData when produceCachedData is passed, but
// ivm's typings omit the property (CachedDataResult exists unattached).
function producedCachedData(script: ivm.Script): ivm.ExternalCopy<ArrayBuffer> | null {
const data: unknown = Reflect.get(script, 'cachedData');
return data instanceof getIvm().ExternalCopy ? data : null;
}
/** Globals the runtime bundle must define. Verified after every bundle load. */
const RUNTIME_GLOBALS = [
'DateTime',
'extend',
'createDeepLazyProxy',
'SafeObject',
'SafeError',
'buildContext',
];
/** Evaluates inside the isolate to a JSON array of the RUNTIME_GLOBALS that are missing. */
const MISSING_RUNTIME_GLOBALS_SOURCE = `JSON.stringify(${JSON.stringify(RUNTIME_GLOBALS)}.filter((name) => typeof globalThis[name] === 'undefined'))`;
function assertRuntimeGlobals(missingJson: unknown): void {
const missing: unknown = typeof missingJson === 'string' ? JSON.parse(missingJson) : missingJson;
if (Array.isArray(missing) && missing.length > 0) {
throw new Error(`Runtime bundle verification failed: missing ${missing.join(', ')}`);
}
}
/**
* The E() error handler injected into every isolate; see injectErrorHandler()
* for the two exception-handling layers it participates in.
*/
const ERROR_HANDLER_SOURCE = `
if (typeof E === 'undefined') {
globalThis.E = function(error, _context) {
// Re-throw ExpressionError / ExpressionExtensionError to match
// the legacy handler in expression.ts. Errors from host callbacks
// arrive as sentinels (not class instances), so check by name.
const name = error?.name;
if (name === 'ExpressionError' || name === 'ExpressionExtensionError') {
throw error;
}
// Swallow everything else (TypeErrors, generic Errors, etc.)
return undefined;
};
}
`;
/** Check if a value is an error sentinel returned by serializeError. */
function isErrorSentinel(value: unknown): value is ErrorSentinel {
return (
@@ -68,11 +122,13 @@ function serializeError(err: unknown): ErrorSentinel {
* - `src/bridge/` (vitest running against source)
* - `dist/cjs/bridge/` (CJS build)
*/
async function readRuntimeBundle(): Promise<string> {
function loadRuntimeBundle(): string {
if (_runtimeBundle !== null) return _runtimeBundle;
let dir = __dirname;
while (dir !== path.dirname(dir)) {
try {
return await readFile(path.join(dir, BUNDLE_RELATIVE_PATH), 'utf-8');
_runtimeBundle = readFileSync(path.join(dir, BUNDLE_RELATIVE_PATH), 'utf-8');
return _runtimeBundle;
} catch {}
dir = path.dirname(dir);
}
@@ -142,9 +198,6 @@ export class IsolatedVmBridge implements RuntimeBridge {
// Load runtime bundle (DateTime, extend, SafeObject, proxy system)
await this.loadVendorLibraries();
// Verify proxy system loaded correctly
await this.verifyProxySystem();
// Inject E() error handler needed by tournament-generated try-catch code
await this.injectErrorHandler();
@@ -172,68 +225,29 @@ export class IsolatedVmBridge implements RuntimeBridge {
try {
// Load runtime bundle (includes vendor libraries + proxy system)
const runtimeBundle = await readRuntimeBundle();
const runtimeBundle = loadRuntimeBundle();
// Evaluate bundle in isolate context
// This makes all exported globals available (DateTime, extend, extendOptional, SafeObject, SafeError, createDeepLazyProxy, buildContext)
await this.context.eval(runtimeBundle);
this.logger.debug('[IsolatedVmBridge] Runtime bundle loaded');
// Verify vendor libraries loaded correctly
const hasDateTime = await this.context.eval('typeof DateTime !== "undefined"');
const hasExtend = await this.context.eval('typeof extend !== "undefined"');
if (!hasDateTime || !hasExtend) {
throw new Error(
`Library verification failed: DateTime=${hasDateTime}, extend=${hasExtend}`,
if (this.config.compileCache) {
const script = await this.isolate.compileScript(
runtimeBundle,
_bundleCachedData ? { cachedData: _bundleCachedData } : { produceCachedData: true },
);
if (!_bundleCachedData) _bundleCachedData = producedCachedData(script);
await script.run(this.context);
} else {
await this.context.eval(runtimeBundle);
}
this.logger.debug('[IsolatedVmBridge] Vendor libraries verified successfully');
assertRuntimeGlobals(await this.context.eval(MISSING_RUNTIME_GLOBALS_SOURCE));
this.logger.debug('[IsolatedVmBridge] Runtime bundle loaded and verified');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to load runtime bundle: ${errorMessage}`);
}
}
/**
* Verify the proxy system loaded correctly.
*
* The proxy system is loaded as part of the runtime bundle in loadVendorLibraries().
* This method verifies all required components are available.
*
* @private
* @throws {Error} If context not initialized or proxy system verification fails
*/
private async verifyProxySystem(): Promise<void> {
if (!this.context) {
throw new Error('Context not initialized');
}
try {
// Verify proxy system components loaded correctly
const hasProxyCreator = await this.context.eval('typeof createDeepLazyProxy !== "undefined"');
const hasSafeObject = await this.context.eval('typeof SafeObject !== "undefined"');
const hasSafeError = await this.context.eval('typeof SafeError !== "undefined"');
const hasBuildContext = await this.context.eval('typeof buildContext !== "undefined"');
if (!hasProxyCreator || !hasSafeObject || !hasSafeError || !hasBuildContext) {
throw new Error(
`Proxy system verification failed: ` +
`createDeepLazyProxy=${hasProxyCreator}, ` +
`SafeObject=${hasSafeObject}, SafeError=${hasSafeError}, ` +
`buildContext=${hasBuildContext}`,
);
}
this.logger.debug('[IsolatedVmBridge] Proxy system verified successfully');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to verify proxy system: ${errorMessage}`);
}
}
/**
* Inject the E() error handler into the isolate context.
*
@@ -262,25 +276,52 @@ export class IsolatedVmBridge implements RuntimeBridge {
throw new Error('Context not initialized');
}
await this.context.eval(`
if (typeof E === 'undefined') {
globalThis.E = function(error, _context) {
// Re-throw ExpressionError / ExpressionExtensionError to match
// the legacy handler in expression.ts. Errors from host callbacks
// arrive as sentinels (not class instances), so check by name.
const name = error?.name;
if (name === 'ExpressionError' || name === 'ExpressionExtensionError') {
throw error;
}
// Swallow everything else (TypeErrors, generic Errors, etc.)
return undefined;
};
}
`);
await this.context.eval(ERROR_HANDLER_SOURCE);
this.logger.debug('[IsolatedVmBridge] Error handler injected successfully');
}
/**
* Synchronous variant of initialize(): the same steps through isolated-vm's
* sync APIs, for on-demand creation inside the synchronous evaluate() path
* (lazy acquisition with an exhausted pool). Blocks the event loop for the
* duration of one isolate setup — pool warmup should stay on the async
* initialize().
*/
initializeSync(): void {
if (this.initialized) return;
try {
this.context = this.isolate.createContextSync();
const jail = this.context.global;
jail.setSync('global', jail.derefInto());
if (this.config.compileCache) {
const script = this.isolate.compileScriptSync(
loadRuntimeBundle(),
_bundleCachedData ? { cachedData: _bundleCachedData } : { produceCachedData: true },
);
if (!_bundleCachedData) _bundleCachedData = producedCachedData(script);
script.runSync(this.context);
} else {
this.context.evalSync(loadRuntimeBundle());
}
assertRuntimeGlobals(this.context.evalSync(MISSING_RUNTIME_GLOBALS_SOURCE));
this.context.evalSync(ERROR_HANDLER_SOURCE);
} catch (error) {
// A failed cold start must not retain the native isolate. dispose()
// has no awaits before the isolate is released, so not awaiting it
// here is safe.
void this.dispose();
throw error;
}
this.initialized = true;
this.logger.debug('[IsolatedVmBridge] Initialized synchronously');
}
/**
* Create an ivm.Callback for getting value/metadata at a path.
*
@@ -1,4 +1,4 @@
import { readFile } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import * as path from 'node:path';
import type { RuntimeBridge, BridgeConfig, ExecuteOptions, WorkflowData } from '../types';
@@ -19,8 +19,18 @@ import { bridgeMessageSchema } from './bridge-messages';
// file is statically imported (e.g. for error classes). The module is
// only loaded when QuickJsBridge.initialize() is actually called.
type QuickJSModule = typeof import('quickjs-emscripten');
type QuickJSWasm = Awaited<ReturnType<QuickJSModule['getQuickJS']>>;
let _quickjs: QuickJSModule | null = null;
/**
* The instantiated WASM module, cached after the first async initialize().
* Creating runtimes/contexts from it is synchronous, so once it is cached
* (pool warmup does this) initializeSync() can build a bridge on demand.
*/
let _quickjsWasm: QuickJSWasm | null = null;
/** Runtime bundle source, read once per process by loadRuntimeBundle(). */
let _runtimeBundle: string | null = null;
async function getQuickJSModule(): Promise<QuickJSModule> {
if (!_quickjs) {
_quickjs = await import('quickjs-emscripten');
@@ -283,11 +293,13 @@ function serializeError(err: unknown): ErrorSentinel {
* relative path) works from either compiled output dir — `dist/cjs/bridge/`
* and `dist/esm/bridge/` sit at different depths from the bundle.
*/
async function readRuntimeBundle(): Promise<string> {
function loadRuntimeBundle(): string {
if (_runtimeBundle !== null) return _runtimeBundle;
let dir = __dirname;
while (dir !== path.dirname(dir)) {
try {
return await readFile(path.join(dir, BUNDLE_RELATIVE_PATH), 'utf-8');
_runtimeBundle = readFileSync(path.join(dir, BUNDLE_RELATIVE_PATH), 'utf-8');
return _runtimeBundle;
} catch {}
dir = path.dirname(dir);
}
@@ -540,7 +552,36 @@ export class QuickJsBridge implements RuntimeBridge {
const { getQuickJS } = await getQuickJSModule();
const QuickJS = await getQuickJS();
_quickjsWasm = QuickJS;
this.setupContext(QuickJS, loadRuntimeBundle());
}
/**
* Synchronous variant of initialize(), for on-demand creation inside the
* synchronous evaluate() path (lazy acquisition with an exhausted pool).
* Requires the WASM module to have been instantiated by an earlier async
* initialize() in this process — pool warmup provides that.
*/
initializeSync(): void {
if (this.disposed) throw new Error('Bridge has been disposed and cannot be reinitialized.');
if (this.initialized) return;
// Both caches are populated by the same async initialize() (pool
// warmup), so the sync path never touches the filesystem or the
// event loop beyond the context setup itself.
if (_quickjsWasm === null || _runtimeBundle === null) {
throw new Error(
'QuickJS WASM module and runtime bundle are not loaded yet: an async initialize() ' +
'must run once (pool warmup) before bridges can be created synchronously',
);
}
this.setupContext(_quickjsWasm, _runtimeBundle);
}
/** Everything after module/bundle acquisition is synchronous and shared. */
private setupContext(QuickJS: QuickJSWasm, runtimeBundle: string): void {
// Create runtime with memory limit (MB → bytes)
this.runtime = QuickJS.newRuntime();
this.runtime.setMemoryLimit(this.config.memoryLimit * 1024 * 1024);
@@ -561,7 +602,7 @@ export class QuickJsBridge implements RuntimeBridge {
this.injectIntlPolyfill();
// Load runtime bundle (DateTime, extend, SafeObject, proxy system, buildContext)
await this.loadRuntimeBundle();
this.loadRuntimeBundle(runtimeBundle);
// Wrap __prepareForTransfer to mark Date/NaN/Map/Set/Error so they survive vm.dump()
this.injectTransferWrapper();
@@ -579,11 +620,9 @@ export class QuickJsBridge implements RuntimeBridge {
/**
* Load the runtime IIFE bundle and verify required globals are present.
*/
private async loadRuntimeBundle(): Promise<void> {
private loadRuntimeBundle(runtimeBundle: string): void {
if (!this.vm) throw new Error('Context not initialized');
const runtimeBundle = await readRuntimeBundle();
const result = this.vm.evalCode(runtimeBundle);
if (result.error) {
const errorStr = this.vm.dump(result.error);
@@ -0,0 +1,206 @@
import { describe, it, expect, vi } from 'vitest';
import { createMockBridge, createMockObservability } from '../../__tests__/helpers';
import { EXPRESSION_METRICS } from '../../observability/metrics';
import type { ObservabilityProvider, RuntimeBridge } from '../../types';
import { ExpressionEvaluator } from '../expression-evaluator';
// Lazy acquisition (lazyAcquire: true): acquire() only opens a scope, the
// bridge is created on the first evaluate() that reaches the engine, and a
// scope that never evaluates costs nothing.
const acquiredCount = (observability: ObservabilityProvider) =>
vi
.mocked(observability.metrics.counter)
.mock.calls.filter(([name]) => name === EXPRESSION_METRICS.poolAcquired.name).length;
const setup = async ({ syncInit = true }: { syncInit?: boolean } = {}) => {
const bridges: RuntimeBridge[] = [];
const createBridge = vi.fn(() => {
const bridge = syncInit
? { ...createMockBridge(), initializeSync: vi.fn() }
: createMockBridge();
bridges.push(bridge);
return bridge;
});
const observability = createMockObservability();
const evaluator = new ExpressionEvaluator({
createBridge,
maxCodeCacheSize: 100,
poolSize: 1,
lazyAcquire: true,
observability,
});
await evaluator.initialize();
return { evaluator, bridges, createBridge, observability };
};
const DATA = { $json: { x: 1 } };
describe('ExpressionEvaluator — lazy acquisition', () => {
it('acquire opens a scope without consuming a bridge', async () => {
const { evaluator, createBridge, observability } = await setup();
const caller = {};
expect(await evaluator.acquire(caller)).toBe(true);
expect(await evaluator.acquire(caller)).toBe(false);
// Only the pool warmup created a bridge; none was handed out.
expect(createBridge).toHaveBeenCalledTimes(1);
expect(acquiredCount(observability)).toBe(0);
await evaluator.dispose();
});
it('creates the bridge on the first engine evaluation and reuses it', async () => {
const { evaluator, bridges, observability } = await setup();
const caller = {};
await evaluator.acquire(caller);
evaluator.evaluate('{{ $json.x }}', DATA, caller);
evaluator.evaluate('{{ $json.x }}', DATA, caller);
expect(acquiredCount(observability)).toBe(1);
expect(bridges[0].execute).toHaveBeenCalledTimes(2);
await evaluator.release(caller);
await evaluator.dispose();
});
it('a scope that never evaluates releases without touching the pool', async () => {
const { evaluator, bridges, observability } = await setup();
const caller = {};
await evaluator.acquire(caller);
await evaluator.release(caller);
expect(acquiredCount(observability)).toBe(0);
expect(bridges[0].dispose).not.toHaveBeenCalled();
await evaluator.dispose();
});
it('evaluating without an open scope throws', async () => {
const { evaluator } = await setup();
expect(() => evaluator.evaluate('{{ $json.x }}', DATA, {})).toThrow('No bridge acquired');
await evaluator.dispose();
});
it('evaluating after release throws (the scope is closed)', async () => {
const { evaluator } = await setup();
const caller = {};
await evaluator.acquire(caller);
await evaluator.release(caller);
expect(() => evaluator.evaluate('{{ $json.x }}', DATA, caller)).toThrow('No bridge acquired');
await evaluator.dispose();
});
it('cold-starts synchronously via initializeSync when the pool is exhausted', async () => {
const { evaluator, bridges, createBridge, observability } = await setup();
const callerA = {};
const callerB = {};
await evaluator.acquire(callerA);
await evaluator.acquire(callerB);
// A pops the warm bridge; replenishment cannot complete inside this
// synchronous block, so B must cold-start synchronously.
evaluator.evaluate('{{ $json.x }}', DATA, callerA);
evaluator.evaluate('{{ $json.x }}', DATA, callerB);
const coldBridge = bridges[createBridge.mock.calls.length - 1];
expect(coldBridge.initializeSync).toHaveBeenCalledTimes(1);
expect(acquiredCount(observability)).toBe(2);
// The sync cold start is tracked separately from regular acquisition.
const coldStartCounts = vi
.mocked(observability.metrics.counter)
.mock.calls.filter(([name]) => name === EXPRESSION_METRICS.poolColdStartSync.name);
expect(coldStartCounts).toHaveLength(1);
expect(observability.metrics.histogram).toHaveBeenCalledWith(
EXPRESSION_METRICS.poolColdStartSyncDuration.name,
expect.any(Number),
);
await evaluator.release(callerA);
await evaluator.release(callerB);
await evaluator.dispose();
});
it('reports a clear error when the bridge has no synchronous initializer', async () => {
const { evaluator, bridges } = await setup({ syncInit: false });
const callerA = {};
const callerB = {};
await evaluator.acquire(callerA);
await evaluator.acquire(callerB);
evaluator.evaluate('{{ $json.x }}', DATA, callerA);
expect(() => evaluator.evaluate('{{ $json.x }}', DATA, callerB)).toThrow(
'no synchronous initializer',
);
// The unusable cold-start bridge must not be leaked.
const coldBridge = bridges[bridges.length - 1];
expect(coldBridge.dispose).toHaveBeenCalledTimes(1);
await evaluator.release(callerA);
await evaluator.release(callerB);
await evaluator.dispose();
});
it('disposes the cold-start bridge when its synchronous initialization fails', async () => {
const { evaluator, createBridge } = await setup();
const callerA = {};
const callerB = {};
await evaluator.acquire(callerA);
await evaluator.acquire(callerB);
// A pops the warm bridge (queuing async replenishment, whose
// createBridge call fires inside this evaluate). B's cold start is
// then the next createBridge call, since no microtask can run between
// the two synchronous evaluations.
evaluator.evaluate('{{ $json.x }}', DATA, callerA);
const failingBridge = {
...createMockBridge(),
initializeSync: vi.fn(() => {
throw new Error('sync init failed');
}),
};
createBridge.mockImplementationOnce(() => failingBridge);
expect(() => evaluator.evaluate('{{ $json.x }}', DATA, callerB)).toThrow('sync init failed');
expect(failingBridge.dispose).toHaveBeenCalledTimes(1);
await evaluator.release(callerA);
await evaluator.release(callerB);
await evaluator.dispose();
});
it('acquire throws after the evaluator is disposed', async () => {
const { evaluator } = await setup();
await evaluator.dispose();
await expect(evaluator.acquire({})).rejects.toThrow('Pool is disposed');
});
it('release disposes a lazily created bridge', async () => {
const { evaluator, bridges } = await setup();
const caller = {};
await evaluator.acquire(caller);
evaluator.evaluate('{{ $json.x }}', DATA, caller);
await evaluator.release(caller);
expect(bridges[0].dispose).toHaveBeenCalledTimes(1);
await evaluator.dispose();
});
});
@@ -47,6 +47,13 @@ export class ExpressionEvaluator implements IExpressionEvaluator {
private bridgesByCaller = new WeakMap<object, RuntimeBridge>();
/**
* Callers with an open lazy scope (lazyAcquire mode): acquired, but no
* bridge created yet. The bridge is created on the first evaluate() that
* reaches the engine and then lives in bridgesByCaller as usual.
*/
private lazyScopes = new WeakSet<object>();
/**
* Depth of the evaluation chain currently in progress, and when it started.
* An expression can evaluate another one (`$evaluateExpression`), which
@@ -103,9 +110,22 @@ export class ExpressionEvaluator implements IExpressionEvaluator {
* acquired: `false` means the caller already held one, so the current
* scope must not release it (release is not reference-counted and would
* return the caller's bridge to the pool mid-use).
*
* Under `lazyAcquire`, this only opens a scope: the bridge is created on
* the first evaluate() that needs the engine, so a scope whose expressions
* never reach the engine costs nothing. The return value then means "scope
* newly opened" and keeps the same do-not-double-release contract.
*/
async acquire(caller: object): Promise<boolean> {
// The pool throws this for eager acquisition; the lazy branch would
// otherwise happily open scopes whose every evaluation then fails.
if (this.disposed) throw new PoolDisposedError();
if (this.bridgesByCaller.has(caller)) return false;
if (this.config.lazyAcquire) {
if (this.lazyScopes.has(caller)) return false;
this.lazyScopes.add(caller);
return true;
}
let bridge: RuntimeBridge;
try {
bridge = this.pool.acquire();
@@ -119,6 +139,48 @@ export class ExpressionEvaluator implements IExpressionEvaluator {
return true;
}
/**
* Pop a warm bridge from the pool, cold-starting one when it is empty.
* Runs inside the synchronous evaluate() path (lazy acquisition), so the
* cold start uses the bridge's synchronous initializer.
*/
private acquireBridgeSync(): RuntimeBridge {
let bridge: RuntimeBridge;
try {
bridge = this.pool.acquire();
} catch (error) {
if (error instanceof PoolDisposedError) throw error;
if (!(error instanceof PoolExhaustedError)) throw error;
bridge = this.config.createBridge();
const coldStartedAt = performance.now();
try {
if (typeof bridge.initializeSync !== 'function') {
throw new IsolateError(
'Isolate pool exhausted and the bridge has no synchronous initializer for a lazy cold start',
);
}
bridge.initializeSync();
} catch (initError) {
// Failed cold start must not leak the bridge - dispose it here before rethrowing.
void bridge.dispose();
throw initError;
}
this.config.observability?.metrics.counter(EXPRESSION_METRICS.poolColdStartSync.name, 1);
this.config.observability?.metrics.histogram(
EXPRESSION_METRICS.poolColdStartSyncDuration.name,
(performance.now() - coldStartedAt) / 1000,
);
}
this.config.observability?.metrics.counter(EXPRESSION_METRICS.poolAcquired.name, 1);
return bridge;
}
evaluate(
expression: string,
data: WorkflowData,
@@ -161,7 +223,12 @@ export class ExpressionEvaluator implements IExpressionEvaluator {
}
private getBridge(caller: object): RuntimeBridge {
const bridge = this.bridgesByCaller.get(caller);
let bridge = this.bridgesByCaller.get(caller);
if (!bridge && this.lazyScopes.has(caller)) {
bridge = this.acquireBridgeSync();
this.bridgesByCaller.set(caller, bridge);
}
if (!bridge) {
throw new IsolateError('No bridge acquired for this context. Call acquire() first.');
}
@@ -176,6 +243,7 @@ export class ExpressionEvaluator implements IExpressionEvaluator {
}
async release(caller: object): Promise<void> {
this.lazyScopes.delete(caller);
const bridge = this.bridgesByCaller.get(caller);
if (!bridge) return;
this.bridgesByCaller.delete(caller);
@@ -48,6 +48,18 @@ export const EXPRESSION_METRICS = {
labels: [],
help: 'Failed pool bridge replenishments.',
},
poolColdStartSync: {
name: 'expression.pool.cold_start_sync',
kind: 'counter',
labels: [],
help: 'Bridges built synchronously on the request path because the pool was exhausted during a lazy evaluation.',
},
poolColdStartSyncDuration: {
name: 'expression.pool.cold_start_sync.duration_seconds',
kind: 'histogram',
labels: [],
help: 'Duration of synchronous cold starts (event-loop blocking time) in seconds.',
},
poolScaledUp: {
name: 'expression.pool.scaled_up',
kind: 'counter',
@@ -23,6 +23,18 @@ export interface RuntimeBridge {
*/
initialize(): Promise<void>;
/**
* Synchronous variant of initialize(), for creating a bridge on demand
* from inside the synchronous evaluate() path (lazy acquisition with an
* exhausted pool). Optional: a bridge whose setup is inherently async can
* omit it, but then it can only enter service through pool warmup.
*
* May require one-time async preparation to have happened earlier in the
* process (e.g. QuickJS's WASM module load); implementations must throw a
* clear error when that preparation is missing.
*/
initializeSync?(): void;
/**
* Execute JavaScript code in the isolated context.
*
@@ -80,6 +92,12 @@ export interface BridgeConfig {
/** Optional logger. Falls back to no-op if not provided. */
logger?: Logger;
/**
* Reuse V8 compile cache for the runtime bundle. isolated-vm only.
* Default: false
*/
compileCache?: boolean;
}
const NO_OP_LOGGER: Logger = {
@@ -94,6 +112,7 @@ export const DEFAULT_BRIDGE_CONFIG: Required<BridgeConfig> = {
memoryLimit: 128,
timeout: 5000,
logger: NO_OP_LOGGER,
compileCache: false,
};
/** Options for a single execute() call. */
@@ -45,6 +45,16 @@ export interface EvaluatorConfig {
/** If set, scale the pool to 0 warm bridges after this many ms with no isolate acquire. */
idleTimeoutMs?: number;
/**
* Lazy acquisition mode (prototype): acquire() only opens a scope for the
* caller; the bridge is created synchronously on the first evaluate() that
* actually needs the engine. Callers whose expressions never reach the
* engine (e.g. handled by a host-side fast path) never consume a bridge.
* On pool exhaustion the bridge is cold-started synchronously via
* RuntimeBridge.initializeSync().
*/
lazyAcquire?: boolean;
/** Optional logger. Passed through to pool. Falls back to no-op. */
logger?: Logger;
}
@@ -14,6 +14,7 @@ const ENGINE_AWARE = [
'**/integration.test.ts',
'**/typed-rpc.test.ts',
'**/host-fn-shadowing.test.ts',
'**/lazy-sync-cold-start.test.ts',
];
export default defineConfig({
@@ -199,6 +199,8 @@ test('should exit with a crash when expression engine init fails', async () => {
bridgeTimeout: 5000,
bridgeMemoryLimit: 128,
idleTimeout: 30,
lazyAcquire: false,
compileCache: false,
},
generic: { gracefulShutdownTimeout: 30 },
}),
@@ -223,6 +225,8 @@ test('should exit with a crash when expression engine init fails', async () => {
bridgeTimeout: 5000,
bridgeMemoryLimit: 128,
idleTimeoutMs: 30_000, // the config value is in seconds
lazyAcquire: false,
compileCache: false,
observability: expressionObservability,
});
expect(exitSpy).toHaveBeenCalledWith(expect.stringContaining('isolated-vm'), expect.any(Error));
+12 -2
View File
@@ -247,8 +247,16 @@ export abstract class BaseCommand<F = never> {
Container.get(WorkflowFailureNotificationEventRelay).init();
if (this.needsExpressionEngine) {
const { engine, poolSize, maxCodeCacheSize, bridgeTimeout, bridgeMemoryLimit, idleTimeout } =
this.globalConfig.expressionEngine;
const {
engine,
poolSize,
maxCodeCacheSize,
bridgeTimeout,
bridgeMemoryLimit,
idleTimeout,
lazyAcquire,
compileCache,
} = this.globalConfig.expressionEngine;
const observability = Container.get(ExpressionObservabilityProvider);
try {
await Expression.initExpressionEngine({
@@ -258,6 +266,8 @@ export abstract class BaseCommand<F = never> {
bridgeTimeout,
bridgeMemoryLimit,
idleTimeoutMs: idleTimeout === undefined ? undefined : idleTimeout * 1000,
lazyAcquire,
compileCache,
observability,
});
} catch (error) {
@@ -0,0 +1,55 @@
/**
* Tier 2: Synchronous isolate cold start
*
* Under lazy acquisition (N8N_EXPRESSION_ENGINE_LAZY_ACQUIRE) an evaluation
* that finds the pool exhausted builds its bridge synchronously on the main
* thread. This measures that build, one evaluation, and disposal, per engine
* and for isolated-vm with and without the V8 compile cache.
*
* Run: pnpm --filter=@n8n/performance bench
*/
import { bench } from 'vitest';
import { IsolatedVmBridge, QuickJsBridge } from '@n8n/expression-runtime';
import { BENCH_OPTIONS } from '../bench-options';
const EXPRESSION = 'return typeof DateTime !== "undefined" && 40 + 2';
// QuickJS caches its WASM module during an async initialize(); initializeSync()
// requires that to have happened once in the process, as pool warmup does.
const warmup = new QuickJsBridge({ timeout: 5000 });
await warmup.initialize();
await warmup.dispose();
bench(
'cold start: isolated-vm initializeSync + eval',
async () => {
const bridge = new IsolatedVmBridge({ timeout: 5000 });
bridge.initializeSync();
bridge.execute(EXPRESSION, {});
await bridge.dispose();
},
BENCH_OPTIONS,
);
bench(
'cold start: isolated-vm initializeSync + eval, compile cache',
async () => {
const bridge = new IsolatedVmBridge({ timeout: 5000, compileCache: true });
bridge.initializeSync();
bridge.execute(EXPRESSION, {});
await bridge.dispose();
},
BENCH_OPTIONS,
);
bench(
'cold start: quickjs initializeSync + eval',
async () => {
const bridge = new QuickJsBridge({ timeout: 5000 });
bridge.initializeSync();
bridge.execute(EXPRESSION, {});
await bridge.dispose();
},
BENCH_OPTIONS,
);
+4
View File
@@ -249,6 +249,8 @@ export class Expression {
maxCodeCacheSize: number;
observability?: ObservabilityProvider;
idleTimeoutMs?: number;
lazyAcquire?: boolean;
compileCache?: boolean;
}): Promise<void> {
if ((options.engine !== 'vm' && options.engine !== 'quickjs') || IS_FRONTEND) return;
this.expressionEngine = options.engine;
@@ -269,12 +271,14 @@ export class Expression {
timeout: options.bridgeTimeout,
memoryLimit: options.bridgeMemoryLimit,
logger: LoggerProxy,
compileCache: options.compileCache,
});
this.vmEvaluator = new runtime.ExpressionEvaluator({
createBridge,
maxCodeCacheSize: options.maxCodeCacheSize,
poolSize: options.poolSize,
idleTimeoutMs: options.idleTimeoutMs,
lazyAcquire: options.lazyAcquire,
hooks: expressionSandboxHooks,
logger: LoggerProxy,
observability: options.observability,