Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/ReadTests.cs
T
Joseph Doherty 27ca6cd791 feat(historian): #677 Part 13/SDK reverse anchoring, start-of-data Partial, AggregateConfiguration, IntegralDivisor
- BucketPlan: reverse processed reads anchored at the later time for every
  aggregate: window (end, start], partial interval earliest, results stamped
  with the later edge, latest first (SDK CreateSlice/GetTimestamp/backward
  GetValues). SqlServer adds @bucketAdjustTicks + flips the window predicate;
  QuestDB binds BucketBaseUnixTicks. IndexOf is the C# statement of the math.
- Partial bit on the interval the tag's data starts inside (SDK m_startOfData),
  every aggregate but Average; per-sample reads fetch the earliest sample.
- Node manager: non-default client AggregateConfiguration =>
  BadAggregateConfigurationRejected per node (ServerAggregateConfiguration,
  pinned to the SDK AggregateManager defaults).
- GatewayTagProvisioner sends IntegralDivisor = 1.
- SDK parity oracle (Opc.Ua.Server calculators) in unit + both backends'
  conformance suites, forward and reverse; remaining divergences documented.
- docs/Historian.md + CLAUDE.md release note (client-visible).
2026-10-11 11:26:00 -04:00

268 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text;
using Npgsql;
using NpgsqlTypes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests;
/// <summary>
/// QuestDB-specific read behaviour (the shared Raw/AtTime/Processed literals run through the conformance suite):
/// tombstones, the pre-1970 / whole-range bucket math, reverse paging at one value per page, QuestDB's LONG NULL
/// sentinel, the kind-decides-the-value rule, AtTime chunking, Events and failure accounting.
/// </summary>
[Collection(QuestDbCollection.Name)]
public sealed class ReadTests(QuestDbFixture fx) : IAsyncLifetime
{
private static readonly CancellationToken Ct = CancellationToken.None;
private const uint Good = ConformanceData.Good;
private const uint Bad = ConformanceData.Bad;
private const uint GoodCalc = 0x00000401u;
private const uint UncertainCalc = 0x40A40401u;
private const uint UncertainInterpolated = 0x40A40402u;
private const uint GoodInterpolated = 0x00000402u;
private const uint NoDataCalc = 0x809B0401u;
private QuestDbConformanceBackend _b = null!;
private static DateTime At(double s) => ConformanceData.At(s);
public async ValueTask InitializeAsync() => _b = await QuestDbConformanceBackend.CreateConcreteAsync(fx);
public async ValueTask DisposeAsync()
{
if (_b is not null) await _b.DisposeAsync();
}
private static double?[] Values(HistoryReadResult r) => r.Samples.Select(s => (double?)s.Value).ToArray();
/// <summary>What T6's DeleteAtTime writes: the same (ts, tag), deleted = true, value columns NULL, status BadNoData.</summary>
private Task TombstoneAsync(string tag, DateTime t) =>
_b.InsertRowsAsync(tag, [(t, HistorianValue.Empty, HistorianStatusCodes.BadNoData, true)]);
[Fact]
public async Task Tombstoned_rows_are_invisible_to_raw_attime_and_processed()
{
await ConformanceData.SeedAAsync(_b); // +0=10, +10=20, +20=Bad 30, +30=40, +40=50
await TombstoneAsync("A", At(10)); // DEDUP replaces the +10 row with a tombstone
(await _b.ReadBackAsync("A")).Count.ShouldBe(4);
var src = _b.CreateDataSource();
var raw = await src.ReadRawAsync("A", At(0), At(100), 0, Ct);
Values(raw).ShouldBe([10d, 30d, 40d, 50d]);
raw.Samples.Select(s => s.StatusCode).ShouldBe([Good, Bad, Good, Good]);
// +10 is no longer an exact hit: previous is +0 = 10 (Good), next is the Bad +20 ⇒ held, Uncertain.
var at = (await src.ReadAtTimeAsync("A", [At(10), At(5)], Ct)).Samples;
at.Select(s => s.Value).ShouldBe([(object?)10d, 10d]);
at.ShouldAllBe(s => s.StatusCode == UncertainInterpolated);
// B0 [0,20) holds only {10}: Good, count 1. A visible tombstone would be a Bad sample ⇒ Uncertain, PercentBad 50.
async Task<DataValueSnapshot> B0(HistoryAggregateType a) =>
(await src.ReadProcessedAsync("A", At(0), At(80), TimeSpan.FromSeconds(20), a, Ct)).Samples[0];
var avg = await B0(HistoryAggregateType.Average);
avg.Value.ShouldBe(10d);
avg.StatusCode.ShouldBe(GoodCalc);
(await B0(HistoryAggregateType.Count)).Value.ShouldBe(1);
(await B0(HistoryAggregateType.PercentBad)).Value.ShouldBe(0d);
(await B0(HistoryAggregateType.End)).Value.ShouldBe(10d);
}
[Fact]
public async Task A_whole_range_processed_read_from_1601_to_9999_with_interval_zero_does_not_overflow()
{
await ConformanceData.SeedAAsync(_b);
var src = _b.CreateDataSource();
var start = new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var end = new DateTime(9999, 12, 31, 0, 0, 0, DateTimeKind.Utc);
foreach (var agg in new[] { HistoryAggregateType.Average, HistoryAggregateType.Start, HistoryAggregateType.MaximumActualTime })
{
var s = (await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, agg, Ct)).Samples.Single();
// The data starts (+0) strictly inside the one interval ⇒ Partial on every aggregate but Average (#677).
s.StatusCode.ShouldBe(agg == HistoryAggregateType.Average ? UncertainCalc : UncertainCalc | 0x4u, agg.ToString());
}
(await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.Average, Ct)).Samples.Single().Value.ShouldBe(30d);
(await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.Start, Ct)).Samples.Single().Value.ShouldBe(10d);
var max = (await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.MaximumActualTime, Ct)).Samples.Single();
max.Value.ShouldBe(50d);
max.SourceTimestampUtc.ShouldBe(_b.Round(At(40)));
// The extreme DateTime range: MinValue/MaxValue are nudged (never bound as ±infinity, which would match nothing).
(await src.ReadProcessedAsync("A", DateTime.MinValue, DateTime.MaxValue, TimeSpan.Zero, HistoryAggregateType.Count, Ct))
.Samples.Single().Value.ShouldBe(4);
// A pre-1970 window holds no data and is not an error.
(await src.ReadProcessedAsync("A", start, new DateTime(1969, 1, 1, 0, 0, 0, DateTimeKind.Utc), TimeSpan.FromDays(3650),
HistoryAggregateType.Count, Ct)).Samples.ShouldAllBe(x => x.StatusCode == NoDataCalc);
src.GetHealthSnapshot().TotalFailures.ShouldBe(0);
}
/// <summary>
/// The node manager's reverse paging at <c>NumValuesPerNode = 1</c> (OtOpcUaNodeManager + HistoryPaging): resume at
/// the last timestamp inclusive and trim it; when the trimmed page stalls on that timestamp, point-read it and step
/// one tick down. One tick below a µs-aligned time rounds to the previous µs, so the walk advances.
/// </summary>
[Fact]
public async Task A_reverse_paging_walk_with_one_value_per_page_returns_every_row()
{
await ConformanceData.SeedAAsync(_b);
var src = _b.CreateDataSource();
var end = At(-1);
DateTime start = At(100);
var resumed = false;
var emitted = new List<object?>();
for (var guard = 0; guard < 50; guard++)
{
var page = (await src.ReadRawAsync("A", start, end, 1, Ct)).Samples;
var trimmed = resumed ? page.Where(s => s.SourceTimestampUtc != start).ToList() : page.ToList();
if (page.Count == 0) break;
if (resumed && (trimmed.Count == 0 || trimmed[^1].SourceTimestampUtc == start))
{
// Stalled on the boundary: the tie cluster at `start` is one sample (already emitted) ⇒ step down a tick.
(await src.ReadRawAsync("A", start, start, 2, Ct)).Samples.Count.ShouldBe(1);
if (start.AddTicks(-1) <= end) break;
start = start.AddTicks(-1);
resumed = false;
continue;
}
emitted.AddRange(trimmed.Select(s => s.Value));
start = page[^1].SourceTimestampUtc!.Value;
resumed = true;
}
emitted.ShouldBe([50d, 40d, 30d, 20d, 10d]);
}
[Fact]
public async Task An_int64_of_long_MinValue_is_served_as_long_MinValue_not_zero()
{
// QuestDB stores long.MinValue in a LONG as NULL (its NULL sentinel); num_value keeps -2^63 exactly (RC2).
await _b.SeedAsync("L",
(At(0), HistorianValue.FromInt64(long.MinValue), Good),
(At(1), HistorianValue.FromInt64(long.MinValue + 1), Good));
var src = _b.CreateDataSource();
(await src.ReadRawAsync("L", At(0), At(10), 0, Ct)).Samples.Select(s => s.Value)
.ShouldBe([(object?)long.MinValue, long.MinValue + 1]);
(await src.ReadAtTimeAsync("L", [At(0)], Ct)).Samples.Single().Value.ShouldBe(long.MinValue);
(await src.ReadProcessedAsync("L", At(0), At(10), TimeSpan.Zero, HistoryAggregateType.Start, Ct))
.Samples.Single().Value.ShouldBe(long.MinValue);
}
[Fact]
public async Task The_kind_not_the_populated_column_decides_the_value()
{
// An Int64-kind row whose num_value is NULL (a NaN-free double view lost) still serves its int_value, and a
// Double-kind row carrying a stray int_value still serves num_value.
await _b.SeedAsync("K");
await _b.ExecuteAsync(
$"INSERT INTO {_b.Conn.SampleTable} (ts, tag, kind, num_value, int_value, status, deleted, server_ts) " +
"VALUES ($1, $2, $3, NULL, $4, $5, false, $6), ($7, $2, $8, $9, $10, $5, false, $11)",
cmd =>
{
QuestDbConnection.BindTimestamp(cmd, At(0)); // $1
QuestDbConnection.BindText(cmd, "K"); // $2
QuestDbConnection.BindByte(cmd, (byte)HistorianValueKind.Int64); // $3
QuestDbConnection.BindLong(cmd, 7); // $4
QuestDbConnection.BindLong(cmd, 0); // $5 status Good
QuestDbConnection.BindTimestamp(cmd, At(1)); // $6
QuestDbConnection.BindTimestamp(cmd, At(2)); // $7
QuestDbConnection.BindByte(cmd, (byte)HistorianValueKind.Double); // $8
cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Double, Value = 2.5 }); // $9
QuestDbConnection.BindLong(cmd, 99); // $10
QuestDbConnection.BindTimestamp(cmd, At(3)); // $11
});
var s = (await _b.CreateDataSource().ReadRawAsync("K", At(0), At(10), 0, Ct)).Samples;
s.Select(x => x.Value).ShouldBe([(object?)7L, 2.5d]);
}
[Fact]
public async Task AtTime_over_several_statement_chunks_keeps_request_length_and_order()
{
await ConformanceData.SeedAAsync(_b);
// 140 distinct times (3 statements of ≤ 50), requested in descending order, plus a duplicate of the first.
var times = Enumerable.Range(0, 140).Select(i => At((390 - i * 3) / 10.0)).ToList();
times.Add(times[0]);
var s = (await _b.CreateDataSource().ReadAtTimeAsync("A", times, Ct)).Samples;
s.Count.ShouldBe(times.Count);
s.Select(x => x.SourceTimestampUtc).ShouldBe(times.Select(t => (DateTime?)t)); // the requested times (M3)
s[0].Value.ShouldBe(49d); // +39: 40 + 10 × 9/10
s[0].StatusCode.ShouldBe(GoodInterpolated);
s[^1].Value.ShouldBe(49d); // the duplicate
s[100].Value.ShouldBe(19d); // +9: 10 + 10 × 9/10, from the third chunk
s[139].Value.ShouldBeNull(); // -2.7 s: no previous sample
s[139].StatusCode.ShouldBe(0x809B0000u); // BadNoData
}
[Fact]
public async Task Events_are_filtered_by_source_and_window_ascending_and_limited_with_a_continuation()
{
await _b.Boot.EnsureAsync(Ct);
async Task Event(DateTime t, string id, string source, int severity, string? message)
{
await _b.ExecuteAsync(
$"INSERT INTO {_b.Conn.EventTable} (ts, event_id, source, severity, message, received_utc) VALUES ($1, $2, $3, $4, $5, $6)",
cmd =>
{
QuestDbConnection.BindTimestamp(cmd, t);
QuestDbConnection.BindText(cmd, id);
QuestDbConnection.BindText(cmd, source);
cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Integer, Value = severity });
QuestDbConnection.BindText(cmd, message);
QuestDbConnection.BindTimestamp(cmd, t.AddSeconds(2));
});
await _b.Wal.WaitAsync(_b.Conn.EventTable, Ct);
}
await Event(At(3), "e3", "src1", 700, "three");
await Event(At(1), "e1", "src1", 70000, "one");
await Event(At(2), "e2", "src2", 500, null);
await Event(At(9), "e9", "src1", 500, "out of window");
var src = _b.CreateDataSource();
var all = await src.ReadEventsAsync(null, At(0), At(5), 0, Ct);
all.Events.Select(e => e.EventId).ShouldBe(["e1", "e2", "e3"]);
all.ContinuationPoint.ShouldBeNull();
all.Events[0].Severity.ShouldBe((ushort)65535);
all.Events[0].EventTimeUtc.ShouldBe(_b.Round(At(1)));
all.Events[0].ReceivedTimeUtc.ShouldBe(_b.Round(At(1)).AddSeconds(2));
all.Events[1].Message.ShouldBeNull();
var one = await src.ReadEventsAsync("src1", At(5), At(0), 1, Ct); // reversed window is swapped
one.Events.Select(e => e.EventId).ShouldBe(["e1"]);
Encoding.UTF8.GetString(one.ContinuationPoint!).ShouldBe(EventContinuation.Encode(_b.Round(At(3)), "e3"));
(await src.ReadEventsAsync("src1", At(0), At(5), 2, Ct)).ContinuationPoint.ShouldBeNull();
(await src.ReadEventsAsync(new string('x', 401), At(0), At(5), 0, Ct)).Events.ShouldBeEmpty();
}
[Fact]
public async Task A_failed_read_is_rethrown_and_recorded_in_health_without_counting_client_errors()
{
// A table set that does not exist, with AutoCreateSchema off: every read that reaches the database fails.
var prefix = fx.UniquePrefix();
var o = fx.Options(prefix, autoCreate: false);
await using var conn = new QuestDbConnection(o);
var boot = new QuestDbSchemaBootstrapper(conn, o);
using var src = new QuestDbHistorianDataSource(conn, boot, new QuestDbTagCatalog(conn, boot, new QuestDbWalVisibility(conn, o.VisibilityTimeout)));
await Should.ThrowAsync<HistorianSchemaMissingException>(() => src.ReadRawAsync("A", At(0), At(100), 0, Ct));
await Should.ThrowAsync<HistorianSchemaMissingException>(() => src.ReadEventsAsync(null, At(0), At(5), 0, Ct));
// Client-request errors are thrown before any I/O and are not backend failures.
await Should.ThrowAsync<HistoryAggregateNotSupportedException>(() =>
src.ReadProcessedAsync("A", At(0), At(5), TimeSpan.Zero, HistoryAggregateType.StandardDeviationPopulation, Ct));
await Should.ThrowAsync<ArgumentOutOfRangeException>(() =>
src.ReadProcessedAsync("A", At(0), At(100), TimeSpan.FromTicks(10), HistoryAggregateType.Count, Ct));
var h = src.GetHealthSnapshot();
h.TotalQueries.ShouldBe(2);
h.TotalFailures.ShouldBe(2);
h.ConsecutiveFailures.ShouldBe(2);
h.LastError.ShouldNotBeNull();
h.LastError.ShouldStartWith(nameof(HistorianSchemaMissingException));
h.ProcessConnectionOpen.ShouldBeFalse();
// The healthy backend's own data source is unaffected (a failure fails only its call).
await ConformanceData.SeedAAsync(_b);
(await _b.CreateDataSource().ReadRawAsync("A", At(0), At(100), 0, Ct)).Samples.Count.ShouldBe(5);
}
}