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:
Joseph Doherty
2026-07-28 00:30:52 -04:00
parent 88ce8df099
commit 5184a2e107
7 changed files with 467 additions and 62 deletions
@@ -21,9 +21,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
{
private readonly FocasDriverOptions _options;
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config (#516). Written only
/// on the init path, together with <see cref="_clientFactory"/>, before either is used.</summary>
private FocasDriverOptions _options;
private readonly string _driverInstanceId;
private readonly IFocasClientFactory _clientFactory;
/// <summary>Mutable for the same reason as <see cref="_options"/> — the <c>Backend</c> config key
/// selects it, so the two must move together or a new tag set polls through an old backend.</summary>
private IFocasClientFactory _clientFactory;
/// <summary>Re-parses a <c>DriverConfig</c> into every config-derived dependency. Null when the driver
/// was constructed directly (tests, or a caller supplying its own backend), which keeps the
/// constructor-supplied options authoritative — exactly the prior behaviour.</summary>
private readonly Func<string, FocasDriverBinding>? _rebind;
private readonly PollGroupEngine _poll;
private readonly ILogger<FocasDriver> _logger;
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
@@ -58,14 +67,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
/// <param name="clientFactory">Optional factory for creating FOCAS client instances.</param>
/// <param name="logger">Optional logger instance.</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, e.g. a test or a caller supplying its own backend) keeps the constructor-supplied
/// options + backend.</param>
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
IFocasClientFactory? clientFactory = null,
ILogger<FocasDriver>? logger = null)
ILogger<FocasDriver>? logger = null,
Func<string, FocasDriverBinding>? rebind = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
_rebind = rebind;
_logger = logger ?? NullLogger<FocasDriver>.Instance;
_resolver = new EquipmentTagRefResolver<FocasTagDefinition>(
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
@@ -77,6 +92,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
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 options + backend.</summary>
private static bool HasConfigBody(string? driverConfigJson)
{
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
var trimmed = driverConfigJson.Trim();
return trimmed is not "{}" and not "[]";
}
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
@@ -102,13 +127,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary>
/// Opens the configured CNC handles and builds the authored tag table.
/// <para><paramref name="driverConfigJson"/> is deliberately <b>not</b> re-parsed here, unlike
/// Modbus / AbLegacy / OpcUaClient (Gitea #516). This driver builds more than options from config —
/// its <c>IFocasClientFactory</c> is selected from the <c>Backend</c> key and injected at
/// construction — so adopting a new <c>FocasDriverOptions</c> alone would run a NEW device/tag set
/// against the OLD backend. Half a re-parse is worse than none.</para>
/// <para>A config change is covered by the stop + respawn in <c>DriverSpawnPlanner</c>, which
/// rebuilds options and backend together through the factory.</para>
/// <para><paramref name="driverConfigJson"/> <b>is</b> re-parsed here when the driver was built by
/// the factory (Gitea #516), and the re-parse is <b>all-or-nothing</b>: the typed options and the
/// <c>IFocasClientFactory</c> the <c>Backend</c> key selects come from one <c>ParseBinding</c> call
/// and are adopted together. That pairing is the point — adopting options alone would poll a NEW
/// device/tag set through the OLD backend, which is why this driver was excluded from the first
/// #516 pass.</para>
/// <para>A driver constructed directly (every unit test, or a caller supplying its own backend) gets
/// no rebinder and keeps its constructor-supplied pair. <c>DriverSpawnPlanner</c>'s stop + respawn
/// remains the outer guarantee.</para>
/// </summary>
/// <param name="driverConfigJson">The driver configuration JSON (see remarks — not re-parsed).</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
@@ -118,6 +145,17 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null));
try
{
// #516: adopt a changed config. Options and the backend client factory are re-derived by ONE
// parse and assigned together — a partial adoption would poll a new device/tag set through the
// old backend. A null rebinder (direct construction) or an empty/placeholder document keeps the
// constructor-supplied pair, which is what every lifecycle test passing "{}" relies on.
if (_rebind is not null && HasConfigBody(driverConfigJson))
{
var binding = _rebind(driverConfigJson);
_options = binding.Options;
_clientFactory = binding.ClientFactory;
}
// Fail fast if the factory is a stub/unimplemented backend — the operator must
// see an actionable error at init rather than a phantom-Healthy driver that fails
// every read/write/subscribe silently.
@@ -47,6 +47,29 @@ public static class FocasDriverFactoryExtensions
/// <param name="driverConfigJson">The driver configuration JSON string.</param>
/// <returns>A configured <see cref="FocasDriver"/> instance.</returns>
internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson)
{
var binding = ParseBinding(driverInstanceId, driverConfigJson);
return new FocasDriver(
binding.Options,
driverInstanceId,
binding.ClientFactory,
// Hand the driver the SAME parse it was built with, so InitializeAsync can re-derive EVERY
// config-derived dependency on a reinitialize — options AND the backend client factory
// together (#516). Re-parsing options alone would run a new device/tag set against the old
// backend, which is worse than not re-parsing at all.
rebind: json => ParseBinding(driverInstanceId, json));
}
/// <summary>
/// Parses a FOCAS <c>DriverConfig</c> JSON document into <b>every</b> dependency the driver derives
/// from config: the typed options and the <see cref="IFocasClientFactory"/> the <c>Backend</c> key
/// selects. Returned together because they must be adopted together — see the <c>rebind</c> note in
/// <see cref="CreateInstance"/>.
/// </summary>
/// <param name="driverInstanceId">The unique driver instance identifier.</param>
/// <param name="driverConfigJson">The driver configuration JSON string.</param>
/// <returns>The parsed options + backend factory.</returns>
internal static FocasDriverBinding ParseBinding(string driverInstanceId, string driverConfigJson)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
@@ -86,8 +109,7 @@ public static class FocasDriverFactoryExtensions
HandleRecycle = BuildHandleRecycle(dto.HandleRecycle),
};
var clientFactory = BuildClientFactory(dto, driverInstanceId);
return new FocasDriver(options, driverInstanceId, clientFactory);
return new FocasDriverBinding(options, BuildClientFactory(dto, driverInstanceId));
}
/// <summary>
@@ -327,3 +349,14 @@ public static class FocasDriverFactoryExtensions
public TimeSpan? Interval { get; init; }
}
}
/// <summary>
/// Everything <c>FocasDriver</c> derives from its <c>DriverConfig</c> JSON, returned as one value so a
/// reinitialize adopts all of it atomically.
/// <para>This exists because a partial adoption is a real hazard, not a theoretical one: the driver's
/// <see cref="IFocasClientFactory"/> is chosen by the config's <c>Backend</c> key, so re-parsing
/// <see cref="Options"/> alone would poll a NEW device/tag set through the OLD backend (Gitea #516).</para>
/// </summary>
/// <param name="Options">The typed driver options.</param>
/// <param name="ClientFactory">The backend client factory the <c>Backend</c> key selects.</param>
public sealed record FocasDriverBinding(FocasDriverOptions Options, IFocasClientFactory ClientFactory);