fix(virtual-tags): resolve Medium code-review findings (Core.VirtualTags-002, -003, -005, -008, -012)

Core.VirtualTags-002: cold-start guard publishes BadWaitingForInitialData
instead of silently returning a stale value.
Core.VirtualTags-003: Load detects duplicate Path values and keys the
upstream-subscription loop off the registered tag set.
Core.VirtualTags-005: VirtualTagSource fires the initial-data callback per
path before registering the change observer, fixing an ordering race.
Core.VirtualTags-008: DependencyGraph caches topological rank, lowering
per-change-event cost from O(V+E) to O(closure).
Core.VirtualTags-012: added 9 engine tests; CoerceResult null-return now
maps to BadInternalError as the code comment intended.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 08:31:49 -04:00
parent 11612900ba
commit 3d8c285034
5 changed files with 252 additions and 31 deletions

View File

@@ -7,7 +7,7 @@
| Review date | 2026-05-22 |
| Commit reviewed | `76d35d1` |
| Status | Reviewed |
| Open findings | 12 |
| Open findings | 7 |
## Checklist coverage
@@ -67,7 +67,7 @@ code and docs agree.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs:237` |
| Status | Open |
| Status | Resolved |
**Description:** The cold-start guard `if (!AreInputsReady(ctxCache)) return;` silently
abandons the evaluation when any input is null or Bad-quality. For a chained virtual tag
@@ -87,7 +87,7 @@ rather than returning with no state change, so clients see a defined quality. If
operators need scripts that handle Bad upstreams, consider a per-definition opt-out of
the readiness guard.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-22 — cold-start guard now publishes `BadWaitingForInitialData` (0x80320000) and notifies observers instead of silently returning, so OPC UA clients see a defined quality rather than a stale prior value.
### Core.VirtualTags-003
@@ -96,7 +96,7 @@ the readiness guard.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs:117-120` |
| Status | Open |
| Status | Resolved |
**Description:** The upstream-subscription loop in `Load` iterates
`definitions.SelectMany(d => _tags[d.Path].Reads)`. If `definitions` contains two rows
@@ -115,7 +115,7 @@ them to `compileFailures` (or a dedicated rejection list) so the aggregated
`definitions.SelectMany(d => _tags[d.Path]...)` when collecting upstream paths so the
collection is keyed off the registered set, not the raw input list.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-22 — `Load` now tracks seen paths and adds a duplicate-path entry to `compileFailures`; the upstream-subscription loop iterates `_tags.Values` instead of the raw `definitions` list so it is keyed off the registered set.
### Core.VirtualTags-004
@@ -148,7 +148,7 @@ document precisely which `DriverDataType` values `CoerceResult` supports and val
| Severity | Medium |
| Category | Concurrency & thread safety |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs:50-64` |
| Status | Open |
| Status | Resolved |
**Description:** `SubscribeAsync` registers the per-path engine observers first (lines
52-56), then in a second loop reads the current value and fires the initial-data
@@ -163,7 +163,7 @@ each path before registering the change observer for that path (or hold a per-ha
lock spanning both so no engine callback interleaves). The initial value must be
delivered before any subsequent change for that path.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-22 — `SubscribeAsync` now fires the initial-data callback per path before registering the change observer for that path, eliminating the out-of-order delivery race.
### Core.VirtualTags-006
@@ -223,7 +223,7 @@ expected upper bound on group evaluation time relative to the interval.
| Severity | Medium |
| Category | Performance & resource management |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs:81-115` |
| Status | Open |
| Status | Resolved |
**Description:** `TransitiveDependentsInOrder` calls `TopologicalSort()` (a full O(V+E)
Kahn pass plus a Dictionary rank build) on every invocation, and it is invoked from
@@ -237,7 +237,7 @@ end of `Load` and cache it on `DependencyGraph` (invalidated by `Add` / `Clear`)
`TransitiveDependentsInOrder` then reuses the cached rank map. This turns a per-event
O(V+E) cost into an O(closure) cost.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-22 — `DependencyGraph` now caches the topological rank dictionary (invalidated by `Add`/`Clear`) via `GetOrBuildRank()`; `TransitiveDependentsInOrder` reuses it, reducing per-change-event cost from O(V+E) to O(closure).
### Core.VirtualTags-009
@@ -314,7 +314,7 @@ retained.
| Severity | Medium |
| Category | Testing coverage |
| Location | `tests/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests/` |
| Status | Open |
| Status | Resolved |
**Description:** Several behaviours of the engine have no test coverage:
(1) the cold-start `AreInputsReady` guard -- no test exercises an upstream that is
@@ -333,7 +333,7 @@ double-to-int32 is tested);
**Recommendation:** Add unit tests for each path above. Items (1), (2), and (6) directly
correspond to open correctness findings and would have caught them.
**Resolution:** _(open)_
**Resolution:** Resolved 2026-05-22 — added 9 unit tests covering all 7 gaps: `AreInputsReady` guard publishes `BadWaitingForInitialData` and recovers; `SetVirtualTag` cascade to dependent; write to non-registered path; `EvaluateOneAsync` before `Load` and for unregistered path; `CoerceResult` failure maps to `BadInternalError`; duplicate-path rejection; `Read`/`Subscribe` before `Load`.
### Core.VirtualTags-013