Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/LiveWriteReadRaceStressTests.cs
T
Joseph Doherty 2ceed561ad fix(historian): live-gate 8 F1 — per-sample counts and picks from one table version (#680 follow-up)
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.
2026-10-11 18:28:29 -04:00

99 lines
5.0 KiB
C#

using System.Collections.Concurrent;
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.QuestDb.IntegrationTests;
/// <summary>
/// Live-gate 8 F1 (#680 follow-up): per-sample processed reads of a tag that is being written. The StartEnd and
/// ActualTime queries reference their classified rows more than once; QuestDB evaluates each reference with its own
/// table reader, so a row committed between two of them produced a bucket whose counts included a sample its
/// first/last (or extreme) join could not find — a NULL first-of-any-quality time threw <c>InvalidCastException</c>
/// and failed the node's whole read (~1 in 90k reads on the rig), and ActualTime returned a value-less Good. StartEnd
/// now reads the table once; ActualTime keeps its form and an inconsistent bucket evaluates to <c>BadNoData</c>. This
/// test interleaves a continuous single-row writer with tight-loop reads and asserts that no read throws and no
/// non-Bad result is value-less.
/// </summary>
/// <remarks>
/// Opt-in: runs only when <c>HISTORIAN_QUESTDB_STRESS_SECONDS</c> is set (and the fixture's connection is), so it never
/// slows the default suite. Category <c>SlowUnit</c> as well, for filter-based exclusion.
/// </remarks>
[Collection(QuestDbCollection.Name)]
[Trait("Category", "SlowUnit")]
public sealed class LiveWriteReadRaceStressTests(QuestDbFixture fx)
{
/// <summary>Seconds to run the race loop for; absent ⇒ the test skips.</summary>
public const string SecondsEnvVar = "HISTORIAN_QUESTDB_STRESS_SECONDS";
private static readonly HistoryAggregateType[] Aggregates =
[
HistoryAggregateType.Start, HistoryAggregateType.End, HistoryAggregateType.Delta,
HistoryAggregateType.MinimumActualTime, HistoryAggregateType.MaximumActualTime,
];
[Fact]
public async Task Per_sample_reads_of_a_tag_being_written_never_fault_and_never_return_a_valueless_non_bad_result()
{
if (!int.TryParse(Environment.GetEnvironmentVariable(SecondsEnvVar), out var seconds) || seconds <= 0)
Assert.Skip($"{SecondsEnvVar} is not set; the live write/read race stress test is opt-in.");
await using var b = await QuestDbConformanceBackend.CreateConcreteAsync(fx);
const string tag = "Race";
await b.NewCatalog().EnsureAsync(tag, 0, CancellationToken.None);
var src = b.CreateDataSource();
using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(seconds));
long written = 0, reads = 0;
var faults = new ConcurrentBag<string>();
var writer = Task.Run(async () =>
{
var i = 0;
while (!stop.IsCancellationRequested)
{
// One committed row per statement (one WAL transaction each), every 7th Bad, never waiting for visibility.
var status = ++i % 7 == 0 ? HistorianStatusCodes.Bad : 0u;
await b.InsertRowsNoWaitAsync(tag, [(DateTime.UtcNow, HistorianValue.FromDouble(i), status, false)]);
Interlocked.Increment(ref written);
}
});
var readers = Enumerable.Range(0, 4).Select(_ => Task.Run(async () =>
{
while (!stop.IsCancellationRequested)
{
var now = DateTime.UtcNow;
var start = new DateTime(now.Ticks / TimeSpan.TicksPerSecond / 10 * 10 * TimeSpan.TicksPerSecond, DateTimeKind.Utc)
.AddSeconds(-30);
var end = start.AddSeconds(70);
foreach (var agg in Aggregates)
foreach (var reverse in new[] { false, true })
{
try
{
var r = await src.ReadProcessedAsync(tag, reverse ? end : start, reverse ? start : end,
TimeSpan.FromSeconds(10), agg, CancellationToken.None);
foreach (var v in r.Samples)
if (!StatusBits.IsBad(v.StatusCode) && v.Value is null)
faults.Add($"{agg} {(reverse ? "rev" : "fwd")}: non-Bad 0x{v.StatusCode:X8} with no value at {v.SourceTimestampUtc:O}");
}
catch (Exception ex)
{
faults.Add($"{agg} {(reverse ? "rev" : "fwd")}: {ex.GetType().Name}: {ex.Message}");
}
Interlocked.Increment(ref reads);
}
}
})).ToArray();
await Task.WhenAll(readers.Append(writer));
var summary = $"race stress: {seconds}s, {Interlocked.Read(ref written)} rows written, {Interlocked.Read(ref reads)} reads, {faults.Count} faults";
var output = TestContext.Current.TestOutputHelper;
output?.WriteLine(summary);
foreach (var f in faults.Take(20)) output?.WriteLine("fault: " + f);
written.ShouldBeGreaterThan(0);
faults.ShouldBeEmpty(summary);
}
}