Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs
T
Joseph Doherty 53978ca638 feat(historian-gateway): native TimeAverage/Total on by default, SendEvents batches, server-side max_events (#694, #746)
#694 item 3 — historiangw#27 is fixed and now live-verified through OtOpcUa:
- ServerHistorian:Gateway:EnableNativeTimeWeighted defaults to true (false is
  the kill switch for a gateway older than 9f711c1). TimeAverage / Total are
  served and advertised again (12 aggregates).
- The gateway sends the interval as max(1, round(ms)) and AVEVA clips its last
  interval at the request end (live). NativeAggregatePlan therefore sends a
  multi-interval grid only for whole-ms intervals; a fractional interval is read
  one interval per request (at most 1,000, else BadTooManyOperations), and every
  one-interval request goes out rounded UP to whole ms so AVEVA answers one
  clipped row. Live, a 3.0004 s clipped interval sent at its own width lost its
  last 0.4 ms (Total 123 vs 123.0172).

#746 — HistorianGateway Contracts/Client 1.0.0 surface:
- Alarm history: GatewayAlarmHistorianWriter sends SendEvents batches of <=200.
  A failed per-event result is re-sent alone via SendEvent (classified as
  before); a refused batch (InvalidArgument etc.) goes event by event so a
  poison event dead-letters alone; a systemic fault retries the rest without
  sending more; Unimplemented (older gateway) falls back to SendEvent for good.
  IHistorianGatewayClient.SendEventsAsync defaults to Unimplemented.
- Events reads: a capped page sends cap + 1 as ReadEventsRequest.max_events, so
  the gateway completes the stream instead of the client cancelling it. An
  ambiguous capped answer (foreign rows filtered out of the server's cut) or a
  refused cap is re-read uncapped with the existing EventDrainBudget drain.

Tests: GatewayServerCappedEventsTests, GatewayAlarmWriterBatchTests,
GatewayNativeWholeMillisecondGridTests (gateway-faithful rounding fake), gate
tests flipped to the new default; every new test mutation-checked (each
mutation of the production change fails at least one). Live harness
AvevaGatewayHistorianFollowupsLiveGateTests (read-only) for #694/#700/#746.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-13 00:09:34 -05:00

179 lines
9.1 KiB
C#

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
public sealed class HistorianBackendRegistryTests
{
private static readonly IConfiguration Cfg = new ConfigurationBuilder().Build();
private static ServerHistorianOptions Gateway(string backend = "Gateway") => new()
{
Backend = backend, Enabled = true, Endpoint = "http://localhost:5222", ApiKey = "histgw_id_secret", UseTls = false,
};
[Fact]
public void Default_backend_is_Gateway_and_resolves_the_gateway_provider()
{
new ServerHistorianOptions().Backend.ShouldBe("Gateway");
HistorianBackendRegistry.Resolve(new ServerHistorianOptions(), Cfg).ShouldBeOfType<GatewayHistorianProvider>();
}
[Theory]
[InlineData("gateway")]
[InlineData("GATEWAY")]
[InlineData("Gateway")]
public void Resolve_is_case_insensitive(string name) =>
HistorianBackendRegistry.Resolve(Gateway(name), Cfg).Name.ShouldBe("Gateway");
[Fact]
public void Unknown_backend_throws_naming_the_value_and_the_allowed_names()
{
var ex = Should.Throw<OptionsValidationException>(
() => HistorianBackendRegistry.Resolve(Gateway("Nope"), Cfg));
var msg = string.Join(" ", ex.Failures);
msg.ShouldContain("Nope");
msg.ShouldContain("Gateway");
msg.ShouldContain("ServerHistorian:Backend");
}
[Fact]
public void KnownBackends_lists_Gateway() => HistorianBackendRegistry.KnownBackends.ShouldContain("Gateway");
[Fact]
public void Gateway_provider_builds_the_same_concrete_seams_Program_used_to_build()
{
var sp = new ServiceCollection().BuildServiceProvider();
var p = HistorianBackendRegistry.Resolve(Gateway(), Cfg);
p.CreateDataSource(sp).ShouldBeOfType<GatewayHistorianDataSource>();
p.CreateValueWriter(sp).ShouldBeOfType<GatewayHistorianValueWriter>();
p.CreateAlarmWriter(sp).ShouldBeOfType<GatewayAlarmHistorianWriter>();
p.CreateProvisioning(sp).ShouldBeOfType<GatewayTagProvisioner>();
p.CreateUpdater(sp).ShouldBeNull();
}
private static IConfiguration SqlCfg() => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ServerHistorian:SqlServer:ConnectionString"] = "Server=localhost;User Id=sa;Password=x;TrustServerCertificate=True",
}).Build();
[Fact]
public void SqlServer_backend_resolves_and_builds_the_sql_seams()
{
var sp = new ServiceCollection().BuildServiceProvider();
var p = HistorianBackendRegistry.Resolve(new ServerHistorianOptions { Backend = "sqlserver", Enabled = true }, SqlCfg());
p.ShouldBeOfType<SqlServerHistorianProvider>();
HistorianBackendRegistry.KnownBackends.ShouldContain("SqlServer");
p.SupportedKinds.Count.ShouldBe(6);
p.SupportedKinds.ShouldContain(HistorianValueKind.String);
p.SupportedKinds.ShouldContain(HistorianValueKind.Empty);
p.CreateDataSource(sp).ShouldBeOfType<SqlServerHistorianDataSource>();
p.CreateValueWriter(sp).GetType().Name.ShouldBe("SqlServerHistorianValueWriter");
p.CreateAlarmWriter(sp).GetType().Name.ShouldBe("SqlServerAlarmHistorianWriter");
p.CreateProvisioning(sp).GetType().Name.ShouldBe("SqlServerTagProvisioner");
var holder = new HistorianUpdaterHolder(p.CreateUpdater(sp));
holder.Updater.ShouldNotBeNull();
holder.Updater!.GetType().Name.ShouldBe("SqlServerHistorianDataUpdater");
}
private static IConfiguration QuestDbCfg() => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ServerHistorian:QuestDb:ConnectionString"] = "Host=localhost;Port=18812;Username=admin;Password=quest;Database=qdb",
["ServerHistorian:QuestDb:TablePrefix"] = "rig_",
}).Build();
[Theory]
[InlineData("questdb")]
[InlineData("QUESTDB")]
[InlineData("QuestDb")]
public void QuestDb_backend_resolves_case_insensitively(string name) =>
HistorianBackendRegistry.Resolve(new ServerHistorianOptions { Backend = name, Enabled = true }, QuestDbCfg())
.ShouldBeOfType<QuestDbHistorianProvider>().Name.ShouldBe("QuestDb");
[Fact]
public void QuestDb_backend_resolves_and_builds_the_questdb_seams()
{
var sp = new ServiceCollection().BuildServiceProvider();
var p = HistorianBackendRegistry.Resolve(new ServerHistorianOptions { Backend = "questdb", Enabled = true }, QuestDbCfg());
p.ShouldBeOfType<QuestDbHistorianProvider>();
HistorianBackendRegistry.KnownBackends.ShouldContain("QuestDb");
p.SupportedKinds.Count.ShouldBe(6);
p.SupportedKinds.ShouldContain(HistorianValueKind.String);
p.SupportedKinds.ShouldContain(HistorianValueKind.Empty);
p.CreateDataSource(sp).ShouldBeOfType<QuestDbHistorianDataSource>();
p.CreateValueWriter(sp).GetType().Name.ShouldBe("QuestDbHistorianValueWriter");
p.CreateAlarmWriter(sp).GetType().Name.ShouldBe("QuestDbAlarmHistorianWriter");
p.CreateProvisioning(sp).GetType().Name.ShouldBe("QuestDbTagProvisioner");
var holder = new HistorianUpdaterHolder(p.CreateUpdater(sp));
holder.Updater.ShouldNotBeNull();
holder.Updater!.GetType().Name.ShouldBe("QuestDbHistorianDataUpdater");
}
[Fact]
public void BindQuestDb_reads_the_QuestDb_section()
{
var o = HistorianBackendRegistry.BindQuestDb(QuestDbCfg());
o.ConnectionString.ShouldContain("Port=18812");
o.TablePrefix.ShouldBe("rig_");
}
/// <summary>The nineteen aggregates the SQL backends serve, spelled out (not derived) so a drift in either the
/// family table or a backend's declaration fails here (#682).</summary>
private static readonly HistoryAggregateType[] SqlServed =
[
HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum, HistoryAggregateType.Count,
HistoryAggregateType.Range, HistoryAggregateType.Start, HistoryAggregateType.End, HistoryAggregateType.Delta,
HistoryAggregateType.MinimumActualTime, HistoryAggregateType.MaximumActualTime, HistoryAggregateType.TimeAverage,
HistoryAggregateType.TimeAverage2, HistoryAggregateType.Total, HistoryAggregateType.Total2,
HistoryAggregateType.Interpolative, HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad,
HistoryAggregateType.DurationGood, HistoryAggregateType.DurationBad,
];
[Fact]
public void Each_backend_data_source_declares_the_aggregates_it_serves()
{
// #682: what the server advertises under AggregateFunctions comes from these — resolved exactly as the Host does.
var sp = new ServiceCollection().BuildServiceProvider();
IHistorianDataSource Source(ServerHistorianOptions o, IConfiguration c) =>
HistorianBackendRegistry.Resolve(o, c).CreateDataSource(sp);
// #681: the ten per-sample aggregates (raw-evaluated), plus — by default since historiangw#27 was live-verified
// (lmxopcua#694) — the two AVEVA-native ones (TimeAverage / Total) ...
HistoryAggregateType[] gatewayRawEvaluated =
[
HistoryAggregateType.Average, HistoryAggregateType.Count, HistoryAggregateType.Minimum,
HistoryAggregateType.Maximum, HistoryAggregateType.Range, HistoryAggregateType.Start, HistoryAggregateType.End,
HistoryAggregateType.Delta, HistoryAggregateType.MinimumActualTime, HistoryAggregateType.MaximumActualTime,
];
Source(Gateway(), Cfg).SupportedAggregates.ShouldBe(
[.. gatewayRawEvaluated, HistoryAggregateType.TimeAverage, HistoryAggregateType.Total], ignoreOrder: true);
// ... and refused + unadvertised again, through the Host's resolve path, with the
// ServerHistorian:Gateway:EnableNativeTimeWeighted=false kill switch (a gateway older than the #27 fix).
var nativeOff = new ServerHistorianOptions
{
Backend = "Gateway", Enabled = true, Endpoint = "http://localhost:5222", ApiKey = "histgw_id_secret", UseTls = false,
Gateway = new GatewayHistorianSettings { EnableNativeTimeWeighted = false },
};
Source(nativeOff, Cfg).SupportedAggregates.ShouldBe(gatewayRawEvaluated, ignoreOrder: true);
Source(new ServerHistorianOptions { Backend = "SqlServer", Enabled = true }, SqlCfg())
.SupportedAggregates.ShouldBe(SqlServed, ignoreOrder: true);
Source(new ServerHistorianOptions { Backend = "QuestDb", Enabled = true }, QuestDbCfg())
.SupportedAggregates.ShouldBe(SqlServed, ignoreOrder: true);
NullHistorianDataSource.Instance.SupportedAggregates.ShouldBeEmpty();
}
}