feat(mesh-phase5): node-side TelemetryStreamService (hub -> server-streaming)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -76,6 +76,7 @@
|
|||||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj" />
|
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj" />
|
||||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.csproj" />
|
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.csproj" />
|
||||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.csproj" />
|
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.csproj" />
|
||||||
|
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/ZB.MOM.WW.OtOpcUa.Host.Tests.csproj" />
|
||||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj" />
|
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj" />
|
||||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj" />
|
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj" />
|
||||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj" />
|
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj" />
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
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.Commons.Protos.Telemetry.V1;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.Grpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Node-side projection of a domain <see cref="TelemetryItem"/> onto its wire
|
||||||
|
/// <see cref="TelemetryEvent"/> envelope (per-cluster mesh Phase 5). The exact reverse of
|
||||||
|
/// <c>telemetry.proto</c>: it transcribes every domain field onto the matching proto field and
|
||||||
|
/// selects the envelope's <c>oneof</c> arm from the <see cref="TelemetryItem"/> subtype.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>DateTime → Timestamp is Kind-defensive.</b> <see cref="Timestamp.FromDateTime"/> throws
|
||||||
|
/// unless the source <see cref="DateTime.Kind"/> is <see cref="DateTimeKind.Utc"/>. Producers
|
||||||
|
/// use <see cref="DateTime.UtcNow"/> today, but a stray <see cref="DateTimeKind.Local"/> or
|
||||||
|
/// <see cref="DateTimeKind.Unspecified"/> value must never crash the stream — so a Local value
|
||||||
|
/// is converted and an Unspecified value is assumed already-UTC (matching the producer intent
|
||||||
|
/// and preserving the instant on any machine).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nullable presence is preserved.</b> A null nullable-<c>DateTime</c> leaves the proto
|
||||||
|
/// Timestamp unset (absent == null per the contract); a null <c>optional string</c> leaves the
|
||||||
|
/// proto field unset so the generated <c>Has…</c> presence distinguishes null from "".
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class TelemetryProtoMapNode
|
||||||
|
{
|
||||||
|
/// <summary>Projects <paramref name="item"/> onto a wire <see cref="TelemetryEvent"/>.</summary>
|
||||||
|
/// <param name="item">The domain telemetry item to project.</param>
|
||||||
|
/// <param name="correlationId">Stream correlation id echoed on every envelope.</param>
|
||||||
|
/// <returns>The wire envelope with the matching <c>oneof</c> arm populated.</returns>
|
||||||
|
public static TelemetryEvent ToProto(TelemetryItem item, string correlationId)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(item);
|
||||||
|
|
||||||
|
return item switch
|
||||||
|
{
|
||||||
|
TelemetryItem.Alarm a => new TelemetryEvent
|
||||||
|
{
|
||||||
|
CorrelationId = correlationId,
|
||||||
|
AlarmTransition = MapAlarm(a.E),
|
||||||
|
},
|
||||||
|
TelemetryItem.Script s => new TelemetryEvent
|
||||||
|
{
|
||||||
|
CorrelationId = correlationId,
|
||||||
|
ScriptLog = MapScript(s.E),
|
||||||
|
},
|
||||||
|
TelemetryItem.Health h => new TelemetryEvent
|
||||||
|
{
|
||||||
|
CorrelationId = correlationId,
|
||||||
|
DriverHealth = MapHealth(h.E),
|
||||||
|
},
|
||||||
|
TelemetryItem.Resilience r => new TelemetryEvent
|
||||||
|
{
|
||||||
|
CorrelationId = correlationId,
|
||||||
|
DriverResilience = MapResilience(r.E),
|
||||||
|
},
|
||||||
|
_ => throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(item), item.GetType().Name, "Unknown TelemetryItem subtype"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AlarmTransition MapAlarm(AlarmTransitionEvent e)
|
||||||
|
{
|
||||||
|
var msg = new AlarmTransition
|
||||||
|
{
|
||||||
|
AlarmId = e.AlarmId,
|
||||||
|
EquipmentPath = e.EquipmentPath,
|
||||||
|
AlarmName = e.AlarmName,
|
||||||
|
TransitionKind = e.TransitionKind,
|
||||||
|
Severity = e.Severity,
|
||||||
|
Message = e.Message,
|
||||||
|
User = e.User,
|
||||||
|
TimestampUtc = ToUtcTimestamp(e.TimestampUtc),
|
||||||
|
AlarmTypeName = e.AlarmTypeName,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (e.Comment is not null)
|
||||||
|
msg.Comment = e.Comment;
|
||||||
|
if (e.HistorizeToAveva is not null)
|
||||||
|
msg.HistorizeToAveva = e.HistorizeToAveva.Value;
|
||||||
|
msg.ReferencingEquipmentPaths.AddRange(e.ReferencingEquipmentPaths ?? Enumerable.Empty<string>());
|
||||||
|
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ScriptLog MapScript(ScriptLogEntry e)
|
||||||
|
{
|
||||||
|
var msg = new ScriptLog
|
||||||
|
{
|
||||||
|
ScriptId = e.ScriptId,
|
||||||
|
Level = e.Level,
|
||||||
|
Message = e.Message,
|
||||||
|
TimestampUtc = ToUtcTimestamp(e.TimestampUtc),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (e.VirtualTagId is not null)
|
||||||
|
msg.VirtualTagId = e.VirtualTagId;
|
||||||
|
if (e.AlarmId is not null)
|
||||||
|
msg.AlarmId = e.AlarmId;
|
||||||
|
if (e.EquipmentId is not null)
|
||||||
|
msg.EquipmentId = e.EquipmentId;
|
||||||
|
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DriverHealth MapHealth(DriverHealthChanged e)
|
||||||
|
{
|
||||||
|
var msg = new DriverHealth
|
||||||
|
{
|
||||||
|
ClusterId = e.ClusterId,
|
||||||
|
DriverInstanceId = e.DriverInstanceId,
|
||||||
|
State = e.State,
|
||||||
|
ErrorCount5Min = e.ErrorCount5Min,
|
||||||
|
PublishedUtc = ToUtcTimestamp(e.PublishedUtc),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (e.LastSuccessfulReadUtc is not null)
|
||||||
|
msg.LastSuccessfulReadUtc = ToUtcTimestamp(e.LastSuccessfulReadUtc.Value);
|
||||||
|
if (e.LastError is not null)
|
||||||
|
msg.LastError = e.LastError;
|
||||||
|
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DriverResilienceStatus MapResilience(DriverResilienceStatusChanged e)
|
||||||
|
{
|
||||||
|
var msg = new DriverResilienceStatus
|
||||||
|
{
|
||||||
|
DriverInstanceId = e.DriverInstanceId,
|
||||||
|
HostName = e.HostName,
|
||||||
|
BreakerOpen = e.BreakerOpen,
|
||||||
|
ConsecutiveFailures = e.ConsecutiveFailures,
|
||||||
|
CurrentInFlight = e.CurrentInFlight,
|
||||||
|
LastSampledUtc = ToUtcTimestamp(e.LastSampledUtc),
|
||||||
|
PublishedUtc = ToUtcTimestamp(e.PublishedUtc),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (e.LastBreakerOpenUtc is not null)
|
||||||
|
msg.LastBreakerOpenUtc = ToUtcTimestamp(e.LastBreakerOpenUtc.Value);
|
||||||
|
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a domain <see cref="DateTime"/> to a proto <see cref="Timestamp"/> without ever
|
||||||
|
/// throwing on Kind: Utc is used as-is, Local is converted, and Unspecified is assumed
|
||||||
|
/// already-UTC (the producer contract — <see cref="DateTime.UtcNow"/> — with the wall-clock
|
||||||
|
/// ticks preserved so the instant round-trips).
|
||||||
|
/// </summary>
|
||||||
|
private static Timestamp ToUtcTimestamp(DateTime dt)
|
||||||
|
{
|
||||||
|
var utc = dt.Kind switch
|
||||||
|
{
|
||||||
|
DateTimeKind.Utc => dt,
|
||||||
|
DateTimeKind.Local => dt.ToUniversalTime(),
|
||||||
|
_ => DateTime.SpecifyKind(dt, DateTimeKind.Utc),
|
||||||
|
};
|
||||||
|
|
||||||
|
return Timestamp.FromDateTime(utc);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
|
||||||
|
// The generated service container is itself named TelemetryStreamService; alias its nested server
|
||||||
|
// base so this impl can keep the natural name without colliding with the generated type.
|
||||||
|
using GeneratedServiceBase =
|
||||||
|
ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1.TelemetryStreamService.TelemetryStreamServiceBase;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.Grpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Node-side live-telemetry streaming server (per-cluster mesh Phase 5). Central dials a driver
|
||||||
|
/// node and opens <see cref="Subscribe"/>; the node attaches a fresh reader on its
|
||||||
|
/// <see cref="ITelemetryLocalHub"/> and pumps this node's own telemetry
|
||||||
|
/// (alarm-transitions / script-logs / driver-health / driver-resilience) to the caller as a
|
||||||
|
/// sequence of <see cref="TelemetryEvent"/> envelopes until the client cancels or the
|
||||||
|
/// max-lifetime cap fires.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The hub carries ONLY this node's telemetry, so a per-node stream never double-counts a peer's
|
||||||
|
/// events. Fan-out is lossy under backpressure by design (the per-subscriber channel is
|
||||||
|
/// bounded/DropOldest inside the hub). A simple process-wide concurrency cap sheds excess dials
|
||||||
|
/// with <see cref="StatusCode.ResourceExhausted"/>, and every accepted stream is bounded by a
|
||||||
|
/// linked max-lifetime CTS.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class TelemetryStreamGrpcService : GeneratedServiceBase
|
||||||
|
{
|
||||||
|
/// <summary>Process-wide cap on concurrently-open telemetry streams.</summary>
|
||||||
|
private const int MaxConcurrentStreams = 100;
|
||||||
|
|
||||||
|
/// <summary>Upper bound on an accepted correlation id (defends against absurd inputs).</summary>
|
||||||
|
private const int MaxCorrelationIdLength = 256;
|
||||||
|
|
||||||
|
/// <summary>Per-subscriber bounded-channel capacity requested from the hub.</summary>
|
||||||
|
private const int SubscriptionCapacity = 1000;
|
||||||
|
|
||||||
|
/// <summary>Hard ceiling on a single stream's lifetime; the linked CTS cancels the pump when it fires.</summary>
|
||||||
|
private static readonly TimeSpan MaxStreamLifetime = TimeSpan.FromHours(4);
|
||||||
|
|
||||||
|
/// <summary>Process-wide count of currently-open streams; mutated only via <see cref="Interlocked"/>.</summary>
|
||||||
|
private static int _activeStreams;
|
||||||
|
|
||||||
|
private readonly ITelemetryLocalHub _hub;
|
||||||
|
private readonly ILogger<TelemetryStreamGrpcService> _log;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="TelemetryStreamGrpcService"/> class.</summary>
|
||||||
|
/// <param name="hub">The node-local telemetry fan-out hub this stream drains.</param>
|
||||||
|
/// <param name="log">Logger.</param>
|
||||||
|
public TelemetryStreamGrpcService(ITelemetryLocalHub hub, ILogger<TelemetryStreamGrpcService> log)
|
||||||
|
{
|
||||||
|
_hub = hub;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override async Task Subscribe(
|
||||||
|
TelemetryStreamRequest request,
|
||||||
|
IServerStreamWriter<TelemetryEvent> responseStream,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ArgumentNullException.ThrowIfNull(responseStream);
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
|
||||||
|
var correlationId = request.CorrelationId;
|
||||||
|
if (string.IsNullOrWhiteSpace(correlationId) || correlationId.Length > MaxCorrelationIdLength)
|
||||||
|
throw new RpcException(new Status(
|
||||||
|
StatusCode.InvalidArgument,
|
||||||
|
"correlation_id must be non-empty and at most 256 characters"));
|
||||||
|
|
||||||
|
// Concurrency cap. The increment is ALWAYS paired: on rejection we decrement here and throw
|
||||||
|
// before entering the try; on acceptance the finally decrements exactly once. No path
|
||||||
|
// decrements without a matching successful increment, and no path double-decrements.
|
||||||
|
var active = Interlocked.Increment(ref _activeStreams);
|
||||||
|
if (active > MaxConcurrentStreams)
|
||||||
|
{
|
||||||
|
Interlocked.Decrement(ref _activeStreams);
|
||||||
|
_log.LogWarning(
|
||||||
|
"Telemetry stream rejected (correlationId={CorrelationId}): {Active} open streams exceeds cap {Cap}",
|
||||||
|
correlationId, active - 1, MaxConcurrentStreams);
|
||||||
|
throw new RpcException(new Status(
|
||||||
|
StatusCode.ResourceExhausted, "too many concurrent telemetry streams"));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken);
|
||||||
|
lifetime.CancelAfter(MaxStreamLifetime);
|
||||||
|
using var sub = _hub.Subscribe(SubscriptionCapacity);
|
||||||
|
|
||||||
|
_log.LogDebug("Telemetry stream opened (correlationId={CorrelationId})", correlationId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (var item in sub.Reader.ReadAllAsync(lifetime.Token).ConfigureAwait(false))
|
||||||
|
await responseStream
|
||||||
|
.WriteAsync(TelemetryProtoMapNode.ToProto(item, correlationId), lifetime.Token)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Normal termination: the client disconnected (context token) or the max-lifetime cap
|
||||||
|
// fired (linked token). Neither is an error — swallow and let the stream close cleanly.
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.LogDebug("Telemetry stream closed (correlationId={CorrelationId})", correlationId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Interlocked.Decrement(ref _activeStreams);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using Xunit;
|
||||||
|
|
||||||
|
// The TelemetryStreamGrpcService concurrency counter is process-wide (static). Run this assembly's
|
||||||
|
// tests serially so the cap test observes a deterministic active-stream count with no cross-test
|
||||||
|
// interference. This project holds only telemetry tests, so serial execution costs nothing.
|
||||||
|
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
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.Commons.Protos.Telemetry.V1;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Host.Grpc;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Grpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the node-side telemetry streaming service (per-cluster mesh Phase 5): the hub is
|
||||||
|
/// fanned to a connected caller as <see cref="TelemetryEvent"/> envelopes, every domain field is
|
||||||
|
/// transcribed onto its proto field, DateTime→Timestamp mapping is Kind-defensive, a cancelled
|
||||||
|
/// context ends the stream cleanly, and the process-wide concurrency cap sheds excess dials with
|
||||||
|
/// <see cref="StatusCode.ResourceExhausted"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TelemetryStreamGrpcServiceTests
|
||||||
|
{
|
||||||
|
private static TelemetryStreamGrpcService NewService(ITelemetryLocalHub hub) =>
|
||||||
|
new(hub, NullLogger<TelemetryStreamGrpcService>.Instance);
|
||||||
|
|
||||||
|
private static ServerCallContext Ctx(CancellationToken token) => new FakeServerCallContext(token);
|
||||||
|
|
||||||
|
private static async Task WaitUntilAsync(Func<bool> condition, string because)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
if (condition())
|
||||||
|
return;
|
||||||
|
await Task.Delay(15);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TimeoutException($"Timed out waiting: {because}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Streams_one_of_each_kind_with_fields_transcribed()
|
||||||
|
{
|
||||||
|
var hub = new TelemetryLocalHub();
|
||||||
|
var service = NewService(hub);
|
||||||
|
var writer = new CapturingStreamWriter();
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var request = new TelemetryStreamRequest { CorrelationId = "corr-1" };
|
||||||
|
|
||||||
|
var publishedHealth = new DateTime(2026, 7, 23, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
var health = new DriverHealthChanged(
|
||||||
|
"cluster-a", "drv-1", "Healthy", LastSuccessfulReadUtc: null, LastError: null,
|
||||||
|
ErrorCount5Min: 3, PublishedUtc: publishedHealth);
|
||||||
|
var resilience = new DriverResilienceStatusChanged(
|
||||||
|
"drv-1", "host-x", BreakerOpen: true, ConsecutiveFailures: 4, CurrentInFlight: 2,
|
||||||
|
LastBreakerOpenUtc: null, LastSampledUtc: DateTime.UtcNow, PublishedUtc: DateTime.UtcNow);
|
||||||
|
|
||||||
|
// Snapshot-style items are cached, so emitting them BEFORE the service attaches guarantees they
|
||||||
|
// are replayed to the fresh subscription — a deterministic "the pump is draining" signal.
|
||||||
|
hub.Emit(new TelemetryItem.Health(health));
|
||||||
|
hub.Emit(new TelemetryItem.Resilience(resilience));
|
||||||
|
|
||||||
|
var pump = service.Subscribe(request, writer, Ctx(cts.Token));
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => writer.Count >= 2, "cached snapshots to be replayed");
|
||||||
|
|
||||||
|
// Append-style items are live-only: emit them once the subscription is proven attached.
|
||||||
|
var alarm = new AlarmTransitionEvent(
|
||||||
|
"raw/dev/Speed", "Area/Line/Equip", "HighSpeed", "Activated", 700, "over limit", "operator1",
|
||||||
|
new DateTime(2026, 7, 23, 11, 0, 0, DateTimeKind.Utc), "LimitAlarm",
|
||||||
|
Comment: "manual ack", HistorizeToAveva: true,
|
||||||
|
ReferencingEquipmentPaths: ["Area/Line/EquipA", "Area/Line/EquipB"]);
|
||||||
|
var script = new ScriptLogEntry(
|
||||||
|
"script-9", "Warning", "threshold near", new DateTime(2026, 7, 23, 12, 0, 0, DateTimeKind.Utc),
|
||||||
|
VirtualTagId: "vt-1", AlarmId: null, EquipmentId: "equip-1");
|
||||||
|
|
||||||
|
hub.Emit(new TelemetryItem.Alarm(alarm));
|
||||||
|
hub.Emit(new TelemetryItem.Script(script));
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => writer.Count >= 4, "all four telemetry events to be streamed");
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
await pump; // cancellation must be swallowed — Subscribe returns cleanly.
|
||||||
|
|
||||||
|
var events = writer.Snapshot();
|
||||||
|
events.ShouldAllBe(e => e.CorrelationId == "corr-1");
|
||||||
|
|
||||||
|
var healthEvt = events.Single(e => e.EventCase == TelemetryEvent.EventOneofCase.DriverHealth);
|
||||||
|
healthEvt.DriverHealth.ClusterId.ShouldBe("cluster-a");
|
||||||
|
healthEvt.DriverHealth.State.ShouldBe("Healthy");
|
||||||
|
healthEvt.DriverHealth.ErrorCount5Min.ShouldBe(3);
|
||||||
|
healthEvt.DriverHealth.PublishedUtc.ToDateTime().ShouldBe(publishedHealth);
|
||||||
|
healthEvt.DriverHealth.LastSuccessfulReadUtc.ShouldBeNull(); // nullable DateTime absent
|
||||||
|
healthEvt.DriverHealth.HasLastError.ShouldBeFalse(); // nullable string absent
|
||||||
|
|
||||||
|
var resilienceEvt = events.Single(e => e.EventCase == TelemetryEvent.EventOneofCase.DriverResilience);
|
||||||
|
resilienceEvt.DriverResilience.DriverInstanceId.ShouldBe("drv-1");
|
||||||
|
resilienceEvt.DriverResilience.BreakerOpen.ShouldBeTrue();
|
||||||
|
resilienceEvt.DriverResilience.LastBreakerOpenUtc.ShouldBeNull();
|
||||||
|
|
||||||
|
var alarmEvt = events.Single(e => e.EventCase == TelemetryEvent.EventOneofCase.AlarmTransition);
|
||||||
|
alarmEvt.AlarmTransition.AlarmId.ShouldBe("raw/dev/Speed");
|
||||||
|
alarmEvt.AlarmTransition.TransitionKind.ShouldBe("Activated");
|
||||||
|
alarmEvt.AlarmTransition.Severity.ShouldBe(700);
|
||||||
|
alarmEvt.AlarmTransition.Comment.ShouldBe("manual ack");
|
||||||
|
alarmEvt.AlarmTransition.HistorizeToAveva.ShouldBeTrue();
|
||||||
|
alarmEvt.AlarmTransition.ReferencingEquipmentPaths.ShouldBe(["Area/Line/EquipA", "Area/Line/EquipB"]);
|
||||||
|
|
||||||
|
var scriptEvt = events.Single(e => e.EventCase == TelemetryEvent.EventOneofCase.ScriptLog);
|
||||||
|
scriptEvt.ScriptLog.ScriptId.ShouldBe("script-9");
|
||||||
|
scriptEvt.ScriptLog.Level.ShouldBe("Warning");
|
||||||
|
scriptEvt.ScriptLog.VirtualTagId.ShouldBe("vt-1");
|
||||||
|
scriptEvt.ScriptLog.HasAlarmId.ShouldBeFalse(); // nullable string absent
|
||||||
|
scriptEvt.ScriptLog.EquipmentId.ShouldBe("equip-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelled_context_ends_stream_cleanly()
|
||||||
|
{
|
||||||
|
var hub = new TelemetryLocalHub();
|
||||||
|
var service = NewService(hub);
|
||||||
|
var writer = new CapturingStreamWriter();
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var request = new TelemetryStreamRequest { CorrelationId = "corr-cancel" };
|
||||||
|
|
||||||
|
var pump = service.Subscribe(request, writer, Ctx(cts.Token));
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
await Should.NotThrowAsync(() => pump); // client disconnect is normal, not an error.
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public async Task Empty_correlation_id_is_invalid_argument(string correlationId)
|
||||||
|
{
|
||||||
|
var service = NewService(new TelemetryLocalHub());
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<RpcException>(() => service.Subscribe(
|
||||||
|
new TelemetryStreamRequest { CorrelationId = correlationId },
|
||||||
|
new CapturingStreamWriter(), Ctx(cts.Token)));
|
||||||
|
|
||||||
|
ex.StatusCode.ShouldBe(StatusCode.InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Over_long_correlation_id_is_invalid_argument()
|
||||||
|
{
|
||||||
|
var service = NewService(new TelemetryLocalHub());
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<RpcException>(() => service.Subscribe(
|
||||||
|
new TelemetryStreamRequest { CorrelationId = new string('x', 257) },
|
||||||
|
new CapturingStreamWriter(), Ctx(cts.Token)));
|
||||||
|
|
||||||
|
ex.StatusCode.ShouldBe(StatusCode.InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Concurrency_cap_sheds_the_over_the_limit_dial()
|
||||||
|
{
|
||||||
|
// Serial assembly (see AssemblyInfo) => the static active-stream counter starts at 0 here.
|
||||||
|
var hub = new TelemetryLocalHub();
|
||||||
|
var service = NewService(hub);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
// Open exactly the cap. Each stream blocks in the pump (hub emits nothing and is never completed),
|
||||||
|
// so all 100 stay open, holding the counter at 100.
|
||||||
|
var open = new List<Task>();
|
||||||
|
for (var i = 0; i < 100; i++)
|
||||||
|
open.Add(service.Subscribe(
|
||||||
|
new TelemetryStreamRequest { CorrelationId = $"cap-{i}" },
|
||||||
|
new CapturingStreamWriter(), Ctx(cts.Token)));
|
||||||
|
|
||||||
|
// The 101st dial must be rejected.
|
||||||
|
var ex = await Should.ThrowAsync<RpcException>(() => service.Subscribe(
|
||||||
|
new TelemetryStreamRequest { CorrelationId = "cap-overflow" },
|
||||||
|
new CapturingStreamWriter(), Ctx(cts.Token)));
|
||||||
|
ex.StatusCode.ShouldBe(StatusCode.ResourceExhausted);
|
||||||
|
|
||||||
|
// Drain the 100 open streams; the counter returns to 0 for the next test.
|
||||||
|
cts.Cancel();
|
||||||
|
await Task.WhenAll(open);
|
||||||
|
|
||||||
|
// With the counter back at 0, a fresh single dial is accepted (proves no leak / no double-count).
|
||||||
|
using var cts2 = new CancellationTokenSource();
|
||||||
|
var writer = new CapturingStreamWriter();
|
||||||
|
var pump = service.Subscribe(
|
||||||
|
new TelemetryStreamRequest { CorrelationId = "cap-after" }, writer, Ctx(cts2.Token));
|
||||||
|
cts2.Cancel();
|
||||||
|
await Should.NotThrowAsync(() => pump);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToProto_health_with_unspecified_kind_datetime_does_not_throw_and_roundtrips()
|
||||||
|
{
|
||||||
|
// Producers use UtcNow, but a Kind=Unspecified value must never crash the stream. The mapper
|
||||||
|
// assumes an Unspecified value is already-UTC, so the instant round-trips on any machine.
|
||||||
|
var wall = new DateTime(2026, 7, 23, 13, 30, 0, DateTimeKind.Unspecified);
|
||||||
|
var expectedUtc = DateTime.SpecifyKind(wall, DateTimeKind.Utc);
|
||||||
|
var health = new DriverHealthChanged(
|
||||||
|
"cluster-a", "drv-1", "Healthy", LastSuccessfulReadUtc: wall, LastError: null,
|
||||||
|
ErrorCount5Min: 0, PublishedUtc: wall);
|
||||||
|
|
||||||
|
TelemetryEvent evt = null!;
|
||||||
|
Should.NotThrow(() => evt = TelemetryProtoMapNode.ToProto(new TelemetryItem.Health(health), "c"));
|
||||||
|
|
||||||
|
evt.DriverHealth.PublishedUtc.ToDateTime().ShouldBe(expectedUtc);
|
||||||
|
evt.DriverHealth.LastSuccessfulReadUtc.ToDateTime().ShouldBe(expectedUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToProto_alarm_omits_absent_nullables()
|
||||||
|
{
|
||||||
|
var alarm = new AlarmTransitionEvent(
|
||||||
|
"id", "Area/Line/Equip", "Name", "Cleared", 100, "msg", "system",
|
||||||
|
DateTime.UtcNow, Comment: null, HistorizeToAveva: null, ReferencingEquipmentPaths: null);
|
||||||
|
|
||||||
|
var msg = TelemetryProtoMapNode.ToProto(new TelemetryItem.Alarm(alarm), "c").AlarmTransition;
|
||||||
|
|
||||||
|
msg.HasComment.ShouldBeFalse();
|
||||||
|
msg.HasHistorizeToAveva.ShouldBeFalse();
|
||||||
|
msg.ReferencingEquipmentPaths.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Minimal <see cref="ServerCallContext"/> exposing a caller-controlled cancellation token.</summary>
|
||||||
|
private sealed class FakeServerCallContext(CancellationToken token) : ServerCallContext
|
||||||
|
{
|
||||||
|
protected override string MethodCore => "/telemetry.v1.TelemetryStreamService/Subscribe";
|
||||||
|
protected override string HostCore => "localhost";
|
||||||
|
protected override string PeerCore => "ipv4:127.0.0.1:0";
|
||||||
|
protected override DateTime DeadlineCore => DateTime.MaxValue;
|
||||||
|
protected override Metadata RequestHeadersCore => [];
|
||||||
|
protected override CancellationToken CancellationTokenCore => token;
|
||||||
|
protected override Metadata ResponseTrailersCore { get; } = [];
|
||||||
|
protected override Status StatusCore { get; set; }
|
||||||
|
protected override WriteOptions? WriteOptionsCore { get; set; }
|
||||||
|
protected override AuthContext AuthContextCore => new(null, new Dictionary<string, List<AuthProperty>>());
|
||||||
|
|
||||||
|
protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captures every streamed <see cref="TelemetryEvent"/>. Implements the cancellable
|
||||||
|
/// <c>WriteAsync</c> overload explicitly — otherwise the interface's default method throws
|
||||||
|
/// <see cref="NotSupportedException"/> for the pump's cancellable lifetime token.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class CapturingStreamWriter : IServerStreamWriter<TelemetryEvent>
|
||||||
|
{
|
||||||
|
private readonly List<TelemetryEvent> _items = [];
|
||||||
|
private readonly Lock _gate = new();
|
||||||
|
|
||||||
|
public WriteOptions? WriteOptions { get; set; }
|
||||||
|
|
||||||
|
public int Count
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
return _items.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task WriteAsync(TelemetryEvent message)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
_items.Add(message);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task WriteAsync(TelemetryEvent message, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
return WriteAsync(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TelemetryEvent> Snapshot()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
return [.. _items];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Host.Tests</RootNamespace>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit"/>
|
||||||
|
<PackageReference Include="Shouldly"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Host\ZB.MOM.WW.OtOpcUa.Host.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Runtime\ZB.MOM.WW.OtOpcUa.Runtime.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user