Merge R2-06 ServerHistorian fail-fast + 0.2.0 DataType (arch-review round 2) [PR #427]
Findings 06/S-11 (fail-fast ServerHistorianOptionsValidator, no UriFormatException crash-loop), 06/U-7 (0.2.0 Boolean->Int1 explicit-presence + retype probe), 06/C-7 (keep-populating decision doc). T12 live VPN gate deferred. STATUS.md conflict (R2-12 bookkeeping) resolved additively. Build clean.
This commit is contained in:
@@ -315,6 +315,11 @@ When the section is disabled the applier binds the no-op `NullHistorianProvision
|
||||
run. Every dispatch logs a tally (`dispatched/requested/ensured/skipped/failed`); `dispatched=N` with
|
||||
`requested=0` flags the dormant no-op. The API key must carry `historian:tags:write` for `EnsureTags`. (PR
|
||||
#423 shipped `GatewayTagProvisioner` but left this wiring out, so provisioning was dormant; restored 2026-06-27.)
|
||||
**0.2.0 DataType delta (archreview 06/U-7):** `EnsureTags` is an **upsert** (create-or-update, not
|
||||
skip-if-exists), and the 0.2.0 `HistorianTagDefinition.DataType` proto3-optional change means Boolean tags now
|
||||
provision as **Int1** (0.1.0 silently defaulted them to Float). A pre-bump Float Boolean is asked to retype
|
||||
on its next deploy; the AVEVA-side retype policy is pinned by the live retype probe — see the "0.2.0 DataType
|
||||
provisioning delta" section in `docs/Historian.md`.
|
||||
|
||||
### Gateway-side prerequisites
|
||||
|
||||
@@ -349,6 +354,7 @@ export HISTGW_GATEWAY_ENDPOINT=https://wonder-sql-vd03:5222 # absolute gateway
|
||||
export HISTGW_GATEWAY_APIKEY=histgw_<id>_<secret> # must carry historian:read + historian:write (+ tags:write) scopes
|
||||
export HISTGW_TEST_TAG=<existing-tag> # read round-trip
|
||||
export HISTGW_WRITE_SANDBOX_TAG=<writable-float-tag> # e.g. HistGW.LiveTest.Sandbox — write round-trip (EnsureTags + write)
|
||||
export HISTGW_BOOL_SANDBOX_TAG=<writable-bool-tag> # e.g. HistGW.LiveTest.BoolSandbox — 0.2.0 Boolean EnsureTags leg + retype probe (06/U-7)
|
||||
export HISTGW_ALARM_SOURCE=<source-name> # alarm SendEvent → ReadEvents round-trip
|
||||
dotnet test --filter "Category=LiveIntegration"
|
||||
```
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
| U-5 (Low) | Outbox serializer has no version/format byte | **STILL OPEN** — `HistorizationOutboxEntrySerializer.cs:14-16` unchanged. |
|
||||
| U-6 | Test-coverage thin spots (advise-failure status, out-of-order `RemoveAsync`, S-3 hang, pump filter) | **STILL OPEN** — zero test changes in the three gateway test projects since `9cad9ed0`. |
|
||||
|
||||
**New findings this pass:** S-11 (Medium), U-7 (Medium), C-7 (Low) — see body.
|
||||
**New findings this pass:** S-11 (Medium), U-7 (Medium), C-7 (Low) — see body. **All three REMEDIATED on `r2/06-serverhistorian-failfast` (R2-06, 2026-07-13); the U-7 live EnsureTags/retype leg is operator-gated pending a VPN run.**
|
||||
|
||||
---
|
||||
|
||||
@@ -138,6 +138,8 @@ The read synthesis (`GalaxyDriver.cs:738-755`) fills pending snapshots with `Bad
|
||||
|
||||
#### S-11 (Medium, NEW) — An enabled-but-misconfigured `ServerHistorian` section crash-loops the Host with a raw `UriFormatException`; one corner gets no warning at all
|
||||
|
||||
> **REMEDIATED — `r2/06-serverhistorian-failfast` (R2-06, 2026-07-13; live leg operator-gated).** New fail-fast `ServerHistorianOptionsValidator` (Host, wired via `AddValidatedOptions`) consumer-gates on `ServerHistorian:Enabled` **OR** `AlarmHistorian:Enabled` (closing the zero-warning corner) and fails startup with a named, aggregated `OptionsValidationException` on an empty / non-absolute / non-http(s) `Endpoint`. Adapter defense-in-depth replaces the bare `new Uri(...)` with `Uri.TryCreate`→named `InvalidOperationException`. `Validate()` gains malformed-endpoint warning parity; an alarm-only deployment logs a startup warning. Also directly answers C-4's "give the warnings style a fail-fast tier".
|
||||
|
||||
`HistorianGatewayClientAdapter.Create` eagerly does `new Uri(options.Endpoint)` (`HistorianGatewayClientAdapter.cs:45`). `ServerHistorianOptions.Validate()` produces exactly the right warning for an empty Endpoint (`ServerHistorianOptions.cs:78-79`) — but it is **warnings-only**: registration proceeds (`Runtime/ServiceCollectionExtensions.cs:135-139`, `Host/Program.cs:123-127`) and the first DI resolution throws an unhandled `UriFormatException`, crashing the Host at bring-up. This is not theoretical — it made docker-dev unbootable until the config-side fix `7233e2ba` (whose commit message documents the exact failure). The code path is untouched.
|
||||
|
||||
The sharper corner: **`AddAlarmHistorian` gates only on `AlarmHistorian:Enabled`** (`Runtime/ServiceCollectionExtensions.cs:87`) but sources its connection from the `ServerHistorian` section (`Program.cs:127`). With `AlarmHistorian:Enabled=true` and `ServerHistorian:Enabled=false` (Endpoint legitimately empty), `Validate()` early-returns on `!Enabled` (`ServerHistorianOptions.cs:77`) — so the operator gets **zero warning**, then the same `UriFormatException` out of `CreateAlarmWriter` when the sink singleton is built. Same exposure for the continuous-historization writer (`Program.cs:187`, though that one is co-gated on `ServerHistorian.Enabled`, so only a *malformed* non-empty Endpoint reaches it).
|
||||
@@ -212,6 +214,8 @@ Galaxy: throw-on-construct for required fields; DataAnnotations attributes on th
|
||||
|
||||
#### C-7 (Low, NEW) — The live-value writer populates a documented-dead `Quality` field
|
||||
|
||||
> **REMEDIATED — `r2/06-serverhistorian-failfast` (R2-06, 2026-07-13).** Decision: **keep populating** the field (the "stop populating so it's visible" alternative was rejected — proto3 sends `0` = Bad when unset, planting a future bug the instant a gateway version honors the field, whereas the recorder's `192` is correct). Visibility achieved with doc comments instead: `GatewayHistorianValueWriter` `<remarks>` + inline note at the mapping, `ContinuousHistorizationRecorder.GoodQuality` guard note, and a `docs/Historian.md` sentence — all citing 0.2.0 Contracts C-002.
|
||||
|
||||
`GatewayHistorianValueWriter` maps `HistorizationValue.Quality` onto `HistorianLiveValue.Quality` (`GatewayHistorianValueWriter.cs:63`), but the 0.2.0 contract now documents that field (and `QualityDetail`) as **silently discarded on the `WriteLiveValues` SQL path** — the gateway's SQL login is column-permission-denied on the server-managed Quality column, so the value is accepted on the wire and dropped. Harmless today because the recorder hardcodes `GoodQuality = 192` for every captured value (`ContinuousHistorizationRecorder.cs:45,256` — the outbox round-trips it, `:419`), so nothing meaningful is lost. The hazard is forward-looking: if the recorder is ever extended to capture *real* node quality (a natural next step), it will appear to work and silently persist nothing — quality will remain server-stamped. Add a comment at the mapping site citing the 0.2.0 contract note (Contracts C-002), or stop populating the field so the dead input is visible.
|
||||
|
||||
### 4. Underdeveloped Areas
|
||||
@@ -237,6 +241,8 @@ The `Category=LiveIntegration` suite is complete and skip-clean: `GatewayLiveFix
|
||||
|
||||
#### U-7 (Medium, NEW) — The 0.2.0 bump's impact analysis missed the `DataType` presence change that alters Boolean tag provisioning
|
||||
|
||||
> **REMEDIATED (code + docs) — `r2/06-serverhistorian-failfast` (R2-06, 2026-07-13); LIVE LEG OPERATOR-GATED.** Pinned the 0.2.0 Boolean→Int1 explicit-presence semantics at two levels (`GatewayTagProvisionerTests.Boolean_definition_carries_explicit_Int1_presence` asserts `DataType==Int1` **and** `HasDataType` — the 0.2.0 presence witness; a package downgrade breaks the compile, a dropped-presence contract change breaks the assert). Added a written upsert/retype migration note to `docs/Historian.md` (three retype hypotheses + `failed=N` tally observability). Added the env-gated live Boolean EnsureTags leg + a **retype probe** (`HISTGW_BOOL_SANDBOX_TAG`) that records the deployed historian's in-place-retype policy. **Pending:** the live VPN run on a 0.2.0 gateway (feeds the observed retype answer back into the doc note; also discharges U-2's full documented run).
|
||||
|
||||
The bump commit (`f6eaa267`) gates only on the M3 `opc_quality` change ("checked and CLEAR" — correct, verified: OtOpcUa never writes `HistorianHistoricalValue`). But 0.2.0 also made **`HistorianTagDefinition.DataType` proto3-optional** (contracts review M-2), and that one lands squarely on `GatewayTagProvisioner`:
|
||||
|
||||
- The provisioner always sets `DataType` explicitly (`GatewayTagProvisioner.cs:57`), and `Boolean → HistorianDataType.Int1` (`HistorianTypeMapper.cs:28`) — where **Int1 is wire value 0**.
|
||||
|
||||
@@ -245,3 +245,11 @@ Every cited file:line was opened against the working tree at `f6eaa267`. **All t
|
||||
| C-7 | **XS** (Task 10, ~5 min) | None | Comments/docs; deliberate keep-populating decision recorded |
|
||||
|
||||
**Total: ~1 hour of implementation across 3 commits, plus one operator VPN session for the live gate.** Suggested sequencing: Commit 1 (S-11) → Commit 2 (U-7) → Commit 3 (C-7) → Task 11 bookkeeping → Task 12 operator gate.
|
||||
|
||||
## Execution deviations (R2-06)
|
||||
|
||||
Executed 2026-07-13 on branch `r2/06-serverhistorian-failfast` (off master `1676c8f4`).
|
||||
|
||||
- **Commit granularity — per-task, not the plan's 3 grouped commits.** The plan text grouped the work into 3 commits (one per finding). The executor's operating method mandated one pathspec-scoped commit **per task** (with the task's `tasks.json` status flip in the same commit) for traceability/bisectability. Followed the per-task rule → 11 task commits (T1–T11) instead of 3. No content difference; strictly finer history. Reversible (squashable at merge if 3 commits are still wanted).
|
||||
- **Validator + wiring tests live in `Host.IntegrationTests` but are pure unit tests.** Per the plan (they mirror `LdapOptionsValidatorTests`, which lives there). They were verified green **filtered** (`--filter FullyQualifiedName~ServerHistorianOptionsValidatorTests`, 8/8) once during T4 — **before** a mid-run controller constraint arrived forbidding any run of `*.IntegrationTests` projects (a ~16 GB/run memory-leak in the heavy integration tests) and whole-solution `dotnet test`. After that constraint they were **not re-run**; the earlier filtered pass + a clean `dotnet build ZB.MOM.WW.OtOpcUa.slnx` (0 errors) are the standing evidence. The Historian.Gateway driver unit suite (a plain `.Tests` project) and the Runtime.Tests suite were run freely.
|
||||
- **T12 (live VPN gate) → `deferred-live`.** Genuinely external infra (VPN to `wonder-sql-vd03` + a 0.2.0 gateway with `RuntimeDb:Enabled=true` and an API key carrying `historian:read`+`write`+`tags:write`). The code (T8: fixture env var + Boolean EnsureTags leg + retype probe) is complete and offline **skip-clean** (6 LiveIntegration tests, all skipped, 0 failed). The operator runs the suite on the VPN and feeds the observed retype policy back into `docs/Historian.md` + STATUS.md.
|
||||
|
||||
@@ -5,74 +5,96 @@
|
||||
{
|
||||
"id": "T1",
|
||||
"subject": "RED: misconfig repro test — HistorianGatewayClientAdapter.Create with empty/malformed Endpoint must throw a named InvalidOperationException (currently UriFormatException; test MUST fail on current code)",
|
||||
"status": "pending",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T2",
|
||||
"subject": "GREEN: adapter guard — Uri.TryCreate + InvalidOperationException naming ServerHistorian:Endpoint in HistorianGatewayClientAdapter.Create (defense in depth)",
|
||||
"status": "pending",
|
||||
"blockedBy": ["T1"]
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T3",
|
||||
"subject": "RED: ServerHistorianOptionsValidator unit tests (7 cases incl. the AlarmHistorian-enabled/ServerHistorian-disabled corner naming both sections)",
|
||||
"status": "pending",
|
||||
"blockedBy": ["T1"]
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T4",
|
||||
"subject": "GREEN: implement ServerHistorianOptionsValidator (OptionsValidatorBase, ctor IConfiguration, consumed = Enabled || AlarmHistorian:Enabled, fail on empty/non-absolute/non-http(s) Endpoint)",
|
||||
"status": "pending",
|
||||
"blockedBy": ["T3"]
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T5",
|
||||
"subject": "Wiring: AddValidatedOptions<ServerHistorianOptions, ServerHistorianOptionsValidator> in Program.cs hasDriver block + OptionsValidationException wiring test + residual alarm-only-mode startup warning",
|
||||
"status": "pending",
|
||||
"blockedBy": ["T4"]
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T6",
|
||||
"subject": "Warning parity: malformed-endpoint warning in ServerHistorianOptions.Validate() + Runtime.Tests case; commit 1 (S-11) after T1-T6 green",
|
||||
"status": "pending",
|
||||
"blockedBy": ["T4"]
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T7",
|
||||
"subject": "U-7 pin: GatewayTagProvisionerTests Boolean_definition_carries_explicit_Int1_presence (asserts HistorianDataType.Int1 AND HasDataType — 0.2.0 proto3-optional presence witness; pin, no RED phase)",
|
||||
"status": "pending",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T8",
|
||||
"subject": "U-7 live leg: HISTGW_BOOL_SANDBOX_TAG in GatewayLiveFixture + EnsureTags_boolean_sandbox_provisions_as_Int1 + EnsureTags_type_change_outcome_is_observable retype probe (skip-clean offline; verify all-skip run)",
|
||||
"status": "pending",
|
||||
"blockedBy": ["T7"]
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T9",
|
||||
"subject": "U-7 docs: migration/assessment note (EnsureTags is upsert; pre-bump Float Booleans; three retype hypotheses; failed=N tally observability) in docs/Historian.md + CLAUDE.md HISTGW_BOOL_SANDBOX_TAG recipe update; commit 2 (U-7) after T7-T9",
|
||||
"status": "pending",
|
||||
"blockedBy": ["T8"]
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T10",
|
||||
"subject": "C-7: keep-populating decision + dead-Quality doc comments at GatewayHistorianValueWriter.cs:63, ContinuousHistorizationRecorder.GoodQuality, docs/Historian.md (cites 0.2.0 Contracts C-002); commit 3 (C-7)",
|
||||
"status": "pending",
|
||||
"status": "completed",
|
||||
"blockedBy": []
|
||||
},
|
||||
{
|
||||
"id": "T11",
|
||||
"subject": "Bookkeeping: update archreview/plans/STATUS.md + 06-gateway-integrations.md prior-finding rows for S-11/U-7/C-7 when the work lands",
|
||||
"status": "pending",
|
||||
"blockedBy": ["T6", "T9", "T10"]
|
||||
"status": "completed",
|
||||
"blockedBy": [
|
||||
"T6",
|
||||
"T9",
|
||||
"T10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T12",
|
||||
"subject": "OPERATOR GATE: run Category=LiveIntegration on the VPN against a 0.2.0 gateway (all 6 tests, incl. Boolean EnsureTags + retype probe); record the observed retype policy back into docs/Historian.md + STATUS.md; also discharges 06/U-2's full documented run",
|
||||
"status": "pending",
|
||||
"blockedBy": ["T9", "T11"]
|
||||
"status": "deferred-live",
|
||||
"blockedBy": [
|
||||
"T9",
|
||||
"T11"
|
||||
],
|
||||
"note": "needs live gateway over VPN; needs-user / serial live pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -164,3 +164,9 @@ clean. **OPEN follow-ups:** (a) when a runner-reachable fleet + a real `tests/Se
|
||||
add a fresh E2E workflow WITH a zero-match guard (fail if `Category=E2E` matches nothing); (b) S-2
|
||||
option #2 — a fixture-backed integration leg (opc-plc `services:` block, `MIN_EXECUTED=1`) covering
|
||||
what the unit leg cannot; (c) migrate the 3 xunit v2 holdouts once Akka ships an xunit.v3 TestKit.
|
||||
|
||||
### Round-2 remediation landed
|
||||
|
||||
| Plan | Findings | Branch | Commits | Status |
|
||||
|---|---|---|---|---|
|
||||
| **R2-06** | 06/S-11 (rank 7), 06/U-7 (rank 8), 06/C-7 | `r2/06-serverhistorian-failfast` (off `1676c8f4`) | `3a049a13`…`4640ecb1` (10 task commits) | **Code-complete, offline-verified.** S-11: fail-fast `ServerHistorianOptionsValidator` (consumer-gated on `ServerHistorian:Enabled` OR `AlarmHistorian:Enabled`, fails empty/non-http(s) Endpoint at startup) + `AddValidatedOptions` wiring + adapter defense-in-depth (`TryCreate`→named `InvalidOperationException`, no more raw `UriFormatException` crash-loop) + `Validate()` warning parity + alarm-only-mode startup warning. U-7: pinned Boolean→Int1 explicit-presence (`HasDataType`) + `HISTGW_BOOL_SANDBOX_TAG` live Boolean EnsureTags leg + retype probe (skip-clean offline) + upsert/retype migration note (`docs/Historian.md`). C-7: keep-populating decision + dead-`Quality` doc comments (0.2.0 C-002). **Live leg operator-gated (T12) — pending a VPN run on a 0.2.0 gateway** (records the observed in-place retype policy back into `docs/Historian.md`; also discharges 06/U-2's full documented run). |
|
||||
|
||||
+43
-1
@@ -102,6 +102,12 @@ write path) and the `ContinuousHistorization` section (driver-value capture). Al
|
||||
**same** gateway — but only `ServerHistorian` carries the connection (endpoint/key/TLS); the other two
|
||||
source it from there.
|
||||
|
||||
> **Continuous-historization value quality is server-stamped.** The gateway's `WriteLiveValues` SQL path
|
||||
> **discards** the quality the recorder sends (a server-managed column; 0.2.0 Contracts C-002), so persisted
|
||||
> quality is stamped gateway-side regardless of what the `ContinuousHistorizationRecorder` provides (it
|
||||
> hardcodes OPC-DA Good, `192`). Capturing real per-node quality will not persist until the gateway honors
|
||||
> the field — see `GatewayHistorianValueWriter`'s remarks (archreview 06/C-7).
|
||||
|
||||
---
|
||||
|
||||
## Tag auto-provisioning (EnsureTags)
|
||||
@@ -129,6 +135,35 @@ for this node), which is the signal to check that `ServerHistorian:Enabled=true`
|
||||
> so a HistoryRead on it runs to the full `CallTimeout` (~30 s) before erroring. Auto-provisioning on deploy
|
||||
> keeps freshly-historized tags from hitting that slow path. See the `CallTimeout` row above.
|
||||
|
||||
### 0.2.0 DataType provisioning delta (archreview 06/U-7)
|
||||
|
||||
`EnsureTags` is contract-documented (HistorianGateway 0.2.0) as an **upsert** — *"creates or updates
|
||||
historian tags according to the given definitions"* — that maps per-tag onto AVEVA's
|
||||
`HistorianClient.EnsureTagAsync(definition) -> bool`. It is **not idempotent** (not routed through the
|
||||
idempotent retry pipeline) and **not skip-if-exists**.
|
||||
|
||||
The 0.1.0→0.2.0 client bump made `HistorianTagDefinition.DataType` **proto3-optional** (explicit presence).
|
||||
Under 0.1.0, the provisioner's `Boolean → Int1` mapping sent wire value `0`, indistinguishable from unset, so
|
||||
the gateway defaulted such tags to the SDK-default **Float**. Under 0.2.0 the same call carries explicit
|
||||
presence, so Boolean tags now provision as **Int1** (the pre-existing intent of `HistorianTypeMapper` — no
|
||||
mapper code change is needed or wanted). Consequences:
|
||||
|
||||
1. **New Boolean tags provision correctly as Int1.** This is the fix arriving as a silent, zero-diff
|
||||
semantic change from the bump.
|
||||
2. **Pre-bump Boolean tags exist historian-side as Float.** On the next deploy that dispatches provisioning
|
||||
for such a tag, the gateway is asked (via the upsert) to update it to Int1. The AVEVA-side outcome of an
|
||||
in-place analog retype is the one thing the contract does not pin; the candidate behaviors are
|
||||
(i) **accepted with tag versioning** (history preserved under the old version, new writes under Int1),
|
||||
(ii) **accepted as a plain retype**, or (iii) **rejected per-tag** → `TagOperationResult.Success=false`
|
||||
→ counted in the provisioner's `failed=N` dispatch tally. Only a live run distinguishes these — see the
|
||||
**retype probe** (`EnsureTags_type_change_outcome_is_observable`) in the [Live /run gate](#live-run-gate).
|
||||
*(This list is replaced with the observed answer after the first live run.)*
|
||||
3. **The failure mode is bounded and observable either way.** Provisioning is fire-and-forget and never
|
||||
blocks/fails a deploy; a per-tag rejection surfaces only as the tally's `failed` count. Values written to
|
||||
a still-Float tag by the recorder remain readable (Float supersets Int1's 0/1 range); nothing is lost
|
||||
pending a reconcile. A bulk retype sweep is **not** done in code — assess via the probe first, then decide
|
||||
whether reconciliation is a one-time operator action.
|
||||
|
||||
---
|
||||
|
||||
## HistoryRead behavior
|
||||
@@ -350,7 +385,14 @@ AVEVA Historian behind it healthy. Set `ServerHistorian:Enabled=true` with the c
|
||||
least one historized Galaxy tag. The gate is operator-driven — it is not part of the local docker-dev rig.
|
||||
The gateway-backed driver also ships an env-gated live suite (`Category=LiveIntegration`); see the
|
||||
`HISTGW_GATEWAY_ENDPOINT` / `HISTGW_GATEWAY_APIKEY` / `HISTGW_TEST_TAG` / `HISTGW_WRITE_SANDBOX_TAG` /
|
||||
`HISTGW_ALARM_SOURCE` env vars (it skips cleanly when they are absent).
|
||||
`HISTGW_BOOL_SANDBOX_TAG` / `HISTGW_ALARM_SOURCE` env vars (it skips cleanly when they are absent).
|
||||
`HISTGW_BOOL_SANDBOX_TAG` (e.g. `HistGW.LiveTest.BoolSandbox`, a **dedicated** writable Boolean tag —
|
||||
never the shared Float write sandbox) drives the 0.2.0 Boolean `EnsureTags` leg
|
||||
(`EnsureTags_boolean_sandbox_provisions_as_Int1`) and the retype probe
|
||||
(`EnsureTags_type_change_outcome_is_observable`) that resolve the
|
||||
[0.2.0 DataType provisioning delta](#020-datatype-provisioning-delta-archreview-06u-7) open question. Running
|
||||
the full suite against a **0.2.0** gateway (all six tests, none skipped) also discharges 06/U-2's
|
||||
"full documented run" for the historian suite.
|
||||
|
||||
See [AlarmHistorian.md](AlarmHistorian.md) for the alarm write path and
|
||||
[ServiceHosting.md](ServiceHosting.md) for the (external) HistorianGateway deployment.
|
||||
|
||||
+18
-1
@@ -35,14 +35,31 @@ public sealed class HistorianGatewayClientAdapter : IHistorianGatewayClient, IDi
|
||||
/// <param name="options">The bound <c>ServerHistorian</c> configuration (endpoint, key, TLS posture).</param>
|
||||
/// <param name="loggerFactory">Logger factory threaded into the package client's channel diagnostics.</param>
|
||||
/// <returns>A ready-to-use adapter whose underlying channel has not yet dialed the gateway.</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// <see cref="ServerHistorianOptions.Endpoint"/> is empty or not an absolute <c>http(s)</c> URI.
|
||||
/// Defense in depth (archreview 06/S-11): the Host's <c>ServerHistorianOptionsValidator</c> fails
|
||||
/// these configs at startup, but any caller that bypasses <c>ValidateOnStart</c> (tests, the live
|
||||
/// fixture, future non-Host wiring) still gets a named, config-key-carrying error here instead of
|
||||
/// the raw <see cref="UriFormatException"/> the bare <c>new Uri(...)</c> ctor throws.
|
||||
/// </exception>
|
||||
public static HistorianGatewayClientAdapter Create(ServerHistorianOptions options, ILoggerFactory loggerFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(loggerFactory);
|
||||
|
||||
// Fail fast with a named, actionable error rather than letting `new Uri("")` throw a raw
|
||||
// UriFormatException deep in the factory (archreview 06/S-11). The Endpoint is not a secret,
|
||||
// so it is safe to echo; the ApiKey is never surfaced.
|
||||
if (!Uri.TryCreate(options.Endpoint, UriKind.Absolute, out var endpointUri)
|
||||
|| (endpointUri.Scheme != Uri.UriSchemeHttp && endpointUri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"ServerHistorian:Endpoint must be an absolute http(s) URI (e.g. https://host:5222); got '{options.Endpoint}'.");
|
||||
}
|
||||
|
||||
var clientOptions = new HistorianGatewayClientOptions
|
||||
{
|
||||
Endpoint = new Uri(options.Endpoint),
|
||||
Endpoint = endpointUri,
|
||||
ApiKey = options.ApiKey,
|
||||
UseTls = options.UseTls,
|
||||
CaCertificatePath = options.CaCertificatePath,
|
||||
|
||||
+13
@@ -24,6 +24,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder;
|
||||
/// A success ack OR a store-forward-queued ack maps to <c>true</c>: a value the gateway
|
||||
/// durably queued must not be re-drained.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Quality is a documented-dead input</b> on the <c>WriteLiveValues</c> SQL path
|
||||
/// (HistorianGateway 0.2.0 Contracts C-002): <c>HistorianLiveValue.Quality</c> /
|
||||
/// <c>QualityDetail</c> are server-managed columns — any value sent is accepted on the wire and
|
||||
/// then silently discarded. We populate <c>Quality</c> anyway (see the mapping below): proto3
|
||||
/// transmits <c>0</c> when a field is left unset, and <c>0</c> is "Bad" in OPC-DA terms, so
|
||||
/// <em>not</em> setting it would send a <em>wrong</em> quality to any future gateway version that
|
||||
/// starts honoring the field — whereas the recorder's <c>192</c> ("Good") is the correct value.
|
||||
/// <b>Do NOT extend the recorder to capture real node quality expecting it to persist</b> — it
|
||||
/// will not, until the gateway honors the field.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class GatewayHistorianValueWriter : IHistorianValueWriter, IAsyncDisposable
|
||||
{
|
||||
@@ -60,6 +71,8 @@ public sealed class GatewayHistorianValueWriter : IHistorianValueWriter, IAsyncD
|
||||
var live = new HistorianLiveValue
|
||||
{
|
||||
NumericValue = value.Value,
|
||||
// Documented-dead input on the WriteLiveValues SQL path (0.2.0 Contracts C-002 —
|
||||
// server-managed, silently discarded). Populated deliberately; see the class <remarks>.
|
||||
Quality = value.Quality,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Fail-fast startup validator for <see cref="ServerHistorianOptions"/> (archreview 06/S-11),
|
||||
/// built on the shared <c>ZB.MOM.WW.Configuration</c> <see cref="OptionsValidatorBase{TOptions}"/>.
|
||||
/// Wired via <c>AddValidatedOptions</c> so a provably-crashing historian config fails host startup
|
||||
/// with a named, aggregated <c>OptionsValidationException</c> instead of the raw
|
||||
/// <see cref="System.UriFormatException"/> the gateway client factory's <c>new Uri(...)</c> would
|
||||
/// otherwise throw at first resolution (a crash-loop under a service/docker restart policy).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Consumer-gated, not section-gated.</b> The load-bearing question is not "is
|
||||
/// <c>ServerHistorian</c> enabled?" but "is its gateway connection <em>consumed</em> by anything
|
||||
/// enabled?". The alarm-history sink (<c>AlarmHistorian:Enabled=true</c>) sources its
|
||||
/// endpoint/key/TLS from the <c>ServerHistorian</c> section independently of
|
||||
/// <see cref="ServerHistorianOptions.Enabled"/>, so it must also gate this check — otherwise the
|
||||
/// <c>AlarmHistorian:Enabled=true</c> / <c>ServerHistorian:Enabled=false</c> corner crashes with
|
||||
/// zero warning. Continuous historization is already co-gated on
|
||||
/// <see cref="ServerHistorianOptions.Enabled"/> in the Host, so <c>Enabled</c> covers it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Fail tier = provably-crashing configs only.</b> Only an empty / non-absolute / non-http(s)
|
||||
/// <c>Endpoint</c> fails here (it throws in the factory). Empty <c>ApiKey</c> and non-positive
|
||||
/// <c>MaxTieClusterOverfetch</c> degrade rather than crash (the gateway rejects calls / the node
|
||||
/// manager surfaces a Bad read), so they stay operator warnings in
|
||||
/// <see cref="ServerHistorianOptions.Validate"/>. The <c>Endpoint</c> value is not a secret and
|
||||
/// is echoed to make the error actionable; the <c>ApiKey</c> is never surfaced.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ServerHistorianOptionsValidator : OptionsValidatorBase<ServerHistorianOptions>
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
/// <summary>Creates the validator over the app configuration (read to resolve the consumer gate).</summary>
|
||||
/// <param name="configuration">The app configuration; used to read <c>AlarmHistorian:Enabled</c>.</param>
|
||||
public ServerHistorianOptionsValidator(IConfiguration configuration) =>
|
||||
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Validate(ValidationBuilder builder, ServerHistorianOptions options)
|
||||
{
|
||||
var alarmHistorianEnabled = _configuration.GetValue<bool>("AlarmHistorian:Enabled");
|
||||
var consumed = options.Enabled || alarmHistorianEnabled;
|
||||
|
||||
// A section nobody consumes may legitimately be empty (the docker-dev default) — no checks.
|
||||
if (!consumed) return;
|
||||
|
||||
var endpointValid = Uri.TryCreate(options.Endpoint, UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
|
||||
|
||||
// Name the DRIVING section(s) so the operator knows why an empty/malformed endpoint fails —
|
||||
// especially in the alarm-only corner where ServerHistorian itself is disabled.
|
||||
var reason = options.Enabled
|
||||
? "required because ServerHistorian:Enabled=true"
|
||||
: "required because AlarmHistorian:Enabled=true sources its gateway connection (endpoint/key/TLS) from the ServerHistorian section";
|
||||
|
||||
builder.RequireThat(
|
||||
endpointValid,
|
||||
$"ServerHistorian:Endpoint is empty or not an absolute http(s) URI ('{options.Endpoint}') — {reason}.");
|
||||
}
|
||||
}
|
||||
@@ -117,11 +117,32 @@ if (hasDriver)
|
||||
// the read-side GatewayHistorianDataSource to stop owning + disposing its client (regressing the read
|
||||
// cutover), and a second channel to a co-located sidecar is cheap (the gateway pools the historian
|
||||
// sessions server-side).
|
||||
// Fail-fast startup validation for the ServerHistorian section (archreview 06/S-11). A
|
||||
// provably-crashing config — an enabled historian (or an enabled AlarmHistorian, which sources its
|
||||
// gateway connection from THIS section) with an empty/malformed Endpoint — now fails host start with
|
||||
// a named OptionsValidationException instead of a raw UriFormatException crash-loop out of the gateway
|
||||
// client factory. Driver-block-only (mirrors OpcUaApplicationHostOptionsValidator below): admin-only
|
||||
// nodes never build the historian singletons, so they must not be failed by a section they never
|
||||
// consume. ApiKey/MaxTieClusterOverfetch stay warnings (they degrade, not crash) — see Validate().
|
||||
builder.Services.AddValidatedOptions<ServerHistorianOptions, ServerHistorianOptionsValidator>(
|
||||
builder.Configuration, ServerHistorianOptions.SectionName);
|
||||
|
||||
var serverHistorianOptions = builder.Configuration
|
||||
.GetSection(ServerHistorianOptions.SectionName).Get<ServerHistorianOptions>()
|
||||
?? new ServerHistorianOptions();
|
||||
foreach (var warning in serverHistorianOptions.Validate())
|
||||
Log.Warning("ServerHistorian misconfiguration detected at startup: {Warning}", warning);
|
||||
|
||||
// Residual "unusual but valid" corner (archreview 06/S-11): an alarm-history-only deployment
|
||||
// (AlarmHistorian:Enabled=true while ServerHistorian:Enabled=false) legitimately sources its gateway
|
||||
// connection from the disabled ServerHistorian section. That is allowed (the endpoint was already
|
||||
// validated above), but log it so the mode is not a silent surprise: HistoryReads stay GoodNoData,
|
||||
// and a wrong endpoint/key surfaces only as SQLite store-and-forward retry/dead-letter growth.
|
||||
if (builder.Configuration.GetValue<bool>("AlarmHistorian:Enabled") && !serverHistorianOptions.Enabled)
|
||||
Log.Warning(
|
||||
"AlarmHistorian is enabled but ServerHistorian is disabled — the alarm-history sink is sourcing its " +
|
||||
"gateway connection (endpoint/key/TLS) from the disabled ServerHistorian section; HistoryReads stay GoodNoData.");
|
||||
|
||||
builder.Services.AddAlarmHistorian(
|
||||
builder.Configuration,
|
||||
(_, sp) => GatewayHistorian.CreateAlarmWriter(serverHistorianOptions, sp));
|
||||
|
||||
@@ -41,7 +41,14 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
|
||||
{
|
||||
/// <summary>OPC-DA quality byte recorded for mux-fanned values (the mux drops quality; driver-published
|
||||
/// values are Good by the same convention the scripted-alarm host applies).</summary>
|
||||
/// values are Good by the same convention the scripted-alarm host applies).
|
||||
/// <para>
|
||||
/// Note (archreview 06/C-7): the historian gateway's <c>WriteLiveValues</c> SQL path <b>discards</b>
|
||||
/// quality (server-managed column, 0.2.0 Contracts C-002 — see
|
||||
/// <c>GatewayHistorianValueWriter</c>'s remarks). This constant is therefore what a future
|
||||
/// quality-honoring gateway path <em>would</em> receive; capturing real per-node quality here will
|
||||
/// NOT persist until the gateway honors the field.
|
||||
/// </para></summary>
|
||||
public const ushort GoodQuality = 192;
|
||||
|
||||
private const string DrainTimerKey = "drain";
|
||||
|
||||
@@ -69,7 +69,16 @@ public sealed class ServerHistorianOptions
|
||||
public int MaxTieClusterOverfetch { get; init; } = 65536;
|
||||
|
||||
/// <summary>Returns operator-facing misconfiguration warnings for an <c>Enabled</c> historian
|
||||
/// (empty when disabled or correctly configured). Pure — the registration logs each entry.</summary>
|
||||
/// (empty when disabled or correctly configured). Pure — the registration logs each entry.
|
||||
/// <para>
|
||||
/// These are <b>warnings only</b>. The Host layers a fail-fast startup validator
|
||||
/// (<c>ServerHistorianOptionsValidator</c>, wired via <c>AddValidatedOptions</c>) on top of this
|
||||
/// for the <b>provably-crashing</b> subset — an empty/malformed <c>Endpoint</c> that is consumed
|
||||
/// by an enabled historian or an enabled <c>AlarmHistorian</c> — so that config fails host start
|
||||
/// with a named error instead of a raw <c>UriFormatException</c> crash-loop (archreview 06/S-11).
|
||||
/// The empty-<c>ApiKey</c> / non-positive-<c>MaxTieClusterOverfetch</c> entries stay warnings
|
||||
/// because they degrade rather than crash.
|
||||
/// </para></summary>
|
||||
/// <returns>Zero or more human-readable warning messages (never carrying secret values).</returns>
|
||||
public IReadOnlyList<string> Validate()
|
||||
{
|
||||
@@ -77,6 +86,13 @@ public sealed class ServerHistorianOptions
|
||||
if (!Enabled) return warnings;
|
||||
if (string.IsNullOrWhiteSpace(Endpoint))
|
||||
warnings.Add("ServerHistorian:Endpoint is empty while the historian is enabled — the read client has no gateway address to dial.");
|
||||
// archreview 06/S-11 warning parity: a non-empty but unparseable / non-absolute-http(s) Endpoint
|
||||
// is just as fatal (it throws in the gateway client factory). Warn on it here so the Runtime-side
|
||||
// registration logs describe malformed endpoints too, consistent with the Host's fail-fast
|
||||
// ServerHistorianOptionsValidator (which turns this same condition into a startup failure).
|
||||
else if (!Uri.TryCreate(Endpoint, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
warnings.Add($"ServerHistorian:Endpoint ('{Endpoint}') is not an absolute http(s) URI while the historian is enabled — the read client cannot dial it (e.g. https://host:5222).");
|
||||
if (string.IsNullOrWhiteSpace(ApiKey))
|
||||
warnings.Add("ServerHistorian:ApiKey is empty while the historian is enabled — the gateway gRPC surface will reject unauthenticated calls.");
|
||||
// MaxTieClusterOverfetch is intentionally checked AFTER the Enabled early-return above:
|
||||
|
||||
+29
@@ -31,6 +31,35 @@ public sealed class GatewayTagProvisionerTests
|
||||
Assert.Equal(0, result.Skipped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// archreview 06/U-7 — pins the 0.2.0 Boolean→Int1 provisioning semantics at TWO levels. The
|
||||
/// 0.1.0→0.2.0 client bump made <c>HistorianTagDefinition.DataType</c> proto3-optional (explicit
|
||||
/// presence): under 0.1.0 the provisioner's <c>Int1</c> (wire 0) was indistinguishable from unset,
|
||||
/// so the gateway defaulted Boolean tags to Float; under 0.2.0 the same call carries explicit
|
||||
/// presence and provisions Int1 (the intended behavior). Asserting BOTH the mapped type AND
|
||||
/// <c>HasDataType</c> compile-pins the package floor (the <c>HasDataType</c>/<c>ClearDataType</c>
|
||||
/// members exist only on the 0.2.0 proto3-optional contract — a downgrade breaks the build) and
|
||||
/// assert-pins that the provisioner always sends explicit presence (a future contract change that
|
||||
/// stopped sending it would break the assert). This is a pin, not a fix — it passes on current code.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Boolean_definition_carries_explicit_Int1_presence()
|
||||
{
|
||||
var fake = new FakeHistorianGatewayClient { EnsureTagsResult = new TagOperationResults() };
|
||||
var p = Provisioner(fake);
|
||||
|
||||
await p.EnsureTagsAsync(
|
||||
new[] { new HistorianTagProvisionRequest("Pump1.Run", DriverDataType.Boolean, null, null) },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var defs = fake.LastEnsureDefinitions!;
|
||||
Assert.Single(defs);
|
||||
Assert.Equal(HistorianDataType.Int1, defs[0].DataType);
|
||||
// 0.2.0 proto3-optional presence witness — explicit presence is what flips the gateway from its
|
||||
// SDK-default Float to the requested Int1. Without this, a Boolean tag would provision as Float.
|
||||
Assert.True(defs[0].HasDataType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Maps_metadata_and_coalesces_null_metadata_to_empty()
|
||||
{
|
||||
|
||||
+33
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
|
||||
@@ -21,6 +22,38 @@ public sealed class HistorianGatewayClientAdapterTests
|
||||
Assert.NotNull(adapter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// archreview 06/S-11 — an enabled historian with an empty <c>Endpoint</c> must fail with a
|
||||
/// named, config-key-carrying <see cref="InvalidOperationException"/> (defense-in-depth for any
|
||||
/// caller that bypasses the Host's <c>ServerHistorianOptionsValidator</c>), not the raw
|
||||
/// <see cref="UriFormatException"/> that <c>new Uri("")</c> throws deep in the factory. Current
|
||||
/// code throws <see cref="UriFormatException"/> → this is the failing-first (RED) repro.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Create_with_empty_endpoint_throws_named_config_error()
|
||||
{
|
||||
var opts = new ServerHistorianOptions { Enabled = true, Endpoint = "", ApiKey = "histgw_x_y" };
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(
|
||||
() => HistorianGatewayClientAdapter.Create(opts, NullLoggerFactory.Instance));
|
||||
ex.Message.ShouldContain("ServerHistorian:Endpoint");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// archreview 06/S-11 companion — a non-empty but unparseable / non-absolute <c>Endpoint</c> must
|
||||
/// also fail with the same named <see cref="InvalidOperationException"/>, not a raw
|
||||
/// <see cref="UriFormatException"/>. RED on current code.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Create_with_malformed_endpoint_throws_named_config_error()
|
||||
{
|
||||
var opts = new ServerHistorianOptions { Enabled = true, Endpoint = "not a uri", ApiKey = "histgw_x_y" };
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(
|
||||
() => HistorianGatewayClientAdapter.Create(opts, NullLoggerFactory.Instance));
|
||||
ex.Message.ShouldContain("ServerHistorian:Endpoint");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_builds_GatewayHistorianDataSource()
|
||||
{
|
||||
|
||||
+7
@@ -22,6 +22,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Live;
|
||||
/// <item><c>HISTGW_GATEWAY_APIKEY</c> — the <c>histgw_<id>_<secret></c> key (must carry <c>historian:read</c> + <c>historian:write</c> scopes). Required; absent ⇒ all tests skip.</item>
|
||||
/// <item><c>HISTGW_TEST_TAG</c> — an existing Galaxy / historian tag for the read round-trip.</item>
|
||||
/// <item><c>HISTGW_WRITE_SANDBOX_TAG</c> — a Float sandbox tag the write round-trip may <c>EnsureTags</c> + write (e.g. <c>HistGW.LiveTest.Sandbox</c>).</item>
|
||||
/// <item><c>HISTGW_BOOL_SANDBOX_TAG</c> — a writable Boolean sandbox tag (e.g. <c>HistGW.LiveTest.BoolSandbox</c>) for the 0.2.0 Boolean <c>EnsureTags</c> leg + the retype probe (archreview 06/U-7). Dedicated — never the shared write sandbox.</item>
|
||||
/// <item><c>HISTGW_ALARM_SOURCE</c> — a source name for the alarm <c>SendEvent</c> → <c>ReadEvents</c> round-trip.</item>
|
||||
/// <item><c>HISTGW_GATEWAY_ALLOW_UNTRUSTED</c> — <c>true</c> to accept a self-signed dev cert (optional).</item>
|
||||
/// </list>
|
||||
@@ -44,6 +45,7 @@ public sealed class GatewayLiveFixture
|
||||
private const string EnvApiKey = "HISTGW_GATEWAY_APIKEY";
|
||||
private const string EnvTestTag = "HISTGW_TEST_TAG";
|
||||
private const string EnvWriteSandboxTag = "HISTGW_WRITE_SANDBOX_TAG";
|
||||
private const string EnvBoolSandboxTag = "HISTGW_BOOL_SANDBOX_TAG";
|
||||
private const string EnvAlarmSource = "HISTGW_ALARM_SOURCE";
|
||||
private const string EnvAllowUntrusted = "HISTGW_GATEWAY_ALLOW_UNTRUSTED";
|
||||
|
||||
@@ -68,6 +70,7 @@ public sealed class GatewayLiveFixture
|
||||
_apiKey = Trimmed(EnvApiKey);
|
||||
TestTag = Trimmed(EnvTestTag);
|
||||
WriteSandboxTag = Trimmed(EnvWriteSandboxTag);
|
||||
BoolSandboxTag = Trimmed(EnvBoolSandboxTag);
|
||||
AlarmSource = Trimmed(EnvAlarmSource);
|
||||
_allowUntrusted = string.Equals(
|
||||
Trimmed(EnvAllowUntrusted), "true", StringComparison.OrdinalIgnoreCase);
|
||||
@@ -105,6 +108,10 @@ public sealed class GatewayLiveFixture
|
||||
/// <summary>The Float sandbox tag for the write round-trip (<c>HISTGW_WRITE_SANDBOX_TAG</c>); null when unset.</summary>
|
||||
public string? WriteSandboxTag { get; }
|
||||
|
||||
/// <summary>The dedicated Boolean sandbox tag for the 0.2.0 Boolean EnsureTags leg + retype probe
|
||||
/// (<c>HISTGW_BOOL_SANDBOX_TAG</c>, archreview 06/U-7); null when unset.</summary>
|
||||
public string? BoolSandboxTag { get; }
|
||||
|
||||
/// <summary>The source name for the alarm round-trip (<c>HISTGW_ALARM_SOURCE</c>); null when unset.</summary>
|
||||
public string? AlarmSource { get; }
|
||||
|
||||
|
||||
+120
@@ -253,4 +253,124 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
|
||||
HistorianWriteOutcome.Ack,
|
||||
"the alarm SendEvent must be acked (the AlarmEventMapper must NOT set the wire event Id — the gateway rejects a client-supplied id).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// archreview 06/U-7 — Boolean provisioning leg on the 0.2.0 contract. Through the <b>real</b>
|
||||
/// <see cref="GatewayTagProvisioner"/>, <c>EnsureTags</c> a <see cref="DriverDataType.Boolean"/>
|
||||
/// request for the dedicated Boolean sandbox tag (mapped to <c>Int1</c> with explicit proto3
|
||||
/// presence), assert it is ensured (not failed), then <c>WriteLiveValues</c> a <c>1.0</c> and read
|
||||
/// it back over a wide window. This retires "the live EnsureTags leg has never run on 0.2.0" for
|
||||
/// the Boolean path. The ±12h read window + timezone caveat mirror <see cref="Write_then_read_on_sandbox_tag"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "LiveIntegration")]
|
||||
public async Task EnsureTags_boolean_sandbox_provisions_as_Int1()
|
||||
{
|
||||
if (_fx.NotConfigured) Assert.Skip(_fx.SkipReason!);
|
||||
if (_fx.BoolSandboxTag is null)
|
||||
Assert.Skip("Skipped: set HISTGW_BOOL_SANDBOX_TAG to a writable Boolean sandbox tag (e.g. HistGW.LiveTest.BoolSandbox) to run the 0.2.0 Boolean EnsureTags leg.");
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var tag = _fx.BoolSandboxTag;
|
||||
|
||||
// EnsureTags (Boolean → Int1) through the REAL provisioner seam (the same code path deploys use).
|
||||
await using var writeClient = _fx.CreateClient();
|
||||
var provisioner = new GatewayTagProvisioner(writeClient, NullLogger<GatewayTagProvisioner>.Instance);
|
||||
var provision = await provisioner.EnsureTagsAsync(
|
||||
new[] { new HistorianTagProvisionRequest(tag, DriverDataType.Boolean, null, "OtOpcUa live validation Boolean sandbox") },
|
||||
ct);
|
||||
|
||||
provision.Ensured.ShouldBe(1,
|
||||
"the Boolean sandbox tag must be ensured (needs the gateway on 0.2.0 contracts + an API key carrying historian:tags:write).");
|
||||
provision.Failed.ShouldBe(0);
|
||||
|
||||
// WriteLiveValues a Boolean-as-1.0 and read it back (SQL live path can lag a flush cadence → poll).
|
||||
const ushort goodQuality = 192; // OPC-DA "Good" floor.
|
||||
var writeUtc = DateTime.UtcNow;
|
||||
const double written = 1.0;
|
||||
var valueWriter = new GatewayHistorianValueWriter(writeClient, NullLogger<GatewayHistorianValueWriter>.Instance);
|
||||
var acked = await valueWriter.WriteLiveValuesAsync(
|
||||
tag, new[] { new HistorizationValue(writeUtc, written, goodQuality) }, ct);
|
||||
acked.ShouldBeTrue("the Boolean live write must be acked (gateway RuntimeDb:Enabled=true + EnsureTags-provisioned tag).");
|
||||
|
||||
await using var dataSource = _fx.CreateDataSource();
|
||||
DataValueSnapshot? hit = null;
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15);
|
||||
do
|
||||
{
|
||||
var read = await dataSource.ReadRawAsync(
|
||||
tag, writeUtc - TimeSpan.FromHours(12), writeUtc + TimeSpan.FromHours(12), maxValuesPerNode: 50_000, ct);
|
||||
hit = read.Samples.FirstOrDefault(s => s.Value is double d && Math.Abs(d - written) < 0.5);
|
||||
if (hit is not null) break;
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), ct);
|
||||
}
|
||||
while (DateTime.UtcNow < deadline);
|
||||
|
||||
hit.ShouldNotBeNull($"the written Boolean sample ({written}) should read back from '{tag}'.");
|
||||
TestContext.Current.SendDiagnosticMessage(
|
||||
$"Boolean round-trip: EnsureTags(Int1) + WriteLiveValues '{tag}'={written} → read back at {hit!.SourceTimestampUtc:O}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// archreview 06/U-7 retype probe — answers the assessment note's open question: what does the
|
||||
/// deployed AVEVA Historian do when <c>EnsureTags</c> (an <b>upsert</b>) is asked to change an
|
||||
/// existing tag's type? Against the <b>dedicated Boolean sandbox tag only</b> (never the shared
|
||||
/// Float write sandbox), EnsureTags first as <c>Float</c> then as <c>Int1</c> and assert the second
|
||||
/// call's per-tag outcome is <em>deterministic and observable</em> — either <c>Success=true</c>
|
||||
/// (retype / tag-versioning accepted) or <c>Success=false</c> with a non-empty <c>Error</c> (mapped
|
||||
/// to the provisioner's <c>failed</c> tally). The assertion accepts both because the contract
|
||||
/// guarantees only the observability, not the policy; the observed behavior is emitted as a
|
||||
/// diagnostic so the run documents which of accepted-versioned / accepted-retyped / rejected the
|
||||
/// historian exhibits (fed back into docs/Historian.md after the first live run).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "LiveIntegration")]
|
||||
public async Task EnsureTags_type_change_outcome_is_observable()
|
||||
{
|
||||
if (_fx.NotConfigured) Assert.Skip(_fx.SkipReason!);
|
||||
if (_fx.BoolSandboxTag is null)
|
||||
Assert.Skip("Skipped: set HISTGW_BOOL_SANDBOX_TAG to a dedicated Boolean sandbox tag to run the retype probe.");
|
||||
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var tag = _fx.BoolSandboxTag;
|
||||
|
||||
await using var client = _fx.CreateClient();
|
||||
|
||||
// First establish the tag as Float, then ask EnsureTags to retype it to Int1 (Boolean).
|
||||
await client.EnsureTagsAsync(
|
||||
new[]
|
||||
{
|
||||
new HistorianTagDefinition
|
||||
{
|
||||
TagName = tag,
|
||||
DataType = HistorianDataType.Float,
|
||||
EngineeringUnit = string.Empty,
|
||||
Description = "OtOpcUa live validation retype probe (stage 1: Float)",
|
||||
},
|
||||
},
|
||||
ct);
|
||||
|
||||
var retype = await client.EnsureTagsAsync(
|
||||
new[]
|
||||
{
|
||||
new HistorianTagDefinition
|
||||
{
|
||||
TagName = tag,
|
||||
DataType = HistorianDataType.Int1,
|
||||
EngineeringUnit = string.Empty,
|
||||
Description = "OtOpcUa live validation retype probe (stage 2: Int1)",
|
||||
},
|
||||
},
|
||||
ct);
|
||||
|
||||
var outcome = retype.Results.ShouldHaveSingleItem();
|
||||
var observable = outcome.Success || !string.IsNullOrEmpty(outcome.Error);
|
||||
observable.ShouldBeTrue(
|
||||
"the retype outcome must be deterministic + observable: either Success=true (retype/version accepted) " +
|
||||
"or Success=false with a non-empty Error (mapped to the provisioner's failed tally).");
|
||||
|
||||
TestContext.Current.SendDiagnosticMessage(
|
||||
$"retype probe '{tag}' Float→Int1: Success={outcome.Success}, Error='{outcome.Error}'. " +
|
||||
"(records the deployed historian's in-place analog-retype policy — feed this back into docs/Historian.md).");
|
||||
}
|
||||
}
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// archreview 06/S-11 — verifies the net-new <see cref="ServerHistorianOptionsValidator"/> (built on
|
||||
/// the shared <c>ZB.MOM.WW.Configuration</c> <c>OptionsValidatorBase</c>/<c>ValidationBuilder</c>).
|
||||
/// The fail tier is deliberately narrow: only <b>provably-crashing</b> configs fail — an enabled
|
||||
/// historian (or an enabled <c>AlarmHistorian</c>, which sources its gateway connection from the
|
||||
/// <c>ServerHistorian</c> section) with an empty / non-absolute / non-http(s) <c>Endpoint</c>, i.e.
|
||||
/// exactly the configs that would otherwise throw <see cref="System.UriFormatException"/> deep in the
|
||||
/// gateway client factory at startup. Empty <c>ApiKey</c> / non-positive <c>MaxTieClusterOverfetch</c>
|
||||
/// stay operator warnings in <see cref="ServerHistorianOptions.Validate"/> (they degrade, not crash).
|
||||
/// </summary>
|
||||
public sealed class ServerHistorianOptionsValidatorTests
|
||||
{
|
||||
private static ServerHistorianOptionsValidator Sut(bool alarmHistorianEnabled = false)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AlarmHistorian:Enabled"] = alarmHistorianEnabled ? "true" : "false",
|
||||
})
|
||||
.Build();
|
||||
return new ServerHistorianOptionsValidator(configuration);
|
||||
}
|
||||
|
||||
/// <summary>Enabled historian with an empty endpoint fails (would crash-loop in the factory).</summary>
|
||||
[Fact]
|
||||
public void Enabled_with_empty_endpoint_fails()
|
||||
{
|
||||
var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "" });
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint"));
|
||||
}
|
||||
|
||||
/// <summary>Enabled with a relative (non-absolute) URI fails.</summary>
|
||||
[Fact]
|
||||
public void Enabled_with_relative_uri_fails()
|
||||
{
|
||||
var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "host:5222" });
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint"));
|
||||
}
|
||||
|
||||
/// <summary>Enabled with a non-http(s) scheme (ftp) fails.</summary>
|
||||
[Fact]
|
||||
public void Enabled_with_bad_scheme_fails()
|
||||
{
|
||||
var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "ftp://host:5222" });
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint"));
|
||||
}
|
||||
|
||||
/// <summary>Enabled with a valid absolute https endpoint succeeds.</summary>
|
||||
[Fact]
|
||||
public void Enabled_with_valid_https_endpoint_succeeds()
|
||||
{
|
||||
var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = true, Endpoint = "https://host:5222" });
|
||||
|
||||
result.Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>A disabled section may legitimately be empty — no failure.</summary>
|
||||
[Fact]
|
||||
public void Disabled_with_empty_endpoint_succeeds()
|
||||
{
|
||||
var result = Sut().Validate(null, new ServerHistorianOptions { Enabled = false, Endpoint = "" });
|
||||
|
||||
result.Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The residual corner: <c>ServerHistorian:Enabled=false</c> but <c>AlarmHistorian:Enabled=true</c>
|
||||
/// (which sources its connection from the ServerHistorian section) with an empty endpoint fails —
|
||||
/// and the message names BOTH sections so the operator understands why a disabled section is required.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Disabled_but_alarm_historian_enabled_with_empty_endpoint_fails_naming_both_sections()
|
||||
{
|
||||
var result = Sut(alarmHistorianEnabled: true)
|
||||
.Validate(null, new ServerHistorianOptions { Enabled = false, Endpoint = "" });
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain(f =>
|
||||
f.Contains("ServerHistorian:Endpoint") && f.Contains("AlarmHistorian:Enabled"));
|
||||
}
|
||||
|
||||
/// <summary>Alarm-only mode with a valid endpoint succeeds.</summary>
|
||||
[Fact]
|
||||
public void Disabled_but_alarm_historian_enabled_with_valid_endpoint_succeeds()
|
||||
{
|
||||
var result = Sut(alarmHistorianEnabled: true)
|
||||
.Validate(null, new ServerHistorianOptions { Enabled = false, Endpoint = "https://host:5222" });
|
||||
|
||||
result.Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wiring guard (the "register-AND-consume" trap): resolving <c>IOptions<ServerHistorianOptions>.Value</c>
|
||||
/// after <see cref="ServiceCollectionExtensions.AddValidatedOptions{TOptions,TValidator}"/> throws
|
||||
/// <see cref="OptionsValidationException"/> for an enabled+empty section — proving the validator is
|
||||
/// attached to the options pipeline (not merely defined). <c>ValidateOnStart</c> reaching host start
|
||||
/// is already proven by the LDAP precedent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddValidatedOptions_wiring_throws_on_enabled_empty_endpoint()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ServerHistorian:Enabled"] = "true",
|
||||
["ServerHistorian:Endpoint"] = "",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
services.AddValidatedOptions<ServerHistorianOptions, ServerHistorianOptionsValidator>(
|
||||
configuration, ServerHistorianOptions.SectionName);
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var ex = Should.Throw<OptionsValidationException>(
|
||||
() => _ = provider.GetRequiredService<IOptions<ServerHistorianOptions>>().Value);
|
||||
ex.Failures.ShouldContain(f => f.Contains("ServerHistorian:Endpoint"));
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,16 @@ public sealed class ServerHistorianOptionsTests
|
||||
Assert.Contains(w, m => m.Contains("Endpoint"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enabled_with_malformed_endpoint_warns()
|
||||
{
|
||||
// archreview 06/S-11 warning parity: a non-empty but unparseable / non-absolute Endpoint warns
|
||||
// too, so the Runtime-side registration logs describe malformed endpoints (not just empty ones),
|
||||
// keeping this surface consistent with the Host's fail-fast ServerHistorianOptionsValidator.
|
||||
var w = new ServerHistorianOptions { Enabled = true, Endpoint = "not a uri", ApiKey = "histgw_x_y" }.Validate();
|
||||
Assert.Contains(w, m => m.Contains("Endpoint"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enabled_without_apikey_warns()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user