Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests/SqlServerHistorianProviderTests.cs
T
Joseph Doherty 6669fae7e5 feat(historian): ServerHistorian:MaxRawValuesPerRead caps raw pages and backend reads
Review R08:1.1: an uncapped HistoryRead-Raw (NumValuesPerNode = 0) read a
tag's whole window into memory on every backend (SQL TOP(int.MaxValue),
QuestDB LIMIT, Gateway max_values = int.MaxValue + a second snapshot copy).

New ServerHistorian:MaxRawValuesPerRead (default 10 000; <= 0 unbounded with
a Validate() warning; 1 raised to 2). Each backend data source takes it,
reports it as IHistorianDataSource.MaxRawValuesPerRead (the Gateway: min
with a strict gateway's learned ceiling), so the node manager's existing
RawPageLimit paging serves N = 0 and N > cap as pages of at most the cap
with a continuation point. Independently, a backend raw read asking for 0
or more than the cap reads cap + 1 rows and is REFUSED
(HistoryReadLimitExceededException => BadTooManyOperations) when the window
holds more - never answered truncated, since the server's paging reads a
short answer as the last page. Bound through the Host registry, the
SqlServer/QuestDb providers and GatewayHistorian.CreateDataSource.

Tests: options (default/binding/warnings), provider binding, Gateway unit +
through a real SDK node manager (uncapped -> 999/998/998/605 + CPs), and a
conformance case for the SQL backends.

Refs #736

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 06:29:10 -04:00

111 lines
4.9 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.Tests;
public sealed class SqlServerHistorianProviderTests
{
private static SqlServerHistorianProvider Make() => new(new SqlServerHistorianOptions
{
ConnectionString = "Server=localhost;User Id=sa;Password=x;TrustServerCertificate=True",
});
[Theory]
[InlineData("Raw")]
[InlineData("AtTime")]
[InlineData("Processed")]
[InlineData("Events")]
[InlineData("Modified")]
public async Task A_connection_failure_is_exposed_as_a_transport_failure(string kind)
{
var provider = new SqlServerHistorianProvider(new SqlServerHistorianOptions {
ConnectionString = "Server=127.0.0.1,1;User Id=sa;Password=test;TrustServerCertificate=True;Connect Timeout=1;ConnectRetryCount=0;Pooling=False", AutoCreateSchema = false
});
using var source = provider.CreateDataSource(new NullSp());
var t = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
Func<Task> read = kind switch
{
"Raw" => async () => await source.ReadRawAsync("T", t, t.AddHours(1), 10, TestContext.Current.CancellationToken),
"AtTime" => async () => await source.ReadAtTimeAsync("T", [t], TestContext.Current.CancellationToken),
"Processed" => async () => await source.ReadProcessedAsync("T", t, t.AddHours(1), TimeSpan.FromSeconds(10), HistoryAggregateType.Average, TestContext.Current.CancellationToken),
"Events" => async () => await source.ReadEventsAsync("T", t, t.AddHours(1), 10, TestContext.Current.CancellationToken),
_ => async () => await source.ReadModifiedAsync("T", t, t.AddHours(1), 10, null, TestContext.Current.CancellationToken)
};
var error = await Should.ThrowAsync<System.IO.IOException>(read);
error.InnerException.ShouldNotBeNull();
// A cached bootstrap outage must remain retryable on subsequent reads as well.
(await Should.ThrowAsync<System.IO.IOException>(read)).InnerException.ShouldNotBeNull();
}
[Fact]
public void Name_and_all_six_kinds()
{
var p = Make();
p.Name.ShouldBe("SqlServer");
p.SupportedKinds.ShouldBe(Enum.GetValues<HistorianValueKind>(), ignoreOrder: true);
p.SupportedKinds.Count.ShouldBe(6);
}
/// <summary>R08:1.1 (#736): the server raw cap the Host binds reaches the data source; without one it is unbounded as before.</summary>
[Fact]
public void The_server_raw_cap_reaches_the_data_source()
{
var sp = new NullSp();
Make().CreateDataSource(sp).MaxRawValuesPerRead.ShouldBe(0);
var capped = new SqlServerHistorianProvider(new SqlServerHistorianOptions
{
ConnectionString = "Server=localhost;User Id=sa;Password=x;TrustServerCertificate=True",
}, maxRawValuesPerRead: 2500);
capped.CreateDataSource(sp).MaxRawValuesPerRead.ShouldBe(2500);
}
[Fact]
public void Creates_all_five_seams_without_touching_sql()
{
var sp = new NullSp();
var p = Make();
p.CreateDataSource(sp).ShouldBeOfType<SqlServerHistorianDataSource>();
p.CreateValueWriter(sp).ShouldBeOfType<SqlServerHistorianValueWriter>();
p.CreateAlarmWriter(sp).ShouldBeOfType<SqlServerAlarmHistorianWriter>();
p.CreateProvisioning(sp).ShouldBeOfType<SqlServerTagProvisioner>();
p.CreateUpdater(sp).ShouldBeOfType<SqlServerHistorianDataUpdater>();
}
[Fact]
public void Supported_kinds_are_a_frozen_set() =>
Make().SupportedKinds.ShouldBeAssignableTo<System.Collections.Frozen.FrozenSet<HistorianValueKind>>();
/// <summary>
/// An invalid schema must not fail provider construction (the host resolves the provider even when no
/// consumer is enabled); it surfaces only when a seam is actually built.
/// </summary>
[Fact]
public void An_invalid_schema_fails_only_when_a_seam_is_built()
{
var p = new SqlServerHistorianProvider(new SqlServerHistorianOptions { ConnectionString = "x", Schema = "bad;schema" });
p.Name.ShouldBe("SqlServer");
Should.Throw<ArgumentException>(() => p.CreateDataSource(new NullSp()));
}
[Fact]
public void Every_seam_shares_one_connection_bootstrapper_and_catalog()
{
var sp = new NullSp();
var p = Make();
var a = (SqlServerHistorianDataSource)p.CreateDataSource(sp);
var b = (SqlServerHistorianDataSource)p.CreateDataSource(sp);
Field(a, "_bootstrap").ShouldBeSameAs(Field(b, "_bootstrap"));
Field(a, "_catalog").ShouldBeSameAs(Field(b, "_catalog"));
}
private static object? Field(object o, string name) =>
o.GetType().GetField(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(o);
private sealed class NullSp : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
}