feat(runtime): F11 — HistorianAdapterActor wired to IAlarmHistorianSink
Some checks failed
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been cancelled
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been cancelled
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been cancelled
v2-ci / build (push) Has been cancelled
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been cancelled
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been cancelled
v2-ci / integration (push) Has been cancelled
Some checks failed
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been cancelled
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been cancelled
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been cancelled
v2-ci / build (push) Has been cancelled
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been cancelled
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been cancelled
v2-ci / integration (push) Has been cancelled
Reshapes the placeholder buffered-counter actor into a thin fire-and-forget bridge over the existing IAlarmHistorianSink contract. Default sink is NullAlarmHistorianSink; production deployments override the DI binding to SqliteStoreAndForwardSink wrapping WonderwareHistorianClient (the v1 components in src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware* are reused verbatim — actor is just a mailbox-friendly entry point). - HistorianAdapterActor.Props(IAlarmHistorianSink?) — null defaults to NullAlarmHistorianSink - Receive<AlarmHistorianEvent>: fire-and-forget sink.EnqueueAsync - Receive<GetStatus>: returns sink.GetStatus() (queue depth + drain state) - ServiceCollectionExtensions.AddOtOpcUaRuntime registers the default sink - WithOtOpcUaRuntimeActors spawns the actor + registers HistorianAdapterActorKey - Program.cs calls AddOtOpcUaRuntime when hasDriver Tests: 2 new (forward-to-sink + GetStatus). Runtime suite 17 → 18.
This commit is contained in:
@@ -38,6 +38,9 @@ builder.Host.UseSerilog((ctx, lc) => lc
|
|||||||
builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
|
builder.Services.AddOtOpcUaConfigDb(builder.Configuration);
|
||||||
builder.Services.AddOtOpcUaCluster(builder.Configuration);
|
builder.Services.AddOtOpcUaCluster(builder.Configuration);
|
||||||
|
|
||||||
|
if (hasDriver)
|
||||||
|
builder.Services.AddOtOpcUaRuntime();
|
||||||
|
|
||||||
// Akka cluster bootstrap. Role-specific singletons are registered on the AkkaConfigurationBuilder
|
// Akka cluster bootstrap. Role-specific singletons are registered on the AkkaConfigurationBuilder
|
||||||
// from inside the configurator lambda. AddAkka spins the ActorSystem at host start.
|
// from inside the configurator lambda. AddAkka spins the ActorSystem at host start.
|
||||||
builder.Services.AddAkka("otopcua", (ab, sp) =>
|
builder.Services.AddAkka("otopcua", (ab, sp) =>
|
||||||
|
|||||||
@@ -1,32 +1,58 @@
|
|||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Akka.Event;
|
using Akka.Event;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wraps the named-pipe IPC to the Wonderware historian sidecar with a store-and-forward
|
/// Thin actor wrapper around <see cref="IAlarmHistorianSink"/>. Engine code (ScriptedAlarmActor,
|
||||||
/// SQLite buffer for pipe outages. Engine wiring (named-pipe client + <c>SqliteStoreAndForwardSink</c>)
|
/// Galaxy native alarm bridge, AB CIP ALMD reader) tells <see cref="AlarmHistorianEvent"/>s to this
|
||||||
/// is staged for follow-up F11.
|
/// actor; the actor enqueues them on the sink fire-and-forget. Production deployments register
|
||||||
|
/// <see cref="SqliteStoreAndForwardSink"/> against <c>IAlarmHistorianSink</c>; the sink owns the
|
||||||
|
/// durable queue + drain-to-Wonderware-pipe loop. The actor here owns nothing operational beyond
|
||||||
|
/// the message contract — its job is to keep the engine actors on Akka's mailbox without blocking
|
||||||
|
/// them on disk I/O or pipe handshakes.
|
||||||
|
///
|
||||||
|
/// Query queue depth + drain health via <see cref="GetStatus"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HistorianAdapterActor : ReceiveActor
|
public sealed class HistorianAdapterActor : ReceiveActor
|
||||||
{
|
{
|
||||||
public sealed record HistoryRow(string Source, string AttributeId, object? Value, DateTime TimestampUtc);
|
public sealed record GetStatus
|
||||||
|
|
||||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
|
||||||
private int _buffered;
|
|
||||||
|
|
||||||
public int BufferedCount => _buffered;
|
|
||||||
|
|
||||||
public static Props Props() => Akka.Actor.Props.Create(() => new HistorianAdapterActor());
|
|
||||||
|
|
||||||
public HistorianAdapterActor()
|
|
||||||
{
|
{
|
||||||
Receive<HistoryRow>(row =>
|
public static readonly GetStatus Instance = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly IAlarmHistorianSink _sink;
|
||||||
|
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||||
|
|
||||||
|
public static Props Props(IAlarmHistorianSink? sink = null) =>
|
||||||
|
Akka.Actor.Props.Create(() => new HistorianAdapterActor(sink ?? NullAlarmHistorianSink.Instance));
|
||||||
|
|
||||||
|
public HistorianAdapterActor(IAlarmHistorianSink sink)
|
||||||
|
{
|
||||||
|
_sink = sink;
|
||||||
|
|
||||||
|
Receive<AlarmHistorianEvent>(evt =>
|
||||||
{
|
{
|
||||||
// F11: dispatch to named-pipe sink; on disconnect → buffer in SQLite.
|
// Fire-and-forget: SqliteStoreAndForwardSink persists to local SQLite synchronously
|
||||||
Interlocked.Increment(ref _buffered);
|
// inside EnqueueAsync (it returns once the row is committed), so we don't block on
|
||||||
_log.Debug("Historian: buffered row for {Source}/{Attr} (sink wiring staged for F11)",
|
// network/pipe latency. Failures are surfaced via GetStatus's LastError + drain state.
|
||||||
row.Source, row.AttributeId);
|
_ = EnqueueAsync(evt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Receive<GetStatus>(_ => Sender.Tell(_sink.GetStatus()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnqueueAsync(AlarmHistorianEvent evt)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _sink.EnqueueAsync(evt, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.Error(ex, "Historian sink rejected event for {AlarmId} at {Ts}",
|
||||||
|
evt.AlarmId, evt.TimestampUtc);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Akka.Hosting;
|
using Akka.Hosting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
|
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Runtime;
|
namespace ZB.MOM.WW.OtOpcUa.Runtime;
|
||||||
|
|
||||||
@@ -14,11 +18,25 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
public const string DriverHostActorName = "driver-host";
|
public const string DriverHostActorName = "driver-host";
|
||||||
public const string DbHealthProbeActorName = "db-health";
|
public const string DbHealthProbeActorName = "db-health";
|
||||||
|
public const string HistorianAdapterActorName = "historian-adapter";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers shared runtime services. Currently binds <see cref="IAlarmHistorianSink"/>
|
||||||
|
/// to <see cref="NullAlarmHistorianSink"/> as the default; production deployments
|
||||||
|
/// override this with <c>SqliteStoreAndForwardSink</c> wrapping <c>WonderwareHistorianClient</c>.
|
||||||
|
/// Call this BEFORE <c>AddAkka</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddOtOpcUaRuntime(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.TryAddSingleton<IAlarmHistorianSink>(NullAlarmHistorianSink.Instance);
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Spawns the per-node driver-role actors on the host's <see cref="ActorSystem"/>:
|
/// Spawns the per-node driver-role actors on the host's <see cref="ActorSystem"/>:
|
||||||
/// <see cref="DriverHostActor"/> (one per node) and <see cref="DbHealthProbeActor"/>
|
/// <see cref="DriverHostActor"/> (one per node), <see cref="DbHealthProbeActor"/>
|
||||||
/// (consumed by the health endpoint + redundancy calc).
|
/// (consumed by the health endpoint + redundancy calc), and
|
||||||
|
/// <see cref="HistorianAdapterActor"/> wrapping the registered <see cref="IAlarmHistorianSink"/>.
|
||||||
///
|
///
|
||||||
/// Mirror of <c>WithOtOpcUaControlPlaneSingletons</c> for the driver role. Both must
|
/// Mirror of <c>WithOtOpcUaControlPlaneSingletons</c> for the driver role. Both must
|
||||||
/// be registered on the same <see cref="AkkaConfigurationBuilder"/> as the cluster
|
/// be registered on the same <see cref="AkkaConfigurationBuilder"/> as the cluster
|
||||||
@@ -26,8 +44,8 @@ public static class ServiceCollectionExtensions
|
|||||||
///
|
///
|
||||||
/// Wire from the fused Host's Program.cs when the node carries the <c>driver</c> role:
|
/// Wire from the fused Host's Program.cs when the node carries the <c>driver</c> role:
|
||||||
/// <code>
|
/// <code>
|
||||||
/// if (hasDriver)
|
/// services.AddOtOpcUaRuntime();
|
||||||
/// ab.WithOtOpcUaRuntimeActors();
|
/// services.AddAkka("otopcua", (ab, sp) => { ab.WithOtOpcUaClusterBootstrap(sp); if (hasDriver) ab.WithOtOpcUaRuntimeActors(); });
|
||||||
/// </code>
|
/// </code>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static AkkaConfigurationBuilder WithOtOpcUaRuntimeActors(this AkkaConfigurationBuilder builder)
|
public static AkkaConfigurationBuilder WithOtOpcUaRuntimeActors(this AkkaConfigurationBuilder builder)
|
||||||
@@ -36,6 +54,8 @@ public static class ServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
var dbFactory = resolver.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
|
var dbFactory = resolver.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
|
||||||
var roleInfo = resolver.GetService<IClusterRoleInfo>();
|
var roleInfo = resolver.GetService<IClusterRoleInfo>();
|
||||||
|
// Fallback to NullAlarmHistorianSink if AddOtOpcUaRuntime wasn't called (e.g., test harnesses).
|
||||||
|
var historianSink = resolver.GetService<IAlarmHistorianSink>() ?? NullAlarmHistorianSink.Instance;
|
||||||
|
|
||||||
var dbHealth = system.ActorOf(
|
var dbHealth = system.ActorOf(
|
||||||
DbHealthProbeActor.Props(dbFactory),
|
DbHealthProbeActor.Props(dbFactory),
|
||||||
@@ -46,6 +66,11 @@ public static class ServiceCollectionExtensions
|
|||||||
DriverHostActor.Props(dbFactory, roleInfo.LocalNode),
|
DriverHostActor.Props(dbFactory, roleInfo.LocalNode),
|
||||||
DriverHostActorName);
|
DriverHostActorName);
|
||||||
registry.Register<DriverHostActorKey>(driverHost);
|
registry.Register<DriverHostActorKey>(driverHost);
|
||||||
|
|
||||||
|
var historian = system.ActorOf(
|
||||||
|
HistorianAdapterActor.Props(historianSink),
|
||||||
|
HistorianAdapterActorName);
|
||||||
|
registry.Register<HistorianAdapterActorKey>(historian);
|
||||||
});
|
});
|
||||||
|
|
||||||
return builder;
|
return builder;
|
||||||
@@ -55,3 +80,4 @@ public static class ServiceCollectionExtensions
|
|||||||
/// <summary>Marker key types used by <c>Akka.Hosting</c> to resolve runtime actors from the registry.</summary>
|
/// <summary>Marker key types used by <c>Akka.Hosting</c> to resolve runtime actors from the registry.</summary>
|
||||||
public sealed class DriverHostActorKey { }
|
public sealed class DriverHostActorKey { }
|
||||||
public sealed class DbHealthProbeActorKey { }
|
public sealed class DbHealthProbeActorKey { }
|
||||||
|
public sealed class HistorianAdapterActorKey { }
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
the reflective-load design.
|
the reflective-load design.
|
||||||
-->
|
-->
|
||||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
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.Health;
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||||
@@ -61,14 +64,63 @@ public sealed class HealthProbeActorTests : RuntimeActorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HistorianAdapterActor_buffers_rows()
|
public void HistorianAdapterActor_forwards_events_to_injected_sink()
|
||||||
{
|
{
|
||||||
var actor = Sys.ActorOf(HistorianAdapterActor.Props());
|
var sink = new RecordingSink();
|
||||||
for (var i = 0; i < 5; i++)
|
var actor = Sys.ActorOf(HistorianAdapterActor.Props(sink));
|
||||||
actor.Tell(new HistorianAdapterActor.HistoryRow("driver-a", $"tag-{i}", i, DateTime.UtcNow));
|
|
||||||
|
|
||||||
ExpectNoMsg(TimeSpan.FromMilliseconds(100));
|
for (var i = 0; i < 5; i++)
|
||||||
// No direct readback of the count from a sealed actor — assert by Ask of a self-probe later
|
actor.Tell(new AlarmHistorianEvent(
|
||||||
// when the engine wiring lands (F11). For now this asserts the actor accepts the contract.
|
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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[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
|
||||||
|
{
|
||||||
|
public ConcurrentBag<AlarmHistorianEvent> Enqueued { get; } = [];
|
||||||
|
|
||||||
|
public Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Enqueued.Add(evt);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HistorianSinkStatus GetStatus() => new(
|
||||||
|
QueueDepth: Enqueued.Count,
|
||||||
|
DeadLetterDepth: 0,
|
||||||
|
LastDrainUtc: null,
|
||||||
|
LastSuccessUtc: null,
|
||||||
|
LastError: null,
|
||||||
|
DrainState: HistorianDrainState.Idle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,11 +46,14 @@ public sealed class ServiceCollectionExtensionsTests
|
|||||||
{
|
{
|
||||||
var driverHost = host.Services.GetRequiredService<IRequiredActor<DriverHostActorKey>>();
|
var driverHost = host.Services.GetRequiredService<IRequiredActor<DriverHostActorKey>>();
|
||||||
var dbHealth = host.Services.GetRequiredService<IRequiredActor<DbHealthProbeActorKey>>();
|
var dbHealth = host.Services.GetRequiredService<IRequiredActor<DbHealthProbeActorKey>>();
|
||||||
|
var historian = host.Services.GetRequiredService<IRequiredActor<HistorianAdapterActorKey>>();
|
||||||
|
|
||||||
driverHost.ActorRef.ShouldNotBeNull();
|
driverHost.ActorRef.ShouldNotBeNull();
|
||||||
dbHealth.ActorRef.ShouldNotBeNull();
|
dbHealth.ActorRef.ShouldNotBeNull();
|
||||||
|
historian.ActorRef.ShouldNotBeNull();
|
||||||
driverHost.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.DriverHostActorName);
|
driverHost.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.DriverHostActorName);
|
||||||
dbHealth.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.DbHealthProbeActorName);
|
dbHealth.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.DbHealthProbeActorName);
|
||||||
|
historian.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.HistorianAdapterActorName);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user