37f444b99c
C1: SqlDriver's Initialize/Read failure paths no longer interpolate the ADO.NET provider's ex.Message into LastError, the thrown message, or host status — only ex.GetType().Name reaches the operator surface; the full exception goes to the log sink via the structured logger's exception parameter. The driver is dialect-agnostic over an arbitrary DbProviderFactory, so it cannot rely on any provider's message discipline (Microsoft.Data.SqlClient's parser echoes an unrecognised keyword lower-cased, which a value-based redactor misses). C1a: replace the vacuous credential test (SQLite's "unable to open" message never contains the connection string, so it passed regardless) with one that fabricates a DbException whose own .Message carries a credential token and drives it through the Initialize liveness-failure path via the injectable factory seam — red against the pre-fix ex.Message interpolation. I1: HandlePollError now ignores the DbException class ReadAsync already classified, so a subscribed-read outage is classified (and logged) once, not twice with the worse message. I2: the poll's Healthy verdict routes through a shared SetHealthUnlessFaulted guard (the one Degrade already used), so a late in-flight poll cannot un-fault a driver a concurrent ReinitializeAsync just faulted. I3: DiscoverAsync warns when materializing an omitted-type tag as String, the only operator signal for the declared-vs-published type mismatch on a numeric column. M1: BuildTagTable wraps the per-entry parse so a stray non-JsonException throw skips the tag instead of stranding health at Initializing (it runs before Initialize's try/catch). M2 left as a documented TODO to avoid touching SqlPollReader. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
758 lines
39 KiB
C#
758 lines
39 KiB
C#
using System.Data.Common;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
|
|
|
|
/// <summary>
|
|
/// The <c>Sql</c> driver instance: a <b>read-only</b> Equipment-kind driver that polls SQL tables/views
|
|
/// and publishes selected cells as OPC UA variables. Structurally the SQL analogue of
|
|
/// <c>ModbusDriver</c> — this type owns lifecycle, the authored RawPath table, health, host
|
|
/// connectivity, and the <see cref="PollGroupEngine"/> subscription overlay, while
|
|
/// <see cref="SqlPollReader"/> owns the read path.
|
|
/// <para><b>Read-only is structural, not configured.</b> <see cref="IWritable"/> is deliberately not
|
|
/// implemented, so no amount of config (and no <c>WriteOperate</c> role) can produce a write; every
|
|
/// discovered variable is <see cref="SecurityClassification.ViewOnly"/>. A future write feature is a
|
|
/// new capability, not a flag flip.</para>
|
|
/// <para><b>Health is classified here, because nothing below does it.</b> The reader honours
|
|
/// <see cref="IReadable"/> literally: it throws only when the database cannot be reached, and turns
|
|
/// every other failure — including a frozen database — into Bad-coded snapshots. Those return
|
|
/// <em>successfully</em>, so <see cref="PollGroupEngine"/> sees a clean tick, does not back off, and does
|
|
/// not call <see cref="HandlePollError"/>. Without <see cref="ObservePollOutcome"/> below, a driver whose
|
|
/// every value is <see cref="SqlStatusCodes.BadTimeout"/> would keep reporting
|
|
/// <see cref="DriverState.Healthy"/>. See that method for the exact rule and for why it does not
|
|
/// manufacture an exception to force backoff.</para>
|
|
/// </summary>
|
|
public sealed class SqlDriver
|
|
: IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IAsyncDisposable
|
|
{
|
|
/// <summary>
|
|
/// The driver-type string this driver registers under.
|
|
/// <para><b>Deliberately a local constant, not <c>DriverTypeNames.Sql</c>.</b>
|
|
/// <c>DriverTypeNamesGuardTests</c> asserts bidirectional parity between the constants and the
|
|
/// driver factories actually registered in the process, so the constant may only be added in the
|
|
/// same change that wires the factory (this task ships no factory — see the plan's Task 11, which
|
|
/// adds <c>DriverTypeNames.Sql</c> and repoints this constant at it).</para>
|
|
/// </summary>
|
|
public const string DriverTypeName = "Sql";
|
|
|
|
/// <summary>Shown for a connection string that names no recognisable server.</summary>
|
|
private const string UnknownEndpoint = "(unknown sql endpoint)";
|
|
|
|
/// <summary>Upper bound on the poll-loop failure backoff — the S7-proven 30 s cap adopted fleet-wide (05/STAB-8).</summary>
|
|
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
|
|
|
|
/// <summary>Connection-string keys that name the server, in the order they are consulted.</summary>
|
|
private static readonly string[] ServerKeys =
|
|
["server", "data source", "datasource", "host", "address", "addr", "network address"];
|
|
|
|
/// <summary>Connection-string keys that name the database.</summary>
|
|
private static readonly string[] DatabaseKeys = ["database", "initial catalog"];
|
|
|
|
// ---- instance fields (grouped at top for auditability) ----
|
|
|
|
private readonly SqlDriverOptions _options;
|
|
private readonly string _driverInstanceId;
|
|
private readonly ISqlDialect _dialect;
|
|
private readonly DbProviderFactory _factory;
|
|
|
|
/// <summary>
|
|
/// The resolved connection string — a <b>secret</b>. It is passed to the provider and to nothing
|
|
/// else: every log line, health message and host status carries <see cref="Endpoint"/> instead.
|
|
/// </summary>
|
|
private readonly string _connectionString;
|
|
|
|
private readonly ILogger<SqlDriver> _logger;
|
|
|
|
/// <summary>
|
|
/// The authored RawPath → definition table, held as an <b>immutable snapshot swapped atomically</b>
|
|
/// rather than as a mutated dictionary (the shape <c>ModbusDriver</c> uses).
|
|
/// <see cref="ReinitializeAsync"/> rebuilds this table while poll loops are reading it; clearing and
|
|
/// refilling a shared <see cref="Dictionary{TKey,TValue}"/> under that concurrency is a torn read at
|
|
/// best and an <see cref="InvalidOperationException"/> inside a poll at worst.
|
|
/// </summary>
|
|
private IReadOnlyDictionary<string, SqlTagDefinition> _tagsByRawPath =
|
|
new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal);
|
|
|
|
/// <summary>Resolves a read/subscribe RawPath to its authored definition — a single table hit, or a miss.</summary>
|
|
private readonly EquipmentTagRefResolver<SqlTagDefinition> _resolver;
|
|
|
|
/// <summary>
|
|
/// The read path. Built once, in the constructor, and <b>not</b> injected: its <c>resolve</c>
|
|
/// delegate must read this driver's live authored table, which does not exist before the driver does.
|
|
/// </summary>
|
|
private readonly SqlPollReader _reader;
|
|
|
|
/// <summary>Polled subscriptions. The driver supplies the reader + change bridge; the engine owns the loop.</summary>
|
|
private readonly PollGroupEngine _poll;
|
|
|
|
// Single logical host: one driver instance dials one connection string. HostName is the endpoint
|
|
// description (server[/database]) so the Admin UI shows operators where to look.
|
|
private readonly object _probeLock = new();
|
|
private HostState _hostState = HostState.Unknown;
|
|
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
|
|
|
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
|
|
|
/// <summary>Occurs when a subscribed tag's value or quality changes.</summary>
|
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
|
|
|
/// <summary>Occurs when the database endpoint transitions between reachable and unreachable.</summary>
|
|
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
|
|
|
// ---- ctor + identity ----
|
|
|
|
/// <summary>Initializes a new <c>Sql</c> driver instance.</summary>
|
|
/// <param name="options">The driver's typed configuration (the factory applies the documented defaults).</param>
|
|
/// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
|
|
/// <param name="dialect">
|
|
/// The provider seam — identifier quoting, liveness SQL, row-limit syntax and column-type mapping.
|
|
/// </param>
|
|
/// <param name="connectionString">
|
|
/// The <b>already-resolved</b> connection string. The driver never resolves a
|
|
/// <c>connectionStringRef</c> itself and never logs this value.
|
|
/// </param>
|
|
/// <param name="factory">
|
|
/// Creates the provider's connections. Defaults to <paramref name="dialect"/>'s factory; passed
|
|
/// explicitly by tests that need to observe or delay connection creation.
|
|
/// </param>
|
|
/// <param name="logger">Optional; defaults to a no-op logger.</param>
|
|
/// <exception cref="ArgumentNullException">A required reference argument is null.</exception>
|
|
/// <exception cref="ArgumentException"><paramref name="driverInstanceId"/> or <paramref name="connectionString"/> is blank.</exception>
|
|
public SqlDriver(
|
|
SqlDriverOptions options,
|
|
string driverInstanceId,
|
|
ISqlDialect dialect,
|
|
string connectionString,
|
|
DbProviderFactory? factory = null,
|
|
ILogger<SqlDriver>? logger = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
ArgumentNullException.ThrowIfNull(dialect);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
|
if (string.IsNullOrWhiteSpace(connectionString))
|
|
throw new ArgumentException("A Sql driver needs a resolved connection string.", nameof(connectionString));
|
|
|
|
_options = options;
|
|
_driverInstanceId = driverInstanceId;
|
|
_dialect = dialect;
|
|
_factory = factory ?? dialect.Factory;
|
|
_connectionString = connectionString;
|
|
_logger = logger ?? NullLogger<SqlDriver>.Instance;
|
|
Endpoint = DescribeEndpoint(connectionString);
|
|
|
|
_resolver = new EquipmentTagRefResolver<SqlTagDefinition>(Lookup);
|
|
_reader = new SqlPollReader(
|
|
_factory,
|
|
connectionString,
|
|
dialect,
|
|
commandTimeout: options.CommandTimeout,
|
|
operationTimeout: options.OperationTimeout,
|
|
maxConcurrentGroups: options.MaxConcurrentGroups,
|
|
nullIsBad: options.NullIsBad,
|
|
resolve: rawPath => _resolver.TryResolve(rawPath, out var definition) ? definition : null,
|
|
logger: _logger);
|
|
_poll = new PollGroupEngine(
|
|
reader: ReadAsync,
|
|
onChange: (handle, tagRef, snapshot) =>
|
|
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
|
|
onError: HandlePollError,
|
|
backoffCap: PollBackoffCap);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public string DriverInstanceId => _driverInstanceId;
|
|
|
|
/// <inheritdoc />
|
|
public string DriverType => DriverTypeName;
|
|
|
|
/// <summary>
|
|
/// The credential-free description of the database this instance polls — <c>server/database</c> where
|
|
/// the connection string names both. This is the only rendering of the connection string that may
|
|
/// appear in a log, a health message, or the Admin UI.
|
|
/// </summary>
|
|
public string Endpoint { get; }
|
|
|
|
/// <summary>Host identifier surfaced through <see cref="IHostConnectivityProbe"/> — the endpoint description.</summary>
|
|
public string HostName => Endpoint;
|
|
|
|
/// <summary>Active polled-subscription count. Diagnostics + disposal assertions.</summary>
|
|
internal int ActiveSubscriptionCount => _poll.ActiveSubscriptionCount;
|
|
|
|
/// <summary>The current authored-tag snapshot. Read through a barrier — <see cref="BuildTagTable"/> swaps it.</summary>
|
|
private IReadOnlyDictionary<string, SqlTagDefinition> Tags => Volatile.Read(ref _tagsByRawPath);
|
|
|
|
/// <summary>The resolver's lookup: RawPath → authored definition, or null on a miss.</summary>
|
|
private SqlTagDefinition? Lookup(string rawPath) => Tags.GetValueOrDefault(rawPath);
|
|
|
|
// ---- IDriver lifecycle ----
|
|
|
|
/// <summary>
|
|
/// Builds the authored RawPath table and proves the database is reachable.
|
|
/// <para><paramref name="driverConfigJson"/> is <b>not</b> re-parsed here: the driver serves the
|
|
/// typed <see cref="SqlDriverOptions"/> it was constructed with, exactly as <c>ModbusDriver</c> does
|
|
/// (config parsing belongs to the factory, which builds a fresh instance).</para>
|
|
/// <para>The table is built first because it is pure and cannot fail; the liveness check is the only
|
|
/// I/O, and on failure this method records <see cref="DriverState.Faulted"/> <b>and rethrows</b> —
|
|
/// <c>DriverInstanceActor</c> reads a throw as InitializeFailed and lands in Reconnecting with its
|
|
/// retry timer running, which is exactly the recovery a database that is merely down needs.</para>
|
|
/// </summary>
|
|
/// <param name="driverConfigJson">The driver configuration JSON (unused; see remarks).</param>
|
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
/// <exception cref="InvalidOperationException">The database could not be reached.</exception>
|
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
{
|
|
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
|
|
BuildTagTable();
|
|
|
|
try
|
|
{
|
|
await VerifyLivenessAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
// The caller tore the initialization down; not a database verdict.
|
|
WriteHealth(new DriverHealth(DriverState.Unknown, null, null));
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Never interpolate _connectionString OR ex.Message onto the operator surface. The driver is
|
|
// dialect-agnostic over an arbitrary DbProviderFactory, so it cannot rely on any one provider's
|
|
// message discipline — Microsoft.Data.SqlClient's connection-string parser, for one, echoes an
|
|
// unrecognised keyword lower-cased, which a value-based password redactor misses entirely. Only
|
|
// the exception TYPE reaches LastError/host status; the full exception object goes to the log
|
|
// SINK via the structured logger's exception parameter (below), never into a rendered string.
|
|
var message =
|
|
$"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}. " +
|
|
$"Check the database is reachable and the connection string is valid.";
|
|
_logger.LogError(ex, "Sql driver {DriverInstanceId} liveness check against {Endpoint} failed.",
|
|
_driverInstanceId, Endpoint);
|
|
WriteHealth(new DriverHealth(DriverState.Faulted, null, message));
|
|
TransitionHostTo(HostState.Stopped);
|
|
throw new InvalidOperationException(message, ex);
|
|
}
|
|
|
|
WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
|
|
TransitionHostTo(HostState.Running);
|
|
_logger.LogInformation(
|
|
"Sql driver {DriverInstanceId} initialized against {Endpoint} with {TagCount} authored tag(s).",
|
|
_driverInstanceId, Endpoint, Tags.Count);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
{
|
|
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
|
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
|
{
|
|
var lastRead = ReadHealth().LastSuccessfulRead;
|
|
await TeardownAsync().ConfigureAwait(false);
|
|
WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public DriverHealth GetHealth() => ReadHealth();
|
|
|
|
/// <summary>No caches, no symbol table, no connection between polls — the footprint is the tag table.</summary>
|
|
/// <returns>Always zero.</returns>
|
|
public long GetMemoryFootprint() => 0;
|
|
|
|
/// <summary>Nothing optional to flush: the authored table is required for correctness.</summary>
|
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
|
/// <returns>A completed task.</returns>
|
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
|
|
// ---- ITagDiscovery ----
|
|
|
|
/// <summary>
|
|
/// Exactly one pass: the tag set is the authored one, fixed at Initialize, so re-running discovery
|
|
/// could only produce the same nodes.
|
|
/// </summary>
|
|
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
|
|
|
|
/// <summary>
|
|
/// False — <see cref="DiscoverAsync"/> replays authored tags rather than enumerating the backend, so
|
|
/// the universal discovery browser must not treat this driver as browsable. (Live schema browsing is
|
|
/// a separate surface: <c>Driver.Sql.Browser</c>.)
|
|
/// </summary>
|
|
public bool SupportsOnlineDiscovery => false;
|
|
|
|
/// <summary>
|
|
/// Streams the authored tags as read-only variables.
|
|
/// <para>An undeclared tag materialises as <see cref="DriverDataType.String"/> — the same fallback
|
|
/// <see cref="ISqlDialect.MapColumnType"/> uses for an unrecognised column type — because a column's
|
|
/// real type is only known once a poll has returned result-set metadata, which has not happened at
|
|
/// discovery time. Author <c>"type"</c> on a numeric tag.</para>
|
|
/// </summary>
|
|
/// <param name="builder">The address-space builder to stream discovered nodes into.</param>
|
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
|
/// <returns>A completed task — discovery is synchronous over the authored table.</returns>
|
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
var folder = builder.Folder(DriverTypeName, DriverTypeName);
|
|
foreach (var tag in Tags.Values)
|
|
{
|
|
if (tag.DeclaredType is null)
|
|
{
|
|
// Discovery cannot infer a column's real type — that needs live result-set metadata, which
|
|
// only a poll returns — so an omitted-type tag materialises as String (the same fallback
|
|
// ISqlDialect.MapColumnType uses) while the reader coerces and publishes the column's actual,
|
|
// possibly numeric, CLR value against that String node. Nothing else signals the operator to
|
|
// this declared-vs-published mismatch, so warn and point them at the fix.
|
|
_logger.LogWarning(
|
|
"Sql tag '{Tag}' has no authored \"type\" and is discovered as String; a numeric column " +
|
|
"will then publish numeric values against a String node. Author an explicit \"type\" on " +
|
|
"numeric tags. Driver={DriverInstanceId}",
|
|
tag.Name, _driverInstanceId);
|
|
}
|
|
|
|
folder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
|
FullName: tag.Name,
|
|
DriverDataType: tag.DeclaredType ?? DriverDataType.String,
|
|
IsArray: false,
|
|
ArrayDim: null,
|
|
// v1 is read-only: no tag, and no configuration, can widen this.
|
|
SecurityClass: SecurityClassification.ViewOnly,
|
|
IsHistorized: false,
|
|
IsAlarm: false,
|
|
WriteIdempotent: false));
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// ---- IReadable ----
|
|
|
|
/// <summary>
|
|
/// Reads a batch, delegating to <see cref="SqlPollReader"/> and classifying what came back
|
|
/// (<see cref="ObservePollOutcome"/>).
|
|
/// </summary>
|
|
/// <param name="fullReferences">The RawPaths to read.</param>
|
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
|
/// <returns>One snapshot per reference, at the same index.</returns>
|
|
/// <exception cref="DbException">The database could not be reached — the engine backs off on this.</exception>
|
|
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
|
{
|
|
// Guarded here rather than inside the try, so a caller's bug cannot be misread as "the database is
|
|
// unreachable" and degrade a perfectly healthy driver.
|
|
ArgumentNullException.ThrowIfNull(fullReferences);
|
|
|
|
try
|
|
{
|
|
var snapshots = await _reader.ReadAsync(fullReferences, cancellationToken).ConfigureAwait(false);
|
|
ObservePollOutcome(snapshots);
|
|
return snapshots;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Teardown, not a database verdict — leave health and host state exactly as they were.
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// The reader throws for one reason only: opening a connection failed (IReadable's contract).
|
|
_logger.LogWarning(ex, "Sql driver {DriverInstanceId} could not reach {Endpoint} during a read.",
|
|
_driverInstanceId, Endpoint);
|
|
// Type name only — never ex.Message (see InitializeAsync for why); the full exception is already
|
|
// on the log sink via the LogWarning exception parameter above.
|
|
Degrade($"Sql driver '{_driverInstanceId}' could not reach {Endpoint}: {ex.GetType().Name}.");
|
|
TransitionHostTo(HostState.Stopped);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
// ---- ISubscribable (polling overlay via the shared engine) ----
|
|
|
|
/// <summary>
|
|
/// Registers a polled subscription.
|
|
/// <para>A non-positive <paramref name="publishingInterval"/> falls back to the configured
|
|
/// <see cref="SqlDriverOptions.DefaultPollInterval"/>; anything faster than
|
|
/// <see cref="PollGroupEngine.DefaultMinInterval"/> is floored by the engine.</para>
|
|
/// </summary>
|
|
/// <param name="fullReferences">The RawPaths to poll.</param>
|
|
/// <param name="publishingInterval">The requested publishing interval.</param>
|
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
|
/// <returns>The subscription handle.</returns>
|
|
public Task<ISubscriptionHandle> SubscribeAsync(
|
|
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
|
{
|
|
var interval = publishingInterval > TimeSpan.Zero ? publishingInterval : _options.DefaultPollInterval;
|
|
return Task.FromResult(_poll.Subscribe(fullReferences, interval));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
|
{
|
|
_poll.Unsubscribe(handle);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// ---- IHostConnectivityProbe ----
|
|
|
|
/// <summary>
|
|
/// The single logical host this instance dials. There is no background probe loop: the state is a
|
|
/// by-product of the Initialize-time liveness check and of every poll, so the report is always a
|
|
/// statement about traffic that actually happened rather than about a synthetic ping.
|
|
/// </summary>
|
|
/// <returns>A one-element list describing the configured database endpoint.</returns>
|
|
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
|
|
{
|
|
lock (_probeLock)
|
|
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
|
|
}
|
|
|
|
// ---- health + host-state classification ----
|
|
|
|
/// <summary>
|
|
/// Classifies one poll's snapshots into a health verdict and a host state.
|
|
/// <list type="bullet">
|
|
/// <item>Any Good snapshot ⇒ the database answered: <c>LastSuccessfulRead</c> advances and the
|
|
/// host is Running.</item>
|
|
/// <item>Any <b>connection-class</b> Bad snapshot (<see cref="SqlStatusCodes.BadTimeout"/> /
|
|
/// <see cref="SqlStatusCodes.BadCommunicationError"/>) ⇒ Degraded; when nothing at all was Good,
|
|
/// the host is Stopped.</item>
|
|
/// <item>Bad codes that are <b>authoring</b> facts — an unresolvable RawPath, an absent row, a
|
|
/// type mismatch, a rejected definition — change nothing. A tag typo must never report the
|
|
/// database as down and send an operator to the wrong system.</item>
|
|
/// </list>
|
|
/// <para><b>Why this does not throw to force poll-engine backoff.</b> Backoff would require turning
|
|
/// an all-<c>BadTimeout</c> batch into an exception, and the engine's exception path publishes
|
|
/// nothing — clients would lose the very BadTimeout quality the reader went out of its way to
|
|
/// produce, and would sit on stale Good values instead. Nor is hammering a real risk: each group is
|
|
/// already bounded by <c>operationTimeout</c> and the reader never holds more than
|
|
/// <c>maxConcurrentGroups</c> connections however long the database stays frozen. So a frozen
|
|
/// database is reported (Degraded + host Stopped + Bad-quality values) at the configured poll
|
|
/// cadence rather than backed off from.</para>
|
|
/// </summary>
|
|
/// <param name="snapshots">The snapshots the reader returned for one poll.</param>
|
|
internal void ObservePollOutcome(IReadOnlyList<DataValueSnapshot> snapshots)
|
|
{
|
|
if (snapshots.Count == 0) return;
|
|
|
|
var good = 0;
|
|
var unreachable = 0;
|
|
foreach (var snapshot in snapshots)
|
|
{
|
|
if (SqlStatusCodes.IsGood(snapshot.StatusCode)) good++;
|
|
else if (IsConnectionClass(snapshot.StatusCode)) unreachable++;
|
|
}
|
|
|
|
if (unreachable > 0)
|
|
{
|
|
Degrade(
|
|
$"{unreachable} of {snapshots.Count} Sql tag(s) timed out or failed communication against " +
|
|
$"{Endpoint} on the last poll.");
|
|
if (good > 0)
|
|
{
|
|
// Partial: something answered, so the endpoint is up — but the driver is not healthy.
|
|
TouchLastSuccessfulRead();
|
|
TransitionHostTo(HostState.Running);
|
|
}
|
|
else
|
|
{
|
|
TransitionHostTo(HostState.Stopped);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (good == 0) return; // authoring-class Bad only — not a statement about the database.
|
|
|
|
// Route through the Faulted guard, not a raw WriteHealth: a poll still in flight when a concurrent
|
|
// ReinitializeAsync has recorded Faulted (the engine's ~5 s teardown wait is shorter than the 15 s
|
|
// default operationTimeout, so a stale poll can still complete afterwards) must not flip Faulted back
|
|
// to Healthy with no real recovery. Only a successful (re)initialize clears Faulted.
|
|
SetHealthUnlessFaulted(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
|
|
TransitionHostTo(HostState.Running);
|
|
}
|
|
|
|
/// <summary>True for the status codes that mean "the database did not answer", as opposed to "the data is not there".</summary>
|
|
private static bool IsConnectionClass(uint statusCode)
|
|
=> statusCode is SqlStatusCodes.BadTimeout or SqlStatusCodes.BadCommunicationError;
|
|
|
|
/// <summary>
|
|
/// Routes a poll-loop reader failure to the health surface (05/STAB-9). Deliberately does <b>not</b>
|
|
/// touch host state: the engine also reports its own contract violations here, which say nothing
|
|
/// about connectivity — the unreachable-database transition is raised by <see cref="ReadAsync"/>,
|
|
/// which knows the exception came from the reader.
|
|
/// </summary>
|
|
/// <param name="ex">The exception the poll engine caught.</param>
|
|
internal void HandlePollError(Exception ex)
|
|
{
|
|
// A subscribed-read outage surfaces as a DbException the reader threw, which ReadAsync has ALREADY
|
|
// classified — good, endpoint-bearing LastError + host Stopped — before rethrowing. The poll engine
|
|
// then routes that same exception here. Re-degrading would overwrite the good LastError with a worse,
|
|
// guidance-free one (and, for a provider whose text echoes credentials, re-open the C1 leak) and log
|
|
// the outage a second time. So skip the connection class ReadAsync owns; the only exceptions that
|
|
// reach past this guard are the engine's own contract-violation throws — an InvalidOperationException
|
|
// carrying no provider text — which nothing else classifies.
|
|
if (ex is DbException) return;
|
|
|
|
_logger.LogWarning(ex, "Sql poll failed. Driver={DriverInstanceId} Endpoint={Endpoint}",
|
|
_driverInstanceId, Endpoint);
|
|
Degrade(ex.Message);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Degrades health, preserving <c>LastSuccessfulRead</c> and never downgrading
|
|
/// <see cref="DriverState.Faulted"/> — a config-level verdict that only a successful read or an
|
|
/// operator reinitialize may clear.
|
|
/// </summary>
|
|
/// <param name="reason">The operator-facing reason, which must never carry the connection string.</param>
|
|
private void Degrade(string reason)
|
|
{
|
|
var current = ReadHealth();
|
|
SetHealthUnlessFaulted(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, reason));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Publishes <paramref name="value"/> unless the driver is already <see cref="DriverState.Faulted"/>
|
|
/// — a liveness/config verdict that only a successful (re)initialize may clear. Every non-Initialize
|
|
/// health write routes through here: <see cref="Degrade"/> and the poll's Healthy verdict in
|
|
/// <see cref="ObservePollOutcome"/>, so a late in-flight poll cannot un-fault a driver a concurrent
|
|
/// <see cref="ReinitializeAsync"/> just faulted.
|
|
/// </summary>
|
|
/// <param name="value">The health snapshot to publish when the driver is not Faulted.</param>
|
|
private void SetHealthUnlessFaulted(DriverHealth value)
|
|
{
|
|
if (ReadHealth().State == DriverState.Faulted) return;
|
|
WriteHealth(value);
|
|
}
|
|
|
|
/// <summary>Advances <c>LastSuccessfulRead</c> without changing the current state or error.</summary>
|
|
private void TouchLastSuccessfulRead()
|
|
{
|
|
var current = ReadHealth();
|
|
WriteHealth(current with { LastSuccessfulRead = DateTime.UtcNow });
|
|
}
|
|
|
|
/// <summary>Barrier-protected read of the multi-threaded health field.</summary>
|
|
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
|
|
|
|
/// <summary>Barrier-protected publish of a new health snapshot.</summary>
|
|
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
|
|
|
|
/// <summary>Records a host-state change and raises <see cref="OnHostStatusChanged"/> — on transitions only.</summary>
|
|
private void TransitionHostTo(HostState newState)
|
|
{
|
|
HostState old;
|
|
lock (_probeLock)
|
|
{
|
|
old = _hostState;
|
|
if (old == newState) return;
|
|
_hostState = newState;
|
|
_hostStateChangedUtc = DateTime.UtcNow;
|
|
}
|
|
|
|
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
|
|
}
|
|
|
|
// ---- authored tag table ----
|
|
|
|
/// <summary>
|
|
/// Maps every authored <see cref="RawTagEntry"/> through <see cref="SqlEquipmentTagParser.TryParse"/>
|
|
/// and publishes the result as one atomic snapshot. A blob that does not map is <b>logged and
|
|
/// skipped</b>, never thrown: one malformed tag must not take the whole driver — and therefore every
|
|
/// other tag on that database — down with it. The skipped RawPath then resolves to nothing, so it
|
|
/// publishes <see cref="SqlStatusCodes.BadNodeIdUnknown"/> rather than someone else's row.
|
|
/// </summary>
|
|
private void BuildTagTable()
|
|
{
|
|
var table = new Dictionary<string, SqlTagDefinition>(_options.RawTags.Count, StringComparer.Ordinal);
|
|
foreach (var entry in _options.RawTags)
|
|
{
|
|
try
|
|
{
|
|
if (SqlEquipmentTagParser.TryParse(entry.TagConfig, entry.RawPath, out var definition))
|
|
table[entry.RawPath] = definition;
|
|
else
|
|
_logger.LogWarning(
|
|
"Sql tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
|
|
_driverInstanceId, entry.RawPath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// TryParse is contracted never to throw, but BuildTagTable runs BEFORE InitializeAsync's
|
|
// try/catch, so a stray throw here (a parser bug on one blob) would escape Initialize and
|
|
// strand health at Initializing across every DriverInstanceActor retry. Skip the offending
|
|
// tag — the same "one bad tag must not take the whole driver down" rule the malformed-blob
|
|
// branch above follows — rather than fault the driver over a single entry.
|
|
_logger.LogError(
|
|
ex, "Sql tag config threw while parsing; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
|
|
_driverInstanceId, entry.RawPath);
|
|
}
|
|
}
|
|
|
|
Volatile.Write(ref _tagsByRawPath, table);
|
|
}
|
|
|
|
// ---- liveness ----
|
|
|
|
/// <summary>
|
|
/// Opens ONE connection, runs the dialect's liveness statement, and disposes it. No connection is
|
|
/// held between this check and the first poll — the reader opens, uses and disposes per poll, so a
|
|
/// long-lived connection here would only be a second thing to keep alive.
|
|
/// <para><b>Bounded by wall-clock, not only by the token</b> (the R2-01 / STAB-14 lesson): some ADO.NET
|
|
/// providers implement the async path synchronously, and a wedged socket can hang inside the
|
|
/// provider's own cancellation handshake. If Initialize hung there, <c>DriverInstanceActor</c>'s init
|
|
/// task would never complete and the driver would sit in Connecting forever with no retry. The work
|
|
/// runs on the thread pool so a synchronous provider blocks a pool thread rather than the caller, and
|
|
/// an abandoned attempt still owns and disposes its own connection.</para>
|
|
/// </summary>
|
|
/// <param name="cancellationToken">The caller's token.</param>
|
|
/// <returns>A task that completes when the database has answered.</returns>
|
|
private async Task VerifyLivenessAsync(CancellationToken cancellationToken)
|
|
{
|
|
var budget = _options.CommandTimeout;
|
|
var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
Task work;
|
|
try
|
|
{
|
|
deadline.CancelAfter(budget);
|
|
work = Task.Run(
|
|
async () =>
|
|
{
|
|
try { await PingAsync(deadline.Token).ConfigureAwait(false); }
|
|
finally { deadline.Dispose(); }
|
|
},
|
|
CancellationToken.None);
|
|
}
|
|
catch
|
|
{
|
|
// Ownership of the CTS transfers to the work task; release it only if that task never started.
|
|
deadline.Dispose();
|
|
throw;
|
|
}
|
|
|
|
try
|
|
{
|
|
await work.WaitAsync(budget, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (TimeoutException)
|
|
{
|
|
Detach(work);
|
|
throw new TimeoutException(
|
|
$"the liveness statement did not return within {(int)budget.TotalMilliseconds} ms.");
|
|
}
|
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
// Our deadline fired and the provider DID honour the linked token.
|
|
Detach(work);
|
|
throw new TimeoutException(
|
|
$"the liveness statement did not return within {(int)budget.TotalMilliseconds} ms.");
|
|
}
|
|
}
|
|
|
|
/// <summary>Opens a connection and executes the dialect's cheapest proof-of-life statement.</summary>
|
|
private async Task PingAsync(CancellationToken cancellationToken)
|
|
{
|
|
var connection = _factory.CreateConnection()
|
|
?? throw new InvalidOperationException(
|
|
$"the {_dialect.Provider} provider factory returned no connection.");
|
|
|
|
await using (connection.ConfigureAwait(false))
|
|
{
|
|
connection.ConnectionString = _connectionString;
|
|
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
var command = connection.CreateCommand();
|
|
await using (command.ConfigureAwait(false))
|
|
{
|
|
command.CommandText = _dialect.LivenessSql;
|
|
// ADO.NET reads CommandTimeout = 0 as "wait forever" — the one value it must never become.
|
|
command.CommandTimeout = Math.Max(1, (int)Math.Ceiling(_options.CommandTimeout.TotalSeconds));
|
|
await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Observes an abandoned liveness attempt's eventual fault so it never surfaces as an unobserved
|
|
/// task exception. The task still owns — and disposes — its own connection.
|
|
/// <para>TODO(M2): a verbatim duplicate of <c>SqlPollReader.Detach</c>; both live in Driver.Sql and
|
|
/// could hoist to one internal helper. Left in place here to keep this change off the reader.</para>
|
|
/// </summary>
|
|
private static void Detach(Task work)
|
|
=> _ = work.ContinueWith(
|
|
static t => _ = t.Exception,
|
|
CancellationToken.None,
|
|
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
|
TaskScheduler.Default);
|
|
|
|
// ---- endpoint description ----
|
|
|
|
/// <summary>
|
|
/// Renders a connection string as <c>server/database</c>, reading only those two keys.
|
|
/// <para>This is the <b>only</b> function permitted to look at the connection string outside the
|
|
/// provider call, and it exists so that no other code is ever tempted to log the string itself:
|
|
/// credentials live in <c>User ID</c> / <c>Password</c> / <c>Authentication</c>, which are never
|
|
/// read here. A string that cannot be parsed yields <see cref="UnknownEndpoint"/> — an unusable
|
|
/// description must not become a crash at construction.</para>
|
|
/// </summary>
|
|
/// <param name="connectionString">The resolved connection string.</param>
|
|
/// <returns>A credential-free description safe to log and display.</returns>
|
|
private static string DescribeEndpoint(string connectionString)
|
|
{
|
|
try
|
|
{
|
|
var builder = new DbConnectionStringBuilder { ConnectionString = connectionString };
|
|
var server = FirstValue(builder, ServerKeys);
|
|
if (string.IsNullOrWhiteSpace(server)) return UnknownEndpoint;
|
|
var database = FirstValue(builder, DatabaseKeys);
|
|
return string.IsNullOrWhiteSpace(database) ? server : $"{server}/{database}";
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
return UnknownEndpoint;
|
|
}
|
|
}
|
|
|
|
/// <summary>Reads the first of <paramref name="keys"/> the builder carries; null when it carries none.</summary>
|
|
private static string? FirstValue(DbConnectionStringBuilder builder, string[] keys)
|
|
{
|
|
foreach (var key in keys)
|
|
{
|
|
// DbConnectionStringBuilder's key comparison is case-insensitive.
|
|
if (builder.TryGetValue(key, out var value) && value is not null)
|
|
{
|
|
var text = value.ToString();
|
|
if (!string.IsNullOrWhiteSpace(text)) return text;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ---- teardown ----
|
|
|
|
/// <summary>
|
|
/// Performs the same teardown as <see cref="ShutdownAsync"/>, so a caller that only uses
|
|
/// <c>await using</c> does not leak the poll loops.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
public async ValueTask DisposeAsync() => await TeardownAsync().ConfigureAwait(false);
|
|
|
|
/// <summary>
|
|
/// Shared teardown: stops every polled subscription and drops the authored table. Idempotent — safe
|
|
/// to call any number of times, in any order, which is what makes
|
|
/// <c>ShutdownAsync</c>-then-<c>DisposeAsync</c> (and <see cref="ReinitializeAsync"/>) safe.
|
|
/// <para>There is no connection to close: the reader opens and disposes one per poll.</para>
|
|
/// </summary>
|
|
private async Task TeardownAsync()
|
|
{
|
|
await _poll.DisposeAsync().ConfigureAwait(false);
|
|
Volatile.Write(ref _tagsByRawPath, new Dictionary<string, SqlTagDefinition>(StringComparer.Ordinal));
|
|
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
|
|
}
|
|
}
|