56bb4855bb
M1: the time-weighted family (incl. Total, formerly a pushed-down SUM) streams raw window rows into the C# evaluator. New shared knob ServerHistorian:MaxTimeWeightedRawRows (default 1,000,000; <=0 unbounded with a Validate warning) flows through HistorianBackendRegistry into both providers. The window query is capped at cap+1 (SQL Server TOP (@maxRows), QuestDB LIMIT $4); one row past the cap throws the new Core.Abstractions HistoryReadLimitExceededException, which the node manager maps to BadTooManyOperations for that node. Not counted as a backend health failure; bound rows are not counted; per-sample aggregates are not capped. M2: reverse Interpolative is anchored at the later time (Part 13): points at End - i*interval, emitted latest first. 10:00->09:00 @1h yields 10:00. L1: Partial bit (0x4, = Opc.Ua.AggregateBits.Partial) on non-Bad results of a truncated last interval (time-weighted family; per-sample family is a follow-up). L2/L3: aligned with the SDK AggregateCalculator.GetTimeBasedStatusCode (decompiled from Opc.Ua.Server 1.5.378.106): TreatUncertainAsBad=false counts Uncertain time (Uncertain samples, held-into-outage, extrapolation) as Good time; data but no non-Bad time => generic Bad (0x80000000); BadNoData only when there is no data at all. Consequence: a live tag's latest interval reads Good (documented). L5: release note no longer claims Total matches the Gateway Integral exactly (AVEVA's divisor is unverified); it notes Total is now evaluated in C# and subject to the cap. Tests: unit (reverse Interpolative x3, Partial, Bad vs BadNoData incl. an open-ended outage, cap helpers, SDK bit oracles); conformance (reverse Interpolative, raw-row cap at/over/0, bounds not counted, health unaffected); node manager BadTooManyOperations; options default + warning. Each new behaviour falsified by mutation (aggregator x9, data-source cap checks on both backends against the real DBs, node-manager catch).
89 lines
4.4 KiB
C#
89 lines
4.4 KiB
C#
using Microsoft.Data.SqlClient;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Seeds a fresh schema with plain INSERTs (independent of the value writer) and builds a data source over it.
|
|
/// Shared by the SQL Server-specific tests and by <see cref="SqlServerConformanceBackend"/>. <see cref="T0"/> is deliberately NOT millisecond-aligned.
|
|
/// </summary>
|
|
internal sealed class ReadTestDb
|
|
{
|
|
/// <summary>Base time: 2026-10-09 08:00:00.1234567 UTC (sub-millisecond ticks on purpose).</summary>
|
|
public static readonly DateTime T0 = Conformance.ConformanceData.T0;
|
|
|
|
public const uint Good = Conformance.ConformanceData.Good;
|
|
public const uint Bad = Conformance.ConformanceData.Bad;
|
|
public const uint BadNoCommunication = Conformance.ConformanceData.BadNoCommunication;
|
|
public const uint Uncertain = Conformance.ConformanceData.Uncertain;
|
|
|
|
private ReadTestDb(SqlServerHistorianOptions options, SqlHistorianConnection conn, SqlServerSchemaBootstrapper boot)
|
|
{
|
|
Options = options;
|
|
Conn = conn;
|
|
Boot = boot;
|
|
}
|
|
|
|
public SqlServerHistorianOptions Options { get; }
|
|
public SqlHistorianConnection Conn { get; }
|
|
public SqlServerSchemaBootstrapper Boot { get; }
|
|
|
|
public static DateTime At(double seconds) => Conformance.ConformanceData.At(seconds);
|
|
|
|
public static async Task<ReadTestDb> CreateAsync(SqlHistorianFixture fx)
|
|
{
|
|
fx.SkipIfUnavailable();
|
|
var o = new SqlServerHistorianOptions { ConnectionString = fx.ConnectionString, Schema = SqlHistorianFixture.UniqueSchema() };
|
|
var conn = new SqlHistorianConnection(o);
|
|
var boot = new SqlServerSchemaBootstrapper(conn, o);
|
|
await boot.EnsureAsync(CancellationToken.None);
|
|
return new ReadTestDb(o, conn, boot);
|
|
}
|
|
|
|
/// <summary>A fresh data source (fresh catalog cache) over this schema; <paramref name="clock"/> is its "now".</summary>
|
|
public SqlServerHistorianDataSource Source(TimeProvider? clock = null,
|
|
int maxTimeWeightedRawRows = TimeWeightedAggregator.DefaultMaxWindowRows) =>
|
|
new(Conn, Boot, new SqlServerTagCatalog(Conn, Boot), timeProvider: clock, maxTimeWeightedRawRows: maxTimeWeightedRawRows);
|
|
|
|
/// <summary>Inserts a tag row and the given samples. ServerTimeUtc = source time + 1 s (so tests can tell them apart).</summary>
|
|
public async Task<int> SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples)
|
|
{
|
|
await using var c = await Conn.OpenAsync(CancellationToken.None);
|
|
int id;
|
|
await using (var cmd = Conn.CreateCommand(c,
|
|
$"INSERT {Conn.Q("Tag")} (Name, DataType, CreatedUtc, UpdatedUtc) OUTPUT inserted.TagId VALUES (@n, 0, SYSUTCDATETIME(), SYSUTCDATETIME())"))
|
|
{
|
|
cmd.Parameters.AddWithValue("@n", tag);
|
|
id = (int)(await cmd.ExecuteScalarAsync())!;
|
|
}
|
|
foreach (var (t, v, status) in samples)
|
|
{
|
|
var col = SampleColumnMapper.ToColumns(v);
|
|
await using var cmd = Conn.CreateCommand(c,
|
|
$"INSERT {Conn.Q("Sample")} (TagId, SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, ServerTimeUtc) " +
|
|
"VALUES (@id, @t, @k, @num, @int, @str, @st, @srv)");
|
|
cmd.Parameters.AddWithValue("@id", id);
|
|
SqlHistorianConnection.AddDateTime2(cmd, "@t", t);
|
|
cmd.Parameters.AddWithValue("@k", col.ValueKind);
|
|
cmd.Parameters.Add(new SqlParameter("@num", System.Data.SqlDbType.Float) { Value = (object?)col.NumValue ?? DBNull.Value });
|
|
cmd.Parameters.Add(new SqlParameter("@int", System.Data.SqlDbType.BigInt) { Value = (object?)col.IntValue ?? DBNull.Value });
|
|
cmd.Parameters.Add(new SqlParameter("@str", System.Data.SqlDbType.NVarChar, -1) { Value = (object?)col.StrValue ?? DBNull.Value });
|
|
cmd.Parameters.AddWithValue("@st", SampleColumnMapper.ToDbStatus(status));
|
|
SqlHistorianConnection.AddDateTime2(cmd, "@srv", t.AddSeconds(1));
|
|
await cmd.ExecuteNonQueryAsync();
|
|
}
|
|
return id;
|
|
}
|
|
|
|
public async Task<long> ScalarAsync(string sql)
|
|
{
|
|
await using var c = await Conn.OpenAsync(CancellationToken.None);
|
|
await using var cmd = Conn.CreateCommand(c, sql);
|
|
return Convert.ToInt64(await cmd.ExecuteScalarAsync());
|
|
}
|
|
}
|
|
|