docs(historian): Phase C server-side HistoryRead design

This commit is contained in:
Joseph Doherty
2026-06-14 18:46:43 -04:00
parent c9a0f627ea
commit 5e42c1bc38
@@ -0,0 +1,229 @@
# Galaxy Phase C — Server-side OPC UA HistoryRead — Design
**Date:** 2026-06-14
**Status:** Approved (brainstorming) — ready for implementation planning
**Branch:** `feat/galaxy-phase-c-historian` off master `c9a0f627`
**Parent design:** `docs/plans/2026-06-12-galaxy-standard-driver-design.md` (workstream 3 / Phase C).
**Follow-up:** `pending.md` open item #2 ("Phase C — server-side `HistoryRead` backend over the Wonderware reader").
## Goal
Make the OtOpcUa server answer OPC UA **HistoryRead** for an authored equipment tag flagged
historized — driver-agnostically — by dispatching to the already-built, already-hardened
server-side `IHistorianDataSource` (the Wonderware TCP client). No mxaccessgw history RPC; no EF
migration. A historized tag's raw / processed / at-time samples and an equipment folder's
event-history come back to any OPC UA client (e.g. `Client.CLI historyread`).
## What already exists (grounded in code, 2026-06-14)
Phase C is **mostly wiring** — the read surface is built:
- **`IHistorianDataSource`** (`src/Core/.../Core.Abstractions/Historian/IHistorianDataSource.cs`) —
the server-side read seam, with **required** `ReadRawAsync` / `ReadProcessedAsync` /
`ReadAtTimeAsync` / `ReadEventsAsync` / `GetHealthSnapshot`. Returns
`HistoryReadResult(IReadOnlyList<DataValueSnapshot> Samples, byte[]? ContinuationPoint)` and
`HistoricalEventsResult(IReadOnlyList<HistoricalEvent> Events, byte[]? ContinuationPoint)`
(both in `IHistoryProvider.cs`). `DataValueSnapshot(object? Value, uint StatusCode,
DateTime? SourceTimestampUtc, DateTime ServerTimestampUtc)` mirrors `Opc.Ua.DataValue`.
- **`WonderwareHistorianClient`** (`Driver.Historian.Wonderware.Client`, .NET 10) **implements
`IHistorianDataSource`** over a hardened TCP/TLS/shared-secret channel. The **Host is the only
project that references it** (same as the alarm-historian writer).
- **`DriverAttributeInfo.IsHistorized`** already exists; Galaxy + OpcUaClient drivers populate it
(informational — the equipment-tag path does not consume `DriverAttributeInfo`, it carries
`TagConfig`; see below).
- **The Phase B `alarm`-object-in-`TagConfig` carrier** — `EquipmentTagPlan.Alarm` parsed by
`Phase7Composer.ExtractTagAlarm` and the byte-parity `DeploymentArtifact.ExtractTagAlarm`. This is
the **exact precedent** for carrying `isHistorized` / `historianTagname`.
- **The wiring pattern** — the node manager exposes settable props (`AlarmCommandRouter`,
`NodeWriteGateway`); `OtOpcUaSdkServer.Set*` methods set them; the Host's
`OtOpcUaServerHostedService.StartAsync` wires them post-start.
- **The DI pattern** — `AddAlarmHistorian(config, writerFactory)` (Null default via
`AddOtOpcUaRuntime`'s `TryAddSingleton`, config-gated real impl), Host supplies the Wonderware
client factory in `Program.cs`.
- **`EnsureFolderIsEventNotifier`** already promotes alarm-owning equipment folders to
`EventNotifiers.SubscribeToEvents` + `AddRootNotifier` (guarded by `_notifierFolders`).
## What's missing (the Phase C work)
1. **No `HistoryRead` override.** `OtOpcUaNodeManager : CustomNodeManager2` overrides only
`CreateAddressSpace`. `Historizing` is hardcoded `false` in `EnsureVariable` (`:846`) and
`CreateVariable` (`:977`); no `AccessLevels.HistoryRead` bit, no event-notifier `HistoryRead` bit.
2. **No carrier for `isHistorized` / `historianTagname`** through `EquipmentTagPlan` → artifact →
materialization.
3. **No `IHistorianDataSource` registration / Null default / node-manager wiring / config section.**
## Locked decisions (this Phase C brainstorming)
| Decision | Choice |
|---|---|
| Read variants | **All four** — Raw, Processed, AtTime over historized **variable** nodes; **Events** over historized **equipment-folder** event-notifier nodes. (User-selected.) |
| Authoring | **Raw `TagConfig` JSON**`"isHistorized": true` (+ optional `"historianTagname"`) rides in the existing `TagConfig` blob, mirroring the Phase B `alarm` object. **No UI control, no EF migration.** Galaxy already uses the raw-JSON tag editor. (User-selected.) |
| Tagname mapping | Default = the tag's driver **`FullName`** (already on `EquipmentTagPlan`); optional explicit override via `TagConfig.historianTagname`. |
| Async bridge | The override is **synchronous** and blocks on the async data source with its own call-timeout. **No actor** — HistoryRead is read-only and is **not** invoked under the node-manager `Lock` (unlike `OnWriteValue`), so blocking is safe and won't freeze the address space. |
| Redundancy / auth | **No Primary gate** (reads are safe from any node). Auth is the standard `NodePermissions.HistoryRead` enforced by the SDK via the `AccessLevels.HistoryRead` bit we set at materialization. |
| Graceful degrade | A **`NullHistorianDataSource`** (returns empty `Samples` / `Events`) is the default. A historized node with the Null source returns **`GoodNoData`** (empty), not an error spike (parent-design invariant). A **non-historized** node returns `BadHistoryOperationUnsupported`. |
| Continuation points | **Single-shot** read honoring the client's `NumValuesPerNode`; `results[i].ContinuationPoint = null`. `releaseContinuationPoints == true``Good` (we never issue one). Server-managed paging is an explicit follow-up. |
| Config | New **`ServerHistorian`** appsettings section (`Host`/`Port`/`SharedSecret`/`UseTls`/`ServerCertThumbprint`/`Enabled`) + `AddServerHistorian(config, factory)`, mirroring `AlarmHistorian`. Independent of the alarm-write path (its own enable + its own client instance to the same sidecar). |
### Rejected / out of scope
- **Server-object fleet-wide event history.** The Server object belongs to the SDK's
`CoreNodeManager`, not ours; the `MasterNodeManager` routes HistoryRead by node ownership. We expose
event history only on **our** equipment-folder event-notifiers (per-equipment `sourceName`). Clean
ownership boundary.
- **An historian read actor** (mirroring `HistorianAdapterActor`). The write path needs an actor for
fire-and-forget + redundancy gating; reads are synchronous, ungated, and ownerless — a direct
blocking bridge is simpler and correct.
- **Continuation-point paging / `HistoryUpdate` / live UI authoring control.** Follow-ups.
## Carrier seam (mirrors Phase B `alarm` exactly)
```
Tag.TagConfig JSON { "FullName":"…", "isHistorized":true, "historianTagname":"…"? , "alarm":{…}? }
→ Phase7Composer.ExtractTagHistorize(tagConfig) → (bool IsHistorized, string? HistorianTagname)
→ EquipmentTagPlan { …, IsHistorized, HistorianTagname } (+2 fields)
→ ConfigComposer snapshot → artifact JSON
→ DeploymentArtifact.ExtractTagHistorize(tagConfig) ← BYTE-PARITY copy of the composer parse
→ Phase7Applier.MaterialiseEquipmentTags
historianTagname := tag.IsHistorized ? (tag.HistorianTagname ?? tag.FullName) : null
→ IOpcUaAddressSpaceSink.EnsureVariable(…, string? historianTagname) (+1 nullable param)
→ OtOpcUaNodeManager.EnsureVariable
if historianTagname is not null:
Historizing = true
AccessLevel |= AccessLevels.HistoryRead (Read + History; ReadWrite + History)
_historizedTagnames[nodeId] = historianTagname
```
**Single nullable param.** The sink seam gains exactly one `string? historianTagname` (null ⇒ not
historized) across `IOpcUaAddressSpaceSink` / `NullOpcUaAddressSpaceSink` /
`DeferredAddressSpaceSink` / `SdkAddressSpaceSink` / `Phase7Applier.SafeEnsureVariable`. The applier
resolves default-vs-override so the node manager just consumes the final tagname.
## HistoryRead dispatch (the new override)
`OtOpcUaNodeManager` gains a settable `public IHistorianDataSource HistorianDataSource { get; set; }`
(defaults to `NullHistorianDataSource.Instance`; volatile-backed like `NodeWriteGateway`) and a
`ConcurrentDictionary<string,string> _historizedTagnames` (NodeId-string → historian tagname).
Override the `CustomNodeManager2` HistoryRead surface. **The exact override point is version-sensitive
— the implementer confirms it** against the installed `OPCFoundation.NetStandard.Opc.Ua.Server`
(use the DeepWiki MCP `OPCFoundation/UA-.NETStandard`, per CLAUDE.md). Recommended: override the
per-details protected virtuals (`HistoryReadRawModified` / `HistoryReadProcessed` /
`HistoryReadAtTime` / `HistoryReadEvents`); fallback: override the single `HistoryRead` and switch on
`details`. Per `nodesToRead[i]`:
- **Resolve.** NodeId.Identifier (string) → `_historizedTagnames`. Miss ⇒
`errors[i] = BadHistoryOperationUnsupported`, `Processed = true`. (Reading `_historizedTagnames`
needs **no `Lock`** — it's a concurrent dict.)
- **Data (variable nodes):** switch on the details type →
`HistorianDataSource.ReadRawAsync(tagname, start, end, NumValuesPerNode, ct)` /
`ReadProcessedAsync(…interval, MapAggregate(aggregateId)…)` /
`ReadAtTimeAsync(tagname, reqTimes, ct)`. Block with the data source's timeout
(`.GetAwaiter().GetResult()` inside `try`). Map each `DataValueSnapshot`
`Opc.Ua.DataValue(new Variant(Value), new StatusCode(StatusCode), SourceTs, ServerTs)`; wrap the
list in `HistoryData { DataValues = … }`; set `results[i].HistoryData =
new ExtensionObject(historyData)`, `results[i].StatusCode = samples.Count == 0 ? GoodNoData : Good`,
`ContinuationPoint = null`, `Processed = true`.
- **Events (equipment-folder notifier nodes):** `ReadEventDetails` target folder →
`_eventNotifierSources[folderNodeId]` (`sourceName`, default = the folder's string id = equipment
id, registered when `EnsureFolderIsEventNotifier` promotes it) →
`ReadEventsAsync(sourceName, start, end, maxEvents, ct)`. Project each `HistoricalEvent` into a
`HistoryEventFieldList` per the request's `ReadEventDetails.Filter` SelectClauses — support the
standard `BaseEventType` operands (`EventId`, `SourceName`, `Time`, `Message`, `Severity`); an
unsupported operand yields a `null` field (spec-conformant). Wrap in `HistoryEvent { Events = … }`.
- **Errors never escape.** A backend throw / timeout → `errors[i] = BadHistoryOperationUnsupported`
(or `BadUnexpectedError`) + log; one node's failure never faults the batch.
`MapAggregate(NodeId aggregateId)` maps the OPC UA Part 13 aggregate NodeIds
(`ObjectIds.AggregateFunction_Average/Minimum/Maximum/Total/Count`) → `HistoryAggregateType`; an
unsupported aggregate ⇒ `BadAggregateNotSupported`.
## Materialization: Historizing + access bits
- **Variable nodes** — `EnsureVariable` sets `Historizing = historianTagname is not null` and ORs
`AccessLevels.HistoryRead` into both `AccessLevel` and `UserAccessLevel` when historized; registers
`_historizedTagnames`. `CreateVariable` (lazy, never historized) stays `Historizing = false`.
- **Event-notifier folders** — `EnsureFolderIsEventNotifier` ORs `EventNotifiers.HistoryRead` into the
promoted folder's `EventNotifier` **when a non-Null historian is wired**, and registers
`_eventNotifierSources[folder.NodeId-string] = folder.NodeId.Identifier` (the equipment id). On
`RebuildAddressSpace`, clear `_historizedTagnames` + `_eventNotifierSources` alongside the existing
guards.
## Wiring + DI
- **`NullHistorianDataSource`** (Core.Abstractions/Historian) — empty `Samples`/`Events`, disabled
health snapshot. The node-manager prop default + the DI default.
- **`ServerHistorianOptions`** (Runtime/Historian, `SectionName = "ServerHistorian"`) +
**`AddServerHistorian(this IServiceCollection, IConfiguration, Func<ServerHistorianOptions,
IServiceProvider, IHistorianDataSource> factory)`** — `Enabled=false` ⇒ no-op (Null default from
`AddOtOpcUaRuntime`'s new `TryAddSingleton<IHistorianDataSource>(NullHistorianDataSource.Instance)`);
`Enabled=true``AddSingleton` the factory result; `Validate()` warnings mirror `AlarmHistorian`.
- **`OtOpcUaSdkServer.SetHistorianDataSource(IHistorianDataSource?)`** — mirrors
`SetNodeWriteGateway` (null-coalesces to the Null default; returns false if NodeManager not up yet).
- **Host** — `Program.cs` adds `AddServerHistorian(config, (opts,sp) => new WonderwareHistorianClient(
new WonderwareHistorianClientOptions(opts.Host, opts.Port, opts.SharedSecret){ UseTls=…,
ServerCertThumbprint=… }, sp.GetService<ILogger<WonderwareHistorianClient>>()))`;
`OtOpcUaServerHostedService.StartAsync` resolves `IHistorianDataSource` from DI and calls
`_server.SetHistorianDataSource(...)` (right after `SetNodeWriteGateway`); `StopAsync` clears it.
## Error handling / edge cases
- **Byte-parity invariant** — `Phase7Composer.ExtractTagHistorize` and
`DeploymentArtifact.ExtractTagHistorize` must parse identically; a parity round-trip test guards it
(precedent: `DeploymentArtifactAliasParityTests`).
- **Non-historized HistoryRead** → `BadHistoryOperationUnsupported` (node not in
`_historizedTagnames`); the SDK's own `AccessLevel` check already blocks most before we see them.
- **Null / unconfigured historian** → `GoodNoData` empty (not an error).
- **Backend timeout / throw** → per-node `BadHistoryOperationUnsupported` + log; batch survives.
- **Redeploy** → `RebuildAddressSpace` clears the two new maps; re-materialization re-registers.
## Testing (no bUnit, offline xUnit + Shouldly)
- **Composer** `ExtractTagHistorize` — true / false / absent / override / malformed-JSON (never throws).
- **Artifact parity** — composer plan == artifact plan for a historized tag (incl. override) round-trip.
- **`EnsureVariable`** — historized ⇒ `Historizing==true` + `HistoryRead` access bit + registry entry;
non-historized ⇒ unchanged; default-vs-override tagname resolution in the applier.
- **HistoryRead override (data)** — fake `IHistorianDataSource`; assert Raw / Processed / AtTime
dispatch, tagname passed (default + override), `DataValueSnapshot→DataValue` mapping (value / status
/ timestamps), `GoodNoData` on empty, `BadHistoryOperationUnsupported` on non-historized,
`BadAggregateNotSupported` on an unknown aggregate.
- **HistoryRead override (events)** — fake source; assert folder→sourceName resolution + event-field
projection for the standard select clauses + empty/Null.
- **DI** — `AddServerHistorian` leaves Null when disabled, swaps the real source when enabled;
`NullHistorianDataSource` returns empty.
**Live docker-dev `/run` (user-driven; the agent does NOT sign in) — DEFERRED.** Requires the
Wonderware sidecar + AVEVA Historian (the WW Historian VM `10.100.0.48`), which is not part of the
local docker-dev rig, so this gate is inherently operator-driven. When run: author a historized Galaxy
equipment tag, set `ServerHistorian:Enabled=true` pointed at the sidecar, deploy, then
`Client.CLI historyread` → samples return.
## Hard rules (carried into implementation)
- Stage by path; never `git add .`. Never stage `sql_login.txt`,
`src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`, `pending.md`, `current.md`, or
`docker-dev/docker-compose.yml`. Never echo the gateway API key or the historian `SharedSecret` into
a tracked file. No force-push, no `--no-verify`.
- **NO EF/Configuration entity or migration change** — the flag rides in the existing `TagConfig` blob
(the Phase A `NamespaceKind` migration was a one-off already shipped; Phase C adds none).
- Build on `feat/galaxy-phase-c-historian` off master `c9a0f627`.
## Authoritative touched-code list
- `src/Server/.../OpcUaServer/Phase7Composer.cs` — `EquipmentTagPlan` +2 fields; `ExtractTagHistorize`.
- `src/Server/.../Runtime/Drivers/DeploymentArtifact.cs` — byte-parity `ExtractTagHistorize` + thread.
- `src/Core/.../Commons/OpcUa/IOpcUaAddressSpaceSink.cs` (+ `NullOpcUaAddressSpaceSink`,
`DeferredAddressSpaceSink`) — `EnsureVariable` gains `string? historianTagname`.
- `src/Server/.../OpcUaServer/SdkAddressSpaceSink.cs` — pass-through.
- `src/Server/.../OpcUaServer/Phase7Applier.cs` — resolve + thread the tagname.
- `src/Server/.../OpcUaServer/OtOpcUaNodeManager.cs` — `HistorianDataSource` prop, `_historizedTagnames`,
`_eventNotifierSources`, `EnsureVariable`/`EnsureFolderIsEventNotifier`/`RebuildAddressSpace` edits,
the HistoryRead override + mapping helpers.
- `src/Server/.../OpcUaServer/OtOpcUaSdkServer.cs` — `SetHistorianDataSource`.
- `src/Core/.../Core.Abstractions/Historian/NullHistorianDataSource.cs` (new).
- `src/Server/.../Runtime/Historian/ServerHistorianOptions.cs` (new) +
`src/Server/.../Runtime/ServiceCollectionExtensions.cs` — `AddServerHistorian` + Null default in
`AddOtOpcUaRuntime`.
- `src/Server/.../Host/Program.cs` + `Host/OpcUa/OtOpcUaServerHostedService.cs` — wire the data source.
- Docs: new `docs/Historian.md` (or a Galaxy-driver note) + a `CLAUDE.md` pointer; `appsettings.json`
`ServerHistorian` block (disabled).