refactor(runtime): delete the dead in-place ApplyDelta seam; config change = respawn only (#665)
Since #516 a DriverConfig change is stop + respawn, so DriverSpawnPlan.ToApplyDelta was always empty and the in-place path had no producer. #663 and #664 were both latent bugs in it. This deletes it: - host: ApplyChildDelta, _pendingDelta, HandleApplyResult and HandleDeltaApplied - plan: DriverSpawnPlan.ToApplyDelta - actor: the ApplyDelta, ApplyResult and DeltaApplied messages, the Connected reinit handler and the mid-connect AdoptConfigDuringInit - tests: the actor tests that drove the seam IDriver.ReinitializeAsync and each driver's implementation and unit tests stay. Comments and docs that cited AdoptConfigDuringInit as the reason a driver must release prior state at init now cite the IDriver contract and overlapping retries, which still apply. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -24,8 +24,9 @@ public interface IDriver
|
||||
|
||||
/// <summary>
|
||||
/// Apply a config change in place without tearing down the driver process.
|
||||
/// Invoked by the v2 <c>DriverInstanceActor</c> when ApplyDelta reports that only this
|
||||
/// driver's config changed in the new deployment.
|
||||
/// <para>The runtime does not call this today: since #516 a DriverConfig change is a stop + respawn,
|
||||
/// and #665 deleted <c>DriverInstanceActor</c>'s in-place <c>ApplyDelta</c> path. Kept as the in-place
|
||||
/// contract each driver implements and tests.</para>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/driver-stability.md</c> §"In-process only (Tier A/B)" — Reinitialize is the
|
||||
|
||||
@@ -360,8 +360,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit
|
||||
// runs it with no teardown first). Release the previous device states' probe loops + handles before
|
||||
// #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
|
||||
// allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + handles before
|
||||
// the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions.
|
||||
await ReleaseDeviceStatesAsync(cancellationToken).ConfigureAwait(false);
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
|
||||
@@ -145,8 +145,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
// #630: InitializeAsync can run on a live driver (DriverInstanceActor.AdoptConfigDuringInit calls it
|
||||
// without a teardown first). Release the previous device states' probe loops + runtimes BEFORE the
|
||||
// #630: InitializeAsync can run on a live driver without a teardown first (the IDriver contract
|
||||
// allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + runtimes BEFORE the
|
||||
// overwrite below, or they are orphaned beyond the reach of ShutdownAsync. Deliberately NOT
|
||||
// ShutdownAsync: that also drops _poll and every subscription, which are owned by the shutdown path (the actor re-subscribes after every successful init).
|
||||
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -67,7 +67,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
|
||||
// Authored calc tags, keyed by RawPath (identity + evaluation id). Built at construction so
|
||||
// DependencyRefs is available BEFORE InitializeAsync (the host reads it when spawning the mux-adapter),
|
||||
// and REBUILT on every ReinitializeAsync (an in-place ApplyDelta re-binds an edited calc-tag set).
|
||||
// and REBUILT on every ReinitializeAsync / body-carrying InitializeAsync (an in-place re-bind of an edited calc-tag set).
|
||||
private readonly Dictionary<string, CalculationTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
|
||||
|
||||
// rawPath (a dependency) → the calc tags that read it AND are change-triggered. Drives OnDependencyValue.
|
||||
@@ -139,7 +139,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
/// (Re)build the authored tag table (<see cref="_tagsByRawPath"/>), the change-trigger fan-out
|
||||
/// (<see cref="_changeDependents"/>), the dependency-ref interest set (<see cref="_dependencyRefs"/>),
|
||||
/// and the per-tag state map (<see cref="_stateByRawPath"/>) from <paramref name="config"/>. Called
|
||||
/// once at construction AND on every <see cref="ReinitializeAsync"/> so an in-place ApplyDelta — a
|
||||
/// once at construction AND on every <see cref="ReinitializeAsync"/> so an in-place config change — a
|
||||
/// calc-tag add/remove/edit, or a script-source edit — actually takes effect. Extracted so the ctor
|
||||
/// and reinit paths share ONE construction routine and cannot drift.
|
||||
/// <para>Per-tag <see cref="TagState"/> is preserved for a tag that survives the edit (cheap last-known
|
||||
@@ -200,7 +200,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
}
|
||||
|
||||
// Reinit config-bind options — mirrors the factory's bind (case-insensitive, enum-string, trailing-comma
|
||||
// tolerant) so an ApplyDelta payload re-binds identically to the spawn payload.
|
||||
// tolerant) so an in-place config payload re-binds identically to the spawn payload.
|
||||
private static readonly JsonSerializerOptions ReinitJsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
@@ -209,8 +209,8 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
/// <summary>Re-bind the authored raw calc tags from a new merged DriverConfig JSON (the ApplyDelta
|
||||
/// payload). The ctor-fixed <see cref="CalculationEvaluator"/> timeout is kept — <c>RunTimeout</c> is bound
|
||||
/// <summary>Re-bind the authored raw calc tags from a new merged DriverConfig JSON (an in-place
|
||||
/// config payload). The ctor-fixed <see cref="CalculationEvaluator"/> timeout is kept — <c>RunTimeout</c> is bound
|
||||
/// at spawn (a change to it is out of this in-place path's scope). A parse failure logs and keeps the
|
||||
/// current tags so a malformed delta can never blank the driver.</summary>
|
||||
private CalculationDriverOptions ParseOptions(string driverConfigJson)
|
||||
@@ -287,8 +287,8 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// #664: <c>InitializeAsync</c> is reachable on a LIVE driver — <c>DriverInstanceActor.AdoptConfigDuringInit</c>
|
||||
/// re-runs it with a NEW config when an ApplyDelta lands mid-connect — so it adopts a config carrying a
|
||||
/// #664: <c>InitializeAsync</c> takes a config document and the <see cref="IDriver"/> contract is that the
|
||||
/// driver serves it (the runtime's mid-connect adoption path was deleted in #665) — so it adopts a config carrying a
|
||||
/// real body (the #516 convention every other driver follows), and disposes any running timer scheduler
|
||||
/// before arming a fresh one (#630: re-initialise without teardown must not leak). A <c>"{}"</c> / empty
|
||||
/// document keeps the constructor-supplied tags, as unit tests rely on.
|
||||
@@ -308,14 +308,10 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
/// <inheritdoc />
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
// A calc-tag add/remove/edit — or a script edit that changes the injected scriptSource — changes the
|
||||
// merged DriverConfig, which DriverSpawnPlanner routes to an in-place ApplyDelta → THIS call (a
|
||||
// respawn fires only on a DriverType/ResilienceConfig change). So the authored tag table is NOT
|
||||
// fixed at construction: we re-bind the new config and REBUILD the tag table + dependency-ref set
|
||||
// here. Without this, added tags never compute, removed tags keep publishing, and edited scripts
|
||||
// serve stale results (deploy reports success but nothing changed). After the rebuild _dependencyRefs
|
||||
// reflects the new authored tags, and the host re-registers the mux interest post-reinit (the
|
||||
// DriverInstanceActor.DeltaApplied → DriverHostActor path, sequenced AFTER this completes).
|
||||
// An in-place config change: re-bind the new config and REBUILD the tag table + dependency-ref set, so
|
||||
// added tags compute, removed tags stop publishing and edited scripts serve the new result. The runtime
|
||||
// itself applies a DriverConfig change by respawning (#516/#665), where a fresh driver registers its
|
||||
// new dependency refs on construction; this path keeps the IDriver contract honest for any caller.
|
||||
StopTimers();
|
||||
AdoptConfig(driverConfigJson);
|
||||
// Re-arm timers off the rebuilt tag set.
|
||||
|
||||
@@ -196,8 +196,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <returns>A completed task.</returns>
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
// #630: release the previous device states' loops + clients BEFORE they are overwritten (the actor's
|
||||
// AdoptConfigDuringInit calls this without a teardown first). Not ShutdownAsync: that also drops _poll
|
||||
// #630: release the previous device states' loops + clients BEFORE they are overwritten (a live driver can be
|
||||
// re-initialised without a teardown first). Not ShutdownAsync: that also drops _poll
|
||||
// and subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init).
|
||||
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
|
||||
ResetAlarmHealth();
|
||||
|
||||
@@ -349,8 +349,8 @@ public sealed class GalaxyDriver
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// #664: <c>InitializeAsync</c> is reachable on a live driver with a NEW config —
|
||||
/// <c>DriverInstanceActor.AdoptConfigDuringInit</c> re-runs it when an ApplyDelta lands mid-connect. The
|
||||
/// #664: <c>InitializeAsync</c> takes a config document, and the <see cref="IDriver"/> contract is that a
|
||||
/// driver serves the one it is handed (the runtime's own mid-connect adoption path was deleted in #665). The
|
||||
/// driver cannot hot-swap its options (they shape the gw session; see <see cref="ReinitializeAsync"/>), so a
|
||||
/// non-equivalent config with a real body is REFUSED here too rather than silently ignored — the driver
|
||||
/// would otherwise connect on its old endpoint while the deploy reported the change applied. A
|
||||
|
||||
@@ -21,11 +21,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// Agent. Every connection is owned by <see cref="InitializeAsync"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The <c>driverConfigJson</c> argument is honoured, not decorative.</b> The runtime hands
|
||||
/// a config change to a <i>live</i> instance — <c>DriverInstanceActor</c>'s <c>ApplyDelta</c>
|
||||
/// calls <see cref="ReinitializeAsync"/> with the new document and never rebuilds the object
|
||||
/// — so a driver that read only its constructor options would turn every MTConnect config
|
||||
/// edit into a silent no-op that still seals the deployment green. This class therefore
|
||||
/// <b>The <c>driverConfigJson</c> argument is honoured, not decorative.</b> <see cref="IDriver.InitializeAsync"/>
|
||||
/// and <see cref="ReinitializeAsync"/> both take the document, and the contract is that the driver
|
||||
/// serves it — a driver that read only its constructor options would turn an in-place config
|
||||
/// change into a silent no-op. (The runtime itself now applies a config change by respawning,
|
||||
/// #516/#665.) This class therefore
|
||||
/// re-parses the document (<see cref="ParseOptions"/>) whenever it carries a real body,
|
||||
/// matching S7/AbCip/TwinCAT/Galaxy rather than the Modbus/FOCAS/OpcUaClient drivers that
|
||||
/// ignore the argument. A blank / <c>{}</c> / <c>[]</c> document means "no config supplied"
|
||||
|
||||
@@ -95,7 +95,7 @@ public sealed class ModbusDriver
|
||||
/// concurrent operations would dispose each other's freshly-built transport or null
|
||||
/// <c>_transport</c> out from under an in-flight connect. Not hypothetical:
|
||||
/// <c>DriverInstanceActor</c> dispatches initialize on a fire-and-forget <c>Task.Run</c> and
|
||||
/// its <c>AdoptConfigDuringInit</c> can fire twice back to back. Same shape as
|
||||
/// a reconnect retry tick can start a second attempt while the first is still running. Same shape as
|
||||
/// <c>MqttDriver._lifecycleGate</c>.
|
||||
/// </summary>
|
||||
private readonly SemaphoreSlim _lifecycleGate = new(1, 1);
|
||||
@@ -297,9 +297,9 @@ public sealed class ModbusDriver
|
||||
// Release whatever a PREVIOUS initialize published before building the replacements. All
|
||||
// three fields assigned below (_transport, _probeCts, _reprobeCts) were simply overwritten,
|
||||
// so a second initialize on a live driver stranded a connected transport plus two running
|
||||
// background loops that no later dispose could reach. Reachable without any teardown of
|
||||
// ours: DriverInstanceActor re-runs InitializeAsync with no ShutdownAsync when an ApplyDelta
|
||||
// lands while the driver is in Connecting/Reconnecting (AdoptConfigDuringInit).
|
||||
// background loops that no later dispose could reach. The IDriver contract lets a
|
||||
// caller re-initialise a live driver without ShutdownAsync, and DriverInstanceActor's retry tick
|
||||
// can overlap a still-running attempt, so this must not rely on a teardown having happened.
|
||||
// Deliberately NOT the shared TeardownAsync(): that also disposes _poll, which tears down
|
||||
// every ACTIVE SUBSCRIPTION — and nothing re-subscribes them afterwards, because a
|
||||
// mid-life re-initialize is not a shutdown the OPC UA layer knows to re-drive. A caller
|
||||
|
||||
@@ -983,9 +983,9 @@ public sealed class MqttDriver
|
||||
// published below (_connection, _primaryHost) are IAsyncDisposable and were previously
|
||||
// overwritten, so a second initialize on a live driver stranded a connected, supervised
|
||||
// broker session — plus a whole second primary-host connection — that no later dispose
|
||||
// could reach. Reachable without any teardown of ours: DriverInstanceActor re-runs
|
||||
// InitializeAsync with no ShutdownAsync when an ApplyDelta lands while the driver is in
|
||||
// Connecting/Reconnecting (AdoptConfigDuringInit). Idempotent, so the ReinitializeAsync
|
||||
// could reach. The IDriver contract lets a caller
|
||||
// re-initialise a live driver without ShutdownAsync, and a reconnect retry can overlap a running
|
||||
// attempt. Idempotent, so the ReinitializeAsync
|
||||
// rebuild branch calling TeardownAsync first costs a no-op, and a first initialize sees
|
||||
// three nulls.
|
||||
await TeardownAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -227,8 +227,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit runs
|
||||
// it with no teardown first); `Session = session` below would otherwise orphan the previous session,
|
||||
// #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
|
||||
// allows it, and a reconnect retry can overlap a running attempt); `Session = session` below would otherwise orphan the previous session,
|
||||
// its keep-alive handler and any reconnect handler.
|
||||
await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
|
||||
@@ -225,8 +225,8 @@ public sealed class S7Driver
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit
|
||||
// runs it with no teardown first). Stop the previous probe loop and drop the previous connection
|
||||
// #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
|
||||
// allows it, and a reconnect retry can overlap a running attempt). Stop the previous probe loop and drop the previous connection
|
||||
// before they are overwritten below. Deliberately NOT ShutdownAsync: that also drops _poll and the
|
||||
// subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init).
|
||||
await ReleaseConnectionAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -113,8 +113,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit
|
||||
// runs it with no teardown first). Release the previous device states' probe loops + clients before
|
||||
// #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
|
||||
// allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + clients before
|
||||
// the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions.
|
||||
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
|
||||
@@ -406,7 +406,7 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
|
||||
// v3: each spec's DriverConfig is the MERGED config the driver factory binds from —
|
||||
// DriverConfig (protocol/channel) + per-Device DeviceConfig (endpoint) + the driver's authored
|
||||
// raw tags (RawTags array, keyed by RawPath) — folded once here via DriverDeviceConfigMerger so
|
||||
// both spawn (TryCreate) and redeploy (ApplyDelta) hand the driver the same shape.
|
||||
// the factory (TryCreate) and the init (InitializeRequested) hand the driver the same shape.
|
||||
var rawTagsByDriver = BuildRawTagsByDriver(root);
|
||||
var deviceRowsByDriver = BuildDeviceRowsByDriver(root);
|
||||
|
||||
|
||||
@@ -391,10 +391,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// 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>One driver child's desired subscription payload — exactly the
|
||||
/// <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> the apply's bulk push sent it.</summary>
|
||||
@@ -1345,8 +1341,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||
Receive<CommsRecoveryAudit>(HandleCommsRecoveryAudit);
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
|
||||
Receive<RestartDriver>(HandleRestartDriver);
|
||||
Receive<DriverInstanceActor.ReprovisionRequested>(HandleReprovisionRequested);
|
||||
Receive<ReprovisionDeferred>(m => ReprovisionChild(m.Request, m.Child));
|
||||
@@ -1408,8 +1402,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||
Receive<CommsRecoveryAudit>(HandleCommsRecoveryAudit);
|
||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
||||
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
|
||||
Receive<RestartDriver>(HandleRestartDriver);
|
||||
Receive<DriverInstanceActor.ReprovisionRequested>(HandleReprovisionRequested);
|
||||
Receive<ReprovisionDeferred>(m => ReprovisionChild(m.Request, m.Child));
|
||||
@@ -2284,10 +2276,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
// Likewise the 03/S15 recovery audit: a timer armed before the host went Stale has nothing to report
|
||||
// on (no latch was set while Stale, and the rebuild re-establishes every node's status anyway).
|
||||
Receive<CommsRecoveryAudit>(_ => { });
|
||||
// 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);
|
||||
}
|
||||
@@ -2704,7 +2692,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
|
||||
/// <summary>
|
||||
/// Read the deployment artifact + reconcile the set of running <see cref="DriverInstanceActor"/>
|
||||
/// children. Spawn missing, ApplyDelta on config change, stop removed/disabled drivers.
|
||||
/// children. Spawn missing, respawn on config change, stop removed/disabled drivers.
|
||||
/// When the artifact blob is empty (legacy ControlPlane tests, smoke fixtures) the reconcile is
|
||||
/// SKIPPED outright — see the issue #485 guard below — and when the configured
|
||||
/// <see cref="IDriverFactory"/> can't materialise any of the requested types this is a no-op.
|
||||
@@ -2917,16 +2905,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
var plan = DriverSpawnPlanner.Compute(snapshots, specs);
|
||||
|
||||
foreach (var id in plan.ToStop) StopChild(id);
|
||||
foreach (var spec in plan.ToApplyDelta) ApplyChildDelta(spec);
|
||||
foreach (var spec in plan.ToSpawn) SpawnChild(spec);
|
||||
|
||||
// v3 (WP7): a dependency-consumer driver's declared dependency refs change when its tags/scripts are
|
||||
// edited — the adapter must re-register the NEW set on the mux. Newly-spawned adapters register in
|
||||
// PreStart; an ApplyDelta'd driver's adapter is re-registered when the child reports it finished
|
||||
// reinitialising (DriverInstanceActor.DeltaApplied → HandleDeltaApplied). That notification is
|
||||
// sequenced AFTER ReinitializeAsync rebuilds DependencyRefs — a synchronous re-register HERE would
|
||||
// re-read the STALE ref set (ApplyDelta runs asynchronously on the child), so it is deliberately NOT
|
||||
// done inline.
|
||||
// edited. That edit changes its DriverConfig, which respawns it (#516), and the respawned driver's
|
||||
// adapter registers the NEW set in PreStart — no separate re-register is needed (#665).
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -3557,69 +3540,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
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;
|
||||
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
|
||||
/// mux adapter so a changed dependency-ref set (calc tags/scripts edited during the redeploy) is
|
||||
/// adopted on the mux. This runs AFTER reinit — closing the ordering hazard where a synchronous
|
||||
/// re-register at ApplyDelta dispatch would re-read the driver's stale
|
||||
/// <see cref="IDependencyConsumer.DependencyRefs"/>. A Tell (no blocking); a no-op for a driver with no
|
||||
/// adapter (not an <see cref="IDependencyConsumer"/>, or no mux wired on this node).
|
||||
/// </summary>
|
||||
private void HandleDeltaApplied(DriverInstanceActor.DeltaApplied msg)
|
||||
{
|
||||
if (_depConsumerAdapters.TryGetValue(msg.DriverInstanceId, out var adapter))
|
||||
adapter.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags.DependencyConsumerMuxAdapter.ReRegister());
|
||||
}
|
||||
|
||||
private void StopChild(string driverInstanceId)
|
||||
{
|
||||
if (!_children.TryGetValue(driverInstanceId, out var entry)) return;
|
||||
|
||||
@@ -25,8 +25,7 @@ file sealed class HealthPollTick
|
||||
/// <item><c>Failed</c> — terminal until parent restarts the actor.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// Engine wiring (subscriptions → AttributeValueUpdate publishes, ApplyDelta-driven Reinitialize,
|
||||
/// per-tag write Asks) is staged for follow-up F7. This skeleton compiles + has a working
|
||||
/// Engine wiring (subscriptions → AttributeValueUpdate publishes, per-tag write Asks) is staged for follow-up F7. This skeleton compiles + has a working
|
||||
/// state machine so the Phase 6 control-plane integration tests can target it.
|
||||
/// </summary>
|
||||
public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
@@ -41,7 +40,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// <c>Connected=true</c> on entering the Connected state, <c>false</c> on entering Reconnecting. The host
|
||||
/// annotates this driver's native alarm conditions' source-data Quality from it (comms lost → Bad,
|
||||
/// restored → Good) — independently of alarm transitions, since a comms-lost driver emits no alarm
|
||||
/// events. Fire-and-forget, mirroring <see cref="DeltaApplied"/>.</summary>
|
||||
/// events. Fire-and-forget.</summary>
|
||||
public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected);
|
||||
|
||||
/// <summary>
|
||||
@@ -62,18 +61,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// <param name="DriverInstanceId">The driver instance that was restarted.</param>
|
||||
/// <param name="Reason">The type name of the exception that tripped supervision, for the host's log.</param>
|
||||
public sealed record ReprovisionRequested(string DriverInstanceId, string Reason);
|
||||
public sealed record ApplyDelta(string DriverConfigJson, CorrelationId Correlation);
|
||||
public sealed record ApplyResult(bool Success, string? Reason, CorrelationId Correlation);
|
||||
/// <summary>
|
||||
/// Sent to the parent (<see cref="DriverHostActor"/>) AFTER an <see cref="ApplyDelta"/> has fully
|
||||
/// re-initialised the driver (i.e. <see cref="IDriver.ReinitializeAsync"/> completed). The host uses
|
||||
/// it to re-register this driver's dependency-consumer mux adapter: a driver may rebuild its declared
|
||||
/// <see cref="IDependencyConsumer.DependencyRefs"/> during reinit (the Calculation driver re-binds its
|
||||
/// calc tags), so the re-register MUST happen after reinit — NOT synchronously alongside the ApplyDelta
|
||||
/// dispatch, which would re-read the STALE ref set. Emitted only on a successful apply.
|
||||
/// </summary>
|
||||
/// <param name="DriverInstanceId">The instance whose adapter should be re-registered.</param>
|
||||
public sealed record DeltaApplied(string DriverInstanceId);
|
||||
public sealed record WriteAttribute(string TagId, object Value);
|
||||
public sealed record WriteAttributeResult(bool Success, string? Reason);
|
||||
/// <summary>
|
||||
@@ -221,15 +208,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||
private string? _currentConfigJson;
|
||||
|
||||
/// <summary>#664: set when <see cref="AdoptConfigDuringInit"/> adopts a new config mid-connect; the next
|
||||
/// (generation-matched) <see cref="InitializeSucceeded"/> sends <see cref="DeltaApplied"/> to the parent and
|
||||
/// clears it, so a dependency consumer's mux interest is re-registered against the ADOPTED config — the
|
||||
/// same post-reinit notification the Connected <see cref="HandleApplyDeltaAsync"/> path sends.</summary>
|
||||
private bool _adoptedConfigPendingNotify;
|
||||
|
||||
/// <summary>Monotonic token tagging each <see cref="InitializeAsync"/> attempt. An init result is
|
||||
/// honoured only when its generation matches the latest; an older result is from a superseded attempt
|
||||
/// (e.g. an <see cref="ApplyDelta"/> adopted a new config mid-(re)connect) and is dropped. Touched only
|
||||
/// (a retry tick that overlapped a still-running attempt) and is dropped. Touched only
|
||||
/// on the actor thread, so no lock is needed.</summary>
|
||||
private int _initGeneration;
|
||||
|
||||
@@ -442,7 +423,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// Stubbed drivers accept the standard message contracts but return deterministic
|
||||
// success without touching real hardware. Read returns null; Write succeeds.
|
||||
Receive<InitializeRequested>(_ => { /* no-op */ });
|
||||
Receive<ApplyDelta>(msg => Sender.Tell(new ApplyResult(true, "stubbed", msg.Correlation)));
|
||||
Receive<WriteAttribute>(_ => Sender.Tell(new WriteAttributeResult(true, "stubbed")));
|
||||
// Stubbed drivers have no upstream alarm system — swallow the ack (it's fire-and-forget, no reply).
|
||||
Receive<RouteAlarmAck>(_ => { /* stubbed drivers have no alarm backend */ });
|
||||
@@ -468,13 +448,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// fire-and-forget — no reply to surface — and the OPC UA condition state already committed locally).
|
||||
Receive<RouteAlarmAck>(_ =>
|
||||
_log.Debug("DriverInstance {Id}: alarm ack arrived during connect — dropped (driver not connected)", _driverInstanceId));
|
||||
Receive<ApplyDelta>(AdoptConfigDuringInit);
|
||||
Receive<InitializeSucceeded>(msg =>
|
||||
{
|
||||
if (msg.Generation != _initGeneration) return;
|
||||
_log.Info("DriverInstance {Id}: connected", _driverInstanceId);
|
||||
Become(Connected);
|
||||
NotifyAdoptedConfig();
|
||||
PublishHealthSnapshot();
|
||||
ResubscribeDesired();
|
||||
AttachAlarmSource();
|
||||
@@ -522,7 +500,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// reconnects but never manages a read is measured from THIS connect, not from a stale earlier one.
|
||||
_connectedSinceUtc = DateTime.UtcNow;
|
||||
|
||||
ReceiveAsync<ApplyDelta>(HandleApplyDeltaAsync);
|
||||
Receive<DisconnectObserved>(msg =>
|
||||
{
|
||||
_log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting",
|
||||
@@ -771,14 +748,12 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// no reply — the OPC UA condition state already committed locally on the Part 9 ack).
|
||||
Receive<RouteAlarmAck>(_ =>
|
||||
_log.Debug("DriverInstance {Id}: alarm ack arrived during reconnect — dropped (driver not connected)", _driverInstanceId));
|
||||
Receive<ApplyDelta>(AdoptConfigDuringInit);
|
||||
Receive<InitializeSucceeded>(msg =>
|
||||
{
|
||||
if (msg.Generation != _initGeneration) return;
|
||||
Timers.Cancel("retry-connect");
|
||||
_log.Info("DriverInstance {Id}: reconnected", _driverInstanceId);
|
||||
Become(Connected);
|
||||
NotifyAdoptedConfig();
|
||||
PublishHealthSnapshot();
|
||||
ResubscribeDesired();
|
||||
AttachAlarmSource();
|
||||
@@ -875,94 +850,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Adopt a new config while not connected: ApplyDelta in Connecting/Reconnecting re-inits
|
||||
/// immediately with the new config. <see cref="InitializeAsync"/> swaps <c>_currentConfigJson</c> and
|
||||
/// bumps the generation, so the in-flight (old-config) init is superseded and its result is dropped.
|
||||
/// The actor stays in its current state; the new init's result drives the next transition. In
|
||||
/// Reconnecting the retry timer is left running — if this immediate attempt fails it keeps retrying
|
||||
/// the new config (a redundant concurrent attempt is deduped by the generation guard).</summary>
|
||||
private void AdoptConfigDuringInit(ApplyDelta msg)
|
||||
{
|
||||
_log.Info("DriverInstance {Id}: ApplyDelta during (re)connect — adopting new config, re-initialising now",
|
||||
_driverInstanceId);
|
||||
InitializeAsync(msg.DriverConfigJson);
|
||||
_adoptedConfigPendingNotify = true;
|
||||
Sender.Tell(new ApplyResult(true, "config adopted; reinitializing", msg.Correlation));
|
||||
}
|
||||
|
||||
/// <summary>Send the deferred <see cref="DeltaApplied"/> for a mid-connect adoption (#664) once the driver
|
||||
/// has actually initialised on the adopted config. A no-op otherwise.</summary>
|
||||
private void NotifyAdoptedConfig()
|
||||
{
|
||||
if (!_adoptedConfigPendingNotify) return;
|
||||
_adoptedConfigPendingNotify = false;
|
||||
Context.Parent.Tell(new DeltaApplied(_driverInstanceId));
|
||||
}
|
||||
|
||||
/// <summary>Adopt a new config while Connected, in place, via <see cref="IDriver.ReinitializeAsync"/>.
|
||||
/// <para>#663: every driver's reinit releases its device/session state (#630), so the driver-side data and
|
||||
/// native-alarm subscriptions do not survive it — but this handler used to keep both handles, and the
|
||||
/// health-poll reconcile only re-subscribes a MISSING handle, so the driver went silent until a
|
||||
/// reconnect or respawn. Both subscriptions are now released explicitly BEFORE the reinit (explicitly,
|
||||
/// not just by nulling the handles, so a driver whose reinit keeps its subscriptions is not left
|
||||
/// holding two) and re-established after it — in a <c>finally</c>, because a failed reinit has already
|
||||
/// lost them too. Reinit stays the adoption path rather than shutdown + initialise: Calculation and
|
||||
/// Galaxy take their config at construction and ignore <see cref="IDriver.InitializeAsync"/>'s JSON.</para>
|
||||
/// <para>Not reachable from a deploy since #516 (a DriverConfig change respawns the driver).</para></summary>
|
||||
private async Task HandleApplyDeltaAsync(ApplyDelta msg)
|
||||
{
|
||||
var replyTo = Sender;
|
||||
await UnsubscribeAsync();
|
||||
await ReleaseAlarmSubscriptionAsync();
|
||||
try
|
||||
{
|
||||
await _driver.ReinitializeAsync(msg.DriverConfigJson, CancellationToken.None);
|
||||
_currentConfigJson = msg.DriverConfigJson;
|
||||
// The driver may have rebuilt its dependency-consumer interest during reinit (the Calculation
|
||||
// driver re-binds its calc tags + dependency refs). Notify the parent so it re-registers this
|
||||
// driver's mux adapter AFTER reinit completed — a synchronous re-register at ApplyDelta-dispatch
|
||||
// time would re-read the driver's STALE DependencyRefs. Parent Tell (fire-and-forget); the
|
||||
// continuation resumes on the actor context (no ConfigureAwait) so Context.Parent is live.
|
||||
Context.Parent.Tell(new DeltaApplied(_driverInstanceId));
|
||||
replyTo.Tell(new ApplyResult(true, null, msg.Correlation));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
replyTo.Tell(new ApplyResult(false, ex.Message, msg.Correlation));
|
||||
}
|
||||
finally
|
||||
{
|
||||
ResubscribeDesired();
|
||||
AttachAlarmSource();
|
||||
SubscribeDesiredAlarms();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Release the native-alarm subscription at the driver (best-effort, bounded) and drop the
|
||||
/// actor's handler + handle. The in-place reinit counterpart of the data <see cref="UnsubscribeAsync"/>
|
||||
/// (#663).</summary>
|
||||
private async Task ReleaseAlarmSubscriptionAsync()
|
||||
{
|
||||
if (_driver is IAlarmSource src && _alarmSubscriptionHandle is { } handle)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await _invoker.ExecuteAsync(
|
||||
DriverCapability.AlarmSubscribe,
|
||||
_driverInstanceId,
|
||||
async ct => await src.UnsubscribeAlarmsAsync(handle, ct),
|
||||
cts.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "DriverInstance {Id}: releasing the native-alarm subscription ({Diag}) threw (continuing)",
|
||||
_driverInstanceId, handle.DiagnosticId);
|
||||
}
|
||||
}
|
||||
DetachAlarmSource();
|
||||
}
|
||||
|
||||
private async Task HandleWriteAsync(WriteAttribute msg)
|
||||
{
|
||||
var replyTo = Sender;
|
||||
|
||||
@@ -3,38 +3,31 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
/// <summary>
|
||||
/// Pure diff between the currently-running driver children (keyed by
|
||||
/// <c>DriverInstance.DriverInstanceId</c>) and the target spec list from a freshly-applied
|
||||
/// deployment artifact. The DriverHostActor consumes the three lists and calls
|
||||
/// spawn / ApplyDelta / stop on its child actors accordingly.
|
||||
/// deployment artifact. The DriverHostActor consumes the two lists and calls
|
||||
/// spawn / stop on its child actors accordingly. There is no in-place config path: since #516 a config
|
||||
/// change is a stop + respawn, and #665 deleted the unreachable in-place <c>ApplyDelta</c> seam.
|
||||
/// </summary>
|
||||
/// <param name="ToSpawn">Specs with no current child — create a new actor.</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="ToSpawn">Specs with no current child (or whose child is being replaced) — create a new actor.</param>
|
||||
/// <param name="ToStop">DriverInstanceIds currently running but missing from the new artifact, or now disabled.</param>
|
||||
public sealed record DriverSpawnPlan(
|
||||
IReadOnlyList<DriverInstanceSpec> ToSpawn,
|
||||
IReadOnlyList<DriverInstanceSpec> ToApplyDelta,
|
||||
IReadOnlyList<string> ToStop);
|
||||
|
||||
public static class DriverSpawnPlanner
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute the spawn/delta/stop sets. Disabled entries in <paramref name="target"/> are
|
||||
/// Compute the spawn/stop sets. Disabled entries in <paramref name="target"/> are
|
||||
/// treated as "not desired here": if a child exists for the id it goes into ToStop,
|
||||
/// otherwise the row is dropped entirely (no spawn for a disabled driver).
|
||||
/// </summary>
|
||||
/// <param name="current">The currently running driver children keyed by ID.</param>
|
||||
/// <param name="target">The target driver instances from the deployment artifact.</param>
|
||||
/// <returns>The computed <see cref="DriverSpawnPlan"/> with spawn, apply-delta, and stop sets.</returns>
|
||||
/// <returns>The computed <see cref="DriverSpawnPlan"/> with spawn and stop sets.</returns>
|
||||
public static DriverSpawnPlan Compute(
|
||||
IReadOnlyDictionary<string, DriverChildSnapshot> current,
|
||||
IReadOnlyList<DriverInstanceSpec> target)
|
||||
{
|
||||
var toSpawn = new List<DriverInstanceSpec>();
|
||||
var toDelta = new List<DriverInstanceSpec>();
|
||||
var toStop = new List<string>();
|
||||
|
||||
var targetById = new Dictionary<string, DriverInstanceSpec>(StringComparer.Ordinal);
|
||||
@@ -83,7 +76,7 @@ public static class DriverSpawnPlanner
|
||||
toSpawn.Add(spec);
|
||||
}
|
||||
|
||||
return new DriverSpawnPlan(toSpawn, toDelta, toStop);
|
||||
return new DriverSpawnPlan(toSpawn, toStop);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user