Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/SelfHealCreatesDclConnectionTests.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

386 lines
17 KiB
C#

using System.Collections.Concurrent;
using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Regression tests for deferred register row <b>85</b> — <b>an Instance Actor started by a
/// self-heal got no DCL connection actors</b>, so every tag it subscribed was answered
/// <c>Unknown connection: X</c> forever.
///
/// <para>
/// <b>The defect.</b> A DCL connection actor is never created from the
/// <c>data_connection_definitions</c> table — that table has no production reader at all. It is
/// created only as a side effect of starting an instance, from the <c>Connections</c> map inside
/// that instance's own flattened config, via <c>EnsureDclConnections</c>. That call used to be
/// each CALLER's job, and only two of the four callers made it: the boot-time batch and
/// <c>ApplyDeployment</c>. The two self-heal starts did not:
/// </para>
/// <list type="bullet">
/// <item><c>StartStoredInstance</c> — the path shared by the startup-reconcile
/// ensure-started command and the sweep.</item>
/// <item><c>HandleEnableResult</c> — enabling an instance that was disabled at boot.</item>
/// </list>
///
/// <para>
/// <b>The live sequence (2026-08-16).</b> A fresh node-a was seeded by LocalDb CDC
/// replication; the sweep correctly started the enabled instances, and then every tag
/// subscribe failed for 6+ minutes with <c>Unknown connection: OPC PLC Simulator</c> /
/// <c>No connection actor for OPC PLC Simulator</c>, because nothing on that node had created the
/// connection actor. A second pair restart healed it — the boot load creates connections, so it
/// papered over the gap.
/// </para>
///
/// <para>
/// <b>The enable path proves this is not a replication-only bug.</b> The boot-time load reads
/// only ENABLED configs, so a node that boots with an instance disabled never creates that
/// instance's connections either; enabling it afterwards hit the identical dead end with no
/// replication anywhere in the picture.
/// </para>
///
/// <para>
/// <b>The fix</b> moves <c>EnsureDclConnections</c> into <c>CreateInstanceActor</c> — already
/// documented as the single Instance Actor creation funnel — so all four paths get it and a
/// fifth path cannot forget. Design note:
/// <c>docs/plans/2026-08-15-instance-sweep-self-heal-design.md</c> §7.
/// </para>
/// </summary>
public class SelfHealCreatesDclConnectionTests : TestKit, IDisposable
{
private const string ConnectionName = "OPC PLC Simulator";
private readonly SiteStorageService _storage;
private readonly ScriptCompilationService _compilationService;
private readonly SharedScriptLibrary _sharedScriptLibrary;
private readonly TestLocalDb _localDb;
/// <summary>
/// Every <see cref="CreateConnectionCommand"/> the Deployment Manager sent to the DCL, in
/// order. This is the exact message whose absence WAS register row 85.
/// </summary>
private readonly ConcurrentQueue<CreateConnectionCommand> _createdConnections = new();
/// <summary>Initializes a new instance of the <c>SelfHealCreatesDclConnectionTests</c> class.</summary>
public SelfHealCreatesDclConnectionTests()
{
_localDb = TestLocalDb.CreateTemp("dcl-selfheal-test");
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
_storage.InitializeAsync().GetAwaiter().GetResult();
_compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
_sharedScriptLibrary = new SharedScriptLibrary(
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
}
/// <inheritdoc />
void IDisposable.Dispose()
{
// Shut the actors down BEFORE the ILocalDb they write through is disposed.
Shutdown();
var path = _localDb.Path;
_localDb.Dispose();
TestLocalDb.DeleteFiles(path);
}
/// <summary>Shortest sweep the (whole-second) option allows.</summary>
private const int SweepSeconds = 1;
/// <summary>Long enough for at least three sweep ticks to have come and gone.</summary>
private static readonly TimeSpan SeveralSweeps = TimeSpan.FromSeconds(4);
/// <summary>
/// Starts a Deployment Manager wired to a recording stand-in for the DCL manager, and blocks
/// until its boot-time config load has PROVABLY read storage (its first instance-count
/// report — see <see cref="DeploymentManagerStartupProbe"/>).
///
/// <para>
/// That gate is load-bearing for the post-boot tests here for the same reason it is in
/// <c>InstanceSweepSelfHealTests</c>: a config row written before the boot load reads storage
/// is picked up by the boot load, which DOES create connections — so an ungated test would
/// pass while proving the boot load works rather than the self-heal path it names.
/// </para>
/// </summary>
private IActorRef CreateBootedDeploymentManager(int sweepSeconds = SweepSeconds)
{
var dclSpy = ActorOf(Props.Create(() => new DclConnectionSpy(_createdConnections)));
var health = new DeploymentManagerStartupProbe();
var dm = ActorOf(Props.Create(() => new DeploymentManagerActor(
_storage,
_compilationService,
_sharedScriptLibrary,
null,
new SiteRuntimeOptions { InstanceSweepIntervalSeconds = sweepSeconds },
NullLogger<DeploymentManagerActor>.Instance,
dclManager: dclSpy,
healthCollector: health,
serviceProvider: null,
loggerFactory: null,
configFetcher: null)));
AwaitCondition(() => health.ReportCount > 0, TimeSpan.FromSeconds(20),
TimeSpan.FromMilliseconds(25));
return dm;
}
/// <summary>
/// A flattened config carrying one data connection — the <c>Connections</c> map is what
/// <c>EnsureDclConnections</c> reads, and it travels inside every instance's own config.
/// </summary>
private static string MakeConfigJson(string instanceName, string endpoint = "opc.tcp://plc:4840") =>
JsonSerializer.Serialize(new FlattenedConfiguration
{
InstanceUniqueName = instanceName,
Attributes =
[
new ResolvedAttribute
{
CanonicalName = "TestAttr", Value = "42", DataType = "Int32"
}
],
Connections = new Dictionary<string, ConnectionConfig>
{
[ConnectionName] = new()
{
Protocol = "OpcUa",
ConfigurationJson = $"{{\"endpointUrl\":\"{endpoint}\"}}",
FailoverRetryCount = 3
}
}
});
private IReadOnlyList<CreateConnectionCommand> CreatesFor(string connectionName) =>
_createdConnections.Where(c => c.ConnectionName == connectionName).ToArray();
private async Task AwaitConnectionCreatedAsync(string connectionName, string because) =>
await AwaitAssertAsync(() =>
{
Assert.True(CreatesFor(connectionName).Count > 0, because);
return Task.CompletedTask;
}, TimeSpan.FromSeconds(20));
// ── The reproduction: a self-heal start must bring its connections with it ──
/// <summary>
/// THE reproduction. The node boots with an empty store, then a config for an ENABLED
/// instance appears in storage on its own — exactly what LocalDb's CDC sync applier does when
/// it seeds a fresh node from its peer. The sweep starts the Instance Actor; the DCL
/// must be told to create the connection that instance's tags subscribe through.
///
/// <para>
/// <b>Fails before the fix</b> at the connection assert: the instance starts (the sweep works)
/// but NO <see cref="CreateConnectionCommand"/> is ever sent, which is the live symptom —
/// the instance is up and every tag subscribe is answered <c>Unknown connection</c>.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SweepStartedInstance_AlsoCreatesItsDclConnection()
{
const string instanceName = "ReplicatedPump";
var dm = CreateBootedDeploymentManager();
// Baseline: the node genuinely booted with nothing.
Assert.Empty(CreatesFor(ConnectionName));
// Replication seeds the row — a bare storage write, the CDC applier's only side effect.
await _storage.StoreDeployedConfigAsync(
instanceName, MakeConfigJson(instanceName), "dep-replicated", "rev-replicated", true);
await AwaitConnectionCreatedAsync(ConnectionName,
"The sweep started a replication-seeded instance but never asked the DCL to create "
+ "its connection — every tag subscribe would be answered 'Unknown connection' "
+ "until the node was restarted again (register row 85)");
}
/// <summary>
/// The startup-reconcile heal shares <c>StartStoredInstance</c> with the sweep,
/// so it had the identical gap: the reconcile fetched and stored a config, the Deployment
/// Manager started the instance, and its connections were never created.
///
/// <para><b>Fails before the fix.</b></para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReconcileEnsureStartedInstance_AlsoCreatesItsDclConnection()
{
const string instanceName = "ReconciledPump";
var dm = CreateBootedDeploymentManager(sweepSeconds: 0); // isolate the reconcile path
await _storage.StoreDeployedConfigAsync(
instanceName, MakeConfigJson(instanceName), "dep-r", "rev-r", true);
dm.Tell(new EnsureInstanceStartedCommand(instanceName, "startup reconcile"));
await AwaitConnectionCreatedAsync(ConnectionName,
"The startup-reconcile ensure-started heal started the instance without creating "
+ "its DCL connection (register row 85, row-67 path)");
}
/// <summary>
/// <b>Row 85 without any replication at all.</b> The boot-time load reads only ENABLED
/// configs, so a node that boots with this instance DISABLED never created its connection.
/// Enabling it afterwards then started the Instance Actor through
/// <c>HandleEnableResult</c> — the second caller that never ensured connections — leaving a
/// running instance with no connection actor on a perfectly ordinary single site.
///
/// <para><b>Fails before the fix.</b></para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnabledInstance_DisabledAtBoot_AlsoCreatesItsDclConnection()
{
const string instanceName = "EnabledLaterPump";
// Stored DISABLED before boot, so the boot load skips it entirely.
await _storage.StoreDeployedConfigAsync(
instanceName, MakeConfigJson(instanceName), "dep-e", "rev-e", false);
var dm = CreateBootedDeploymentManager(sweepSeconds: 0); // isolate the enable path
Assert.Empty(CreatesFor(ConnectionName));
dm.Tell(new EnableInstanceCommand("cmd-enable", instanceName, DateTimeOffset.UtcNow));
ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(15));
await AwaitConnectionCreatedAsync(ConnectionName,
"Enabling an instance that was disabled at boot started it without creating its DCL "
+ "connection — register row 85 reached with no replication involved");
}
// ── Guards: the repair must be one-shot and silent in steady state ──
/// <summary>
/// Idempotence: many sweeps, ONE create command. The sweep runs forever on a live node, and
/// re-sending the create every tick would be an unbounded message stream — and, since
/// <c>HandleCreateConnection</c> warns and returns on a name it already holds, an unbounded
/// stream of Warning log lines too.
///
/// <para>
/// <b>Fails before the fix</b> (zero commands, not one), and would fail again if the
/// <c>_createdConnections</c> hash guard inside <c>EnsureDclConnection</c> were ever lost.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SweepStartedInstance_CreatesTheConnection_ExactlyOnce_AcrossManySweeps()
{
const string instanceName = "IdempotentConnectionPump";
var dm = CreateBootedDeploymentManager();
await _storage.StoreDeployedConfigAsync(
instanceName, MakeConfigJson(instanceName), "dep-i", "rev-i", true);
await AwaitConnectionCreatedAsync(ConnectionName, "the sweep never created the connection");
// Let several more sweeps run over the now-healthy state.
await Task.Delay(SeveralSweeps);
Assert.Single(CreatesFor(ConnectionName));
}
/// <summary>
/// A sweep over a healthy, deployed-the-ordinary-way instance must send the DCL nothing at
/// all. The hash guard is what makes the connection-ensure safe to sit in the creation
/// funnel, and this pins it against a config that never changes.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Sweep_SendsNoConnectionCommands_WhenThereIsNothingToHeal()
{
const string instanceName = "HealthyConnectionPump";
var dm = CreateBootedDeploymentManager();
dm.Tell(new DeployInstanceCommand(
"dep-1", instanceName, "rev-1", MakeConfigJson(instanceName),
"admin", DateTimeOffset.UtcNow));
Assert.Equal(
DeploymentStatus.Success,
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(15)).Status);
await AwaitConnectionCreatedAsync(ConnectionName, "the deploy never created the connection");
var afterDeploy = _createdConnections.Count;
await Task.Delay(SeveralSweeps);
Assert.Equal(afterDeploy, _createdConnections.Count);
}
// ── Regression guards for the two paths that already worked ──
/// <summary>
/// The fix moved <c>EnsureDclConnections</c> out of <c>ApplyDeployment</c> and into
/// <c>CreateInstanceActor</c>. This pins that an ordinary central deploy still creates its
/// connections — it passes both before and after the fix, which is the point of a
/// refactor guard.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OrdinaryDeploy_StillCreatesItsDclConnection()
{
const string instanceName = "DeployedPump";
var dm = CreateBootedDeploymentManager(sweepSeconds: 0);
dm.Tell(new DeployInstanceCommand(
"dep-1", instanceName, "rev-1", MakeConfigJson(instanceName),
"admin", DateTimeOffset.UtcNow));
Assert.Equal(
DeploymentStatus.Success,
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(15)).Status);
await AwaitConnectionCreatedAsync(ConnectionName,
"an ordinary deploy stopped creating its DCL connection");
var created = CreatesFor(ConnectionName).Single();
Assert.Equal("OpcUa", created.ProtocolType);
}
/// <summary>
/// The other path the fix moved the call out of: the boot-time staggered batch. The config is
/// stored BEFORE the manager starts, so the boot load — not the sweep — is what creates the
/// instance, and it must still bring its connections with it.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BootLoadedInstance_StillCreatesItsDclConnection()
{
const string instanceName = "BootLoadedPump";
await _storage.StoreDeployedConfigAsync(
instanceName, MakeConfigJson(instanceName), "dep-b", "rev-b", true);
CreateBootedDeploymentManager(sweepSeconds: 0);
await AwaitConnectionCreatedAsync(ConnectionName,
"the boot-time load stopped creating its DCL connection");
}
/// <summary>
/// Stand-in for the DCL manager actor: records every
/// <see cref="CreateConnectionCommand"/> and swallows everything else (Instance Actors send
/// their tag subscribes here too, and those are not what these tests assert on).
/// </summary>
private sealed class DclConnectionSpy : ReceiveActor
{
/// <summary>Initializes a new instance of the <c>DclConnectionSpy</c> class.</summary>
/// <param name="creates">The creates.</param>
public DclConnectionSpy(ConcurrentQueue<CreateConnectionCommand> creates)
{
Receive<CreateConnectionCommand>(creates.Enqueue);
ReceiveAny(_ => { });
}
}
}