From 345f40550babdb30bf4fe0eed94faa372bc0ea39 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 17:46:18 -0400 Subject: [PATCH] fix(deploy): a node with a ConfigDb records its degraded apply on its own NodeDeploymentState row (#601 review) The apply writes the node's row Applied/null before the address-space outcome is known; central adds the reason only when it ACCEPTS the ack, so a discarded (stale/timed-out) ack left a fused node's row reading clean. The prefix moves to ApplyAck.DegradedReasonPrefix so both writers spell the row identically. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- .../Messages/Deploy/ApplyAck.cs | 10 ++++++- .../Coordinators/ConfigPublishCoordinator.cs | 2 +- .../Drivers/DriverHostActor.cs | 5 ++++ .../DriverHostActorDegradedAckTests.cs | 27 +++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) 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();