diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/ApplyAck.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/ApplyAck.cs index 6cb593cd..2431bd41 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/ApplyAck.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/ApplyAck.cs @@ -27,4 +27,12 @@ public sealed record ApplyAck( string? FailureReason, CorrelationId CorrelationId, bool Degraded = false, - string? DegradedReason = null); + string? DegradedReason = null) +{ + /// + /// Gitea #601 — prefix of NodeDeploymentState.FailureReason on an Applied row whose + /// address-space apply was degraded. Shared by central (which writes it from the ack) and a node with + /// a ConfigDb (which writes its own row), so both spell the row identically. + /// + public const string DegradedReasonPrefix = "Applied degraded: "; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs index c35fd0d5..d45a20e4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs @@ -93,7 +93,7 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers /// degraded node DID apply the revision, so its status stays /// and the degradation rides the existing reason column, which /fleet already renders. /// - public const string DegradedReasonPrefix = "Applied degraded: "; + public const string DegradedReasonPrefix = ApplyAck.DegradedReasonPrefix; /// /// Gitea #601 — whether a NodeDeploymentState row records a degraded apply: status diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index af119dc4..6bb1f3f4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -2588,6 +2588,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS if (degraded) { + // #601 review — the apply wrote this node's own row Applied/null before the outcome was known. Central + // adds the reason from the ack only when it ACCEPTS the ack (a timed-out or superseded deployment's ack + // is discarded), so a node with a ConfigDb records it itself; a driver-only node no-ops here. + UpsertNodeDeploymentState(pending.DeploymentId, NodeDeploymentStatus.Applied, + ApplyAck.DegradedReasonPrefix + (reason ?? "address-space apply degraded")); OtOpcUaTelemetry.DeploymentApplied.Add(1, new KeyValuePair("outcome", "degraded")); _log.Warning( "DriverHost {Node}: deployment {Id} (rev {Rev}) applied DEGRADED — {Reason}. Acking Applied+degraded; re-dispatching the same revision re-applies it", diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDegradedAckTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDegradedAckTests.cs index 570d4e48..5da97d25 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDegradedAckTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDegradedAckTests.cs @@ -172,6 +172,33 @@ public sealed class DriverHostActorDegradedAckTests : RuntimeActorTestBase publish.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); } + [Fact] + public void A_degraded_apply_records_its_reason_on_the_local_NodeDeploymentState_row() + { + // #601 review — a node WITH a ConfigDb writes its own row Applied/null before the outcome is known. + // Central later adds the reason from the ack, but only when it accepts that ack (a stale/timed-out + // deployment's ack is discarded), so the node must not leave its own row reading clean. + var (db, deploymentId) = SeedOneTagDeployment(); + var coordinator = CreateTestProbe(); + var publish = CreateTestProbe(); + var actor = Sys.ActorOf(DriverHostActor.Props( + db, TestNode, coordinator.Ref, localRoles: new HashSet { "driver" }, + opcUaPublishActor: publish.Ref)); + + actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId())); + var rebuild = publish.FishForMessage(_ => true, PresenceBudget); + publish.Reply(new OpcUaPublishActor.RebuildCompleted(rebuild.Correlation, rebuild.DeploymentId, true, "failedNodes=3")); + coordinator.ExpectMsg(PresenceBudget).Degraded.ShouldBeTrue(); + + AwaitAssert(() => + { + using var ctx = db.CreateDbContext(); + var row = ctx.NodeDeploymentStates.Single(r => r.NodeId == TestNode.Value && r.DeploymentId == deploymentId.Value); + row.Status.ShouldBe(ZB.MOM.WW.OtOpcUa.Configuration.Enums.NodeDeploymentStatus.Applied); + row.FailureReason.ShouldBe(ApplyAck.DegradedReasonPrefix + "failedNodes=3"); + }, PresenceBudget); + } + private (IDbContextFactory Db, DeploymentId Id) SeedOneTagDeployment() { var db = NewInMemoryDbFactory();