feat(mesh-phase5): node-local telemetry hub (snapshot-replay + drop-oldest fan-out)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the node-local telemetry hub (per-cluster mesh Phase 5): snapshot-replay of the two
|
||||
/// snapshot-style channels (Health / Resilience), live-only forwarding of the append-style logs
|
||||
/// (Alarm / Script), independent bounded per-subscriber fan-out, DropOldest backpressure, and
|
||||
/// detach-on-dispose. The hub carries ONLY this node's own telemetry — it never touches DPS.
|
||||
/// </summary>
|
||||
public sealed class TelemetryLocalHubTests
|
||||
{
|
||||
private static DriverHealthChanged Health(string instanceId, string state = "Healthy") =>
|
||||
new("cluster-a", instanceId, state, null, null, 0, DateTime.UtcNow);
|
||||
|
||||
private static DriverResilienceStatusChanged Resilience(string instanceId, string host) =>
|
||||
new(instanceId, host, false, 0, 0, null, DateTime.UtcNow, DateTime.UtcNow);
|
||||
|
||||
private static AlarmTransitionEvent Alarm(string id) =>
|
||||
new(id, "Area/Line/Equip", "AlarmName", "Activated", 500, "msg", "system", DateTime.UtcNow);
|
||||
|
||||
private static ScriptLogEntry Script(string id) =>
|
||||
new(id, "Information", "log", DateTime.UtcNow, null, null, null);
|
||||
|
||||
private static List<TelemetryItem> Drain(ITelemetrySubscription sub)
|
||||
{
|
||||
var items = new List<TelemetryItem>();
|
||||
while (sub.Reader.TryRead(out var item))
|
||||
items.Add(item);
|
||||
return items;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void New_subscriber_is_primed_with_cached_Health_snapshot()
|
||||
{
|
||||
var hub = new TelemetryLocalHub();
|
||||
hub.Emit(new TelemetryItem.Health(Health("drv-1", "Faulted")));
|
||||
|
||||
using var sub = hub.Subscribe(boundedCapacity: 16);
|
||||
|
||||
var items = Drain(sub);
|
||||
items.ShouldHaveSingleItem();
|
||||
var health = items[0].ShouldBeOfType<TelemetryItem.Health>();
|
||||
health.E.DriverInstanceId.ShouldBe("drv-1");
|
||||
health.E.State.ShouldBe("Faulted");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void New_subscriber_is_primed_with_cached_Resilience_snapshot()
|
||||
{
|
||||
var hub = new TelemetryLocalHub();
|
||||
hub.Emit(new TelemetryItem.Resilience(Resilience("drv-1", "host-a")));
|
||||
|
||||
using var sub = hub.Subscribe(boundedCapacity: 16);
|
||||
|
||||
var items = Drain(sub);
|
||||
items.ShouldHaveSingleItem();
|
||||
var res = items[0].ShouldBeOfType<TelemetryItem.Resilience>();
|
||||
res.E.DriverInstanceId.ShouldBe("drv-1");
|
||||
res.E.HostName.ShouldBe("host-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Alarm_and_Script_are_not_replayed_to_a_new_subscriber()
|
||||
{
|
||||
var hub = new TelemetryLocalHub();
|
||||
hub.Emit(new TelemetryItem.Alarm(Alarm("a-1")));
|
||||
hub.Emit(new TelemetryItem.Script(Script("s-1")));
|
||||
|
||||
using var sub = hub.Subscribe(boundedCapacity: 16);
|
||||
|
||||
Drain(sub).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_concurrent_subscribers_each_receive_a_post_subscribe_emit()
|
||||
{
|
||||
var hub = new TelemetryLocalHub();
|
||||
using var s1 = hub.Subscribe(boundedCapacity: 16);
|
||||
using var s2 = hub.Subscribe(boundedCapacity: 16);
|
||||
|
||||
hub.Emit(new TelemetryItem.Alarm(Alarm("a-1")));
|
||||
|
||||
Drain(s1).ShouldHaveSingleItem().ShouldBeOfType<TelemetryItem.Alarm>().E.AlarmId.ShouldBe("a-1");
|
||||
Drain(s2).ShouldHaveSingleItem().ShouldBeOfType<TelemetryItem.Alarm>().E.AlarmId.ShouldBe("a-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Health_snapshot_keeps_only_the_latest_per_instance()
|
||||
{
|
||||
var hub = new TelemetryLocalHub();
|
||||
hub.Emit(new TelemetryItem.Health(Health("drv-1", "Reconnecting")));
|
||||
hub.Emit(new TelemetryItem.Health(Health("drv-1", "Healthy")));
|
||||
|
||||
using var sub = hub.Subscribe(boundedCapacity: 16);
|
||||
|
||||
var items = Drain(sub);
|
||||
items.ShouldHaveSingleItem();
|
||||
items[0].ShouldBeOfType<TelemetryItem.Health>().E.State.ShouldBe("Healthy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Health_snapshot_keeps_distinct_instances()
|
||||
{
|
||||
var hub = new TelemetryLocalHub();
|
||||
hub.Emit(new TelemetryItem.Health(Health("drv-1")));
|
||||
hub.Emit(new TelemetryItem.Health(Health("drv-2")));
|
||||
|
||||
using var sub = hub.Subscribe(boundedCapacity: 16);
|
||||
|
||||
var ids = Drain(sub)
|
||||
.OfType<TelemetryItem.Health>()
|
||||
.Select(h => h.E.DriverInstanceId)
|
||||
.OrderBy(x => x)
|
||||
.ToArray();
|
||||
ids.ShouldBe(new[] { "drv-1", "drv-2" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resilience_snapshot_keeps_only_latest_per_instance_and_host()
|
||||
{
|
||||
var hub = new TelemetryLocalHub();
|
||||
// Same instance, two distinct hosts → two cache entries.
|
||||
hub.Emit(new TelemetryItem.Resilience(Resilience("drv-1", "host-a")));
|
||||
hub.Emit(new TelemetryItem.Resilience(Resilience("drv-1", "host-b")));
|
||||
// Overwrite (drv-1, host-a).
|
||||
hub.Emit(new TelemetryItem.Resilience(Resilience("drv-1", "host-a")));
|
||||
|
||||
using var sub = hub.Subscribe(boundedCapacity: 16);
|
||||
|
||||
var keys = Drain(sub)
|
||||
.OfType<TelemetryItem.Resilience>()
|
||||
.Select(r => (r.E.DriverInstanceId, r.E.HostName))
|
||||
.OrderBy(x => x.HostName)
|
||||
.ToArray();
|
||||
keys.ShouldBe(new[] { ("drv-1", "host-a"), ("drv-1", "host-b") });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bounded_channel_drops_oldest_not_newest_when_full()
|
||||
{
|
||||
var hub = new TelemetryLocalHub();
|
||||
using var sub = hub.Subscribe(boundedCapacity: 2);
|
||||
|
||||
// Append-style (uncached) items so the snapshot doesn't consume capacity.
|
||||
hub.Emit(new TelemetryItem.Alarm(Alarm("a-1")));
|
||||
hub.Emit(new TelemetryItem.Alarm(Alarm("a-2")));
|
||||
hub.Emit(new TelemetryItem.Alarm(Alarm("a-3")));
|
||||
|
||||
var ids = Drain(sub).OfType<TelemetryItem.Alarm>().Select(a => a.E.AlarmId).ToArray();
|
||||
// a-1 (oldest) evicted; the two newest survive in order.
|
||||
ids.ShouldBe(new[] { "a-2", "a-3" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_detaches_the_subscriber_and_completes_its_channel()
|
||||
{
|
||||
var hub = new TelemetryLocalHub();
|
||||
var sub = hub.Subscribe(boundedCapacity: 16);
|
||||
|
||||
sub.Dispose();
|
||||
|
||||
// A further Emit must neither throw nor deliver to the detached subscriber.
|
||||
Should.NotThrow(() => hub.Emit(new TelemetryItem.Alarm(Alarm("a-1"))));
|
||||
sub.Reader.Completion.IsCompleted.ShouldBeTrue();
|
||||
sub.Reader.TryRead(out _).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user