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:
@@ -23,6 +23,8 @@ using ZB.MOM.WW.OtOpcUa.Host.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Scripting;
|
||||
@@ -124,6 +126,49 @@ if (hasDriver)
|
||||
builder.Configuration,
|
||||
(opts, sp) => GatewayHistorian.CreateDataSource(opts, sp));
|
||||
|
||||
// Continuous historization of driver (non-Galaxy) tag values. Gated on ContinuousHistorization:Enabled
|
||||
// AND the ServerHistorian gateway being configured: the recorder drains driver-tag live values to the
|
||||
// SAME single gateway's WriteLiveValues SQL path, sourcing endpoint/key/TLS from the ServerHistorian
|
||||
// section (this section carries only the recorder + outbox knobs). When both are on, register the durable
|
||||
// crash-safe outbox + the gateway-backed live-value writer here; WithOtOpcUaRuntimeActors (below) spawns
|
||||
// the recorder actor itself, gated on the same options.
|
||||
var continuousHistorizationOptions = builder.Configuration
|
||||
.GetSection(ContinuousHistorizationOptions.SectionName).Get<ContinuousHistorizationOptions>()
|
||||
?? new ContinuousHistorizationOptions();
|
||||
foreach (var warning in continuousHistorizationOptions.Validate())
|
||||
Log.Warning("ContinuousHistorization misconfiguration detected at startup: {Warning}", warning);
|
||||
if (serverHistorianOptions.Enabled && continuousHistorizationOptions.Enabled)
|
||||
{
|
||||
// Register the bound options so WithOtOpcUaRuntimeActors can gate the recorder spawn on Enabled.
|
||||
builder.Services.AddSingleton(continuousHistorizationOptions);
|
||||
|
||||
// Durable, crash-safe FasterLog outbox (the historization crash boundary). Built via the factory so
|
||||
// the container OWNS disposal (FasterLogHistorizationOutbox is IDisposable). Binding the observable
|
||||
// outbox depth/dropped gauges here (once, on first resolution) keeps the live instance behind them.
|
||||
builder.Services.AddSingleton<IHistorizationOutbox>(_ =>
|
||||
{
|
||||
var commitMode = Enum.TryParse<HistorizationCommitMode>(
|
||||
continuousHistorizationOptions.CommitMode, ignoreCase: true, out var parsedMode)
|
||||
? parsedMode
|
||||
: HistorizationCommitMode.PerEntry;
|
||||
var outbox = new FasterLogHistorizationOutbox(
|
||||
continuousHistorizationOptions.OutboxPath,
|
||||
commitMode,
|
||||
continuousHistorizationOptions.CommitIntervalMs,
|
||||
continuousHistorizationOptions.Capacity);
|
||||
ContinuousHistorizationMetrics.BindOutbox(outbox);
|
||||
return outbox;
|
||||
});
|
||||
|
||||
// Gateway-backed live-value writer over its OWN gRPC channel to the same single gateway (a second
|
||||
// channel to a co-located sidecar is cheap — the gateway pools the historian sessions server-side).
|
||||
builder.Services.AddSingleton<IHistorianValueWriter>(sp =>
|
||||
new GatewayHistorianValueWriter(
|
||||
HistorianGatewayClientAdapter.Create(
|
||||
serverHistorianOptions, sp.GetRequiredService<ILoggerFactory>()),
|
||||
sp.GetRequiredService<ILogger<GatewayHistorianValueWriter>>()));
|
||||
}
|
||||
|
||||
// Bind every cross-platform driver factory before AddAkka resolves IDriverFactory — replaces
|
||||
// the F7-default NullDriverFactory with a real DriverFactoryRegistryAdapter so DriverHostActor
|
||||
// can materialise real IDriver instances on deploy.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions.Historian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
|
||||
/// <summary>
|
||||
/// Observable-gauge instruments for the continuous-historization durable outbox, hung off the
|
||||
/// central <see cref="OtOpcUaTelemetry.Meter"/> (the same meter the Host's OpenTelemetry /
|
||||
/// Prometheus binding already scrapes), so no extra meter allowlist entry is needed.
|
||||
/// <para>
|
||||
/// The gauges read the bound outbox directly rather than Ask-ing the recorder actor: an
|
||||
/// <c>Ask</c> inside a synchronous observable-gauge callback would block the metrics-collection
|
||||
/// thread on the actor mailbox. The outbox exposes both gauge sources cheaply and
|
||||
/// non-blockingly — <see cref="IHistorizationOutbox.CountAsync"/> completes synchronously (it
|
||||
/// just reads an in-memory FIFO count) and <see cref="IHistorizationOutbox.DroppedCount"/> is a
|
||||
/// plain property. The recorder's other counters (TotalRecorded / DroppedNonNumeric /
|
||||
/// OutboxAppendFailures) remain available via its <c>GetStatus</c> Ask for a health hook, but are
|
||||
/// not surfaced as gauges here (Ask-in-gauge is the awkward path the plan calls out).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ContinuousHistorizationMetrics
|
||||
{
|
||||
private static volatile IHistorizationOutbox? _outbox;
|
||||
|
||||
static ContinuousHistorizationMetrics()
|
||||
{
|
||||
// Registered once on first touch (i.e. when BindOutbox is first called at host start). Instruments
|
||||
// are no-op until a listener attaches, so an unbound process pays nothing; the callbacks are
|
||||
// null-safe and return 0 until BindOutbox supplies the live outbox.
|
||||
OtOpcUaTelemetry.Meter.CreateObservableGauge(
|
||||
"otopcua.historization.outbox.depth",
|
||||
ObserveDepth,
|
||||
unit: "{entry}",
|
||||
description: "Un-acked entries currently held in the continuous-historization durable outbox.");
|
||||
OtOpcUaTelemetry.Meter.CreateObservableGauge(
|
||||
"otopcua.historization.outbox.dropped",
|
||||
ObserveDropped,
|
||||
unit: "{entry}",
|
||||
description: "Lifetime entries the continuous-historization outbox dropped on capacity overflow.");
|
||||
}
|
||||
|
||||
/// <summary>Binds the process outbox the gauges observe. Called once by the Host when continuous
|
||||
/// historization is enabled; subsequent calls re-point the gauges (idempotent in practice).</summary>
|
||||
/// <param name="outbox">The durable outbox the recorder drains.</param>
|
||||
public static void BindOutbox(IHistorizationOutbox outbox)
|
||||
=> _outbox = outbox ?? throw new ArgumentNullException(nameof(outbox));
|
||||
|
||||
private static long ObserveDepth()
|
||||
{
|
||||
IHistorizationOutbox? outbox = _outbox;
|
||||
if (outbox is null) return 0L;
|
||||
// CountAsync over the FasterLog outbox completes synchronously (in-memory FIFO count); read the
|
||||
// already-completed result without blocking. A (theoretical) pending result reports 0 this scrape.
|
||||
ValueTask<int> pending = outbox.CountAsync(CancellationToken.None);
|
||||
return pending.IsCompletedSuccessfully ? pending.Result : 0L;
|
||||
}
|
||||
|
||||
private static long ObserveDropped() => _outbox?.DroppedCount ?? 0L;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the <c>ContinuousHistorization</c> configuration section that gates the continuous
|
||||
/// historization of driver (non-Galaxy) tag values. When <see cref="Enabled"/> is <c>true</c>
|
||||
/// <em>and</em> the <c>ServerHistorian</c> gateway is configured, the Host builds a durable,
|
||||
/// crash-safe <c>FasterLogHistorizationOutbox</c> + a gateway-backed <c>IHistorianValueWriter</c>
|
||||
/// and <c>WithOtOpcUaRuntimeActors</c> spawns the <see cref="ContinuousHistorizationRecorder"/>;
|
||||
/// otherwise no recorder is spawned and driver tag values are not historized.
|
||||
/// <para>
|
||||
/// The recorder taps the per-node dependency-mux value fan-out, appends each numeric value to
|
||||
/// the outbox (the crash boundary), and drains the outbox to the historian's SQL live-value
|
||||
/// write path (<c>WriteLiveValues</c>) through the single <c>ServerHistorian</c> gateway. The
|
||||
/// gateway connection (endpoint / key / TLS) is sourced from <c>ServerHistorianOptions</c> — this
|
||||
/// section carries only the recorder + outbox knobs, never a gateway address or credential.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ContinuousHistorizationOptions
|
||||
{
|
||||
/// <summary>The configuration section name this options class binds.</summary>
|
||||
public const string SectionName = "ContinuousHistorization";
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c> (and the <c>ServerHistorian</c> gateway is configured), the
|
||||
/// continuous-historization recorder + its durable outbox are wired and spawned; when
|
||||
/// <c>false</c> (the default) no recorder is spawned and driver tag values are not historized.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Directory holding the FasterLog outbox segment + commit files. Required when
|
||||
/// <see cref="Enabled"/> is <c>true</c>. In production set an <b>absolute</b> path on durable
|
||||
/// storage — a relative path resolves against the host's working directory, which may change
|
||||
/// across deployments.
|
||||
/// </summary>
|
||||
public string OutboxPath { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Outbox commit cadence: <c>PerEntry</c> (the default) fsyncs the log before each append
|
||||
/// returns (safest, no loss window); <c>Periodic</c> batches commits onto a background timer
|
||||
/// every <see cref="CommitIntervalMs"/> ms (higher throughput, a bounded worst-case loss window).
|
||||
/// Parsed case-insensitively against <c>HistorizationCommitMode</c>; an unrecognized value falls
|
||||
/// back to <c>PerEntry</c>.
|
||||
/// </summary>
|
||||
public string CommitMode { get; init; } = "PerEntry";
|
||||
|
||||
/// <summary>Periodic-mode commit cadence in milliseconds; must be positive when
|
||||
/// <see cref="CommitMode"/> is <c>Periodic</c>. Ignored under <c>PerEntry</c>.</summary>
|
||||
public int CommitIntervalMs { get; init; } = 100;
|
||||
|
||||
/// <summary>Maximum outbox entries peeked + written per drain pass; clamped to a positive value by
|
||||
/// the recorder (a non-positive value falls back to 64).</summary>
|
||||
public int DrainBatchSize { get; init; } = 64;
|
||||
|
||||
/// <summary>Steady drain cadence in seconds (also the post-success reschedule). Defaults to 2.</summary>
|
||||
public double DrainIntervalSeconds { get; init; } = 2;
|
||||
|
||||
/// <summary>Maximum un-acked outbox entries before the drop-oldest capacity policy kicks in;
|
||||
/// <c>0</c> (the default) means unbounded.</summary>
|
||||
public int Capacity { get; init; }
|
||||
|
||||
/// <summary>Initial retry backoff (seconds) after a failed drain pass. Defaults to 1.</summary>
|
||||
public double MinBackoffSeconds { get; init; } = 1;
|
||||
|
||||
/// <summary>Cap (seconds) on the exponential retry backoff after repeated drain failures. Defaults to 30.</summary>
|
||||
public double MaxBackoffSeconds { get; init; } = 30;
|
||||
|
||||
/// <summary>Returns operator-facing misconfiguration warnings for an <c>Enabled</c> recorder
|
||||
/// (empty when disabled or correctly configured). Pure — the registration logs each entry.</summary>
|
||||
/// <returns>Zero or more human-readable warning messages (never carrying secret values).</returns>
|
||||
public IReadOnlyList<string> Validate()
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
if (!Enabled) return warnings;
|
||||
if (string.IsNullOrWhiteSpace(OutboxPath))
|
||||
warnings.Add("ContinuousHistorization:OutboxPath is empty while historization is enabled — the durable outbox has no directory to persist to; the recorder cannot be wired.");
|
||||
if (string.Equals(CommitMode, "Periodic", StringComparison.OrdinalIgnoreCase) && CommitIntervalMs <= 0)
|
||||
warnings.Add($"ContinuousHistorization:CommitIntervalMs is {CommitIntervalMs} — must be > 0 in Periodic commit mode; the periodic-commit loop cannot run.");
|
||||
return warnings;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions.Historian;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
@@ -33,6 +34,7 @@ public static class ServiceCollectionExtensions
|
||||
public const string DependencyMuxActorName = "dependency-mux";
|
||||
public const string OpcUaPublishActorName = "opcua-publish";
|
||||
public const string PeerProbeSupervisorName = "peer-probe-supervisor";
|
||||
public const string ContinuousHistorizationRecorderActorName = "continuous-historization-recorder";
|
||||
|
||||
/// <summary>
|
||||
/// Registers shared runtime services. Currently binds <see cref="IAlarmHistorianSink"/>
|
||||
@@ -245,6 +247,46 @@ public static class ServiceCollectionExtensions
|
||||
HistorianAdapterActor.Props(historianSink, roleInfo.LocalNode),
|
||||
HistorianAdapterActorName);
|
||||
registry.Register<HistorianAdapterActorKey>(historian);
|
||||
|
||||
// Continuous-historization recorder — gated on ContinuousHistorization:Enabled AND the
|
||||
// gateway-backed IHistorianValueWriter + the durable IHistorizationOutbox being registered
|
||||
// (the Host registers both ONLY when historization is enabled and the ServerHistorian gateway
|
||||
// is configured). The recorder taps the dependency mux's value fan-out, so it is spawned after
|
||||
// (and fed) the same `mux` ref the DriverHostActor uses.
|
||||
//
|
||||
// HISTORIZED-REF SET — DOCUMENTED GAP (T18 minimal wiring). The deployed address space (and
|
||||
// thus the set of historized tag refs) is built later at deploy time, not here at actor-spawn
|
||||
// time, so there is no clean ref set to resolve in WithOtOpcUaRuntimeActors. Per the plan, T18
|
||||
// spawns the recorder with an EMPTY initial ref set and registers its key; populating the refs
|
||||
// (a later SetHistorizedRefs feed driven off the deployed composition) is the remaining wiring
|
||||
// and a tracked follow-on. With an empty set the recorder registers interest in nothing and
|
||||
// historizes nothing until that feed lands — the actor + outbox + writer + meters are wired.
|
||||
var continuousOptions = resolver.GetService<ContinuousHistorizationOptions>();
|
||||
if (continuousOptions is { Enabled: true })
|
||||
{
|
||||
var valueWriter = resolver.GetService<IHistorianValueWriter>();
|
||||
var outbox = resolver.GetService<IHistorizationOutbox>();
|
||||
if (valueWriter is not null && outbox is not null)
|
||||
{
|
||||
var recorder = system.ActorOf(
|
||||
ContinuousHistorizationRecorder.Props(
|
||||
dependencyMux: mux,
|
||||
writer: valueWriter,
|
||||
outbox: outbox,
|
||||
historizedRefs: Array.Empty<string>(),
|
||||
drainBatchSize: continuousOptions.DrainBatchSize,
|
||||
drainInterval: TimeSpan.FromSeconds(continuousOptions.DrainIntervalSeconds),
|
||||
minBackoff: TimeSpan.FromSeconds(continuousOptions.MinBackoffSeconds),
|
||||
maxBackoff: TimeSpan.FromSeconds(continuousOptions.MaxBackoffSeconds)),
|
||||
ContinuousHistorizationRecorderActorName);
|
||||
registry.Register<ContinuousHistorizationRecorderKey>(recorder);
|
||||
}
|
||||
else
|
||||
{
|
||||
loggerFactory.CreateLogger("ZB.MOM.WW.OtOpcUa.Runtime.ServiceCollectionExtensions")
|
||||
.LogWarning("ContinuousHistorization is enabled but IHistorianValueWriter and/or IHistorizationOutbox are not registered; the recorder will not be spawned. Expected only in misconfigured deployments or test harnesses.");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return builder;
|
||||
@@ -258,5 +300,9 @@ public sealed class HistorianAdapterActorKey { }
|
||||
public sealed class DependencyMuxActorKey { }
|
||||
public sealed class OpcUaPublishActorKey { }
|
||||
|
||||
/// <summary>Marker key for the per-node ContinuousHistorizationRecorder (spawned only when
|
||||
/// <c>ContinuousHistorization:Enabled=true</c> and the gateway value-writer + outbox are registered).</summary>
|
||||
public sealed class ContinuousHistorizationRecorderKey { }
|
||||
|
||||
/// <summary>Marker key for the per-node PeerProbeSupervisor.</summary>
|
||||
public sealed class PeerProbeSupervisorKey { }
|
||||
|
||||
+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