64e3fbe035
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public members surfaced by commentchecker — resolves 5,847 of 5,869 issues (99.6%) across three /fixdocs passes.
161 lines
7.7 KiB
C#
161 lines
7.7 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
|
|
|
/// <summary>
|
|
/// Source-hash-keyed compile cache for user scripts. Roslyn compilation is the most
|
|
/// expensive step in the evaluator pipeline (5-20ms per script depending on size);
|
|
/// re-compiling on every value-change event would starve the virtual-tag engine.
|
|
/// The cache is generic on the <see cref="ScriptContext"/> subclass + result type so
|
|
/// different engines (virtual-tag / alarm-predicate / future alarm-action) each get
|
|
/// their own cache instance — there's no cross-type pollution.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Concurrent-safe: <see cref="ConcurrentDictionary{TKey, TValue}"/> of
|
|
/// <see cref="Lazy{T}"/> means a miss on two threads compiles exactly once.
|
|
/// <see cref="LazyThreadSafetyMode.ExecutionAndPublication"/> guarantees other
|
|
/// threads block on the in-flight compile rather than racing to duplicate work.
|
|
/// </para>
|
|
/// <para>
|
|
/// Cache is keyed on SHA-256 of the UTF-8 bytes of the source — collision-free in
|
|
/// practice. Whitespace changes therefore miss the cache on purpose; operators
|
|
/// see re-compile time on their first evaluation after a format-only edit which
|
|
/// is rare and benign.
|
|
/// </para>
|
|
/// <para>
|
|
/// No capacity bound. Virtual-tag + alarm scripts are operator-authored and
|
|
/// bounded by config DB (typically low thousands). If that changes in v3, add an
|
|
/// LRU eviction policy — the API stays the same.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Lifecycle:</b> compiled scripts hold a collectible
|
|
/// <see cref="System.Runtime.Loader.AssemblyLoadContext"/> per evaluator
|
|
/// (Core.Scripting-008 fix). <see cref="Clear"/> disposes every materialised
|
|
/// evaluator before dropping its dictionary entry so the emitted assemblies are
|
|
/// eligible for GC immediately after a publish. <see cref="Dispose"/> drops the
|
|
/// cache itself for graceful server shutdown.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class CompiledScriptCache<TContext, TResult> : IDisposable
|
|
where TContext : ScriptContext
|
|
{
|
|
private readonly ConcurrentDictionary<string, Lazy<ScriptEvaluator<TContext, TResult>>> _cache = new();
|
|
private bool _disposed;
|
|
|
|
/// <summary>
|
|
/// Return the compiled evaluator for <paramref name="scriptSource"/>, compiling
|
|
/// on first sight + reusing thereafter. If the source fails to compile, the
|
|
/// original Roslyn / sandbox exception propagates; the cache entry is removed so
|
|
/// the next call retries (useful during Admin UI authoring when the operator is
|
|
/// still fixing syntax).
|
|
/// </summary>
|
|
/// <param name="scriptSource">The source code to compile or retrieve from cache.</param>
|
|
/// <returns>The compiled script evaluator.</returns>
|
|
public ScriptEvaluator<TContext, TResult> GetOrCompile(string scriptSource)
|
|
{
|
|
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
|
|
if (_disposed) throw new ObjectDisposedException(nameof(CompiledScriptCache<TContext, TResult>));
|
|
|
|
var key = HashSource(scriptSource);
|
|
var lazy = _cache.GetOrAdd(key, _ => new Lazy<ScriptEvaluator<TContext, TResult>>(
|
|
() => ScriptEvaluator<TContext, TResult>.Compile(scriptSource),
|
|
LazyThreadSafetyMode.ExecutionAndPublication));
|
|
|
|
try
|
|
{
|
|
return lazy.Value;
|
|
}
|
|
catch
|
|
{
|
|
// Failed compile — evict the SPECIFIC faulted Lazy instance so a retry with
|
|
// corrected source can succeed. The KeyValuePair<,> overload compares the
|
|
// value reference, so if two threads race the same bad source both observe
|
|
// the same faulted Lazy and both reach this catch, and a concurrent retry
|
|
// re-added a fresh Lazy under the same key between the two removals, the
|
|
// second removal does NOT evict the in-flight retry. (Core.Scripting-006.)
|
|
_cache.TryRemove(new KeyValuePair<string, Lazy<ScriptEvaluator<TContext, TResult>>>(key, lazy));
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>Current entry count. Exposed for Admin UI diagnostics / tests.</summary>
|
|
public int Count => _cache.Count;
|
|
|
|
/// <summary>
|
|
/// Drop every cached compile. Used on config generation publish + tests.
|
|
/// Disposes each materialised evaluator before removing it so its collectible
|
|
/// <see cref="System.Runtime.Loader.AssemblyLoadContext"/> unloads and the
|
|
/// emitted script assembly becomes eligible for GC (Core.Scripting-008).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Safe to call after <see cref="Dispose"/> — the operation is idempotent.
|
|
/// <see cref="Dispose"/> sets <c>_disposed = true</c> before invoking this
|
|
/// method (so callers see the post-Dispose guard on <see cref="GetOrCompile"/>),
|
|
/// but this method itself MUST run to completion so the Dispose-triggered
|
|
/// drain actually unloads every materialised evaluator's ALC. (Core.Scripting-016
|
|
/// uncovered this — a previous Clear-aborts-when-disposed guard silently
|
|
/// skipped the entire drain on Dispose, leaving emitted assemblies rooted.)
|
|
/// </remarks>
|
|
public void Clear()
|
|
{
|
|
// Snapshot (key, value) pairs and remove with the value-scoped
|
|
// TryRemove(KeyValuePair<,>) overload — same shape as the
|
|
// Core.Scripting-006 fix in GetOrCompile's catch block. A concurrent
|
|
// GetOrCompile re-add that hashes to the same key between our snapshot
|
|
// and the TryRemove inserts a *different* Lazy reference; the value-
|
|
// scoped removal sees the mismatch and leaves the fresh entry intact
|
|
// (instead of evicting + disposing it while the concurrent caller
|
|
// still holds it). The fresh evaluator and its ALC stay live for the
|
|
// concurrent caller. (Core.Scripting-014.)
|
|
foreach (var entry in _cache.ToArray())
|
|
{
|
|
if (_cache.TryRemove(entry))
|
|
DisposeLazyIfMaterialised(entry.Value);
|
|
}
|
|
}
|
|
|
|
/// <summary>True when the exact source has been compiled at least once + is still cached.</summary>
|
|
/// <param name="scriptSource">The source code to check for in the cache.</param>
|
|
/// <returns>True if the source is cached, false otherwise.</returns>
|
|
public bool Contains(string scriptSource)
|
|
=> _cache.ContainsKey(HashSource(scriptSource));
|
|
|
|
/// <summary>
|
|
/// Drop the cache and dispose every materialised evaluator. After disposal
|
|
/// <see cref="GetOrCompile"/> throws <see cref="ObjectDisposedException"/>.
|
|
/// </summary>
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
Clear();
|
|
}
|
|
|
|
private static void DisposeLazyIfMaterialised(Lazy<ScriptEvaluator<TContext, TResult>> lazy)
|
|
{
|
|
// IsValueCreated is false for a faulted Lazy too, so the catch in GetOrCompile
|
|
// has already taken care of failed compiles — there's no evaluator to dispose.
|
|
if (!lazy.IsValueCreated) return;
|
|
try
|
|
{
|
|
lazy.Value.Dispose();
|
|
}
|
|
catch
|
|
{
|
|
// Dispose is best-effort here: an evaluator disposal failure would leak its
|
|
// ALC but mustn't prevent the rest of the cache from clearing. The ALC
|
|
// unload itself is exception-free in practice; this is defensive.
|
|
}
|
|
}
|
|
|
|
private static string HashSource(string source)
|
|
{
|
|
var bytes = Encoding.UTF8.GetBytes(source);
|
|
var hash = SHA256.HashData(bytes);
|
|
return Convert.ToHexString(hash);
|
|
}
|
|
}
|