Skip to content

Commit

Permalink
Cache CSharp scripts (#6130)
Browse files Browse the repository at this point in the history
- Following the same scheme as the JavaScript engine does
- Also cleans up some duplication in the JavaScript engine
- Change Caching from AbsoluteExpiration to SlidingExpiration. Absolute expiration does not make sense here, as the cached value does not become stale. Instead, the goal is to only keep relevant data cached, for which the inactivity timeout of a sliding expiration is more suited.

Co-authored-by: Sipke Schoorstra <[email protected]>
  • Loading branch information
Suchiman and sfmskywalker authored Nov 22, 2024
1 parent 08e0f8e commit 237e476
Show file tree
Hide file tree
Showing 5 changed files with 64 additions and 22 deletions.
2 changes: 2 additions & 0 deletions src/modules/Elsa.CSharp/Features/CSharpFeature.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Elsa.Caching.Features;
using Elsa.Common.Features;
using Elsa.CSharp.Activities;
using Elsa.CSharp.Contracts;
Expand All @@ -19,6 +20,7 @@ namespace Elsa.CSharp.Features;
/// </summary>
[DependsOn(typeof(MediatorFeature))]
[DependsOn(typeof(ExpressionsFeature))]
[DependsOn(typeof(MemoryCacheFeature))]
public class CSharpFeature : FeatureBase
{
/// <inheritdoc />
Expand Down
11 changes: 10 additions & 1 deletion src/modules/Elsa.CSharp/Options/CSharpOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,16 @@ public class CSharpOptions
typeof(JsonNode).Namespace!, // System.Text.Json.Nodes
typeof(IDictionary<string, object>).Namespace!, // System.Collections.Generic
});


/// <summary>
/// The timeout for script caching.
/// </summary>
/// <remarks>
/// The <c>ScriptCacheTimeout</c> property specifies the duration for which the scripts are cached in the C# engine. When a script is executed, it is compiled and cached for future use. This caching improves performance by avoiding repetitive compilation of the same script.
/// If the value of <c>ScriptCacheTimeout</c> is <c>null</c>, the scripts are cached indefinitely. If a time value is specified, the scripts will be purged from the cache after they've been unused for the specified duration and recompiled on next use.
/// </remarks>
public TimeSpan? ScriptCacheTimeout { get; set; } = TimeSpan.FromDays(1);

/// <summary>
/// Disables the generation of variable wrappers. E.g. <c>Variables.MyVariable</c> will no longer be available for variables. Instead, you can only access variables using <c>Variables.Get("MyVariable")</c> and the typed <c>Variables.Get&lt;T&gt;("MyVariable")</c> function.
/// This is useful if your application requires the use of invalid JavaScript variable names.
Expand Down
63 changes: 49 additions & 14 deletions src/modules/Elsa.CSharp/Services/CSharpEvaluator.cs
Original file line number Diff line number Diff line change
@@ -1,27 +1,28 @@
using System.Security.Cryptography;
using System.Text;
using Elsa.CSharp.Contracts;
using Elsa.CSharp.Models;
using Elsa.CSharp.Notifications;
using Elsa.CSharp.Options;
using Elsa.Expressions.Models;
using Elsa.Mediator.Contracts;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;

namespace Elsa.CSharp.Services;

