cfd403710e
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.
113 lines
5.6 KiB
C#
113 lines
5.6 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, 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, 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, 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));
|
|
}
|
|
}
|