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:
Joseph Doherty
2026-10-09 14:16:31 -04:00
parent fc57eefca6
commit 821763a09d
20 changed files with 611 additions and 126 deletions
@@ -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()
{
@@ -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;
}
@@ -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();
}
}
@@ -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]);
}
}
@@ -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);
}
@@ -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]
@@ -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);
}
@@ -32,6 +32,36 @@ public sealed class SqlServerHistorianProviderTests
p.CreateUpdater(sp).ShouldBeOfType<SqlServerHistorianDataUpdater>();
}
[Fact]
public void Supported_kinds_are_a_frozen_set() =>
Make().SupportedKinds.ShouldBeAssignableTo<System.Collections.Frozen.FrozenSet<HistorianValueKind>>();
/// <summary>
/// An invalid schema must not fail provider construction (the host resolves the provider even when no
/// consumer is enabled); it surfaces only when a seam is actually built.
/// </summary>
[Fact]
public void An_invalid_schema_fails_only_when_a_seam_is_built()
{
var p = new SqlServerHistorianProvider(new SqlServerHistorianOptions { ConnectionString = "x", Schema = "bad;schema" });
p.Name.ShouldBe("SqlServer");
Should.Throw<ArgumentException>(() => p.CreateDataSource(new NullSp()));
}
[Fact]
public void Every_seam_shares_one_connection_bootstrapper_and_catalog()
{
var sp = new NullSp();
var p = Make();
var a = (SqlServerHistorianDataSource)p.CreateDataSource(sp);
var b = (SqlServerHistorianDataSource)p.CreateDataSource(sp);
Field(a, "_bootstrap").ShouldBeSameAs(Field(b, "_bootstrap"));
Field(a, "_catalog").ShouldBeSameAs(Field(b, "_catalog"));
}
private static object? Field(object o, string name) =>
o.GetType().GetField(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(o);
private sealed class NullSp : IServiceProvider
{
public object? GetService(Type serviceType) => null;
@@ -22,6 +22,23 @@ public sealed class StatusCodeOracleTests
((uint)Opc.Ua.AggregateBits.Interpolated).ShouldBe(HistorianInfoBits.HistorianInterpolated);
}
/// <summary>
/// A stored status that already carries a historian bit (a sample written via HistoryUpdate with the Calculated
/// bit, say) must come back with ONE historian bit, never both: the existing pair is masked off first. The SDK
/// agrees — <c>SetAggregateBits</c> replaces, it does not OR.
/// </summary>
[Fact]
public void Setting_one_historian_bit_clears_the_other()
{
var calculatedUncertain = SqlHistorianStatusCodes.UncertainDataSubNormal | 0x400u | HistorianInfoBits.HistorianCalculated;
var interpolated = HistorianInfoBits.WithInterpolated(calculatedUncertain);
interpolated.ShouldBe(SqlHistorianStatusCodes.UncertainDataSubNormal | 0x400u | HistorianInfoBits.HistorianInterpolated);
new Opc.Ua.StatusCode(interpolated).AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Interpolated);
var interpolatedGood = 0x400u | HistorianInfoBits.HistorianInterpolated;
HistorianInfoBits.WithCalculated(interpolatedGood).ShouldBe(0x400u | HistorianInfoBits.HistorianCalculated);
}
[Fact]
public void Info_type_is_data_value() =>
HistorianInfoBits.InfoTypeDataValue.ShouldBe(0x400u);
@@ -9,18 +9,32 @@ public sealed class ValueWriterPrepareTests
private static readonly DateTime T = new(2026, 10, 9, 1, 2, 3, DateTimeKind.Utc);
[Fact]
public void Non_finite_doubles_become_Empty_keeping_status()
public void Non_finite_doubles_become_Empty_and_are_forced_Bad()
{
const uint status = 0x40000000u;
const uint uncertain = 0x40000000u;
const uint badNoCommunication = 0x80310000u;
var rows = SqlServerHistorianValueWriter.Prepare(
[
new(T, HistorianValue.FromDouble(double.NaN), status),
new(T, HistorianValue.FromDouble(double.NaN), uncertain),
new(T.AddTicks(1), HistorianValue.FromDouble(double.PositiveInfinity), 0),
new(T.AddTicks(2), HistorianValue.FromDouble(double.NegativeInfinity), 0),
new(T.AddTicks(2), HistorianValue.FromDouble(double.NegativeInfinity), badNoCommunication),
new(T.AddTicks(3), HistorianValue.FromDouble(1.5), 0),
], () => T);
rows.Select(r => r.Value.Kind).ShouldBe([HistorianValueKind.Empty, HistorianValueKind.Empty, HistorianValueKind.Empty, HistorianValueKind.Double]);
rows[0].StatusCode.ShouldBe(status);
rows.Select(r => r.StatusCode).ShouldBe([SqlHistorianStatusCodes.BadOutOfRange, SqlHistorianStatusCodes.BadOutOfRange,
badNoCommunication, 0u]);
}
[Fact]
public void A_non_bad_Empty_is_stored_BadNoData_and_a_bad_Empty_keeps_its_code()
{
var rows = SqlServerHistorianValueWriter.Prepare(
[
new(T, HistorianValue.Empty, 0),
new(T.AddTicks(1), HistorianValue.Empty, 0x40A40000u),
new(T.AddTicks(2), HistorianValue.Empty, 0x80310000u),
], () => T);
rows.Select(r => r.StatusCode).ShouldBe([SqlHistorianStatusCodes.BadNoData, SqlHistorianStatusCodes.BadNoData, 0x80310000u]);
}
[Fact]