fix(inbound): single-flight method compile so concurrent first callers share one assembly (review 2026-10-21 05/M1)
This commit is contained in:
@@ -40,6 +40,9 @@ public class InboundScriptExecutor
|
||||
// not safe for concurrent read/write, so a ConcurrentDictionary is used throughout.
|
||||
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)
|
||||
// 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
|
||||
/// <returns>True if successfully compiled and registered, false otherwise.</returns>
|
||||
public bool CompileAndRegister(ApiMethod method, out IReadOnlyList<string> 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);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
_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.
|
||||
|
||||
Reference in New Issue
Block a user