Files
lmxopcua/tests/Drivers/Historian.Conformance/ModifiedValueConformance.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

218 lines
10 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>
/// OPC UA Part 11 modified-value history (Gitea #668): every HistoryUpdate a backend applies leaves an ordered
/// modification trail — the PRIOR value, the caller's principal, the Part 11 update type and the modification time —
/// readable through <see cref="IHistorianDataSource.ReadModifiedAsync"/>. Refused items, live recorder writes and
/// reads leave nothing.
/// </summary>
public abstract class ModifiedValueConformance : ConformanceBase
{
private static readonly DateTime T0 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static DateTime T(int n) => T0.AddSeconds(n).AddTicks(1230); // µs-aligned so both backends store it exactly
private static readonly DateTime Lo = T0.AddHours(-1);
private static readonly DateTime Hi = T0.AddHours(1);
private static readonly CancellationToken Ct = CancellationToken.None;
private static readonly HistoryUpdateContext Editor = ConformanceData.Editor;
private static HistorizationValue V(int n, double d, uint status = Good) =>
new(T(n), HistorianValue.FromDouble(d), status);
private async Task<IReadOnlyList<HistoryModifiedValue>> TrailAsync(
string tag, DateTime? start = null, DateTime? end = null, uint max = 0, HistoryModifiedCursor? after = null)
{
using var src = B.CreateDataSource();
var r = await src.ReadModifiedAsync(tag, start ?? Lo, end ?? Hi, max, after, Ct);
if (max == 0) r.HasMore.ShouldBeFalse();
return r.Values;
}
private static void ShouldBeNoPrior(HistoryModifiedValue m)
{
m.Value.Value.ShouldBeNull();
m.Value.StatusCode.ShouldBe(HistorianStatusCodes.BadNoData);
}
[Fact]
public async Task Insert_records_one_Insert_per_sample_with_no_prior_value_and_the_principal()
{
var before = DateTime.UtcNow.AddSeconds(-5);
var u = B.CreateUpdater();
(await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(2, 2), V(1, 1)], Editor, Ct))
.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryInserted]);
var trail = await TrailAsync("t");
trail.Count.ShouldBe(2);
trail.Select(m => m.Value.SourceTimestampUtc).ShouldBe([T(1), T(2)]); // ordered by source time
trail.ShouldAllBe(m => m.UpdateType == HistoryModificationType.Insert);
trail.ShouldAllBe(m => m.UserName == "conformance-editor");
trail.ShouldAllBe(m => m.ModificationTimeUtc >= before && m.ModificationTimeUtc <= DateTime.UtcNow.AddSeconds(5));
foreach (var m in trail) ShouldBeNoPrior(m);
}
[Fact]
public async Task Replace_records_the_prior_value_and_status_after_the_insert()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1)], Editor, Ct);
var other = new HistoryUpdateContext("second-editor", null);
(await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(1, 11, Uncertain)], other, Ct))
.ShouldBe([HistorianStatusCodes.GoodEntryReplaced]);
(await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(1, 111)], Editor, Ct))
.ShouldBe([HistorianStatusCodes.GoodEntryReplaced]);
var trail = await TrailAsync("t");
trail.Select(m => m.UpdateType).ShouldBe(
[HistoryModificationType.Insert, HistoryModificationType.Replace, HistoryModificationType.Replace]);
trail.Select(m => m.UserName).ShouldBe(["conformance-editor", "second-editor", "conformance-editor"]);
ShouldBeNoPrior(trail[0]);
trail[1].Value.Value.ShouldBe(1d); // what the first Replace overwrote
trail[1].Value.StatusCode.ShouldBe(Good);
trail[2].Value.Value.ShouldBe(11d); // what the second Replace overwrote, with its status
trail[2].Value.StatusCode.ShouldBe(Uncertain);
trail.ShouldAllBe(m => m.Value.SourceTimestampUtc == T(1));
trail.Select(m => m.ModifiedId).ShouldBeInOrder(SortDirection.Ascending);
trail.Select(m => m.ModifiedId).Distinct().Count().ShouldBe(3);
// The sample itself carries the latest value.
(await B.ReadBackAsync("t")).ShouldHaveSingleItem().V.Number.ShouldBe(111d);
}
[Fact]
public async Task Update_records_Update_with_the_prior_value_when_it_replaced_and_none_when_it_inserted()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1)], Editor, Ct);
(await u.UpdateDataAsync("t", HistoryUpdateMode.Update, [V(2, 2), V(1, 10)], Editor, Ct))
.ShouldBe([HistorianStatusCodes.GoodEntryInserted, HistorianStatusCodes.GoodEntryReplaced]);
var trail = await TrailAsync("t");
trail.Select(m => (m.Value.SourceTimestampUtc, m.UpdateType)).ShouldBe(
[
(T(1), HistoryModificationType.Insert),
(T(1), HistoryModificationType.Update),
(T(2), HistoryModificationType.Update),
]);
trail[1].Value.Value.ShouldBe(1d);
ShouldBeNoPrior(trail[2]);
}
[Fact]
public async Task DeleteAtTime_records_a_Delete_carrying_each_deleted_value()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1), V(2, 2), V(3, 3, Uncertain)], Editor, Ct);
var deleter = new HistoryUpdateContext("deleter", "k-del");
(await u.DeleteAtTimeAsync("t", [T(3), T(1), T(9)], deleter, Ct))
.ShouldBe([Good, Good, HistorianStatusCodes.BadNoEntryExists]);
var deletes = (await TrailAsync("t")).Where(m => m.UpdateType == HistoryModificationType.Delete).ToList();
deletes.Select(m => m.Value.SourceTimestampUtc).ShouldBe([T(1), T(3)]);
deletes.Select(m => (double)m.Value.Value!).ShouldBe([1d, 3d]);
deletes[1].Value.StatusCode.ShouldBe(Uncertain);
deletes.ShouldAllBe(m => m.UserName == "deleter");
}
[Fact]
public async Task DeleteRaw_records_a_Delete_for_every_sample_it_removed()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1), V(2, 2), V(3, 3)], Editor, Ct);
(await u.DeleteRawAsync("t", T(1), T(3), Editor, Ct)).ShouldBe(Good); // [T1, T3)
var deletes = (await TrailAsync("t")).Where(m => m.UpdateType == HistoryModificationType.Delete).ToList();
deletes.Select(m => m.Value.SourceTimestampUtc).ShouldBe([T(1), T(2)]);
deletes.Select(m => (double)m.Value.Value!).ShouldBe([1d, 2d]);
}
[Fact]
public async Task Refused_items_leave_no_trail()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1)], Editor, Ct);
(await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 99)], Editor, Ct)).ShouldBe([HistorianStatusCodes.BadEntryExists]);
(await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(5, 5)], Editor, Ct)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
(await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [new HistorizationValue(null, HistorianValue.FromDouble(1), Good)], Editor, Ct))
.ShouldBe([HistorianStatusCodes.BadInvalidTimestamp]);
(await u.DeleteAtTimeAsync("t", [T(7)], Editor, Ct)).ShouldBe([HistorianStatusCodes.BadNoEntryExists]);
(await u.DeleteRawAsync("t", T(10), T(20), Editor, Ct)).ShouldBe(HistorianStatusCodes.BadNoData);
var trail = await TrailAsync("t");
trail.ShouldHaveSingleItem().UpdateType.ShouldBe(HistoryModificationType.Insert); // only the first, applied insert
}
[Fact]
public async Task Live_recorder_writes_leave_no_trail()
{
var w = B.CreateValueWriter();
(await w.WriteLiveValuesAsync("live", [new(T(1), HistorianValue.FromDouble(1), Good), new(T(2), HistorianValue.FromDouble(2), Good)], Ct))
.ShouldBeTrue();
(await B.ReadBackAsync("live")).Count.ShouldBe(2); // the writes landed …
(await TrailAsync("live")).ShouldBeEmpty(); // … and recorded no modification
}
[Fact]
public async Task An_unknown_tag_has_an_empty_trail_and_reads_create_no_tag()
{
(await TrailAsync("never")).ShouldBeEmpty();
(await B.TagExistsAsync("never")).ShouldBeFalse();
}
[Fact]
public async Task The_trail_pages_by_key_exactly_once_and_reports_HasMore()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1), V(2, 2), V(3, 3)], Editor, Ct);
await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(2, 20)], Editor, Ct);
await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(2, 200)], Editor, Ct);
var all = await TrailAsync("t");
all.Count.ShouldBe(5); // T1 ins | T2 ins, rep, rep | T3 ins
var seen = new List<long>();
HistoryModifiedCursor? cursor = null;
using var src = B.CreateDataSource();
for (var page = 0; page < 10; page++)
{
var r = await src.ReadModifiedAsync("t", Lo, Hi, 2, cursor, Ct);
seen.AddRange(r.Values.Select(m => m.ModifiedId));
if (!r.HasMore) break;
r.Values.Count.ShouldBe(2);
var last = r.Values[^1];
cursor = new HistoryModifiedCursor(last.Value.SourceTimestampUtc!.Value, last.ModifiedId);
}
seen.ShouldBe(all.Select(m => m.ModifiedId)); // a page boundary inside the T2 run neither repeats nor skips
}
[Fact]
public async Task A_reverse_window_reads_the_trail_descending()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1), V(2, 2)], Editor, Ct);
await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(2, 20)], Editor, Ct);
var forward = await TrailAsync("t");
var reverse = await TrailAsync("t", start: Hi, end: Lo);
reverse.Select(m => m.ModifiedId).ShouldBe(forward.Select(m => m.ModifiedId).Reverse());
}
[Fact]
public async Task The_window_is_half_open_on_source_time()
{
var u = B.CreateUpdater();
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(1, 1), V(2, 2), V(3, 3)], Editor, Ct);
(await TrailAsync("t", start: T(1), end: T(3))).Select(m => m.Value.SourceTimestampUtc).ShouldBe([T(1), T(2)]);
(await TrailAsync("t", start: T(2), end: T(2))).Select(m => m.Value.SourceTimestampUtc).ShouldBe([T(2)]);
}
}