fix(site-runtime): resolve SiteRuntime-012,013,015,016 — doc accuracy, shared LoggerFactory, execution-actor coverage; SiteRuntime-014 deferred
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 5 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -521,7 +521,7 @@ literal/identifier non-detection, allowed-exception resolution); all 39 existing
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs:28` |
|
| Location | `src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs:28` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -543,7 +543,18 @@ internal-only and exposing only the async API.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (`pending commit`): re-triaged against the current source — the
|
||||||
|
finding's own recommendation states the blocking is *acceptable* once SiteRuntime-009's
|
||||||
|
dedicated script dispatcher exists, and SiteRuntime-009 is now Resolved
|
||||||
|
(`ScriptExecutionActor`/`AlarmExecutionActor` run script bodies on the dedicated
|
||||||
|
`ScriptExecutionScheduler` threads, confirmed in `ScriptExecutionActor.cs:74`). A
|
||||||
|
blocked accessor therefore can no longer starve the shared `ThreadPool` or Akka
|
||||||
|
dispatchers — only a dedicated script thread. The remaining defect was the misleading
|
||||||
|
class XML comment, which only said "Reads block on the actor Ask" with no thread-model
|
||||||
|
context. The `AttributeAccessor` XML doc now documents the dispatcher containment
|
||||||
|
(SiteRuntime-009) explicitly and still steers authors to the async `GetAsync`/`SetAsync`
|
||||||
|
variants. No behavioural change — this is a documentation finding; existing
|
||||||
|
`ScopeAccessorTests` continue to pass.
|
||||||
|
|
||||||
### SiteRuntime-013 — `HandleUnsubscribeDebugView` does nothing despite documented behaviour
|
### SiteRuntime-013 — `HandleUnsubscribeDebugView` does nothing despite documented behaviour
|
||||||
|
|
||||||
@@ -551,7 +562,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Documentation & comments |
|
| Category | Documentation & comments |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs:414` |
|
| Location | `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs:414` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -573,7 +584,18 @@ comment to state explicitly that subscription teardown is handled by
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (`pending commit`): root cause confirmed — the Instance Actor
|
||||||
|
holds no per-subscriber state, so `HandleUnsubscribeDebugView` genuinely has nothing to
|
||||||
|
remove; the real debug-stream subscription lifecycle lives in `SiteStreamManager`
|
||||||
|
(Subscribe/Unsubscribe/RemoveSubscriber). The recommendation's "correct the XML comment"
|
||||||
|
option was taken (removing the handler would still leave `UnsubscribeDebugViewRequest`
|
||||||
|
routed here from `DeploymentManagerActor.RouteDebugViewUnsubscribe`, and the no-op
|
||||||
|
acknowledgement is harmless). The XML doc on `HandleUnsubscribeDebugView` now states
|
||||||
|
explicitly that it is a deliberate no-op acknowledgement and that teardown is handled by
|
||||||
|
`SiteStreamManager`; the log message likewise notes "(no-op; subscription teardown
|
||||||
|
handled by SiteStreamManager)". This is a documentation-only finding with no observable
|
||||||
|
behaviour to regression-test, so no new test was added; the existing
|
||||||
|
`InstanceActor`/debug-view tests continue to pass.
|
||||||
|
|
||||||
### SiteRuntime-014 — Trigger-expression evaluation blocks the coordinator actor thread
|
### SiteRuntime-014 — Trigger-expression evaluation blocks the coordinator actor thread
|
||||||
|
|
||||||
@@ -581,7 +603,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Akka.NET conventions |
|
| Category | Akka.NET conventions |
|
||||||
| Status | Open |
|
| Status | Deferred |
|
||||||
| Location | `src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs:219`, `src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs:389` |
|
| Location | `src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs:219`, `src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs:389` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -605,7 +627,26 @@ without the Roslyn scripting `RunAsync` machinery.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Deferred 2026-05-16 (`pending commit`): root cause confirmed — `EvaluateExpressionTrigger`
|
||||||
|
(ScriptActor) and `EvaluateExpression` (AlarmActor) call
|
||||||
|
`_compiledTriggerExpression.RunAsync(...).GetAwaiter().GetResult()` directly inside the
|
||||||
|
`AttributeValueChanged` handler, on the coordinator actor's default (thread-pool-backed)
|
||||||
|
dispatcher, blocking the mailbox for up to the 2 s timeout. Re-triaged from Open to
|
||||||
|
**Deferred** rather than fixed: neither recommended fix stays cleanly in-module without
|
||||||
|
a design decision. (a) **Off-thread eval + pipe-back** changes the actor's concurrency
|
||||||
|
model — the evaluation carries edge-tracking state (`_lastExpressionResult`) and a
|
||||||
|
mutable `_attributeSnapshot`; multiple `AttributeValueChanged` messages can arrive while
|
||||||
|
an evaluation is in flight, so a correct fix must decide overlapping-evaluation
|
||||||
|
semantics (coalesce / serialize / drop) and the snapshot-coherence contract — a behaviour
|
||||||
|
change to the trigger model. (b) **Pre-compile to a plain delegate** would require
|
||||||
|
changing the compilation contract: the trigger expression is produced as a Roslyn
|
||||||
|
`Script<object?>` by `ScriptCompilationService.CompileTriggerExpression`, which is also
|
||||||
|
the security boundary (SiteRuntime-011 trust validation); swapping the artifact type is
|
||||||
|
a cross-component change touching the Template Engine / Deployment Manager compile
|
||||||
|
pipeline. Given Low severity, a bounded 2 s worst case, and the inline note that trigger
|
||||||
|
expressions are trusted, compile-checked, and expected to be cheap, this is left
|
||||||
|
Deferred pending a design decision on trigger-evaluation concurrency rather than forcing
|
||||||
|
an out-of-scope or messaging-contract-changing fix.
|
||||||
|
|
||||||
### SiteRuntime-015 — `LoggerFactory` created per Instance Actor and never disposed
|
### SiteRuntime-015 — `LoggerFactory` created per Instance Actor and never disposed
|
||||||
|
|
||||||
@@ -613,7 +654,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Performance & resource management |
|
| Category | Performance & resource management |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs:746` |
|
| Location | `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs:746` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -634,7 +675,18 @@ up per child. Do not create a fresh `LoggerFactory` in a hot creation path.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (`pending commit`): root cause confirmed — `CreateInstanceActor`
|
||||||
|
did `new LoggerFactory()` per Instance Actor, never disposed, and detached from the
|
||||||
|
host's logging providers. `DeploymentManagerActor` now holds a single `_loggerFactory`
|
||||||
|
field, resolved once in the constructor from (in order) a new optional `ILoggerFactory`
|
||||||
|
constructor parameter, the injected `IServiceProvider`, or `NullLoggerFactory.Instance`
|
||||||
|
as a last resort — never a per-instance allocation. `CreateInstanceActor` mints the
|
||||||
|
`InstanceActor` logger from this shared factory, so loggers are routed through the
|
||||||
|
application's configured providers and no factory leaks. Regression test:
|
||||||
|
`DeploymentManagerLoggerFactoryTests.CreateInstanceActor_ReusesInjectedLoggerFactory_ForEveryInstance`
|
||||||
|
injects a counting `ILoggerFactory` and asserts it is used once per created Instance
|
||||||
|
Actor — confirmed to fail (0 calls) against the pre-fix `new LoggerFactory()` code and
|
||||||
|
pass after the fix.
|
||||||
|
|
||||||
### SiteRuntime-016 — Short-lived execution actors, replication actor, and repositories are untested
|
### SiteRuntime-016 — Short-lived execution actors, replication actor, and repositories are untested
|
||||||
|
|
||||||
@@ -642,7 +694,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Testing coverage |
|
| Category | Testing coverage |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `tests/ScadaLink.SiteRuntime.Tests/` |
|
| Location | `tests/ScadaLink.SiteRuntime.Tests/` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -666,4 +718,18 @@ synthetic-ID lookup, missing-row behaviour).
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (`pending commit`): re-triaged against the current test sources —
|
||||||
|
`SiteExternalSystemRepository` and `SiteNotificationRepository` are already covered by
|
||||||
|
`Repositories/SiteRepositoryTests.cs` (added when SiteRuntime-006/007 were resolved:
|
||||||
|
round-trip read and synthetic-ID-stable-across-restart). The execution-actor gap is now
|
||||||
|
closed by a new `Actors/ExecutionActorTests.cs` — six tests covering
|
||||||
|
`ScriptExecutionActor` (success → `ScriptCallResult` reply + PoisonPill self-stop;
|
||||||
|
script-throws → failure reply + stop; cooperative timeout → failure reply + stop;
|
||||||
|
no-`replyTo` fire-and-forget still self-stops) and `AlarmExecutionActor` (success →
|
||||||
|
self-stop; on-trigger throws → still self-stops). `SiteReplicationActor` is *not* covered
|
||||||
|
here: it depends on `Cluster.Get(Context.System)` and so requires a clustered
|
||||||
|
`ActorSystem` HOCON harness that does not yet exist in this test project — adding that
|
||||||
|
harness is a larger test-infrastructure task tracked separately and out of scope for a
|
||||||
|
Low-severity coverage finding; the highest-value untested paths the finding called out
|
||||||
|
(script timeout/failure/reply/self-stop) are now covered. Full module suite: 192 tests
|
||||||
|
green.
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
private readonly SiteStreamManager? _streamManager;
|
private readonly SiteStreamManager? _streamManager;
|
||||||
private readonly SiteRuntimeOptions _options;
|
private readonly SiteRuntimeOptions _options;
|
||||||
private readonly ILogger<DeploymentManagerActor> _logger;
|
private readonly ILogger<DeploymentManagerActor> _logger;
|
||||||
|
/// <summary>
|
||||||
|
/// Shared logger factory used to mint <see cref="InstanceActor"/> loggers
|
||||||
|
/// (SiteRuntime-015). Reused across every <see cref="CreateInstanceActor"/>
|
||||||
|
/// call rather than newing a per-instance factory that is never disposed.
|
||||||
|
/// When the host injects its configured factory the Instance Actor logs are
|
||||||
|
/// routed through the application's logging providers.
|
||||||
|
/// </summary>
|
||||||
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
private readonly IActorRef? _dclManager;
|
private readonly IActorRef? _dclManager;
|
||||||
private readonly IActorRef? _replicationActor;
|
private readonly IActorRef? _replicationActor;
|
||||||
private readonly ISiteHealthCollector? _healthCollector;
|
private readonly ISiteHealthCollector? _healthCollector;
|
||||||
@@ -59,7 +67,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
IActorRef? dclManager = null,
|
IActorRef? dclManager = null,
|
||||||
IActorRef? replicationActor = null,
|
IActorRef? replicationActor = null,
|
||||||
ISiteHealthCollector? healthCollector = null,
|
ISiteHealthCollector? healthCollector = null,
|
||||||
IServiceProvider? serviceProvider = null)
|
IServiceProvider? serviceProvider = null,
|
||||||
|
ILoggerFactory? loggerFactory = null)
|
||||||
{
|
{
|
||||||
_storage = storage;
|
_storage = storage;
|
||||||
_compilationService = compilationService;
|
_compilationService = compilationService;
|
||||||
@@ -71,6 +80,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
_healthCollector = healthCollector;
|
_healthCollector = healthCollector;
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
// SiteRuntime-015: reuse a single logger factory for all Instance Actors.
|
||||||
|
// Prefer an explicitly injected factory, fall back to one resolved from
|
||||||
|
// the service provider, and only as a last resort use NullLoggerFactory —
|
||||||
|
// never a per-instance `new LoggerFactory()` that leaks undisposed.
|
||||||
|
_loggerFactory = loggerFactory
|
||||||
|
?? serviceProvider?.GetService(typeof(ILoggerFactory)) as ILoggerFactory
|
||||||
|
?? Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance;
|
||||||
|
|
||||||
// Lifecycle commands
|
// Lifecycle commands
|
||||||
Receive<DeployInstanceCommand>(HandleDeploy);
|
Receive<DeployInstanceCommand>(HandleDeploy);
|
||||||
@@ -942,7 +958,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var loggerFactory = new LoggerFactory();
|
// SiteRuntime-015: reuse the shared, host-configured logger factory
|
||||||
|
// instead of allocating (and leaking) a fresh LoggerFactory per instance.
|
||||||
var props = Props.Create(() => new InstanceActor(
|
var props = Props.Create(() => new InstanceActor(
|
||||||
instanceName,
|
instanceName,
|
||||||
configJson,
|
configJson,
|
||||||
@@ -951,7 +968,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
_sharedScriptLibrary,
|
_sharedScriptLibrary,
|
||||||
_streamManager,
|
_streamManager,
|
||||||
_options,
|
_options,
|
||||||
loggerFactory.CreateLogger<InstanceActor>(),
|
_loggerFactory.CreateLogger<InstanceActor>(),
|
||||||
_dclManager,
|
_dclManager,
|
||||||
_healthCollector,
|
_healthCollector,
|
||||||
_serviceProvider));
|
_serviceProvider));
|
||||||
|
|||||||
@@ -494,12 +494,19 @@ public class InstanceActor : ReceiveActor
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// WP-25: Debug view unsubscribe — removes subscription.
|
/// WP-25: Debug view unsubscribe (SiteRuntime-013).
|
||||||
|
/// This handler is a deliberate no-op acknowledgement: the Instance Actor holds
|
||||||
|
/// no per-subscriber state. The real debug-stream subscription lifecycle lives in
|
||||||
|
/// <see cref="ScadaLink.SiteRuntime.Streaming.SiteStreamManager"/>
|
||||||
|
/// (Subscribe / Unsubscribe / RemoveSubscriber); the gRPC stream is torn down
|
||||||
|
/// there when the central side cancels the call. Nothing is removed here.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void HandleUnsubscribeDebugView(UnsubscribeDebugViewRequest request)
|
private void HandleUnsubscribeDebugView(UnsubscribeDebugViewRequest request)
|
||||||
{
|
{
|
||||||
|
// No subscription state in the Instance Actor — see the XML doc above.
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Debug view unsubscribe for {Instance}, correlationId={Id}",
|
"Debug view unsubscribe for {Instance}, correlationId={Id} " +
|
||||||
|
"(no-op; subscription teardown handled by SiteStreamManager)",
|
||||||
_instanceUniqueName, request.CorrelationId);
|
_instanceUniqueName, request.CorrelationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,18 @@ namespace ScadaLink.SiteRuntime.Scripts;
|
|||||||
/// Scope-aware view onto the instance's attributes, anchored at a path prefix.
|
/// Scope-aware view onto the instance's attributes, anchored at a path prefix.
|
||||||
/// <c>Attributes["X"]</c> on the root scope resolves to canonical name "X";
|
/// <c>Attributes["X"]</c> on the root scope resolves to canonical name "X";
|
||||||
/// on a composition with prefix "TempSensor" it resolves to "TempSensor.X".
|
/// on a composition with prefix "TempSensor" it resolves to "TempSensor.X".
|
||||||
/// Reads block on the actor Ask; async variants are provided for callers
|
///
|
||||||
/// that prefer to await explicitly.
|
/// <para>
|
||||||
|
/// Thread-model note (SiteRuntime-012): the indexer get/set block synchronously
|
||||||
|
/// on the Instance Actor Ask (and, for data-connected attributes, the DCL
|
||||||
|
/// round-trip). This is safe because script bodies execute on the dedicated
|
||||||
|
/// <see cref="ScriptExecutionScheduler"/> threads (SiteRuntime-009), not the
|
||||||
|
/// shared <see cref="System.Threading.ThreadPool"/> — so a blocked accessor
|
||||||
|
/// cannot starve unrelated Akka dispatchers or HTTP request handling. The async
|
||||||
|
/// variants (<see cref="GetAsync"/>/<see cref="SetAsync"/>) are still preferred
|
||||||
|
/// where the script can await, as they avoid holding a dedicated thread idle for
|
||||||
|
/// the duration of each round-trip.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AttributeAccessor
|
public class AttributeAccessor
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ScadaLink.Commons.Messages.Deployment;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.Commons.Types.Flattening;
|
||||||
|
using ScadaLink.SiteRuntime.Actors;
|
||||||
|
using ScadaLink.SiteRuntime.Persistence;
|
||||||
|
using ScadaLink.SiteRuntime.Scripts;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ScadaLink.SiteRuntime.Tests.Actors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression test for SiteRuntime-015 — <see cref="DeploymentManagerActor"/> must
|
||||||
|
/// reuse a single, injected <see cref="ILoggerFactory"/> for every Instance Actor it
|
||||||
|
/// creates rather than newing (and leaking) a fresh <see cref="LoggerFactory"/> per
|
||||||
|
/// instance.
|
||||||
|
/// </summary>
|
||||||
|
public class DeploymentManagerLoggerFactoryTests : TestKit, IDisposable
|
||||||
|
{
|
||||||
|
private readonly SiteStorageService _storage;
|
||||||
|
private readonly ScriptCompilationService _compilationService;
|
||||||
|
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||||
|
private readonly string _dbFile;
|
||||||
|
|
||||||
|
public DeploymentManagerLoggerFactoryTests()
|
||||||
|
{
|
||||||
|
_dbFile = Path.Combine(Path.GetTempPath(), $"dm-loggerfactory-test-{Guid.NewGuid():N}.db");
|
||||||
|
_storage = new SiteStorageService(
|
||||||
|
$"Data Source={_dbFile}",
|
||||||
|
NullLogger<SiteStorageService>.Instance);
|
||||||
|
_storage.InitializeAsync().GetAwaiter().GetResult();
|
||||||
|
_compilationService = new ScriptCompilationService(
|
||||||
|
NullLogger<ScriptCompilationService>.Instance);
|
||||||
|
_sharedScriptLibrary = new SharedScriptLibrary(
|
||||||
|
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
void IDisposable.Dispose()
|
||||||
|
{
|
||||||
|
Shutdown();
|
||||||
|
try { File.Delete(_dbFile); } catch { /* cleanup */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MakeConfigJson(string instanceName)
|
||||||
|
{
|
||||||
|
var config = new FlattenedConfiguration
|
||||||
|
{
|
||||||
|
InstanceUniqueName = instanceName,
|
||||||
|
Attributes =
|
||||||
|
[
|
||||||
|
new ResolvedAttribute { CanonicalName = "TestAttr", Value = "1", DataType = "Int32" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
return JsonSerializer.Serialize(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Counts <see cref="ILoggerFactory.CreateLogger"/> calls and records whether
|
||||||
|
/// the factory was disposed. A passing test proves the single injected factory
|
||||||
|
/// is the one used for every Instance Actor.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class CountingLoggerFactory : ILoggerFactory
|
||||||
|
{
|
||||||
|
public int CreateLoggerCalls;
|
||||||
|
public bool Disposed;
|
||||||
|
|
||||||
|
public ILogger CreateLogger(string categoryName)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref CreateLoggerCalls);
|
||||||
|
return NullLogger.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddProvider(ILoggerProvider provider) { }
|
||||||
|
public void Dispose() => Disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateInstanceActor_ReusesInjectedLoggerFactory_ForEveryInstance()
|
||||||
|
{
|
||||||
|
// Pre-populate several enabled instances so startup creates multiple
|
||||||
|
// Instance Actors.
|
||||||
|
const int instanceCount = 6;
|
||||||
|
for (int i = 0; i < instanceCount; i++)
|
||||||
|
{
|
||||||
|
var name = $"Inst{i}";
|
||||||
|
await _storage.StoreDeployedConfigAsync(name, MakeConfigJson(name), $"d{i}", $"h{i}", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
var loggerFactory = new CountingLoggerFactory();
|
||||||
|
|
||||||
|
var actor = ActorOf(Props.Create(() => new DeploymentManagerActor(
|
||||||
|
_storage,
|
||||||
|
_compilationService,
|
||||||
|
_sharedScriptLibrary,
|
||||||
|
null,
|
||||||
|
new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 5 },
|
||||||
|
NullLogger<DeploymentManagerActor>.Instance,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
loggerFactory)));
|
||||||
|
|
||||||
|
// Allow async startup (load configs + staggered creation).
|
||||||
|
await Task.Delay(2000);
|
||||||
|
|
||||||
|
// Every Instance Actor logger must come from the single injected factory.
|
||||||
|
// Before the fix, each CreateInstanceActor allocated its own LoggerFactory,
|
||||||
|
// so the injected factory would never be touched (CreateLoggerCalls == 0).
|
||||||
|
Assert.Equal(instanceCount, loggerFactory.CreateLoggerCalls);
|
||||||
|
}
|
||||||
|
}
|
||||||
171
tests/ScadaLink.SiteRuntime.Tests/Actors/ExecutionActorTests.cs
Normal file
171
tests/ScadaLink.SiteRuntime.Tests/Actors/ExecutionActorTests.cs
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||||
|
using Microsoft.CodeAnalysis.Scripting;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ScadaLink.Commons.Messages.ScriptExecution;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.Commons.Types.Scripts;
|
||||||
|
using ScadaLink.SiteRuntime.Actors;
|
||||||
|
using ScadaLink.SiteRuntime.Scripts;
|
||||||
|
|
||||||
|
namespace ScadaLink.SiteRuntime.Tests.Actors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression coverage for SiteRuntime-016 — the short-lived execution actors
|
||||||
|
/// (<see cref="ScriptExecutionActor"/>, <see cref="AlarmExecutionActor"/>) were
|
||||||
|
/// previously untested. Covers success, exception, timeout, Ask-reply, and the
|
||||||
|
/// PoisonPill self-stop after completion.
|
||||||
|
/// </summary>
|
||||||
|
public class ExecutionActorTests : TestKit, IDisposable
|
||||||
|
{
|
||||||
|
private readonly SharedScriptLibrary _sharedLibrary;
|
||||||
|
private readonly ScriptCompilationService _compilationService;
|
||||||
|
|
||||||
|
public ExecutionActorTests()
|
||||||
|
{
|
||||||
|
_compilationService = new ScriptCompilationService(
|
||||||
|
NullLogger<ScriptCompilationService>.Instance);
|
||||||
|
_sharedLibrary = new SharedScriptLibrary(
|
||||||
|
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
void IDisposable.Dispose() => Shutdown();
|
||||||
|
|
||||||
|
private static Script<object?> CompileScript(string code)
|
||||||
|
{
|
||||||
|
var scriptOptions = ScriptOptions.Default
|
||||||
|
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly)
|
||||||
|
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
|
||||||
|
var script = CSharpScript.Create<object?>(code, scriptOptions, typeof(ScriptGlobals));
|
||||||
|
script.Compile();
|
||||||
|
return script;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SiteRuntimeOptions Options(int timeoutSeconds = 30)
|
||||||
|
=> new() { MaxScriptCallDepth = 10, ScriptExecutionTimeoutSeconds = timeoutSeconds };
|
||||||
|
|
||||||
|
// ── ScriptExecutionActor ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScriptExecutionActor_Success_RepliesWithResultAndStops()
|
||||||
|
{
|
||||||
|
var compiled = CompileScript("return 7 * 6;");
|
||||||
|
var replyTo = CreateTestProbe();
|
||||||
|
var instanceActor = CreateTestProbe();
|
||||||
|
|
||||||
|
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
|
||||||
|
"Answer", "Inst1", compiled, null, 0,
|
||||||
|
instanceActor.Ref, _sharedLibrary, Options(),
|
||||||
|
replyTo.Ref, "corr-1", NullLogger.Instance,
|
||||||
|
ScriptScope.Root, null, null)));
|
||||||
|
|
||||||
|
Watch(exec);
|
||||||
|
|
||||||
|
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Equal("corr-1", result.CorrelationId);
|
||||||
|
Assert.Equal(42, result.ReturnValue);
|
||||||
|
|
||||||
|
// The actor must PoisonPill itself once execution completes.
|
||||||
|
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScriptExecutionActor_ScriptThrows_RepliesFailureAndStops()
|
||||||
|
{
|
||||||
|
var compiled = CompileScript("throw new InvalidOperationException(\"boom\");");
|
||||||
|
var replyTo = CreateTestProbe();
|
||||||
|
var instanceActor = CreateTestProbe();
|
||||||
|
|
||||||
|
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
|
||||||
|
"Bad", "Inst1", compiled, null, 0,
|
||||||
|
instanceActor.Ref, _sharedLibrary, Options(),
|
||||||
|
replyTo.Ref, "corr-2", NullLogger.Instance,
|
||||||
|
ScriptScope.Root, null, null)));
|
||||||
|
|
||||||
|
Watch(exec);
|
||||||
|
|
||||||
|
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal("corr-2", result.CorrelationId);
|
||||||
|
Assert.Contains("boom", result.ErrorMessage);
|
||||||
|
|
||||||
|
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScriptExecutionActor_Timeout_RepliesFailureAndStops()
|
||||||
|
{
|
||||||
|
// A long busy loop that observes the cancellation token so the
|
||||||
|
// 1-second timeout fires cooperatively.
|
||||||
|
var compiled = CompileScript(
|
||||||
|
"while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
|
||||||
|
var replyTo = CreateTestProbe();
|
||||||
|
var instanceActor = CreateTestProbe();
|
||||||
|
|
||||||
|
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
|
||||||
|
"Slow", "Inst1", compiled, null, 0,
|
||||||
|
instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1),
|
||||||
|
replyTo.Ref, "corr-3", NullLogger.Instance,
|
||||||
|
ScriptScope.Root, null, null)));
|
||||||
|
|
||||||
|
Watch(exec);
|
||||||
|
|
||||||
|
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Contains("timed out", result.ErrorMessage);
|
||||||
|
|
||||||
|
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScriptExecutionActor_NoReplyTo_StillStopsAfterCompletion()
|
||||||
|
{
|
||||||
|
var compiled = CompileScript("return 1;");
|
||||||
|
var instanceActor = CreateTestProbe();
|
||||||
|
|
||||||
|
// ActorRefs.Nobody as replyTo — fire-and-forget execution.
|
||||||
|
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
|
||||||
|
"FireForget", "Inst1", compiled, null, 0,
|
||||||
|
instanceActor.Ref, _sharedLibrary, Options(),
|
||||||
|
ActorRefs.Nobody, "corr-4", NullLogger.Instance,
|
||||||
|
ScriptScope.Root, null, null)));
|
||||||
|
|
||||||
|
Watch(exec);
|
||||||
|
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AlarmExecutionActor ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AlarmExecutionActor_Success_StopsAfterCompletion()
|
||||||
|
{
|
||||||
|
var compiled = CompileScript("return 0;");
|
||||||
|
var instanceActor = CreateTestProbe();
|
||||||
|
|
||||||
|
var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
|
||||||
|
"HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
|
||||||
|
compiled, instanceActor.Ref, _sharedLibrary, Options(),
|
||||||
|
NullLogger.Instance)));
|
||||||
|
|
||||||
|
Watch(exec);
|
||||||
|
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AlarmExecutionActor_ScriptThrows_StillStops()
|
||||||
|
{
|
||||||
|
var compiled = CompileScript("throw new System.Exception(\"alarm-boom\");");
|
||||||
|
var instanceActor = CreateTestProbe();
|
||||||
|
|
||||||
|
var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
|
||||||
|
"HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
|
||||||
|
compiled, instanceActor.Ref, _sharedLibrary, Options(),
|
||||||
|
NullLogger.Instance)));
|
||||||
|
|
||||||
|
Watch(exec);
|
||||||
|
// Even on a throwing on-trigger body, the actor must self-stop.
|
||||||
|
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user