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);
}
}