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
@@ -1,5 +1,6 @@
using System.Data;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Text.RegularExpressions;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
@@ -9,7 +10,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
/// <summary>
/// Lazily applies the embedded, idempotent schema scripts once per instance, under a SQL application lock
/// so concurrent nodes/instances serialise. A failure is NOT latched: the next <see cref="EnsureAsync"/> retries.
/// so concurrent nodes/instances serialise. A failure is NOT latched: an <see cref="EnsureAsync"/> after
/// <see cref="FailureCacheDuration"/> retries. Within that window the last failure is re-thrown without touching
/// SQL, so a missing schema (or a SQL outage) does not serialise every write, read and alarm event behind one
/// semaphore and one round-trip each.
/// With <c>AutoCreateSchema=false</c> it only verifies <c>MAX(Version) &gt;= 1</c>.
/// </summary>
internal sealed partial class SqlServerSchemaBootstrapper
@@ -20,21 +24,30 @@ internal sealed partial class SqlServerSchemaBootstrapper
private const string LockResource = "OtOpcUaHistorianSchema";
private const int LockTimeoutMs = 30_000;
/// <summary>How long the last bootstrap failure is re-thrown before SQL is tried again.</summary>
public static readonly TimeSpan FailureCacheDuration = TimeSpan.FromSeconds(5);
private readonly SqlHistorianConnection _conn;
private readonly bool _autoCreate;
private readonly ILogger _log;
private readonly TimeProvider _time;
private readonly SemaphoreSlim _gate = new(1, 1);
private volatile bool _ready;
private ExceptionDispatchInfo? _lastFailure; // guarded by _gate
private DateTimeOffset _retryAfter; // guarded by _gate
/// <summary>Creates the bootstrapper.</summary>
/// <param name="conn">Connection helper.</param>
/// <param name="options">Validated options.</param>
/// <param name="log">Optional logger.</param>
public SqlServerSchemaBootstrapper(SqlHistorianConnection conn, SqlServerHistorianOptions options, ILogger? log = null)
/// <param name="timeProvider">Clock for the failure cache; system by default.</param>
public SqlServerSchemaBootstrapper(SqlHistorianConnection conn, SqlServerHistorianOptions options, ILogger? log = null,
TimeProvider? timeProvider = null)
{
_conn = conn;
_autoCreate = options.AutoCreateSchema;
_log = log ?? NullLogger.Instance;
_time = timeProvider ?? TimeProvider.System;
}
/// <summary>Ensures the schema is ready; no-op after the first success.</summary>
@@ -48,10 +61,25 @@ internal sealed partial class SqlServerSchemaBootstrapper
try
{
if (_ready) return;
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
if (_autoCreate) await ApplyAsync(conn, ct).ConfigureAwait(false);
else await VerifyAsync(conn, ct).ConfigureAwait(false);
_ready = true;
if (_lastFailure is { } cached && _time.GetUtcNow() < _retryAfter) cached.Throw();
try
{
await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false);
if (_autoCreate) await ApplyAsync(conn, ct).ConfigureAwait(false);
else await VerifyAsync(conn, ct).ConfigureAwait(false);
_ready = true;
_lastFailure = null;
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
// A caller's own cancellation is not a backend failure and is not cached.
_lastFailure = ExceptionDispatchInfo.Capture(ex);
_retryAfter = _time.GetUtcNow() + FailureCacheDuration;
_log.LogWarning("Historian schema bootstrap for [{Schema}] failed ({Exception}: {Message}); " +
"historian calls fail fast for {Seconds} s before retrying.",
_conn.Schema, ex.GetType().Name, ex.Message, FailureCacheDuration.TotalSeconds);
throw;
}
}
finally
{
@@ -127,8 +155,13 @@ internal sealed partial class SqlServerSchemaBootstrapper
}
catch (Exception ex)
{
// Closing the session releases a Session-owned lock anyway.
_log.LogDebug(ex, "sp_releaseapplock failed; the lock dies with the connection");
// A pooled connection keeps its server session (sp_reset_connection does not release a Session-owned
// applock), so disposing this connection would return it to the pool STILL HOLDING the lock and block
// every other node's bootstrap until that pooled session dies. Clearing the pool marks it to be
// physically closed on dispose instead, which ends the session and releases the lock.
_log.LogWarning("sp_releaseapplock failed ({Exception}); discarding the connection pool so the " +
"session holding the historian schema applock is closed rather than pooled.", ex.GetType().Name);
SqlConnection.ClearPool(conn);
}
}