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.
99 lines
5.0 KiB
C#
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);
|
|
}
|
|
}
|