fix(siteruntime): injectable ScriptExecutionScheduler + un-leakable expression-fault test

Gitea #18: the process-wide ScriptExecutionScheduler was reached via a static
singleton (Shared) directly inside ScriptActor, ScriptExecutionActor, AlarmActor,
AlarmExecutionActor, and ScriptSchedulerStatsReporter, so it could not be
substituted — a shared mutable global for anything running more than one logical
site in a process, most visibly the test assembly. Add an optional scheduler
injection seam to all five: the Host injects nothing and gets the shared pool
(byte-for-byte unchanged), while a test (or a future multi-site host) hands an
actor its own instance. Spawning actors thread their scheduler down to the
execution children they create. Shared() now also recreates a disposed cached
instance rather than returning it (new IsDisposed guard), so a disposed scheduler
can never silently poison every later script/alarm execution.

Gitea #16: ScriptActorTests now runs on its OWN scheduler (disposed at class
teardown), so the faulted-task test can no longer strand a worker on the
process-wide pool and starve unrelated test classes (one prior run failed 22
tests). EvalGate's wait is bounded to 30 s (was unbounded — the actual leak
mechanism) and reset per test; the teardown releases one permit per worker
instead of Release(Entries), which under-released in exactly the failure case.
Full SiteRuntime suite: 6/6 runs green (524/524); was 3/6 failing on clean main.

