docs(otopcua): record FixedTree follow-ups A-E as implemented (design, plan, RESUME)
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
# FixedTree-injection follow-ups — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (or executing-plans) to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Implement the five approved follow-ups to the FixedTree-under-Equipment dynamic-injection feature: (A) injectable discovery timeout, (B) per-driver re-discovery policy gate, (C) re-trigger discovery on a config-unchanged rebind, (D) de-dup the double `SetDesiredSubscriptions`, and (E) lift the ≥1-authored-tag requirement + support multi-device-per-driver.
|
||||
|
||||
**Architecture:** Akka.NET actor pipeline. `DriverInstanceActor` runs post-connect discovery and publishes `DiscoveredNodesReady`; `DriverHostActor` resolves the bound equipment, maps discovered nodes via `DiscoveredNodeMapper`, caches a plan, materialises via `OpcUaPublishActor`, and merges subscription refs. Composition is built by `AddressSpaceComposer.Compose` (pure, from entities) and mirrored by `DeploymentArtifact` (decode, from the sealed JSON artifact) — the two MUST stay byte-parity-equal. The deployment artifact already serialises full `Equipment` + `Device` entities, so E needs **no DB migration and no artifact wire-format change** — only decode/projection reads.
|
||||
|
||||
**Tech Stack:** .NET 10, C# (default interface members, collection expressions), Akka.NET, xUnit. Build: `dotnet build ZB.MOM.WW.OtOpcUa.slnx` (TreatWarningsAsErrors). Test (macOS — run filtered, NOT full-solution; the net48 Wonderware testhost can't run on macOS):
|
||||
- `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~Runtime.Tests"`
|
||||
- `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~OpcUaServer.Tests"`
|
||||
- `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~FOCAS"`
|
||||
|
||||
**Design:** [`2026-06-26-otopcua-fixedtree-followups-design.md`](2026-06-26-otopcua-fixedtree-followups-design.md). Branch: `feat/focas-fixedtree-equipment-injection` (continue on it; commit per task; do NOT push/merge — standing rule).
|
||||
|
||||
**Out of scope (locked):** discovered-alarm injection; writable discovered nodes.
|
||||
|
||||
---
|
||||
|
||||
## Execution order & parallelism
|
||||
|
||||
Two files are each touched by multiple tasks and MUST be edited serially:
|
||||
- `DriverInstanceActor.cs`: **Task 1 → Task 3 → Task 4**
|
||||
- `DriverHostActor.cs`: **Task 6 → Task 7 → Task 8 → Task 9**
|
||||
|
||||
Independent file sets that can run concurrently with the above: **Task 2** (`ITagDiscovery` + 5 driver files) and **Task 5** (`AddressSpaceComposer.cs` + `DeploymentArtifact.cs`).
|
||||
|
||||
Dependency summary: T3 ⟵{T1,T2}; T4 ⟵T3; T6 ⟵T5; T7 ⟵{T4,T6}; T8 ⟵T7; T9 ⟵{T5,T8}; T10 ⟵T9; T11 ⟵{T2,T4,T9,T10}.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Injectable discovery timeout (follow-up A)
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 2, Task 5
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs` (ctor ~244-259, `Props` ~195-210, fields ~133-137, `HandleRediscoverAsync` ~765)
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs`
|
||||
|
||||
**Context:** `HandleRediscoverAsync` hardcodes `using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));` (line 765). The rediscover interval + attempt-cap are already ctor params (`_rediscoverInterval`, `_rediscoverMaxAttempts`). Add a sibling param for the per-pass discovery timeout, default-preserving.
|
||||
|
||||
**Step 1 — Failing test:** add a test asserting that when constructed with a very short discovery timeout and an `ITagDiscovery` whose `DiscoverAsync` blocks, the pass cancels by the injected timeout (e.g. `DiscoveredNodesReady` carries an empty set within the short window) rather than waiting 30 s. Reuse the existing fake `ITagDiscovery` driver in this test file (search it for the existing discovery-actor fake; mirror that pattern). If a fully deterministic timeout test is too flaky, instead assert the wiring: a new public `DefaultRediscoverDiscoverTimeout` constant exists and equals 30 s, and the ctor/`Props` accept the param.
|
||||
|
||||
**Step 2 — Verify it fails:** `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~DriverInstanceActorDiscoveryTests"` → fails to compile / fails assertion.
|
||||
|
||||
**Step 3 — Implement:**
|
||||
- Add `public static readonly TimeSpan DefaultRediscoverDiscoverTimeout = TimeSpan.FromSeconds(30);` next to the other discovery defaults (~line 36-39).
|
||||
- Add a field `private readonly TimeSpan _rediscoverDiscoverTimeout;` (~133-137).
|
||||
- Add ctor param `TimeSpan? rediscoverDiscoverTimeout = null` (after `rediscoverMaxAttempts`); assign `_rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout;`.
|
||||
- Add the matching optional param to `Props` and forward it.
|
||||
- In `HandleRediscoverAsync`, replace `TimeSpan.FromSeconds(30)` with `_rediscoverDiscoverTimeout`.
|
||||
|
||||
**Step 4 — Verify:** test passes; `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → 0 warnings.
|
||||
|
||||
**Step 5 — Commit:** `git commit -m "feat(otopcua): make FixedTree re-discovery per-pass timeout injectable (follow-up A)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Re-discovery policy enum + ITagDiscovery member + driver overrides (follow-up B, part 1)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 1, Task 5
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs`
|
||||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs`
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs` (or a small new test next to the FOCAS driver tests asserting `FocasDriver` reports `UntilStable`)
|
||||
|
||||
**Context:** `ITagDiscovery` (Core.Abstractions) currently has only `DiscoverAsync`. Add a policy the actor (Task 3) honors. Default = today's behavior so any non-overriding driver is unchanged.
|
||||
|
||||
**Step 1 — Failing test:** assert `new FocasDriver(...).RediscoverPolicy == DiscoveryRediscoverPolicy.UntilStable` and that one network driver (e.g. `OpcUaClientDriver`) reports `Once`. (Construct via the simplest available ctor/fake; if drivers are hard to construct standalone, assert the enum + default member exist and compile, plus a focused test on FOCAS.)
|
||||
|
||||
**Step 2 — Verify it fails:** compile failure (enum/member absent).
|
||||
|
||||
**Step 3 — Implement:**
|
||||
- In `ITagDiscovery.cs`, add the enum + a default-implemented member:
|
||||
```csharp
|
||||
/// <summary>How aggressively the host re-runs post-connect discovery for this driver.</summary>
|
||||
public enum DiscoveryRediscoverPolicy
|
||||
{
|
||||
/// <summary>Retry every interval up to the cap or until the captured set is non-empty and stable
|
||||
/// (for drivers whose discovered shape fills in asynchronously after connect, e.g. FOCAS FixedTree).</summary>
|
||||
UntilStable,
|
||||
/// <summary>Run exactly one discovery pass on connect (drivers that discover synchronously in DiscoverAsync).</summary>
|
||||
Once,
|
||||
/// <summary>Never run post-connect discovery.</summary>
|
||||
Never,
|
||||
}
|
||||
|
||||
public interface ITagDiscovery
|
||||
{
|
||||
/// <summary>Post-connect re-discovery policy. Default preserves the original retry-until-stable behavior.</summary>
|
||||
DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.UntilStable;
|
||||
|
||||
Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken);
|
||||
}
|
||||
```
|
||||
- `FocasDriver`: add `public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.UntilStable;` (explicit — it genuinely needs the retry loop).
|
||||
- `OpcUaClientDriver`, `TwinCATDriver`, `AbCipDriver`, `AbLegacyDriver`: add `public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;` — these discover synchronously inside `DiscoverAsync`, so one pass on connect suffices; the 15× retry was wasted (potentially heavy) work. **Before setting `Once`, confirm each driver's `DiscoverAsync` returns its complete set synchronously** (read each `DiscoverAsync`); if any populates a cache asynchronously after connect like FOCAS, leave it `UntilStable` and note why in a comment.
|
||||
|
||||
**Step 4 — Verify:** test passes; build 0 warnings.
|
||||
|
||||
**Step 5 — Commit:** `git commit -m "feat(otopcua): add ITagDiscovery.RediscoverPolicy + per-driver assignments (follow-up B)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: DriverInstanceActor honors RediscoverPolicy (follow-up B, part 2)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (serial after Task 1 on the same file; needs Task 2's enum)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs` (`StartDiscovery` ~736-740, `HandleRediscoverAsync` ~754-795)
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs`
|
||||
|
||||
**Context:** `StartDiscovery()` currently kicks the loop for every `ITagDiscovery` driver. `HandleRediscoverAsync` schedules the next tick unless stable/capped. Gate both on the driver's `RediscoverPolicy`.
|
||||
|
||||
**Step 1 — Failing tests (3):**
|
||||
1. A fake `ITagDiscovery` driver reporting `Never` → no `DiscoveredNodesReady` is ever published after connect.
|
||||
2. A fake reporting `Once` whose captured set would keep GROWING across passes → exactly ONE `DiscoveredNodesReady` and no further tick scheduled.
|
||||
3. A fake reporting `UntilStable` → existing behavior (retries until stable/cap) — keep/extend the current passing test.
|
||||
|
||||
**Step 2 — Verify they fail:** the `Never`/`Once` tests fail (today everything retries-until-stable).
|
||||
|
||||
**Step 3 — Implement:**
|
||||
- In `StartDiscovery()`: after the `if (_driver is not ITagDiscovery discovery) return;` guard, read the policy; `if (discovery.RediscoverPolicy == DiscoveryRediscoverPolicy.Never) return;` before scheduling the first `RediscoverTick`.
|
||||
- In `HandleRediscoverAsync`: after publishing `DiscoveredNodesReady`, when the policy is `Once`, do NOT schedule another tick (log Debug "policy=Once, single pass" and return). When `UntilStable`, keep today's stop-on-stable + cap logic. (Read the live policy via `((ITagDiscovery)_driver).RediscoverPolicy`.)
|
||||
- Keep the generation guard intact.
|
||||
|
||||
**Step 4 — Verify:** the 3 tests pass; the full `DriverInstanceActorDiscoveryTests` + `Runtime.Tests` suite stays green; build 0 warnings.
|
||||
|
||||
**Step 5 — Commit:** `git commit -m "feat(otopcua): DriverInstanceActor honors RediscoverPolicy (Never/Once/UntilStable) (follow-up B)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: TriggerRediscovery message + handler (follow-up C, part 1)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (serial after Task 3 on the same file)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs` (message decls near `RediscoverTick` ~110-115; add a `Connected`-state receive)
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDiscoveryTests.cs`
|
||||
|
||||
**Context:** Task 7 (`DriverHostActor`) will `Tell` a driver child to re-run discovery after a rebind. The child must accept that message and only act when `Connected`.
|
||||
|
||||
**Step 1 — Failing tests (2):**
|
||||
1. Send `TriggerRediscovery` to an actor whose driver is `Connected` → it runs a discovery pass and publishes `DiscoveredNodesReady` (respecting policy: a `Never` driver does NOT).
|
||||
2. Send `TriggerRediscovery` before connect / while not `Connected` → no `DiscoveredNodesReady`, no crash (no-op).
|
||||
|
||||
**Step 2 — Verify they fail:** message type doesn't exist.
|
||||
|
||||
**Step 3 — Implement:**
|
||||
- Add `public sealed record TriggerRediscovery();` near the other public messages.
|
||||
- In the `Connected` state, add a receive for `TriggerRediscovery` that calls `StartDiscovery()` (which already honors policy + the `ITagDiscovery` guard, and uses the current `_initGeneration`).
|
||||
- In other states, either don't register the receive (so it's unhandled = no-op) or register a no-op. Prefer registering only in `Connected` so a non-connected child silently ignores it (verify the actor's state-machine style — match how other state-scoped messages are handled). Ensure no `Unhandled`-logging noise; if the actor logs unhandled messages, add an explicit ignore in the relevant states.
|
||||
|
||||
**Step 4 — Verify:** both tests pass; suite green; build 0 warnings.
|
||||
|
||||
**Step 5 — Commit:** `git commit -m "feat(otopcua): DriverInstanceActor.TriggerRediscovery message (follow-up C)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: EquipmentNode carries DriverInstanceId/DeviceId/DeviceHost (follow-up E, projection)
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** Task 1, Task 2
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs` (`EquipmentNode` record line 61; projection ~326-332; `Compose` signatures ~281-312)
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` (`ReadEquipmentNode` ~810-820; the equipment decode call ~204; `Empty()` ~362-367; add a `Devices`-array → `DeviceId`→host map)
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/` (composer projection test) and the existing artifact-decode/parity test for `EquipmentNode` (search `tests/` for `ReadEquipmentNode`/`EquipmentNodes`/`DeploymentArtifact` coverage; if a Compose-vs-decode parity test exists, extend it)
|
||||
|
||||
**Context:** The artifact already serialises full `Equipment` rows (incl. nullable `DriverInstanceId`, `DeviceId`) and a full `Devices` array (each `Device` has `DeviceId` + schemaless `DeviceConfig` JSON containing FOCAS's `HostAddress`). `Compose` (pure) and `DeploymentArtifact` (decode) MUST produce identical `EquipmentNode`s. `_lastComposition` (used by the resolver) always comes from decode, but parity is still required by tests.
|
||||
|
||||
**Step 1 — Failing tests:**
|
||||
- Composer: given an `Equipment` with `DriverInstanceId="d1"`, `DeviceId="dev1"`, and a `Device{DeviceId="dev1", DeviceConfig={"HostAddress":"10.0.0.5:8193"}}`, `Compose(...)` yields `EquipmentNode` with `DriverInstanceId=="d1"`, `DeviceId=="dev1"`, `DeviceHost=="10.0.0.5:8193"`; with no device assigned → all three null.
|
||||
- Decode: an artifact JSON whose `Equipment` element has those fields + a matching `Devices` element decodes to the same `EquipmentNode`.
|
||||
|
||||
**Step 2 — Verify they fail:** `EquipmentNode` has no such fields.
|
||||
|
||||
**Step 3 — Implement:**
|
||||
- Extend the record (defaulted params keep all existing call sites compiling):
|
||||
```csharp
|
||||
public sealed record EquipmentNode(
|
||||
string EquipmentId,
|
||||
string DisplayName,
|
||||
string UnsLineId,
|
||||
string? DriverInstanceId = null,
|
||||
string? DeviceId = null,
|
||||
string? DeviceHost = null);
|
||||
```
|
||||
- Add a shared host-extraction helper usable by BOTH sides (place it where both can call it without a new project dependency — e.g. a `public static string? TryExtractDeviceHost(string? deviceConfigJson)` on `AddressSpaceComposer`, parsing the top-level `"HostAddress"` string from the `DeviceConfig` JSON; return null if absent/unparseable). Add a normalization step (trim; lower-case host) and DOCUMENT that the discovered device-host folder segment must be normalized the same way in Task 9.
|
||||
- `Compose`: add an optional `IReadOnlyList<Device>? devices = null` param to BOTH overloads (forward from the 5-arg overload as empty). Build `deviceHostById = devices.ToDictionary(d => d.DeviceId, d => TryExtractDeviceHost(d.DeviceConfig))`. In the equipment projection, set `DriverInstanceId: e.DriverInstanceId`, `DeviceId: e.DeviceId`, `DeviceHost: e.DeviceId is null ? null : deviceHostById.GetValueOrDefault(e.DeviceId)`.
|
||||
- `DeploymentArtifact`: read the `Devices` array (decode `DeviceId` + `DeviceConfig`) into a `DeviceId`→host map using the SAME `TryExtractDeviceHost` helper; thread it into `ReadEquipmentNode` (change its signature to accept the map, or do a post-pass) so it reads `DriverInstanceId`/`DeviceId` from the element and resolves `DeviceHost` from the map. Update `Empty()` only if its arity changed (it won't — record params are defaulted).
|
||||
- **Parity:** ensure the decode-side host normalization is byte-identical to `Compose`'s (same helper). If a Compose-vs-decode parity test exists, pass the same `Devices` to `Compose` in that test.
|
||||
|
||||
**Step 4 — Verify:** new tests pass; `OpcUaServer.Tests` + `Runtime.Tests` green; build 0 warnings. **Run the existing artifact-parity test** — it MUST stay green.
|
||||
|
||||
**Step 5 — Commit:** `git commit -m "feat(otopcua): EquipmentNode carries DriverInstanceId/DeviceId/DeviceHost (follow-up E projection)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: DriverHostActor — cache-as-dict + driver-level equipment resolution (follow-up E, part 1)
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (serial: first DriverHostActor task; needs Task 5)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (`_discoveredByDriver` field ~168; `HandleDiscoveredNodes` ~580-639; `ApplyDiscoveredPlan` ~658-701; `RoutingEquals`; redeploy re-inject tail ~1247-1290)
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs`
|
||||
|
||||
**Context:** Today `_discoveredByDriver` is `Dictionary<string, DiscoveredInjectionPlan>` (one plan per driver) and equipment is resolved ONLY from authored `EquipmentTags`. This task (1) changes the cache value to a per-equipment map so Task 9 can add multiple equipments, and (2) makes resolution also use the equipment-level driver link so a driver with an assigned equipment but ZERO authored tags still grafts. **Still requires exactly one resolved equipment here** (multi-device is Task 9) — >1 keeps the current warn+skip.
|
||||
|
||||
**Step 1 — Failing tests:**
|
||||
- Tag-less graft: composition has an `EquipmentNode{DriverInstanceId="d1"}` with NO authored `EquipmentTags` for `d1`; `DiscoveredNodesReady("d1", nodes)` → nodes graft under that equipment (today: skipped with "no equipment/authored tags").
|
||||
- Regression: the existing single-equipment-with-authored-tags test still grafts identically (collapse retained).
|
||||
|
||||
**Step 2 — Verify it fails:** tag-less case is skipped today.
|
||||
|
||||
**Step 3 — Implement:**
|
||||
- Change `_discoveredByDriver` to `Dictionary<string, IReadOnlyDictionary<string, DiscoveredInjectionPlan>>` (driverId → (equipmentId → plan)). Update ALL readers: `HandleDiscoveredNodes` short-circuit, `ApplyDiscoveredPlan`, and the redeploy re-inject tail must iterate the inner map.
|
||||
- New resolution in `HandleDiscoveredNodes`: candidate equipments =
|
||||
`_lastComposition.EquipmentNodes.Where(e => e.DriverInstanceId == driverId).Select(e => e.EquipmentId)`
|
||||
**∪** the existing authored-tag-derived set. Distinct.
|
||||
- 0 → log Info, skip (unchanged message).
|
||||
- 1 → resolve `equipmentId`; authoredRefs for that driver as today; `DiscoveredNodeMapper.Map(equipmentId, nodes, authoredRefs)`; cache as a 1-entry inner map; apply.
|
||||
- >1 → for THIS task, keep `_log.Warning(... "multi-equipment-per-driver is handled in the multi-device path")` + skip. (Task 9 replaces this branch.)
|
||||
- `ApplyDiscoveredPlan`: keep applying a single `(equipmentId, plan)`; callers now iterate the inner map and call it per entry. The subscription-merge union must include ALL discovered routing keys across the driver's plans (so a multi-plan driver subscribes every device's refs). Keep the authored value/alarm ref computation.
|
||||
- `RoutingEquals` short-circuit: compare the FULL new inner-map routing against the cached inner-map routing (skip re-apply only when every equipment's routing is unchanged).
|
||||
- Redeploy re-inject tail: iterate `_discoveredByDriver`; for each driver, re-resolve candidates from the CURRENT composition; per cached `(equipmentId, plan)` entry, keep the existing drop rules (equipment no longer resolves / plan NodeIds not scoped to `equipmentId`) but applied per-entry; re-apply surviving entries. (Task 7 will add the re-trigger on drop; Task 8 the de-dup.)
|
||||
|
||||
**Step 4 — Verify:** new + existing `DriverHostActorDiscoveryTests` green; `Runtime.Tests` green; build 0 warnings.
|
||||
|
||||
**Step 5 — Commit:** `git commit -m "feat(otopcua): driver-level equipment resolution + per-equipment discovered-plan cache (follow-up E)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: DriverHostActor — re-trigger discovery on rebind drop (follow-up C, part 2)
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none (serial after Task 6; needs Task 4's message)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (redeploy re-inject tail drop branches ~1264-1288; update the deliberate-`won't-fix` comment)
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs`
|
||||
|
||||
**Context:** When the re-inject tail DROPS a cached plan because the equipment rebound/no-longer-resolves, the FixedTree stays absent under the new equipment until the driver's next natural reconnect. Re-trigger discovery so it re-grafts promptly.
|
||||
|
||||
**Step 1 — Failing test:** simulate a redeploy where a driver's equipment changed (cached plan scoped to old `EQ-1`, new composition binds the driver to `EQ-2`). Assert the driver child receives `DriverInstanceActor.TriggerRediscovery` after the drop. (Use the test harness's child-probe/TestProbe pattern already used in this file for asserting messages to driver children.)
|
||||
|
||||
**Step 2 — Verify it fails:** no re-trigger today.
|
||||
|
||||
**Step 3 — Implement:** in each drop branch (the two `Remove` sites), after removing the entry, `Tell` that driver's child actor `new DriverInstanceActor.TriggerRediscovery()` (guard: only if the child exists in `_children`). Update the inline comment: the previous "we deliberately do NOT add re-trigger logic" note becomes a description of the new re-trigger (discovery-only, idempotent, child no-ops if not `Connected`). If a driver maps to MULTIPLE cached equipment entries and only one drops, still send a single `TriggerRediscovery` (discovery re-resolves all of them) — de-dupe so a driver is told at most once per re-inject pass.
|
||||
|
||||
**Step 4 — Verify:** test passes; suite green; build 0 warnings.
|
||||
|
||||
**Step 5 — Commit:** `git commit -m "feat(otopcua): re-trigger discovery on config-unchanged rebind (follow-up C)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 8: DriverHostActor — single SetDesiredSubscriptions per redeploy (follow-up D)
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (serial after Task 7)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (`PushDesiredSubscriptions` bulk loop ~1204; re-inject tail interaction)
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs`
|
||||
|
||||
**Context:** During an in-process redeploy a cached driver gets the bulk authored-only `SetDesiredSubscriptions` (line 1204) AND then the union from `ApplyDiscoveredPlan` (line 697) — one extra unsub/resub blip. Make it exactly one send per driver.
|
||||
|
||||
**Step 1 — Failing test:** redeploy with one driver that has a cached discovered plan; assert the driver child receives `SetDesiredSubscriptions` EXACTLY ONCE during the redeploy, and that the single payload is the authored∪discovered UNION. Add a second test: a driver whose cached plan is DROPPED in the re-inject tail (rebind) still receives exactly one `SetDesiredSubscriptions` carrying the AUTHORED-ONLY set (fallback) — its authored subscriptions must not be lost.
|
||||
|
||||
**Step 2 — Verify it fails:** today the cached-driver case sends twice.
|
||||
|
||||
**Step 3 — Implement:**
|
||||
- In the bulk loop, SKIP the send for any `driverId` present in `_discoveredByDriver` (capture the key set BEFORE the re-inject tail runs).
|
||||
- Re-inject tail: when a cached plan is APPLIED, `ApplyDiscoveredPlan` already sends the union (covers authored). When a cached plan is DROPPED (all entries for the driver removed → the driver no longer has any cached plan), send the authored-only `SetDesiredSubscriptions` for that driver as a fallback (mirror the bulk-loop payload: authored value refs + alarm refs, `SubscriptionPublishingInterval`).
|
||||
- Ensure the invariant holds for drivers WITHOUT a cached plan (unchanged: single bulk send) and drivers added/removed by the reconcile.
|
||||
|
||||
**Step 4 — Verify:** both tests pass; the existing redeploy/restore tests stay green (watch for any test asserting the old double-send count); build 0 warnings.
|
||||
|
||||
**Step 5 — Commit:** `git commit -m "perf(otopcua): one SetDesiredSubscriptions per driver per redeploy (follow-up D)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 9: DriverHostActor — multi-device-per-driver partition (follow-up E, part 2)
|
||||
|
||||
**Classification:** high-risk
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (serial after Task 8; needs Task 5's DeviceHost)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (`HandleDiscoveredNodes` >1-candidate branch from Task 6)
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorDiscoveryTests.cs`
|
||||
|
||||
**Context:** Replace Task 6's ">1 candidate → warn+skip" with a real partition. Each candidate equipment has `EquipmentNode.DeviceHost` (from Task 5). The discovered nodes carry a device-host folder segment at `FolderPathSegments[1]` (FOCAS uses `device.HostAddress`). Partition nodes by that segment, normalize it the SAME way Task 5 normalized `DeviceHost`, and map each device's subset under the matching equipment via the existing `DiscoveredNodeMapper.Map` (a single-device subset → collapse kicks in per equipment → clean `EQ-n/FOCAS/Identity/...`).
|
||||
|
||||
**Step 1 — Failing tests:**
|
||||
- Multi-device: driver `d1` resolves to `EQ-A{DeviceHost=h1}` and `EQ-B{DeviceHost=h2}`; discovered nodes split across folder segments `h1`/`h2`; assert `h1`'s subtree grafts under `EQ-A` and `h2`'s under `EQ-B`, each routing-keyed correctly, and `_discoveredByDriver["d1"]` has two entries.
|
||||
- Unmatched device-host → warn-skip: a discovered segment `h3` with no matching equipment is NOT grafted (logged Warning), while `h1`/`h2` still graft.
|
||||
- Degenerate: >1 candidate but NO `DeviceHost` data anywhere → falls back to warn+skip (no crash, no mis-graft).
|
||||
|
||||
**Step 2 — Verify it fails:** Task 6 left this as warn+skip.
|
||||
|
||||
**Step 3 — Implement:** in the >1-candidate branch, build `hostToEquipment = candidates.Where(e => e.DeviceHost != null).ToDictionary(Normalize(e.DeviceHost), e.EquipmentId)` (guard duplicate hosts → warn+skip the ambiguous host). Partition `nodes` by `Normalize(FolderPathSegments.Count >= 2 ? FolderPathSegments[1] : null)`. For each partition with a matching equipment: compute that equipment's authoredRefs, `Map(equipmentId, partitionNodes, authoredRefs)`, collect into the inner `(equipmentId → plan)` map. Unmatched partitions → `_log.Warning` + skip. Cache the multi-entry inner map and apply every entry (Task 6 made apply per-entry). Use the SAME normalization helper from Task 5 (factor it so both call it).
|
||||
|
||||
**Step 4 — Verify:** all three tests pass; single-device + tag-less tests from Task 6 still green; `Runtime.Tests` + `OpcUaServer.Tests` + FOCAS suites green; build 0 warnings.
|
||||
|
||||
**Step 5 — Commit:** `git commit -m "feat(otopcua): multi-device-per-driver FixedTree partition (follow-up E)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Docs — update follow-up notes + design statuses
|
||||
|
||||
**Classification:** trivial
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** none (after Task 9)
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection-design.md` (the "Follow-ups surfaced during the review chain" section + the decisions-table multi-device row — mark A–E DONE, note the rebind re-trigger now exists)
|
||||
- Modify: `docs/plans/2026-06-26-otopcua-fixedtree-followups-design.md` (Status → Implemented)
|
||||
- Modify: `docs/plans/2026-06-26-otopcua-fixedtree-equipment-injection-RESUME.md` (§3 — strike the now-closed follow-ups)
|
||||
|
||||
**Steps:** update the prose to reflect what shipped (each follow-up + the fact that E required no migration / no artifact change; the rebind re-trigger reversed the earlier `won't-fix`, cleanly). Commit: `git commit -m "docs(otopcua): record FixedTree follow-ups A-E as implemented"`
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Build + full offline suite + regression gate
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min (mostly test wall-time)
|
||||
**Parallelizable with:** none (final; after Tasks 2, 4, 9, 10)
|
||||
|
||||
**Files:** none (verification only)
|
||||
|
||||
**Steps:**
|
||||
1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → **0 errors, 0 warnings**.
|
||||
2. `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~Runtime.Tests"` → all green.
|
||||
3. `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~OpcUaServer.Tests"` → all green.
|
||||
4. `dotnet test ZB.MOM.WW.OtOpcUa.slnx --filter "FullyQualifiedName~FOCAS"` → all green (live-wire integration tests skip without the CNC — expected).
|
||||
5. Confirm the validated single-device FOCAS injection path is unchanged (the relevant `DriverHostActorDiscoveryTests`/end-to-end test passes untouched). Report counts. Do NOT run a full-solution `dotnet test` (net48 Wonderware testhost can't run on macOS).
|
||||
|
||||
No commit (verification). Live wonder re-validation is optional + user-gated.
|
||||
Reference in New Issue
Block a user