0658d244d0
- 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>
113 lines
5.7 KiB
C#
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));
|
|
}
|
|
}
|