mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
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:
co-authored by
Claude Fable 5
parent
f8876ae2ff
commit
357b2a03d9
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+206
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user