fix(drivers): complete the Sql + FOCAS in-place config re-parse (#516)
The first #516 pass deliberately left these two out, because each derives more than options from config: Sql its ISqlDialect and resolved connection string, FOCAS the IFocasClientFactory its Backend key selects — all injected at construction. Adopting new options alone would poll a NEW tag set through the OLD database/backend, and a half-applied config change is worse than a discarded one because it looks like it worked. Rather than accept that exclusion, this fixes the blocker. Each factory now exposes a ParseBinding returning EVERY config-derived dependency as one value, and the driver adopts them atomically: - Sql: ApplyBinding takes (options, dialect, connectionString) and rebuilds everything downstream — provider factory, Endpoint, and the SqlPollReader that captures all three. It runs BEFORE BuildTagTable, so the tag table and the connection it is polled over always come from the same revision. A test-injected DbProviderFactory survives a rebind (_explicitFactory), so a re-derived dialect cannot displace what a test passed in. - FOCAS: options and backend move as a pair in InitializeAsync. Re-resolving Sql's connection string on reinit is a side benefit: a rotated credential is picked up without a process restart. Drivers constructed directly get no rebinder and keep their constructor binding, so every existing "{}"-passing lifecycle test is unaffected by construction rather than by luck. The tests pin ATOMICITY, not merely that a re-parse happened — a test checking only options would have passed against the broken version. Confirmed by simulating the half-fix (adopt options, skip the backend): 2 of 3 FOCAS tests go red. Reverting Sql's rebind turns its connection-string test red. Sql also asserts that a reinit which cannot resolve its new connection leaves the previous binding whole rather than half-adopting. All 12 drivers now honour a changed config in place. Sql.Tests 226, FOCAS.Tests 275 pass.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.Data.Common;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
@@ -51,16 +52,19 @@ public sealed class SqlDriver
|
||||
|
||||
// ---- instance fields (grouped at top for auditability) ----
|
||||
|
||||
private readonly SqlDriverOptions _options;
|
||||
/// <summary>Config-derived, so mutable — see <see cref="ApplyBinding"/>.</summary>
|
||||
private SqlDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly ISqlDialect _dialect;
|
||||
private readonly DbProviderFactory _factory;
|
||||
/// <summary>Config-derived, so mutable: <see cref="ApplyBinding"/> swaps it with the connection string
|
||||
/// and options together on a reinitialize (#516).</summary>
|
||||
private ISqlDialect _dialect;
|
||||
private 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 string _connectionString;
|
||||
|
||||
private readonly ILogger<SqlDriver> _logger;
|
||||
|
||||
@@ -93,11 +97,19 @@ public sealed class SqlDriver
|
||||
/// <summary>Resolves a read/subscribe RawPath to its <b>catalog-validated</b> definition, or a miss.</summary>
|
||||
private readonly EquipmentTagRefResolver<SqlTagDefinition> _resolver;
|
||||
|
||||
/// <summary>The factory the CALLER passed, or null to take the dialect's. Kept separate from
|
||||
/// <see cref="_factory"/> so a re-derived dialect cannot silently displace a test's injected factory.</summary>
|
||||
private readonly DbProviderFactory? _explicitFactory;
|
||||
|
||||
/// <summary>Re-parses a <c>DriverConfig</c> into every config-derived dependency. Null when the driver
|
||||
/// was constructed directly, which keeps the constructor-supplied trio authoritative.</summary>
|
||||
private readonly Func<string, SqlDriverBinding>? _rebind;
|
||||
|
||||
/// <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;
|
||||
private SqlPollReader _reader;
|
||||
|
||||
/// <summary>Polled subscriptions. The driver supplies the reader + change bridge; the engine owns the loop.</summary>
|
||||
private readonly PollGroupEngine _poll;
|
||||
@@ -133,6 +145,11 @@ public sealed class SqlDriver
|
||||
/// explicitly by tests that need to observe or delay connection creation.
|
||||
/// </param>
|
||||
/// <param name="logger">Optional; defaults to a no-op logger.</param>
|
||||
/// <param name="rebind">
|
||||
/// Optional re-parse of a <c>DriverConfig</c> into every config-derived dependency, supplied by the
|
||||
/// factory so <see cref="InitializeAsync"/> can adopt a changed config. Null (direct construction —
|
||||
/// every test) keeps the constructor-supplied options, dialect and connection string.
|
||||
/// </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(
|
||||
@@ -141,7 +158,8 @@ public sealed class SqlDriver
|
||||
ISqlDialect dialect,
|
||||
string connectionString,
|
||||
DbProviderFactory? factory = null,
|
||||
ILogger<SqlDriver>? logger = null)
|
||||
ILogger<SqlDriver>? logger = null,
|
||||
Func<string, SqlDriverBinding>? rebind = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(dialect);
|
||||
@@ -149,15 +167,47 @@ public sealed class SqlDriver
|
||||
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);
|
||||
|
||||
_explicitFactory = factory;
|
||||
_rebind = rebind;
|
||||
_resolver = new EquipmentTagRefResolver<SqlTagDefinition>(Lookup);
|
||||
|
||||
// Everything below this line is derived from config and is re-derived wholesale on a reinitialize.
|
||||
ApplyBinding(options, dialect, connectionString);
|
||||
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
|
||||
onError: HandlePollError,
|
||||
backoffCap: PollBackoffCap);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adopts a config-derived trio — options, dialect, connection string — and rebuilds everything
|
||||
/// downstream of it (the provider factory, the endpoint description, and the poll reader, which
|
||||
/// captures all three). Called from the constructor and again from <see cref="InitializeAsync"/>
|
||||
/// when a changed config arrives.
|
||||
/// <para><b>All-or-nothing on purpose.</b> The reason Sql was excluded from the first #516 pass is
|
||||
/// that adopting options alone would poll a NEW tag set through the OLD connection and dialect. Doing
|
||||
/// it in one method means a future field derived from config gets added here rather than being
|
||||
/// forgotten on the reinit path.</para>
|
||||
/// <para>Not thread-safe by design: only the init path calls it, before any poll group is running.
|
||||
/// The poll engine and the tag resolver survive across a rebind — the engine holds a delegate to
|
||||
/// <c>ReadAsync</c> and the resolver reads the live tag table, so neither captures the old trio.</para>
|
||||
/// </summary>
|
||||
/// <param name="options">The typed options to adopt.</param>
|
||||
/// <param name="dialect">The provider seam to adopt.</param>
|
||||
/// <param name="connectionString">The resolved connection string to adopt. Never logged.</param>
|
||||
[MemberNotNull(nameof(_options), nameof(_dialect), nameof(_factory), nameof(_connectionString), nameof(_reader))]
|
||||
private void ApplyBinding(SqlDriverOptions options, ISqlDialect dialect, string connectionString)
|
||||
{
|
||||
_options = options;
|
||||
_dialect = dialect;
|
||||
_factory = _explicitFactory ?? dialect.Factory;
|
||||
_connectionString = connectionString;
|
||||
Endpoint = DescribeEndpoint(connectionString);
|
||||
_reader = new SqlPollReader(
|
||||
_factory,
|
||||
connectionString,
|
||||
@@ -168,12 +218,16 @@ public sealed class SqlDriver
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always passes
|
||||
/// a populated document; some unit tests pass <c>"{}"</c> or an empty string to exercise lifecycle shape
|
||||
/// without a config — those keep the constructor-supplied trio.</summary>
|
||||
private static bool HasConfigBody(string? driverConfigJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
|
||||
var trimmed = driverConfigJson.Trim();
|
||||
return trimmed is not "{}" and not "[]";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -187,7 +241,7 @@ public sealed class SqlDriver
|
||||
/// 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; }
|
||||
public string Endpoint { get; private set; } = "";
|
||||
|
||||
/// <summary>Host identifier surfaced through <see cref="IHostConnectivityProbe"/> — the endpoint description.</summary>
|
||||
public string HostName => Endpoint;
|
||||
@@ -215,17 +269,20 @@ public sealed class SqlDriver
|
||||
|
||||
/// <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. That premise — "config parsing
|
||||
/// belongs to the factory, which builds a fresh instance" — was <b>false when written</b>
|
||||
/// (Gitea #516): the host reinitialized the EXISTING child in place, so every config edit was
|
||||
/// silently discarded and the deployment still sealed green. It is true <i>now</i>, because
|
||||
/// <c>DriverSpawnPlanner</c> routes a changed <c>DriverConfig</c> through a stop + respawn.</para>
|
||||
/// <para>This driver deliberately does <b>not</b> also re-parse in place, unlike Modbus / AbLegacy /
|
||||
/// OpcUaClient. It builds more than options from config — the <see cref="ISqlDialect"/> and the
|
||||
/// resolved connection string are both injected at construction — so adopting a new
|
||||
/// <see cref="SqlDriverOptions"/> alone would run a NEW tag set against the OLD connection. Half a
|
||||
/// re-parse is worse than none; the respawn rebuilds all three together.</para>
|
||||
/// <para><paramref name="driverConfigJson"/> <b>is</b> re-parsed here when the driver was built by
|
||||
/// the factory (Gitea #516). The original comment on this method claimed it was not, on the premise
|
||||
/// that "config parsing belongs to the factory, which builds a fresh instance" — that was false when
|
||||
/// written, because the host reinitialized the EXISTING child in place and every config edit was
|
||||
/// silently discarded while the deployment still sealed green.</para>
|
||||
/// <para>The re-parse is <b>all-or-nothing</b> via <see cref="ApplyBinding"/>: options, the
|
||||
/// <see cref="ISqlDialect"/> and the resolved connection string are re-derived by one
|
||||
/// <c>ParseBinding</c> call and adopted together. That is what makes it safe here — adopting options
|
||||
/// alone would poll a NEW tag set through the OLD database, which is why this driver was excluded
|
||||
/// from the first #516 pass. It happens BEFORE <c>BuildTagTable</c>, so the tag table and the
|
||||
/// connection it is polled over always come from the same revision.</para>
|
||||
/// <para>A driver constructed directly (every unit test) gets no rebinder and keeps its
|
||||
/// constructor-supplied trio, so <c>"{}"</c>-passing lifecycle tests are unaffected.
|
||||
/// <c>DriverSpawnPlanner</c>'s stop + respawn remains the outer guarantee.</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
|
||||
@@ -243,6 +300,18 @@ public sealed class SqlDriver
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
|
||||
|
||||
// #516: adopt a changed config BEFORE the tag table is built, so the table and the connection it
|
||||
// will be polled over come from the same revision. ApplyBinding takes the options, dialect and
|
||||
// connection string as one unit; adopting options alone would poll a NEW tag set through the OLD
|
||||
// database, which is why this driver was excluded from the first pass. A null rebinder (direct
|
||||
// construction) or an empty/placeholder document keeps the constructor-supplied trio.
|
||||
if (_rebind is not null && HasConfigBody(driverConfigJson))
|
||||
{
|
||||
var binding = _rebind(driverConfigJson);
|
||||
ApplyBinding(binding.Options, binding.Dialect, binding.ConnectionString);
|
||||
}
|
||||
|
||||
BuildTagTable();
|
||||
|
||||
try
|
||||
|
||||
Reference in New Issue
Block a user