2ceed561ad
QuestDB Start/End/Delta reads of a tag being written intermittently failed BadHistoryOperationUnsupported (InvalidCastException: Column 'ts_3' is null): the StartEnd query referenced its CTE four times and QuestDB opens a table reader per reference, so a row committed between two of them was counted but not picked. MinimumActualTime/MaximumActualTime had the same race and returned a value-less Good. (a) SQL - QuestDB StartEnd: one scan, one GROUP BY — first()/last() for the any-quality picks, arg_min/arg_max keyed on the usable rows' time for the usable picks (no str_value: arg_min has no VARCHAR form; only Delta reads them). Byte-identical output to the old query on a 1M-row tag, ~15% faster. - SqlServer StartEnd: one windowed pass + conditional MAX (was five CTE references); identical output, 1.4-3x faster. - ActualTime kept on both backends: every single-scan form measured 1.2-2.4x slower; its inconsistency is absorbed by (b). (b) Defence in depth on both backends - ReadBucket takes DbDataReader and reads absent picks as null (ReadStoredOrNull) instead of throwing. - AggregateEvaluator: Delta with a missing first/last usable pick and ActualTime with a missing extreme are BadNoData (were BadAggregateInvalidInputs / a value-less Good); Start/End with no pick already were. - Unit tests (Sql.Common evaluator, QuestDb + SqlServer mappers over a DataTableReader) fail with the guards removed. LiveWriteReadRaceStressTests (QuestDB integration, opt-in via HISTORIAN_QUESTDB_STRESS_SECONDS, Category=SlowUnit): single-row writer vs 4 tight-loop readers. Old code: 6 faults in ~105k reads; new: 0 in 106k. Docs: Historian.md QuestDB processed-read note; F2 (Min/Max on the interval start: SDK raw, ours Calculated) and F3 (integer tags: SDK casts interpolated values to the integer type, ours Double) added to the SDK-parity deviations.
309 lines
17 KiB
C#
309 lines
17 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using static ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests.Fixture;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests;
|
|
|
|
/// <summary>
|
|
/// Direct tests of the aggregate evaluator over hand-built bucket rows for tag A, 20 s intervals from T0, window
|
|
/// [t0, t0+80s): B0 = {10, 20}; B1 = {Bad 30, 40}; B2 = {50}; B3 empty. Literals from the SqlServer
|
|
/// <c>ProcessedReadTests</c>.
|
|
/// </summary>
|
|
public sealed class AggregateEvaluatorTests
|
|
{
|
|
private static readonly DateTime Now = At(3600);
|
|
|
|
private static Dictionary<long, BucketRow> RowsA(bool maximum = false) => new()
|
|
{
|
|
[0] = new BucketRow
|
|
{
|
|
Bucket = 0, Total = 2, Usable = 2, Good = 2, Bad = 0, NonNumeric = 0, Avg = 15, Min = 10, Max = 20, Sum = 30,
|
|
First = HistorianValue.FromDouble(10), Last = HistorianValue.FromDouble(20),
|
|
Extreme = maximum ? 20 : 10, ExtremeTime = maximum ? At(10) : At(0),
|
|
FirstAny = A[0], LastAny = A[1],
|
|
},
|
|
[1] = new BucketRow
|
|
{
|
|
Bucket = 1, Total = 2, Usable = 1, Good = 1, Bad = 1, NonNumeric = 0, Avg = 40, Min = 40, Max = 40, Sum = 40,
|
|
First = HistorianValue.FromDouble(40), Last = HistorianValue.FromDouble(40), Extreme = 40, ExtremeTime = At(30),
|
|
FirstAny = A[2], LastAny = A[3],
|
|
},
|
|
[2] = new BucketRow
|
|
{
|
|
Bucket = 2, Total = 1, Usable = 1, Good = 1, Bad = 0, NonNumeric = 0, Avg = 50, Min = 50, Max = 50, Sum = 50,
|
|
First = HistorianValue.FromDouble(50), Last = HistorianValue.FromDouble(50), Extreme = 50, ExtremeTime = At(40),
|
|
FirstAny = A[4], LastAny = A[4],
|
|
},
|
|
};
|
|
|
|
private const uint Partial = 0x4u;
|
|
private const uint BadCalc = 0x80000401u; // generic Bad | Calculated
|
|
|
|
/// <summary>Tag A's data runs +0 … +40, so the forward read [0,80) holds the end of the data in B2 [40,60).</summary>
|
|
private static IReadOnlyList<DataValueSnapshot> ReadA(HistoryAggregateType agg, bool maximum = false)
|
|
{
|
|
var plan = BucketPlan.Create(At(0), At(80), TimeSpan.FromSeconds(20));
|
|
return AggregateEvaluator.EvaluateAll(agg, plan, RowsA(maximum).Values, At(0), At(40), Now);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(HistoryAggregateType.Average, 15d, 40d, 50d)]
|
|
[InlineData(HistoryAggregateType.Minimum, 10d, 40d, 50d)]
|
|
[InlineData(HistoryAggregateType.Maximum, 20d, 40d, 50d)]
|
|
[InlineData(HistoryAggregateType.Delta, 10d, 0d, 0d)]
|
|
[InlineData(HistoryAggregateType.Range, 10d, 0d, 0d)]
|
|
public void Numeric_aggregates_over_hand_built_bucket_rows(HistoryAggregateType agg, double b0, double b1, double b2)
|
|
{
|
|
var s = ReadA(agg);
|
|
s.Count.ShouldBe(4);
|
|
s.Select(x => x.Value).ShouldBe([b0, b1, b2, null]);
|
|
// #680: B2 holds the last sample (+40) ⇒ end-of-data Partial on a forward read, for every aggregate but Average.
|
|
// B1 opens on a Bad sample ⇒ Uncertain (for Delta: the interval's first sample was skipped).
|
|
var b2Status = agg == HistoryAggregateType.Average ? GoodCalc : GoodCalc | Partial;
|
|
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, b2Status, NoDataCalc]);
|
|
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(20), At(40), At(60)]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Start_and_End_return_the_raw_first_and_last_sample_of_any_quality()
|
|
{
|
|
// #680 (Part 13 Tables 73/74; SDK ComputeStartEnd): the raw sample — its own value (null when Bad), its own status
|
|
// (Raw, no Calculated bit) and its own timestamp. B1's Start is the Bad sample at +20.
|
|
var start = ReadA(HistoryAggregateType.Start);
|
|
start.Select(x => x.Value).ShouldBe([10d, null, 50d, null]);
|
|
start.Select(x => x.StatusCode).ShouldBe([Good, Bad, Good | 0x400u | Partial, NoDataCalc]);
|
|
start.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(20), At(40), At(60)]);
|
|
var end = ReadA(HistoryAggregateType.End);
|
|
end.Select(x => x.Value).ShouldBe([20d, 40d, 50d, null]);
|
|
end.Select(x => x.StatusCode).ShouldBe([Good, Good, Good | 0x400u | Partial, NoDataCalc]);
|
|
end.Select(x => x.SourceTimestampUtc).ShouldBe([At(10), At(30), At(40), At(60)]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Start_and_End_follow_the_sample_order_in_a_reverse_read()
|
|
{
|
|
// Reverse (80 → 0): (20,40] = {+30, +40}, (0,20] = {+10, +20 Bad}. Start is still the EARLIEST sample of each
|
|
// interval and End the latest (the SDK's GetValues list is ascending in both directions).
|
|
var plan = BucketPlan.Create(At(80), At(0), TimeSpan.FromSeconds(20));
|
|
var rows = new[]
|
|
{
|
|
new BucketRow { Bucket = 1, Total = 2, Usable = 2, Good = 2, FirstAny = A[3], LastAny = A[4] },
|
|
new BucketRow { Bucket = 0, Total = 2, Usable = 1, Good = 1, Bad = 1, FirstAny = A[1], LastAny = A[2] },
|
|
};
|
|
var start = AggregateEvaluator.EvaluateAll(HistoryAggregateType.Start, plan, rows, At(0), At(40), Now);
|
|
start.Select(x => x.SourceTimestampUtc).ShouldBe([At(80), At(60), At(30), At(10)]);
|
|
start.Select(x => x.Value).ShouldBe([null, null, 40d, 20d]);
|
|
var end = AggregateEvaluator.EvaluateAll(HistoryAggregateType.End, plan, rows, At(0), At(40), Now);
|
|
end.Select(x => x.SourceTimestampUtc).ShouldBe([At(80), At(60), At(40), At(20)]);
|
|
end.Select(x => x.Value).ShouldBe([null, null, 50d, null]);
|
|
end.Select(x => x.StatusCode).ShouldBe([NoDataCalc, NoDataCalc, Good, Bad]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Delta_is_good_when_a_bad_sample_sits_mid_interval_and_uncertain_when_one_bounds_it()
|
|
{
|
|
// SDK ComputeDelta: Uncertain only when the first or last sample of the interval is skipped (not usable).
|
|
var mid = new BucketRow
|
|
{
|
|
Total = 3, Usable = 2, Good = 2, Bad = 1, First = HistorianValue.FromDouble(1), Last = HistorianValue.FromDouble(4),
|
|
FirstAny = S(0, HistorianValue.FromDouble(1), Good), LastAny = S(9, HistorianValue.FromDouble(4), Good),
|
|
};
|
|
var d = AggregateEvaluator.Evaluate(HistoryAggregateType.Delta, mid, At(0), Now);
|
|
d.Value.ShouldBe(3d);
|
|
d.StatusCode.ShouldBe(GoodCalc);
|
|
mid.LastAny = S(9, HistorianValue.Empty, BadNoCommunication);
|
|
AggregateEvaluator.Evaluate(HistoryAggregateType.Delta, mid, At(0), Now).StatusCode.ShouldBe(UncertainCalc);
|
|
// An Uncertain bound is used (TreatUncertainAsBad = false), so it does not degrade Delta.
|
|
mid.LastAny = S(9, HistorianValue.FromDouble(4), Uncertain);
|
|
AggregateEvaluator.Evaluate(HistoryAggregateType.Delta, mid, At(0), Now).StatusCode.ShouldBe(GoodCalc);
|
|
}
|
|
|
|
[Fact]
|
|
public void Count_counts_good_samples_as_int()
|
|
{
|
|
// #680 (Part 13 §5.4.3.21; SDK ComputeCount): Good samples only; value-based status; end-of-data Partial on B2.
|
|
var s = ReadA(HistoryAggregateType.Count);
|
|
s.Select(x => x.Value).ShouldBe([(object?)2, 1, 1, null]);
|
|
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc | Partial, NoDataCalc]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Count_excludes_uncertain_samples_and_an_all_bad_interval_is_generic_bad()
|
|
{
|
|
var unc = new BucketRow { Total = 3, Usable = 3, Good = 1, Bad = 0 };
|
|
var c = AggregateEvaluator.Evaluate(HistoryAggregateType.Count, unc, At(0), Now);
|
|
c.Value.ShouldBe(1);
|
|
c.StatusCode.ShouldBe(UncertainCalc);
|
|
var bad = AggregateEvaluator.Evaluate(HistoryAggregateType.Count, new BucketRow { Total = 2, Bad = 2 }, At(0), Now);
|
|
bad.Value.ShouldBeNull();
|
|
bad.StatusCode.ShouldBe(BadCalc);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_empty_interval_inside_the_data_range_counts_zero_and_outside_it_is_no_data()
|
|
{
|
|
// #680: forward [0,60) at 10 s over data +1 … +45: [10,20) and [30,40) hold no sample but lie inside
|
|
// [earliest, latest] ⇒ 0, Good; [50,60) is after the last sample ⇒ BadNoData.
|
|
var plan = BucketPlan.Create(At(0), At(60), TimeSpan.FromSeconds(10));
|
|
var rows = new[]
|
|
{
|
|
new BucketRow { Bucket = 0, Total = 1, Usable = 1, Good = 1 },
|
|
new BucketRow { Bucket = 2, Total = 1, Usable = 1, Good = 1 },
|
|
new BucketRow { Bucket = 4, Total = 1, Usable = 1, Good = 1 },
|
|
};
|
|
var s = AggregateEvaluator.EvaluateAll(HistoryAggregateType.Count, plan, rows, At(1), At(45), Now);
|
|
s.Select(x => x.Value).ShouldBe([(object?)1, 0, 1, 0, 1, null]);
|
|
s.Select(x => x.StatusCode).ShouldBe(
|
|
[GoodCalc | Partial, GoodCalc, GoodCalc, GoodCalc, GoodCalc | Partial, NoDataCalc]);
|
|
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(10), At(20), At(30), At(40), At(50)]);
|
|
|
|
// Reverse (60 → 0): (40,50] is inside the range (its earlier edge +40 < +45); (50,60] is after it. The last sample
|
|
// (+45) lies strictly inside (40,50] ⇒ Partial there; the first sample's interval is not flagged in reverse.
|
|
var rev = BucketPlan.Create(At(60), At(0), TimeSpan.FromSeconds(10));
|
|
var r = AggregateEvaluator.EvaluateAll(HistoryAggregateType.Count, rev, rows, At(1), At(45), Now);
|
|
r.Select(x => x.Value).ShouldBe([null, (object?)1, 0, 1, 0, 1]);
|
|
r.Select(x => x.StatusCode).ShouldBe([NoDataCalc, GoodCalc | Partial, GoodCalc, GoodCalc, GoodCalc, GoodCalc]);
|
|
|
|
// Other per-sample aggregates keep BadNoData on an empty interval.
|
|
AggregateEvaluator.EvaluateAll(HistoryAggregateType.Minimum, plan, rows, At(1), At(45), Now)[1].StatusCode
|
|
.ShouldBe(NoDataCalc);
|
|
// No data range known (an unknown tag / no sample near the window) ⇒ every empty interval is BadNoData.
|
|
AggregateEvaluator.EvaluateAll(HistoryAggregateType.Count, plan, [], null, null, Now)
|
|
.ShouldAllBe(x => x.StatusCode == NoDataCalc);
|
|
}
|
|
|
|
[Fact]
|
|
public void End_of_data_Partial_includes_the_interval_start_forward_and_is_strict_in_reverse()
|
|
{
|
|
var row = new BucketRow { Bucket = 2, Total = 1, Usable = 1, Good = 1, Min = 3, Avg = 3 };
|
|
var fwd = BucketPlan.Create(At(0), At(40), TimeSpan.FromSeconds(10));
|
|
// Latest sample exactly on the interval's earlier edge (+20) ⇒ Partial (SDK m_endOfData >= startTime).
|
|
AggregateEvaluator.EvaluateAll(HistoryAggregateType.Minimum, fwd, [row], At(-5), At(20), Now)[2].StatusCode
|
|
.ShouldBe(GoodCalc | Partial);
|
|
// Data past the window end ⇒ no end-of-data Partial.
|
|
AggregateEvaluator.EvaluateAll(HistoryAggregateType.Minimum, fwd, [row], At(-5), At(100), Now)[2].StatusCode
|
|
.ShouldBe(GoodCalc);
|
|
// Average never sets it.
|
|
AggregateEvaluator.EvaluateAll(HistoryAggregateType.Average, fwd, [row], At(-5), At(25), Now)[2].StatusCode
|
|
.ShouldBe(GoodCalc);
|
|
// A Bad result never carries Partial (ours; the SDK sets it on a Bad result too).
|
|
var bad = new BucketRow { Bucket = 2, Total = 1, Bad = 1 };
|
|
AggregateEvaluator.EvaluateAll(HistoryAggregateType.Minimum, fwd, [bad], At(-5), At(25), Now)[2].StatusCode
|
|
.ShouldBe(NoDataCalc);
|
|
// Reverse (40 → 0; bucket 2 = (20,30], result index 1): the latest sample strictly inside ⇒ Partial (the SDK's
|
|
// reverse m_startOfData); on the interval's later edge (+30) or its excluded earlier edge (+20) ⇒ nothing.
|
|
var rev = BucketPlan.Create(At(40), At(0), TimeSpan.FromSeconds(10));
|
|
AggregateEvaluator.EvaluateAll(HistoryAggregateType.Minimum, rev, [row], At(-5), At(25), Now)
|
|
.Select(x => x.StatusCode & Partial).ShouldBe([0u, Partial, 0u, 0u]);
|
|
AggregateEvaluator.EvaluateAll(HistoryAggregateType.Minimum, rev, [row], At(-5), At(30), Now)
|
|
.ShouldAllBe(x => (x.StatusCode & Partial) == 0);
|
|
AggregateEvaluator.EvaluateAll(HistoryAggregateType.Average, rev, [row], At(-5), At(25), Now)
|
|
.ShouldAllBe(x => (x.StatusCode & Partial) == 0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Actual_time_aggregates_stamp_the_sample_time()
|
|
{
|
|
var min = ReadA(HistoryAggregateType.MinimumActualTime);
|
|
min.Select(x => x.Value).ShouldBe([10d, 40d, 50d, null]);
|
|
min.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(30), At(40), At(60)]);
|
|
min.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc | Partial, NoDataCalc]);
|
|
var max = ReadA(HistoryAggregateType.MaximumActualTime, maximum: true);
|
|
max.Select(x => x.Value).ShouldBe([20d, 40d, 50d, null]);
|
|
max.Select(x => x.SourceTimestampUtc).ShouldBe([At(10), At(30), At(40), At(60)]);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_usable_non_numeric_sample_makes_a_numeric_aggregate_invalid_inputs_but_not_count()
|
|
{
|
|
var row = new BucketRow { Total = 2, Usable = 2, Good = 2, NonNumeric = 1, Avg = 1, First = HistorianValue.FromString("x"),
|
|
Last = HistorianValue.FromDouble(1) };
|
|
var avg = AggregateEvaluator.Evaluate(HistoryAggregateType.Average, row, At(0), Now);
|
|
avg.Value.ShouldBeNull();
|
|
avg.StatusCode.ShouldBe(InvalidInputsCalc);
|
|
AggregateEvaluator.Evaluate(HistoryAggregateType.Count, row, At(0), Now).Value.ShouldBe(2);
|
|
AggregateEvaluator.Evaluate(HistoryAggregateType.Delta, row, At(0), Now).StatusCode.ShouldBe(InvalidInputsCalc);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_bucket_with_only_bad_samples_is_no_data_except_for_count()
|
|
{
|
|
var row = new BucketRow { Total = 2, Usable = 0, Good = 0, Bad = 2 };
|
|
var avg = AggregateEvaluator.Evaluate(HistoryAggregateType.Average, row, At(0), Now);
|
|
avg.Value.ShouldBeNull();
|
|
avg.StatusCode.ShouldBe(NoDataCalc);
|
|
// PercentGood / PercentBad are duration-based (time-weighted family) since #680: not served here.
|
|
Should.Throw<HistoryAggregateNotSupportedException>(() =>
|
|
AggregateEvaluator.Evaluate(HistoryAggregateType.PercentBad, row, At(0), Now));
|
|
// #680: Count of an all-Bad interval is the generic Bad (the SDK's value-based status), not BadNoData.
|
|
AggregateEvaluator.Evaluate(HistoryAggregateType.Count, row, At(0), Now).StatusCode.ShouldBe(BadCalc);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_unsupported_aggregate_throws_not_supported()
|
|
{
|
|
var ex = Should.Throw<HistoryAggregateNotSupportedException>(() =>
|
|
AggregateEvaluator.Evaluate(HistoryAggregateType.TimeAverage, RowsA()[0], At(0), Now));
|
|
ex.Aggregate.ShouldBe(HistoryAggregateType.TimeAverage);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_uncertain_sample_in_a_bucket_degrades_the_aggregate_to_uncertain()
|
|
{
|
|
// Two usable samples, one Good and one Uncertain, no Bad: the value is computed, the status is Uncertain.
|
|
var row = new BucketRow { Total = 2, Usable = 2, Good = 1, Bad = 0, Avg = 15, Min = 10, Max = 20, Sum = 30 };
|
|
var avg = AggregateEvaluator.Evaluate(HistoryAggregateType.Average, row, At(0), Now);
|
|
avg.Value.ShouldBe(15d);
|
|
avg.StatusCode.ShouldBe(UncertainCalc);
|
|
}
|
|
|
|
// ---- Live-gate 8 F1: a row whose counts and picks disagree (a backend that read them from two table versions).
|
|
|
|
/// <summary>Counts say B0 holds two Good samples, but none of the picked samples came back.</summary>
|
|
private static BucketRow InconsistentRow() => new()
|
|
{
|
|
Bucket = 0, Total = 2, Usable = 2, Good = 2, Bad = 0, NonNumeric = 0, Avg = 15, Min = 10, Max = 20, Sum = 30,
|
|
First = HistorianValue.Empty, Last = HistorianValue.Empty, Extreme = null, ExtremeTime = null,
|
|
FirstAny = null, LastAny = null,
|
|
};
|
|
|
|
[Theory]
|
|
[InlineData(HistoryAggregateType.Start)]
|
|
[InlineData(HistoryAggregateType.End)]
|
|
[InlineData(HistoryAggregateType.Delta)]
|
|
[InlineData(HistoryAggregateType.MinimumActualTime)]
|
|
[InlineData(HistoryAggregateType.MaximumActualTime)]
|
|
public void A_bucket_whose_picked_samples_are_missing_is_no_data_not_a_throw_or_a_valueless_good(HistoryAggregateType agg)
|
|
{
|
|
var v = AggregateEvaluator.Evaluate(agg, InconsistentRow(), At(0), Now);
|
|
v.Value.ShouldBeNull();
|
|
v.StatusCode.ShouldBe(NoDataCalc);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_delta_whose_first_usable_pick_alone_is_missing_is_no_data_not_invalid_inputs()
|
|
{
|
|
var row = InconsistentRow();
|
|
row.Last = HistorianValue.FromDouble(20);
|
|
row.FirstAny = A[0];
|
|
row.LastAny = A[1];
|
|
AggregateEvaluator.Evaluate(HistoryAggregateType.Delta, row, At(0), Now).StatusCode.ShouldBe(NoDataCalc);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_inconsistent_bucket_does_not_fail_the_whole_read_and_its_neighbours_keep_their_values()
|
|
{
|
|
var rows = RowsA();
|
|
rows[0] = InconsistentRow();
|
|
var plan = BucketPlan.Create(At(0), At(80), TimeSpan.FromSeconds(20));
|
|
foreach (var agg in new[] { HistoryAggregateType.Start, HistoryAggregateType.Delta, HistoryAggregateType.MaximumActualTime })
|
|
{
|
|
var s = AggregateEvaluator.EvaluateAll(agg, plan, rows.Values, At(0), At(40), Now);
|
|
s.Count.ShouldBe(4);
|
|
s[0].StatusCode.ShouldBe(NoDataCalc);
|
|
s[2].Value.ShouldNotBeNull();
|
|
}
|
|
}
|
|
}
|