feat(deploy): additive degraded flag on ApplyAck + publish-actor apply outcome reply (#601)

ApplyAck gains trailing Degraded/DegradedReason (defaulted, so an old node's
DPS JSON or gRPC ack decodes exactly as before). mesh_transport.proto adds
ApplyAckMessage.degraded = 6 / optional degraded_reason = 7. OpcUaPublishActor
replies RebuildCompleted(Degraded, Reason) on every handler path when the
RebuildAddressSpace carries ReportOutcome.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 17:19:57 -04:00
parent 9b74a65b25
commit 521e1eb89b
6 changed files with 286 additions and 11 deletions
@@ -7,9 +7,24 @@ 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);
@@ -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