feat(historian-gateway): wire ContinuousHistorizationRecorder into DI + hosted lifecycle + meters
Bind ContinuousHistorizationOptions (Enabled/OutboxPath/CommitMode/ CommitIntervalMs/DrainBatchSize/DrainIntervalSeconds/Capacity/backoff) with a warn-only Validate(); gated on Enabled AND the ServerHistorian gateway being configured, the Host registers the durable FasterLogHistorizationOutbox (container -disposed) + a gateway-backed GatewayHistorianValueWriter, and binds outbox depth/dropped observable gauges on the central scraped meter. WithOtOpcUaRuntimeActors spawns the recorder (over the same dependency-mux ref) when the options + writer + outbox resolve, registering ContinuousHistorizationRecorderKey. Spawned with an EMPTY historized-ref set: the deployed address space builds later, so ref population is a documented follow-on (a later SetHistorizedRefs feed) — T18 wires the actor + outbox + writer + meters; the ref feed is the known remaining gap. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
This commit is contained in:
+49
@@ -0,0 +1,49 @@
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="ContinuousHistorizationOptions.Validate"/> self-gates on <c>Enabled</c> and
|
||||
/// warns (warn-only, never blocks startup) on the two gated misconfigurations: a blank
|
||||
/// <c>OutboxPath</c> while enabled, and a non-positive <c>CommitIntervalMs</c> under
|
||||
/// <c>Periodic</c> commit mode. No warning text carries a secret value.
|
||||
/// </summary>
|
||||
public sealed class ContinuousHistorizationOptionsTests
|
||||
{
|
||||
/// <summary>A disabled recorder yields no warnings regardless of the other knobs.</summary>
|
||||
[Fact]
|
||||
public void Disabled_no_warnings()
|
||||
=> Assert.Empty(new ContinuousHistorizationOptions { Enabled = false }.Validate());
|
||||
|
||||
/// <summary>An enabled recorder with a blank outbox directory warns about <c>OutboxPath</c>.</summary>
|
||||
[Fact]
|
||||
public void Enabled_requires_outbox_path()
|
||||
=> Assert.Contains(
|
||||
new ContinuousHistorizationOptions { Enabled = true, OutboxPath = "" }.Validate(),
|
||||
m => m.Contains("OutboxPath"));
|
||||
|
||||
/// <summary>Periodic commit mode with a non-positive interval warns about <c>CommitIntervalMs</c>.</summary>
|
||||
[Fact]
|
||||
public void Periodic_requires_positive_interval()
|
||||
=> Assert.Contains(
|
||||
new ContinuousHistorizationOptions
|
||||
{
|
||||
Enabled = true,
|
||||
OutboxPath = "x",
|
||||
CommitMode = "Periodic",
|
||||
CommitIntervalMs = 0,
|
||||
}.Validate(),
|
||||
m => m.Contains("CommitIntervalMs"));
|
||||
|
||||
/// <summary>A fully-configured enabled recorder produces no warnings.</summary>
|
||||
[Fact]
|
||||
public void Valid_config_is_clean()
|
||||
=> Assert.Empty(
|
||||
new ContinuousHistorizationOptions
|
||||
{
|
||||
Enabled = true,
|
||||
OutboxPath = "/var/lib/otopcua/historization",
|
||||
CommitMode = "PerEntry",
|
||||
}.Validate());
|
||||
}
|
||||
@@ -7,6 +7,8 @@ using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests;
|
||||
|
||||
@@ -71,6 +73,106 @@ public sealed class ServiceCollectionExtensionsTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When <c>ContinuousHistorization</c> is not enabled (no options registered), the recorder
|
||||
/// actor is NOT spawned — its key does not resolve in the registry.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Recorder_not_spawned_when_continuous_historization_disabled()
|
||||
{
|
||||
using var host = BuildRuntimeActorHost(extra: null);
|
||||
|
||||
await host.StartAsync();
|
||||
try
|
||||
{
|
||||
var registry = host.Services.GetRequiredService<ActorRegistry>();
|
||||
registry.TryGet<ContinuousHistorizationRecorderKey>(out _).ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When <c>ContinuousHistorization</c> is enabled and a value-writer + outbox are registered,
|
||||
/// the recorder actor IS spawned and its key resolves under the expected actor name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Recorder_spawned_when_enabled_with_writer_and_outbox()
|
||||
{
|
||||
using var host = BuildRuntimeActorHost(extra: services =>
|
||||
{
|
||||
services.AddSingleton(new ContinuousHistorizationOptions { Enabled = true, OutboxPath = "x" });
|
||||
services.AddSingleton<IHistorianValueWriter>(new FakeValueWriter());
|
||||
services.AddSingleton<IHistorizationOutbox>(new FakeOutbox());
|
||||
});
|
||||
|
||||
await host.StartAsync();
|
||||
try
|
||||
{
|
||||
var recorder = host.Services.GetRequiredService<IRequiredActor<ContinuousHistorizationRecorderKey>>();
|
||||
recorder.ActorRef.ShouldNotBeNull();
|
||||
recorder.ActorRef.Path.Name.ShouldBe(ServiceCollectionExtensions.ContinuousHistorizationRecorderActorName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Builds a driver-role host that runs <c>WithOtOpcUaRuntimeActors</c>, with optional
|
||||
/// extra DI registrations applied before <c>AddAkka</c>.</summary>
|
||||
private static IHost BuildRuntimeActorHost(Action<IServiceCollection>? extra)
|
||||
=> Host.CreateDefaultBuilder()
|
||||
.ConfigureServices((_, services) =>
|
||||
{
|
||||
services.AddSingleton<IDbContextFactory<OtOpcUaConfigDbContext>>(
|
||||
new InMemoryConfigDbFactory(Guid.NewGuid().ToString("N")));
|
||||
services.AddSingleton<IClusterRoleInfo>(new FakeClusterRoleInfo());
|
||||
extra?.Invoke(services);
|
||||
|
||||
services.AddAkka("otopcua-test", (ab, _) =>
|
||||
{
|
||||
ab.AddHocon(@"
|
||||
akka.actor.provider = ""Akka.Cluster.ClusterActorRefProvider, Akka.Cluster""
|
||||
akka.remote.dot-netty.tcp.hostname = ""127.0.0.1""
|
||||
akka.remote.dot-netty.tcp.port = 0
|
||||
akka.cluster.seed-nodes = []
|
||||
akka.cluster.roles = [""driver""]
|
||||
", HoconAddMode.Prepend);
|
||||
ab.WithOtOpcUaRuntimeActors();
|
||||
});
|
||||
})
|
||||
.Build();
|
||||
|
||||
/// <summary>Non-throwing fake value writer that acks every batch.</summary>
|
||||
private sealed class FakeValueWriter : IHistorianValueWriter
|
||||
{
|
||||
/// <summary>Acks the write unconditionally.</summary>
|
||||
public Task<bool> WriteLiveValuesAsync(
|
||||
string tag, IReadOnlyList<HistorizationValue> values, CancellationToken ct)
|
||||
=> Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <summary>Empty in-memory outbox fake — the spawn test only needs construction, not draining.</summary>
|
||||
private sealed class FakeOutbox : IHistorizationOutbox
|
||||
{
|
||||
/// <summary>Never drops (unbounded).</summary>
|
||||
public long DroppedCount => 0;
|
||||
/// <summary>No-op append.</summary>
|
||||
public ValueTask AppendAsync(HistorizationOutboxEntry entry, CancellationToken ct) => ValueTask.CompletedTask;
|
||||
/// <summary>Always returns an empty batch.</summary>
|
||||
public ValueTask<IReadOnlyList<HistorizationOutboxEntry>> PeekBatchAsync(int max, CancellationToken ct)
|
||||
=> ValueTask.FromResult<IReadOnlyList<HistorizationOutboxEntry>>(Array.Empty<HistorizationOutboxEntry>());
|
||||
/// <summary>No-op remove.</summary>
|
||||
public ValueTask RemoveAsync(Guid id, CancellationToken ct) => ValueTask.CompletedTask;
|
||||
/// <summary>Always empty.</summary>
|
||||
public ValueTask<int> CountAsync(CancellationToken ct) => ValueTask.FromResult(0);
|
||||
/// <summary>No-op dispose.</summary>
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>In-memory database factory for testing.</summary>
|
||||
private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user