79d6b0a4b2
- DriverHostActor.StopChild removes the driver's _lastReprovisionUtc stamp (and its deferred-reprovision timer): the map grew per removed id and a re-added driver inherited a backoff it never earned (02/L2). - Stale now registers explicit drops for AttributeValueBatchPublished, AttributeAlarmPublished, ArtifactForApply and ApplyWedged instead of letting them hit Unhandled (02/H5). - DbHealthProbeActor runs its SELECT on the thread pool and pipes the outcome back (single-flight), with a 2 s command timeout on relational providers, so a hung SQL Server no longer pins the actor thread and the cached GetStatus answer stays reachable (02/P7). - TelemetryLocalHub eviction tombstones are timestamped and swept past a 10 min TTL on the next Evict; a TimeProvider ctor is the test seam (02/L4). - Tests: ValuePublishes.Single helper replaces the per-value AttributeValuePublished in CalculationDependencyFlowTests (02/U4, test half; the production receive is owned by Lane E). - R05/8 audit: PollGroupEngine.Unsubscribe has no production caller; every driver already uses UnsubscribeAsync (no change). Refs #731 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
141 lines
5.7 KiB
C#
141 lines
5.7 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using Akka.Actor;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Health;
|
|
|
|
public sealed class HealthProbeActorTests : RuntimeActorTestBase
|
|
{
|
|
/// <summary>Verifies that the DB health probe actor returns reachable status against an in-memory database.
|
|
/// The probe runs off the mailbox (02/P7), so the first Ask may still see "not probed yet" — poll.</summary>
|
|
[Fact]
|
|
public async Task DbHealthProbeActor_returns_reachable_against_in_memory_db()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var actor = Sys.ActorOf(DbHealthProbeActor.Props(db));
|
|
|
|
await AwaitAssertAsync(async () =>
|
|
{
|
|
var status = await actor.Ask<DbHealthProbeActor.DbHealthStatus>(
|
|
DbHealthProbeActor.GetStatus.Instance, PresenceBudget);
|
|
|
|
status.Reachable.ShouldBeTrue();
|
|
status.LastError.ShouldBeNull();
|
|
}, PresenceBudget);
|
|
}
|
|
|
|
/// <summary>Verifies that the peer OPC UA probe actor reports Ok true against a live listener.</summary>
|
|
[Fact]
|
|
public void PeerOpcUaProbeActor_reports_Ok_true_against_a_live_listener()
|
|
{
|
|
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
|
|
var received = new System.Collections.Generic.List<object>();
|
|
Sys.ActorOf(PeerOpcUaProbeActor.Props(
|
|
NodeId.Parse($"127.0.0.1:{port}"),
|
|
interval: TimeSpan.FromMilliseconds(50),
|
|
connectTimeout: TimeSpan.FromMilliseconds(500),
|
|
opcUaPort: port,
|
|
broadcast: msg => received.Add(msg)));
|
|
|
|
AwaitCondition(() => received.OfType<PeerOpcUaProbeActor.OpcUaProbeResult>().Any(r => r.Ok),
|
|
TimeSpan.FromSeconds(3));
|
|
}
|
|
|
|
/// <summary>Verifies that the peer OPC UA probe actor reports Ok false against an unreachable endpoint.</summary>
|
|
[Fact]
|
|
public void PeerOpcUaProbeActor_reports_Ok_false_against_an_unreachable_endpoint()
|
|
{
|
|
// Port 1 is reserved (tcpmux) and almost never bound on dev machines, so the connect fails fast.
|
|
var received = new System.Collections.Generic.List<object>();
|
|
Sys.ActorOf(PeerOpcUaProbeActor.Props(
|
|
NodeId.Parse("127.0.0.1:1"),
|
|
interval: TimeSpan.FromMilliseconds(50),
|
|
connectTimeout: TimeSpan.FromMilliseconds(300),
|
|
opcUaPort: 1,
|
|
broadcast: msg => received.Add(msg)));
|
|
|
|
AwaitCondition(() => received.OfType<PeerOpcUaProbeActor.OpcUaProbeResult>().Any(r => !r.Ok),
|
|
TimeSpan.FromSeconds(3));
|
|
}
|
|
|
|
/// <summary>Verifies that the historian adapter actor forwards events to the injected sink.</summary>
|
|
[Fact]
|
|
public void HistorianAdapterActor_forwards_events_to_injected_sink()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var actor = Sys.ActorOf(HistorianAdapterActor.Props(sink));
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
actor.Tell(new AlarmHistorianEvent(
|
|
AlarmId: $"alm-{i}",
|
|
EquipmentPath: "Plant/LineA",
|
|
AlarmName: $"Alarm{i}",
|
|
AlarmTypeName: "LimitAlarm",
|
|
Severity: AlarmSeverity.High,
|
|
EventKind: "Activated",
|
|
Message: $"Test alarm {i}",
|
|
User: "system",
|
|
Comment: null,
|
|
TimestampUtc: DateTime.UtcNow));
|
|
|
|
AwaitCondition(() => sink.Enqueued.Count == 5, TimeSpan.FromSeconds(2));
|
|
sink.Enqueued.Select(e => e.AlarmId).OrderBy(s => s).ShouldBe(
|
|
new[] { "alm-0", "alm-1", "alm-2", "alm-3", "alm-4" });
|
|
}
|
|
|
|
/// <summary>Verifies that the historian adapter actor returns sink status via GetStatus.</summary>
|
|
[Fact]
|
|
public async Task HistorianAdapterActor_returns_sink_status_via_GetStatus()
|
|
{
|
|
var sink = new RecordingSink();
|
|
var actor = Sys.ActorOf(HistorianAdapterActor.Props(sink));
|
|
|
|
actor.Tell(new AlarmHistorianEvent(
|
|
"alm-x", "Plant/LineB", "OffNormal", "OffNormalAlarm",
|
|
AlarmSeverity.Low, "Activated", "msg", "system", null, DateTime.UtcNow));
|
|
AwaitCondition(() => sink.Enqueued.Count == 1, TimeSpan.FromSeconds(2));
|
|
|
|
var status = await actor.Ask<HistorianSinkStatus>(
|
|
HistorianAdapterActor.GetStatus.Instance, TimeSpan.FromSeconds(2));
|
|
|
|
status.QueueDepth.ShouldBe(1);
|
|
status.DrainState.ShouldBe(HistorianDrainState.Idle);
|
|
}
|
|
|
|
private sealed class RecordingSink : IAlarmHistorianSink
|
|
{
|
|
/// <summary>Gets the list of enqueued alarm historian events.</summary>
|
|
public ConcurrentBag<AlarmHistorianEvent> Enqueued { get; } = [];
|
|
|
|
/// <summary>Enqueues an alarm historian event.</summary>
|
|
/// <param name="evt">The event to enqueue.</param>
|
|
/// <param name="cancellationToken">The cancellation token.</param>
|
|
public Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken)
|
|
{
|
|
Enqueued.Add(evt);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>Gets the current status of the historian sink.</summary>
|
|
public HistorianSinkStatus GetStatus() => new(
|
|
QueueDepth: Enqueued.Count,
|
|
DeadLetterDepth: 0,
|
|
LastDrainUtc: null,
|
|
LastSuccessUtc: null,
|
|
LastError: null,
|
|
DrainState: HistorianDrainState.Idle);
|
|
}
|
|
}
|