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; /// /// 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. /// [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(); /// What T6's DeleteAtTime writes: the same (ts, tag), deleted = true, value columns NULL, status BadNoData. 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 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(); s.StatusCode.ShouldBe(UncertainCalc, 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.Total, Ct)) .Samples.Single().Value.ShouldBe(120d); // 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); } /// /// The node manager's reverse paging at NumValuesPerNode = 1 (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. /// [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(); 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?)_b.Round(t))); 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(() => src.ReadRawAsync("A", At(0), At(100), 0, Ct)); await Should.ThrowAsync(() => 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(() => src.ReadProcessedAsync("A", At(0), At(5), TimeSpan.Zero, HistoryAggregateType.Interpolative, Ct)); await Should.ThrowAsync(() => 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); } }