Merge b640-601: honest deploy acks — Applied only after the address-space outcome, degraded flag + reason (#601)
v2-ci / build (push) Successful in 4m42s
v2-ci / unit-tests (push) Failing after 20m19s

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe

# Conflicts:
#	src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
This commit is contained in:
Joseph Doherty
2026-10-08 18:00:23 -04:00
28 changed files with 1259 additions and 55 deletions
@@ -7,9 +7,32 @@ public enum ApplyAckOutcome { Applied, Failed }
/// <summary>
/// Per-node acknowledgment returned by <c>DriverHostActor</c> to the dispatching coordinator.
/// </summary>
/// <param name="DeploymentId">The deployment being acknowledged.</param>
/// <param name="NodeId">The acknowledging node.</param>
/// <param name="Outcome">Whether the node applied the deployment.</param>
/// <param name="FailureReason">Why the apply failed; null on <see cref="ApplyAckOutcome.Applied"/>.</param>
/// <param name="CorrelationId">The dispatch's correlation id.</param>
/// <param name="Degraded">
/// Gitea #601: <see langword="true"/> when the node applied the deployment (the revision advanced) but
/// its OPC UA address-space apply was DEGRADED — swallowed per-node sink faults, a failed rebuild, or no
/// outcome reported in time — so the served address space may be partial. Only meaningful with
/// <see cref="ApplyAckOutcome.Applied"/>. Trailing and defaulted so an ack from a node built before
/// this field existed (DPS JSON or gRPC without field 6) decodes exactly as it always did: clean.
/// </param>
/// <param name="DegradedReason">A short, operator-readable description of the degradation, or null.</param>
public sealed record ApplyAck(
DeploymentId DeploymentId,
NodeId NodeId,
ApplyAckOutcome Outcome,
string? FailureReason,
CorrelationId CorrelationId);
CorrelationId CorrelationId,
bool Degraded = false,
string? DegradedReason = null)
{
/// <summary>
/// Gitea #601 — prefix of <c>NodeDeploymentState.FailureReason</c> on an <b>Applied</b> 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.
/// </summary>
public const string DegradedReasonPrefix = "Applied degraded: ";
}
@@ -0,0 +1,25 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
/// <summary>
/// The deploy-path timing contract shared by central (<c>ConfigPublishCoordinator</c>) and the node
/// (<c>DriverHostActor</c>). One place, so the node's ack-hold timeout can never drift past the deadline
/// central times the deployment out on.
/// </summary>
public static class DeployTimings
{
/// <summary>How long central waits for every expected node's ack before sealing the deployment TimedOut
/// (<c>ConfigPublishCoordinator.DefaultApplyDeadline</c>).</summary>
public static readonly TimeSpan ApplyDeadline = TimeSpan.FromMinutes(2);
/// <summary>Gitea #601 — how far inside <see cref="ApplyDeadline"/> a node's held Applied ack is released
/// when the address-space outcome has not arrived, leaving room for transport latency.</summary>
public static readonly TimeSpan ApplyOutcomeSafetyMargin = TimeSpan.FromSeconds(15);
/// <summary>Gitea #601 — the node's default apply-outcome timeout: <see cref="ApplyDeadline"/> −
/// <see cref="ApplyOutcomeSafetyMargin"/> (105 s). Long enough that a slow-but-clean rebuild of a large
/// address space is not labelled degraded, short enough to ack before central gives up.</summary>
/// <remarks>The timer is armed after the artifact read and driver reconcile, so if those alone took
/// longer than the margin the ack can still land after the deadline — accepted: they are seconds in
/// practice, and lengthening the margin would label more slow-but-clean rebuilds degraded.</remarks>
public static TimeSpan ApplyOutcomeTimeout => ApplyDeadline - ApplyOutcomeSafetyMargin;
}
@@ -216,6 +216,11 @@ public static class MeshTransportDtoMapper
if (message.FailureReason is not null)
proto.FailureReason = message.FailureReason;
// #601 — additive fields; a clean ack leaves both unset, byte-identical to a pre-#601 encode.
proto.Degraded = message.Degraded;
if (message.DegradedReason is not null)
proto.DegradedReason = message.DegradedReason;
return proto;
}
@@ -249,7 +254,10 @@ public static class MeshTransportDtoMapper
NodeId.Parse(proto.NodeId),
outcome,
proto.HasFailureReason ? proto.FailureReason : null,
CorrelationId.Parse(proto.CorrelationId));
CorrelationId.Parse(proto.CorrelationId),
// #601 — absent on an old node's ack, which decodes as false/null: today's clean ack.
Degraded: proto.Degraded,
DegradedReason: proto.HasDegradedReason ? proto.DegradedReason : null);
}
// ---------------------------------------------------------------- Envelope
@@ -105,6 +105,11 @@ message ApplyAckMessage {
ApplyOutcome outcome = 3;
optional string failure_reason = 4; // nullable in the record — presence distinguishes null from ""
string correlation_id = 5; // CorrelationId (Guid) in its "N" hex form
// Gitea #601 — ADDITIVE. True when the node applied the deployment but its address-space apply was
// degraded (sink faults, failed rebuild, or no outcome in time). An old node never sets it, so it decodes
// as false: exactly today's clean ack. Only meaningful with APPLY_OUTCOME_APPLIED.
bool degraded = 6;
optional string degraded_reason = 7; // short operator-readable reason; presence = set
}
// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy.ApplyAckOutcome, shifted by one so 0 stays