fix(inbound): single-flight method compile so concurrent first callers share one assembly (review 2026-10-21 05/M1)

This commit is contained in:
Joseph Doherty
2026-10-22 08:28:34 -04:00
parent 37c25db2f1
commit d7e87c0688
2 changed files with 58 additions and 2 deletions
@@ -40,6 +40,9 @@ public class InboundScriptExecutor
// not safe for concurrent read/write, so a ConcurrentDictionary is used throughout. // not safe for concurrent read/write, so a ConcurrentDictionary is used throughout.
private readonly ConcurrentDictionary<string, CachedHandler> _scriptHandlers = new(); private readonly ConcurrentDictionary<string, CachedHandler> _scriptHandlers = new();
// In-flight compiles keyed by method name + script text (see CompileSingleFlight).
private readonly ConcurrentDictionary<string, Lazy<(Func<InboundScriptContext, Task<object?>>? Handler, IReadOnlyList<string> Errors)>> _inFlightCompiles = new();
// A script that fails to compile (or violates the trust model) // A script that fails to compile (or violates the trust model)
// is recorded here — keyed by method name, valued by the exact script text // 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 // that failed — so it is compiled at most once. Without this, every subsequent
@@ -188,7 +191,7 @@ public class InboundScriptExecutor
/// <returns>True if successfully compiled and registered, false otherwise.</returns> /// <returns>True if successfully compiled and registered, false otherwise.</returns>
public bool CompileAndRegister(ApiMethod method, out IReadOnlyList<string> errors) public bool CompileAndRegister(ApiMethod method, out IReadOnlyList<string> errors)
{ {
var (handler, compileErrors) = Compile(method); var (handler, compileErrors) = CompileSingleFlight(method);
if (handler == null) if (handler == null)
{ {
// Record the failure so the lazy-compile path does not // Record the failure so the lazy-compile path does not
@@ -207,6 +210,28 @@ public class InboundScriptExecutor
return Register(method, handler); return Register(method, handler);
} }
/// <summary>
/// Single-flight wrapper over <see cref="Compile"/>, 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.
/// </summary>
private (Func<InboundScriptContext, Task<object?>>? Handler, IReadOnlyList<string> Errors) CompileSingleFlight(ApiMethod method)
{
var key = method.Name + "\0" + (method.Script ?? string.Empty);
var lazy = _inFlightCompiles.GetOrAdd(key, _ =>
new Lazy<(Func<InboundScriptContext, Task<object?>>?, IReadOnlyList<string>)>(
() => Compile(method), LazyThreadSafetyMode.ExecutionAndPublication));
try
{
return lazy.Value;
}
finally
{
_inFlightCompiles.TryRemove(new KeyValuePair<string, Lazy<(Func<InboundScriptContext, Task<object?>>?, IReadOnlyList<string>)>>(key, lazy));
}
}
private bool Register(ApiMethod method, Func<InboundScriptContext, Task<object?>> handler) private bool Register(ApiMethod method, Func<InboundScriptContext, Task<object?>> handler)
{ {
_scriptHandlers[method.Name] = new CachedHandler(method.Script, 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 // Lazy compile (handles methods created after startup, and stale/known-bad
// recompiles). Compile outside the cache so a failed compile is not stored, // recompiles). Compile outside the cache so a failed compile is not stored,
// then add so concurrent first-callers share a single handler instance. // then add so concurrent first-callers share a single handler instance.
var (compiled, _) = Compile(method); var (compiled, _) = CompileSingleFlight(method);
if (compiled == null) if (compiled == null)
{ {
// Cache the failure so the next request short-circuits above. // Cache the failure so the next request short-circuits above.
@@ -514,6 +514,37 @@ public class InboundScriptExecutorTests
Assert.Empty(errors); Assert.Empty(errors);
} }
/// <summary>Verifies that concurrent first callers of a new method share a single compile.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<IServiceProvider>());
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<string, object?>(), _route, TimeSpan.FromSeconds(30)))));
Assert.All(results, r => Assert.True(r.Success, r.ErrorMessage));
Assert.Equal(1, logger.CompileCount);
}
private sealed class CompileCountingLogger : ILogger<InboundScriptExecutor>
{
private int _count;
public int CompileCount => _count;
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (formatter(state, exception).EndsWith("script compiled", StringComparison.Ordinal))
Interlocked.Increment(ref _count);
}
}
/// <summary>Verifies that executing after a non-compiling update surfaces the compile failure.</summary> /// <summary>Verifies that executing after a non-compiling update surfaces the compile failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]