/// <summary>
/// A C# expression evaluator using Roslyn.
/// </summary>
public class CSharpEvaluator : ICSharpEvaluator
/// <remarks>
/// Initializes a new instance of the <see cref="CSharpEvaluator"/> class.
/// </remarks>
public class CSharpEvaluator(INotificationSender notificationSender, IOptions<CSharpOptions> scriptOptions, IMemoryCache memoryCache) : ICSharpEvaluator
{
private readonly INotificationSender _notificationSender;

/// <summary>
/// Initializes a new instance of the <see cref="CSharpEvaluator"/> class.
/// </summary>
public CSharpEvaluator(INotificationSender notificationSender)
{
_notificationSender = notificationSender;
}
private readonly CSharpOptions _csharpOptions = scriptOptions.Value;

/// <inheritdoc />
public async Task<object?> EvaluateAsync(
Expand All @@ -33,22 +34,56 @@ public CSharpEvaluator(INotificationSender notificationSender)
Func<Script<object>, Script<object>>? configureScript = default,
CancellationToken cancellationToken = default)
{
var scriptOptions = ScriptOptions.Default;
var scriptOptions = ScriptOptions.Default.WithOptimizationLevel(OptimizationLevel.Release);

if (configureScriptOptions != null)
scriptOptions = configureScriptOptions(scriptOptions);

var globals = new Globals(context, options.Arguments);
var script = CSharpScript.Create("", scriptOptions, typeof(Globals));

if (configureScript != null)
script = configureScript(script);

var notification = new EvaluatingCSharp(options, script, scriptOptions, context);
await _notificationSender.SendAsync(notification, cancellationToken);
await notificationSender.SendAsync(notification, cancellationToken);
scriptOptions = notification.ScriptOptions;
script = notification.Script.ContinueWith(expression, scriptOptions);
script = GetPreCompiledScript(script);
var scriptState = await script.RunAsync(globals, cancellationToken: cancellationToken);
return scriptState.ReturnValue;
}

private Script<object> GetPreCompiledScript(Script<object> script)
{
var cacheKey = "csharp:script:" + Hash(script);

return memoryCache.GetOrCreate(cacheKey, entry =>
{
if (_csharpOptions.ScriptCacheTimeout.HasValue)
entry.SetSlidingExpiration(_csharpOptions.ScriptCacheTimeout.Value);
return script;
})!;
}

private static string Hash(Script<object> script)
{
var ms = new MemoryStream();
using (var sw = new StreamWriter(ms, Encoding.UTF8))
{
for (Script current = script; current != null; current = current.Previous)
{
sw.WriteLine(current.Code);
}
}

if (!ms.TryGetBuffer(out var segment))
{
segment = ms.ToArray();
}

var hash = SHA256.HashData(segment.AsSpan());
return Convert.ToBase64String(hash);
}
}
2 changes: 1 addition & 1 deletion src/modules/Elsa.JavaScript/Options/JintOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public class JintOptions
/// </summary>
/// <remarks>
/// The <c>ScriptCacheTimeout</c> property specifies the duration for which the scripts are cached in the Jint JavaScript engine. When a script is executed, it is compiled and cached for future use. This caching improves performance by avoiding repetitive compilation of the same script.
/// If the value of <c>ScriptCacheTimeout</c> is <c>null</c>, the scripts are cached indefinitely. If a time value is specified, the scripts will be recompiled after the specified duration has elapsed.
/// If the value of <c>ScriptCacheTimeout</c> is <c>null</c>, the scripts are cached indefinitely. If a time value is specified, the scripts will be purged from the cache after they've been unused for the specified duration and recompiled on next use.
/// </remarks>
public TimeSpan? ScriptCacheTimeout { get; set; } = TimeSpan.FromDays(1);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,10 @@ private async Task<Engine> GetConfiguredEngine(Action<Engine>? configureEngine,
ExperimentalFeatures = ExperimentalFeature.TaskInterop
};

if (_jintOptions.AllowClrAccess)
engineOptions.AllowClr();

ConfigureClrAccess(engineOptions);
ConfigureObjectWrapper(engineOptions);
ConfigureObjectConverters(engineOptions);

engineOptions.Interop.ObjectConverters.Add(new ByteArrayConverter());
await mediator.SendAsync(new CreatingJavaScriptEngine(engineOptions, context), cancellationToken);
_jintOptions.ConfigureEngineOptionsCallback(engineOptions, context);

Expand Down Expand Up @@ -95,7 +91,7 @@ private void ConfigureObjectWrapper(Jint.Options options)

private void ConfigureObjectConverters(Jint.Options options)
{
options.Interop.ObjectConverters.AddRange([new ByteArrayConverter()]);
options.Interop.ObjectConverters.Add(new ByteArrayConverter());
}

private void ConfigureArgumentGetters(Engine engine, ExpressionEvaluatorOptions options)
Expand Down Expand Up @@ -124,7 +120,7 @@ private Prepared<Script> GetOrCreatePrepareScript(string expression)
return memoryCache.GetOrCreate(cacheKey, entry =>
{
if (_jintOptions.ScriptCacheTimeout.HasValue)
entry.SetAbsoluteExpiration(_jintOptions.ScriptCacheTimeout.Value);
entry.SetSlidingExpiration(_jintOptions.ScriptCacheTimeout.Value);
return PrepareScript(expression);
})!;
Expand Down

0 comments on commit 237e476

Please sign in to comment.