fix(historian): SQL quality contract, long-name refusal, info-bit masking, AtTime-after-now, bootstrap hardening
- I-1: an Empty sample is always Bad. The writer stores a non-Bad NaN/Inf as Empty+BadOutOfRange and any other non-Bad Empty as BadNoData; the updater refuses a non-Bad Empty with BadInvalidArgument; reads clamp a legacy non-Bad Empty row to BadNoData (Raw and AtTime). - Tag/source names over 400 chars are refused, never truncated: writer per-tag failure, updater BadInvalidArgument, reads not-found, events none. - HistorianInfoBits masks the historian bits before setting one. - AtTime past now, stepped from the last sample => UncertainDataSubNormal. - Bootstrapper: logger, 5 s negative cache, ClearPool + Warning when sp_releaseapplock fails. - Provider builds its shared state lazily (an invalid Schema no longer throws at Resolve); Kinds is a FrozenSet; Gateway reuses the recorder's DefaultSupportedKinds. - Cleanup: SqlHistorianDataTypes in its own file (updater dup removed), dead Invalidate and unused status consts deleted, named severity masks, alarm writer brace. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+13
@@ -116,6 +116,19 @@ public sealed class AtTimeReadTests(SqlHistorianFixture fx)
|
||||
s.StatusCode.ShouldBe(GoodInterpolated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_time_after_now_stepped_from_the_last_sample_is_uncertain()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync(); // last sample +40 = 50, Good
|
||||
var src = db.Source(new FixedClock(new DateTimeOffset(At(42))));
|
||||
var s = (await src.ReadAtTimeAsync("A", [At(41), At(45)], Ct)).Samples;
|
||||
s[0].Value.ShouldBe(50d);
|
||||
s[0].StatusCode.ShouldBe(GoodInterpolated); // after the last sample but not after now: held Good
|
||||
s[1].Value.ShouldBe(50d);
|
||||
s[1].StatusCode.ShouldBe(UncertainInterpolated); // after now: the value is not known to still hold
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_uncertain_neighbour_makes_the_interpolation_uncertain()
|
||||
{
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
|
||||
|
||||
/// <summary>A settable clock for the data source's "now" and the bootstrapper's failure cache.</summary>
|
||||
internal sealed class FixedClock(DateTimeOffset now) : TimeProvider
|
||||
{
|
||||
public DateTimeOffset Now { get; set; } = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => Now;
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Names longer than the 400-character <c>Tag.Name</c> / <c>Event.SourceName</c> columns are refused, never
|
||||
/// truncated. Each test seeds a series under the 400-character PREFIX of the long name: a sized
|
||||
/// <c>NVarChar(400)</c> parameter would silently cut the long name to that prefix and hit the wrong series.
|
||||
/// </summary>
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class LongNameTests(SqlHistorianFixture fx)
|
||||
{
|
||||
private static readonly CancellationToken Ct = CancellationToken.None;
|
||||
private static DateTime At(double s) => ReadTestDb.At(s);
|
||||
|
||||
private const uint BadInvalidArgument = 0x80AB0000u;
|
||||
private const uint BadNoData = 0x809B0000u;
|
||||
|
||||
private static readonly string Prefix = new('p', 400);
|
||||
private static readonly string Long = Prefix + "-x";
|
||||
|
||||
[Fact]
|
||||
public async Task The_writer_refuses_a_long_name_as_a_per_tag_failure_and_writes_nothing()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAsync(Prefix, (At(0), HistorianValue.FromDouble(1), ReadTestDb.Good));
|
||||
var w = new SqlServerHistorianValueWriter(db.Conn, db.Boot, new SqlServerTagCatalog(db.Conn, db.Boot));
|
||||
|
||||
(await w.WriteLiveValuesAsync(Long, [new(At(5), HistorianValue.FromDouble(2), 0u)], Ct)).ShouldBeFalse();
|
||||
|
||||
(await db.ScalarAsync($"SELECT COUNT(*) FROM {db.Conn.Q("Sample")}")).ShouldBe(1);
|
||||
(await db.ScalarAsync($"SELECT COUNT(*) FROM {db.Conn.Q("Tag")}")).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_updater_answers_BadInvalidArgument_for_every_item_of_a_long_name()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAsync(Prefix, (At(0), HistorianValue.FromDouble(1), ReadTestDb.Good));
|
||||
var u = new SqlServerHistorianDataUpdater(db.Conn, new SqlServerTagCatalog(db.Conn, db.Boot));
|
||||
|
||||
(await u.UpdateDataAsync(Long, HistoryUpdateMode.Update,
|
||||
[new(At(0), HistorianValue.FromDouble(9), 0u), new(At(1), HistorianValue.FromDouble(9), 0u)], Ct))
|
||||
.ShouldBe([BadInvalidArgument, BadInvalidArgument]);
|
||||
(await u.DeleteRawAsync(Long, At(-1), At(10), Ct)).ShouldBe(BadInvalidArgument);
|
||||
(await u.DeleteAtTimeAsync(Long, [At(0)], Ct)).ShouldBe([BadInvalidArgument]);
|
||||
|
||||
// The prefix series is untouched: not replaced, not deleted.
|
||||
var raw = (await db.Source().ReadRawAsync(Prefix, At(-1), At(10), 0, Ct)).Samples;
|
||||
raw.Select(s => s.Value).ShouldBe([(object)1d]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_of_a_long_name_are_not_found_rather_than_the_prefix_series()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAsync(Prefix, (At(0), HistorianValue.FromDouble(1), ReadTestDb.Good));
|
||||
var src = db.Source();
|
||||
|
||||
(await src.ReadRawAsync(Long, At(-1), At(10), 0, Ct)).Samples.ShouldBeEmpty();
|
||||
(await src.ReadAtTimeAsync(Long, [At(0)], Ct)).Samples.Single().StatusCode.ShouldBe(BadNoData);
|
||||
(await src.ReadProcessedAsync(Long, At(-1), At(10), TimeSpan.Zero, HistoryAggregateType.Count, Ct))
|
||||
.Samples.Single().StatusCode.ShouldBe(HistorianInfoBits.WithCalculated(BadNoData));
|
||||
src.GetHealthSnapshot().TotalFailures.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_event_read_filtered_by_a_long_source_matches_nothing()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await using (var c = await db.Conn.OpenAsync(Ct))
|
||||
await using (var cmd = db.Conn.CreateCommand(c,
|
||||
$"INSERT {db.Conn.Q("Event")} (EventId, SourceName, AlarmId, AlarmName, AlarmTypeName, Severity, EventKind, Message, " +
|
||||
"UserName, Comment, EventTimeUtc, ReceivedTimeUtc) VALUES ('e1', @src, 'a', 'n', 'T', 500, 'Activated', NULL, NULL, NULL, @t, @t)"))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@src", Prefix);
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@t", At(0));
|
||||
await cmd.ExecuteNonQueryAsync(Ct);
|
||||
}
|
||||
|
||||
var src = db.Source();
|
||||
(await src.ReadEventsAsync(Prefix, At(-1), At(10), 0, Ct)).Events.Count.ShouldBe(1); // control
|
||||
(await src.ReadEventsAsync(Long, At(-1), At(10), 0, Ct)).Events.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Final-review I-1: a stored sample is never served with a better quality than "no value". An Empty (null-value)
|
||||
/// sample is always Bad — forced Bad by the value writer, refused non-Bad by the updater, and clamped to
|
||||
/// <c>BadNoData</c> on read for rows written before either rule existed.
|
||||
/// </summary>
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class QualityContractTests(SqlHistorianFixture fx)
|
||||
{
|
||||
private static readonly CancellationToken Ct = CancellationToken.None;
|
||||
private static DateTime At(double s) => ReadTestDb.At(s);
|
||||
|
||||
private const uint BadOutOfRange = 0x803C0000u;
|
||||
private const uint BadNoData = 0x809B0000u;
|
||||
private const uint BadInvalidArgument = 0x80AB0000u;
|
||||
private const uint BadNoCommunication = 0x80310000u;
|
||||
|
||||
private static bool IsBad(uint status) => (status & 0x80000000u) != 0;
|
||||
|
||||
private static SqlServerHistorianValueWriter Writer(ReadTestDb db) =>
|
||||
new(db.Conn, db.Boot, new SqlServerTagCatalog(db.Conn, db.Boot));
|
||||
|
||||
private static SqlServerHistorianDataUpdater Updater(ReadTestDb db) =>
|
||||
new(db.Conn, new SqlServerTagCatalog(db.Conn, db.Boot));
|
||||
|
||||
[Fact]
|
||||
public async Task A_good_NaN_written_through_the_real_writer_reads_back_Bad_on_Raw_and_AtTime()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
(await Writer(db).WriteLiveValuesAsync("nan", [
|
||||
new(At(0), HistorianValue.FromDouble(10), 0u),
|
||||
new(At(10), HistorianValue.FromDouble(double.NaN), 0u),
|
||||
new(At(20), HistorianValue.FromDouble(double.PositiveInfinity), 0x40000000u),
|
||||
new(At(30), HistorianValue.FromDouble(double.NegativeInfinity), BadNoCommunication),
|
||||
], Ct)).ShouldBeTrue();
|
||||
|
||||
var raw = (await db.Source().ReadRawAsync("nan", At(0), At(100), 0, Ct)).Samples;
|
||||
raw.Count.ShouldBe(4);
|
||||
raw[1].Value.ShouldBeNull();
|
||||
raw[1].StatusCode.ShouldBe(BadOutOfRange); // Good NaN ⇒ stored Bad
|
||||
raw[2].StatusCode.ShouldBe(BadOutOfRange); // Uncertain +Inf ⇒ stored Bad
|
||||
raw[3].StatusCode.ShouldBe(BadNoCommunication); // an existing Bad code is kept
|
||||
|
||||
var at = (await db.Source().ReadAtTimeAsync("nan", [At(10), At(15)], Ct)).Samples;
|
||||
at.ShouldAllBe(s => s.Value == null);
|
||||
at.ShouldAllBe(s => IsBad(s.StatusCode));
|
||||
at[1].StatusCode.ShouldBe(BadOutOfRange); // inside the "outage" the NaN opened
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_non_bad_Empty_written_through_the_real_writer_is_stored_BadNoData()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
(await Writer(db).WriteLiveValuesAsync("empty", [
|
||||
new(At(0), HistorianValue.Empty, 0u),
|
||||
new(At(1), HistorianValue.Empty, 0x40000000u),
|
||||
new(At(2), HistorianValue.Empty, BadNoCommunication),
|
||||
], Ct)).ShouldBeTrue();
|
||||
|
||||
// Read the stored column directly so the read-side clamp cannot mask a writer that forgot to force Bad.
|
||||
(await db.ScalarAsync($"SELECT COUNT(*) FROM {db.Conn.Q("Sample")} WHERE StatusCode >= 0")).ShouldBe(0);
|
||||
var raw = (await db.Source().ReadRawAsync("empty", At(0), At(10), 0, Ct)).Samples;
|
||||
raw.Select(s => s.StatusCode).ShouldBe([BadNoData, BadNoData, BadNoCommunication]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_legacy_good_Empty_row_is_clamped_to_BadNoData_on_Raw_and_AtTime()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
// Seeded with plain INSERTs, bypassing the writer: what a pre-fix build could have stored.
|
||||
await db.SeedAsync("legacy",
|
||||
(At(0), HistorianValue.FromDouble(10), ReadTestDb.Good),
|
||||
(At(10), HistorianValue.Empty, ReadTestDb.Good),
|
||||
(At(20), HistorianValue.Empty, ReadTestDb.Uncertain),
|
||||
(At(30), HistorianValue.FromDouble(40), ReadTestDb.Good));
|
||||
|
||||
var raw = (await db.Source().ReadRawAsync("legacy", At(0), At(100), 0, Ct)).Samples;
|
||||
raw.Select(s => s.StatusCode).ShouldBe([ReadTestDb.Good, BadNoData, BadNoData, ReadTestDb.Good]);
|
||||
|
||||
var at = (await db.Source().ReadAtTimeAsync("legacy", [At(10), At(15), At(25)], Ct)).Samples;
|
||||
at.ShouldAllBe(s => s.Value == null && s.StatusCode == BadNoData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_updater_refuses_a_non_bad_Empty_and_accepts_a_bad_one()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
var r = await Updater(db).UpdateDataAsync("u", HistoryUpdateMode.Insert, [
|
||||
new(At(0), HistorianValue.Empty, 0u),
|
||||
new(At(1), HistorianValue.Empty, 0x40000000u),
|
||||
new(At(2), HistorianValue.Empty, BadNoCommunication),
|
||||
new(At(3), HistorianValue.FromDouble(1), 0u),
|
||||
], Ct);
|
||||
r.ShouldBe([BadInvalidArgument, BadInvalidArgument, SqlHistorianStatusCodes.GoodEntryInserted,
|
||||
SqlHistorianStatusCodes.GoodEntryInserted]);
|
||||
(await db.Source().ReadRawAsync("u", At(0), At(10), 0, Ct)).Samples
|
||||
.Select(s => s.StatusCode).ShouldBe([BadNoCommunication, 0u]);
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -42,8 +42,9 @@ internal sealed class ReadTestDb
|
||||
return new ReadTestDb(o, conn, boot);
|
||||
}
|
||||
|
||||
/// <summary>A fresh data source (fresh catalog cache) over this schema.</summary>
|
||||
public SqlServerHistorianDataSource Source() => new(Conn, Boot, new SqlServerTagCatalog(Conn, Boot));
|
||||
/// <summary>A fresh data source (fresh catalog cache) over this schema; <paramref name="clock"/> is its "now".</summary>
|
||||
public SqlServerHistorianDataSource Source(TimeProvider? clock = null) =>
|
||||
new(Conn, Boot, new SqlServerTagCatalog(Conn, Boot), timeProvider: clock);
|
||||
|
||||
/// <summary>Inserts a tag row and the given samples. ServerTimeUtc = source time + 1 s (so tests can tell them apart).</summary>
|
||||
public async Task<int> SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples)
|
||||
@@ -237,7 +238,8 @@ public sealed class RawReadTests(SqlHistorianFixture fx)
|
||||
ConnectionString = fx.ConnectionString, Schema = SqlHistorianFixture.UniqueSchema(), AutoCreateSchema = false,
|
||||
};
|
||||
var conn = new SqlHistorianConnection(o);
|
||||
var boot = new SqlServerSchemaBootstrapper(conn, o);
|
||||
var clock = new FixedClock(DateTimeOffset.UtcNow);
|
||||
var boot = new SqlServerSchemaBootstrapper(conn, o, timeProvider: clock);
|
||||
var src = new SqlServerHistorianDataSource(conn, boot, new SqlServerTagCatalog(conn, boot));
|
||||
|
||||
await Should.ThrowAsync<HistorianSchemaMissingException>(() => src.ReadRawAsync("A", At(0), At(1), 0, Ct));
|
||||
@@ -251,6 +253,7 @@ public sealed class RawReadTests(SqlHistorianFixture fx)
|
||||
// Another node creates the schema; the same data source then succeeds (bootstrap failure is not latched).
|
||||
var creator = new SqlServerHistorianOptions { ConnectionString = o.ConnectionString, Schema = o.Schema };
|
||||
await new SqlServerSchemaBootstrapper(new SqlHistorianConnection(creator), creator).EnsureAsync(Ct);
|
||||
clock.Now += SqlServerSchemaBootstrapper.FailureCacheDuration; // past the 5 s failure cache (M-2)
|
||||
(await src.ReadRawAsync("A", At(0), At(1), 0, Ct)).Samples.ShouldBeEmpty();
|
||||
src.GetHealthSnapshot().ConsecutiveFailures.ShouldBe(0);
|
||||
}
|
||||
|
||||
+29
-2
@@ -77,10 +77,37 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
var checker = Boot(Opts(s, auto: false));
|
||||
var clock = new FixedClock(DateTimeOffset.UtcNow);
|
||||
var o = Opts(s, auto: false);
|
||||
var checker = new SqlServerSchemaBootstrapper(new SqlHistorianConnection(o), o, timeProvider: clock);
|
||||
await Should.ThrowAsync<HistorianSchemaMissingException>(() => checker.EnsureAsync(CancellationToken.None));
|
||||
await Boot(Opts(s)).EnsureAsync(CancellationToken.None);
|
||||
await checker.EnsureAsync(CancellationToken.None); // same instance retries
|
||||
clock.Now += SqlServerSchemaBootstrapper.FailureCacheDuration;
|
||||
await checker.EnsureAsync(CancellationToken.None); // same instance retries once the failure cache expires
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M-2: within <see cref="SqlServerSchemaBootstrapper.FailureCacheDuration"/> of a failure the bootstrapper
|
||||
/// re-throws it without touching SQL — proven by creating the schema in between: a call that went to SQL
|
||||
/// would now pass.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_bootstrap_failure_is_cached_for_five_seconds_then_retried()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
var clock = new FixedClock(DateTimeOffset.UtcNow);
|
||||
var o = Opts(s, auto: false);
|
||||
var checker = new SqlServerSchemaBootstrapper(new SqlHistorianConnection(o), o, timeProvider: clock);
|
||||
await Should.ThrowAsync<HistorianSchemaMissingException>(() => checker.EnsureAsync(CancellationToken.None));
|
||||
|
||||
await Boot(Opts(s)).EnsureAsync(CancellationToken.None); // the schema now exists
|
||||
|
||||
clock.Now += SqlServerSchemaBootstrapper.FailureCacheDuration - TimeSpan.FromTicks(1);
|
||||
await Should.ThrowAsync<HistorianSchemaMissingException>(() => checker.EnsureAsync(CancellationToken.None));
|
||||
|
||||
clock.Now += TimeSpan.FromTicks(1);
|
||||
await checker.EnsureAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+3
-2
@@ -89,7 +89,7 @@ public sealed class WriteTests(SqlHistorianFixture fx)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_finite_doubles_store_as_Empty_with_their_status_and_do_not_fail_the_write()
|
||||
public async Task Non_finite_doubles_store_as_Empty_forced_Bad_and_do_not_fail_the_write()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, s) = Make();
|
||||
@@ -101,7 +101,8 @@ public sealed class WriteTests(SqlHistorianFixture fx)
|
||||
rows.Count.ShouldBe(3);
|
||||
rows[0].Kind.ShouldBe((byte)HistorianValueKind.Empty);
|
||||
rows[0].N.ShouldBeNull();
|
||||
rows[0].Status.ShouldBe(unchecked((int)0x40000000u));
|
||||
rows[0].Status.ShouldBe(unchecked((int)SqlHistorianStatusCodes.BadOutOfRange)); // Uncertain NaN ⇒ Bad (I-1)
|
||||
rows[1].Status.ShouldBe(unchecked((int)SqlHistorianStatusCodes.BadOutOfRange)); // Good +Inf ⇒ Bad (I-1)
|
||||
rows[2].N.ShouldBe(7);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user