using Akka.Actor; using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// lmxopcua#868 fourth review M2: a revision a node ADOPTS without an outcome-reporting apply — Stale recovery, a /// failed bootstrap restore — is not known to be served. A dispatch of that same revision must RE-APPLY (the /// publish actor gets a rebuild) instead of short-circuiting a clean Applied over an empty address space. /// public sealed class DriverHostActorUnconfirmedRevisionTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-test"); private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); [Fact] public void A_revision_adopted_by_Stale_recovery_is_reapplied_on_redispatch() { var db = new SwitchableDbFactory(Guid.NewGuid().ToString("N")); var deploymentId = Seed(db); db.Down = true; var publish = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( db, TestNode, CreateTestProbe().Ref, localRoles: new HashSet { "driver" }, opcUaPublishActor: publish.Ref)); // Bootstrap fails (DB down) ⇒ Stale. Bring the DB back and drive the recovery tick by hand. // Barrier: Stale answers RouteNodeWrite with a "stale" refusal, so a reply proves the host is in Stale. var asker = CreateTestProbe(); actor.Tell(new DriverHostActor.RouteNodeWrite("eq-x/speed", 0.0, ZB.MOM.WW.OtOpcUa.Commons.OpcUa.AddressSpaceRealm.Uns), asker.Ref); asker.ExpectMsg(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken) .Reason!.ShouldContain("stale"); db.Down = false; actor.Tell(DriverHostActor.RetryConfigDbConnection.Instance); AwaitAssert(() => { using var ctx = db.CreateDbContext(); ctx.NodeDeploymentStates.Single(r => r.NodeId == TestNode.Value).Status.ShouldBe(NodeDeploymentStatus.Applied); }, PresenceBudget, cancellationToken: TestContext.Current.CancellationToken); publish.ExpectNoMsg(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken); // recovery restores nothing actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); publish.FishForMessage( m => m.DeploymentId == deploymentId && m.ReportOutcome, PresenceBudget, cancellationToken: TestContext.Current.CancellationToken); } [Fact] public void A_revision_whose_bootstrap_restore_failed_is_reapplied_on_redispatch() { var db = new SwitchableDbFactory(Guid.NewGuid().ToString("N")); var deploymentId = Seed(db); using (var ctx = db.CreateDbContext()) { ctx.NodeDeploymentStates.Add(new NodeDeploymentState { NodeId = TestNode.Value, DeploymentId = deploymentId.Value, Status = NodeDeploymentStatus.Applied, StartedAtUtc = DateTime.UtcNow, AppliedAtUtc = DateTime.UtcNow, }); ctx.SaveChanges(); } db.FailRestore = true; // the restore's artifact read throws; the bootstrap's own row read does not var publish = CreateTestProbe(); var actor = Sys.ActorOf(DriverHostActor.Props( db, TestNode, CreateTestProbe().Ref, localRoles: new HashSet { "driver" }, opcUaPublishActor: publish.Ref)); AwaitCondition(() => db.RestoreFailures > 0, PresenceBudget, cancellationToken: TestContext.Current.CancellationToken); db.FailRestore = false; actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); publish.FishForMessage( m => m.DeploymentId == deploymentId && m.ReportOutcome, PresenceBudget, cancellationToken: TestContext.Current.CancellationToken); } private static DeploymentId Seed(IDbContextFactory db) => V3ArtifactSeeder.SeedV3Deployment(db, RevA, rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) }, unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) }); /// In-memory ConfigDb that can be down, or fail only the bootstrap restore's artifact read. private sealed class SwitchableDbFactory(string dbName) : IDbContextFactory { private volatile bool _down; private volatile bool _failRestore; private int _attempts; private int _restoreFailures; public bool Down { get => _down; set => _down = value; } public bool FailRestore { get => _failRestore; set => _failRestore = value; } public int Attempts => Volatile.Read(ref _attempts); public int RestoreFailures => Volatile.Read(ref _restoreFailures); public OtOpcUaConfigDbContext CreateDbContext() { Interlocked.Increment(ref _attempts); if (_down) throw new InvalidOperationException("ConfigDb unreachable (simulated)"); if (_failRestore && new System.Diagnostics.StackTrace().ToString().Contains("RestoreApplied")) { Interlocked.Increment(ref _restoreFailures); throw new InvalidOperationException("ConfigDb read failed during restore (simulated)"); } return new OtOpcUaConfigDbContext(new DbContextOptionsBuilder() .UseInMemoryDatabase(dbName).Options); } } }