diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs index c07829bc..f18ff59a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs @@ -40,6 +40,9 @@ public class InboundScriptExecutor // not safe for concurrent read/write, so a ConcurrentDictionary is used throughout. private readonly ConcurrentDictionary _scriptHandlers = new(); + // In-flight compiles keyed by method name + script text (see CompileSingleFlight). + private readonly ConcurrentDictionary>? Handler, IReadOnlyList Errors)>> _inFlightCompiles = new(); + // A script that fails to compile (or violates the trust model) // is recorded here — keyed by method name, valued by the exact script text // that failed — so it is compiled at most once. Without this, every subsequent @@ -188,7 +191,7 @@ public class InboundScriptExecutor /// True if successfully compiled and registered, false otherwise. public bool CompileAndRegister(ApiMethod method, out IReadOnlyList errors) { - var (handler, compileErrors) = Compile(method); + var (handler, compileErrors) = CompileSingleFlight(method); if (handler == null) { // Record the failure so the lazy-compile path does not @@ -207,6 +210,28 @@ public class InboundScriptExecutor return Register(method, handler); } + /// + /// Single-flight wrapper over , keyed by method name + script + /// revision: N concurrent first-callers of a new/changed method share ONE compile (and + /// one non-collectible loaded assembly) instead of each running Roslyn. The entry is + /// removed as soon as the compile finishes, so a later edit recompiles normally. + /// + private (Func>? Handler, IReadOnlyList Errors) CompileSingleFlight(ApiMethod method) + { + var key = method.Name + "\0" + (method.Script ?? string.Empty); + var lazy = _inFlightCompiles.GetOrAdd(key, _ => + new Lazy<(Func>?, IReadOnlyList)>( + () => Compile(method), LazyThreadSafetyMode.ExecutionAndPublication)); + try + { + return lazy.Value; + } + finally + { + _inFlightCompiles.TryRemove(new KeyValuePair>?, IReadOnlyList)>>(key, lazy)); + } + } + private bool Register(ApiMethod method, Func> handler) { _scriptHandlers[method.Name] = new CachedHandler(method.Script, handler); @@ -518,7 +543,7 @@ public class InboundScriptExecutor // Lazy compile (handles methods created after startup, and stale/known-bad // recompiles). Compile outside the cache so a failed compile is not stored, // then add so concurrent first-callers share a single handler instance. - var (compiled, _) = Compile(method); + var (compiled, _) = CompileSingleFlight(method); if (compiled == null) { // Cache the failure so the next request short-circuits above. diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs index 091bb383..1570bcd9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs @@ -514,6 +514,37 @@ public class InboundScriptExecutorTests Assert.Empty(errors); } + /// Verifies that concurrent first callers of a new method share a single compile. + /// A task that represents the asynchronous operation. + [Fact] + public async Task ExecuteAsync_ConcurrentFirstCallers_ShareOneCompile() + { + // Review 2026-10-21 05/M1: N concurrent first-callers used to each run Roslyn (N + // non-collectible loaded assemblies for one revision). Single-flight per name+revision. + var logger = new CompileCountingLogger(); + var executor = new InboundScriptExecutor(logger, Substitute.For()); + var method = new ApiMethod("burst", "var x = 0; for (int i = 0; i < 10; i++) x += i; return x;") { Id = 1, TimeoutSeconds = 30 }; + + var results = await Task.WhenAll(Enumerable.Range(0, 12).Select(_ => Task.Run(() => + executor.ExecuteAsync(method, new Dictionary(), _route, TimeSpan.FromSeconds(30))))); + + Assert.All(results, r => Assert.True(r.Success, r.ErrorMessage)); + Assert.Equal(1, logger.CompileCount); + } + + private sealed class CompileCountingLogger : ILogger + { + private int _count; + public int CompileCount => _count; + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (formatter(state, exception).EndsWith("script compiled", StringComparison.Ordinal)) + Interlocked.Increment(ref _count); + } + } + /// Verifies that executing after a non-compiling update surfaces the compile failure. /// A task that represents the asynchronous operation. [Fact]