Fixes: #18
Fixes: #16
This commit was merged in pull request #21.
This commit is contained in:
Joseph Doherty
2026-07-17 15:36:39 -04:00
parent 3e84eee195
commit b1b4874090
9 changed files with 205 additions and 37 deletions
@@ -41,6 +41,15 @@ public class AlarmActor : ReceiveActor
private readonly ISiteHealthCollector? _healthCollector;
private readonly IServiceProvider? _serviceProvider;
/// <summary>
/// Script-execution scheduler seam (#18): the process-wide
/// <see cref="ScriptExecutionScheduler"/> when null, or an injected instance so this
/// alarm's trigger-expression evaluation and spawned on-trigger scripts run on a
/// caller-owned pool. Resolved lazily at each use so the null (host) path is
/// unchanged.
/// </summary>
private readonly ScriptExecutionScheduler? _scheduler;
/// <summary>
/// The optional site operational-event log, resolved once from
/// <see cref="_serviceProvider"/> at construction and cached. The
@@ -125,6 +134,7 @@ public class AlarmActor : ReceiveActor
/// <param name="onTriggerExecutionTimeoutSeconds">The on-trigger script's per-script
/// execution timeout in seconds (from its <see cref="ResolvedScript.ExecutionTimeoutSeconds"/>),
/// or null/non-positive to use the global default.</param>
/// <param name="scheduler">Optional script-execution scheduler override (#18); null uses the process-wide shared scheduler.</param>
public AlarmActor(
string alarmName,
string instanceName,
@@ -139,7 +149,9 @@ public class AlarmActor : ReceiveActor
ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = null,
// Per-script timeout for the on-trigger script (null = global).
int? onTriggerExecutionTimeoutSeconds = null)
int? onTriggerExecutionTimeoutSeconds = null,
// Script-execution scheduler seam (#18); null uses the process-wide shared scheduler.
ScriptExecutionScheduler? scheduler = null)
{
_alarmName = alarmName;
_instanceName = instanceName;
@@ -149,6 +161,7 @@ public class AlarmActor : ReceiveActor
_logger = logger;
_healthCollector = healthCollector;
_serviceProvider = serviceProvider;
_scheduler = scheduler;
// Resolve the optional site event logger once and cache it,
// rather than calling GetService on every alarm transition.
_siteEventLogger = serviceProvider?.GetService<ISiteEventLogger>();
@@ -554,7 +567,7 @@ public class AlarmActor : ReceiveActor
return false;
}
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach,
ScriptExecutionScheduler.Shared(_options)).Unwrap().PipeTo(self,
_scheduler ?? ScriptExecutionScheduler.Shared(_options)).Unwrap().PipeTo(self,
success: r => new ExpressionEvalResult(r),
failure: ex => new ExpressionEvalFailed(ex));
}
@@ -685,7 +698,10 @@ public class AlarmActor : ReceiveActor
// Per-script timeout from the on-trigger script (null = global).
_onTriggerExecutionTimeoutSeconds,
// The firing context's execution id (null today).
parentExecutionId));
parentExecutionId,
// Scheduler seam (#18): share this alarm's scheduler override with the
// spawned on-trigger script body (null = process-wide shared).
_scheduler));
Context.ActorOf(props, executionId);
}
@@ -52,7 +52,10 @@ public class AlarmExecutionActor : ReceiveActor
// alarm on-trigger script. Null or non-positive falls back to the global.
int? executionTimeoutSeconds = null,
// The firing context's execution id (null today).
Guid? parentExecutionId = null)
Guid? parentExecutionId = null,
// Script-execution scheduler seam (#18): the process-wide scheduler by
// default; null selects the shared default.
ScriptExecutionScheduler? scheduler = null)
{
var self = Self;
var parent = Context.Parent;
@@ -61,7 +64,7 @@ public class AlarmExecutionActor : ReceiveActor
alarmName, instanceName, level, priority, message,
compiledScript, instanceActor,
sharedScriptLibrary, options, self, parent, logger,
executionTimeoutSeconds, parentExecutionId);
executionTimeoutSeconds, parentExecutionId, scheduler);
}
private static void ExecuteAlarmScript(
@@ -78,7 +81,8 @@ public class AlarmExecutionActor : ReceiveActor
IActorRef parent,
ILogger logger,
int? executionTimeoutSeconds,
Guid? parentExecutionId)
Guid? parentExecutionId,
ScriptExecutionScheduler? scheduler)
{
// Per-script timeout overrides the global default. A null or
// non-positive per-script value (≤ 0) falls back to the global.
@@ -88,8 +92,9 @@ public class AlarmExecutionActor : ReceiveActor
: options.ScriptExecutionTimeoutSeconds);
// Run the alarm on-trigger body on the dedicated
// script-execution scheduler, not the shared .NET thread pool.
var scheduler = ScriptExecutionScheduler.Shared(options);
// script-execution scheduler, not the shared .NET thread pool. An injected
// scheduler (#18) overrides the process-wide default.
var executionScheduler = scheduler ?? ScriptExecutionScheduler.Shared(options);
_ = Task.Factory.StartNew(async () =>
{
@@ -157,6 +162,6 @@ public class AlarmExecutionActor : ReceiveActor
{
self.Tell(PoisonPill.Instance);
}
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach, scheduler).Unwrap();
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach, executionScheduler).Unwrap();
}
}
@@ -40,6 +40,15 @@ public class ScriptActor : ReceiveActor, IWithTimers
private readonly ISiteHealthCollector? _healthCollector;
private readonly IServiceProvider? _serviceProvider;
/// <summary>
/// Script-execution scheduler seam (#18): the process-wide
/// <see cref="ScriptExecutionScheduler"/> when null, or an injected instance so
/// this actor's trigger-expression evaluation and spawned script bodies run on a
/// caller-owned pool instead of the shared one. Resolved lazily at each use so the
/// null (host) path stays byte-for-byte identical to the previous static call.
/// </summary>
private readonly ScriptExecutionScheduler? _scheduler;
private Script<object?>? _compiledScript;
private ScriptTriggerConfig? _triggerConfig;
private TimeSpan? _minTimeBetweenRuns;
@@ -101,6 +110,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
/// <param name="initialAttributes">Initial attribute snapshot used to seed expression trigger evaluation state.</param>
/// <param name="healthCollector">Optional health metrics collector.</param>
/// <param name="serviceProvider">Optional DI service provider for script execution context services.</param>
/// <param name="scheduler">Optional script-execution scheduler override (#18); null uses the process-wide shared scheduler.</param>
public ScriptActor(
string scriptName,
string instanceName,
@@ -113,7 +123,8 @@ public class ScriptActor : ReceiveActor, IWithTimers
Script<object?>? compiledTriggerExpression = null,
IReadOnlyDictionary<string, object?>? initialAttributes = null,
ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = null)
IServiceProvider? serviceProvider = null,
ScriptExecutionScheduler? scheduler = null)
{
_scriptName = scriptName;
_instanceName = instanceName;
@@ -124,6 +135,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
_logger = logger;
_healthCollector = healthCollector;
_serviceProvider = serviceProvider;
_scheduler = scheduler;
_minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns;
_executionTimeoutSeconds = scriptConfig.ExecutionTimeoutSeconds;
_scope = scriptConfig.Scope;
@@ -313,7 +325,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
return false;
}
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach,
ScriptExecutionScheduler.Shared(_options)).Unwrap().PipeTo(self,
_scheduler ?? ScriptExecutionScheduler.Shared(_options)).Unwrap().PipeTo(self,
success: r => new ExpressionEvalResult(r),
failure: ex => new ExpressionEvalFailed(ex));
}
@@ -480,7 +492,10 @@ public class ScriptActor : ReceiveActor, IWithTimers
// an inbound-API-routed call supplies the inbound request's id.
parentExecutionId,
// Per-script timeout override (null = use global).
_executionTimeoutSeconds));
_executionTimeoutSeconds,
// Scheduler seam (#18): thread this actor's scheduler override down so
// spawned script bodies share the same pool (null = process-wide shared).
_scheduler));
Context.ActorOf(props, executionId);
}
@@ -69,7 +69,12 @@ public class ScriptExecutionActor : ReceiveActor
Guid? parentExecutionId = null,
// Per-script execution timeout override (seconds). Null or
// non-positive falls back to the global ScriptExecutionTimeoutSeconds.
int? executionTimeoutSeconds = null)
int? executionTimeoutSeconds = null,
// Script-execution scheduler seam (#18): the process-wide
// ScriptExecutionScheduler by default; a test (or a future multi-site
// host) can inject its own instance so script bodies never run on the
// shared process-wide pool. Null selects the shared default.
ScriptExecutionScheduler? scheduler = null)
{
// Immediately begin execution
var self = Self;
@@ -79,7 +84,7 @@ public class ScriptExecutionActor : ReceiveActor
scriptName, instanceName, compiledScript, parameters, callDepth,
instanceActor, sharedScriptLibrary, options, replyTo, correlationId,
self, parent, logger, scope, healthCollector, serviceProvider,
parentExecutionId, executionTimeoutSeconds);
parentExecutionId, executionTimeoutSeconds, scheduler);
}
private static void ExecuteScript(
@@ -100,7 +105,8 @@ public class ScriptExecutionActor : ReceiveActor
ISiteHealthCollector? healthCollector,
IServiceProvider? serviceProvider,
Guid? parentExecutionId,
int? executionTimeoutSeconds)
int? executionTimeoutSeconds,
ScriptExecutionScheduler? scheduler)
{
// Per-script timeout overrides the global default. A null or
// non-positive per-script value (≤ 0) falls back to the global.
@@ -111,8 +117,9 @@ public class ScriptExecutionActor : ReceiveActor
// Run the script body on the dedicated script-execution
// scheduler, not the shared .NET thread pool, so blocking script I/O cannot
// starve the global pool and stall Akka dispatchers / HTTP handling.
var scheduler = ScriptExecutionScheduler.Shared(options);
// starve the global pool and stall Akka dispatchers / HTTP handling. An
// injected scheduler (#18) overrides the process-wide default.
var executionScheduler = scheduler ?? ScriptExecutionScheduler.Shared(options);
// Notification Outbox: the site communication actor that Notify.Status queries
// central through. Resolved by actor path so the Notify helper does not need an
@@ -329,6 +336,6 @@ public class ScriptExecutionActor : ReceiveActor
// Stop self after execution completes
self.Tell(PoisonPill.Instance);
}
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach, scheduler).Unwrap();
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach, executionScheduler).Unwrap();
}
}
@@ -32,20 +32,30 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
private static readonly object SharedLock = new();
/// <summary>
/// The process-wide script-execution scheduler. Lazily created on first use with the
/// thread count from <see cref="SiteRuntimeOptions.ScriptExecutionThreadCount"/>; the
/// first caller wins, subsequent calls reuse the existing instance.
/// The process-wide script-execution scheduler, used as the default when no scheduler
/// is injected. Lazily created on first use with the thread count from
/// <see cref="SiteRuntimeOptions.ScriptExecutionThreadCount"/>; the first caller wins,
/// subsequent calls reuse the existing instance.
///
/// If the cached instance has been disposed it is recreated rather than handed back:
/// a disposed scheduler can execute no work, so returning it would silently poison
/// every subsequent script and alarm execution for the rest of the process. Nothing
/// disposes the shared instance today, but the guard keeps that latent failure mode
/// closed regardless of a future shutdown path or a multi-site host.
/// </summary>
/// <param name="options">Site runtime options supplying the thread count for the scheduler.</param>
/// <returns>The process-wide <see cref="ScriptExecutionScheduler"/> instance, creating it on first call.</returns>
/// <returns>A live process-wide <see cref="ScriptExecutionScheduler"/> instance, creating it on first call.</returns>
public static ScriptExecutionScheduler Shared(SiteRuntimeOptions options)
{
if (_shared != null)
return _shared;
var existing = _shared;
if (existing is { IsDisposed: false })
return existing;
lock (SharedLock)
{
return _shared ??= new ScriptExecutionScheduler(options.ScriptExecutionThreadCount);
if (_shared is null || _shared.IsDisposed)
_shared = new ScriptExecutionScheduler(options.ScriptExecutionThreadCount);
return _shared;
}
}
@@ -73,6 +83,10 @@ public sealed class ScriptExecutionScheduler : TaskScheduler, IDisposable
}
}
/// <summary><see langword="true"/> once <see cref="Dispose"/> has run; a disposed scheduler
/// can execute no further work and must not be handed out by <see cref="Shared"/>.</summary>
public bool IsDisposed => Volatile.Read(ref _disposed) != 0;
/// <inheritdoc />
public override int MaximumConcurrencyLevel => _threads.Count;
@@ -24,21 +24,30 @@ public sealed class ScriptSchedulerStatsReporter : BackgroundService
private readonly ILogger<ScriptSchedulerStatsReporter> _logger;
private readonly TimeSpan _pollInterval;
/// <summary>
/// Script-execution scheduler seam (#18): the process-wide scheduler when null, or
/// an injected instance so the reporter samples the same pool the actors run on.
/// </summary>
private readonly ScriptExecutionScheduler? _scheduler;
/// <summary>Initializes a new instance of <see cref="ScriptSchedulerStatsReporter"/>.</summary>
/// <param name="collector">The site health collector that receives the scheduler gauges.</param>
/// <param name="options">Site runtime options supplying the shared scheduler's thread count.</param>
/// <param name="logger">Logger instance.</param>
/// <param name="pollInterval">Poll interval override; defaults to <see cref="DefaultPollInterval"/> (10 s).</param>
/// <param name="scheduler">Optional script-execution scheduler override (#18); null samples the process-wide shared scheduler.</param>
public ScriptSchedulerStatsReporter(
ISiteHealthCollector collector,
SiteRuntimeOptions options,
ILogger<ScriptSchedulerStatsReporter> logger,
TimeSpan? pollInterval = null)
TimeSpan? pollInterval = null,
ScriptExecutionScheduler? scheduler = null)
{
_collector = collector ?? throw new ArgumentNullException(nameof(collector));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_pollInterval = pollInterval ?? DefaultPollInterval;
_scheduler = scheduler;
}
/// <inheritdoc />
@@ -67,7 +76,7 @@ public sealed class ScriptSchedulerStatsReporter : BackgroundService
{
try
{
var scheduler = ScriptExecutionScheduler.Shared(_options);
var scheduler = _scheduler ?? ScriptExecutionScheduler.Shared(_options);
_collector.SetScriptSchedulerStats(
scheduler.QueueDepth,
scheduler.BusyThreadCount,