384dbd7d36
v2-ci / build (push) Failing after 42s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
CLAUDE.md: add scadaproj sister-project index, fix mxaccessgw path to ~/Desktop/MxAccessGateway, note the temporary CVE-2025-6965 NuGetAuditSuppress, and correct the driver-typed tag-editor list (OpcUaClient is now mapped; only Galaxy falls back). Also captures docker-dev data-plane GroupToRole + ServerHistorian live-verify config and the HistorianGateway integration/backlog notes.
184 lines
11 KiB
Markdown
184 lines
11 KiB
Markdown
# HistorianGateway backend — issues identified
|
||
|
||
**Date:** 2026-06-27
|
||
**Context:** Installed the `ZB.MOM.WW.HistorianGateway` sidecar on **wonder-app-vd03** and updated OtOpcUa to
|
||
`origin/master` (`245316d8`, PR #423 "HistorianGateway as the OtOpcUa historian backend"), then smoke-tested
|
||
the OtOpcUa→gateway link live. These are the issues found during that work.
|
||
|
||
> **File/line references are against `origin/master` @ `245316d8`** (the build that contains the gateway
|
||
> backend — `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/`). They will not resolve on older
|
||
> local checkouts that predate PR #423.
|
||
|
||
> **Live evidence** came from the gateway's Prometheus `/metrics` on the box: an OPC UA `historyread` on a
|
||
> historized tag produced `historian_gateway_grpc_requests_total{method="ReadRaw",service="HistorianRead"}=1`
|
||
> and `historian_session_pool_warm_sessions=1`, while a historized-tag **deploy** produced **zero**
|
||
> `EnsureTags` calls.
|
||
|
||
---
|
||
|
||
## Issue 1 — `GatewayTagProvisioner` is never wired; EnsureTags auto-provisioning is dormant (HIGH, confirmed)
|
||
|
||
**Summary.** PR #423 ships `GatewayTagProvisioner` (the `IHistorianProvisioning` implementation that calls the
|
||
gateway's `EnsureTags`) and unit tests for it, but it is **never registered in DI and never passed into the
|
||
`AddressSpaceApplier`**. At runtime the applier uses the no-op `NullHistorianProvisioning`, so **deploying
|
||
historized tags does NOT provision them in the historian**. The documented "EnsureTags auto-provisions
|
||
historian tags on deploy" behaviour does not occur.
|
||
|
||
**Root cause (code).**
|
||
- `AddressSpaceApplier` constructor coalesces a missing provisioner to the no-op:
|
||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs:63`
|
||
`_provisioning = provisioning ?? NullHistorianProvisioning.Instance;`
|
||
- The **only** construction site omits the `provisioning:` argument:
|
||
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs:258`
|
||
```csharp
|
||
var applier = new AddressSpaceApplier(
|
||
addressSpaceSink,
|
||
loggerFactory.CreateLogger<AddressSpaceApplier>(),
|
||
historizedSubscriptions: historizedSubscriptions); // <-- no provisioning:
|
||
```
|
||
- **No `IHistorianProvisioning` / `GatewayTagProvisioner` registration exists anywhere** in the repo
|
||
(`grep` over `origin/master` finds only the interface definition, `NullHistorianProvisioning`, and the
|
||
`GatewayTagProvisioner` constructor definition itself — no `AddSingleton<IHistorianProvisioning, …>` and
|
||
no `new GatewayTagProvisioner(…)`).
|
||
- Consequently `ProvisionHistorizedTags` (called unconditionally at
|
||
`AddressSpaceApplier.cs:208`, body at `:226`, dispatch at `:261`) builds the request list and invokes
|
||
`_provisioning.EnsureTagsAsync(...)` on the **no-op**, which returns without contacting the gateway.
|
||
|
||
**Live evidence.** On wonder-app-vd03: flagged the `parts-count` equipment tag `isHistorized:true`, deployed,
|
||
and restarted the host. The gateway `/metrics` showed **no** gRPC request series for `HistorianTags`/`EnsureTags`
|
||
(zero gateway calls). By contrast, an explicit OPC UA `historyread` on the same tag *did* reach the gateway
|
||
(`ReadRaw`), confirming the read path is wired but the provisioning path is not.
|
||
|
||
**Impact.**
|
||
- Historized tags are never created in the historian on deploy.
|
||
- When **continuous historization** (`ContinuousHistorization:Enabled=true`) is later turned on, the recorder
|
||
drains live values to historian tags that were never provisioned — those writes will fail / land nowhere
|
||
unless the tags are created by some other means.
|
||
- Docs (`docs/Historian.md`, repo `CLAUDE.md`, and the umbrella scadaproj `CLAUDE.md`) describe provisioning
|
||
as active; that claim is currently false. See Issue 4.
|
||
|
||
**Recommended fix (two parts).**
|
||
1. **Register the provisioner** in the Host alongside the existing gateway wiring — in
|
||
`src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`, inside the `if (serverHistorianOptions.Enabled)` block that
|
||
already calls `AddServerHistorian` / `AddAlarmHistorian` — e.g.
|
||
```csharp
|
||
builder.Services.AddSingleton<IHistorianProvisioning>(sp =>
|
||
new GatewayTagProvisioner(
|
||
HistorianGatewayClientAdapter.Create(serverHistorianOptions, sp.GetRequiredService<ILoggerFactory>()),
|
||
sp.GetRequiredService<ILogger<GatewayTagProvisioner>>()));
|
||
```
|
||
(mirror the `GatewayHistorianValueWriter` registration pattern already in that block).
|
||
2. **Pass it into the applier** at `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs:258`
|
||
by resolving it the same way sibling deps are resolved there (`resolver.GetService<…>()`):
|
||
```csharp
|
||
var provisioning = resolver.GetService<IHistorianProvisioning>();
|
||
var applier = new AddressSpaceApplier(
|
||
addressSpaceSink,
|
||
loggerFactory.CreateLogger<AddressSpaceApplier>(),
|
||
provisioning: provisioning,
|
||
historizedSubscriptions: historizedSubscriptions);
|
||
```
|
||
3. **Add a wiring/integration test** asserting `EnsureTags` is invoked on a historized-tag deploy. The existing
|
||
`GatewayTagProvisioner` unit tests pass but do not cover the DI wiring, which is exactly the gap that let
|
||
this ship dormant.
|
||
|
||
---
|
||
|
||
## Issue 2 — Provisioning success is logged nothing; the dormant path is invisible (LOW, observability)
|
||
|
||
**Summary.** The provisioning continuation in `AddressSpaceApplier` only emits a log line when
|
||
`result.Failed > 0 || result.Skipped > 0`. A fully-successful provisioning (`Ensured>0, Failed=0, Skipped=0`)
|
||
**and** the no-op `NullHistorianProvisioning` path both log **nothing**. This is what made Issue 1 invisible:
|
||
there was no log line either way, so "no provisioning log" could not be distinguished from "provisioning ran
|
||
and fully succeeded."
|
||
|
||
**Location.** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` — the
|
||
`EnsureTagsAsync(...).ContinueWith(...)` block (~lines 261–283); the Information log is gated behind
|
||
`if (result.Failed > 0 || result.Skipped > 0)`.
|
||
|
||
**Recommended fix.** Emit an Information tally on every dispatch (e.g. `requested/ensured/skipped/failed`),
|
||
or at minimum whenever `Ensured > 0`, so a successful provisioning is observable and a no-op is detectable.
|
||
|
||
---
|
||
|
||
## Issue 3 — HistoryRead on a historized-but-unprovisioned tag blocks for the full CallTimeout (~30s) (MEDIUM, needs investigation)
|
||
|
||
**Summary.** An OPC UA `historyread` on a tag that is historized but has no corresponding historian
|
||
tag/data returned only after ~30 s — the gateway's `ReadRaw` recorded
|
||
`historian_gateway_grpc_request_duration_seconds_sum ≈ 30.5` for the single call, matching the
|
||
`ServerHistorian:CallTimeout` default of `00:00:30`. The read did not fail fast on an unknown tag; it ran to
|
||
the timeout and then errored (`outcome="error"`).
|
||
|
||
**Notes / attribution.**
|
||
- The slow leg is the gateway→historian read of a non-existent tag; the OtOpcUa-facing symptom is a 30 s OPC UA
|
||
HistoryRead latency governed by `ServerHistorianOptions.CallTimeout` (default 30 s, `…Runtime/Historian/ServerHistorianOptions.cs`).
|
||
- This is **coupled to Issue 1**: because provisioning never runs, freshly-historized tags don't exist in the
|
||
historian, so every HistoryRead on them hits this slow path until tags/data exist.
|
||
- Single data point (one unprovisioned tag); confirm whether the slow path is the historian, the gateway, or the
|
||
client-side timeout before changing behaviour.
|
||
|
||
**Recommended action.** Primarily fix Issue 1 so historian tags exist. Separately, consider a shorter default
|
||
HistoryRead timeout and/or fast-fail when the historian reports an unknown tag, so a missing tag doesn't hang an
|
||
OPC UA HistoryRead for 30 s.
|
||
|
||
---
|
||
|
||
## Issue 4 — Docs describe provisioning as active (LOW, documentation)
|
||
|
||
`docs/Historian.md`, the repo `CLAUDE.md`, and the umbrella `scadaproj/CLAUDE.md` state that the gateway backend
|
||
"auto-provisions historian tags on deploy (EnsureTags)". Given Issue 1, that is inaccurate in the shipped build.
|
||
Update the docs once Issue 1 is fixed, or flag the provisioning behaviour as not-yet-wired in the interim.
|
||
|
||
---
|
||
|
||
## Issue 5 — Provisioning covers only *newly-added* tags; flagging an existing tag for historization never provisions it (MEDIUM, follow-up to the Issue 1 fix)
|
||
|
||
**Status:** found while live-verifying the Issue 1 fix (commit `257214f7`) on wonder-app-vd03.
|
||
|
||
**Summary.** The Issue 1 fix correctly wires `GatewayTagProvisioner` and is confirmed deployed (the new
|
||
`"...provisioning completed (dispatched…"` log literal is present in the deployed `OpcUaServer.dll`). However,
|
||
`ProvisionHistorizedTags` still iterates **only** `plan.AddedEquipmentTags`:
|
||
|
||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` (`ProvisionHistorizedTags`):
|
||
`foreach (var tag in plan.AddedEquipmentTags)` — `ChangedEquipmentTags` is **not** considered.
|
||
|
||
So when an operator flags an **existing** tag `isHistorized:true` and deploys, that tag is classified
|
||
**Changed** (its config changed; the tag already existed) and is **never provisioned**. This is compounded by
|
||
the bootstrap "restore served state" behaviour: a host restart re-applies the served address space
|
||
**incrementally**, so an existing tag comes back as *unchanged/changed*, never *Added* — i.e. a restart does
|
||
not provision it either.
|
||
|
||
**Live evidence.** Flagging `parts-count` (an existing tag) historized + deploy + restart produced **zero**
|
||
`EnsureTags` calls at the gateway (`/metrics` had no `HistorianTags/EnsureTags` series). The read path on the
|
||
same tag *did* reach the gateway (`HistorianRead/ReadRaw`), confirming the tag was historized and the gateway
|
||
link works — only provisioning was skipped. (A raw `dbo.Tag` INSERT of a brand-new historized tag did not
|
||
materialise either, because a raw insert bypasses the normal compose/validate path — so the clean way to
|
||
exercise the *added* path is to author a new historized tag via the AdminUI.)
|
||
|
||
**Impact.** The most common operator action — take an existing tag and turn on historization — does **not**
|
||
auto-provision the historian tag. Continuous historization for such a tag would then write to a tag that does
|
||
not exist. Only genuinely **new** historized tags get provisioned.
|
||
|
||
**Recommended fix.** Provision the union of added **and** newly-historized changed tags: also iterate
|
||
`plan.ChangedEquipmentTags` (or compute "tags whose `IsHistorized` transitioned false→true") and include them
|
||
in the `EnsureTags` request. `EnsureTags` is idempotent, so re-ensuring an already-present tag is safe. Add a
|
||
test that flips an existing tag to historized and asserts `EnsureTags` is invoked.
|
||
|
||
---
|
||
|
||
## Pre-existing observation (not introduced by this work)
|
||
|
||
- **Serilog log path is service-CWD-relative.** On wonder-app-vd03 the host writes to
|
||
`C:\Windows\System32\logs\otopcua-<date>.log` (because the service CWD is `System32`), not under the install
|
||
directory. This was noted in prior sessions; re-observed here. Not a gateway-integration issue, but it makes
|
||
on-box diagnosis awkward and is worth fixing (anchor the Serilog file sink to an absolute/content-root path).
|
||
|
||
---
|
||
|
||
## What is working (for context)
|
||
|
||
The OtOpcUa↔gateway **read and credential path is confirmed working end-to-end** on wonder-app-vd03: an OPC UA
|
||
HistoryRead routed through `GatewayHistorianDataSource` to the gateway over h2c, authenticated with a
|
||
peppered-HMAC API key (`historian:read` scope), and leased a real historian session (the gateway reached
|
||
wonder-sql-vd03). Only the **provisioning** half (Issue 1) is unwired.
|