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 row-67 startup-reconcile
/// ensure-started command and the row-78 sweep.
/// - HandleEnableResult — enabling an instance that was disabled at boot.
///
///
///
/// Row 85's live sequence (2026-08-16). A fresh node-a was seeded by LocalDb CDC
/// replication; the row-78 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();
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 row-85 reproduction: a self-heal start must bring its connections with it ──
///
/// THE row-85 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 row-78 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 (row 78 works)
/// but NO is ever sent, which is the live symptom —
/// the instance is up and every tag subscribe is answered Unknown connection.
///
///
[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 row-67 startup-reconcile heal shares StartStoredInstance with the row-78 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.
///
[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.
///
[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.
///
///
[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.
///
[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.
///
[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.
///
[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
{
public DclConnectionSpy(ConcurrentQueue creates)
{
Receive(creates.Enqueue);
ReceiveAny(_ => { });
}
}
}