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:
+25
-28
@@ -5,29 +5,6 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
|
||||
|
||||
/// <summary>
|
||||
/// The one place that maps value kinds to <c>Tag.DataType</c> (the schema stores a <see cref="DriverDataType"/> byte).
|
||||
/// Used only when the writer has to create a tag row on a miss; provisioning later corrects it.
|
||||
/// </summary>
|
||||
internal static class SqlHistorianDataTypes
|
||||
{
|
||||
/// <summary>Data type used when a tag is created from a batch that holds no typed value.</summary>
|
||||
public const DriverDataType DefaultForEmpty = DriverDataType.Float64;
|
||||
|
||||
/// <summary>Maps a value kind to the stored <c>Tag.DataType</c> byte (Empty ⇒ <see cref="DefaultForEmpty"/>).</summary>
|
||||
/// <param name="kind">Value kind.</param>
|
||||
/// <returns>The <see cref="DriverDataType"/> as a byte.</returns>
|
||||
public static byte FromKind(HistorianValueKind kind) => (byte)(kind switch
|
||||
{
|
||||
HistorianValueKind.Double => DriverDataType.Float64,
|
||||
HistorianValueKind.Int64 => DriverDataType.Int64,
|
||||
HistorianValueKind.Boolean => DriverDataType.Boolean,
|
||||
HistorianValueKind.String => DriverDataType.String,
|
||||
HistorianValueKind.DateTime => DriverDataType.DateTime,
|
||||
_ => DefaultForEmpty,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IHistorianValueWriter"/> over the SQL <c>Sample</c> table. Redelivery-idempotent: rows are
|
||||
/// inserted only where <c>(TagId, SourceTimeUtc)</c> is absent, so the recorder's at-least-once redelivery
|
||||
@@ -36,8 +13,13 @@ internal static class SqlHistorianDataTypes
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Non-finite doubles.</b> NaN / ±Infinity cannot live in a SQL <c>float</c> (the TVP throws). They are stored
|
||||
/// as <see cref="HistorianValueKind.Empty"/> keeping the original status code, so the sample's presence and
|
||||
/// status survive and the tag is not quarantined for one bad reading.
|
||||
/// as <see cref="HistorianValueKind.Empty"/> so the sample's presence survives and the tag is not quarantined
|
||||
/// for one bad reading. <b>An Empty sample is always Bad</b> (the quality contract: a stored sample is never
|
||||
/// served better than "no value"): a non-finite Double whose status is not already Bad is stored with
|
||||
/// <c>BadOutOfRange</c>; any other Empty with a non-Bad status is stored with <c>BadNoData</c>. An existing
|
||||
/// Bad code (e.g. the comms-loss <c>BadNoCommunication</c> marker) is kept as it is.
|
||||
/// <b>Tag names over <see cref="SqlServerTagCatalog.MaxNameLength"/> characters</b> are refused (the write
|
||||
/// returns false, a per-tag failure for the recorder's #538 quarantine), never truncated onto another tag.
|
||||
/// <b>Duplicate timestamps within a batch</b> collapse to the first (matching insert-if-absent across batches).
|
||||
/// </remarks>
|
||||
internal sealed class SqlServerHistorianValueWriter : IHistorianValueWriter
|
||||
@@ -69,6 +51,12 @@ internal sealed class SqlServerHistorianValueWriter : IHistorianValueWriter
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!SqlServerTagCatalog.IsValidName(tag))
|
||||
{
|
||||
_log.LogWarning("SQL historian live-value write refused: tag name is missing or longer than {Max} characters.",
|
||||
SqlServerTagCatalog.MaxNameLength);
|
||||
return false;
|
||||
}
|
||||
await _bootstrap.EnsureAsync(ct).ConfigureAwait(false);
|
||||
if (values is null || values.Count == 0) return true;
|
||||
|
||||
@@ -108,8 +96,9 @@ internal sealed class SqlServerHistorianValueWriter : IHistorianValueWriter
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalises a batch for the TVP: null timestamp ⇒ now, non-finite doubles ⇒ Empty (status kept), and
|
||||
/// duplicate timestamps collapsed to the first.
|
||||
/// Normalises a batch for the TVP: null timestamp ⇒ now, non-finite doubles ⇒ Empty, every Empty forced Bad
|
||||
/// (non-finite ⇒ <c>BadOutOfRange</c>, otherwise <c>BadNoData</c>, an existing Bad code kept), and duplicate
|
||||
/// timestamps collapsed to the first.
|
||||
/// </summary>
|
||||
/// <param name="values">Input samples, in append order.</param>
|
||||
/// <param name="utcNow">Clock for null timestamps.</param>
|
||||
@@ -124,9 +113,17 @@ internal sealed class SqlServerHistorianValueWriter : IHistorianValueWriter
|
||||
t = t.Kind == DateTimeKind.Local ? t.ToUniversalTime() : DateTime.SpecifyKind(t, DateTimeKind.Utc);
|
||||
if (!seen.Add(t.Ticks)) continue;
|
||||
var value = v.Value;
|
||||
var status = v.StatusCode;
|
||||
if (value.Kind == HistorianValueKind.Double && !double.IsFinite(value.Number))
|
||||
{
|
||||
value = HistorianValue.Empty;
|
||||
rows.Add(new SqlSampleRow(t, value, v.StatusCode));
|
||||
if (!StatusBits.IsBad(status)) status = SqlHistorianStatusCodes.BadOutOfRange;
|
||||
}
|
||||
else if (value.Kind == HistorianValueKind.Empty && !StatusBits.IsBad(status))
|
||||
{
|
||||
status = SqlHistorianStatusCodes.BadNoData;
|
||||
}
|
||||
rows.Add(new SqlSampleRow(t, value, status));
|
||||
}
|
||||
|
||||
return rows;
|
||||
|
||||
Reference in New Issue
Block a user