fix(deploy): persist which nodes never acked on a deploy timeout (lmxopcua#784)
In the normal timeout branch (some acks landed) the deployment row was set to
TimedOut with a null FailureReason, so GET /api/deployments/{id} could not say
which node to fix; only the central log named it. The branch now persists a
reason naming the silent nodes ("Timed out waiting for acks (n/m landed). No ack
from: ..."), bounded to 20 named nodes plus a count of the rest, and still
backstopped by the 2048-char Truncate.
Tests: the reason names the silent node and not the acked one; on a 25-node
silence it names 20, counts 5 and fits the column. Mutation-checked: dropping
the SetFailureReason fails both, dropping the bound fails the second.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -186,6 +186,8 @@ hold.
|
||||
`GET /api/deployments/{id}` returns `status: TimedOut` with `failureReason: null`. Only the central log names the
|
||||
silent node (`No ack from: site-b-1:4053`). The no-expected-rows branch does set a reason. An automation client
|
||||
polling the API cannot see which node to fix.
|
||||
**Resolved (lmxopcua#784):** the timeout branch now persists `failureReason` = `Timed out waiting for acks
|
||||
(n/m landed). No ack from: <nodes>. …` (at most 20 nodes named, the rest counted).
|
||||
2. **Rig: mesh PSK warnings on the centrals.** central logs `Mesh PSK secret ot-mesh-psk-{main,site-a,site-b} … is
|
||||
absent (or blank) in the store; the key is REVOKED — commands to that cluster and acks from it are refused`. Yet
|
||||
every gRPC dispatch and ack in these runs succeeded. Either the rig's PSK secrets are not seeded and enforcement is
|
||||
|
||||
@@ -470,11 +470,14 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
|
||||
ResetForNext();
|
||||
return;
|
||||
}
|
||||
db.SaveChanges();
|
||||
// Name the silent nodes, not just the counts. Since the expected set became DB-derived this
|
||||
// is the failure an operator meets after deploying while a configured node is switched off,
|
||||
// so it has to say which node — "4/5 acks landed" leaves them reading logs on five machines.
|
||||
var missing = _expectedAcks.Where(n => !_acks.ContainsKey(n)).Select(n => n.Value).Order().ToList();
|
||||
// lmxopcua#784: persist the same answer on the deployment row, so GET /api/deployments/{id} says which
|
||||
// node to fix instead of TimedOut + a null reason (only the no-expected-rows branch above set one).
|
||||
SetFailureReason(db, TimeoutFailureReason(_acks.Count, _expectedAcks.Count, missing));
|
||||
db.SaveChanges();
|
||||
_log.Warning(
|
||||
"Deployment {Id} timed out after {Deadline} ({Acked}/{Total} acks landed). " +
|
||||
"No ack from: {MissingNodes}. Each is an enabled, non-maintenance ClusterNode row — start " +
|
||||
@@ -483,6 +486,29 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
|
||||
ResetForNext();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The most silent nodes a timeout reason names before summarising the rest as a count. Bounds the
|
||||
/// reason on a large fleet well inside <see cref="FailureReasonMaxLength"/>; <see cref="Truncate"/>
|
||||
/// still backstops pathological node ids.
|
||||
/// </summary>
|
||||
private const int TimeoutReasonMaxNamedNodes = 20;
|
||||
|
||||
/// <summary>The persisted reason for a deployment that timed out with some acks outstanding.</summary>
|
||||
/// <param name="acked">Acks that landed.</param>
|
||||
/// <param name="expected">Acks that were expected.</param>
|
||||
/// <param name="missing">The nodes that never acked, ordered.</param>
|
||||
/// <returns>A reason naming the silent nodes, bounded in length.</returns>
|
||||
private static string TimeoutFailureReason(int acked, int expected, IReadOnlyList<string> missing)
|
||||
{
|
||||
var named = string.Join(", ", missing.Take(TimeoutReasonMaxNamedNodes));
|
||||
var rest = missing.Count > TimeoutReasonMaxNamedNodes
|
||||
? $" (and {missing.Count - TimeoutReasonMaxNamedNodes} more)"
|
||||
: string.Empty;
|
||||
return Truncate(
|
||||
$"Timed out waiting for acks ({acked}/{expected} landed). No ack from: {named}{rest}. Start the node, " +
|
||||
"or set ClusterNode.MaintenanceMode = 1 if it is down for maintenance, then redeploy.");
|
||||
}
|
||||
|
||||
private void ResetForNext()
|
||||
{
|
||||
Timers.Cancel(DeadlineTimerKey);
|
||||
|
||||
+68
@@ -111,6 +111,74 @@ public sealed class ConfigPublishCoordinatorTimeoutTests : ControlPlaneActorTest
|
||||
}, duration: TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#784 — a deployment that timed out with SOME acks landed must persist WHY: the status API
|
||||
/// returned TimedOut with a null reason, and only the central log named the silent node. The reason
|
||||
/// names the node(s) that never acked and not the one that did.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Timeout_with_some_acks_landed_persists_a_reason_naming_the_silent_nodes()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDispatchingDeployment(dbFactory, status: DeploymentStatus.AwaitingApplyAcks);
|
||||
SeedNodeRows(dbFactory, deploymentId, applied: new[] { "site-a-1:4053" }, applying: new[] { "site-b-1:4053" });
|
||||
|
||||
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, TimeSpan.FromMinutes(5)));
|
||||
actor.Tell(new ConfigPublishCoordinator.DeadlineElapsed(deploymentId));
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
var row = db.Deployments.Single();
|
||||
row.Status.ShouldBe(DeploymentStatus.TimedOut);
|
||||
row.FailureReason.ShouldNotBeNull();
|
||||
row.FailureReason.ShouldContain("No ack from: site-b-1:4053.");
|
||||
row.FailureReason.ShouldContain("(1/2 landed)");
|
||||
row.FailureReason.ShouldNotContain("site-a-1");
|
||||
}, duration: TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
/// <summary>lmxopcua#784 — the timeout reason stays bounded on a large fleet: it names 20 nodes and counts the rest.</summary>
|
||||
[Fact]
|
||||
public void Timeout_reason_is_bounded_when_many_nodes_are_silent()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDispatchingDeployment(dbFactory, status: DeploymentStatus.AwaitingApplyAcks);
|
||||
var silent = Enumerable.Range(0, 25).Select(i => $"node-{i:D2}-with-a-fairly-long-host-name:4053").ToArray();
|
||||
SeedNodeRows(dbFactory, deploymentId, applied: new[] { "acked-node:4053" }, applying: silent);
|
||||
|
||||
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, TimeSpan.FromMinutes(5)));
|
||||
actor.Tell(new ConfigPublishCoordinator.DeadlineElapsed(deploymentId));
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
var reason = db.Deployments.Single().FailureReason;
|
||||
reason.ShouldNotBeNull();
|
||||
reason.ShouldContain("node-19-with-a-fairly-long-host-name:4053");
|
||||
reason.ShouldNotContain("node-20-");
|
||||
reason.ShouldContain("(and 5 more)");
|
||||
reason.Length.ShouldBeLessThanOrEqualTo(2048);
|
||||
}, duration: TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
private static void SeedNodeRows(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, DeploymentId deploymentId, string[] applied, string[] applying)
|
||||
{
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
foreach (var n in applied)
|
||||
db.NodeDeploymentStates.Add(new Configuration.Entities.NodeDeploymentState
|
||||
{
|
||||
NodeId = n, DeploymentId = deploymentId.Value, Status = NodeDeploymentStatus.Applied,
|
||||
});
|
||||
foreach (var n in applying)
|
||||
db.NodeDeploymentStates.Add(new Configuration.Entities.NodeDeploymentState
|
||||
{
|
||||
NodeId = n, DeploymentId = deploymentId.Value, Status = NodeDeploymentStatus.Applying,
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static DeploymentId SeedDispatchingDeployment(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
DeploymentStatus status = DeploymentStatus.Dispatching)
|
||||
|
||||
Reference in New Issue
Block a user