From 2e9a3ad04f8b704fbe5a3ffad07041e2e8e93b8f Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Mon, 17 Aug 2026 02:16:38 +0300 Subject: [PATCH] Trim per-evaluation work in the JavaScript evaluator (#7892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(javascript): trim per-evaluation work in the JavaScript evaluator A Jint engine is built for every expression evaluation, so anything done during setup is paid for on every evaluation. Four pieces of that work are avoidable: * The three `IObjectConverter` implementations are stateless but were allocated fresh for every engine. They are now shared static instances. * Every prepared-script cache lookup — including hits — computed a SHA-256 hash of the expression text, base64-encoded it and concatenated a prefix, purely to build the cache key. Using a dedicated key type instead keeps the entries distinct from other users of the shared cache while letting the expression itself be the key, so a hit is a dictionary lookup. Looking the entry up directly rather than through `GetOrCreate` also keeps the factory closure off the hit path. * `ObjectConverterHelper.ConvertToJsObject` built an explicit `PropertyDescriptor` per property and called `DefineOwnProperty`. `CreateDataProperty` is public, produces exactly the same writable/enumerable/configurable descriptor, and is the engine's fast path for it. * The variable write-back resolved the workflow input names — walking the whole activity execution context ancestor chain — before checking whether there was anything to write back. Only variables the expression actually referenced are copied into the engine, so for the common case of an expression that never mentions `variables.` the container is empty and all of that work is wasted. The input names are also now looked up through a set rather than a list. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0179sA2T7HuRfRfSc2JirFik * test(javascript): pin the parse-failure test to the same exception every time Calling `ThrowsAnyAsync` twice only proved that both evaluations threw something, which is exactly the assertion a poisoned cache would still satisfy: had the failed preparation left a null or half-built entry behind, the second evaluation would have failed too, just with a different exception. The test now captures both exceptions and asserts they are the same type with the same message, so "keeps reporting the same parse failure" is what is actually checked. The message is stable to compare: it is `Could not prepare script: Unexpected end of input (1:9)`, and since both evaluations run the identical script literal the position is identical as well. No file or path detail is involved. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0179sA2T7HuRfRfSc2JirFik --------- Co-authored-by: Claude Opus 5 (1M context) --- .../Handlers/ConfigureEngineWithVariables.cs | 8 ++- .../Helpers/ObjectConverterHelper.cs | 12 ++--- .../Services/JintJavaScriptEvaluator.cs | 40 ++++++++------ .../ScriptCacheTests.cs | 52 +++++++++++++++++++ 4 files changed, 86 insertions(+), 26 deletions(-) create mode 100644 test/integration/Elsa.JavaScript.IntegrationTests/ScriptCacheTests.cs diff --git a/src/modules/Elsa.Expressions.JavaScript/Handlers/ConfigureEngineWithVariables.cs b/src/modules/Elsa.Expressions.JavaScript/Handlers/ConfigureEngineWithVariables.cs index 13400e509..042e43eb9 100644 --- a/src/modules/Elsa.Expressions.JavaScript/Handlers/ConfigureEngineWithVariables.cs +++ b/src/modules/Elsa.Expressions.JavaScript/Handlers/ConfigureEngineWithVariables.cs @@ -46,7 +46,13 @@ public partial class ConfigureEngineWithVariables(IOptions options) var context = notification.Context; var engine = notification.Engine; var variablesContainer = (IDictionary)engine.GetValue("variables").ToObject()!; - var inputNames = GetInputNames(context).FilterInvalidVariableNames().Distinct().ToList(); + + // Only the variables the expression actually referenced were copied in, so an expression that never + // mentions "variables." has nothing to copy back and does not need the input names resolved at all. + if (variablesContainer.Count == 0) + return; + + var inputNames = GetInputNames(context).FilterInvalidVariableNames().ToHashSet(StringComparer.Ordinal); foreach (var (variableName, variableValue) in variablesContainer) { diff --git a/src/modules/Elsa.Expressions.JavaScript/Helpers/ObjectConverterHelper.cs b/src/modules/Elsa.Expressions.JavaScript/Helpers/ObjectConverterHelper.cs index 28d9ecdcc..e3e2a8282 100644 --- a/src/modules/Elsa.Expressions.JavaScript/Helpers/ObjectConverterHelper.cs +++ b/src/modules/Elsa.Expressions.JavaScript/Helpers/ObjectConverterHelper.cs @@ -1,19 +1,14 @@ using System.Collections; using System.Dynamic; using Elsa.Extensions; -using Elsa.Expressions.JavaScript.Options; using Jint; using Jint.Native; using Jint.Native.Object; -using Jint.Runtime.Descriptors; -using Microsoft.Extensions.Options; namespace Elsa.Expressions.JavaScript.Helpers; internal static class ObjectConverterHelper { - - public static object? ProcessVariableValue(Engine engine, object? variableValue) { if (variableValue == null) @@ -31,10 +26,9 @@ internal static class ObjectConverterHelper foreach (var kvp in expando) { - var value = kvp.Value; - var jsValue = ConvertToJsValue(engine, value); - var propertyDescriptor = new PropertyDescriptor(jsValue, true, true, true); - jsObject.DefineOwnProperty(kvp.Key, propertyDescriptor); + // CreateDataProperty defines a writable, enumerable and configurable property, which is what the + // explicit descriptor used to spell out, and takes the engine's fast path for doing so. + jsObject.CreateDataProperty(kvp.Key, ConvertToJsValue(engine, kvp.Value)); } return jsObject; diff --git a/src/modules/Elsa.Expressions.JavaScript/Services/JintJavaScriptEvaluator.cs b/src/modules/Elsa.Expressions.JavaScript/Services/JintJavaScriptEvaluator.cs index bbc395fc6..15c1818cb 100644 --- a/src/modules/Elsa.Expressions.JavaScript/Services/JintJavaScriptEvaluator.cs +++ b/src/modules/Elsa.Expressions.JavaScript/Services/JintJavaScriptEvaluator.cs @@ -1,6 +1,4 @@ using System.Diagnostics.CodeAnalysis; -using System.Security.Cryptography; -using System.Text; using Acornima.Ast; using Elsa.Expressions.Helpers; using Elsa.Expressions.Models; @@ -25,6 +23,9 @@ namespace Elsa.Expressions.JavaScript.Services; public class JintJavaScriptEvaluator(IConfiguration configuration, INotificationSender mediator, IOptions scriptOptions, IMemoryCache memoryCache) : IJavaScriptEvaluator { + // The converters are stateless, so a single instance of each can serve every engine. + private static readonly IObjectConverter[] ObjectConverters = [new ByteArrayConverter(), new EnumToStringConverter(), new JsonElementConverter()]; + private readonly JintOptions _jintOptions = scriptOptions.Value; /// @@ -97,7 +98,7 @@ public class JintJavaScriptEvaluator(IConfiguration configuration, INotification private void ConfigureObjectConverters(Jint.Options options) { - options.Interop.ObjectConverters.AddRange([new ByteArrayConverter(), new EnumToStringConverter(), new JsonElementConverter()]); + options.Interop.ObjectConverters.AddRange(ObjectConverters); } private void ConfigureArgumentGetters(Engine engine, ExpressionEvaluatorOptions options) @@ -124,15 +125,24 @@ public class JintJavaScriptEvaluator(IConfiguration configuration, INotification private Prepared