fix(drivers): stop silently discarding driver config edits (§8.3, #516)
Five drivers ignored the config handed to them on reinitialize, serving the
options their constructor captured — and the deployment sealed green anyway.
Fixed on both halves, as chosen.
Seam (load-bearing): DriverSpawnPlanner routes a changed DriverConfig to
ToStop + ToSpawn instead of an in-place delta, making the factory the single
parse authority. This REVERSES the deliberate decision documented at
DriverSpawnPlan.cs:49-50 ("a pure DriverConfig change stays an in-place delta
— no reconnect"). That reasoning was correct about the resilience pipeline and
wrong about the driver. The accepted price is a reconnect per config edit.
Per-driver, and this split was not anticipated:
- Re-parse in place — Modbus, AbLegacy, OpcUaClient. ParseOptions extracted
from each factory, called from InitializeAsync behind a HasConfigBody guard
so "{}" still keeps the constructor options.
- Respawn-only, deliberately NOT re-parsed — Sql and FOCAS. Each builds more
than options from config (Sql's dialect + resolved connection string,
FOCAS's client-factory backend, both injected at construction), so adopting
new options alone would run a NEW tag set against an OLD connection. Half a
re-parse is worse than none. SqlDriver's doc-comment asserted the opposite
premise — "config parsing belongs to the factory, which builds a fresh
instance" — which was false when written and is true only now.
Two green seals removed from ApplyChildDelta: it overwrote the cached Spec
synchronously BEFORE the child dequeued the message, so the host believed the
new config was live and the next reconcile computed no delta, sealing the
drift permanently; and it Tell'd with no Receive<ApplyResult> registered, so a
failed reinit — including Galaxy's deliberate NotSupportedException —
dead-lettered.
Exposed (not created) by the change: a factory throw is a CONFIG error, and
SpawnChild catches it and silently substitutes a stub. Only a brand-new driver
could reach that before; an ordinary config edit can now. Raised Warning ->
Error with an actionable message. It still does not fail the deployment —
doing so would let one malformed driver block a fleet deploy, so that is a
follow-up rather than a drive-by.
Tests: every pre-existing reinit test in these suites passes "{}", the exact
input a guarded re-parser treats as "keep constructor options" — blind to this
defect by construction. New tests pass CHANGED json; the Modbus one was
verified falsifiable by deleting the re-parse (goes red). Two tests asserted
the old behaviour and were rewritten, including the positive control in
DriverHostActorUnreadableArtifactTests, whose observable moved from
UnsubscribeAsync to ShutdownAsync now that teardown happens by stopping the
child rather than emptying its desired set.
Remaining full-suite failures are pre-existing and environmental, verified
identical on the pre-change tree: Host.IntegrationTests (3) and
Driver.AbLegacy.IntegrationTests (4, docker fixture-gated).
This commit is contained in:
@@ -13,7 +13,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly AbLegacyDriverOptions _options;
|
||||
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize
|
||||
/// (#516). Only ever written on the init path, before any reader/session uses it.</summary>
|
||||
private AbLegacyDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly IAbLegacyTagFactory _tagFactory;
|
||||
private readonly ILogger<AbLegacyDriver> _logger;
|
||||
@@ -94,12 +96,28 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "AbLegacy";
|
||||
|
||||
/// <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.</summary>
|
||||
private static bool HasConfigBody(string? driverConfigJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
|
||||
var trimmed = driverConfigJson.Trim();
|
||||
return trimmed is not "{}" and not "[]";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
// #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
|
||||
// contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED
|
||||
// with, so an operator's edit is discarded while the deployment still seals green.
|
||||
if (HasConfigBody(driverConfigJson))
|
||||
_options = AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
|
||||
|
||||
foreach (var device in _options.Devices)
|
||||
{
|
||||
var addr = AbLegacyHostAddress.TryParse(device.HostAddress)
|
||||
|
||||
@@ -49,6 +49,23 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
/// <param name="loggerFactory">Optional logger factory for the driver instance.</param>
|
||||
/// <returns>A configured <see cref="AbLegacyDriver"/> instance.</returns>
|
||||
internal static AbLegacyDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
||||
{
|
||||
return new AbLegacyDriver(
|
||||
ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId,
|
||||
tagFactory: null,
|
||||
logger: loggerFactory?.CreateLogger<AbLegacyDriver>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an AB Legacy <c>DriverConfig</c> JSON document into typed options. Extracted so
|
||||
/// <see cref="AbLegacyDriver.InitializeAsync"/> can re-parse a CHANGED config on reinitialize
|
||||
/// (Gitea #516) instead of serving the options it was constructed with — which silently discarded
|
||||
/// every operator edit while the deployment still sealed green.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The unique driver instance identifier.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
|
||||
/// <returns>The parsed <see cref="AbLegacyDriverOptions"/>.</returns>
|
||||
internal static AbLegacyDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
@@ -82,10 +99,7 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
|
||||
};
|
||||
|
||||
return new AbLegacyDriver(
|
||||
options, driverInstanceId,
|
||||
tagFactory: null,
|
||||
logger: loggerFactory?.CreateLogger<AbLegacyDriver>());
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -100,7 +100,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "FOCAS";
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <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>
|
||||
/// </summary>
|
||||
/// <param name="driverConfigJson">The driver configuration JSON (see remarks — not re-parsed).</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null));
|
||||
|
||||
@@ -23,7 +23,9 @@ public sealed class ModbusDriver
|
||||
{
|
||||
// ---- instance fields (grouped at top for auditability) ----
|
||||
|
||||
private readonly ModbusDriverOptions _options;
|
||||
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize
|
||||
/// (#516). Only ever written on the init path, before any reader/transport uses it.</summary>
|
||||
private ModbusDriverOptions _options;
|
||||
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly ILogger<ModbusDriver> _logger;
|
||||
@@ -188,12 +190,29 @@ public sealed class ModbusDriver
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "Modbus";
|
||||
|
||||
/// <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.</summary>
|
||||
private static bool HasConfigBody(string? driverConfigJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
|
||||
var trimmed = driverConfigJson.Trim();
|
||||
return trimmed is not "{}" and not "[]";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
WriteHealth(new DriverHealth(DriverState.Initializing, null, null));
|
||||
try
|
||||
{
|
||||
// #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
|
||||
// contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED
|
||||
// with, so an operator's edit is discarded while the deployment still seals green. An empty /
|
||||
// placeholder document (the "{}" some unit tests pass) keeps the constructor-supplied options.
|
||||
if (HasConfigBody(driverConfigJson))
|
||||
_options = ModbusDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
|
||||
|
||||
_transport = _transportFactory(_options);
|
||||
await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
|
||||
|
||||
@@ -44,6 +44,23 @@ public static class ModbusDriverFactoryExtensions
|
||||
/// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
|
||||
/// <returns>The constructed <see cref="ModbusDriver"/> instance.</returns>
|
||||
public static ModbusDriver CreateInstance(string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
||||
{
|
||||
return new ModbusDriver(
|
||||
ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId,
|
||||
transportFactory: null,
|
||||
logger: loggerFactory?.CreateLogger<ModbusDriver>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a Modbus <c>DriverConfig</c> JSON document into typed options. Extracted from
|
||||
/// <see cref="CreateInstance(string,string,ILoggerFactory?)"/> so <see cref="ModbusDriver.InitializeAsync"/>
|
||||
/// can re-parse a CHANGED config on reinitialize (Gitea #516) rather than serving the options it was
|
||||
/// constructed with — which silently discarded every edit while the deployment still sealed green.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||
/// <param name="driverConfigJson">The JSON configuration string for the driver.</param>
|
||||
/// <returns>The parsed <see cref="ModbusDriverOptions"/>.</returns>
|
||||
internal static ModbusDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
@@ -106,10 +123,7 @@ public static class ModbusDriverFactoryExtensions
|
||||
},
|
||||
};
|
||||
|
||||
return new ModbusDriver(
|
||||
options, driverInstanceId,
|
||||
transportFactory: null,
|
||||
logger: loggerFactory?.CreateLogger<ModbusDriver>());
|
||||
return options;
|
||||
}
|
||||
|
||||
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field) where T : struct, Enum
|
||||
|
||||
@@ -54,7 +54,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||
}
|
||||
|
||||
private readonly OpcUaClientDriverOptions _options;
|
||||
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize
|
||||
/// (#516). Only ever written on the init path, before any reader/session uses it.</summary>
|
||||
private OpcUaClientDriverOptions _options;
|
||||
private readonly ISecretResolver _secretResolver;
|
||||
private readonly string _driverInstanceId;
|
||||
// ---- IAlarmSource state ----
|
||||
@@ -156,12 +158,29 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "OpcUaClient";
|
||||
|
||||
/// <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.</summary>
|
||||
private static bool HasConfigBody(string? driverConfigJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
|
||||
var trimmed = driverConfigJson.Trim();
|
||||
return trimmed is not "{}" and not "[]";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
// #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
|
||||
// contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED
|
||||
// with — the endpoint, security policy and tag set were all frozen at construction, and only
|
||||
// secret rotation was picked up.
|
||||
if (HasConfigBody(driverConfigJson))
|
||||
_options = OpcUaClientDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
|
||||
|
||||
// Enforce the Equipment-vs-SystemPlatform choice at startup per driver-specs.md
|
||||
// §8 "Namespace Assignment" — a misconfigured remote fails draft validation here,
|
||||
// not as a runtime surprise.
|
||||
|
||||
+20
-5
@@ -63,16 +63,31 @@ public static class OpcUaClientDriverFactoryExtensions
|
||||
public static OpcUaClientDriver CreateInstance(
|
||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory = null,
|
||||
ISecretResolver? secretResolver = null)
|
||||
{
|
||||
return new OpcUaClientDriver(
|
||||
ParseOptions(driverInstanceId, driverConfigJson), driverInstanceId,
|
||||
loggerFactory?.CreateLogger<OpcUaClientDriver>(),
|
||||
secretResolver ?? NullSecretResolver.Instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an OpcUaClient <c>DriverConfig</c> JSON document into typed options. Extracted so
|
||||
/// <see cref="OpcUaClientDriver.InitializeAsync"/> can re-parse a CHANGED config on reinitialize
|
||||
/// (Gitea #516) instead of serving the options it was constructed with. Note the driver's own
|
||||
/// doc-comment previously claimed "resolving on every InitializeAsync picks up rotations" — that was
|
||||
/// true of SECRET rotation only; the endpoint, security policy and tag set were all frozen at
|
||||
/// construction.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The unique driver instance identifier.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
|
||||
/// <returns>The parsed <see cref="OpcUaClientDriverOptions"/>.</returns>
|
||||
internal static OpcUaClientDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
var options = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(driverConfigJson, JsonOptions)
|
||||
return JsonSerializer.Deserialize<OpcUaClientDriverOptions>(driverConfigJson, JsonOptions)
|
||||
?? throw new InvalidOperationException(
|
||||
$"OpcUaClient driver config for '{driverInstanceId}' deserialised to null");
|
||||
|
||||
return new OpcUaClientDriver(
|
||||
options, driverInstanceId, loggerFactory?.CreateLogger<OpcUaClientDriver>(),
|
||||
secretResolver ?? NullSecretResolver.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,8 +216,16 @@ 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, exactly as <c>ModbusDriver</c> does
|
||||
/// (config parsing belongs to the factory, which builds a fresh instance).</para>
|
||||
/// 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>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
|
||||
|
||||
@@ -244,6 +244,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// value maps so stale condition state never leaks across redeploys.</summary>
|
||||
private readonly NativeAlarmProjector _nativeAlarmProjector = new();
|
||||
|
||||
/// <summary>In-flight <see cref="DriverInstanceActor.ApplyDelta"/> sends, keyed by correlation, so
|
||||
/// <see cref="HandleApplyResult"/> can advance the cached spec only once the child confirms it adopted
|
||||
/// the config (#516). Bounded by the number of driver children with a delta in flight.</summary>
|
||||
private readonly Dictionary<CorrelationId, DriverInstanceSpec> _pendingDelta = new();
|
||||
|
||||
/// <summary>The composition from the most-recent apply (set at the END of
|
||||
/// <see cref="PushDesiredSubscriptions"/>). Null until the first apply.</summary>
|
||||
private AddressSpaceComposition? _lastComposition;
|
||||
@@ -868,6 +873,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
|
||||
Receive<RestartDriver>(HandleRestartDriver);
|
||||
Receive<ReconnectDriver>(HandleReconnectDriver);
|
||||
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
||||
@@ -901,6 +907,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
|
||||
Receive<RestartDriver>(HandleRestartDriver);
|
||||
Receive<ReconnectDriver>(HandleReconnectDriver);
|
||||
Receive<RouteNodeWrite>(HandleRouteNodeWrite);
|
||||
@@ -1353,6 +1360,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
// A late DeltaApplied (an apply completed just before the DB went Stale) — re-register the driver's
|
||||
// mux adapter anyway; it simply re-reads the driver's current refs (harmless, no DB access).
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
|
||||
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
|
||||
Timers.StartPeriodicTimer("retry-db", RetryConfigDbConnection.Instance, ReconnectInterval);
|
||||
}
|
||||
@@ -2007,7 +2015,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
try { driver = _driverFactory.TryCreate(spec.DriverType, spec.DriverInstanceId, spec.DriverConfig); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "DriverHost {Node}: factory for {Type} threw on {Id}; stubbing",
|
||||
// A factory throw is a CONFIG error, not a connectivity one — TryCreate is pure parsing;
|
||||
// device I/O happens later in InitializeAsync. Logged at Error because the node silently
|
||||
// degrades to a stub that answers nothing, and since #516 routed DriverConfig changes
|
||||
// through a respawn this path is now reachable by an ordinary operator edit rather than
|
||||
// only by a brand-new driver. It still does NOT fail the deployment — making it do so is
|
||||
// a deliberate follow-up, since it would let one malformed driver block a fleet deploy.
|
||||
_log.Error(ex,
|
||||
"DriverHost {Node}: factory for {Type} REJECTED the config for {Id} — the driver is " +
|
||||
"stubbed and will serve nothing until the config is fixed and redeployed",
|
||||
_localNode, spec.DriverType, spec.DriverInstanceId);
|
||||
}
|
||||
if (driver is null)
|
||||
@@ -2087,15 +2103,54 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
Context.Stop(adapter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an in-place config delta to a running child.
|
||||
/// <para><b>Unreachable from the reconcile path since #516</b> — a DriverConfig change is now a
|
||||
/// stop + respawn (<see cref="DriverSpawnPlanner"/>), so <c>ToApplyDelta</c> is always empty.
|
||||
/// Kept because the seam is still exercised by <c>DriverInstanceActor</c>'s own mid-connect config
|
||||
/// adoption.</para>
|
||||
/// <para><b>Two seals were removed here.</b> This used to overwrite the cached
|
||||
/// <c>Spec</c> SYNCHRONOUSLY, before the child had even dequeued the message — so the host
|
||||
/// immediately believed the new config was live, the NEXT reconcile computed no delta against it,
|
||||
/// and any drift was sealed permanently. It also <c>Tell</c>d with no <c>Receive<ApplyResult></c>
|
||||
/// handler registered, so a FAILED reinit — including Galaxy's deliberate
|
||||
/// <c>NotSupportedException</c> — dead-lettered and was never surfaced.</para>
|
||||
/// </summary>
|
||||
private void ApplyChildDelta(DriverInstanceSpec spec)
|
||||
{
|
||||
if (!_children.TryGetValue(spec.DriverInstanceId, out var entry)) return;
|
||||
entry.Actor.Tell(new DriverInstanceActor.ApplyDelta(spec.DriverConfig, CorrelationId.NewId()));
|
||||
// Store the full new spec — a delta can change Name, Enabled, ClusterId, etc. in addition to config.
|
||||
_children[spec.DriverInstanceId] = entry with { Spec = spec };
|
||||
var correlation = CorrelationId.NewId();
|
||||
_pendingDelta[correlation] = spec;
|
||||
entry.Actor.Tell(new DriverInstanceActor.ApplyDelta(spec.DriverConfig, correlation), Self);
|
||||
_log.Debug("DriverHost {Node}: ApplyDelta queued for {Id}", _localNode, spec.DriverInstanceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A child's reply to <see cref="ApplyChildDelta"/>. On success the cached <c>Spec</c> is advanced —
|
||||
/// only now, when the child has actually adopted the config, so a failed reinit leaves the host
|
||||
/// believing the OLD config is live and the next reconcile re-attempts the change instead of
|
||||
/// silently treating the drift as applied. A failure is logged at Error rather than swallowed.
|
||||
/// </summary>
|
||||
private void HandleApplyResult(DriverInstanceActor.ApplyResult msg)
|
||||
{
|
||||
if (msg.Success)
|
||||
{
|
||||
if (_pendingDelta.Remove(msg.Correlation, out var spec)
|
||||
&& _children.TryGetValue(spec.DriverInstanceId, out var entry))
|
||||
{
|
||||
// A delta can change Name, Enabled, ClusterId etc. alongside the config.
|
||||
_children[spec.DriverInstanceId] = entry with { Spec = spec };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingDelta.Remove(msg.Correlation, out var failed);
|
||||
_log.Error(
|
||||
"DriverHost {Node}: driver {Id} REJECTED an in-place config change ({Reason}) — the host keeps " +
|
||||
"the previous config so the next reconcile re-attempts it",
|
||||
_localNode, failed?.DriverInstanceId ?? "<unknown>", msg.Reason ?? "no reason given");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A driver child finished applying an in-place <see cref="DriverInstanceActor.ApplyDelta"/> (its
|
||||
/// <see cref="IDriver.ReinitializeAsync"/> completed). Re-register THAT driver's dependency-consumer
|
||||
|
||||
@@ -7,7 +7,12 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
/// spawn / ApplyDelta / stop on its child actors accordingly.
|
||||
/// </summary>
|
||||
/// <param name="ToSpawn">Specs with no current child — create a new actor.</param>
|
||||
/// <param name="ToApplyDelta">Specs whose child exists but config JSON or type differs.</param>
|
||||
/// <param name="ToApplyDelta">
|
||||
/// In-place config deltas. <b>Always empty since #516</b> — a DriverConfig change is now a
|
||||
/// stop + respawn so the factory is the single parse authority. Retained because
|
||||
/// <c>DriverInstanceActor</c> still handles <c>ApplyDelta</c> on its own config-adoption path
|
||||
/// (a config arriving mid-connect), and removing the list would hide that seam.
|
||||
/// </param>
|
||||
/// <param name="ToStop">DriverInstanceIds currently running but missing from the new artifact, or now disabled.</param>
|
||||
public sealed record DriverSpawnPlan(
|
||||
IReadOnlyList<DriverInstanceSpec> ToSpawn,
|
||||
@@ -42,23 +47,33 @@ public static class DriverSpawnPlanner
|
||||
toStop.Add(id);
|
||||
continue;
|
||||
}
|
||||
// A driver TYPE change can't be reinitialized in-place (factory-bound) — stop + respawn.
|
||||
// A RESILIENCE-CONFIG change likewise forces a respawn: the CapabilityInvoker (and its
|
||||
// resolved options) is bound to the child actor at spawn time, so the only way a changed
|
||||
// ResilienceConfig takes effect is to rebuild the child. The factory invalidates the stale
|
||||
// cached pipelines on the respawn's Create call. (A pure DriverConfig change stays an
|
||||
// in-place delta — no reconnect — because it doesn't touch the resilience pipeline.)
|
||||
// ANY of the three config surfaces changing forces a stop + respawn, so the FACTORY is the
|
||||
// single parse authority for everything a driver is built from.
|
||||
//
|
||||
// • DriverType — factory-bound, can't be reinitialized in place.
|
||||
// • ResilienceConfig — the CapabilityInvoker (and its resolved options) binds to the child
|
||||
// actor at spawn time; the factory invalidates the stale cached pipelines on respawn.
|
||||
// • DriverConfig — was an in-place ApplyDelta until #516. It is now a respawn.
|
||||
//
|
||||
// #516: the in-place delta silently DISCARDED the edit on five drivers (Modbus, FOCAS,
|
||||
// OpcUaClient, AbLegacy, Sql), whose InitializeAsync served the options captured by their
|
||||
// constructor and never looked at the driverConfigJson they were handed. The deployment still
|
||||
// sealed green. Those five now re-parse (belt), and this respawn is the braces: several
|
||||
// drivers build MORE than options from config — Sql derives its dialect + connection string
|
||||
// and FOCAS its client-factory backend, both injected at construction — so an in-place
|
||||
// re-parse of options ALONE would apply a new tag set against an old connection. Only a
|
||||
// respawn rebuilds all of it.
|
||||
//
|
||||
// The accepted cost is a reconnect on every DriverConfig edit, replacing the prior
|
||||
// no-reconnect in-place path. That is deliberate: a correct reconnect beats a silent no-op.
|
||||
if (!string.Equals(snap.DriverType, spec.DriverType, StringComparison.Ordinal)
|
||||
|| !string.Equals(snap.ResilienceConfig, spec.ResilienceConfig, StringComparison.Ordinal))
|
||||
|| !string.Equals(snap.ResilienceConfig, spec.ResilienceConfig, StringComparison.Ordinal)
|
||||
|| !string.Equals(snap.LastConfigJson, spec.DriverConfig, StringComparison.Ordinal))
|
||||
{
|
||||
toStop.Add(id);
|
||||
toSpawn.Add(spec);
|
||||
continue;
|
||||
}
|
||||
if (!string.Equals(snap.LastConfigJson, spec.DriverConfig, StringComparison.Ordinal))
|
||||
{
|
||||
toDelta.Add(spec);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (id, spec) in targetById)
|
||||
|
||||
Reference in New Issue
Block a user