1891f5d6a7
Re-ran all seven domain reviews at master f6eaa267 (reports rewritten in
place, each with a prior-finding status table): all 4 round-1 Criticals
verified closed; new top findings are the S7 connect-timeout OCE regression
(05/STAB-14), the ResilienceConfig operator-authorable brick (01/S-6), and
a batch of resilience-seam Mediums. 00-OVERALL.md carries the updated
maturity matrix + 12-item action list.
Adds R2-01..R2-12 design/implementation plans (one per action item, house
format + bite-sized TDD task breakdowns + co-located .tasks.json; 193 tasks,
~18-24 dev-days). STATUS.md updated: round-1 topology marked historical
(all merged+pushed), re-review findings table + plan pointers added.
676 lines
46 KiB
Markdown
676 lines
46 KiB
Markdown
# Design + Implementation Plan — R2-03: VT script-failure quality + cache-clear hardening
|
||
|
||
- **Source report:** `archreview/02-scripting-alarms.md` (2026-07-12 re-review)
|
||
- **Review commit:** `f6eaa267` · **Plan verified against tree at:** `f6eaa267` (master, clean)
|
||
- **Scope:** OVERALL prioritized action item **#3** — "Publish Bad quality on VT script failure/timeout
|
||
(match the dormant engine's BadInternalError); optionally gate the apply-boundary cache clear on
|
||
changed scripts" — covering findings **02/S13** (Medium — failure/timeout never degrades node quality),
|
||
**02/S12** (Medium — apply-boundary `ClearCompiledScripts` races in-flight child evaluations), and
|
||
**02/P7** (Low — unconditional per-apply cache flush forces full VT recompilation on every deploy).
|
||
- **Touches:** `Runtime/VirtualTags/VirtualTagActor.cs`, `Runtime/VirtualTags/VirtualTagHostActor.cs`,
|
||
`Host/Engines/RoslynVirtualTagEvaluator.cs`, `Runtime.Tests/VirtualTags/*`,
|
||
`Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs`. **No Core.Scripting / Commons-contract /
|
||
OPC UA-sink changes** except one additive field on `VirtualTagActor.EvaluationResult`.
|
||
|
||
## Verification summary
|
||
|
||
Every anchor in the three findings was opened at the cited file:line against the working tree at
|
||
`f6eaa267`. **All three findings CONFIRMED. One minor anchor drift** (noted below); everything else is
|
||
line-exact.
|
||
|
||
- **S13** — Confirmed. `VirtualTagActor.OnDependencyChanged` returns without any publish on
|
||
`!result.Success` (`Runtime/VirtualTags/VirtualTagActor.cs:123-128`) and on evaluator-threw
|
||
(`:115-121`) — Warning `ScriptLogEntry` + `fail` metric only. The host bridge hard-codes
|
||
`OpcUaQuality.Good` into every `AttributeValueUpdate` (`Runtime/VirtualTags/VirtualTagHostActor.cs:179-180`)
|
||
and hard-codes `0u /* StatusCodes.Good */` into the historize snapshot (`:187-188`).
|
||
`EvaluationResult` (`VirtualTagActor.cs:23`) carries no quality at all, so the child *cannot* express
|
||
degradation today. Confirmed downstream that Bad quality *is* honored end to end once published:
|
||
`OpcUaPublishActor.HandleAttributeUpdate` → `IOpcUaAddressSpaceSink.WriteValue` →
|
||
`OtOpcUaNodeManager.WriteValue` (`OpcUaServer/OtOpcUaNodeManager.cs:261-281`) sets
|
||
`variable.StatusCode = StatusFromQuality(quality)` + `ClearChangeMasks`, where `StatusFromQuality`
|
||
(`:2418-2423`) maps `Good→StatusCodes.Good`, `Uncertain→StatusCodes.Uncertain`, `_→StatusCodes.Bad`.
|
||
So the *only* missing plumbing is child → bridge.
|
||
- **S13 anchor drift (minor):** the report cites the dormant engine's failure mapping at
|
||
`VirtualTagEngine.cs:307,328`. At `f6eaa267`, line **307** is the *cold-start* guard
|
||
(`BadWaitingForInitialData` when inputs aren't ready yet — `AreInputsReady`); the actual
|
||
failure→`BadInternalError` (`0x80020000u`) mappings are at **327-329** (uncoercible result), **334**
|
||
(`ScriptTimeoutException`), and **343** (script threw). The substance of the finding is unaffected —
|
||
and line 305-311 turns out to be load-bearing for this plan's design (the inputs-ready gate, below).
|
||
- **S12** — Confirmed. `VirtualTagHostActor.OnApply` calls `ClearCompiledScripts()` first thing
|
||
(`VirtualTagHostActor.cs:92`), on the host's dispatcher thread, with nothing gating concurrent child
|
||
evaluations. The race window is inside one `RoslynVirtualTagEvaluator.Evaluate` call: the evaluator is
|
||
fetched at `RoslynVirtualTagEvaluator.cs:69` (`_cache.GetOrCompile`), the host's `Clear()`
|
||
value-scoped-removes **and disposes** it (`CompiledScriptCache.cs:113-116` →
|
||
`ScriptEvaluator.Dispose` → `_alc.Unload()`), and the child's subsequent run
|
||
(`RoslynVirtualTagEvaluator.cs:108-109`, via `TimedScriptEvaluator.RunAsync`'s `Task.Run`) trips the
|
||
disposed guard (`ScriptEvaluator.cs:180`) → `ObjectDisposedException` surfaces through
|
||
`GetAwaiter().GetResult()` into the generic `catch (Exception)` (`RoslynVirtualTagEvaluator.cs:116-120`)
|
||
→ `Failure("script threw: Cannot access a disposed object…")` → dropped update (and, per S13, no
|
||
quality degradation either). The contrast with `ScriptedAlarmEngine` holds: its per-generation
|
||
`_compileCache.Clear()` (`ScriptedAlarmEngine.cs:209`) runs **under `_evalGate` with upstream
|
||
subscriptions already torn down**, so no alarm evaluation can hold a disposed evaluator. The secondary
|
||
wrinkle also confirmed: the clear runs *before* the removed/changed children are stopped
|
||
(`VirtualTagHostActor.cs:98-118`) and `Context.Stop` is asynchronous, so a doomed child can recompile
|
||
its stale expression into the fresh cache for one generation (bounded — not a leak).
|
||
Note a mid-*execution* clear is safe: `AssemblyLoadContext.Unload()` is cooperative (the executing
|
||
delegate keeps the ALC alive); only the guard at `ScriptEvaluator.cs:180` — i.e. dispose landing
|
||
between fetch and run-start — produces the dropped update. The `Task.Run` hop inside
|
||
`TimedScriptEvaluator` widens exactly that window.
|
||
- **P7** — Confirmed. The clear at `VirtualTagHostActor.cs:92` is unconditional per `ApplyVirtualTags`,
|
||
including a byte-identical redeploy whose plans all diff equal (the no-churn path proven by
|
||
`ApplyVirtualTags_does_not_respawn_child_when_plan_unchanged`). The host already keeps last
|
||
generation's plans in `_planByVtag` (`:50`, rebuilt at `:160-164`) — the comparison input for a
|
||
conditional clear is already sitting in actor state. `EquipmentVirtualTagPlan` has element-wise value
|
||
equality including `Expression` (`OpcUaServer/AddressSpaceComposer.cs:179-188`). The existing wiring
|
||
guard `VirtualTagHostActorTests.ApplyVirtualTags_clears_the_evaluator_compile_cache_each_generation`
|
||
asserts the clear fires on an **identical** second apply — that test encodes the P7 behavior and must
|
||
be rewritten, not just kept green (called out in the task breakdown).
|
||
- **Existing-test impact identified:** `VirtualTagActorTests.Evaluator_failure_publishes_ScriptLogEntry_warning`
|
||
ends with `parent.ExpectNoMsg(...)` — under the S13 fix a failure now *does* send the parent a
|
||
Bad-quality `EvaluationResult`, so that assertion inverts (task R2-03-T3 updates it deliberately).
|
||
- **Design constraint verified:** the value fan-out's quality vocabulary is the coarse 3-value
|
||
`OpcUaQuality { Good, Uncertain, Bad }` (`Commons/OpcUa/IOpcUaAddressSpaceSink.cs:99`) — there is no
|
||
sub-code channel to the node, and `AttributeValueUpdate` (`Runtime/OpcUa/OpcUaPublishActor.cs:42`)
|
||
already carries it. `NullVirtualTagEvaluator` returns `NoChange` (Success=true), so the dev/Mac path
|
||
can never emit Bad. The passthrough fast-path returns `Ok(null)` for a missing dep (never Failure), so
|
||
it is unaffected by all three fixes.
|
||
|
||
---
|
||
|
||
## Priority ordering
|
||
|
||
Per the source report's Priority Recommendations (#1 = S13, #2 = S12) and OVERALL action item #3:
|
||
|
||
1. **S13** (Medium, report's top item) — failure/timeout → node quality. The stale-Good repro test lands first and must fail on current code.
|
||
2. **S12** (Medium) — close the clear-vs-in-flight-evaluate race (retry-once in the adapter).
|
||
3. **P7** (Low) — gate the apply-boundary clear on an actually-changed script set.
|
||
|
||
All three are one PR (`fix/archreview-r2-03-vt-failure-quality`): S13 and S12 touch disjoint files;
|
||
P7 touches the same `OnApply` region S13's bridge work sits next to.
|
||
|
||
---
|
||
|
||
## 1. S13 — MEDIUM — Script failure/timeout never degrades the OPC UA node's quality: stale value stays Good forever
|
||
|
||
**Restatement:** When a VT script evaluation fails (compile error, runtime throw, sandbox violation, or
|
||
the U2-era timeout), `VirtualTagActor` logs + meters and returns without publishing anything, and the
|
||
host bridge only ever publishes `OpcUaQuality.Good`. A previously-working script that starts failing
|
||
leaves its node serving the last Good value with its old timestamp indefinitely — OPC UA clients, the
|
||
continuous-historization recorder, and ScadaBridge cannot distinguish "healthy but unchanged" from
|
||
"script broken for a week". The dormant `VirtualTagEngine` maps this correctly to `BadInternalError`;
|
||
the live actor path never implemented it.
|
||
|
||
**Verification:** Confirmed (see summary). Bad quality is fully plumbed from `AttributeValueUpdate`
|
||
through the sink to the SDK node — the gap is exactly `VirtualTagActor` (no quality concept) and the
|
||
`VirtualTagHostActor` bridge (hard-coded Good at `:179-180`, hard-coded `0u` historize status at
|
||
`:187-188`).
|
||
|
||
**Root cause:** `EvaluationResult` was designed as a success-only message ("emit to the parent whenever
|
||
the value actually changes"); failures were treated purely as an observability event (script-log +
|
||
metric). The quality dimension was left to the materialiser's initial `BadWaitingForInitialData` and
|
||
never revisited when the U2 remediation made timeouts a first-class, expected failure mode.
|
||
|
||
**Proposed design:**
|
||
|
||
*(a) StatusCode policy — coarse `OpcUaQuality.Bad` for both failure and timeout.* The node will read
|
||
`StatusCodes.Bad` (0x80000000) via the existing `StatusFromQuality` mapping. This "matches the dormant
|
||
engine's BadInternalError" at the level that matters — the severity bit that flips client/consumer
|
||
handling — and the failure *kind* (timeout vs throw vs compile) is already delivered verbatim to
|
||
operators as the script-log `Reason` string on the `script-logs` topic. Alternatives rejected:
|
||
- *Widen `OpcUaQuality` with `BadInternalError`/`BadTimeout` members* — additive on the enum, but it
|
||
ripples a Commons contract consumed by every sink implementation and driver publish path, for a
|
||
sub-code no consumer acts on. Contained follow-up if a real client requirement appears.
|
||
- *Carry a raw `uint` status through `AttributeValueUpdate`/`IOpcUaAddressSpaceSink.WriteValue`* — the
|
||
precise-parity option, but it changes an interface with four implementations and re-runs the
|
||
F10b/DeferredAddressSpaceSink forwarding trap for zero operator value. Rejected.
|
||
- Exception: the **historize snapshot** takes raw `uint` status already (`DataValueSnapshot`), so there
|
||
the bridge records exact dormant-engine parity: `0x80020000u /* BadInternalError */` on Bad results
|
||
(the dormant engine historizes its Bad snapshots unconditionally at `VirtualTagEngine.cs:346-348`;
|
||
we mirror that).
|
||
|
||
*(b) Publish cadence — once per transition, not per failure.* Every publish is a sink write +
|
||
`ClearChangeMasks` → client notification on the pinned OPC UA dispatcher. A failing script on a hot
|
||
dependency would otherwise emit one identical Bad write per dependency change (each also paying the P2
|
||
pool hop + 2 s worst-case actor block per S7). The actor tracks `_lastPublishedBad`; it publishes Bad on
|
||
the Good→Bad (or initial→Bad) transition only, and on recovery it **force-publishes the next success even
|
||
if the value equals the pre-failure value** — otherwise the value-dedup at `VirtualTagActor.cs:138`
|
||
would leave the node Bad forever after a same-value recovery. (Script-log Warning entries stay
|
||
per-failure, unchanged — that's the existing operator diagnostic and its volume is pre-existing.)
|
||
|
||
*(c) Cold-start suppression — an inputs-ready gate, mirroring the dormant engine.* The live path has no
|
||
`AreInputsReady` equivalent: a multi-dep script fails on the first dependency arrival (the missing dep's
|
||
`GetTag(...).Value` is null → cast throws) until all deps have landed. Publishing Bad on those would
|
||
make every fresh/respawned multi-dep VT flash `BadWaitingForInitialData → Bad → Good` on every deploy —
|
||
operator-visible churn for healthy scripts. Worse, gating on "had a prior success in this child" is
|
||
**insufficient for S13's headline scenario**: an *edited-now-broken* script respawns its child (plan
|
||
change ⇒ stop+respawn at `VirtualTagHostActor.cs:109-118`), so the fresh child has no prior success and
|
||
would never degrade — reintroducing stale-Good exactly where it matters most. The correct gate is the
|
||
dormant engine's: **suppress Bad publishes until every `dependencyRefs` entry has been received at least
|
||
once** (`_dependencyRefs.All(_dependencies.ContainsKey)`); after that, any failure degrades. A
|
||
never-arriving dependency keeps the node at the materialiser's `BadWaitingForInitialData` (already Bad —
|
||
acceptable, and per the report "acceptable" for never-succeeded scripts).
|
||
|
||
*(d) Message/bridge shape — additive `Quality` on `EvaluationResult`.* Add
|
||
`OpcUaQuality Quality = OpcUaQuality.Good` as a trailing optional parameter on the
|
||
`EvaluationResult` positional record: every existing construction and destructuring site compiles
|
||
unchanged, and the message is a local `Context.Parent.Tell` (no wire serialization concern). The bridge
|
||
replaces the hard-coded `OpcUaQuality.Good` with `result.Quality`, and maps the historize status
|
||
(`Good→0u`, else `0x80020000u`). **The dependency mux is untouched** — quality flows child→parent→publish
|
||
actor; the mux only fans values *into* children (`DependencyValueChanged` is unchanged), so no mux
|
||
registration, routing, or message contract moves.
|
||
|
||
On failure the Bad `EvaluationResult` carries the **last-known value** (`_lastValue` if `_hasLastValue`,
|
||
else `null`) with the triggering `msg.TimestampUtc` — clients see the stale value explicitly flagged Bad
|
||
(the report's "publish the existing value with Bad quality"), rather than the dormant engine's
|
||
null-value choice; either is defensible, this one preserves more information for trend displays.
|
||
|
||
**Implementation steps:**
|
||
|
||
1. `Runtime/VirtualTags/VirtualTagActor.cs`:
|
||
- `using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;`
|
||
- `public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation, OpcUaQuality Quality = OpcUaQuality.Good);`
|
||
- Add fields `private bool _lastPublishedBad;` and a helper
|
||
`private bool AllDependenciesArrived() => _dependencyRefs.All(_dependencies.ContainsKey);`
|
||
(refs list is small; called once per failure — no caching needed).
|
||
- Extract the failure tail shared by the evaluator-threw catch (`:115-121`) and the `!Success` branch
|
||
(`:123-128`) into `private void OnEvaluationFailed(string reason, string level, DateTime timestampUtc)`:
|
||
keep the existing metric + `PublishLog`, then
|
||
`if (_lastPublishedBad || !AllDependenciesArrived()) return;` else
|
||
`_lastPublishedBad = true; Context.Parent.Tell(new EvaluationResult(_virtualTagId, _hasLastValue ? _lastValue : null, timestampUtc, CorrelationId.NewId(), OpcUaQuality.Bad));`
|
||
- Success path: change the dedup guard at `:138` to
|
||
`if (!_lastPublishedBad && _hasLastValue && Equals(_lastValue, result.Value))` and set
|
||
`_lastPublishedBad = false;` alongside `_hasLastValue = true;` before the Good publish. The
|
||
`NoChange` short-circuit (`:132-136`) stays *above* the dedup and does **not** touch
|
||
`_lastPublishedBad` (NoChange = "no fresh value", not a recovery).
|
||
- Update the class summary: failures now degrade quality once per transition.
|
||
2. `Runtime/VirtualTags/VirtualTagHostActor.cs` (`OnResult`, `:170-190`):
|
||
- `_publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(nodeId, result.Value, result.Quality, result.TimestampUtc));`
|
||
- Historize: `var status = result.Quality == OpcUaQuality.Good ? 0u : 0x80020000u /* BadInternalError — dormant-engine parity */;`
|
||
and pass `status` into the `DataValueSnapshot`.
|
||
- Update the `:179-180` comment (no longer hard-coded Good).
|
||
3. Update `VirtualTagActorTests.Evaluator_failure_publishes_ScriptLogEntry_warning`: the trailing
|
||
`parent.ExpectNoMsg` becomes `parent.ExpectMsg<EvaluationResult>()` with `Quality == Bad` (the test
|
||
constructs the actor with empty `dependencyRefs`, so the inputs-ready gate is vacuously satisfied —
|
||
`All` over an empty list is true).
|
||
|
||
**Tests:** (all deterministic; probes only)
|
||
- **Repro first (must FAIL at `f6eaa267`):** `Runtime.Tests` `VirtualTagHostActorTests.`
|
||
`Failing_script_after_success_degrades_node_quality_to_Bad` — host + real child + a scripted
|
||
fail-after-success `IVirtualTagEvaluator` stub; drive the child (captured via the mux probe's
|
||
`RegisterInterest` sender) with two `DependencyValueChanged`s; assert the publish probe receives
|
||
update 1 `(Good, 42)` then update 2 with `Quality == OpcUaQuality.Bad` carrying the last value. Uses
|
||
only current types (`AttributeValueUpdate` already has `Quality`), so it compiles today and times out
|
||
on the second `ExpectMsg` — the stale-Good repro.
|
||
- `VirtualTagActorTests`: failure-after-success emits Bad `EvaluationResult` with last value;
|
||
second consecutive failure emits **nothing** (once-per-transition); recovery to the *same* value as
|
||
before the failure **does** publish (dedup bypass); evaluator-*threw* path (not just `Failure`) also
|
||
degrades; multi-dep cold-start failure with a missing dep publishes **no** Bad (inputs-ready gate);
|
||
after all deps arrive, failure publishes Bad.
|
||
- `VirtualTagHostActorTests`: a Bad-quality `EvaluationResult` bridges to an `AttributeValueUpdate`
|
||
with `Quality == Bad`; a historized plan's Bad result records status `0x80020000u` (BadInternalError).
|
||
- Wiring note (theme #1, "unit-green ≠ wired"): the repro test above IS the wiring assertion — it runs
|
||
the real host + real child + parent-Tell bridge, not a hand-delivered message. The remaining live wire
|
||
(publish actor → sink → SDK node) is pre-existing, already exercised by `OpcUaPublishActor` tests, and
|
||
covered by the live-`/run` gate below.
|
||
|
||
**Effort:** S. **Risk:** Low-Medium. The behavioral deltas are (1) parents now receive Bad results —
|
||
only `VirtualTagHostActor` parents children in production, and it handles the new field natively; (2) a
|
||
node that used to freeze Good now goes Bad on persistent failure — the intended, strictly-safer change;
|
||
(3) one existing test's expectation inverts (deliberate, task-tracked). Blast radius = VT value path
|
||
only; alarms/drivers untouched.
|
||
|
||
---
|
||
|
||
## 2. S12 — MEDIUM — Apply-boundary `ClearCompiledScripts` races in-flight child evaluations
|
||
|
||
**Restatement:** The host's per-apply `Clear()` disposes cached evaluators while unchanged children keep
|
||
evaluating on other dispatcher threads. A child that fetched its evaluator (`GetOrCompile`,
|
||
`RoslynVirtualTagEvaluator.cs:69`) before the clear hits the disposed guard on run
|
||
(`ScriptEvaluator.cs:180`) → `ObjectDisposedException` → generic catch → `Failure` → silently dropped
|
||
update (self-heals only on the tag's *next* dependency change — a long staleness window for slow tags).
|
||
Secondary: a stopping child can recompile its stale script into the fresh cache for one generation.
|
||
|
||
**Verification:** Confirmed (see summary). The cache itself is blameless — its value-scoped `TryRemove`
|
||
protects concurrent `GetOrCompile` re-adds; the gap is clear-vs-*use* of an already-handed-out
|
||
evaluator. The `ScriptedAlarmEngine` mirror is inexact exactly as the report says (gate-held clear after
|
||
subscription teardown, `ScriptedAlarmEngine.cs:199-209`).
|
||
|
||
**Root cause:** `IScriptCacheOwner.ClearCompiledScripts` was wired at the apply boundary (U3) without a
|
||
coordination primitive between the host actor's clear and child actors' fetch→run sequence — the
|
||
`Task.Run` hop inside `TimedScriptEvaluator` guarantees a real window between fetch and the disposed
|
||
check.
|
||
|
||
**Proposed design — catch-`ObjectDisposedException`, re-fetch, retry once (in the adapter).** The
|
||
recompile is idempotent (same source → fresh evaluator in the *current* cache generation), the retry is
|
||
entirely local to `RoslynVirtualTagEvaluator.Evaluate`, and it converts the dropped update into a
|
||
correct evaluation against the post-clear generation. If a *second* clear lands mid-retry (two applies
|
||
inside one evaluation — not a realistic cadence), the second `ObjectDisposedException` falls through to
|
||
the existing `Failure` path — same as today's worst case, now with an S13 Bad-quality publish instead of
|
||
silence. Alternatives rejected:
|
||
- *`ReaderWriterLockSlim` (children read-lock fetch→run; clear write-locks)* — blocks the **host actor**
|
||
for up to a full in-flight timeout budget (2 s) during every apply, and adds lock traffic to the hot
|
||
path, to prevent a race the retry heals for free. Rejected.
|
||
- *Per-generation cache instances with deferred dispose of the old generation* — disposing the old
|
||
generation safely still requires knowing when in-flight evaluations finish, i.e. ref-counting — the
|
||
same coordination problem with more machinery. Rejected.
|
||
- *Move the clear after the stop/respawn reconciliation* — doesn't close the race for **unchanged**
|
||
children (the common case), and the stale-recompile wrinkle survives either way because `Context.Stop`
|
||
is asynchronous. Rejected as a fix; the wrinkle stays **accepted + documented** (bounded to one
|
||
generation, released at the next apply-with-changes; P7's gate also means no-op redeploys no longer
|
||
create the window at all).
|
||
|
||
**Implementation steps** (`Host/Engines/RoslynVirtualTagEvaluator.cs`, the compiled path `:66-121`):
|
||
|
||
1. Restructure fetch+run into a bounded loop (`for (var attempt = 0; ; attempt++)`):
|
||
- fetch: `evaluator = _cache.GetOrCompile(expression);` — keep the existing
|
||
`CompilationErrorException` / `ScriptSandboxViolationException` / generic catches on the fetch
|
||
(unchanged behavior). A disposed **cache** here means the whole evaluator is being shut down —
|
||
`GetOrCompile` throws `ObjectDisposedException`, which the new handler must NOT retry: check
|
||
`_disposed` and return `VirtualTagEvalResult.Failure("evaluator disposed")`.
|
||
- run: existing `TimedScriptEvaluator` + `GetAwaiter().GetResult()` block, adding **before** the
|
||
generic catch:
|
||
```csharp
|
||
catch (ObjectDisposedException) when (!_disposed && attempt == 0)
|
||
{
|
||
// S12: the apply-boundary ClearCompiledScripts disposed the evaluator between our
|
||
// GetOrCompile and the run's disposed-guard. Re-fetch — GetOrCompile recompiles the same
|
||
// source into the CURRENT cache generation — and retry once. A second disposal mid-retry
|
||
// (two applies inside one evaluation) falls through to the generic failure path.
|
||
continue;
|
||
}
|
||
```
|
||
- `catch (ObjectDisposedException)` (second hit, or `_disposed`) → `Failure("evaluator disposed during evaluation")`.
|
||
2. Update the class doc: the apply-boundary clear is retry-safe for in-flight evaluations; the
|
||
one-generation stale-recompile wrinkle for stopping children is the documented residual.
|
||
|
||
**Tests** (`Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs`):
|
||
- `Evaluate_racing_ClearCompiledScripts_never_fails_with_disposed` — one evaluator; task A loops N
|
||
(≈300) compiled (non-passthrough) evaluations of the same expression; task B loops
|
||
`ClearCompiledScripts()` with `Thread.Yield()` between calls until A finishes; whole test
|
||
`WaitAsync`-bounded (30 s). Assert **every** result `Success == true` (and specifically no `Reason`
|
||
containing "disposed"). *Determinism note:* the pre-fix failure is probabilistic per iteration (the
|
||
window is fetch → `Task.Run`-hopped disposed-guard, widened by pool queuing), but at N=300 racing
|
||
clears it reproduces reliably in practice; post-fix it is deterministic-green by construction (the
|
||
retry is exhaustive for a single concurrent clear). This is the report's prescribed
|
||
"concurrent clear-during-evaluate test".
|
||
- `Evaluate_after_ClearCompiledScripts_recompiles_and_succeeds` — deterministic sequential guard:
|
||
evaluate, clear, evaluate same source again → Success both times, cache Count back to 1 (uses the
|
||
existing `CompiledCacheCount` reflection helper).
|
||
- Existing `Evaluate_after_dispose_returns_Failure` still green (the `_disposed` short-circuit at `:51`
|
||
is untouched; the new ODE handler's `!_disposed` filter keeps shutdown semantics).
|
||
|
||
**Effort:** S (the report's "10-line fix" is accurate; the loop restructure is the only care point).
|
||
**Risk:** Low. Happy path unchanged (attempt 0, no exception); passthrough fast-path never enters the
|
||
loop. Blast radius = one method in the Host adapter.
|
||
|
||
---
|
||
|
||
## 3. P7 — LOW — Unconditional per-apply cache flush forces full VT recompilation on every deploy
|
||
|
||
**Restatement:** `OnApply` clears the compile cache on **every** `ApplyVirtualTags`
|
||
(`VirtualTagHostActor.cs:92`) — including a byte-identical redeploy that stops/spawns nothing. Every VT
|
||
script then recompiles on its next evaluation, each paying the still-open P1 full-BCL-reference-set
|
||
rebuild (5-20 ms+ per script). Amplifies P1; also each unnecessary clear is an unnecessary S12 race
|
||
window.
|
||
|
||
**Verification:** Confirmed (see summary). The host already holds last generation's plans in
|
||
`_planByVtag`; `EquipmentVirtualTagPlan.Expression` participates in value equality. The existing wiring
|
||
guard asserts the *current* (unconditional) behavior on an identical redeploy and must be rewritten.
|
||
|
||
**Root cause:** The U3 fix chose the simplest correct lifecycle ("clear per generation", mirroring the
|
||
alarm engine) without consulting the plan diff the same method computes a few lines later.
|
||
|
||
**Proposed design — clear only when the deployed *expression set* changed.** At the top of `OnApply`,
|
||
compare the desired expression set against the previous generation's
|
||
(`_planByVtag.Values.Select(p => p.Expression)`), and clear only on inequality:
|
||
|
||
```csharp
|
||
var newExpressions = msg.Plans.Select(p => p.Expression).ToHashSet(StringComparer.Ordinal);
|
||
var prevExpressions = _planByVtag.Values.Select(p => p.Expression).ToHashSet(StringComparer.Ordinal);
|
||
if (!newExpressions.SetEquals(prevExpressions))
|
||
{
|
||
(_evaluator as IScriptCacheOwner)?.ClearCompiledScripts();
|
||
}
|
||
```
|
||
|
||
Decisions inside this shape:
|
||
- **Expression set, not full-plan set:** the cache is keyed by source hash only, so a rename /
|
||
FolderPath move / Historize toggle (which respawns the child) does not need a recompile — comparing
|
||
full plans would clear on those spuriously. Two vtags sharing one expression are handled naturally
|
||
(set semantics: the expression survives while any plan still uses it).
|
||
- **Any-change clears everything** (not per-source eviction): editing 1 of 200 scripts still recompiles
|
||
200. Accepted per the report ("cheap to leave as-is at current scale; fix alongside P1") — the
|
||
headline P7 cost is the **no-op redeploy**, which this eliminates entirely. Rejected alternative: a
|
||
`CompiledScriptCache.RetainOnly(IEnumerable<string> sources)` API — precise, but new Core.Scripting
|
||
surface + tests for a Low; note it as the follow-up shape if P1's memoization doesn't land first.
|
||
- **First apply after actor (re)start:** `_planByVtag` is empty → sets differ → clear fires (harmless:
|
||
the singleton evaluator's cache may hold a prior incarnation's entries; clearing converges state).
|
||
- **Interaction with S12 (design question d):** independent and composing — the gate reduces clear
|
||
*frequency* (no-op redeploys stop racing entirely), the retry remains the correctness backstop for
|
||
genuine changes. The gate must NOT be treated as the S12 fix: a real script edit still clears while
|
||
unchanged children evaluate.
|
||
- **Interaction with S13:** none (quality path doesn't touch the cache).
|
||
|
||
**Implementation steps:**
|
||
1. Replace `VirtualTagHostActor.cs:88-92` (comment + unconditional call) with the guarded block above +
|
||
a comment explaining the expression-set gate (and that `_planByVtag` is "the previous generation" at
|
||
this point — the method rebuilds it at the end).
|
||
2. Rewrite the wiring guard `ApplyVirtualTags_clears_the_evaluator_compile_cache_each_generation` →
|
||
`ApplyVirtualTags_clears_the_compile_cache_only_when_the_expression_set_changes`: apply plan A
|
||
(ClearCount 1) → re-apply identical plan A (ClearCount **still 1**) → apply plan A′ with an edited
|
||
`Expression` (ClearCount 2) → re-apply with a vtag removed but its expression still used by another
|
||
vtag (ClearCount still 2). Keeps the load-bearing "the host actually calls it" wiring assertion while
|
||
pinning the new gate.
|
||
|
||
**Tests:** the rewritten wiring guard above (in `Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs`,
|
||
using the existing `CacheOwningStubEvaluator` spy). New-behavior red state: the *identical-redeploy*
|
||
step fails on current code (ClearCount is 2, expected 1).
|
||
|
||
**Effort:** S. **Risk:** Low. The only hazard is under-clearing (a stale ALC surviving a deploy whose
|
||
expression set is unchanged) — by construction those entries are exactly the still-deployed sources, so
|
||
nothing stale survives. Blast radius = one method + one test.
|
||
|
||
---
|
||
|
||
## Live-`/run` verification (after all tasks green)
|
||
|
||
Deterministic units + wiring guards above are necessary but not sufficient — **unit-green ≠ wired**
|
||
(the F10b lesson). Close with the docker-dev rig (login disabled; do it yourself, no user sign-in):
|
||
|
||
1. Rebuild **both** `central-1` and `central-2` images (`:9200` round-robins them) and bring
|
||
`docker-dev/docker-compose.yml` up.
|
||
2. On `/uns` (AdminUI `http://localhost:9200`), author a VirtualTag on a rig equipment with a script
|
||
that succeeds against live deps (e.g. `return (double)ctx.GetTag("<dep>").Value * 2;`), deploy
|
||
(`POST :9200/api/deployments`, `X-Api-Key`), and confirm via Client.CLI:
|
||
`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "<folder-scoped NodeId>"`
|
||
→ value present, StatusCode Good.
|
||
3. Edit the script to a guaranteed runtime throw (e.g. `int z = 0; return 1 / z;`), redeploy, wait one
|
||
dependency change → Client.CLI `read` again → **StatusCode Bad** (0x80000000), value = last-known.
|
||
This exercises the exact S13 headline path (edited-broken script ⇒ respawned child ⇒ inputs-ready
|
||
gate ⇒ Bad publish) end to end through the SDK node.
|
||
4. Fix the script back, redeploy → `read` returns Good with a fresh value (recovery + dedup-bypass live).
|
||
5. P7 spot-check: redeploy the identical config; central logs show no recompile burst (and the S12
|
||
stress guard covers the race in CI).
|
||
|
||
---
|
||
|
||
## Task breakdown
|
||
|
||
Branch: `fix/archreview-r2-03-vt-failure-quality` off `master@f6eaa267`. Tasks are ≤5 min each;
|
||
TDD-ordered (repro/red test precedes each implementation). Test commands run from the repo root.
|
||
|
||
---
|
||
|
||
### R2-03-T1 — S13 stale-Good repro test (RED — must fail on current code)
|
||
|
||
**Classification:** test-first repro (deterministic unit, host-level wiring shape)
|
||
**Estimated implement time:** 5 min
|
||
**Parallelizable with:** T7 (different test file); everything else depends on this landing red first.
|
||
**Files:** `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs`
|
||
|
||
1. Add a `FailAfterFirstSuccessEvaluator : IVirtualTagEvaluator` private stub (call 1 → `Ok(42.0)`,
|
||
thereafter → `Failure("boom")`; interlocked counter).
|
||
2. Add test `Failing_script_after_success_degrades_node_quality_to_Bad`: host with publish + mux probes
|
||
and the stub; apply one plan (deps `["a"]`); capture the child via
|
||
`mux.ExpectMsg<DependencyMuxActor.RegisterInterest>()` + `mux.LastSender`; tell the child
|
||
`DependencyValueChanged("a", 1, ts1)` → publish probe expects `AttributeValueUpdate` with
|
||
`Quality == OpcUaQuality.Good`, `Value == 42.0`; tell `DependencyValueChanged("a", 2, ts2)` →
|
||
expect a second `AttributeValueUpdate` with `Quality == OpcUaQuality.Bad` and `Value == 42.0`
|
||
(last-known) and `TimestampUtc == ts2`.
|
||
3. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~VirtualTagHostActorTests.Failing_script_after_success_degrades_node_quality_to_Bad"`
|
||
→ **expected: FAIL** (timeout on the second ExpectMsg — the stale-Good bug, verbatim).
|
||
|
||
Commit: `test(runtime): red repro — VT script failure after success leaves node stale-Good (02/S13)`
|
||
|
||
---
|
||
|
||
### R2-03-T2 — EvaluationResult.Quality + bridge pass-through + historize parity
|
||
|
||
**Classification:** implementation (additive message field + bridge)
|
||
**Estimated implement time:** 5 min
|
||
**Parallelizable with:** T7/T8 (disjoint files). Blocked by T1 (repro must be red first).
|
||
**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs` (record only),
|
||
`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs`,
|
||
`tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs`
|
||
|
||
1. RED: add bridge tests `Bad_quality_EvaluationResult_is_bridged_as_Bad` (tell the host a synthetic
|
||
`EvaluationResult(..., Quality: OpcUaQuality.Bad)` → publish probe's `AttributeValueUpdate.Quality`
|
||
is Bad) and `Historized_Bad_result_is_recorded_with_BadInternalError_status` (PlanH historize:true;
|
||
Bad result → `CapturingHistoryWriter` snapshot `StatusCode == 0x80020000u`). These require the record
|
||
change to compile — so within this task: add
|
||
`OpcUaQuality Quality = OpcUaQuality.Good` to `EvaluationResult` (+ `using ...Commons.OpcUa;`) FIRST,
|
||
then run the two tests:
|
||
`dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~VirtualTagHostActorTests.Bad_quality_EvaluationResult_is_bridged_as_Bad|FullyQualifiedName~VirtualTagHostActorTests.Historized_Bad_result_is_recorded_with_BadInternalError_status"`
|
||
→ **expected: FAIL** (bridge still hard-codes Good / `0u`).
|
||
2. GREEN: `OnResult` — pass `result.Quality` into `AttributeValueUpdate`; historize status
|
||
`result.Quality == OpcUaQuality.Good ? 0u : 0x80020000u /* BadInternalError — dormant-engine parity */`;
|
||
fix the stale comments at `:179-188`. Re-run the filter → **PASS**. Also run the full host-actor
|
||
class (`--filter "FullyQualifiedName~VirtualTagHostActorTests"`) — all pre-existing tests
|
||
(Good-path bridging, historize Good `0u`) must stay green, proving the default keeps byte-parity.
|
||
|
||
Commit: `feat(runtime): EvaluationResult carries OpcUaQuality; VT bridge publishes it + historizes BadInternalError on Bad (02/S13)`
|
||
|
||
---
|
||
|
||
### R2-03-T3 — VirtualTagActor failure→Bad transition (turns T1 green)
|
||
|
||
**Classification:** implementation (actor behavior)
|
||
**Estimated implement time:** 5 min
|
||
**Parallelizable with:** T7/T8. Blocked by T2.
|
||
**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs`,
|
||
`tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs`
|
||
|
||
1. Implement per design §1(b)/(c)/(d): `_lastPublishedBad` field, `AllDependenciesArrived()`,
|
||
`OnEvaluationFailed(...)` shared by the throw-catch and `!Success` branch (metric + script-log
|
||
preserved per-failure; Bad publish once per transition, gated on inputs-ready, carrying
|
||
`_hasLastValue ? _lastValue : null` + `msg.TimestampUtc`); success path resets `_lastPublishedBad`
|
||
and bypasses the value-dedup when recovering; class-summary xmldoc updated.
|
||
2. Update `Evaluator_failure_publishes_ScriptLogEntry_warning`: replace the trailing
|
||
`parent.ExpectNoMsg(...)` with
|
||
`parent.ExpectMsg<VirtualTagActor.EvaluationResult>().Quality.ShouldBe(OpcUaQuality.Bad)` —
|
||
the deliberate behavioral inversion (empty `dependencyRefs` ⇒ inputs-ready gate vacuously true).
|
||
3. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~VirtualTagHostActorTests.Failing_script_after_success_degrades_node_quality_to_Bad"`
|
||
→ **expected: PASS** (T1 repro green).
|
||
`dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~VirtualTagActorTests"`
|
||
→ **expected: PASS** (incl. the updated failure test).
|
||
|
||
Commit: `fix(runtime): VT script failure/timeout degrades node quality to Bad once per transition (02/S13)`
|
||
|
||
---
|
||
|
||
### R2-03-T4 — S13 transition-semantics unit guards
|
||
|
||
**Classification:** tests (deterministic units pinning the state machine)
|
||
**Estimated implement time:** 5 min
|
||
**Parallelizable with:** T5, T7/T8. Blocked by T3.
|
||
**Files:** `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs`
|
||
|
||
Add (scriptable stub evaluator with a queue of results):
|
||
1. `Second_consecutive_failure_does_not_publish_a_second_Bad` — fail, fail → exactly one Bad
|
||
`EvaluationResult` at the parent (`ExpectMsg` then `ExpectNoMsg`).
|
||
2. `Recovery_to_the_same_value_republishes_Good_after_Bad` — Ok(42), fail, Ok(42) → three parent
|
||
messages; the third is `Quality == Good`, `Value == 42` (dedup bypass — this is the guard against the
|
||
node staying Bad forever after a same-value recovery).
|
||
3. `Evaluator_throw_also_degrades_quality` — evaluator that throws (not `Failure`) → Bad
|
||
`EvaluationResult` published (the `:115-121` path).
|
||
4. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~VirtualTagActorTests"`
|
||
→ **expected: PASS** (all should pass immediately if T3 is correct; any red here is a T3 bug, fix in
|
||
place).
|
||
|
||
Commit: `test(runtime): pin VT Bad-quality transition semantics — once-per-transition, same-value recovery, throw path (02/S13)`
|
||
|
||
---
|
||
|
||
### R2-03-T5 — S13 cold-start suppression guard (inputs-ready gate)
|
||
|
||
**Classification:** tests (deterministic unit pinning the gate)
|
||
**Estimated implement time:** 5 min
|
||
**Parallelizable with:** T4, T7/T8. Blocked by T3.
|
||
**Files:** `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagActorTests.cs`
|
||
|
||
1. `Cold_start_failure_with_missing_dependency_publishes_no_Bad` — actor with
|
||
`dependencyRefs: ["a","b"]`, always-failing evaluator; send `DependencyValueChanged("a", ...)` only →
|
||
`parent.ExpectNoMsg` (node stays at the materialiser's `BadWaitingForInitialData`; no false Bad
|
||
flash during deploy/respawn warm-up).
|
||
2. `Failure_after_all_dependencies_arrived_publishes_Bad` — same actor; send `"a"` then `"b"` → the
|
||
second change (all refs now seen) publishes exactly one Bad `EvaluationResult`.
|
||
3. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~VirtualTagActorTests.Cold_start_failure_with_missing_dependency_publishes_no_Bad|FullyQualifiedName~VirtualTagActorTests.Failure_after_all_dependencies_arrived_publishes_Bad"`
|
||
→ **expected: PASS**.
|
||
|
||
Commit: `test(runtime): pin VT inputs-ready gate — no Bad flash before all dependencies arrive (02/S13)`
|
||
|
||
---
|
||
|
||
### R2-03-T6 — S12 sequential clear-then-recompile guard (RED-able)
|
||
|
||
**Classification:** test-first (deterministic unit)
|
||
**Estimated implement time:** 3 min
|
||
**Parallelizable with:** T1-T5 (different project/file).
|
||
**Files:** `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs`
|
||
|
||
1. Add `Evaluate_after_ClearCompiledScripts_recompiles_and_succeeds`: evaluate a compiled expression,
|
||
`ClearCompiledScripts()`, evaluate the same source again → both `Success`, `CompiledCacheCount == 1`
|
||
after the second call. (Green on current code — this pins the sequential contract the T8 retry loop
|
||
must not regress; the restructure moves the fetch inside a loop.)
|
||
2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~RoslynVirtualTagEvaluatorTests.Evaluate_after_ClearCompiledScripts_recompiles_and_succeeds"`
|
||
→ **expected: PASS** (pre-fix baseline pin).
|
||
|
||
Commit: `test(host): pin sequential clear-then-recompile contract of RoslynVirtualTagEvaluator (02/S12 baseline)`
|
||
|
||
---
|
||
|
||
### R2-03-T7 — S12 concurrent clear-vs-evaluate stress guard (RED — fails on current code)
|
||
|
||
**Classification:** test-first repro (concurrency stress; probabilistic-per-iteration, reliable at volume)
|
||
**Estimated implement time:** 5 min
|
||
**Parallelizable with:** T1-T6.
|
||
**Files:** `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs`
|
||
|
||
1. Add `async Task Evaluate_racing_ClearCompiledScripts_never_fails_with_disposed`: one evaluator
|
||
(generous 5 s timeout so the budget never fires); `Task.Run` loop A doing 300 sequential
|
||
`Evaluate("vt-race", "return (int)ctx.GetTag(\"a\").Value + 1;", …)` calls collecting results;
|
||
concurrent loop B calling `ClearCompiledScripts()` + `Thread.Yield()` until A completes; whole test
|
||
awaited with `WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken)` (regression
|
||
fails, never hangs — the U2 test's pattern). Assert every result `Success` shouldBeTrue with
|
||
`result.Reason` as the message (surfaces "script threw: Cannot access a disposed object" on red).
|
||
2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~RoslynVirtualTagEvaluatorTests.Evaluate_racing_ClearCompiledScripts_never_fails_with_disposed"`
|
||
→ **expected: FAIL** on current code (disposed-reason failures observed within 300 racing
|
||
iterations; the window is GetOrCompile-return → Task.Run-hopped disposed guard). If a run happens to
|
||
pass, bump iterations — do not weaken the assertion.
|
||
|
||
Commit: `test(host): red repro — apply-boundary cache clear races in-flight VT evaluation into ObjectDisposedException (02/S12)`
|
||
|
||
---
|
||
|
||
### R2-03-T8 — S12 retry-once on ObjectDisposedException (turns T7 green)
|
||
|
||
**Classification:** implementation (adapter hardening)
|
||
**Estimated implement time:** 5 min
|
||
**Parallelizable with:** T1-T5. Blocked by T6 + T7.
|
||
**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs`
|
||
|
||
1. Restructure the compiled path per design §2: `for (var attempt = 0; ; attempt++)` around
|
||
fetch (`GetOrCompile` — existing catch chain preserved) + run (`TimedScriptEvaluator` block), adding
|
||
`catch (ObjectDisposedException) when (!_disposed && attempt == 0) { continue; }` before the generic
|
||
catch, and `catch (ObjectDisposedException)` → `Failure("evaluator disposed during evaluation")` for
|
||
the shutdown / double-clear case. Update the class xmldoc (retry-safe vs apply-boundary clear;
|
||
one-generation stale-recompile residual documented).
|
||
2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~RoslynVirtualTagEvaluatorTests"`
|
||
→ **expected: PASS** (T7 stress green; T6 sequential pin green; all pre-existing U2/U3/passthrough
|
||
tests green — proves the loop restructure didn't disturb timeout, cache, or fast-path semantics).
|
||
|
||
Commit: `fix(host): retry VT evaluation once on ObjectDisposedException from the apply-boundary cache clear (02/S12)`
|
||
|
||
---
|
||
|
||
### R2-03-T9 — P7 conditional-clear wiring guard (RED — fails on current code)
|
||
|
||
**Classification:** test-first (rewrites an existing wiring guard — deliberate behavior change)
|
||
**Estimated implement time:** 5 min
|
||
**Parallelizable with:** T7/T8. Blocked by T3 (same test file churn as T1-T5; serialize after S13 lands).
|
||
**Files:** `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/VirtualTagHostActorTests.cs`
|
||
|
||
1. Rewrite `ApplyVirtualTags_clears_the_evaluator_compile_cache_each_generation` →
|
||
`ApplyVirtualTags_clears_the_compile_cache_only_when_the_expression_set_changes` (keep the
|
||
`CacheOwningStubEvaluator` spy; keep the "the host actually calls it" wiring-assertion role — update
|
||
the xmldoc to say the clear is now gated, citing 02/P7):
|
||
apply plan A (`ClearCount == 1`) → re-apply value-identical plan A (**`ClearCount == 1`**) → apply
|
||
plan A with an edited `Expression` (`ClearCount == 2`) → apply two vtags sharing one expression, then
|
||
remove one of them (expression set unchanged ⇒ count unchanged).
|
||
2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~VirtualTagHostActorTests.ApplyVirtualTags_clears_the_compile_cache_only_when_the_expression_set_changes"`
|
||
→ **expected: FAIL** on current code (identical-redeploy step observes `ClearCount == 2`).
|
||
|
||
Commit: `test(runtime): red — apply-boundary compile-cache clear must be gated on a changed expression set (02/P7)`
|
||
|
||
---
|
||
|
||
### R2-03-T10 — P7 expression-set gate (turns T9 green)
|
||
|
||
**Classification:** implementation (host-actor apply path)
|
||
**Estimated implement time:** 3 min
|
||
**Parallelizable with:** — (same method as T2's file; do after T9). Blocked by T9.
|
||
**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagHostActor.cs`
|
||
|
||
1. Replace `OnApply`'s unconditional clear (`:88-92`) with the guarded block from design §3
|
||
(`HashSet.SetEquals` over `msg.Plans` vs `_planByVtag.Values` expressions; ordinal comparer), with a
|
||
comment covering: `_planByVtag` is the previous generation at this point; rename/Historize-only
|
||
changes don't clear (cache is source-keyed); first-apply-after-restart clears by construction;
|
||
the S12 retry — not this gate — is the in-flight-race fix.
|
||
2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~VirtualTagHostActorTests"`
|
||
→ **expected: PASS** (T9 guard green + every other host-actor test green).
|
||
|
||
Commit: `perf(runtime): gate the VT apply-boundary compile-cache clear on a changed expression set (02/P7)`
|
||
|
||
---
|
||
|
||
### R2-03-T11 — Full-suite sweep + STATUS/report bookkeeping
|
||
|
||
**Classification:** verification + docs
|
||
**Estimated implement time:** 5 min
|
||
**Parallelizable with:** —. Blocked by T1-T10.
|
||
**Files:** `archreview/plans/STATUS.md`, `archreview/plans/R2-03-vt-failure-quality-plan.md.tasks.json`
|
||
|
||
1. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests` → **expected: PASS** (whole project).
|
||
2. `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --filter "FullyQualifiedName~RoslynVirtualTagEvaluatorTests"`
|
||
→ **expected: PASS**.
|
||
3. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → clean (analyzer OTOPCUA0001 + xmldoc warnings unchanged).
|
||
4. Record S13/S12/P7 as fixed-on-branch in `STATUS.md` (Re-review 2026-07-12 section) + tick the
|
||
tasks.json.
|
||
|
||
Commit: `docs(archreview): record R2-03 (02/S13+S12+P7) fixed on branch; full VT suites green`
|
||
|
||
---
|
||
|
||
### R2-03-T12 — Live-`/run` gate on docker-dev (fail→Bad→recover, end to end)
|
||
|
||
**Classification:** live verification (docker-dev rig; no user sign-in needed — login disabled)
|
||
**Estimated implement time:** 15-20 min (rig rebuild dominates)
|
||
**Parallelizable with:** —. Blocked by T11.
|
||
**Files:** none (verification only; findings, if any, loop back as new tasks)
|
||
|
||
Follow the "Live-`/run` verification" section above: rebuild **both** central images; author a working
|
||
VT → Client.CLI `read` Good; edit it to a runtime throw → redeploy → after one dependency change,
|
||
Client.CLI `read` shows **StatusCode Bad** with the last-known value (this is the edited-broken-script
|
||
respawn path — the S13 headline — through the real mux/child/bridge/publish-actor/SDK-node chain); fix
|
||
→ redeploy → `read` Good with a fresh value. Spot-check an identical redeploy produces no recompile
|
||
burst in central logs (P7). Do NOT merge on unit-green alone — this is the wiring bar.
|
||
|
||
Commit: none (verification). If the rig exposes a gap, open follow-up tasks before merge.
|
||
|
||
---
|
||
|
||
## Task dependency summary
|
||
|
||
```
|
||
T1 (S13 red repro) ──► T2 (record+bridge) ──► T3 (actor fix, T1 green) ──► T4, T5 (guards)
|
||
└────────► T9 (P7 red) ──► T10 (P7 green)
|
||
T6 (S12 pin) ─┐
|
||
T7 (S12 red) ─┴──► T8 (S12 retry, T7 green)
|
||
T4, T5, T8, T10 ──► T11 (sweep + docs) ──► T12 (live /run gate)
|
||
```
|
||
|
||
**Overall effort:** Small (≈2-3 h implementation + the rig session), matching the OVERALL table's
|
||
estimate for action item #3. **Overall risk:** Low-Medium, concentrated in S13's deliberate behavior
|
||
change (nodes that used to freeze Good now go Bad — the point of the fix) and the one inverted legacy
|
||
test expectation, both explicitly task-tracked.
|