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; /// /// 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 InvalidCastException /// 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 BadNoData. 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. /// /// /// Opt-in: runs only when HISTORIAN_QUESTDB_STRESS_SECONDS is set (and the fixture's connection is), so it never /// slows the default suite. Category SlowUnit as well, for filter-based exclusion. /// [Collection(QuestDbCollection.Name)] [Trait("Category", "SlowUnit")] public sealed class LiveWriteReadRaceStressTests(QuestDbFixture fx) { /// Seconds to run the race loop for; absent ⇒ the test skips. 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(); 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); } }