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); } }