Merge hw6-665 (#665 delete the dead in-place ApplyDelta seam)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -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;
|
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
|
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
|
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
|
- **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
|
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
|
`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.
|
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
|
- **A config change is a respawn — there is NO in-place path (#665).** The `ApplyDelta`/`ApplyResult`/`DeltaApplied`
|
||||||
driver, reinitialises, then re-subscribes both (#663) — it used to keep handles the reinit had invalidated.
|
seam (host `ApplyChildDelta`, `DriverSpawnPlan.ToApplyDelta`, the actor's Connected reinit and mid-connect
|
||||||
A mid-connect `ApplyDelta` re-runs `InitializeAsync(newConfig)`, so **every driver's `InitializeAsync` must adopt a
|
`AdoptConfigDuringInit`) was unreachable since #516 and is deleted; #663/#664 were latent bugs in it. `IDriver.ReinitializeAsync`
|
||||||
config with a body** — Calculation now does, Galaxy (no hot-swap) now refuses a non-equivalent one, and the actor
|
stays as a per-driver contract the runtime never calls. Every driver's `InitializeAsync` still serves the config it is
|
||||||
sends `DeltaApplied` once the adopted init succeeds (#664).
|
handed (Calculation adopts, Galaxy refuses a non-equivalent one — #664), and still releases prior state (#630).
|
||||||
`docs/DriverLifecycle.md`.
|
`docs/DriverLifecycle.md`.
|
||||||
- **S7 (#632/#638):** STRING/WSTRING/CHAR/WCHAR and DTL/DATE/TOD/TIME/S5TIME codecs (`S7ValueCodecs`; TOD/TIME/S5TIME
|
- **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.
|
surface as Int32 ms); an undecodable buffer or unrepresentable write is `BadOutOfRange` with no health impact.
|
||||||
|
|||||||
+11
-21
@@ -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
|
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.
|
subscription from its session when the subscribe throws, so those retries do not accumulate.
|
||||||
End-to-end pin: `NativeAlarmFeedSurvivesDeployTests`.
|
End-to-end pin: `NativeAlarmFeedSurvivesDeployTests`.
|
||||||
- **An in-place `ApplyDelta` on a Connected driver releases and re-establishes BOTH subscriptions (#663).**
|
- **There is no in-place config path — a config change is a respawn (#665).** Since #516 any DriverConfig,
|
||||||
`ReinitializeAsync` drops every driver-side subscription (#630), but the handler used to keep the actor's
|
DriverType or ResilienceConfig change is stop + respawn, so the factory is the single parse authority. The old
|
||||||
handles, and the reconcile only re-subscribes a *missing* handle — so the driver went silent until a
|
in-place seam (host `ApplyChildDelta` → actor `ApplyDelta` → `ReinitializeAsync`, plus the mid-connect
|
||||||
reconnect. It now releases data and alarms at the driver first (explicitly, so a driver whose reinit keeps
|
`AdoptConfigDuringInit`, `ApplyResult` and `DeltaApplied`) had no producer and was deleted after two latent
|
||||||
its subscriptions is not left holding two), reinitialises, and re-subscribes both in a `finally` (a failed
|
bugs were found in it: a Connected reinit kept subscription handles the driver had dropped (#663), and the
|
||||||
reinit has lost them too). It stays on `ReinitializeAsync` rather than shutdown + initialise because
|
mid-connect path re-ran `InitializeAsync(newConfig)` on two drivers that ignored the JSON (#664). Kept from that
|
||||||
Calculation and Galaxy take their config at construction and ignore the JSON passed to `InitializeAsync`.
|
work: Calculation's `InitializeAsync` adopts a config with a body and Galaxy's refuses a non-equivalent one,
|
||||||
Not reachable from a deploy since #516 (a DriverConfig change respawns). Pin:
|
because the `IDriver` contract is that a driver serves the document it is handed. `ReinitializeAsync` stays as a
|
||||||
`An_in_place_ApplyDelta_leaves_one_live_data_and_one_live_alarm_subscription` (one case per wrong fix).
|
per-driver contract the runtime does not call.
|
||||||
- **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.
|
|
||||||
- **Restart tests must assert a FUNCTION, never existence.** `RestartDriver_respawns_the_child_without_an_actor_name_collision`
|
- **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
|
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
|
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.
|
on it still gets its initialise attempt.
|
||||||
|
|
||||||
**`InitializeAsync` itself releases the previous device/connection state first (#630).** The retry above is not
|
**`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*
|
the only way in: the `IDriver` contract allows `InitializeAsync` on a live driver with *no* teardown, and a retry
|
||||||
teardown, so a driver that does `_devices[host] = new DeviceState(...)` (or `Session = ...`, `Plc = ...`) orphans
|
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
|
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`
|
OpcUaClient now cancel, bounded-join (5 s) and dispose that state at the top of `InitializeAsync`
|
||||||
(`ReleaseDeviceStatesAsync` / `ReleaseConnectionAsync` / `ReleaseSessionAsync`), alongside Modbus and MQTT.
|
(`ReleaseDeviceStatesAsync` / `ReleaseConnectionAsync` / `ReleaseSessionAsync`), alongside Modbus and MQTT.
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ changes the served address space — the **deploy artifact** — and one advisor
|
|||||||
check (`FetchAndCache`) — see `docs/Configuration.md` §`ConfigSource`.
|
check (`FetchAndCache`) — see `docs/Configuration.md` §`ConfigSource`.
|
||||||
3. **Apply.** `DriverHostActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/`) opens the artifact and
|
3. **Apply.** `DriverHostActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/`) opens the artifact and
|
||||||
reconciles in two halves:
|
reconciles in two halves:
|
||||||
- **Drivers** — `DriverSpawnPlanner.Compute` yields `ToStop` / `ToApplyDelta` / `ToSpawn`. A changed
|
- **Drivers** — `DriverSpawnPlanner.Compute` yields `ToStop` / `ToSpawn`. A changed
|
||||||
driver config reaches the live driver as `IDriver.ReinitializeAsync` on its `DriverInstanceActor`;
|
driver config (DriverConfig, DriverType or ResilienceConfig) is a stop + respawn (#516) — there is no
|
||||||
a removed driver is stopped. An unreadable or empty blob is "no answer": the apply fails and the
|
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).
|
running children are left alone (never an empty configuration).
|
||||||
- **Address space** — `AddressSpaceComposer` builds an `AddressSpaceComposition` from the artifact;
|
- **Address space** — `AddressSpaceComposer` builds an `AddressSpaceComposition` from the artifact;
|
||||||
`AddressSpacePlan` diffs it against the last applied composition; `AddressSpaceChangeClassifier`
|
`AddressSpacePlan` diffs it against the last applied composition; `AddressSpaceChangeClassifier`
|
||||||
|
|||||||
@@ -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
|
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`
|
own supervisor recovers keeps the same `MqttConnection`, so `Reconnected` → `OnReconnectedAsync`
|
||||||
re-establishes every live filter. A **session rebuild** — `ShutdownAsync` + `InitializeAsync` (the #527
|
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
|
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`
|
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
|
therefore **forgets the filters the previous session had established**: the dedupe set is a claim about
|
||||||
|
|||||||
+1
-1
@@ -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).
|
`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
|
## VirtualTagActor / ScriptedAlarmActor
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ public interface IDriver
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Apply a config change in place without tearing down the driver process.
|
/// Apply a config change in place without tearing down the driver process.
|
||||||
/// Invoked by the v2 <c>DriverInstanceActor</c> when ApplyDelta reports that only this
|
/// <para>The runtime does not call this today: since #516 a DriverConfig change is a stop + respawn,
|
||||||
/// driver's config changed in the new deployment.
|
/// and #665 deleted <c>DriverInstanceActor</c>'s in-place <c>ApplyDelta</c> path. Kept as the in-place
|
||||||
|
/// contract each driver implements and tests.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Per <c>docs/v2/driver-stability.md</c> §"In-process only (Tier A/B)" — Reinitialize is the
|
/// Per <c>docs/v2/driver-stability.md</c> §"In-process only (Tier A/B)" — Reinitialize is the
|
||||||
|
|||||||
@@ -360,8 +360,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit
|
// #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
|
||||||
// runs it with no teardown first). Release the previous device states' probe loops + handles before
|
// 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.
|
// the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions.
|
||||||
await ReleaseDeviceStatesAsync(cancellationToken).ConfigureAwait(false);
|
await ReleaseDeviceStatesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||||
|
|||||||
@@ -145,8 +145,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// #630: InitializeAsync can run on a live driver (DriverInstanceActor.AdoptConfigDuringInit calls it
|
// #630: InitializeAsync can run on a live driver without a teardown first (the IDriver contract
|
||||||
// without a teardown first). Release the previous device states' probe loops + runtimes BEFORE the
|
// 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
|
// 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).
|
// 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);
|
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
|
// 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),
|
// 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);
|
private readonly Dictionary<string, CalculationTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
// rawPath (a dependency) → the calc tags that read it AND are change-triggered. Drives OnDependencyValue.
|
// 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
|
/// (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"/>),
|
/// (<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
|
/// 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
|
/// 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.
|
/// 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
|
/// <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
|
// 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()
|
private static readonly JsonSerializerOptions ReinitJsonOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true,
|
PropertyNameCaseInsensitive = true,
|
||||||
@@ -209,8 +209,8 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
|||||||
Converters = { new JsonStringEnumConverter() },
|
Converters = { new JsonStringEnumConverter() },
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Re-bind the authored raw calc tags from a new merged DriverConfig JSON (the ApplyDelta
|
/// <summary>Re-bind the authored raw calc tags from a new merged DriverConfig JSON (an in-place
|
||||||
/// payload). The ctor-fixed <see cref="CalculationEvaluator"/> timeout is kept — <c>RunTimeout</c> is bound
|
/// 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
|
/// 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>
|
/// current tags so a malformed delta can never blank the driver.</summary>
|
||||||
private CalculationDriverOptions ParseOptions(string driverConfigJson)
|
private CalculationDriverOptions ParseOptions(string driverConfigJson)
|
||||||
@@ -287,8 +287,8 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
|||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// #664: <c>InitializeAsync</c> is reachable on a LIVE driver — <c>DriverInstanceActor.AdoptConfigDuringInit</c>
|
/// #664: <c>InitializeAsync</c> takes a config document and the <see cref="IDriver"/> contract is that the
|
||||||
/// re-runs it with a NEW config when an ApplyDelta lands mid-connect — so it adopts a config carrying a
|
/// 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
|
/// 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
|
/// 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.
|
/// document keeps the constructor-supplied tags, as unit tests rely on.
|
||||||
@@ -308,14 +308,10 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// A calc-tag add/remove/edit — or a script edit that changes the injected scriptSource — changes the
|
// An in-place config change: re-bind the new config and REBUILD the tag table + dependency-ref set, so
|
||||||
// merged DriverConfig, which DriverSpawnPlanner routes to an in-place ApplyDelta → THIS call (a
|
// added tags compute, removed tags stop publishing and edited scripts serve the new result. The runtime
|
||||||
// respawn fires only on a DriverType/ResilienceConfig change). So the authored tag table is NOT
|
// itself applies a DriverConfig change by respawning (#516/#665), where a fresh driver registers its
|
||||||
// fixed at construction: we re-bind the new config and REBUILD the tag table + dependency-ref set
|
// new dependency refs on construction; this path keeps the IDriver contract honest for any caller.
|
||||||
// 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).
|
|
||||||
StopTimers();
|
StopTimers();
|
||||||
AdoptConfig(driverConfigJson);
|
AdoptConfig(driverConfigJson);
|
||||||
// Re-arm timers off the rebuilt tag set.
|
// 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>
|
/// <returns>A completed task.</returns>
|
||||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// #630: release the previous device states' loops + clients BEFORE they are overwritten (the actor's
|
// #630: release the previous device states' loops + clients BEFORE they are overwritten (a live driver can be
|
||||||
// AdoptConfigDuringInit calls this without a teardown first). Not ShutdownAsync: that also drops _poll
|
// 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).
|
// and subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init).
|
||||||
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
|
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
|
||||||
ResetAlarmHealth();
|
ResetAlarmHealth();
|
||||||
|
|||||||
@@ -349,8 +349,8 @@ public sealed class GalaxyDriver
|
|||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// #664: <c>InitializeAsync</c> is reachable on a live driver with a NEW config —
|
/// #664: <c>InitializeAsync</c> takes a config document, and the <see cref="IDriver"/> contract is that a
|
||||||
/// <c>DriverInstanceActor.AdoptConfigDuringInit</c> re-runs it when an ApplyDelta lands mid-connect. The
|
/// 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
|
/// 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
|
/// 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
|
/// 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"/>.
|
/// Agent. Every connection is owned by <see cref="InitializeAsync"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The <c>driverConfigJson</c> argument is honoured, not decorative.</b> The runtime hands
|
/// <b>The <c>driverConfigJson</c> argument is honoured, not decorative.</b> <see cref="IDriver.InitializeAsync"/>
|
||||||
/// a config change to a <i>live</i> instance — <c>DriverInstanceActor</c>'s <c>ApplyDelta</c>
|
/// and <see cref="ReinitializeAsync"/> both take the document, and the contract is that the driver
|
||||||
/// calls <see cref="ReinitializeAsync"/> with the new document and never rebuilds the object
|
/// serves it — a driver that read only its constructor options would turn an in-place config
|
||||||
/// — so a driver that read only its constructor options would turn every MTConnect config
|
/// change into a silent no-op. (The runtime itself now applies a config change by respawning,
|
||||||
/// edit into a silent no-op that still seals the deployment green. This class therefore
|
/// #516/#665.) This class therefore
|
||||||
/// re-parses the document (<see cref="ParseOptions"/>) whenever it carries a real body,
|
/// 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
|
/// 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"
|
/// 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
|
/// 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>_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
|
/// <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>.
|
/// <c>MqttDriver._lifecycleGate</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly SemaphoreSlim _lifecycleGate = new(1, 1);
|
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
|
// Release whatever a PREVIOUS initialize published before building the replacements. All
|
||||||
// three fields assigned below (_transport, _probeCts, _reprobeCts) were simply overwritten,
|
// 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
|
// 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
|
// background loops that no later dispose could reach. The IDriver contract lets a
|
||||||
// ours: DriverInstanceActor re-runs InitializeAsync with no ShutdownAsync when an ApplyDelta
|
// caller re-initialise a live driver without ShutdownAsync, and DriverInstanceActor's retry tick
|
||||||
// lands while the driver is in Connecting/Reconnecting (AdoptConfigDuringInit).
|
// 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
|
// Deliberately NOT the shared TeardownAsync(): that also disposes _poll, which tears down
|
||||||
// every ACTIVE SUBSCRIPTION — and nothing re-subscribes them afterwards, because a
|
// 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
|
// 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
|
// published below (_connection, _primaryHost) are IAsyncDisposable and were previously
|
||||||
// overwritten, so a second initialize on a live driver stranded a connected, supervised
|
// 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
|
// broker session — plus a whole second primary-host connection — that no later dispose
|
||||||
// could reach. Reachable without any teardown of ours: DriverInstanceActor re-runs
|
// could reach. The IDriver contract lets a caller
|
||||||
// InitializeAsync with no ShutdownAsync when an ApplyDelta lands while the driver is in
|
// re-initialise a live driver without ShutdownAsync, and a reconnect retry can overlap a running
|
||||||
// Connecting/Reconnecting (AdoptConfigDuringInit). Idempotent, so the ReinitializeAsync
|
// attempt. Idempotent, so the ReinitializeAsync
|
||||||
// rebuild branch calling TeardownAsync first costs a no-op, and a first initialize sees
|
// rebuild branch calling TeardownAsync first costs a no-op, and a first initialize sees
|
||||||
// three nulls.
|
// three nulls.
|
||||||
await TeardownAsync().ConfigureAwait(false);
|
await TeardownAsync().ConfigureAwait(false);
|
||||||
|
|||||||
@@ -227,8 +227,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit runs
|
// #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
|
||||||
// it with no teardown first); `Session = session` below would otherwise orphan the previous session,
|
// 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.
|
// its keep-alive handler and any reconnect handler.
|
||||||
await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false);
|
await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||||
|
|||||||
@@ -225,8 +225,8 @@ public sealed class S7Driver
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit
|
// #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
|
||||||
// runs it with no teardown first). Stop the previous probe loop and drop the previous connection
|
// 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
|
// 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).
|
// subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init).
|
||||||
await ReleaseConnectionAsync().ConfigureAwait(false);
|
await ReleaseConnectionAsync().ConfigureAwait(false);
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit
|
// #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
|
||||||
// runs it with no teardown first). Release the previous device states' probe loops + clients before
|
// 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.
|
// the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions.
|
||||||
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
|
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
|
||||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||||
|
|||||||
@@ -406,7 +406,7 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
|
|||||||
// v3: each spec's DriverConfig is the MERGED config the driver factory binds from —
|
// 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
|
// DriverConfig (protocol/channel) + per-Device DeviceConfig (endpoint) + the driver's authored
|
||||||
// raw tags (RawTags array, keyed by RawPath) — folded once here via DriverDeviceConfigMerger so
|
// 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 rawTagsByDriver = BuildRawTagsByDriver(root);
|
||||||
var deviceRowsByDriver = BuildDeviceRowsByDriver(root);
|
var deviceRowsByDriver = BuildDeviceRowsByDriver(root);
|
||||||
|
|
||||||
|
|||||||
@@ -391,10 +391,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
/// value maps so stale condition state never leaks across redeploys.</summary>
|
/// value maps so stale condition state never leaks across redeploys.</summary>
|
||||||
private readonly NativeAlarmProjector _nativeAlarmProjector = new();
|
private readonly NativeAlarmProjector _nativeAlarmProjector = new();
|
||||||
|
|
||||||
/// <summary>In-flight <see cref="DriverInstanceActor.ApplyDelta"/> sends, keyed by correlation, so
|
|
||||||
/// <see cref="HandleApplyResult"/> can advance the cached spec only once the child confirms it adopted
|
|
||||||
/// the config (#516). Bounded by the number of driver children with a delta in flight.</summary>
|
|
||||||
private readonly Dictionary<CorrelationId, DriverInstanceSpec> _pendingDelta = new();
|
|
||||||
|
|
||||||
/// <summary>One driver child's desired subscription payload — exactly the
|
/// <summary>One driver child's desired subscription payload — exactly the
|
||||||
/// <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> the apply's bulk push sent it.</summary>
|
/// <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> the apply's bulk push sent it.</summary>
|
||||||
@@ -1345,8 +1341,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||||
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||||
Receive<CommsRecoveryAudit>(HandleCommsRecoveryAudit);
|
Receive<CommsRecoveryAudit>(HandleCommsRecoveryAudit);
|
||||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
|
||||||
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
|
|
||||||
Receive<RestartDriver>(HandleRestartDriver);
|
Receive<RestartDriver>(HandleRestartDriver);
|
||||||
Receive<DriverInstanceActor.ReprovisionRequested>(HandleReprovisionRequested);
|
Receive<DriverInstanceActor.ReprovisionRequested>(HandleReprovisionRequested);
|
||||||
Receive<ReprovisionDeferred>(m => ReprovisionChild(m.Request, m.Child));
|
Receive<ReprovisionDeferred>(m => ReprovisionChild(m.Request, m.Child));
|
||||||
@@ -1408,8 +1402,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
|
||||||
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
|
||||||
Receive<CommsRecoveryAudit>(HandleCommsRecoveryAudit);
|
Receive<CommsRecoveryAudit>(HandleCommsRecoveryAudit);
|
||||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
|
||||||
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
|
|
||||||
Receive<RestartDriver>(HandleRestartDriver);
|
Receive<RestartDriver>(HandleRestartDriver);
|
||||||
Receive<DriverInstanceActor.ReprovisionRequested>(HandleReprovisionRequested);
|
Receive<DriverInstanceActor.ReprovisionRequested>(HandleReprovisionRequested);
|
||||||
Receive<ReprovisionDeferred>(m => ReprovisionChild(m.Request, m.Child));
|
Receive<ReprovisionDeferred>(m => ReprovisionChild(m.Request, m.Child));
|
||||||
@@ -2284,10 +2276,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
// Likewise the 03/S15 recovery audit: a timer armed before the host went Stale has nothing to report
|
// 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).
|
// on (no latch was set while Stale, and the rebuild re-establishes every node's status anyway).
|
||||||
Receive<CommsRecoveryAudit>(_ => { });
|
Receive<CommsRecoveryAudit>(_ => { });
|
||||||
// A late DeltaApplied (an apply completed just before the DB went Stale) — re-register the driver's
|
|
||||||
// mux adapter anyway; it simply re-reads the driver's current refs (harmless, no DB access).
|
|
||||||
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
|
|
||||||
Receive<DriverInstanceActor.ApplyResult>(HandleApplyResult);
|
|
||||||
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
|
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
|
||||||
Timers.StartPeriodicTimer("retry-db", RetryConfigDbConnection.Instance, ReconnectInterval);
|
Timers.StartPeriodicTimer("retry-db", RetryConfigDbConnection.Instance, ReconnectInterval);
|
||||||
}
|
}
|
||||||
@@ -2704,7 +2692,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read the deployment artifact + reconcile the set of running <see cref="DriverInstanceActor"/>
|
/// Read the deployment artifact + reconcile the set of running <see cref="DriverInstanceActor"/>
|
||||||
/// children. Spawn missing, ApplyDelta on config change, stop removed/disabled drivers.
|
/// children. Spawn missing, respawn on config change, stop removed/disabled drivers.
|
||||||
/// When the artifact blob is empty (legacy ControlPlane tests, smoke fixtures) the reconcile is
|
/// 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
|
/// SKIPPED outright — see the issue #485 guard below — and when the configured
|
||||||
/// <see cref="IDriverFactory"/> can't materialise any of the requested types this is a no-op.
|
/// <see cref="IDriverFactory"/> can't materialise any of the requested types this is a no-op.
|
||||||
@@ -2917,16 +2905,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
var plan = DriverSpawnPlanner.Compute(snapshots, specs);
|
var plan = DriverSpawnPlanner.Compute(snapshots, specs);
|
||||||
|
|
||||||
foreach (var id in plan.ToStop) StopChild(id);
|
foreach (var id in plan.ToStop) StopChild(id);
|
||||||
foreach (var spec in plan.ToApplyDelta) ApplyChildDelta(spec);
|
|
||||||
foreach (var spec in plan.ToSpawn) SpawnChild(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
|
// 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
|
// edited. That edit changes its DriverConfig, which respawns it (#516), and the respawned driver's
|
||||||
// PreStart; an ApplyDelta'd driver's adapter is re-registered when the child reports it finished
|
// adapter registers the NEW set in PreStart — no separate re-register is needed (#665).
|
||||||
// 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.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -3557,69 +3540,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
|||||||
Context.Stop(adapter);
|
Context.Stop(adapter);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sends an in-place config delta to a running child.
|
|
||||||
/// <para><b>Unreachable from the reconcile path since #516</b> — a DriverConfig change is now a
|
|
||||||
/// stop + respawn (<see cref="DriverSpawnPlanner"/>), so <c>ToApplyDelta</c> is always empty.
|
|
||||||
/// Kept because the seam is still exercised by <c>DriverInstanceActor</c>'s own mid-connect config
|
|
||||||
/// adoption.</para>
|
|
||||||
/// <para><b>Two seals were removed here.</b> This used to overwrite the cached
|
|
||||||
/// <c>Spec</c> SYNCHRONOUSLY, before the child had even dequeued the message — so the host
|
|
||||||
/// immediately believed the new config was live, the NEXT reconcile computed no delta against it,
|
|
||||||
/// and any drift was sealed permanently. It also <c>Tell</c>d with no <c>Receive<ApplyResult></c>
|
|
||||||
/// handler registered, so a FAILED reinit — including Galaxy's deliberate
|
|
||||||
/// <c>NotSupportedException</c> — dead-lettered and was never surfaced.</para>
|
|
||||||
/// </summary>
|
|
||||||
private void ApplyChildDelta(DriverInstanceSpec spec)
|
|
||||||
{
|
|
||||||
if (!_children.TryGetValue(spec.DriverInstanceId, out var entry)) return;
|
|
||||||
var correlation = CorrelationId.NewId();
|
|
||||||
_pendingDelta[correlation] = spec;
|
|
||||||
entry.Actor.Tell(new DriverInstanceActor.ApplyDelta(spec.DriverConfig, correlation), Self);
|
|
||||||
_log.Debug("DriverHost {Node}: ApplyDelta queued for {Id}", _localNode, spec.DriverInstanceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A child's reply to <see cref="ApplyChildDelta"/>. On success the cached <c>Spec</c> is advanced —
|
|
||||||
/// only now, when the child has actually adopted the config, so a failed reinit leaves the host
|
|
||||||
/// believing the OLD config is live and the next reconcile re-attempts the change instead of
|
|
||||||
/// silently treating the drift as applied. A failure is logged at Error rather than swallowed.
|
|
||||||
/// </summary>
|
|
||||||
private void HandleApplyResult(DriverInstanceActor.ApplyResult msg)
|
|
||||||
{
|
|
||||||
if (msg.Success)
|
|
||||||
{
|
|
||||||
if (_pendingDelta.Remove(msg.Correlation, out var spec)
|
|
||||||
&& _children.TryGetValue(spec.DriverInstanceId, out var entry))
|
|
||||||
{
|
|
||||||
// A delta can change Name, Enabled, ClusterId etc. alongside the config.
|
|
||||||
_children[spec.DriverInstanceId] = entry with { Spec = spec };
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_pendingDelta.Remove(msg.Correlation, out var failed);
|
|
||||||
_log.Error(
|
|
||||||
"DriverHost {Node}: driver {Id} REJECTED an in-place config change ({Reason}) — the host keeps " +
|
|
||||||
"the previous config so the next reconcile re-attempts it",
|
|
||||||
_localNode, failed?.DriverInstanceId ?? "<unknown>", msg.Reason ?? "no reason given");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A driver child finished applying an in-place <see cref="DriverInstanceActor.ApplyDelta"/> (its
|
|
||||||
/// <see cref="IDriver.ReinitializeAsync"/> completed). Re-register THAT driver's dependency-consumer
|
|
||||||
/// mux adapter so a changed dependency-ref set (calc tags/scripts edited during the redeploy) is
|
|
||||||
/// adopted on the mux. This runs AFTER reinit — closing the ordering hazard where a synchronous
|
|
||||||
/// re-register at ApplyDelta dispatch would re-read the driver's stale
|
|
||||||
/// <see cref="IDependencyConsumer.DependencyRefs"/>. A Tell (no blocking); a no-op for a driver with no
|
|
||||||
/// adapter (not an <see cref="IDependencyConsumer"/>, or no mux wired on this node).
|
|
||||||
/// </summary>
|
|
||||||
private void HandleDeltaApplied(DriverInstanceActor.DeltaApplied msg)
|
|
||||||
{
|
|
||||||
if (_depConsumerAdapters.TryGetValue(msg.DriverInstanceId, out var adapter))
|
|
||||||
adapter.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags.DependencyConsumerMuxAdapter.ReRegister());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StopChild(string driverInstanceId)
|
private void StopChild(string driverInstanceId)
|
||||||
{
|
{
|
||||||
if (!_children.TryGetValue(driverInstanceId, out var entry)) return;
|
if (!_children.TryGetValue(driverInstanceId, out var entry)) return;
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ file sealed class HealthPollTick
|
|||||||
/// <item><c>Failed</c> — terminal until parent restarts the actor.</item>
|
/// <item><c>Failed</c> — terminal until parent restarts the actor.</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
///
|
///
|
||||||
/// Engine wiring (subscriptions → AttributeValueUpdate publishes, ApplyDelta-driven Reinitialize,
|
/// Engine wiring (subscriptions → AttributeValueUpdate publishes, per-tag write Asks) is staged for follow-up F7. This skeleton compiles + has a working
|
||||||
/// 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.
|
/// state machine so the Phase 6 control-plane integration tests can target it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||||
@@ -41,7 +40,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
/// <c>Connected=true</c> on entering the Connected state, <c>false</c> on entering Reconnecting. The host
|
/// <c>Connected=true</c> on entering the Connected state, <c>false</c> on entering Reconnecting. The host
|
||||||
/// annotates this driver's native alarm conditions' source-data Quality from it (comms lost → Bad,
|
/// 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
|
/// restored → Good) — independently of alarm transitions, since a comms-lost driver emits no alarm
|
||||||
/// events. Fire-and-forget, mirroring <see cref="DeltaApplied"/>.</summary>
|
/// events. Fire-and-forget.</summary>
|
||||||
public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected);
|
public sealed record ConnectivityChanged(string DriverInstanceId, bool Connected);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -62,18 +61,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
/// <param name="DriverInstanceId">The driver instance that was restarted.</param>
|
/// <param name="DriverInstanceId">The driver instance that was restarted.</param>
|
||||||
/// <param name="Reason">The type name of the exception that tripped supervision, for the host's log.</param>
|
/// <param name="Reason">The type name of the exception that tripped supervision, for the host's log.</param>
|
||||||
public sealed record ReprovisionRequested(string DriverInstanceId, string Reason);
|
public sealed record ReprovisionRequested(string DriverInstanceId, string Reason);
|
||||||
public sealed record ApplyDelta(string DriverConfigJson, CorrelationId Correlation);
|
|
||||||
public sealed record ApplyResult(bool Success, string? Reason, CorrelationId Correlation);
|
|
||||||
/// <summary>
|
|
||||||
/// Sent to the parent (<see cref="DriverHostActor"/>) AFTER an <see cref="ApplyDelta"/> has fully
|
|
||||||
/// re-initialised the driver (i.e. <see cref="IDriver.ReinitializeAsync"/> completed). The host uses
|
|
||||||
/// it to re-register this driver's dependency-consumer mux adapter: a driver may rebuild its declared
|
|
||||||
/// <see cref="IDependencyConsumer.DependencyRefs"/> during reinit (the Calculation driver re-binds its
|
|
||||||
/// calc tags), so the re-register MUST happen after reinit — NOT synchronously alongside the ApplyDelta
|
|
||||||
/// dispatch, which would re-read the STALE ref set. Emitted only on a successful apply.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="DriverInstanceId">The instance whose adapter should be re-registered.</param>
|
|
||||||
public sealed record DeltaApplied(string DriverInstanceId);
|
|
||||||
public sealed record WriteAttribute(string TagId, object Value);
|
public sealed record WriteAttribute(string TagId, object Value);
|
||||||
public sealed record WriteAttributeResult(bool Success, string? Reason);
|
public sealed record WriteAttributeResult(bool Success, string? Reason);
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -221,15 +208,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
private readonly ILoggingAdapter _log = Context.GetLogger();
|
private readonly ILoggingAdapter _log = Context.GetLogger();
|
||||||
private string? _currentConfigJson;
|
private string? _currentConfigJson;
|
||||||
|
|
||||||
/// <summary>#664: set when <see cref="AdoptConfigDuringInit"/> adopts a new config mid-connect; the next
|
|
||||||
/// (generation-matched) <see cref="InitializeSucceeded"/> sends <see cref="DeltaApplied"/> to the parent and
|
|
||||||
/// clears it, so a dependency consumer's mux interest is re-registered against the ADOPTED config — the
|
|
||||||
/// same post-reinit notification the Connected <see cref="HandleApplyDeltaAsync"/> path sends.</summary>
|
|
||||||
private bool _adoptedConfigPendingNotify;
|
|
||||||
|
|
||||||
/// <summary>Monotonic token tagging each <see cref="InitializeAsync"/> attempt. An init result is
|
/// <summary>Monotonic token tagging each <see cref="InitializeAsync"/> attempt. An init result is
|
||||||
/// honoured only when its generation matches the latest; an older result is from a superseded attempt
|
/// honoured only when its generation matches the latest; an older result is from a superseded attempt
|
||||||
/// (e.g. an <see cref="ApplyDelta"/> adopted a new config mid-(re)connect) and is dropped. Touched only
|
/// (a retry tick that overlapped a still-running attempt) and is dropped. Touched only
|
||||||
/// on the actor thread, so no lock is needed.</summary>
|
/// on the actor thread, so no lock is needed.</summary>
|
||||||
private int _initGeneration;
|
private int _initGeneration;
|
||||||
|
|
||||||
@@ -442,7 +423,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
// Stubbed drivers accept the standard message contracts but return deterministic
|
// Stubbed drivers accept the standard message contracts but return deterministic
|
||||||
// success without touching real hardware. Read returns null; Write succeeds.
|
// success without touching real hardware. Read returns null; Write succeeds.
|
||||||
Receive<InitializeRequested>(_ => { /* no-op */ });
|
Receive<InitializeRequested>(_ => { /* no-op */ });
|
||||||
Receive<ApplyDelta>(msg => Sender.Tell(new ApplyResult(true, "stubbed", msg.Correlation)));
|
|
||||||
Receive<WriteAttribute>(_ => Sender.Tell(new WriteAttributeResult(true, "stubbed")));
|
Receive<WriteAttribute>(_ => Sender.Tell(new WriteAttributeResult(true, "stubbed")));
|
||||||
// Stubbed drivers have no upstream alarm system — swallow the ack (it's fire-and-forget, no reply).
|
// Stubbed drivers have no upstream alarm system — swallow the ack (it's fire-and-forget, no reply).
|
||||||
Receive<RouteAlarmAck>(_ => { /* stubbed drivers have no alarm backend */ });
|
Receive<RouteAlarmAck>(_ => { /* stubbed drivers have no alarm backend */ });
|
||||||
@@ -468,13 +448,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
// fire-and-forget — no reply to surface — and the OPC UA condition state already committed locally).
|
// fire-and-forget — no reply to surface — and the OPC UA condition state already committed locally).
|
||||||
Receive<RouteAlarmAck>(_ =>
|
Receive<RouteAlarmAck>(_ =>
|
||||||
_log.Debug("DriverInstance {Id}: alarm ack arrived during connect — dropped (driver not connected)", _driverInstanceId));
|
_log.Debug("DriverInstance {Id}: alarm ack arrived during connect — dropped (driver not connected)", _driverInstanceId));
|
||||||
Receive<ApplyDelta>(AdoptConfigDuringInit);
|
|
||||||
Receive<InitializeSucceeded>(msg =>
|
Receive<InitializeSucceeded>(msg =>
|
||||||
{
|
{
|
||||||
if (msg.Generation != _initGeneration) return;
|
if (msg.Generation != _initGeneration) return;
|
||||||
_log.Info("DriverInstance {Id}: connected", _driverInstanceId);
|
_log.Info("DriverInstance {Id}: connected", _driverInstanceId);
|
||||||
Become(Connected);
|
Become(Connected);
|
||||||
NotifyAdoptedConfig();
|
|
||||||
PublishHealthSnapshot();
|
PublishHealthSnapshot();
|
||||||
ResubscribeDesired();
|
ResubscribeDesired();
|
||||||
AttachAlarmSource();
|
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.
|
// reconnects but never manages a read is measured from THIS connect, not from a stale earlier one.
|
||||||
_connectedSinceUtc = DateTime.UtcNow;
|
_connectedSinceUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
ReceiveAsync<ApplyDelta>(HandleApplyDeltaAsync);
|
|
||||||
Receive<DisconnectObserved>(msg =>
|
Receive<DisconnectObserved>(msg =>
|
||||||
{
|
{
|
||||||
_log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting",
|
_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).
|
// no reply — the OPC UA condition state already committed locally on the Part 9 ack).
|
||||||
Receive<RouteAlarmAck>(_ =>
|
Receive<RouteAlarmAck>(_ =>
|
||||||
_log.Debug("DriverInstance {Id}: alarm ack arrived during reconnect — dropped (driver not connected)", _driverInstanceId));
|
_log.Debug("DriverInstance {Id}: alarm ack arrived during reconnect — dropped (driver not connected)", _driverInstanceId));
|
||||||
Receive<ApplyDelta>(AdoptConfigDuringInit);
|
|
||||||
Receive<InitializeSucceeded>(msg =>
|
Receive<InitializeSucceeded>(msg =>
|
||||||
{
|
{
|
||||||
if (msg.Generation != _initGeneration) return;
|
if (msg.Generation != _initGeneration) return;
|
||||||
Timers.Cancel("retry-connect");
|
Timers.Cancel("retry-connect");
|
||||||
_log.Info("DriverInstance {Id}: reconnected", _driverInstanceId);
|
_log.Info("DriverInstance {Id}: reconnected", _driverInstanceId);
|
||||||
Become(Connected);
|
Become(Connected);
|
||||||
NotifyAdoptedConfig();
|
|
||||||
PublishHealthSnapshot();
|
PublishHealthSnapshot();
|
||||||
ResubscribeDesired();
|
ResubscribeDesired();
|
||||||
AttachAlarmSource();
|
AttachAlarmSource();
|
||||||
@@ -875,94 +850,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Adopt a new config while not connected: ApplyDelta in Connecting/Reconnecting re-inits
|
|
||||||
/// immediately with the new config. <see cref="InitializeAsync"/> swaps <c>_currentConfigJson</c> and
|
|
||||||
/// bumps the generation, so the in-flight (old-config) init is superseded and its result is dropped.
|
|
||||||
/// The actor stays in its current state; the new init's result drives the next transition. In
|
|
||||||
/// Reconnecting the retry timer is left running — if this immediate attempt fails it keeps retrying
|
|
||||||
/// the new config (a redundant concurrent attempt is deduped by the generation guard).</summary>
|
|
||||||
private void AdoptConfigDuringInit(ApplyDelta msg)
|
|
||||||
{
|
|
||||||
_log.Info("DriverInstance {Id}: ApplyDelta during (re)connect — adopting new config, re-initialising now",
|
|
||||||
_driverInstanceId);
|
|
||||||
InitializeAsync(msg.DriverConfigJson);
|
|
||||||
_adoptedConfigPendingNotify = true;
|
|
||||||
Sender.Tell(new ApplyResult(true, "config adopted; reinitializing", msg.Correlation));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Send the deferred <see cref="DeltaApplied"/> for a mid-connect adoption (#664) once the driver
|
|
||||||
/// has actually initialised on the adopted config. A no-op otherwise.</summary>
|
|
||||||
private void NotifyAdoptedConfig()
|
|
||||||
{
|
|
||||||
if (!_adoptedConfigPendingNotify) return;
|
|
||||||
_adoptedConfigPendingNotify = false;
|
|
||||||
Context.Parent.Tell(new DeltaApplied(_driverInstanceId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Adopt a new config while Connected, in place, via <see cref="IDriver.ReinitializeAsync"/>.
|
|
||||||
/// <para>#663: every driver's reinit releases its device/session state (#630), so the driver-side data and
|
|
||||||
/// native-alarm subscriptions do not survive it — but this handler used to keep both handles, and the
|
|
||||||
/// health-poll reconcile only re-subscribes a MISSING handle, so the driver went silent until a
|
|
||||||
/// reconnect or respawn. Both subscriptions are now released explicitly BEFORE the reinit (explicitly,
|
|
||||||
/// not just by nulling the handles, so a driver whose reinit keeps its subscriptions is not left
|
|
||||||
/// holding two) and re-established after it — in a <c>finally</c>, because a failed reinit has already
|
|
||||||
/// lost them too. Reinit stays the adoption path rather than shutdown + initialise: Calculation and
|
|
||||||
/// Galaxy take their config at construction and ignore <see cref="IDriver.InitializeAsync"/>'s JSON.</para>
|
|
||||||
/// <para>Not reachable from a deploy since #516 (a DriverConfig change respawns the driver).</para></summary>
|
|
||||||
private async Task HandleApplyDeltaAsync(ApplyDelta msg)
|
|
||||||
{
|
|
||||||
var replyTo = Sender;
|
|
||||||
await UnsubscribeAsync();
|
|
||||||
await ReleaseAlarmSubscriptionAsync();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _driver.ReinitializeAsync(msg.DriverConfigJson, CancellationToken.None);
|
|
||||||
_currentConfigJson = msg.DriverConfigJson;
|
|
||||||
// The driver may have rebuilt its dependency-consumer interest during reinit (the Calculation
|
|
||||||
// driver re-binds its calc tags + dependency refs). Notify the parent so it re-registers this
|
|
||||||
// driver's mux adapter AFTER reinit completed — a synchronous re-register at ApplyDelta-dispatch
|
|
||||||
// time would re-read the driver's STALE DependencyRefs. Parent Tell (fire-and-forget); the
|
|
||||||
// continuation resumes on the actor context (no ConfigureAwait) so Context.Parent is live.
|
|
||||||
Context.Parent.Tell(new DeltaApplied(_driverInstanceId));
|
|
||||||
replyTo.Tell(new ApplyResult(true, null, msg.Correlation));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
replyTo.Tell(new ApplyResult(false, ex.Message, msg.Correlation));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
ResubscribeDesired();
|
|
||||||
AttachAlarmSource();
|
|
||||||
SubscribeDesiredAlarms();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Release the native-alarm subscription at the driver (best-effort, bounded) and drop the
|
|
||||||
/// actor's handler + handle. The in-place reinit counterpart of the data <see cref="UnsubscribeAsync"/>
|
|
||||||
/// (#663).</summary>
|
|
||||||
private async Task ReleaseAlarmSubscriptionAsync()
|
|
||||||
{
|
|
||||||
if (_driver is IAlarmSource src && _alarmSubscriptionHandle is { } handle)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
||||||
await _invoker.ExecuteAsync(
|
|
||||||
DriverCapability.AlarmSubscribe,
|
|
||||||
_driverInstanceId,
|
|
||||||
async ct => await src.UnsubscribeAlarmsAsync(handle, ct),
|
|
||||||
cts.Token);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_log.Warning(ex, "DriverInstance {Id}: releasing the native-alarm subscription ({Diag}) threw (continuing)",
|
|
||||||
_driverInstanceId, handle.DiagnosticId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DetachAlarmSource();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleWriteAsync(WriteAttribute msg)
|
private async Task HandleWriteAsync(WriteAttribute msg)
|
||||||
{
|
{
|
||||||
var replyTo = Sender;
|
var replyTo = Sender;
|
||||||
|
|||||||
@@ -3,38 +3,31 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pure diff between the currently-running driver children (keyed by
|
/// Pure diff between the currently-running driver children (keyed by
|
||||||
/// <c>DriverInstance.DriverInstanceId</c>) and the target spec list from a freshly-applied
|
/// <c>DriverInstance.DriverInstanceId</c>) and the target spec list from a freshly-applied
|
||||||
/// deployment artifact. The DriverHostActor consumes the three lists and calls
|
/// deployment artifact. The DriverHostActor consumes the two lists and calls
|
||||||
/// spawn / ApplyDelta / stop on its child actors accordingly.
|
/// spawn / stop on its child actors accordingly. There is no in-place config path: since #516 a config
|
||||||
|
/// change is a stop + respawn, and #665 deleted the unreachable in-place <c>ApplyDelta</c> seam.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ToSpawn">Specs with no current child — create a new actor.</param>
|
/// <param name="ToSpawn">Specs with no current child (or whose child is being replaced) — create a new actor.</param>
|
||||||
/// <param name="ToApplyDelta">
|
|
||||||
/// In-place config deltas. <b>Always empty since #516</b> — a DriverConfig change is now a
|
|
||||||
/// stop + respawn so the factory is the single parse authority. Retained because
|
|
||||||
/// <c>DriverInstanceActor</c> still handles <c>ApplyDelta</c> on its own config-adoption path
|
|
||||||
/// (a config arriving mid-connect), and removing the list would hide that seam.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="ToStop">DriverInstanceIds currently running but missing from the new artifact, or now disabled.</param>
|
/// <param name="ToStop">DriverInstanceIds currently running but missing from the new artifact, or now disabled.</param>
|
||||||
public sealed record DriverSpawnPlan(
|
public sealed record DriverSpawnPlan(
|
||||||
IReadOnlyList<DriverInstanceSpec> ToSpawn,
|
IReadOnlyList<DriverInstanceSpec> ToSpawn,
|
||||||
IReadOnlyList<DriverInstanceSpec> ToApplyDelta,
|
|
||||||
IReadOnlyList<string> ToStop);
|
IReadOnlyList<string> ToStop);
|
||||||
|
|
||||||
public static class DriverSpawnPlanner
|
public static class DriverSpawnPlanner
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compute the spawn/delta/stop sets. Disabled entries in <paramref name="target"/> are
|
/// Compute the spawn/stop sets. Disabled entries in <paramref name="target"/> are
|
||||||
/// treated as "not desired here": if a child exists for the id it goes into ToStop,
|
/// 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).
|
/// otherwise the row is dropped entirely (no spawn for a disabled driver).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="current">The currently running driver children keyed by ID.</param>
|
/// <param name="current">The currently running driver children keyed by ID.</param>
|
||||||
/// <param name="target">The target driver instances from the deployment artifact.</param>
|
/// <param name="target">The target driver instances from the deployment artifact.</param>
|
||||||
/// <returns>The computed <see cref="DriverSpawnPlan"/> with spawn, apply-delta, and stop sets.</returns>
|
/// <returns>The computed <see cref="DriverSpawnPlan"/> with spawn and stop sets.</returns>
|
||||||
public static DriverSpawnPlan Compute(
|
public static DriverSpawnPlan Compute(
|
||||||
IReadOnlyDictionary<string, DriverChildSnapshot> current,
|
IReadOnlyDictionary<string, DriverChildSnapshot> current,
|
||||||
IReadOnlyList<DriverInstanceSpec> target)
|
IReadOnlyList<DriverInstanceSpec> target)
|
||||||
{
|
{
|
||||||
var toSpawn = new List<DriverInstanceSpec>();
|
var toSpawn = new List<DriverInstanceSpec>();
|
||||||
var toDelta = new List<DriverInstanceSpec>();
|
|
||||||
var toStop = new List<string>();
|
var toStop = new List<string>();
|
||||||
|
|
||||||
var targetById = new Dictionary<string, DriverInstanceSpec>(StringComparer.Ordinal);
|
var targetById = new Dictionary<string, DriverInstanceSpec>(StringComparer.Ordinal);
|
||||||
@@ -83,7 +76,7 @@ public static class DriverSpawnPlanner
|
|||||||
toSpawn.Add(spec);
|
toSpawn.Add(spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new DriverSpawnPlan(toSpawn, toDelta, toStop);
|
return new DriverSpawnPlan(toSpawn, toStop);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -5,8 +5,8 @@ using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #630 — <c>InitializeAsync</c> on a live driver (<c>DriverInstanceActor.AdoptConfigDuringInit</c> runs
|
/// #630 — <c>InitializeAsync</c> on a live driver with no teardown first (the <c>IDriver</c> contract
|
||||||
/// it with no teardown first) overwrote <c>_devices[host]</c> and orphaned the previous state's
|
/// allows it) overwrote <c>_devices[host]</c> and orphaned the previous state's
|
||||||
/// probe loop + runtime. It now releases them first.
|
/// probe loop + runtime. It now releases them first.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Trait("Category", "Unit")]
|
[Trait("Category", "Unit")]
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ public sealed class CalculationDriverTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The skip ledger must be REBUILT on ReinitializeAsync, not just at construction — an
|
/// <summary>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.</summary>
|
/// since RebuildTagTable is the one routine both the ctor and reinit share.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetSkippedTags_updates_after_reinitialize()
|
public async Task GetSkippedTags_updates_after_reinitialize()
|
||||||
@@ -414,9 +414,9 @@ public sealed class CalculationDriverTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gitea #664 — <c>InitializeAsync</c> re-runs on a LIVE driver with a new config when an ApplyDelta lands
|
/// Gitea #664 — <c>InitializeAsync</c> must serve the config document it is handed (the <c>IDriver</c>
|
||||||
/// mid-connect (<c>DriverInstanceActor.AdoptConfigDuringInit</c>). It used to ignore the JSON, so the
|
/// contract). It used to ignore the JSON, so a live driver re-initialised with a new config kept its
|
||||||
/// driver kept its constructor tag set while the deploy reported the change applied. A config with a body
|
/// constructor tag set. A config with a body
|
||||||
/// now rebuilds the tag table (and the dependency-ref set the host re-registers from).
|
/// now rebuilds the tag table (and the dependency-ref set the host re-registers from).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
+2
-2
@@ -5,8 +5,8 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #630 — <c>InitializeAsync</c> on a live driver (<c>DriverInstanceActor.AdoptConfigDuringInit</c> runs
|
/// #630 — <c>InitializeAsync</c> on a live driver with no teardown first (the <c>IDriver</c> contract
|
||||||
/// it with no teardown first) overwrote <c>_devices[host]</c> and orphaned the previous state's
|
/// allows it) overwrote <c>_devices[host]</c> and orphaned the previous state's
|
||||||
/// probe loop + client. It now releases them first.
|
/// probe loop + client. It now releases them first.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Trait("Category", "Unit")]
|
[Trait("Category", "Unit")]
|
||||||
|
|||||||
@@ -209,9 +209,8 @@ public sealed class GalaxyDriverFactoryTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gitea #664 — <c>InitializeAsync</c> re-runs on a live driver with a NEW config when an ApplyDelta lands
|
/// Gitea #664 — <c>InitializeAsync</c> must not silently ignore the config it is handed. Galaxy cannot
|
||||||
/// mid-connect. Galaxy cannot hot-swap its options, and it used to ignore the JSON — connecting on the OLD
|
/// hot-swap its options, and it used to ignore the JSON — connecting on the OLD endpoint. A non-equivalent config is now refused, exactly
|
||||||
/// endpoint while the deploy reported the change applied. A non-equivalent config is now refused, exactly
|
|
||||||
/// as <c>ReinitializeAsync</c> refuses it; an equivalent one still initialises.
|
/// as <c>ReinitializeAsync</c> refuses it; an equivalent one still initialises.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
+3
-4
@@ -265,9 +265,8 @@ public sealed class MTConnectDriverLifecycleTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Initialize_honours_the_config_json_over_the_constructor_options()
|
public async Task Initialize_honours_the_config_json_over_the_constructor_options()
|
||||||
{
|
{
|
||||||
// The runtime delivers a config change ONLY through this argument (DriverInstanceActor
|
// The IDriver contract: the driver serves the config document it is handed, not only its
|
||||||
// ApplyDelta -> ReinitializeAsync(json) on the LIVE instance). Ignoring it would make every
|
// constructor options — ignoring it would turn an in-place config change into a silent no-op.
|
||||||
// MTConnect config change a silent no-op that still seals the deployment green.
|
|
||||||
MTConnectDriverOptions? seen = null;
|
MTConnectDriverOptions? seen = null;
|
||||||
var driver = new MTConnectDriver(Opts(), "mt1", o =>
|
var driver = new MTConnectDriver(Opts(), "mt1", o =>
|
||||||
{
|
{
|
||||||
@@ -533,7 +532,7 @@ public sealed class MTConnectDriverLifecycleTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Reinitialize_with_an_unreadable_config_keeps_the_running_driver_serving()
|
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.
|
// not down a driver that is happily serving its previous, valid configuration.
|
||||||
var (driver, client) = await InitializedDriverAsync();
|
var (driver, client) = await InitializedDriverAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -225,9 +225,8 @@ public sealed class ModbusLifecycleHygieneTests
|
|||||||
// -------------------- Re-initialize transport hygiene --------------------
|
// -------------------- Re-initialize transport hygiene --------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A second <c>InitializeAsync</c> on a live driver — the shape
|
/// A second <c>InitializeAsync</c> on a live driver with no teardown between (the <c>IDriver</c>
|
||||||
/// <c>DriverInstanceActor.AdoptConfigDuringInit</c> produces when an ApplyDelta lands while the
|
/// contract allows it) must dispose the transport the previous initialize
|
||||||
/// driver is Connecting/Reconnecting — must dispose the transport the previous initialize
|
|
||||||
/// published before publishing a new one. Pre-fix <c>_transport</c> was simply overwritten,
|
/// published before publishing a new one. Pre-fix <c>_transport</c> was simply overwritten,
|
||||||
/// stranding a connected transport no later dispose could reach.
|
/// stranding a connected transport no later dispose could reach.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -251,7 +250,7 @@ public sealed class ModbusLifecycleHygieneTests
|
|||||||
});
|
});
|
||||||
|
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
// No ShutdownAsync in between — exactly what AdoptConfigDuringInit does.
|
// No ShutdownAsync in between.
|
||||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
handed.Count.ShouldBe(2, "each initialize must build its own transport");
|
handed.Count.ShouldBe(2, "each initialize must build its own transport");
|
||||||
@@ -415,8 +414,8 @@ public sealed class ModbusLifecycleHygieneTests
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Two initializes racing on the same driver — the shape <c>DriverInstanceActor</c> produces by
|
/// Two initializes racing on the same driver — the shape <c>DriverInstanceActor</c> produces by
|
||||||
/// dispatching initialize on a fire-and-forget <c>Task.Run</c> with <c>AdoptConfigDuringInit</c>
|
/// dispatching initialize on a fire-and-forget <c>Task.Run</c>, where a reconnect retry tick can
|
||||||
/// firing twice — must serialize on the lifecycle gate. Ungated, the teardown at the top of one
|
/// 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 <c>_transport</c>
|
/// initialize disposes the transport the other just published (or nulls <c>_transport</c>
|
||||||
/// mid-connect), leaving the driver holding a disposed transport.
|
/// mid-connect), leaving the driver holding a disposed transport.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+3
-4
@@ -28,10 +28,9 @@ public sealed class MqttDriverSessionLifecycleTests
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Reachable with no teardown of the driver's own: <c>DriverInstanceActor</c> re-runs
|
/// The <c>IDriver</c> contract allows re-initialising a live driver with no
|
||||||
/// <c>InitializeAsync</c> with no <c>ShutdownAsync</c> when an <c>ApplyDelta</c> arrives while
|
/// <c>ShutdownAsync</c>, and the #527 retry path's <c>teardownFirst</c> flag does not stop a
|
||||||
/// the driver is Connecting/Reconnecting (<c>AdoptConfigDuringInit</c>). The
|
/// retry overlapping a still-running attempt, so the driver must not rely on a teardown.
|
||||||
/// <c>teardownFirst</c> flag on the #527 retry path covers only the retry path.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The assertion is the broker's <b>live socket count</b>, not the driver's own view: a leak
|
/// The assertion is the broker's <b>live socket count</b>, not the driver's own view: a leak
|
||||||
|
|||||||
@@ -483,8 +483,8 @@ public sealed class S7DriverReconnectTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #630 — a second <c>InitializeAsync</c> on a live driver (no <c>ShutdownAsync</c> between, as
|
/// #630 — a second <c>InitializeAsync</c> on a live driver (no <c>ShutdownAsync</c> between — the
|
||||||
/// <c>DriverInstanceActor.AdoptConfigDuringInit</c> does) must stop the first probe loop and
|
/// <c>IDriver</c> contract allows it) must stop the first probe loop and
|
||||||
/// dispose the first connection rather than orphan them.
|
/// dispose the first connection rather than orphan them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
+5
-4
@@ -31,7 +31,7 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
|
|||||||
return new RawTagEntry(rawPath, json, WriteIdempotent: false);
|
return new RawTagEntry(rawPath, json, WriteIdempotent: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Build the merged DriverConfig JSON the ApplyDelta path re-binds from (mirrors the deploy
|
/// <summary>Build the merged DriverConfig JSON an in-place re-bind reads (mirrors the deploy
|
||||||
/// artifact folding the driver's RawTags into DriverInstanceSpec.DriverConfig).</summary>
|
/// artifact folding the driver's RawTags into DriverInstanceSpec.DriverConfig).</summary>
|
||||||
private static string ConfigJson(params RawTagEntry[] tags) =>
|
private static string ConfigJson(params RawTagEntry[] tags) =>
|
||||||
System.Text.Json.JsonSerializer.Serialize(new { rawTags = 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));
|
published.ShouldContain(e => e.FullReference == "calc/remove" && Equals(e.Snapshot.Value, 7.0));
|
||||||
}, PresenceBudget, TimeSpan.FromMilliseconds(100));
|
}, 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;
|
// 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.
|
// "remove" is dropped entirely.
|
||||||
var newConfig = ConfigJson(
|
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).
|
// 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" });
|
driver.DependencyRefs.OrderBy(r => r).ShouldBe(new[] { "src/a", "src/b" });
|
||||||
|
|
||||||
// Part 2 sequencing: the host re-registers the adapter AFTER reinit (DeltaApplied → HandleDeltaApplied).
|
// The adapter re-reads the driver's refs only when told to; re-register AFTER the reinit so it adopts the
|
||||||
// Mirror that here — re-register so the adapter adopts the NEW ref set (incl. the brand-new src/b).
|
// 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());
|
adapter.Tell(new DependencyConsumerMuxAdapter.ReRegister());
|
||||||
|
|
||||||
// Leg 2 — after redeploy: the NEW dep src/b flows + the NEW tag computes; the EDITED script serves the
|
// Leg 2 — after redeploy: the NEW dep src/b flows + the NEW tag computes; the EDITED script serves the
|
||||||
|
|||||||
+8
-104
@@ -369,56 +369,6 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
.Connected.ShouldBeTrue();
|
.Connected.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gitea #663 — an in-place config change on a Connected driver (<see cref="DriverInstanceActor.ApplyDelta"/>
|
|
||||||
/// → <see cref="IDriver.ReinitializeAsync"/>) 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.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="reinitKeepsSubscriptions">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.</param>
|
|
||||||
[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<DriverInstanceActor.ApplyResult>(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<DriverInstanceActor.AttributeAlarmPublished>(_ => true, PresenceBudget)
|
|
||||||
.Args.ConditionId.ShouldBe("Temp.HiHi");
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- stub drivers ----------------------------------------------------------------------------
|
// --- stub drivers ----------------------------------------------------------------------------
|
||||||
|
|
||||||
private class StubDriver : IDriver
|
private class StubDriver : IDriver
|
||||||
@@ -439,7 +389,7 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Reinitializes the driver.</summary>
|
/// <summary>Reinitializes the driver.</summary>
|
||||||
public virtual Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) =>
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) =>
|
||||||
Task.CompletedTask;
|
Task.CompletedTask;
|
||||||
|
|
||||||
/// <summary>Shuts down the driver.</summary>
|
/// <summary>Shuts down the driver.</summary>
|
||||||
@@ -463,55 +413,14 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
/// <summary>Number of live subscribers on <see cref="OnAlarmEvent"/> — proves attach/detach.</summary>
|
/// <summary>Number of live subscribers on <see cref="OnAlarmEvent"/> — proves attach/detach.</summary>
|
||||||
public int AlarmSubscriberCount => OnAlarmEvent?.GetInvocationList().Length ?? 0;
|
public int AlarmSubscriberCount => OnAlarmEvent?.GetInvocationList().Length ?? 0;
|
||||||
|
|
||||||
private readonly object _liveLock = new();
|
|
||||||
private readonly HashSet<object> _liveData = new(ReferenceEqualityComparer.Instance);
|
|
||||||
private readonly HashSet<object> _liveAlarms = new(ReferenceEqualityComparer.Instance);
|
|
||||||
private int _reinitializeCount;
|
|
||||||
|
|
||||||
/// <summary>Driver-side data subscriptions currently open (subscribed and not yet released).</summary>
|
|
||||||
public int LiveDataSubscriptions { get { lock (_liveLock) return _liveData.Count; } }
|
|
||||||
|
|
||||||
/// <summary>Driver-side native-alarm subscriptions currently open.</summary>
|
|
||||||
public int LiveAlarmSubscriptions { get { lock (_liveLock) return _liveAlarms.Count; } }
|
|
||||||
|
|
||||||
/// <summary>Number of <see cref="ReinitializeAsync"/> calls.</summary>
|
|
||||||
public int ReinitializeCount => Volatile.Read(ref _reinitializeCount);
|
|
||||||
|
|
||||||
/// <summary>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.</summary>
|
|
||||||
public bool ReinitKeepsSubscriptions { get; init; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
Interlocked.Increment(ref _reinitializeCount);
|
|
||||||
if (!ReinitKeepsSubscriptions)
|
|
||||||
{
|
|
||||||
lock (_liveLock)
|
|
||||||
{
|
|
||||||
_liveData.Clear();
|
|
||||||
_liveAlarms.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Subscribes to the specified full references.</summary>
|
/// <summary>Subscribes to the specified full references.</summary>
|
||||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
|
||||||
{
|
Task.FromResult<ISubscriptionHandle>(_subHandle);
|
||||||
var handle = new StubHandle();
|
|
||||||
lock (_liveLock) _liveData.Add(handle);
|
|
||||||
return Task.FromResult<ISubscriptionHandle>(handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Unsubscribes from the specified subscription handle.</summary>
|
/// <summary>Unsubscribes from the specified subscription handle.</summary>
|
||||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) =>
|
||||||
{
|
Task.CompletedTask;
|
||||||
lock (_liveLock) _liveData.Remove(handle);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int _subscribeAlarmsCallCount;
|
private int _subscribeAlarmsCallCount;
|
||||||
|
|
||||||
@@ -530,9 +439,7 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
var call = Interlocked.Increment(ref _subscribeAlarmsCallCount);
|
var call = Interlocked.Increment(ref _subscribeAlarmsCallCount);
|
||||||
if (call <= AlarmSubscribeFailuresBeforeSuccess)
|
if (call <= AlarmSubscribeFailuresBeforeSuccess)
|
||||||
throw new InvalidOperationException($"alarm subscribe failure {call} (test-injected)");
|
throw new InvalidOperationException($"alarm subscribe failure {call} (test-injected)");
|
||||||
var handle = new StubAlarmHandle();
|
return Task.FromResult<IAlarmSubscriptionHandle>(new StubAlarmHandle());
|
||||||
lock (_liveLock) _liveAlarms.Add(handle);
|
|
||||||
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>How many leading <see cref="SubscribeAlarmsAsync"/> calls throw — the "session not ready
|
/// <summary>How many leading <see cref="SubscribeAlarmsAsync"/> calls throw — the "session not ready
|
||||||
@@ -540,11 +447,8 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
|
|||||||
public int AlarmSubscribeFailuresBeforeSuccess { get; init; }
|
public int AlarmSubscribeFailuresBeforeSuccess { get; init; }
|
||||||
|
|
||||||
/// <summary>Cancels an alarm subscription.</summary>
|
/// <summary>Cancels an alarm subscription.</summary>
|
||||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
|
||||||
{
|
Task.CompletedTask;
|
||||||
lock (_liveLock) _liveAlarms.Remove(handle);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Acknowledges a batch of alarms.</summary>
|
/// <summary>Acknowledges a batch of alarms.</summary>
|
||||||
public Task AcknowledgeAsync(
|
public Task AcknowledgeAsync(
|
||||||
|
|||||||
@@ -11,28 +11,6 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|||||||
|
|
||||||
public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
||||||
{
|
{
|
||||||
/// <summary>Verifies that ApplyDelta calls ReinitializeAsync when connected and replies success.</summary>
|
|
||||||
[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<DriverInstanceActor.ApplyResult>(
|
|
||||||
new DriverInstanceActor.ApplyDelta("{\"changed\":true}", correlation),
|
|
||||||
TimeSpan.FromSeconds(3));
|
|
||||||
|
|
||||||
reply.Success.ShouldBeTrue();
|
|
||||||
reply.Correlation.ShouldBe(correlation);
|
|
||||||
driver.ReinitializeCount.ShouldBe(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Verifies that initialize failure keeps the actor in Reconnecting state.</summary>
|
/// <summary>Verifies that initialize failure keeps the actor in Reconnecting state.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Initialize_failure_keeps_actor_in_Reconnecting_state()
|
public void Initialize_failure_keeps_actor_in_Reconnecting_state()
|
||||||
@@ -305,109 +283,6 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
|||||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>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.</summary>
|
|
||||||
[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<DriverInstanceActor.ApplyResult>(
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gitea #664 — a mid-connect adoption sends <see cref="DriverInstanceActor.DeltaApplied"/> 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.
|
|
||||||
/// </summary>
|
|
||||||
[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<DriverInstanceActor.DeltaApplied>(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));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>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.</summary>
|
|
||||||
[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<DriverInstanceActor.ApplyResult>(
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Archreview <b>03/S15 (T11)</b> — a child that is <b>stopped</b> announces comms loss on its way
|
/// Archreview <b>03/S15 (T11)</b> — a child that is <b>stopped</b> announces comms loss on its way
|
||||||
/// out, so the host latches its value nodes <c>BadNoCommunication</c>.
|
/// out, so the host latches its value nodes <c>BadNoCommunication</c>.
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ public sealed class DriverSpawnPlannerTests
|
|||||||
var plan = DriverSpawnPlanner.Compute(current, target);
|
var plan = DriverSpawnPlanner.Compute(current, target);
|
||||||
|
|
||||||
plan.ToSpawn.Count.ShouldBe(2);
|
plan.ToSpawn.Count.ShouldBe(2);
|
||||||
plan.ToApplyDelta.ShouldBeEmpty();
|
|
||||||
plan.ToStop.ShouldBeEmpty();
|
plan.ToStop.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +35,6 @@ public sealed class DriverSpawnPlannerTests
|
|||||||
var plan = DriverSpawnPlanner.Compute(current, target);
|
var plan = DriverSpawnPlanner.Compute(current, target);
|
||||||
|
|
||||||
plan.ToSpawn.ShouldBeEmpty();
|
plan.ToSpawn.ShouldBeEmpty();
|
||||||
plan.ToApplyDelta.ShouldBeEmpty();
|
|
||||||
plan.ToStop.ShouldBeEmpty();
|
plan.ToStop.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +63,6 @@ public sealed class DriverSpawnPlannerTests
|
|||||||
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
|
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
|
||||||
plan.ToSpawn.Single().DriverConfig.ShouldBe("{\"host\":\"new\"}");
|
plan.ToSpawn.Single().DriverConfig.ShouldBe("{\"host\":\"new\"}");
|
||||||
// The whole point: nothing is left on the in-place path that could discard the edit.
|
// The whole point: nothing is left on the in-place path that could discard the edit.
|
||||||
plan.ToApplyDelta.ShouldBeEmpty();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that removed drivers are routed to ToStop.</summary>
|
/// <summary>Verifies that removed drivers are routed to ToStop.</summary>
|
||||||
@@ -83,7 +80,6 @@ public sealed class DriverSpawnPlannerTests
|
|||||||
|
|
||||||
plan.ToStop.ShouldBe(new[] { "b" });
|
plan.ToStop.ShouldBe(new[] { "b" });
|
||||||
plan.ToSpawn.ShouldBeEmpty();
|
plan.ToSpawn.ShouldBeEmpty();
|
||||||
plan.ToApplyDelta.ShouldBeEmpty();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that disabled drivers with running children are routed to ToStop.</summary>
|
/// <summary>Verifies that disabled drivers with running children are routed to ToStop.</summary>
|
||||||
@@ -100,7 +96,6 @@ public sealed class DriverSpawnPlannerTests
|
|||||||
|
|
||||||
plan.ToStop.Single().ShouldBe("a");
|
plan.ToStop.Single().ShouldBe("a");
|
||||||
plan.ToSpawn.ShouldBeEmpty();
|
plan.ToSpawn.ShouldBeEmpty();
|
||||||
plan.ToApplyDelta.ShouldBeEmpty();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that disabled new drivers are not spawned.</summary>
|
/// <summary>Verifies that disabled new drivers are not spawned.</summary>
|
||||||
@@ -113,7 +108,6 @@ public sealed class DriverSpawnPlannerTests
|
|||||||
var plan = DriverSpawnPlanner.Compute(current, target);
|
var plan = DriverSpawnPlanner.Compute(current, target);
|
||||||
|
|
||||||
plan.ToSpawn.ShouldBeEmpty();
|
plan.ToSpawn.ShouldBeEmpty();
|
||||||
plan.ToApplyDelta.ShouldBeEmpty();
|
|
||||||
plan.ToStop.ShouldBeEmpty();
|
plan.ToStop.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +125,6 @@ public sealed class DriverSpawnPlannerTests
|
|||||||
|
|
||||||
plan.ToStop.Single().ShouldBe("a");
|
plan.ToStop.Single().ShouldBe("a");
|
||||||
plan.ToSpawn.Single().DriverType.ShouldBe("AbCip");
|
plan.ToSpawn.Single().DriverType.ShouldBe("AbCip");
|
||||||
plan.ToApplyDelta.ShouldBeEmpty();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -153,7 +146,6 @@ public sealed class DriverSpawnPlannerTests
|
|||||||
|
|
||||||
plan.ToStop.Single().ShouldBe("a");
|
plan.ToStop.Single().ShouldBe("a");
|
||||||
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
|
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
|
||||||
plan.ToApplyDelta.ShouldBeEmpty();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -173,7 +165,6 @@ public sealed class DriverSpawnPlannerTests
|
|||||||
|
|
||||||
plan.ToStop.Single().ShouldBe("a");
|
plan.ToStop.Single().ShouldBe("a");
|
||||||
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
|
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
|
||||||
plan.ToApplyDelta.ShouldBeEmpty();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -196,6 +187,5 @@ public sealed class DriverSpawnPlannerTests
|
|||||||
|
|
||||||
plan.ToStop.Single().ShouldBe("a");
|
plan.ToStop.Single().ShouldBe("a");
|
||||||
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
|
plan.ToSpawn.Single().DriverInstanceId.ShouldBe("a");
|
||||||
plan.ToApplyDelta.ShouldBeEmpty();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user