Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ModifiedHistoryAtomicityTests.cs
T
Joseph Doherty 0658d244d0 feat(historian): #674 HistoryUpdate posture — future-skew bound, Primary gate, SqlServer v1 accepted, QuestDB TTL pin
- HistoryUpdate items with SourceTimestamp > now + ServerHistorian:HistoryUpdateMaxFutureSkew
  (default 1 day, <=0 disables) => BadOutOfRange in the node manager (every backend, never sent);
  clock injected via OtOpcUaNodeManager.Clock (host TimeProvider).
- HistoryUpdate is Primary-only: off-Primary => BadInvalidState per node, after authorization,
  same IDriverPrimaryRoleSource verdict as the native-ack gate; HistoryRead ungated.
- SqlServer: base schema requirement back to v1; SupportsModifiedTrail (v>=2). On v1 with
  AutoCreateSchema=false: writes/reads/events/retention work, modified reads + HistoryUpdate =>
  BadHistoryOperationUnsupported with nothing sent, retention skips SampleModified, one Warning per boot.
- QuestDB integration test pinning wall-clock TTL (far-future row evicts only partitions older
  than the TTL by the wall clock), with a positive control.
- Docs: Historian.md, Configuration.md, security.md, CLAUDE.md (incl. QuestDB retry duplicates,
  #674.4 anonymous-readable trail with ACL off, #674.5 not implemented).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-11 11:18:44 -04:00

113 lines
5.7 KiB
C#

using Microsoft.Data.SqlClient;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
/// <summary>
/// #668, SQL Server only: the modification-trail row is written by the SAME statement that changes
/// <c>hist.Sample</c> (<c>OUTPUT … INTO</c>), so a change and its trail row commit or roll back together. The failure
/// is forced INSIDE the audit insert (a unique index on the trail that the second modification of one timestamp
/// violates), i.e. after the sample row was already changed within the statement — the strongest place to prove it.
/// Each test has a positive control: the same operation succeeds, with its trail row, once the index is dropped.
/// </summary>
[Collection(SqlHistorianCollection.Name)]
public sealed class ModifiedHistoryAtomicityTests(SqlHistorianFixture fx)
{
private static readonly DateTime T1 = new(2026, 1, 1, 0, 0, 1, DateTimeKind.Utc);
private static readonly DateTime T2 = T1.AddSeconds(1);
private static readonly HistoryUpdateContext Who = ConformanceData.Editor;
private static HistorizationValue V(DateTime t, double d) => new(t, HistorianValue.FromDouble(d), 0u);
private async Task ExecAsync(ReadTestDb db, string sql)
{
await using var c = await db.Conn.OpenAsync(CancellationToken.None);
await using var cmd = db.Conn.CreateCommand(c, sql);
await cmd.ExecuteNonQueryAsync();
}
private async Task<long> CountTrailAsync(ReadTestDb db)
{
await using var c = await db.Conn.OpenAsync(CancellationToken.None);
await using var cmd = db.Conn.CreateCommand(c, $"SELECT COUNT(*) FROM {db.Conn.Q("SampleModified")}");
return Convert.ToInt64(await cmd.ExecuteScalarAsync());
}
private Task BreakTheTrailAsync(ReadTestDb db) => ExecAsync(db,
$"CREATE UNIQUE INDEX UX_Break ON {db.Conn.Q("SampleModified")} (TagId, SourceTimeUtc)");
private Task RepairTheTrailAsync(ReadTestDb db) => ExecAsync(db,
$"DROP INDEX UX_Break ON {db.Conn.Q("SampleModified")}");
private static async Task<double> ValueAtAsync(ReadTestDb db, string tag, DateTime t)
{
using var src = db.Source();
var r = await src.ReadRawAsync(tag, t, t, 0, CancellationToken.None);
return (double)r.Samples.ShouldHaveSingleItem().Value!;
}
[Fact]
public async Task A_replace_whose_trail_insert_fails_changes_nothing()
{
var db = await ReadTestDb.CreateAsync(fx);
var u = new SqlServerHistorianDataUpdater(db.Conn, db.Boot, new SqlServerTagCatalog(db.Conn, db.Boot));
(await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T1, 1)], Who, CancellationToken.None))
.ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
(await CountTrailAsync(db)).ShouldBe(1);
await BreakTheTrailAsync(db);
await Should.ThrowAsync<SqlException>(
() => u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(T1, 99)], Who, CancellationToken.None));
(await ValueAtAsync(db, "t", T1)).ShouldBe(1d, "the sample change rolled back with its trail row");
(await CountTrailAsync(db)).ShouldBe(1, "a failed update leaves no trail row");
// Positive control: the identical replace succeeds, with its trail row, once the trail accepts it.
await RepairTheTrailAsync(db);
(await u.UpdateDataAsync("t", HistoryUpdateMode.Replace, [V(T1, 99)], Who, CancellationToken.None))
.ShouldBe([HistorianStatusCodes.GoodEntryReplaced]);
(await ValueAtAsync(db, "t", T1)).ShouldBe(99d);
(await CountTrailAsync(db)).ShouldBe(2);
}
[Fact]
public async Task Deletes_whose_trail_insert_fails_delete_nothing()
{
var db = await ReadTestDb.CreateAsync(fx);
var u = new SqlServerHistorianDataUpdater(db.Conn, db.Boot, new SqlServerTagCatalog(db.Conn, db.Boot));
await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T1, 1), V(T2, 2)], Who, CancellationToken.None);
await BreakTheTrailAsync(db);
await Should.ThrowAsync<SqlException>(() => u.DeleteAtTimeAsync("t", [T1], Who, CancellationToken.None));
await Should.ThrowAsync<SqlException>(() => u.DeleteRawAsync("t", T1, T2.AddSeconds(1), Who, CancellationToken.None));
(await ValueAtAsync(db, "t", T1)).ShouldBe(1d);
(await ValueAtAsync(db, "t", T2)).ShouldBe(2d);
(await CountTrailAsync(db)).ShouldBe(2, "only the two inserts");
await RepairTheTrailAsync(db);
(await u.DeleteAtTimeAsync("t", [T1], Who, CancellationToken.None)).ShouldBe([HistorianStatusCodes.Good]);
(await u.DeleteRawAsync("t", T1, T2.AddSeconds(1), Who, CancellationToken.None)).ShouldBe(HistorianStatusCodes.Good);
(await CountTrailAsync(db)).ShouldBe(4);
}
[Fact]
public async Task An_over_long_principal_is_truncated_never_refused()
{
var db = await ReadTestDb.CreateAsync(fx);
var u = new SqlServerHistorianDataUpdater(db.Conn, db.Boot, new SqlServerTagCatalog(db.Conn, db.Boot));
var who = new HistoryUpdateContext(new string('p', 500), new string('k', 500));
(await u.UpdateDataAsync("t", HistoryUpdateMode.Insert, [V(T1, 1)], who, CancellationToken.None))
.ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
using var src = db.Source();
var trail = await src.ReadModifiedAsync("t", T1.AddHours(-1), T1.AddHours(1), 0, null, CancellationToken.None);
trail.Values.ShouldHaveSingleItem().UserName.ShouldBe(new string('p', HistoryUpdateContext.MaxIdentityLength));
}
}