# Code Review — Driver.Calculation | Field | Value | |---|---| | Module | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation` | | Reviewer | Claude (automated review agent) | | Review date | 2026-08-10 | | Commit reviewed | `b990adae` | | Status | Reviewed | | Open findings | 1 | ## Checklist coverage | # | Category | Result | |---|---|---| | 1 | Correctness & logic bugs | 3 findings (Driver.Calculation-001, -002, -003), all Resolved | | 2 | OtOpcUa conventions | No issues found. Galaxy/MXAccess-specific conventions do not apply to this pseudo-driver (no transport, no gateway). It correctly implements the capability-interface seams (IDriver, IDependencyConsumer, ISubscribable, IReadable, ISkippedTagReporter), .NET 10/AnyCPU, and authorization stays out of driver-level code. | | 3 | Concurrency & thread safety | 1 finding (Driver.Calculation-005), Open (cross-module) | | 4 | Error handling & resilience | Covered by Driver.Calculation-002 (script-evaluation faults never throw out of the driver; SkippedTagLedger tolerates a single bad tag without failing the whole rebuild). No further issues found. | | 5 | Security | No issues found. No secrets, no I/O, no SQL; the sandboxed Roslyn evaluator (shared Core.Scripting sandbox) is the security boundary and lives outside this module. | | 6 | Performance & resource management | Covered by Driver.Calculation-005 (blocking evaluation under a shared lock). CalculationTimerScheduler correctly bounds outstanding work to one tick per interval group (skip-if-in-flight) and CalculationEvaluator.ClearCompiledScripts() is invoked on every ReinitializeAsync, so compiled-script AssemblyLoadContexts do not accrete across script edits. No further issues found. | | 7 | Design-document adherence | No issues found. The class-level XML docs cite the "mini-design" sections directly and the implementation matches them (change-gate, once-per-transition Bad, force-published recovery, GetHealth() per Gitea #517, ISkippedTagReporter per Gitea #541). | | 8 | Code organization & conventions | 1 finding (Driver.Calculation-003, also an organization smell: three near-duplicate JsonSerializerOptions definitions across the module). Otherwise clean: one construction routine (RebuildTagTable) shared by the ctor and ReinitializeAsync so the two paths cannot drift. | | 9 | Testing coverage | 1 finding (Driver.Calculation-004), Resolved (tests added). CalculationTimerScheduler.SkippedTickCount remains untested — see note under -004. | | 10 | Documentation & comments | No issues found. XML docs are extensive, accurate, and were kept in sync with the fixes below. | ## Findings ### Driver.Calculation-001 | Field | Value | |---|---| | Severity | Medium | | Category | Correctness & logic bugs | | Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs:103-106` (pre-fix), `CalculationEvaluator.cs:47,56-59` (pre-fix) | | Status | Resolved | **Description:** `CalculationDriver`'s constructor built the evaluator's logger as `_logger is ILogger el ? el : NullLogger.Instance`, where `_logger` is typed `ILogger`. `ILogger` and `ILogger` are unrelated closed generic interfaces (no inheritance relationship between `CalculationDriver` and `CalculationEvaluator`), so the concrete logger instance the host injects (a `Logger` from `ILoggerFactory.CreateLogger()`, or `NullLogger.Instance`) never actually implements `ILogger`. The `is` check was therefore **always false**, and every `CalculationEvaluator` silently ran with `NullLogger.Instance` regardless of what logger the driver itself was constructed with — swallowing the evaluator's host-side diagnostics (Roslyn compile failures, sandbox violations, unexpected execution throws, the single-tag `SetVirtualTag`-drop Debug line) with no way to route them to the real host log sink. (The actual script-failure *reason* still reached the operator via the `script-logs` DPS topic through `CalculationDriver.PublishScriptLog`, so this was a silent loss of redundant host-side diagnostics rather than a loss of the primary operator signal — hence Medium, not High.) **Recommendation:** Have `CalculationEvaluator` accept the non-generic `ILogger` (every `ILogger` already implements it) instead of the closed generic `ILogger`, and pass the driver's own `_logger` straight through. **Resolution:** Fixed 2026-08-10 (branch `review/calculation-session`). Changed `CalculationEvaluator`'s constructor parameter/field from `ILogger` to `ILogger`, and `CalculationDriver`'s constructor now does `_evaluator = new CalculationEvaluator(_logger, _scriptRoot, _options.RunTimeout);` with no type gymnastics. Regression test `CalculationDriverTests.Evaluator_compile_diagnostics_flow_through_to_the_drivers_own_logger` (a capturing `ILogger` passed into the driver, a deliberately broken timer-triggered script, asserting the capturing logger receives the "Roslyn compile failed" Warning) fails against the pre-fix code and passes after. --- ### Driver.Calculation-002 | Field | Value | |---|---| | Severity | High | | Category | Correctness & logic bugs | | Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs:374-410` (pre-fix `EvaluateTag`) | | Status | Resolved | **Description:** `EvaluateTag` computed the change-gate flag (`allArrived`) but only used it to decide whether to *publish* the result — it called `_evaluator.Evaluate(...)` **before** checking `allArrived`, against a dependency snapshot that only contains the deps that HAVE arrived. `VirtualTagContext.GetTag` (shared with VirtualTag scripts) returns a Bad snapshot with `.Value == null` for a key not present in the read cache. The idiomatic calc-tag script shape used throughout this module's own tests and comments — `return (double)ctx.GetTag("src/a").Value + (double)ctx.GetTag("src/b").Value;` — casts that `null` to a non-nullable value type, which throws `NullReferenceException` at the unboxing conversion. That exception was caught by `CalculationEvaluator.Evaluate`'s generic `catch (Exception ex)` and reported as a `Failure("script threw: Object reference not set to an instance of an object.")`, which `OnEvaluationFailed` then unconditionally logged to the `script-logs` topic as a Warning — **on every partial-dependency arrival**, for any multi-dependency change-triggered calc tag whose declared deps don't all arrive on the same tick. This is not a corner case: it fires on ordinary warm-up (every deploy and every driver restart) for the standard multi-input calc-tag shape, producing a false-positive "script threw" warning that looks exactly like a real script bug even though nothing is wrong, plus a wasted Roslyn/compiled-cache invocation per partial arrival. **Recommendation:** Move the `allArrived` check before the call to `_evaluator.Evaluate` so the script never runs against a snapshot it wasn't declared against, matching the class's own documented intent ("publish nothing until every declared dep has arrived at least once"). **Resolution:** Fixed 2026-08-10 (branch `review/calculation-session`). `EvaluateTag` now returns before building the dependency snapshot / calling the evaluator when `!def.DependencyRefs.All(_arrivedDeps.Contains)`. `OnEvaluationFailed`'s now-always-true `allArrived` parameter was removed (the caller only reaches it once every dep has arrived). Regression test `CalculationDriverTests.No_evaluation_or_script_log_while_a_declared_dep_is_still_missing` reproduces the exact scenario from `No_publish_until_every_declared_dep_has_arrived` but also asserts `logs.ShouldBeEmpty()`; it fails with the captured `"script threw: Object reference not set..."` entry against the pre-fix code and passes after. The pre-existing `No_publish_until_every_declared_dep_has_arrived` and `Failure_publishes_Bad_once_per_transition_emits_script_log_and_recovers` tests are unaffected (neither exercises a multi-dep tag mid-warm-up while asserting on `logs`). --- ### Driver.Calculation-003 | Field | Value | |---|---| | Severity | Medium | | Category | Correctness & logic bugs / Code organization & conventions | | Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverProbe.cs:16-21` (pre-fix) | | Status | Resolved | **Description:** The module defines its config-parsing `JsonSerializerOptions` in three places — `CalculationDriverFactoryExtensions.JsonOptions` (spawn), `CalculationDriver.ReinitJsonOptions` (in-place ApplyDelta), and `CalculationDriverProbe.Opts` (Test-Connect) — and the first two both set `ReadCommentHandling = JsonCommentHandling.Skip` and `AllowTrailingCommas = true` while the probe's did not. A config blob the real spawn/reinit path tolerates (a trailing comma or a `//` comment, plausible for a hand-edited config used during troubleshooting) could therefore fail Test-Connect with "Config JSON is invalid" even though deploying the identical JSON would succeed — the probe was **stricter** than the path it exists to stand in for, the opposite of its documented contract ("Never throws; returns Ok with negligible latency" / parse-only parity with the real path). **Recommendation:** Align `CalculationDriverProbe.Opts` with the other two option sets (or extract a single shared `JsonSerializerOptions` constant the three call sites reuse, to remove the duplication risk entirely for future changes). **Resolution:** Fixed 2026-08-10 (branch `review/calculation-session`). Added `ReadCommentHandling = JsonCommentHandling.Skip` and `AllowTrailingCommas = true` to `Opts`, with a comment naming the two option sets it must stay in parity with. New test file `CalculationDriverProbeTests.cs` (previously nonexistent — see Driver.Calculation-004) pins this with `Tolerates_trailing_commas_like_the_real_spawn_and_reinit_paths` and `Tolerates_comments_like_the_real_spawn_and_reinit_paths`, both of which fail against the pre-fix `Opts` and pass after. --- ### Driver.Calculation-004 | Field | Value | |---|---| | Severity | Medium | | Category | Testing coverage | | Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverProbe.cs`, `CalculationDriver.cs` (`ISkippedTagReporter`/`GetSkippedTags()`), `CalculationTimerScheduler.cs` (`SkippedTickCount`) | | Status | Resolved | **Description:** Two behaviours in this module had zero test coverage before this review: 1. `CalculationDriverProbe` had no test file at all — the Test-Connect surface for this driver type was completely unverified (and, per Driver.Calculation-003, was in fact wrong). 2. `CalculationDriver`'s implementation of `ISkippedTagReporter`/`GetSkippedTags()` (the Gitea #541 authored-vs-mapped-tag visibility feature — the exact class of bug that let a driver silently ignore 20 of 22 tags while still reporting Healthy) had no test verifying a raw tag with no `scriptId` is actually reported skipped, that `AuthoredCount`/`SkippedCount`/`Sample` are correct, or that the report is rebuilt (not stale) after `ReinitializeAsync`. `CalculationTimerScheduler.SkippedTickCount` (the in-flight-tick-skip counter) remains untested — left open rather than force-fitted, since reliably forcing a same-group timer overlap without a sleep-based flaky test would need either an injectable clock/scheduler or an artificially slow evaluator hook, neither of which exists yet in this module. **Recommendation:** Add a dedicated `CalculationDriverProbeTests.cs`; add `GetSkippedTags()` coverage to `CalculationDriverTests.cs`. For `SkippedTickCount`, a follow-up could add a test-only evaluator delay hook if the counter ever needs to be pinned by a test rather than only exercised implicitly. **Resolution:** Fixed 2026-08-10 (branch `review/calculation-session`). Added `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationDriverProbeTests.cs` (6 tests, including the two parity regressions from -003). Added to `CalculationDriverTests.cs`: `GetSkippedTags_reports_a_raw_tag_with_no_scriptId_alongside_the_authored_total`, `GetSkippedTags_has_no_skips_when_every_authored_tag_maps`, and `GetSkippedTags_updates_after_reinitialize`. `SkippedTickCount` is intentionally left without a dedicated test — noted above, not silently dropped. --- ### Driver.Calculation-005 | Field | Value | |---|---| | Severity | Medium | | Category | Concurrency & thread safety | | Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriver.cs:300-314` (`OnDependencyValue`), `:374-410` (`EvaluateTag`); cross-module: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyConsumerMuxAdapter.cs` | | Status | Open | **Description:** `OnDependencyValue` evaluates every change-triggered dependent tag **inline, under `_lock`**, and `EvaluateTag`'s call into `CalculationEvaluator.Evaluate` ultimately runs `TimedScriptEvaluator<...>.RunAsync(context).GetAwaiter().GetResult()` — a synchronous blocking wait bounded by `RunTimeout` (default 2 s, configurable via `RunTimeoutMs`, with no enforced ceiling). `OnDependencyValue` is invoked directly and synchronously from `DependencyConsumerMuxAdapter.OnDependencyValueChanged` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyConsumerMuxAdapter.cs:66-70`), an Akka `ReceiveActor`'s message handler — i.e. on the actor's dispatcher thread. A calc script that runs close to its timeout budget therefore blocks that thread (and, transitively, that specific driver's dependency-value mailbox) for up to `RunTimeout` on every dependency arrival, and — because `TimedScriptEvaluator` offloads the actual run via `Task.Run` while the caller blocks synchronously waiting on it — occupies a pool thread for the duration rather than yielding it, which is a known thread-pool-starvation risk under Akka's fork-join dispatcher if enough calc drivers are evaluating slow scripts concurrently. The in-line comment ("the compile cache makes this a bounded method invocation — the same 'fast enough to run inline' contract as the VT evaluator") is not actually enforced by anything faster than `RunTimeout` itself — the existence of the timeout mechanism is evidence a script CAN run slow, not evidence it never will. This exact pattern (`_evaluator.Evaluate(...)` called unconditionally, inline, from an Akka actor's synchronous message handler, with the identical `GetAwaiter().GetResult()` blocking shape) is also present in `VirtualTagActor.OnDependencyChanged` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs:179-199`), which `CalculationDriver`'s own docs explicitly say this module mirrors — so this is a systemic, pre-existing trade-off in the scripting engine, not something introduced by the Calculation driver alone. Recorded here because the blocking call itself lives in this module's code (`CalculationDriver.cs`), even though a full fix spans `Core.Abstractions` (the synchronous `IDependencyConsumer.OnDependencyValue` contract) and `Runtime` (the adapter actor) — both out of this review's module scope. **Recommendation:** Not a fix for this review (cross-module: the synchronous `IDependencyConsumer` contract lives in `Core.Abstractions`, and the actor calling it lives in `Runtime`). If this risk is judged worth closing, options include: (a) giving `IDependencyConsumer.OnDependencyValue` an async signature so the actor can `PipeTo` the result instead of blocking its own thread; (b) having `CalculationDriver` dispatch evaluation onto a dedicated bounded scheduler (e.g. `Task.Run` from `OnDependencyValue` itself, publishing the result asynchronously) so the actor thread returns immediately; or (c) tightening `RunTimeout`'s effective ceiling independent of the `RunTimeoutMs` config knob. Any of these should be scoped and reviewed together with the equivalent `VirtualTagActor` behaviour so the two mirrors do not diverge. **Resolution:** _(Open — cross-module, not fixed as part of this review.)_