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:
+46
-19
@@ -34,6 +34,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
/// markers included. Aggregates exclude Bad samples from the math, report the interval Uncertain when
|
||||
/// any non-Good sample is present, and count them in PercentBad. AtTime never interpolates across a Bad
|
||||
/// neighbour and reports a time inside an outage with the outage's Bad code (see <see cref="ReadAtTimeAsync"/>).
|
||||
/// <b>An Empty sample is always served Bad:</b> a row with no value whose stored status is not Bad (only
|
||||
/// possible for rows written before the writer forced Empty samples Bad) is read back as <c>BadNoData</c>,
|
||||
/// on Raw and AtTime alike.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Long names.</b> A tag name or event source name longer than <see cref="SqlServerTagCatalog.MaxNameLength"/>
|
||||
/// can never be stored, so it is answered as not found (Raw: empty; AtTime / Processed: <c>BadNoData</c>;
|
||||
/// Events: none) without being truncated onto another series.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
@@ -41,15 +49,13 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
/// <summary>Spec §9.7: a processed read covering more intervals than this is refused.</summary>
|
||||
public const int MaxProcessedBuckets = 100_000;
|
||||
|
||||
/// <summary>Severity + sub-code (top 16 bits) of a status, i.e. the code without info bits.</summary>
|
||||
private const uint StatusCodeBitsMask = 0xFFFF0000u;
|
||||
|
||||
private const string SampleColumns = "SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, ServerTimeUtc";
|
||||
|
||||
private readonly SqlHistorianConnection _conn;
|
||||
private readonly SqlServerSchemaBootstrapper _bootstrap;
|
||||
private readonly SqlServerTagCatalog _catalog;
|
||||
private readonly ILogger _log;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
private readonly object _healthLock = new();
|
||||
private DateTime? _lastSuccessUtc;
|
||||
@@ -65,13 +71,16 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
/// <param name="bootstrap">Schema bootstrapper (shared with the other SQL seams).</param>
|
||||
/// <param name="catalog">Tag catalog (shared; reads use only its read-only lookup).</param>
|
||||
/// <param name="log">Optional logger.</param>
|
||||
/// <param name="timeProvider">Clock for "now" (AtTime beyond the last sample, server timestamps); system by default.</param>
|
||||
internal SqlServerHistorianDataSource(
|
||||
SqlHistorianConnection conn, SqlServerSchemaBootstrapper bootstrap, SqlServerTagCatalog catalog, ILogger? log = null)
|
||||
SqlHistorianConnection conn, SqlServerSchemaBootstrapper bootstrap, SqlServerTagCatalog catalog, ILogger? log = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_conn = conn ?? throw new ArgumentNullException(nameof(conn));
|
||||
_bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap));
|
||||
_catalog = catalog ?? throw new ArgumentNullException(nameof(catalog));
|
||||
_log = log ?? NullLogger.Instance;
|
||||
_time = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------ Raw
|
||||
@@ -149,7 +158,8 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
/// neighbours are Good, else <c>UncertainDataSubNormal</c> + Interpolated (never better than the
|
||||
/// inputs).</item>
|
||||
/// <item>Boolean/String/DateTime, or no <c>n</c> (after the last sample) ⇒ stepped: <c>p</c>'s value with
|
||||
/// <c>p</c>'s status code + Interpolated bits.</item>
|
||||
/// <c>p</c>'s status code + Interpolated bits — except that a time later than now that is stepped from the
|
||||
/// last sample is <c>UncertainDataSubNormal</c> + Interpolated (the value is not known to still hold).</item>
|
||||
/// </list>
|
||||
/// Unknown tag ⇒ every entry <c>BadNoData</c>.
|
||||
/// </remarks>
|
||||
@@ -160,7 +170,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
ArgumentNullException.ThrowIfNull(timestampsUtc);
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
var byTicks = new Dictionary<long, DataValueSnapshot>(timestampsUtc.Count);
|
||||
var tagId = timestampsUtc.Count == 0 ? null : await ResolveAsync(fullReference, cancellationToken).ConfigureAwait(false);
|
||||
if (tagId is { } id)
|
||||
@@ -182,7 +192,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
StoredSample? p = reader.IsDBNull(1) ? null : ReadStored(reader, 1);
|
||||
StoredSample? n = reader.IsDBNull(8) ? null : ReadStored(reader, 8);
|
||||
var serverTime = p is { } ps && ps.TimeUtc == t ? SqlHistorianConnection.GetUtc(reader, 7) : now;
|
||||
byTicks[t.Ticks] = AtTimeValue(t, p, n, serverTime);
|
||||
byTicks[t.Ticks] = AtTimeValue(t, p, n, serverTime, now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +216,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
}
|
||||
|
||||
/// <summary>Applies the AtTime rules (see <see cref="ReadAtTimeAsync"/>) to one requested time.</summary>
|
||||
private static DataValueSnapshot AtTimeValue(DateTime t, StoredSample? prev, StoredSample? next, DateTime serverTime)
|
||||
private static DataValueSnapshot AtTimeValue(DateTime t, StoredSample? prev, StoredSample? next, DateTime serverTime, DateTime now)
|
||||
{
|
||||
if (prev is not { } p)
|
||||
return new DataValueSnapshot(null, SqlHistorianStatusCodes.BadNoData, t, serverTime);
|
||||
@@ -215,7 +225,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
|
||||
// Inside an outage: report the outage's own Bad code, never an upgraded Uncertain.
|
||||
if (!p.IsUsable)
|
||||
return new DataValueSnapshot(null, p.Status & StatusCodeBitsMask, t, serverTime);
|
||||
return new DataValueSnapshot(null, p.Status & StatusBits.CodeBitsMask, t, serverTime);
|
||||
|
||||
// Usable previous, Bad next: hold the previous value up to the outage; do not interpolate into it.
|
||||
if (next is { IsUsable: false })
|
||||
@@ -234,6 +244,11 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
return new DataValueSnapshot(value, HistorianInfoBits.WithInterpolated(status), t, serverTime);
|
||||
}
|
||||
|
||||
// Beyond the last sample AND beyond now: the value is held into the future, so it is not known to be Good.
|
||||
if (next is null && t > now)
|
||||
return new DataValueSnapshot(p.Value.ToObject(),
|
||||
HistorianInfoBits.WithInterpolated(SqlHistorianStatusCodes.UncertainDataSubNormal), t, serverTime);
|
||||
|
||||
// Stepped (non-numeric kind, or after the last sample): hold the previous value and its status.
|
||||
return new DataValueSnapshot(p.Value.ToObject(), HistorianInfoBits.WithInterpolated(p.Status), t, serverTime);
|
||||
}
|
||||
@@ -252,6 +267,10 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
public async Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken)
|
||||
{
|
||||
// No stored event can carry a source longer than the column (the alarm writer dead-letters it rather than
|
||||
// truncating), so an over-long filter matches nothing — never a truncated prefix of someone else's source.
|
||||
if (sourceName is not null && sourceName.Length > SqlServerTagCatalog.MaxNameLength)
|
||||
return new HistoricalEventsResult([], null);
|
||||
try
|
||||
{
|
||||
await _bootstrap.EnsureAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -269,7 +288,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@lo", lo);
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@hi", hi);
|
||||
if (sourceName is not null)
|
||||
cmd.Parameters.Add(new SqlParameter("@source", SqlDbType.NVarChar, 400) { Value = sourceName });
|
||||
cmd.Parameters.Add(new SqlParameter("@source", SqlDbType.NVarChar, SqlServerTagCatalog.MaxNameLength) { Value = sourceName });
|
||||
|
||||
var events = new List<HistoricalEvent>();
|
||||
byte[]? continuation = null;
|
||||
@@ -349,7 +368,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
var rows = new Dictionary<long, BucketRow>();
|
||||
var tagId = bucketCount == 0 ? null : await ResolveAsync(fullReference, cancellationToken).ConfigureAwait(false);
|
||||
if (tagId is { } id)
|
||||
@@ -492,21 +511,29 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
private readonly record struct StoredSample(DateTime TimeUtc, HistorianValue Value, uint Status)
|
||||
{
|
||||
/// <summary>Severity Good or Uncertain, and a value present (an Empty sample is an outage marker).</summary>
|
||||
public bool IsUsable => (Status & 0x80000000u) == 0 && Value.Kind != HistorianValueKind.Empty;
|
||||
public bool IsUsable => !StatusBits.IsBad(Status) && Value.Kind != HistorianValueKind.Empty;
|
||||
|
||||
/// <summary>Usable with severity Good.</summary>
|
||||
public bool IsGood => IsUsable && (Status & 0xC0000000u) == 0;
|
||||
public bool IsGood => IsUsable && StatusBits.IsGood(Status);
|
||||
}
|
||||
|
||||
/// <summary>Reads <c>SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode</c> starting at <paramref name="o"/>.</summary>
|
||||
private static StoredSample ReadStored(SqlDataReader r, int o) => new(
|
||||
SqlHistorianConnection.GetUtc(r, o),
|
||||
SampleRowMapper.FromColumns(
|
||||
/// <summary>
|
||||
/// Reads <c>SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode</c> starting at <paramref name="o"/>.
|
||||
/// An Empty (no-value) row whose stored status is not Bad is clamped to <c>BadNoData</c> — defence in depth for
|
||||
/// rows written before the writer forced every Empty sample Bad.
|
||||
/// </summary>
|
||||
private static StoredSample ReadStored(SqlDataReader r, int o)
|
||||
{
|
||||
var value = SampleRowMapper.FromColumns(
|
||||
r.GetByte(o + 1),
|
||||
r.IsDBNull(o + 2) ? null : r.GetDouble(o + 2),
|
||||
r.IsDBNull(o + 3) ? null : r.GetInt64(o + 3),
|
||||
r.IsDBNull(o + 4) ? null : r.GetString(o + 4)),
|
||||
SampleRowMapper.FromDbStatus(r.GetInt32(o + 5)));
|
||||
r.IsDBNull(o + 4) ? null : r.GetString(o + 4));
|
||||
var status = SampleRowMapper.FromDbStatus(r.GetInt32(o + 5));
|
||||
if (value.Kind == HistorianValueKind.Empty && !StatusBits.IsBad(status))
|
||||
status = SqlHistorianStatusCodes.BadNoData;
|
||||
return new StoredSample(SqlHistorianConnection.GetUtc(r, o), value, status);
|
||||
}
|
||||
|
||||
/// <summary>Read-only tag lookup (never creates a row); runs the schema bootstrap on a cache miss.</summary>
|
||||
private Task<int?> ResolveAsync(string name, CancellationToken ct) => _catalog.TryGetTagIdAsync(name, ct);
|
||||
|
||||
Reference in New Issue
Block a user