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>
This commit is contained in:
+80
@@ -0,0 +1,80 @@
|
||||
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.QuestDb.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #674.1 — pins the QuestDB TTL behaviour the historian's retention relies on: a table TTL is judged against
|
||||
/// the wall clock (<c>min(max timestamp, now)</c>), so one far-future row — a 2066 typo that reached the backend
|
||||
/// without going through the node manager's <c>HistoryUpdateMaxFutureSkew</c> bound — cannot age out every older
|
||||
/// partition, including the Part 11 modification trail (the record of who changed history).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Positive control.</b> A partition genuinely older than the TTL (40 days, against a 30-day TTL) is kept until
|
||||
/// the far-future commit and evicted BY it — proving TTL housekeeping ran on exactly the commit that could have
|
||||
/// dropped the in-TTL partitions. Without it the "nothing evicted" assertion would also pass against a table
|
||||
/// whose TTL never ran.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ Observed on QuestDB 10.0.1 (2026-10-11): the in-TTL partitions also survived with
|
||||
/// <c>cairo.ttl.use.wall.clock=false</c> (verified effective via <c>SHOW PARAMETERS</c>), so this test does not
|
||||
/// isolate that flag — it pins the outcome. Keep the flag at its default (<c>true</c>) regardless; the server-side
|
||||
/// future-skew bound is the primary guard.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(QuestDbCollection.Name)]
|
||||
public sealed class TtlWallClockTests(QuestDbFixture fx)
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
private static readonly HistoryUpdateContext Who = new("ttl-test", null);
|
||||
|
||||
[Fact]
|
||||
public async Task A_far_future_row_evicts_only_partitions_older_than_the_ttl_by_the_wall_clock()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var o = fx.Options(fx.UniquePrefix(), retentionDays: 30);
|
||||
var c = new QuestDbConnection(o);
|
||||
var wal = new QuestDbWalVisibility(c, o.VisibilityTimeout);
|
||||
var tags = new QuestDbTagCatalog(c, new QuestDbSchemaBootstrapper(c, o), wal);
|
||||
var updater = new QuestDbHistorianDataUpdater(c, tags, wal);
|
||||
|
||||
// Whole seconds (µs-aligned), one row per DAY partition. With the newest row 10 days old, the 40-day partition is
|
||||
// still inside the TTL measured from the table's max timestamp, so this commit evicts nothing.
|
||||
var now = DateTime.UtcNow;
|
||||
var today = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, DateTimeKind.Utc);
|
||||
DateTime[] older = [today.AddDays(-40), today.AddDays(-20), today.AddDays(-10)];
|
||||
(await updater.UpdateDataAsync("ttl.tag", HistoryUpdateMode.Insert, older.Select((t, i) => V(t, i)).ToList(), Who, Ct))
|
||||
.ShouldAllBe(code => code == HistorianStatusCodes.GoodEntryInserted);
|
||||
(await DaysAsync(c, c.SampleTable)).ShouldBe(older.Select(t => t.Date));
|
||||
(await DaysAsync(c, c.SampleModifiedTable)).ShouldBe(older.Select(t => t.Date));
|
||||
|
||||
// The far-future row, then one more ordinary commit (TTL housekeeping runs on commits).
|
||||
(await updater.UpdateDataAsync("ttl.tag", HistoryUpdateMode.Insert,
|
||||
[V(new DateTime(2066, 1, 1, 0, 0, 0, DateTimeKind.Utc), 9)], Who, Ct)).ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
|
||||
(await updater.UpdateDataAsync("ttl.tag", HistoryUpdateMode.Insert, [V(today, 10)], Who, Ct))
|
||||
.ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
|
||||
(await wal.WaitAsync(c.SampleTable, Ct)).ShouldBeTrue();
|
||||
(await wal.WaitAsync(c.SampleModifiedTable, Ct)).ShouldBeTrue();
|
||||
|
||||
// Only the genuinely expired 40-day partition is gone; the 20- and 10-day partitions survive in both tables.
|
||||
DateTime[] expected = [today.AddDays(-20).Date, today.AddDays(-10).Date, today.Date, new(2066, 1, 1)];
|
||||
(await DaysAsync(c, c.SampleTable)).ShouldBe(expected);
|
||||
(await DaysAsync(c, c.SampleModifiedTable)).ShouldBe(expected);
|
||||
}
|
||||
|
||||
private static HistorizationValue V(DateTime t, double v) => new(t, HistorianValue.FromDouble(v), HistorianStatusCodes.Good);
|
||||
|
||||
/// <summary>The distinct UTC days holding rows, ascending.</summary>
|
||||
private static async Task<List<DateTime>> DaysAsync(QuestDbConnection c, string table)
|
||||
{
|
||||
await using var conn = await c.OpenAsync(Ct);
|
||||
await using var cmd = c.CreateCommand(conn, $"SELECT ts FROM {table} ORDER BY ts");
|
||||
await using var r = await cmd.ExecuteReaderAsync(Ct);
|
||||
var days = new SortedSet<DateTime>();
|
||||
while (await r.ReadAsync(Ct)) days.Add(QuestDbConnection.ReadUtc(r, 0).Date);
|
||||
return days.ToList();
|
||||
}
|
||||
}
|
||||
+5
@@ -14,6 +14,11 @@ public sealed class StatusCodeOracleTests
|
||||
((uint)Opc.Ua.AggregateBits.Calculated).ShouldBe(HistorianInfoBits.HistorianCalculated);
|
||||
}
|
||||
|
||||
/// <summary>#674: the v1-schema refusal code is the SDK's BadHistoryOperationUnsupported.</summary>
|
||||
[Fact]
|
||||
public void History_operation_unsupported_matches_the_SDK() =>
|
||||
HistorianStatusCodes.BadHistoryOperationUnsupported.ShouldBe(Opc.Ua.StatusCodes.BadHistoryOperationUnsupported);
|
||||
|
||||
[Fact]
|
||||
public void Interpolated_bits_match_the_SDK()
|
||||
{
|
||||
|
||||
+3
-3
@@ -54,7 +54,7 @@ public sealed class ModifiedHistoryAtomicityTests(SqlHistorianFixture fx)
|
||||
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));
|
||||
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);
|
||||
@@ -78,7 +78,7 @@ public sealed class ModifiedHistoryAtomicityTests(SqlHistorianFixture fx)
|
||||
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));
|
||||
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);
|
||||
@@ -99,7 +99,7 @@ public sealed class ModifiedHistoryAtomicityTests(SqlHistorianFixture fx)
|
||||
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 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))
|
||||
|
||||
+14
-9
@@ -32,7 +32,7 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
await Boot(Opts(s)).EnsureAsync(CancellationToken.None);
|
||||
await Boot(Opts(s)).EnsureAsync(CancellationToken.None); // fresh instance => re-runs the guarded path
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.RequiredVersion);
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.ModifiedTrailVersion);
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM sys.indexes WHERE name='CX_Event_Time' AND object_id=OBJECT_ID('[{s}].Event')")).ShouldBe(1);
|
||||
}
|
||||
|
||||
@@ -51,13 +51,14 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
|
||||
cmd.CommandText = b;
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.RequiredVersion);
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.ModifiedTrailVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #668 upgrade path: a schema left at version 1 (only <c>001_initial.sql</c> applied, as every pre-#668 install is)
|
||||
/// is upgraded in place by an auto-creating bootstrap — <c>SampleModified</c> appears, both version rows exist, and
|
||||
/// existing samples are untouched. With <c>AutoCreateSchema=false</c> the same v1 schema is refused.
|
||||
/// existing samples are untouched. With <c>AutoCreateSchema=false</c> the same v1 schema is accepted with the
|
||||
/// modification trail switched off and left at v1 (#674).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_version_1_schema_is_upgraded_to_version_2_in_place()
|
||||
@@ -81,11 +82,15 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
|
||||
}
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM sys.tables WHERE name = 'SampleModified' AND schema_id = SCHEMA_ID('{s}')")).ShouldBe(0);
|
||||
|
||||
var refused = await Should.ThrowAsync<HistorianSchemaMissingException>(
|
||||
() => Boot(Opts(s, auto: false)).EnsureAsync(CancellationToken.None));
|
||||
refused.Message.ShouldContain("missing version 2 (found 1)");
|
||||
// #674: verify-only accepts v1 — with the modification trail switched off — and does not upgrade it.
|
||||
var verifyOnly = Boot(Opts(s, auto: false));
|
||||
await verifyOnly.EnsureAsync(CancellationToken.None);
|
||||
verifyOnly.SupportsModifiedTrail.ShouldBeFalse();
|
||||
(await ScalarAsync($"SELECT MAX(Version) FROM [{s}].SchemaVersion")).ShouldBe(1);
|
||||
|
||||
await Boot(Opts(s)).EnsureAsync(CancellationToken.None);
|
||||
var upgrading = Boot(Opts(s));
|
||||
await upgrading.EnsureAsync(CancellationToken.None);
|
||||
upgrading.SupportsModifiedTrail.ShouldBeTrue();
|
||||
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(2);
|
||||
(await ScalarAsync($"SELECT MAX(Version) FROM [{s}].SchemaVersion")).ShouldBe(2);
|
||||
@@ -101,7 +106,7 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
var tasks = Enumerable.Range(0, 4).Select(_ => Task.Run(() => Boot(Opts(s)).EnsureAsync(CancellationToken.None))).ToArray();
|
||||
await Task.WhenAll(tasks);
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.RequiredVersion);
|
||||
(await ScalarAsync($"SELECT COUNT(*) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.ModifiedTrailVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -110,7 +115,7 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
|
||||
fx.SkipIfUnavailable();
|
||||
var ex = await Should.ThrowAsync<HistorianSchemaMissingException>(
|
||||
() => Boot(Opts(SqlHistorianFixture.UniqueSchema(), auto: false)).EnsureAsync(CancellationToken.None));
|
||||
ex.Message.ShouldContain("missing version 2");
|
||||
ex.Message.ShouldContain("missing version 1 (found 0)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #674: a database left at schema version 1 (no Part 11 <c>SampleModified</c> trail) with
|
||||
/// <c>AutoCreateSchema=false</c> keeps historizing. Before #674 the bootstrapper required v2 and refused EVERY call —
|
||||
/// live writes, raw reads, alarm events, retention — until script 002 was applied by hand. Now only the features
|
||||
/// that need the trail are refused (<c>BadHistoryOperationUnsupported</c>, nothing sent to SQL), and the
|
||||
/// bootstrapper logs ONE warning for the whole provider. Built through <see cref="SqlServerHistorianProvider"/>, the
|
||||
/// production wiring, so every seam shares the one bootstrapper the way a node does.
|
||||
/// </summary>
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class SchemaV1PostureTests(SqlHistorianFixture fx)
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
private static readonly DateTime Recent = DateTime.UtcNow.AddHours(-1);
|
||||
private static readonly HistoryUpdateContext Who = new("editor", "k1");
|
||||
|
||||
[Fact]
|
||||
public async Task A_v1_schema_keeps_historizing_and_refuses_only_the_modified_trail_features()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
await ApplyV1Async(s);
|
||||
var old = DateTime.UtcNow.AddDays(-40);
|
||||
var loggers = new CapturingLoggerFactory();
|
||||
var provider = new SqlServerHistorianProvider(new SqlServerHistorianOptions
|
||||
{
|
||||
ConnectionString = fx.ConnectionString, Schema = s, AutoCreateSchema = false, RetentionDays = 30,
|
||||
});
|
||||
var sp = new LoggerServices(loggers);
|
||||
|
||||
// Live writes (the continuous-historization drain) and raw reads work.
|
||||
var writer = provider.CreateValueWriter(sp);
|
||||
(await writer.WriteLiveValuesAsync("Plant.Speed",
|
||||
[new HistorizationValue(old, HistorianValue.FromDouble(1), 0), new HistorizationValue(Recent, HistorianValue.FromDouble(2), 0)],
|
||||
Ct)).ShouldBeTrue();
|
||||
var source = provider.CreateDataSource(sp);
|
||||
var raw = await source.ReadRawAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), 10, Ct);
|
||||
raw.Samples.Select(v => v.Value).ShouldBe([2d]);
|
||||
|
||||
// Alarm events are written.
|
||||
var alarms = provider.CreateAlarmWriter(sp);
|
||||
(await alarms.WriteBatchAsync(
|
||||
[new AlarmHistorianEvent("alarm-1", "Plant/Line/Eq", "HiTemp", "LimitAlarm", AlarmSeverity.High, "Activated", "m", "u", null, Recent)],
|
||||
Ct)).ShouldBe([HistorianWriteOutcome.Ack]);
|
||||
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Event")).ShouldBe(1);
|
||||
|
||||
// Modified reads: the interface's "no trail" signal (the node manager reports BadHistoryOperationUnsupported),
|
||||
// not a backend failure — health stays clean.
|
||||
await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
source.ReadModifiedAsync("Plant.Speed", Recent.AddDays(-1), Recent.AddDays(1), 10, null, Ct));
|
||||
source.GetHealthSnapshot().LastError.ShouldBeNull();
|
||||
|
||||
// HistoryUpdate: every item refused, and NOTHING reaches SQL — a sent MERGE/DELETE would either change the
|
||||
// samples or fail on the missing SampleModified target; an Insert on an unknown tag would create its Tag row.
|
||||
var updater = provider.CreateUpdater(sp)!;
|
||||
var tagsBefore = await CountAsync($"SELECT COUNT(*) FROM [{s}].Tag");
|
||||
(await updater.UpdateDataAsync("Plant.Speed", HistoryUpdateMode.Update,
|
||||
[new HistorizationValue(Recent, HistorianValue.FromDouble(99), 0), new HistorizationValue(Recent.AddSeconds(1), HistorianValue.FromDouble(98), 0)],
|
||||
Who, Ct)).ShouldBe([HistorianStatusCodes.BadHistoryOperationUnsupported, HistorianStatusCodes.BadHistoryOperationUnsupported]);
|
||||
(await updater.UpdateDataAsync("Plant.Brand.New", HistoryUpdateMode.Insert,
|
||||
[new HistorizationValue(Recent, HistorianValue.FromDouble(1), 0)], Who, Ct))
|
||||
.ShouldBe([HistorianStatusCodes.BadHistoryOperationUnsupported]);
|
||||
(await updater.DeleteRawAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), Who, Ct))
|
||||
.ShouldBe(HistorianStatusCodes.BadHistoryOperationUnsupported);
|
||||
(await updater.DeleteAtTimeAsync("Plant.Speed", [Recent], Who, Ct))
|
||||
.ShouldBe([HistorianStatusCodes.BadHistoryOperationUnsupported]);
|
||||
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Tag")).ShouldBe(tagsBefore);
|
||||
(await source.ReadRawAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), 10, Ct))
|
||||
.Samples.Select(v => v.Value).ShouldBe([2d]);
|
||||
|
||||
// Retention purges samples and skips the trail it does not have.
|
||||
var purge = await provider.CreateRetention(sp)!.PurgeAsync(Ct);
|
||||
purge.Select(r => r.Table).ShouldBe(["Sample", "Event"]);
|
||||
purge.Single(r => r.Table == "Sample").Rows.ShouldBe(1);
|
||||
(await CountAsync($"SELECT COUNT(*) FROM [{s}].Sample")).ShouldBe(1);
|
||||
|
||||
// One warning for the whole provider (= per boot), however many seams and calls touched the bootstrapper.
|
||||
loggers.Warnings.Count(w => w.Contains("Part 11 modification trail is unavailable")).ShouldBe(1);
|
||||
(await CountAsync($"SELECT MAX(Version) FROM [{s}].SchemaVersion")).ShouldBe(1); // verify-only: never upgraded
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #674: <c>AutoCreateSchema=true</c> still upgrades a v1 schema to v2 — the modification trail appears and a
|
||||
/// HistoryUpdate is recorded in it — and logs no v1 warning.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Auto_create_still_upgrades_a_v1_schema_and_enables_the_trail()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
await ApplyV1Async(s);
|
||||
var loggers = new CapturingLoggerFactory();
|
||||
var provider = new SqlServerHistorianProvider(new SqlServerHistorianOptions
|
||||
{
|
||||
ConnectionString = fx.ConnectionString, Schema = s, AutoCreateSchema = true,
|
||||
});
|
||||
var sp = new LoggerServices(loggers);
|
||||
|
||||
(await provider.CreateUpdater(sp)!.UpdateDataAsync("Plant.Speed", HistoryUpdateMode.Insert,
|
||||
[new HistorizationValue(Recent, HistorianValue.FromDouble(5), 0)], Who, Ct))
|
||||
.ShouldBe([HistorianStatusCodes.GoodEntryInserted]);
|
||||
|
||||
(await CountAsync($"SELECT MAX(Version) FROM [{s}].SchemaVersion")).ShouldBe(SqlServerSchemaBootstrapper.ModifiedTrailVersion);
|
||||
var trail = await provider.CreateDataSource(sp)
|
||||
.ReadModifiedAsync("Plant.Speed", Recent.AddMinutes(-1), Recent.AddMinutes(1), 10, null, Ct);
|
||||
trail.Values.ShouldHaveSingleItem().UpdateType.ShouldBe(HistoryModificationType.Insert);
|
||||
loggers.Warnings.ShouldNotContain(w => w.Contains("Part 11 modification trail is unavailable"));
|
||||
}
|
||||
|
||||
private async Task ApplyV1Async(string schema)
|
||||
{
|
||||
var v1 = new StreamReader(typeof(SqlServerSchemaBootstrapper).Assembly.GetManifestResourceStream("Schema.001_initial.sql")!).ReadToEnd();
|
||||
await using var c = new SqlConnection(fx.ConnectionString);
|
||||
await c.OpenAsync(Ct);
|
||||
foreach (var b in SqlServerSchemaBootstrapper.SplitBatches(v1.Replace("$(Schema)", schema)))
|
||||
{
|
||||
await using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = b;
|
||||
await cmd.ExecuteNonQueryAsync(Ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<long> CountAsync(string sql)
|
||||
{
|
||||
await using var c = new SqlConnection(fx.ConnectionString);
|
||||
await c.OpenAsync(Ct);
|
||||
await using var cmd = c.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
return Convert.ToInt64(await cmd.ExecuteScalarAsync(Ct));
|
||||
}
|
||||
|
||||
/// <summary>The only service the provider resolves is <see cref="ILoggerFactory"/>.</summary>
|
||||
private sealed class LoggerServices(ILoggerFactory loggers) : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => serviceType == typeof(ILoggerFactory) ? loggers : null;
|
||||
}
|
||||
|
||||
/// <summary>Captures every Warning-or-above message from every logger it creates.</summary>
|
||||
private sealed class CapturingLoggerFactory : ILoggerFactory
|
||||
{
|
||||
private readonly List<string> _warnings = [];
|
||||
|
||||
public IReadOnlyList<string> Warnings
|
||||
{
|
||||
get { lock (_warnings) return _warnings.ToList(); }
|
||||
}
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new Capturing(this);
|
||||
|
||||
public void AddProvider(ILoggerProvider provider)
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class Capturing(CapturingLoggerFactory owner) : ILogger
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (logLevel < LogLevel.Warning) return;
|
||||
lock (owner._warnings) owner._warnings.Add(formatter(state, exception));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -34,7 +34,7 @@ internal sealed class SqlServerConformanceBackend : IConformanceBackend
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IHistorianDataUpdater CreateUpdater() =>
|
||||
new SqlServerHistorianDataUpdater(_db.Conn, new SqlServerTagCatalog(_db.Conn, _db.Boot));
|
||||
new SqlServerHistorianDataUpdater(_db.Conn, _db.Boot, new SqlServerTagCatalog(_db.Conn, _db.Boot));
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples) =>
|
||||
|
||||
+3
-2
@@ -32,7 +32,7 @@ public sealed class SchemaScriptTests
|
||||
|
||||
/// <summary>
|
||||
/// #668: schema v2 (the Part 11 modification trail) is embedded, matches its docs copy, records version 2 (which
|
||||
/// must equal <see cref="SqlServerSchemaBootstrapper.RequiredVersion"/>), and carries none of the constructs an
|
||||
/// must equal <see cref="SqlServerSchemaBootstrapper.ModifiedTrailVersion"/>), and carries none of the constructs an
|
||||
/// <c>OUTPUT … INTO</c> target forbids (FOREIGN KEY / CHECK / trigger) — any of them would fail every HistoryUpdate.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
@@ -45,7 +45,8 @@ public sealed class SchemaScriptTests
|
||||
text.ShouldContain("[SampleModified]");
|
||||
text.ShouldContain("PRIMARY KEY CLUSTERED (TagId, SourceTimeUtc, ModifiedId)");
|
||||
text.ShouldContain("VALUES (2, SYSUTCDATETIME())");
|
||||
SqlServerSchemaBootstrapper.RequiredVersion.ShouldBe(2);
|
||||
SqlServerSchemaBootstrapper.ModifiedTrailVersion.ShouldBe(2);
|
||||
SqlServerSchemaBootstrapper.RequiredVersion.ShouldBe(1); // #674: v1 is still served, without the trail
|
||||
var ddl = string.Join('\n', text.Split('\n').Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal)));
|
||||
ddl.ShouldNotContain("FOREIGN KEY", Case.Insensitive);
|
||||
ddl.ShouldNotContain("CHECK", Case.Insensitive);
|
||||
|
||||
@@ -558,6 +558,190 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ───────────────────────────── #674 future-skew bound ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// #674: an item whose SourceTimestamp lies beyond <c>now + HistoryUpdateMaxFutureSkew</c> is refused
|
||||
/// <c>BadOutOfRange</c> by the node manager and never reaches the backend; an item exactly AT the bound and one in
|
||||
/// the past are sent. Judged against the injected clock, not the wall clock.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_source_time_beyond_the_future_skew_is_bad_out_of_range_and_never_sent()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var updater = new FakeUpdater();
|
||||
nm.HistorianDataUpdater = updater;
|
||||
nm.Clock = new FakeClock(Start);
|
||||
nm.HistoryUpdateMaxFutureSkew = TimeSpan.FromHours(1);
|
||||
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
||||
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
||||
var atBound = Start.AddHours(1);
|
||||
|
||||
var (results, errors) = Update(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId,
|
||||
PerformUpdateType.Update, Value(T1, 1d), Value(atBound.AddTicks(1), 2d), Value(atBound, 3d),
|
||||
Value(new DateTime(2062, 1, 1, 0, 0, 0, DateTimeKind.Utc), 4d));
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[0].OperationResults.Select(c => c.Code).ShouldBe(
|
||||
[
|
||||
StatusCodes.GoodEntryInserted, StatusCodes.BadOutOfRange, StatusCodes.GoodEntryInserted,
|
||||
StatusCodes.BadOutOfRange,
|
||||
]);
|
||||
updater.UpdateCalls.ShouldHaveSingleItem().Values.Select(v => v.TimestampUtc).ShouldBe([T1, atBound]);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>#674: every mode is bounded, and a request whose items are ALL out of range sends nothing.</summary>
|
||||
[Theory]
|
||||
[InlineData(PerformUpdateType.Insert)]
|
||||
[InlineData(PerformUpdateType.Replace)]
|
||||
[InlineData(PerformUpdateType.Update)]
|
||||
public async Task Every_update_mode_is_bounded_and_an_all_refused_request_sends_nothing(PerformUpdateType type)
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var updater = new FakeUpdater();
|
||||
nm.HistorianDataUpdater = updater;
|
||||
nm.Clock = new FakeClock(Start);
|
||||
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
||||
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
||||
|
||||
// Default skew: one day.
|
||||
var (results, _) = Update(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, type,
|
||||
Value(Start.AddDays(1).AddSeconds(1), 1d));
|
||||
|
||||
results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.BadOutOfRange]);
|
||||
updater.UpdateCalls.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>#674: a non-positive skew disables the bound — a far-future item reaches the backend.</summary>
|
||||
[Fact]
|
||||
public async Task A_non_positive_skew_disables_the_bound()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var updater = new FakeUpdater();
|
||||
nm.HistorianDataUpdater = updater;
|
||||
nm.Clock = new FakeClock(Start);
|
||||
nm.HistoryUpdateMaxFutureSkew = TimeSpan.Zero;
|
||||
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
||||
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
||||
var farFuture = new DateTime(2062, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
var (results, _) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(farFuture, 1d));
|
||||
|
||||
results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]);
|
||||
updater.UpdateCalls.ShouldHaveSingleItem().Values.ShouldHaveSingleItem().TimestampUtc.ShouldBe(farFuture);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ───────────────────────────── #674 Primary gate ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// #674: off-Primary, an authorized HistoryUpdate is refused <c>BadInvalidState</c> per node — on every arm
|
||||
/// (update data, delete raw, delete at time) — and NOTHING reaches the updater.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Off_primary_every_arm_is_bad_invalid_state_and_nothing_is_sent()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var updater = new FakeUpdater();
|
||||
nm.HistorianDataUpdater = updater;
|
||||
var consulted = 0;
|
||||
nm.HistoryUpdatePrimaryGate = () => { Interlocked.Increment(ref consulted); return false; };
|
||||
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
||||
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
||||
var identity = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate);
|
||||
|
||||
var (results, errors) = Insert(nm, identity, nodeId, Value(T1, 1d));
|
||||
var (rawResults, rawErrors) = Call(nm, identity, typeof(DeleteRawModifiedDetails),
|
||||
new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2 });
|
||||
var (atResults, atErrors) = Call(nm, identity, typeof(DeleteAtTimeDetails),
|
||||
new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new DateTimeCollection { T1 } });
|
||||
|
||||
foreach (var (r, e) in new[] { (results, errors), (rawResults, rawErrors), (atResults, atErrors) })
|
||||
{
|
||||
e[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidState);
|
||||
r[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidState);
|
||||
}
|
||||
|
||||
consulted.ShouldBe(3);
|
||||
updater.UpdateCalls.ShouldBeEmpty();
|
||||
updater.DeleteRawCalls.ShouldBeEmpty();
|
||||
updater.DeleteAtTimeCalls.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #674: on the Primary the gate allows and the update REACHES the updater — the allow leg that makes the deny
|
||||
/// leg meaningful (a gate that refused everything, or one never consulted, fails one of the two).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task On_primary_the_update_reaches_the_updater()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var updater = new FakeUpdater();
|
||||
nm.HistorianDataUpdater = updater;
|
||||
var consulted = 0;
|
||||
nm.HistoryUpdatePrimaryGate = () => { Interlocked.Increment(ref consulted); return true; };
|
||||
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
||||
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
||||
|
||||
var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]);
|
||||
updater.UpdateCalls.ShouldHaveSingleItem().Tag.ShouldBe(Tagname);
|
||||
consulted.ShouldBe(1);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #674: the Primary gate runs AFTER authorization — an unauthorized caller is told <c>BadUserAccessDenied</c>
|
||||
/// (not the node's role) and the gate is never consulted.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task The_primary_gate_runs_after_authorization()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var updater = new FakeUpdater();
|
||||
nm.HistorianDataUpdater = updater;
|
||||
var consulted = 0;
|
||||
nm.HistoryUpdatePrimaryGate = () => { Interlocked.Increment(ref consulted); return false; };
|
||||
WireAcl(nm, enabled: false, NodePermissions.WriteOperate);
|
||||
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
||||
|
||||
var (results, _) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.WriteOperate), nodeId, Value(T1, 1d));
|
||||
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
consulted.ShouldBe(0);
|
||||
updater.UpdateCalls.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ───────────────────────────── helpers ─────────────────────────────
|
||||
|
||||
private static DataValue Value(DateTime timestamp, object value) =>
|
||||
|
||||
+22
@@ -203,6 +203,28 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #674: the HistoryUpdate Primary gate does not reach HistoryRead — a closed gate neither refuses a read nor is
|
||||
/// consulted by one (reads are served from any redundancy node), for modified and raw reads alike.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task The_history_update_primary_gate_is_not_consulted_for_reads()
|
||||
{
|
||||
var (host, nm, node, src) = await SetupAsync([Mod(1, 1, 1, HistoryModificationType.Update, "u", Epoch)]);
|
||||
var consulted = 0;
|
||||
nm.HistoryUpdatePrimaryGate = () => { Interlocked.Increment(ref consulted); return false; };
|
||||
|
||||
var modified = Read(nm, node, Epoch, Epoch.AddHours(1), max: 10, cp: null);
|
||||
var raw = Read(nm, node, Epoch, Epoch.AddHours(1), max: 10, cp: null, modified: false);
|
||||
|
||||
modified.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
modified.Data!.DataValues.Count.ShouldBe(1);
|
||||
raw.Error.StatusCode.Code.ShouldNotBe(StatusCodes.BadInvalidState);
|
||||
src.Calls.ShouldBe(1);
|
||||
consulted.ShouldBe(0);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_backend_failure_is_isolated_to_its_node()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
|
||||
@@ -99,6 +100,29 @@ public sealed class ServerHistorianOptionsTests
|
||||
Assert.Contains(w, m => m.Contains("MaxTimeWeightedRawRows"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #674: the HistoryUpdate future-skew bound defaults to one day and binds from configuration as a TimeSpan;
|
||||
/// a non-positive value is a deliberate "disabled", not a misconfiguration, so it does not warn.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HistoryUpdateMaxFutureSkew_defaults_to_one_day_binds_and_zero_is_silent()
|
||||
{
|
||||
Assert.Equal(TimeSpan.FromDays(1), new ServerHistorianOptions().HistoryUpdateMaxFutureSkew);
|
||||
var bound = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ServerHistorian:HistoryUpdateMaxFutureSkew"] = "00:05:00",
|
||||
})
|
||||
.Build()
|
||||
.GetSection(ServerHistorianOptions.SectionName)
|
||||
.Get<ServerHistorianOptions>();
|
||||
Assert.Equal(TimeSpan.FromMinutes(5), bound!.HistoryUpdateMaxFutureSkew);
|
||||
var w = new ServerHistorianOptions
|
||||
{ Enabled = true, Endpoint = "https://h:5222", ApiKey = "histgw_x_y", HistoryUpdateMaxFutureSkew = TimeSpan.Zero }
|
||||
.Validate();
|
||||
Assert.Empty(w);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disabled_with_nonpositive_HistoryRead_knobs_is_silent()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user