using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
///
/// 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 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.
///
///
///
/// Concurrent-safe: of
/// means a miss on two threads compiles exactly once.
/// guarantees other
/// threads block on the in-flight compile rather than racing to duplicate work.
///
///
/// 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.
///
///
/// 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.
///
///
/// Lifecycle: compiled scripts hold a collectible
/// per evaluator
/// (Core.Scripting-008 fix). disposes every materialised
/// evaluator before dropping its dictionary entry so the emitted assemblies are
/// eligible for GC immediately after a publish. drops the
/// cache itself for graceful server shutdown.
///
///
public sealed class CompiledScriptCache : IDisposable
where TContext : ScriptContext
{
private readonly ConcurrentDictionary>> _cache = new();
private bool _disposed;
///
/// Return the compiled evaluator for , 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).
///
/// The source code to compile or retrieve from cache.
/// The compiled script evaluator.
public ScriptEvaluator GetOrCompile(string scriptSource)
{
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
if (_disposed) throw new ObjectDisposedException(nameof(CompiledScriptCache));
var key = HashSource(scriptSource);
var lazy = _cache.GetOrAdd(key, _ => new Lazy>(
() => ScriptEvaluator.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>>(key, lazy));
throw;
}
}
/// Current entry count. Exposed for Admin UI diagnostics / tests.
public int Count => _cache.Count;
///
/// Drop every cached compile. Used on config generation publish + tests.
/// Disposes each materialised evaluator before removing it so its collectible
/// unloads and the
/// emitted script assembly becomes eligible for GC (Core.Scripting-008).
///
///
/// Safe to call after — the operation is idempotent.
/// sets _disposed = true before invoking this
/// method (so callers see the post-Dispose guard on ),
/// 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.)
///
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);
}
}
/// True when the exact source has been compiled at least once + is still cached.
/// The source code to check for in the cache.
/// True if the source is cached, false otherwise.
public bool Contains(string scriptSource)
=> _cache.ContainsKey(HashSource(scriptSource));
///
/// Drop the cache and dispose every materialised evaluator. After disposal
/// throws .
///
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Clear();
}
private static void DisposeLazyIfMaterialised(Lazy> 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);
}
}