Files
lmxopcua/tests/Drivers/Historian.Conformance/QualityContractConformance.cs
T
Joseph Doherty cfd403710e feat(historian): Part 11 modified-value history on SqlServer + QuestDB (#668)
Every HistoryUpdate a SQL backend applies now leaves a modification trail
(prior value, principal, Part 11 update type, modification time), and
HistoryReadRaw with IsReadModified=true serves it as HistoryModifiedData.

- Core: HistoryUpdateContext threaded into all three IHistorianDataUpdater
  methods; IHistorianDataSource.ReadModifiedAsync (default NotSupported =>
  BadHistoryOperationUnsupported on Gateway/Null); HistoryModified* contracts.
- SqlServer: schema v2 (002_sample_modified.sql, RequiredVersion=2, docs copy);
  trail written by OUTPUT ... INTO on the same MERGE/DELETE statement, so a
  failed update leaves no audit row; retention purges SampleModified per tag on
  the source-time cutoff; ReadModifiedAsync keyed on (SourceTimeUtc, ModifiedId).
- QuestDB: append-only {prefix}sample_modified (TTL like samples), trail row
  written BEFORE the change (orphan-on-crash, never an untracked change);
  existence reads now carry the prior value; ReadModifiedAsync.
- Node manager: modified reads dispatch + page via the #669 continuation store
  (HistoryContinuationState gains HistoryCursorKind + LastModifiedId), gated by
  the existing HistoryRead decision; session principal / key id passed to the
  updater. IsDeleteModified stays unsupported.
- Client.CLI: historyread --modified.
- Tests: node-manager dispatch/paging/cursor binding/principal threading,
  over-the-wire HistoryModifiedData, shared ModifiedValueConformance on both
  backends, SqlServer atomicity (forced trail failure => no change) + v1->v2
  upgrade + retention trail purge.
- Docs: Historian.md (Modified-value history section, schema v2 upgrade note),
  security.md, Client.CLI.md, CLAUDE.md.
2026-10-11 09:26:36 -04:00

95 lines
4.5 KiB
C#

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.Conformance;
/// <summary>
/// Final-review I-1: a stored sample is never served with a better quality than "no value". An Empty (null-value)
/// sample is always Bad — forced Bad by the value writer, refused non-Bad by the updater, and clamped to
/// <c>BadNoData</c> on read for rows written before either rule existed.
/// </summary>
public abstract class QualityContractConformance : ConformanceBase
{
private static readonly CancellationToken Ct = CancellationToken.None;
private const uint BadOutOfRange = 0x803C0000u;
private const uint BadNoData = 0x809B0000u;
private const uint BadInvalidArgument = 0x80AB0000u;
private static bool IsBad(uint status) => (status & 0x80000000u) != 0;
[Fact]
public async Task A_good_NaN_written_through_the_real_writer_reads_back_Bad_on_Raw_and_AtTime()
{
(await B.CreateValueWriter().WriteLiveValuesAsync("nan", [
new(At(0), HistorianValue.FromDouble(10), 0u),
new(At(10), HistorianValue.FromDouble(double.NaN), 0u),
new(At(20), HistorianValue.FromDouble(double.PositiveInfinity), 0x40000000u),
new(At(30), HistorianValue.FromDouble(double.NegativeInfinity), BadNoCommunication),
], Ct)).ShouldBeTrue();
var raw = (await B.CreateDataSource().ReadRawAsync("nan", At(0), At(100), 0, Ct)).Samples;
raw.Count.ShouldBe(4);
raw[1].Value.ShouldBeNull();
raw[1].StatusCode.ShouldBe(BadOutOfRange); // Good NaN ⇒ stored Bad
raw[2].StatusCode.ShouldBe(BadOutOfRange); // Uncertain +Inf ⇒ stored Bad
raw[3].StatusCode.ShouldBe(BadNoCommunication); // an existing Bad code is kept
var at = (await B.CreateDataSource().ReadAtTimeAsync("nan", [At(10), At(15)], Ct)).Samples;
at.ShouldAllBe(s => s.Value == null);
at.ShouldAllBe(s => IsBad(s.StatusCode));
at[1].StatusCode.ShouldBe(BadOutOfRange); // inside the "outage" the NaN opened
}
[Fact]
public async Task A_non_bad_Empty_written_through_the_real_writer_is_stored_BadNoData()
{
(await B.CreateValueWriter().WriteLiveValuesAsync("empty", [
new(At(0), HistorianValue.Empty, 0u),
new(At(1), HistorianValue.Empty, 0x40000000u),
new(At(2), HistorianValue.Empty, BadNoCommunication),
], Ct)).ShouldBeTrue();
// Read the stored status directly so the read-side clamp cannot mask a writer that forgot to force Bad.
var stored = await B.ReadBackAsync("empty");
stored.Count.ShouldBe(3);
stored.ShouldAllBe(r => IsBad(r.Status));
var raw = (await B.CreateDataSource().ReadRawAsync("empty", At(0), At(10), 0, Ct)).Samples;
raw.Select(s => s.StatusCode).ShouldBe([BadNoData, BadNoData, BadNoCommunication]);
}
[Fact]
public async Task A_legacy_good_Empty_row_is_clamped_to_BadNoData_on_Raw_and_AtTime()
{
// Seeded directly, bypassing the writer: what a pre-fix build could have stored.
await B.SeedAsync("legacy",
(At(0), HistorianValue.FromDouble(10), Good),
(At(10), HistorianValue.Empty, Good),
(At(20), HistorianValue.Empty, Uncertain),
(At(30), HistorianValue.FromDouble(40), Good));
var raw = (await B.CreateDataSource().ReadRawAsync("legacy", At(0), At(100), 0, Ct)).Samples;
raw.Select(s => s.StatusCode).ShouldBe([Good, BadNoData, BadNoData, Good]);
var at = (await B.CreateDataSource().ReadAtTimeAsync("legacy", [At(10), At(15), At(25)], Ct)).Samples;
at.ShouldAllBe(s => s.Value == null && s.StatusCode == BadNoData);
}
[Fact]
public async Task The_updater_refuses_a_non_bad_Empty_and_accepts_a_bad_one()
{
var r = await B.CreateUpdater().UpdateDataAsync("u", HistoryUpdateMode.Insert, [
new(At(0), HistorianValue.Empty, 0u),
new(At(1), HistorianValue.Empty, 0x40000000u),
new(At(2), HistorianValue.Empty, BadNoCommunication),
new(At(3), HistorianValue.FromDouble(1), 0u),
], ConformanceData.Editor, Ct);
r.ShouldBe([BadInvalidArgument, BadInvalidArgument, HistorianStatusCodes.GoodEntryInserted,
HistorianStatusCodes.GoodEntryInserted]);
(await B.CreateDataSource().ReadRawAsync("u", At(0), At(10), 0, Ct)).Samples
.Select(s => s.StatusCode).ShouldBe([BadNoCommunication, 0u]);
}
}