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; /// /// Regression tests for deferred register row 85 — an Instance Actor started by a /// self-heal got no DCL connection actors, so every tag it subscribed was answered /// Unknown connection: X forever. /// /// /// The defect. A DCL connection actor is never created from the /// data_connection_definitions table — that table has no production reader at all. It is /// created only as a side effect of starting an instance, from the Connections map inside /// that instance's own flattened config, via EnsureDclConnections. That call used to be /// each CALLER's job, and only two of the four callers made it: the boot-time batch and /// ApplyDeployment. The two self-heal starts did not: /// /// /// StartStoredInstance — the path shared by the startup-reconcile /// ensure-started command and the sweep. /// HandleEnableResult — enabling an instance that was disabled at boot. /// /// /// /// The live sequence (2026-08-16). 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 Unknown connection: OPC PLC Simulator / /// No connection actor for OPC PLC Simulator, 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. /// /// /// /// The enable path proves this is not a replication-only bug. 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. /// /// /// /// The fix moves EnsureDclConnections into CreateInstanceActor — already /// documented as the single Instance Actor creation funnel — so all four paths get it and a /// fifth path cannot forget. Design note: /// docs/plans/2026-08-15-instance-sweep-self-heal-design.md §7. /// /// 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; /// /// Every the Deployment Manager sent to the DCL, in /// order. This is the exact message whose absence WAS register row 85. /// private readonly ConcurrentQueue _createdConnections = new(); /// Initializes a new instance of the SelfHealCreatesDclConnectionTests class. public SelfHealCreatesDclConnectionTests() { _localDb = TestLocalDb.CreateTemp("dcl-selfheal-test"); _storage = new SiteStorageService(_localDb.Db, NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService( NullLogger.Instance); _sharedScriptLibrary = new SharedScriptLibrary( _compilationService, NullLogger.Instance); } /// 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); } /// Shortest sweep the (whole-second) option allows. private const int SweepSeconds = 1; /// Long enough for at least three sweep ticks to have come and gone. private static readonly TimeSpan SeveralSweeps = TimeSpan.FromSeconds(4); /// /// 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 ). /// /// /// That gate is load-bearing for the post-boot tests here for the same reason it is in /// InstanceSweepSelfHealTests: 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. /// /// 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.Instance, dclManager: dclSpy, healthCollector: health, serviceProvider: null, loggerFactory: null, configFetcher: null))); AwaitCondition(() => health.ReportCount > 0, TimeSpan.FromSeconds(20), TimeSpan.FromMilliseconds(25)); return dm; } /// /// A flattened config carrying one data connection — the Connections map is what /// EnsureDclConnections reads, and it travels inside every instance's own config. /// 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 { [ConnectionName] = new() { Protocol = "OpcUa", ConfigurationJson = $"{{\"endpointUrl\":\"{endpoint}\"}}", FailoverRetryCount = 3 } } }); private IReadOnlyList 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 ── /// /// 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. /// /// /// Fails before the fix at the connection assert: the instance starts (the sweep works) /// but NO is ever sent, which is the live symptom — /// the instance is up and every tag subscribe is answered Unknown connection. /// /// /// A task that represents the asynchronous operation. [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)"); } /// /// The startup-reconcile heal shares StartStoredInstance 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. /// /// Fails before the fix. /// /// A task that represents the asynchronous operation. [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)"); } /// /// Row 85 without any replication at all. 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 /// HandleEnableResult — the second caller that never ensured connections — leaving a /// running instance with no connection actor on a perfectly ordinary single site. /// /// Fails before the fix. /// /// A task that represents the asynchronous operation. [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(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 ── /// /// 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 /// HandleCreateConnection warns and returns on a name it already holds, an unbounded /// stream of Warning log lines too. /// /// /// Fails before the fix (zero commands, not one), and would fail again if the /// _createdConnections hash guard inside EnsureDclConnection were ever lost. /// /// /// A task that represents the asynchronous operation. [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)); } /// /// 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. /// /// A task that represents the asynchronous operation. [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(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 ── /// /// The fix moved EnsureDclConnections out of ApplyDeployment and into /// CreateInstanceActor. 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. /// /// A task that represents the asynchronous operation. [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(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); } /// /// 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. /// /// A task that represents the asynchronous operation. [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"); } /// /// Stand-in for the DCL manager actor: records every /// and swallows everything else (Instance Actors send /// their tag subscribes here too, and those are not what these tests assert on). /// private sealed class DclConnectionSpy : ReceiveActor { /// Initializes a new instance of the DclConnectionSpy class. /// The creates. public DclConnectionSpy(ConcurrentQueue creates) { Receive(creates.Enqueue); ReceiveAny(_ => { }); } } }