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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user