fix(mesh-phase5): dial supervisor — re-dial on endpoint change, pin no-restart invariant, throttle skip warnings
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -73,6 +73,7 @@ public sealed class TelemetryDialSupervisor : ReceiveActor, IWithTimers
|
|||||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||||
|
|
||||||
private readonly Dictionary<string, NodeDialer> _dialers = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, NodeDialer> _dialers = new(StringComparer.Ordinal);
|
||||||
|
private readonly HashSet<string> _warnedUnroutedTypes = new(StringComparer.Ordinal);
|
||||||
private bool _pillConnected;
|
private bool _pillConnected;
|
||||||
|
|
||||||
/// <summary>Gets the timer scheduler driving the periodic refresh and per-node reconnect backoff.</summary>
|
/// <summary>Gets the timer scheduler driving the periodic refresh and per-node reconnect backoff.</summary>
|
||||||
@@ -195,15 +196,32 @@ public sealed class TelemetryDialSupervisor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start a dialer for each newly-present node. Existing dialers keep running untouched.
|
// Start a dialer for each newly-present node; re-dial one whose endpoint moved. An existing
|
||||||
|
// node whose endpoint is UNCHANGED keeps its dialer untouched (no churn — a restart would drop
|
||||||
|
// its stream on every refresh).
|
||||||
foreach (var (nodeId, target) in incoming)
|
foreach (var (nodeId, target) in incoming)
|
||||||
{
|
{
|
||||||
if (!_dialers.ContainsKey(nodeId))
|
if (_dialers.TryGetValue(nodeId, out var existing))
|
||||||
{
|
{
|
||||||
var dialer = new NodeDialer(target);
|
if (!string.Equals(existing.Target.Endpoint, target.Endpoint, StringComparison.Ordinal))
|
||||||
_dialers[nodeId] = dialer;
|
{
|
||||||
StartDialer(nodeId);
|
// A re-provisioned node moved host/port under the same id. Update the target in place
|
||||||
|
// and re-dial — StartDialer bumps the SAME dialer's generation, so the old (dead)
|
||||||
|
// endpoint's stream is cancelled AND its in-flight messages are dropped by the
|
||||||
|
// generation guard. Replacing the dialer object would reset the generation and let a
|
||||||
|
// late message from the old stream slip through.
|
||||||
|
_log.Info(
|
||||||
|
"Node {NodeId} telemetry endpoint changed {Old} -> {New}; re-dialing",
|
||||||
|
nodeId, existing.Target.Endpoint, target.Endpoint);
|
||||||
|
existing.Target = target;
|
||||||
|
StartDialer(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_dialers[nodeId] = new NodeDialer(target);
|
||||||
|
StartDialer(nodeId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,9 +381,22 @@ public sealed class TelemetryDialSupervisor : ReceiveActor, IWithTimers
|
|||||||
_resilienceStore.Upsert(resilience);
|
_resilienceStore.Upsert(resilience);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
_log.Warning(
|
// Warn once per unexpected type — a version-skew event would otherwise Warn per
|
||||||
"Telemetry record of unrouted type {RecordType} from node {NodeId}; dropped",
|
// event, forever. Subsequent drops of the same type log at Debug.
|
||||||
record?.GetType().Name ?? "null", nodeId);
|
var typeName = record?.GetType().Name ?? "null";
|
||||||
|
if (_warnedUnroutedTypes.Add(typeName))
|
||||||
|
{
|
||||||
|
_log.Warning(
|
||||||
|
"Telemetry record of unrouted type {RecordType} from node {NodeId}; dropped "
|
||||||
|
+ "(further drops of this type log at Debug)", typeName, nodeId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_log.Debug(
|
||||||
|
"Telemetry record of unrouted type {RecordType} from node {NodeId}; dropped",
|
||||||
|
typeName, nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -405,8 +436,8 @@ public sealed class TelemetryDialSupervisor : ReceiveActor, IWithTimers
|
|||||||
/// <summary>Per-node dialer bookkeeping. Mutated only on the actor thread.</summary>
|
/// <summary>Per-node dialer bookkeeping. Mutated only on the actor thread.</summary>
|
||||||
private sealed class NodeDialer(TelemetryDialTarget target)
|
private sealed class NodeDialer(TelemetryDialTarget target)
|
||||||
{
|
{
|
||||||
/// <summary>The node's dial target.</summary>
|
/// <summary>The node's dial target. Reassigned in place when a known node's endpoint changes.</summary>
|
||||||
public TelemetryDialTarget Target { get; } = target;
|
public TelemetryDialTarget Target { get; set; } = target;
|
||||||
|
|
||||||
/// <summary>Monotonic stream generation; a message tagged with a stale value is dropped.</summary>
|
/// <summary>Monotonic stream generation; a message tagged with a stale value is dropped.</summary>
|
||||||
public int Generation { get; set; }
|
public int Generation { get; set; }
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ public static class TelemetryNodeSource
|
|||||||
ArgumentNullException.ThrowIfNull(dbFactory);
|
ArgumentNullException.ThrowIfNull(dbFactory);
|
||||||
ArgumentNullException.ThrowIfNull(logger);
|
ArgumentNullException.ThrowIfNull(logger);
|
||||||
|
|
||||||
|
// A persistently null-GrpcPort node is skipped on every refresh (every ContactRefreshSeconds);
|
||||||
|
// warn once per node id so it does not spam the log forever.
|
||||||
|
var warnedNullPort = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
return async () =>
|
return async () =>
|
||||||
{
|
{
|
||||||
await using var db = await dbFactory.CreateDbContextAsync().ConfigureAwait(false);
|
await using var db = await dbFactory.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
@@ -44,9 +48,14 @@ public static class TelemetryNodeSource
|
|||||||
{
|
{
|
||||||
if (row.GrpcPort is null)
|
if (row.GrpcPort is null)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
if (warnedNullPort.Add(row.NodeId))
|
||||||
"ClusterNode {NodeId} has no GrpcPort; it exposes no telemetry stream and is "
|
{
|
||||||
+ "skipped in this dial refresh", row.NodeId);
|
logger.LogWarning(
|
||||||
|
"ClusterNode {NodeId} has no GrpcPort; it exposes no telemetry stream and is "
|
||||||
|
+ "skipped in this dial refresh (further skips of this node are silent)",
|
||||||
|
row.NodeId);
|
||||||
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+38
@@ -70,6 +70,44 @@ public sealed class TelemetryDialSupervisorTests : TestKit
|
|||||||
// node-3 gets a fresh dialer.
|
// node-3 gets a fresh dialer.
|
||||||
var invocations = dial.WaitForCount(3, Timeout);
|
var invocations = dial.WaitForCount(3, Timeout);
|
||||||
invocations.ShouldContain(i => i.Target.NodeId == "node-3" && i.CorrelationId == "central-node-3-1");
|
invocations.ShouldContain(i => i.Target.NodeId == "node-3" && i.CorrelationId == "central-node-3-1");
|
||||||
|
|
||||||
|
// node-2 is present in BOTH refreshes with an unchanged endpoint: it must NOT be restarted, or
|
||||||
|
// its live stream would be dropped on every refresh. Exactly one dial invocation for it.
|
||||||
|
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||||
|
dial.Invocations.Count(i => i.Target.NodeId == "node-2").ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Refresh_redials_a_node_whose_endpoint_changed_but_not_an_unchanged_one()
|
||||||
|
{
|
||||||
|
var dial = new FakeDialLoop();
|
||||||
|
var current = new List<TelemetryDialTarget>
|
||||||
|
{
|
||||||
|
new("node-1", "http://old-host:5300"),
|
||||||
|
new("node-2", "http://h2:5300"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var actor = Spawn(dial, () => current.ToArray());
|
||||||
|
dial.WaitForCount(2, Timeout);
|
||||||
|
var node1Original = dial.Invocations.Single(i => i.Target.NodeId == "node-1");
|
||||||
|
|
||||||
|
// node-1 re-provisioned to a new host; node-2 unchanged.
|
||||||
|
current.Clear();
|
||||||
|
current.Add(new TelemetryDialTarget("node-1", "http://new-host:5300"));
|
||||||
|
current.Add(new TelemetryDialTarget("node-2", "http://h2:5300"));
|
||||||
|
actor.Tell(new TelemetryDialSupervisor.RefreshNodes());
|
||||||
|
|
||||||
|
// The old node-1 stream is torn down and a fresh dialer opens at the NEW endpoint (gen 2).
|
||||||
|
AwaitCondition(() => node1Original.Ct.IsCancellationRequested, Timeout);
|
||||||
|
var invocations = dial.WaitForCount(3, Timeout);
|
||||||
|
invocations.ShouldContain(i =>
|
||||||
|
i.Target.NodeId == "node-1"
|
||||||
|
&& i.Target.Endpoint == "http://new-host:5300"
|
||||||
|
&& i.CorrelationId == "central-node-1-2");
|
||||||
|
|
||||||
|
// node-2's endpoint did not change → still exactly one dial invocation (no churn).
|
||||||
|
ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||||
|
dial.Invocations.Count(i => i.Target.NodeId == "node-2").ShouldBe(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user