From 1ea1da8236bf5f65e4a9742ee5bfa0303d326275 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 9 Oct 2026 11:23:16 -0400 Subject: [PATCH] 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 --- CLAUDE.md | 12 +- docs/DriverLifecycle.md | 32 ++--- docs/IncrementalSync.md | 6 +- docs/drivers/Mqtt.md | 2 +- docs/v2/Runtime.md | 2 +- .../IDriver.cs | 5 +- .../AbCipDriver.cs | 4 +- .../AbLegacyDriver.cs | 4 +- .../CalculationDriver.cs | 26 ++-- .../FocasDriver.cs | 4 +- .../GalaxyDriver.cs | 4 +- .../MTConnectDriver.cs | 10 +- .../ModbusDriver.cs | 8 +- .../MqttDriver.cs | 6 +- .../OpcUaClientDriver.cs | 4 +- .../ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs | 4 +- .../TwinCATDriver.cs | 4 +- .../Drivers/DeploymentArtifact.cs | 2 +- .../Drivers/DriverHostActor.cs | 86 +----------- .../Drivers/DriverInstanceActor.cs | 119 +---------------- .../Drivers/DriverSpawnPlan.cs | 21 +-- .../AbLegacyReinitializeReleasesStateTests.cs | 4 +- .../CalculationDriverTests.cs | 8 +- .../FocasReinitializeReleasesStateTests.cs | 4 +- .../GalaxyDriverFactoryTests.cs | 5 +- .../MTConnectDriverLifecycleTests.cs | 7 +- .../ModbusLifecycleHygieneTests.cs | 11 +- .../MqttDriverSessionLifecycleTests.cs | 7 +- .../S7DriverReconnectTests.cs | 4 +- .../Drivers/CalculationDependencyFlowTests.cs | 9 +- .../DriverInstanceActorNativeAlarmTests.cs | 112 ++-------------- .../Drivers/DriverInstanceActorTests.cs | 125 ------------------ .../Drivers/DriverSpawnPlannerTests.cs | 10 -- 33 files changed, 112 insertions(+), 559 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9a9e0135..e754e6aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1161,16 +1161,16 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa first release the prior device/session state (cancel, ≤ 5 s join, dispose — incl. the AbCip alarm projection; FOCAS's is PAUSED, its told record kept, #647) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes after every successful init. AbCip publishes fresh tag tables by reference swap (a second init used to throw - "tag name collision" on `AdoptConfigDuringInit`); S7 swaps `Plc` under `_gate`. + "tag name collision"); S7 swaps `Plc` under `_gate`. - **Data and alarm subscriptions are independent in `DriverInstanceActor` (#662):** a data re-subscribe (every deploy's bulk push to a kept driver) detaches DATA only (`DetachDataSubscription`) — it used to unhook `OnAlarmEvent` too, silently killing the native-alarm feed until a respawn. The health-poll reconcile re-attaches/re-subscribes alarms as well as data, so a failed alarm subscribe is retried every tick. - A Connected in-place `ApplyDelta` (unreachable from deploys since #516) releases both subscriptions at the - driver, reinitialises, then re-subscribes both (#663) — it used to keep handles the reinit had invalidated. - A mid-connect `ApplyDelta` re-runs `InitializeAsync(newConfig)`, so **every driver's `InitializeAsync` must adopt a - config with a body** — Calculation now does, Galaxy (no hot-swap) now refuses a non-equivalent one, and the actor - sends `DeltaApplied` once the adopted init succeeds (#664). +- **A config change is a respawn — there is NO in-place path (#665).** The `ApplyDelta`/`ApplyResult`/`DeltaApplied` + seam (host `ApplyChildDelta`, `DriverSpawnPlan.ToApplyDelta`, the actor's Connected reinit and mid-connect + `AdoptConfigDuringInit`) was unreachable since #516 and is deleted; #663/#664 were latent bugs in it. `IDriver.ReinitializeAsync` + stays as a per-driver contract the runtime never calls. Every driver's `InitializeAsync` still serves the config it is + handed (Calculation adopts, Galaxy refuses a non-equivalent one — #664), and still releases prior state (#630). `docs/DriverLifecycle.md`. - **S7 (#632/#638):** STRING/WSTRING/CHAR/WCHAR and DTL/DATE/TOD/TIME/S5TIME codecs (`S7ValueCodecs`; TOD/TIME/S5TIME surface as Int32 ms); an undecodable buffer or unrepresentable write is `BadOutOfRange` with no health impact. diff --git a/docs/DriverLifecycle.md b/docs/DriverLifecycle.md index ca39046f..99f3c71d 100644 --- a/docs/DriverLifecycle.md +++ b/docs/DriverLifecycle.md @@ -255,25 +255,15 @@ Things to know before touching any of this: instead of gating the feed for the life of the connection. OpcUaClient removes a half-built alarm subscription from its session when the subscribe throws, so those retries do not accumulate. End-to-end pin: `NativeAlarmFeedSurvivesDeployTests`. -- **An in-place `ApplyDelta` on a Connected driver releases and re-establishes BOTH subscriptions (#663).** - `ReinitializeAsync` drops every driver-side subscription (#630), but the handler used to keep the actor's - handles, and the reconcile only re-subscribes a *missing* handle — so the driver went silent until a - reconnect. It now releases data and alarms at the driver first (explicitly, so a driver whose reinit keeps - its subscriptions is not left holding two), reinitialises, and re-subscribes both in a `finally` (a failed - reinit has lost them too). It stays on `ReinitializeAsync` rather than shutdown + initialise because - Calculation and Galaxy take their config at construction and ignore the JSON passed to `InitializeAsync`. - Not reachable from a deploy since #516 (a DriverConfig change respawns). Pin: - `An_in_place_ApplyDelta_leaves_one_live_data_and_one_live_alarm_subscription` (one case per wrong fix). -- **A mid-connect `ApplyDelta` re-runs `InitializeAsync(newConfig)` — so every driver's `InitializeAsync` must - honour the JSON (#664).** `AdoptConfigDuringInit` adopts a delta that lands while Connecting/Reconnecting by - re-initialising, and acks on adoption. Every driver re-parses a config with a body (#516/#630) except two that - used to ignore it: **Calculation** now rebuilds its tag table from it (sharing `AdoptConfig` with - `ReinitializeAsync`, and disposing any running timer scheduler first), and **Galaxy**, which cannot hot-swap - its options, now **refuses** a non-equivalent config with the same `NotSupportedException` its - `ReinitializeAsync` throws — it used to connect on the old endpoint while the deploy reported success. A - `"{}"`/empty document still keeps the constructor options. Once the adopted init succeeds the actor sends - `DeltaApplied`, so the host re-registers a dependency consumer's mux interest — the mid-connect path used to - send nothing. A new driver must follow the same rule. Unreachable from a deploy since #516. +- **There is no in-place config path — a config change is a respawn (#665).** Since #516 any DriverConfig, + DriverType or ResilienceConfig change is stop + respawn, so the factory is the single parse authority. The old + in-place seam (host `ApplyChildDelta` → actor `ApplyDelta` → `ReinitializeAsync`, plus the mid-connect + `AdoptConfigDuringInit`, `ApplyResult` and `DeltaApplied`) had no producer and was deleted after two latent + bugs were found in it: a Connected reinit kept subscription handles the driver had dropped (#663), and the + mid-connect path re-ran `InitializeAsync(newConfig)` on two drivers that ignored the JSON (#664). Kept from that + work: Calculation's `InitializeAsync` adopts a config with a body and Galaxy's refuses a non-equivalent one, + because the `IDriver` contract is that a driver serves the document it is handed. `ReinitializeAsync` stays as a + per-driver contract the runtime does not call. - **Restart tests must assert a FUNCTION, never existence.** `RestartDriver_respawns_the_child_without_an_actor_name_collision` asserts only that a child exists after a restart, and it passed green against the dead driver 03/S16 describes. It is kept — its own subject, the `InvalidActorNameException`, is real — but the load-bearing @@ -458,8 +448,8 @@ have leaked ~360 transports at the 10 s retry interval. The shutdown is best-eff on it still gets its initialise attempt. **`InitializeAsync` itself releases the previous device/connection state first (#630).** The retry above is not -the only way in: `DriverInstanceActor.AdoptConfigDuringInit` calls `InitializeAsync` on a live driver with *no* -teardown, so a driver that does `_devices[host] = new DeviceState(...)` (or `Session = ...`, `Plc = ...`) orphans +the only way in: the `IDriver` contract allows `InitializeAsync` on a live driver with *no* teardown, and a retry +tick can overlap a still-running attempt, so a driver that does `_devices[host] = new DeviceState(...)` (or `Session = ...`, `Plc = ...`) orphans the previous state's probe / recycle / fixed-tree loops and its client. FOCAS, AbLegacy, AbCip, TwinCAT, S7 and OpcUaClient now cancel, bounded-join (5 s) and dispose that state at the top of `InitializeAsync` (`ReleaseDeviceStatesAsync` / `ReleaseConnectionAsync` / `ReleaseSessionAsync`), alongside Modbus and MQTT. diff --git a/docs/IncrementalSync.md b/docs/IncrementalSync.md index deb6be2f..67667378 100644 --- a/docs/IncrementalSync.md +++ b/docs/IncrementalSync.md @@ -16,9 +16,9 @@ changes the served address space — the **deploy artifact** — and one advisor check (`FetchAndCache`) — see `docs/Configuration.md` §`ConfigSource`. 3. **Apply.** `DriverHostActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/`) opens the artifact and reconciles in two halves: - - **Drivers** — `DriverSpawnPlanner.Compute` yields `ToStop` / `ToApplyDelta` / `ToSpawn`. A changed - driver config reaches the live driver as `IDriver.ReinitializeAsync` on its `DriverInstanceActor`; - a removed driver is stopped. An unreadable or empty blob is "no answer": the apply fails and the + - **Drivers** — `DriverSpawnPlanner.Compute` yields `ToStop` / `ToSpawn`. A changed + driver config (DriverConfig, DriverType or ResilienceConfig) is a stop + respawn (#516) — there is no + in-place path (#665 deleted the unreachable `ApplyDelta` seam); a removed driver is stopped. An unreadable or empty blob is "no answer": the apply fails and the running children are left alone (never an empty configuration). - **Address space** — `AddressSpaceComposer` builds an `AddressSpaceComposition` from the artifact; `AddressSpacePlan` diffs it against the last applied composition; `AddressSpaceChangeClassifier` diff --git a/docs/drivers/Mqtt.md b/docs/drivers/Mqtt.md index fb4393c5..f8aef796 100644 --- a/docs/drivers/Mqtt.md +++ b/docs/drivers/Mqtt.md @@ -415,7 +415,7 @@ down rather than swallowing it. A SUBACK failure degrades just the affected RawP There are **two** kinds of reconnect and only one of them fires that callback. A drop the connection's own supervisor recovers keeps the same `MqttConnection`, so `Reconnected` → `OnReconnectedAsync` re-establishes every live filter. A **session rebuild** — `ShutdownAsync` + `InitializeAsync` (the #527 -comms-loss watchdog's retry path, and an `ApplyDelta` that changes the endpoint or credentials) — builds +comms-loss watchdog's retry path, and a `ReinitializeAsync` that changes the endpoint or credentials) — builds a *new* connection and re-attaches the *same* `MqttSubscriptionManager`, and the re-subscribe then comes from the host (`DriverInstanceActor.ResubscribeDesired`) rather than from the callback. `AttachTo` therefore **forgets the filters the previous session had established**: the dedupe set is a claim about diff --git a/docs/v2/Runtime.md b/docs/v2/Runtime.md index 4d5158c7..ee831085 100644 --- a/docs/v2/Runtime.md +++ b/docs/v2/Runtime.md @@ -78,7 +78,7 @@ Per-driver-instance child. State machine: `ShouldStub(driverType, roles)` returns `true` for `"Galaxy" | "Historian.Wonderware"` on non-Windows; the actor goes straight to `Stubbed` and returns deterministic success without touching real hardware. Wiring this into the DriverHost child-spawn path is follow-up F20 (folds into F7). -Engine wiring (subscription publishing, ApplyDelta diff, bad-quality-on-disconnect, write path, supervisor backoff) is stubbed — tracked as F7. Tests exercise message contracts, not engine behaviour. +Engine wiring (subscription publishing, config-change diff, bad-quality-on-disconnect, write path, supervisor backoff) is stubbed — tracked as F7. Tests exercise message contracts, not engine behaviour. ## VirtualTagActor / ScriptedAlarmActor diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs index f735d0f4..0b869802 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs @@ -24,8 +24,9 @@ public interface IDriver /// /// Apply a config change in place without tearing down the driver process. - /// Invoked by the v2 DriverInstanceActor when ApplyDelta reports that only this - /// driver's config changed in the new deployment. + /// The runtime does not call this today: since #516 a DriverConfig change is a stop + respawn, + /// and #665 deleted DriverInstanceActor's in-place ApplyDelta path. Kept as the in-place + /// contract each driver implements and tests. /// /// /// Per docs/v2/driver-stability.md §"In-process only (Tier A/B)" — Reinitialize is the diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 84df5a8b..6287390e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -360,8 +360,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// 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); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index a352a803..5115ee95 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -145,8 +145,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// 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); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs index af48df42..c6274f25 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs @@ -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 _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 (), the change-trigger fan-out /// (), the dependency-ref interest set (), /// and the per-tag state map () from . Called - /// once at construction AND on every so an in-place ApplyDelta — a + /// once at construction AND on every 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. /// Per-tag 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() }, }; - /// Re-bind the authored raw calc tags from a new merged DriverConfig JSON (the ApplyDelta - /// payload). The ctor-fixed timeout is kept — RunTimeout is bound + /// Re-bind the authored raw calc tags from a new merged DriverConfig JSON (an in-place + /// config payload). The ctor-fixed timeout is kept — RunTimeout 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. private CalculationDriverOptions ParseOptions(string driverConfigJson) @@ -287,8 +287,8 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba /// /// - /// #664: InitializeAsync is reachable on a LIVE driver — DriverInstanceActor.AdoptConfigDuringInit - /// re-runs it with a NEW config when an ApplyDelta lands mid-connect — so it adopts a config carrying a + /// #664: InitializeAsync takes a config document and the 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 "{}" / empty /// document keeps the constructor-supplied tags, as unit tests rely on. @@ -308,14 +308,10 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba /// 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. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs index 673b271f..6864a4e1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs @@ -196,8 +196,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// A completed task. 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(); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs index 693619e0..0a7dbf0a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs @@ -349,8 +349,8 @@ public sealed class GalaxyDriver /// /// - /// #664: InitializeAsync is reachable on a live driver with a NEW config — - /// DriverInstanceActor.AdoptConfigDuringInit re-runs it when an ApplyDelta lands mid-connect. The + /// #664: InitializeAsync takes a config document, and the 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 ), 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 diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs index 91c46b04..fc4c6235 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -21,11 +21,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// Agent. Every connection is owned by . /// /// -/// The driverConfigJson argument is honoured, not decorative. The runtime hands -/// a config change to a live instance — DriverInstanceActor's ApplyDelta -/// calls 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 +/// The driverConfigJson argument is honoured, not decorative. +/// and 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 () whenever it carries a real body, /// matching S7/AbCip/TwinCAT/Galaxy rather than the Modbus/FOCAS/OpcUaClient drivers that /// ignore the argument. A blank / {} / [] document means "no config supplied" diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs index e50d433a..a379687d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs @@ -95,7 +95,7 @@ public sealed class ModbusDriver /// concurrent operations would dispose each other's freshly-built transport or null /// _transport out from under an in-flight connect. Not hypothetical: /// DriverInstanceActor dispatches initialize on a fire-and-forget Task.Run and - /// its AdoptConfigDuringInit 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 /// MqttDriver._lifecycleGate. /// 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 diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs index fb39ed43..0a155562 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriver.cs @@ -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); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index e851bc9c..42739295 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -227,8 +227,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit /// 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); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index f25bd794..bda755ca 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -225,8 +225,8 @@ public sealed class S7Driver /// 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); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs index c2d57855..bc5f932e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs @@ -113,8 +113,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery /// 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); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs index 9800e657..77cc2f52 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs @@ -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); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 2c5e6260..3b13328c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -391,10 +391,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS /// value maps so stale condition state never leaks across redeploys. private readonly NativeAlarmProjector _nativeAlarmProjector = new(); - /// In-flight sends, keyed by correlation, so - /// 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. - private readonly Dictionary _pendingDelta = new(); /// One driver child's desired subscription payload — exactly the /// the apply's bulk push sent it. @@ -1345,8 +1341,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS Receive(ForwardNativeAlarm); Receive(OnDriverConnectivityChanged); Receive(HandleCommsRecoveryAudit); - Receive(HandleDeltaApplied); - Receive(HandleApplyResult); Receive(HandleRestartDriver); Receive(HandleReprovisionRequested); Receive(m => ReprovisionChild(m.Request, m.Child)); @@ -1408,8 +1402,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS Receive(ForwardNativeAlarm); Receive(OnDriverConnectivityChanged); Receive(HandleCommsRecoveryAudit); - Receive(HandleDeltaApplied); - Receive(HandleApplyResult); Receive(HandleRestartDriver); Receive(HandleReprovisionRequested); Receive(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(_ => { }); - // 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(HandleDeltaApplied); - Receive(HandleApplyResult); Receive(_ => { /* PubSub ack */ }); Timers.StartPeriodicTimer("retry-db", RetryConfigDbConnection.Instance, ReconnectInterval); } @@ -2704,7 +2692,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS /// /// Read the deployment artifact + reconcile the set of running - /// 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 /// 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). } /// @@ -3557,69 +3540,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS Context.Stop(adapter); } - /// - /// Sends an in-place config delta to a running child. - /// Unreachable from the reconcile path since #516 — a DriverConfig change is now a - /// stop + respawn (), so ToApplyDelta is always empty. - /// Kept because the seam is still exercised by DriverInstanceActor's own mid-connect config - /// adoption. - /// Two seals were removed here. This used to overwrite the cached - /// Spec 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 Telld with no Receive<ApplyResult> - /// handler registered, so a FAILED reinit — including Galaxy's deliberate - /// NotSupportedException — dead-lettered and was never surfaced. - /// - 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); - } - - /// - /// A child's reply to . On success the cached Spec 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. - /// - 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 ?? "", msg.Reason ?? "no reason given"); - } - - /// - /// A driver child finished applying an in-place (its - /// 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 - /// . A Tell (no blocking); a no-op for a driver with no - /// adapter (not an , or no mux wired on this node). - /// - 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; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs index ad3de17c..b5e84b76 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs @@ -25,8 +25,7 @@ file sealed class HealthPollTick /// Failed — terminal until parent restarts the actor. /// /// -/// 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. /// public sealed class DriverInstanceActor : ReceiveActor, IWithTimers @@ -41,7 +40,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// Connected=true on entering the Connected state, false 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 . + /// events. Fire-and-forget. public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected); /// @@ -62,18 +61,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// The driver instance that was restarted. /// The type name of the exception that tripped supervision, for the host's log. 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); - /// - /// Sent to the parent () AFTER an has fully - /// re-initialised the driver (i.e. completed). The host uses - /// it to re-register this driver's dependency-consumer mux adapter: a driver may rebuild its declared - /// 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. - /// - /// The instance whose adapter should be re-registered. - public sealed record DeltaApplied(string DriverInstanceId); public sealed record WriteAttribute(string TagId, object Value); public sealed record WriteAttributeResult(bool Success, string? Reason); /// @@ -221,15 +208,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers private readonly ILoggingAdapter _log = Context.GetLogger(); private string? _currentConfigJson; - /// #664: set when adopts a new config mid-connect; the next - /// (generation-matched) sends 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 path sends. - private bool _adoptedConfigPendingNotify; - /// Monotonic token tagging each attempt. An init result is /// honoured only when its generation matches the latest; an older result is from a superseded attempt - /// (e.g. an 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. 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(_ => { /* no-op */ }); - Receive(msg => Sender.Tell(new ApplyResult(true, "stubbed", msg.Correlation))); Receive(_ => Sender.Tell(new WriteAttributeResult(true, "stubbed"))); // Stubbed drivers have no upstream alarm system — swallow the ack (it's fire-and-forget, no reply). Receive(_ => { /* 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(_ => _log.Debug("DriverInstance {Id}: alarm ack arrived during connect — dropped (driver not connected)", _driverInstanceId)); - Receive(AdoptConfigDuringInit); Receive(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(HandleApplyDeltaAsync); Receive(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(_ => _log.Debug("DriverInstance {Id}: alarm ack arrived during reconnect — dropped (driver not connected)", _driverInstanceId)); - Receive(AdoptConfigDuringInit); Receive(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 }); } - /// Adopt a new config while not connected: ApplyDelta in Connecting/Reconnecting re-inits - /// immediately with the new config. swaps _currentConfigJson 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). - 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)); - } - - /// Send the deferred for a mid-connect adoption (#664) once the driver - /// has actually initialised on the adopted config. A no-op otherwise. - private void NotifyAdoptedConfig() - { - if (!_adoptedConfigPendingNotify) return; - _adoptedConfigPendingNotify = false; - Context.Parent.Tell(new DeltaApplied(_driverInstanceId)); - } - - /// Adopt a new config while Connected, in place, via . - /// #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 finally, 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 's JSON. - /// Not reachable from a deploy since #516 (a DriverConfig change respawns the driver). - 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(); - } - } - - /// 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 - /// (#663). - 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; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs index 37ba98ab..e37d41e3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverSpawnPlan.cs @@ -3,38 +3,31 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; /// /// Pure diff between the currently-running driver children (keyed by /// DriverInstance.DriverInstanceId) 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 ApplyDelta seam. /// -/// Specs with no current child — create a new actor. -/// -/// In-place config deltas. Always empty since #516 — a DriverConfig change is now a -/// stop + respawn so the factory is the single parse authority. Retained because -/// DriverInstanceActor still handles ApplyDelta on its own config-adoption path -/// (a config arriving mid-connect), and removing the list would hide that seam. -/// +/// Specs with no current child (or whose child is being replaced) — create a new actor. /// DriverInstanceIds currently running but missing from the new artifact, or now disabled. public sealed record DriverSpawnPlan( IReadOnlyList ToSpawn, - IReadOnlyList ToApplyDelta, IReadOnlyList ToStop); public static class DriverSpawnPlanner { /// - /// Compute the spawn/delta/stop sets. Disabled entries in are + /// Compute the spawn/stop sets. Disabled entries in 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). /// /// The currently running driver children keyed by ID. /// The target driver instances from the deployment artifact. - /// The computed with spawn, apply-delta, and stop sets. + /// The computed with spawn and stop sets. public static DriverSpawnPlan Compute( IReadOnlyDictionary current, IReadOnlyList target) { var toSpawn = new List(); - var toDelta = new List(); var toStop = new List(); var targetById = new Dictionary(StringComparer.Ordinal); @@ -83,7 +76,7 @@ public static class DriverSpawnPlanner toSpawn.Add(spec); } - return new DriverSpawnPlan(toSpawn, toDelta, toStop); + return new DriverSpawnPlan(toSpawn, toStop); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitializeReleasesStateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitializeReleasesStateTests.cs index ffcce445..85f3d1b5 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitializeReleasesStateTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitializeReleasesStateTests.cs @@ -5,8 +5,8 @@ using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; /// -/// #630 — InitializeAsync on a live driver (DriverInstanceActor.AdoptConfigDuringInit runs -/// it with no teardown first) overwrote _devices[host] and orphaned the previous state's +/// #630 — InitializeAsync on a live driver with no teardown first (the IDriver contract +/// allows it) overwrote _devices[host] and orphaned the previous state's /// probe loop + runtime. It now releases them first. /// [Trait("Category", "Unit")] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationDriverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationDriverTests.cs index d9460d99..5eed4c6b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationDriverTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationDriverTests.cs @@ -396,7 +396,7 @@ public sealed class CalculationDriverTests } /// The skip ledger must be REBUILT on ReinitializeAsync, not just at construction — an - /// ApplyDelta that drops a tag's scriptId (or adds a new tag with none) must be reflected immediately, + /// in-place config change that drops a tag's scriptId (or adds a new tag with none) must be reflected immediately, /// since RebuildTagTable is the one routine both the ctor and reinit share. [Fact] public async Task GetSkippedTags_updates_after_reinitialize() @@ -414,9 +414,9 @@ public sealed class CalculationDriverTests } /// - /// Gitea #664 — InitializeAsync re-runs on a LIVE driver with a new config when an ApplyDelta lands - /// mid-connect (DriverInstanceActor.AdoptConfigDuringInit). It used to ignore the JSON, so the - /// driver kept its constructor tag set while the deploy reported the change applied. A config with a body + /// Gitea #664 — InitializeAsync must serve the config document it is handed (the IDriver + /// contract). It used to ignore the JSON, so a live driver re-initialised with a new config kept its + /// constructor tag set. A config with a body /// now rebuilds the tag table (and the dependency-ref set the host re-registers from). /// [Fact] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitializeReleasesStateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitializeReleasesStateTests.cs index 89b3cd5f..e1687105 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitializeReleasesStateTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReinitializeReleasesStateTests.cs @@ -5,8 +5,8 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// -/// #630 — InitializeAsync on a live driver (DriverInstanceActor.AdoptConfigDuringInit runs -/// it with no teardown first) overwrote _devices[host] and orphaned the previous state's +/// #630 — InitializeAsync on a live driver with no teardown first (the IDriver contract +/// allows it) overwrote _devices[host] and orphaned the previous state's /// probe loop + client. It now releases them first. /// [Trait("Category", "Unit")] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs index 91954ab5..66edc998 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs @@ -209,9 +209,8 @@ public sealed class GalaxyDriverFactoryTests } /// - /// Gitea #664 — InitializeAsync re-runs on a live driver with a NEW config when an ApplyDelta lands - /// mid-connect. Galaxy cannot hot-swap its options, and it used to ignore the JSON — connecting on the OLD - /// endpoint while the deploy reported the change applied. A non-equivalent config is now refused, exactly + /// Gitea #664 — InitializeAsync must not silently ignore the config it is handed. Galaxy cannot + /// hot-swap its options, and it used to ignore the JSON — connecting on the OLD endpoint. A non-equivalent config is now refused, exactly /// as ReinitializeAsync refuses it; an equivalent one still initialises. /// [Fact] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs index ef3dddfe..857f606d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs @@ -265,9 +265,8 @@ public sealed class MTConnectDriverLifecycleTests [Fact] public async Task Initialize_honours_the_config_json_over_the_constructor_options() { - // The runtime delivers a config change ONLY through this argument (DriverInstanceActor - // ApplyDelta -> ReinitializeAsync(json) on the LIVE instance). Ignoring it would make every - // MTConnect config change a silent no-op that still seals the deployment green. + // The IDriver contract: the driver serves the config document it is handed, not only its + // constructor options — ignoring it would turn an in-place config change into a silent no-op. MTConnectDriverOptions? seen = null; var driver = new MTConnectDriver(Opts(), "mt1", o => { @@ -533,7 +532,7 @@ public sealed class MTConnectDriverLifecycleTests [Fact] public async Task Reinitialize_with_an_unreadable_config_keeps_the_running_driver_serving() { - // Blast radius: an unparseable config edit fails the DEPLOYMENT (ApplyResult(false)); it must + // Blast radius: an unparseable in-place config edit fails the reinitialize call; it must // not down a driver that is happily serving its previous, valid configuration. var (driver, client) = await InitializedDriverAsync(); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusLifecycleHygieneTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusLifecycleHygieneTests.cs index df7beb48..316de319 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusLifecycleHygieneTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusLifecycleHygieneTests.cs @@ -225,9 +225,8 @@ public sealed class ModbusLifecycleHygieneTests // -------------------- Re-initialize transport hygiene -------------------- /// - /// A second InitializeAsync on a live driver — the shape - /// DriverInstanceActor.AdoptConfigDuringInit produces when an ApplyDelta lands while the - /// driver is Connecting/Reconnecting — must dispose the transport the previous initialize + /// A second InitializeAsync on a live driver with no teardown between (the IDriver + /// contract allows it) must dispose the transport the previous initialize /// published before publishing a new one. Pre-fix _transport was simply overwritten, /// stranding a connected transport no later dispose could reach. /// @@ -251,7 +250,7 @@ public sealed class ModbusLifecycleHygieneTests }); await drv.InitializeAsync("{}", CancellationToken.None); - // No ShutdownAsync in between — exactly what AdoptConfigDuringInit does. + // No ShutdownAsync in between. await drv.InitializeAsync("{}", CancellationToken.None); handed.Count.ShouldBe(2, "each initialize must build its own transport"); @@ -415,8 +414,8 @@ public sealed class ModbusLifecycleHygieneTests /// /// Two initializes racing on the same driver — the shape DriverInstanceActor produces by - /// dispatching initialize on a fire-and-forget Task.Run with AdoptConfigDuringInit - /// firing twice — must serialize on the lifecycle gate. Ungated, the teardown at the top of one + /// dispatching initialize on a fire-and-forget Task.Run, where a reconnect retry tick can + /// overlap a still-running attempt — must serialize on the lifecycle gate. Ungated, the teardown at the top of one /// initialize disposes the transport the other just published (or nulls _transport /// mid-connect), leaving the driver holding a disposed transport. /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverSessionLifecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverSessionLifecycleTests.cs index 5622a682..ca4e667e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverSessionLifecycleTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverSessionLifecycleTests.cs @@ -28,10 +28,9 @@ public sealed class MqttDriverSessionLifecycleTests /// /// /// - /// Reachable with no teardown of the driver's own: DriverInstanceActor re-runs - /// InitializeAsync with no ShutdownAsync when an ApplyDelta arrives while - /// the driver is Connecting/Reconnecting (AdoptConfigDuringInit). The - /// teardownFirst flag on the #527 retry path covers only the retry path. + /// The IDriver contract allows re-initialising a live driver with no + /// ShutdownAsync, and the #527 retry path's teardownFirst flag does not stop a + /// retry overlapping a still-running attempt, so the driver must not rely on a teardown. /// /// /// The assertion is the broker's live socket count, not the driver's own view: a leak diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs index 6f278194..ce4d4db1 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReconnectTests.cs @@ -483,8 +483,8 @@ public sealed class S7DriverReconnectTests } /// - /// #630 — a second InitializeAsync on a live driver (no ShutdownAsync between, as - /// DriverInstanceActor.AdoptConfigDuringInit does) must stop the first probe loop and + /// #630 — a second InitializeAsync on a live driver (no ShutdownAsync between — the + /// IDriver contract allows it) must stop the first probe loop and /// dispose the first connection rather than orphan them. /// [Fact] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CalculationDependencyFlowTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CalculationDependencyFlowTests.cs index 9a8aa40c..008004d5 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CalculationDependencyFlowTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/CalculationDependencyFlowTests.cs @@ -31,7 +31,7 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase return new RawTagEntry(rawPath, json, WriteIdempotent: false); } - /// Build the merged DriverConfig JSON the ApplyDelta path re-binds from (mirrors the deploy + /// Build the merged DriverConfig JSON an in-place re-bind reads (mirrors the deploy /// artifact folding the driver's RawTags into DriverInstanceSpec.DriverConfig). private static string ConfigJson(params RawTagEntry[] tags) => System.Text.Json.JsonSerializer.Serialize(new { rawTags = tags }); @@ -130,7 +130,7 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase published.ShouldContain(e => e.FullReference == "calc/remove" && Equals(e.Snapshot.Value, 7.0)); }, PresenceBudget, TimeSpan.FromMilliseconds(100)); - // ---- REDEPLOY (in-place ApplyDelta) ---- + // ---- IN-PLACE RE-BIND (driver ReinitializeAsync) ---- // New authored set: "keep" script EDITED to src/a*1000; "add" is a NEW tag reading a NEW dep src/b; // "remove" is dropped entirely. var newConfig = ConfigJson( @@ -141,8 +141,9 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase // The rebuilt dependency-ref set reflects the NEW authored tags (src/a from keep, src/b from add). driver.DependencyRefs.OrderBy(r => r).ShouldBe(new[] { "src/a", "src/b" }); - // Part 2 sequencing: the host re-registers the adapter AFTER reinit (DeltaApplied → HandleDeltaApplied). - // Mirror that here — re-register so the adapter adopts the NEW ref set (incl. the brand-new src/b). + // The adapter re-reads the driver's refs only when told to; re-register AFTER the reinit so it adopts the + // NEW ref set (incl. the brand-new src/b). (The runtime respawns on a config change, #665, so a fresh + // adapter registers the new set in PreStart; this exercises the driver + adapter contract directly.) adapter.Tell(new DependencyConsumerMuxAdapter.ReRegister()); // Leg 2 — after redeploy: the NEW dep src/b flows + the NEW tag computes; the EDITED script serves the diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs index 7663c411..c3895b53 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorNativeAlarmTests.cs @@ -369,56 +369,6 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase .Connected.ShouldBeTrue(); } - /// - /// Gitea #663 — an in-place config change on a Connected driver ( - /// → ) must end with exactly one live data subscription and one live - /// native-alarm subscription at the driver, and the alarm feed still forwarding. The handler used to keep - /// the actor's handles across the reinit while the driver had dropped the subscriptions behind them, so - /// the reconcile (which only re-subscribes a MISSING handle) never restored them and the driver went silent. - /// - /// false = the production shape (reinit drops everything, #630) — - /// the regression; true = a driver that keeps them, which pins that the fix releases before re-subscribing - /// instead of piling a second subscription on top. - [Theory] - [InlineData(false)] - [InlineData(true)] - public void An_in_place_ApplyDelta_leaves_one_live_data_and_one_live_alarm_subscription(bool reinitKeepsSubscriptions) - { - var driver = new AlarmSourceStubDriver { ReinitKeepsSubscriptions = reinitKeepsSubscriptions }; - var publisher = new CountingHealthPublisher(); - var parent = CreateTestProbe(); - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props( - driver, healthPublisher: publisher, healthPollInterval: TimeSpan.FromMilliseconds(100))); - - actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions( - new[] { "tag-a" }, TimeSpan.FromMilliseconds(100), new[] { "Temp.HiHi" })); - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - AwaitCondition(() => driver.LiveDataSubscriptions == 1 && driver.LiveAlarmSubscriptions == 1, PresenceBudget); - - var correlation = CorrelationId.NewId(); - actor.Tell(new DriverInstanceActor.ApplyDelta("{\"changed\":true}", correlation), parent.Ref); - parent.FishForMessage(r => r.Correlation == correlation, PresenceBudget) - .Success.ShouldBeTrue(); - driver.ReinitializeCount.ShouldBe(1); - driver.InitializeCount.ShouldBe(1, "an in-place delta must not go through the reconnect path"); - - AwaitCondition(() => driver.LiveDataSubscriptions == 1 && driver.LiveAlarmSubscriptions == 1, PresenceBudget); - // The "not two" half is an absence, so wait for three more health-poll ticks — each runs the reconcile - // that would issue a duplicate — before re-checking. - var ticks = publisher.Count; - AwaitCondition(() => publisher.Count >= ticks + 3, PresenceBudget); - driver.LiveDataSubscriptions.ShouldBe(1); - driver.LiveAlarmSubscriptions.ShouldBe(1); - driver.AlarmSubscriberCount.ShouldBe(1); - - driver.RaiseAlarm(new AlarmEventArgs( - new StubAlarmHandle(), "Temp.HiHi", "Temp.HiHi", "T", "after reinit", AlarmSeverity.High, - DateTime.UtcNow, Kind: AlarmTransitionKind.Raise)); - parent.FishForMessage(_ => true, PresenceBudget) - .Args.ConditionId.ShouldBe("Temp.HiHi"); - } - // --- stub drivers ---------------------------------------------------------------------------- private class StubDriver : IDriver @@ -439,7 +389,7 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase } /// Reinitializes the driver. - public virtual Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => + public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; /// Shuts down the driver. @@ -463,55 +413,14 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase /// Number of live subscribers on — proves attach/detach. public int AlarmSubscriberCount => OnAlarmEvent?.GetInvocationList().Length ?? 0; - private readonly object _liveLock = new(); - private readonly HashSet _liveData = new(ReferenceEqualityComparer.Instance); - private readonly HashSet _liveAlarms = new(ReferenceEqualityComparer.Instance); - private int _reinitializeCount; - - /// Driver-side data subscriptions currently open (subscribed and not yet released). - public int LiveDataSubscriptions { get { lock (_liveLock) return _liveData.Count; } } - - /// Driver-side native-alarm subscriptions currently open. - public int LiveAlarmSubscriptions { get { lock (_liveLock) return _liveAlarms.Count; } } - - /// Number of calls. - public int ReinitializeCount => Volatile.Read(ref _reinitializeCount); - - /// When false (the default, and every production driver since #630), a reinit releases the - /// device/session state and with it every driver-side subscription; when true it keeps them — the - /// shape that would double-subscribe if the actor re-subscribed without releasing first. - public bool ReinitKeepsSubscriptions { get; init; } - - /// - public override Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) - { - Interlocked.Increment(ref _reinitializeCount); - if (!ReinitKeepsSubscriptions) - { - lock (_liveLock) - { - _liveData.Clear(); - _liveAlarms.Clear(); - } - } - return Task.CompletedTask; - } - /// Subscribes to the specified full references. public Task SubscribeAsync( - IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) - { - var handle = new StubHandle(); - lock (_liveLock) _liveData.Add(handle); - return Task.FromResult(handle); - } + IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) => + Task.FromResult(_subHandle); /// Unsubscribes from the specified subscription handle. - public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) - { - lock (_liveLock) _liveData.Remove(handle); - return Task.CompletedTask; - } + public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) => + Task.CompletedTask; private int _subscribeAlarmsCallCount; @@ -530,9 +439,7 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase var call = Interlocked.Increment(ref _subscribeAlarmsCallCount); if (call <= AlarmSubscribeFailuresBeforeSuccess) throw new InvalidOperationException($"alarm subscribe failure {call} (test-injected)"); - var handle = new StubAlarmHandle(); - lock (_liveLock) _liveAlarms.Add(handle); - return Task.FromResult(handle); + return Task.FromResult(new StubAlarmHandle()); } /// How many leading calls throw — the "session not ready @@ -540,11 +447,8 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase public int AlarmSubscribeFailuresBeforeSuccess { get; init; } /// Cancels an alarm subscription. - public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) - { - lock (_liveLock) _liveAlarms.Remove(handle); - return Task.CompletedTask; - } + public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) => + Task.CompletedTask; /// Acknowledges a batch of alarms. public Task AcknowledgeAsync( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorTests.cs index 9bb72614..1de952b5 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorTests.cs @@ -11,28 +11,6 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; public sealed class DriverInstanceActorTests : RuntimeActorTestBase { - /// Verifies that ApplyDelta calls ReinitializeAsync when connected and replies success. - [Fact] - public async Task ApplyDelta_when_Connected_calls_ReinitializeAsync_and_replies_success() - { - var driver = new StubDriver(); - var actor = Sys.ActorOf(DriverInstanceActor.Props(driver)); - - // Drive: Initialize → Connected. - actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); - AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2)); - - // Issue ApplyDelta and capture the reply via Ask. - var correlation = CorrelationId.NewId(); - var reply = await actor.Ask( - new DriverInstanceActor.ApplyDelta("{\"changed\":true}", correlation), - TimeSpan.FromSeconds(3)); - - reply.Success.ShouldBeTrue(); - reply.Correlation.ShouldBe(correlation); - driver.ReinitializeCount.ShouldBe(1); - } - /// Verifies that initialize failure keeps the actor in Reconnecting state. [Fact] public void Initialize_failure_keeps_actor_in_Reconnecting_state() @@ -305,109 +283,6 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } - /// A driver stuck Reconnecting (init failing on a bad config) adopts a corrected config - /// delivered via ApplyDelta and connects on it — no node restart. Closes pending.md #7. - [Fact] - public async Task ApplyDelta_while_Reconnecting_adopts_new_config_and_connects() - { - const string bad = "{\"v\":\"bad\"}"; - const string good = "{\"v\":\"good\"}"; - var driver = new SubscribableStubDriver - { - InitBehavior = cfg => cfg == good ? Task.CompletedTask : throw new InvalidOperationException("bad-cfg"), - }; - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50))); - - actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100))); - - actor.Tell(new DriverInstanceActor.InitializeRequested(bad)); - AwaitCondition(() => driver.InitializeCount >= 2, TimeSpan.FromSeconds(2)); - driver.SubscribeCount.ShouldBe(0); - - var correlation = CorrelationId.NewId(); - var reply = await actor.Ask( - new DriverInstanceActor.ApplyDelta(good, correlation), TimeSpan.FromSeconds(3)); - reply.Success.ShouldBeTrue(); - reply.Correlation.ShouldBe(correlation); - - AwaitCondition(() => driver.SubscribeCount >= 1, TimeSpan.FromSeconds(3)); - driver.InitConfigs.ShouldContain(good); - } - - /// - /// Gitea #664 — a mid-connect adoption sends once the - /// driver has initialised on the ADOPTED config, so the host re-registers a dependency consumer's mux - /// interest (the Calculation driver's refs change with its tag set). It used to send nothing, leaving the - /// old interest registered. The notification is one-shot: a later reconnect on the same config sends none. - /// - [Fact] - public void A_mid_connect_adoption_notifies_DeltaApplied_once_after_the_adopted_init_succeeds() - { - const string bad = "{\"v\":\"bad\"}"; - const string good = "{\"v\":\"good\"}"; - var driver = new SubscribableStubDriver - { - InitBehavior = cfg => cfg == good ? Task.CompletedTask : throw new InvalidOperationException("bad-cfg"), - }; - var parent = CreateTestProbe(); - parent.IgnoreMessages(m => m is not DriverInstanceActor.DeltaApplied); - var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50))); - actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100))); - - actor.Tell(new DriverInstanceActor.InitializeRequested(bad)); - AwaitCondition(() => driver.InitializeCount >= 2, PresenceBudget); - - actor.Tell(new DriverInstanceActor.ApplyDelta(good, CorrelationId.NewId())); - parent.ExpectMsg(PresenceBudget); - driver.InitConfigs.ShouldContain(good, "the notification must follow an init on the adopted config"); - - // A reconnect on the same config re-runs InitializeSucceeded; the positive control is its re-subscribe. - var subscribes = driver.SubscribeCount; - actor.Tell(new DriverInstanceActor.ForceReconnect()); - AwaitCondition(() => driver.SubscribeCount > subscribes, PresenceBudget); - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); - } - - /// A stale InitializeSucceeded from an old (superseded) config cannot hijack the state: - /// while a gated old-config init is pending in Connecting, an ApplyDelta adopts a new config; the - /// old init completing afterwards is ignored, and only the new config drives Connected. - [Fact] - public async Task ApplyDelta_supersedes_in_flight_init_so_stale_result_is_ignored() - { - const string v1 = "{\"v\":1}"; - const string v2 = "{\"v\":2}"; - var gate1 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var gate2 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var driver = new SubscribableStubDriver - { - InitBehavior = cfg => cfg == v1 ? gate1.Task : gate2.Task, - }; - var parent = CreateTestProbe(); - // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ - // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. - parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); - var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromSeconds(30))); - actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100))); - - actor.Tell(new DriverInstanceActor.InitializeRequested(v1)); - AwaitCondition(() => driver.InitConfigs.Contains(v1), TimeSpan.FromSeconds(2)); - - await actor.Ask( - new DriverInstanceActor.ApplyDelta(v2, CorrelationId.NewId()), TimeSpan.FromSeconds(3)); - AwaitCondition(() => driver.InitConfigs.Contains(v2), TimeSpan.FromSeconds(2)); - - gate1.SetResult(); - parent.ExpectNoMsg(TimeSpan.FromMilliseconds(400)); - driver.SubscribeCount.ShouldBe(0); - - gate2.SetResult(); - AwaitCondition(() => driver.SubscribeCount >= 1, TimeSpan.FromSeconds(3)); - } - /// /// Archreview 03/S15 (T11) — a child that is stopped announces comms loss on its way /// out, so the host latches its value nodes BadNoCommunication. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs index 541d5a1f..3b51388c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverSpawnPlannerTests.cs @@ -19,7 +19,6 @@ public sealed class DriverSpawnPlannerTests var plan = DriverSpawnPlanner.Compute(current, target); plan.ToSpawn.Count.ShouldBe(2); - plan.ToApplyDelta.ShouldBeEmpty(); plan.ToStop.ShouldBeEmpty(); } @@ -36,7 +35,6 @@ public sealed class DriverSpawnPlannerTests var plan = DriverSpawnPlanner.Compute(current, target); plan.ToSpawn.ShouldBeEmpty(); - plan.ToApplyDelta.ShouldBeEmpty(); plan.ToStop.ShouldBeEmpty(); } @@ -65,7 +63,6 @@ public sealed class DriverSpawnPlannerTests plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a"); plan.ToSpawn.Single().DriverConfig.ShouldBe("{\"host\":\"new\"}"); // The whole point: nothing is left on the in-place path that could discard the edit. - plan.ToApplyDelta.ShouldBeEmpty(); } /// Verifies that removed drivers are routed to ToStop. @@ -83,7 +80,6 @@ public sealed class DriverSpawnPlannerTests plan.ToStop.ShouldBe(new[] { "b" }); plan.ToSpawn.ShouldBeEmpty(); - plan.ToApplyDelta.ShouldBeEmpty(); } /// Verifies that disabled drivers with running children are routed to ToStop. @@ -100,7 +96,6 @@ public sealed class DriverSpawnPlannerTests plan.ToStop.Single().ShouldBe("a"); plan.ToSpawn.ShouldBeEmpty(); - plan.ToApplyDelta.ShouldBeEmpty(); } /// Verifies that disabled new drivers are not spawned. @@ -113,7 +108,6 @@ public sealed class DriverSpawnPlannerTests var plan = DriverSpawnPlanner.Compute(current, target); plan.ToSpawn.ShouldBeEmpty(); - plan.ToApplyDelta.ShouldBeEmpty(); plan.ToStop.ShouldBeEmpty(); } @@ -131,7 +125,6 @@ public sealed class DriverSpawnPlannerTests plan.ToStop.Single().ShouldBe("a"); plan.ToSpawn.Single().DriverType.ShouldBe("AbCip"); - plan.ToApplyDelta.ShouldBeEmpty(); } /// @@ -153,7 +146,6 @@ public sealed class DriverSpawnPlannerTests plan.ToStop.Single().ShouldBe("a"); plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a"); - plan.ToApplyDelta.ShouldBeEmpty(); } /// @@ -173,7 +165,6 @@ public sealed class DriverSpawnPlannerTests plan.ToStop.Single().ShouldBe("a"); plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a"); - plan.ToApplyDelta.ShouldBeEmpty(); } /// @@ -196,6 +187,5 @@ public sealed class DriverSpawnPlannerTests plan.ToStop.Single().ShouldBe("a"); plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a"); - plan.ToApplyDelta.ShouldBeEmpty(); } }