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:
+41
-8
@@ -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) >= 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user