16 KiB
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 requiredReadRawAsync/ReadProcessedAsync/ReadAtTimeAsync/ReadEventsAsync/GetHealthSnapshot. ReturnsHistoryReadResult(IReadOnlyList<DataValueSnapshot> Samples, byte[]? ContinuationPoint)andHistoricalEventsResult(IReadOnlyList<HistoricalEvent> Events, byte[]? ContinuationPoint)(both inIHistoryProvider.cs).DataValueSnapshot(object? Value, uint StatusCode, DateTime? SourceTimestampUtc, DateTime ServerTimestampUtc)mirrorsOpc.Ua.DataValue.WonderwareHistorianClient(Driver.Historian.Wonderware.Client, .NET 10) implementsIHistorianDataSourceover a hardened TCP/TLS/shared-secret channel. The Host is the only project that references it (same as the alarm-historian writer).DriverAttributeInfo.IsHistorizedalready exists; Galaxy + OpcUaClient drivers populate it (informational — the equipment-tag path does not consumeDriverAttributeInfo, it carriesTagConfig; see below).- The Phase B
alarm-object-in-TagConfigcarrier —EquipmentTagPlan.Alarmparsed byPhase7Composer.ExtractTagAlarmand the byte-parityDeploymentArtifact.ExtractTagAlarm. This is the exact precedent for carryingisHistorized/historianTagname. - The wiring pattern — the node manager exposes settable props (
AlarmCommandRouter,NodeWriteGateway);OtOpcUaSdkServer.Set*methods set them; the Host'sOtOpcUaServerHostedService.StartAsyncwires them post-start. - The DI pattern —
AddAlarmHistorian(config, writerFactory)(Null default viaAddOtOpcUaRuntime'sTryAddSingleton, config-gated real impl), Host supplies the Wonderware client factory inProgram.cs. EnsureFolderIsEventNotifieralready promotes alarm-owning equipment folders toEventNotifiers.SubscribeToEvents+AddRootNotifier(guarded by_notifierFolders).
What's missing (the Phase C work)
- No
HistoryReadoverride.OtOpcUaNodeManager : CustomNodeManager2overrides onlyCreateAddressSpace.Historizingis hardcodedfalseinEnsureVariable(:846) andCreateVariable(:977); noAccessLevels.HistoryReadbit, no event-notifierHistoryReadbit. - No carrier for
isHistorized/historianTagnamethroughEquipmentTagPlan→ artifact → materialization. - No
IHistorianDataSourceregistration / 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; theMasterNodeManagerroutes HistoryRead by node ownership. We expose event history only on our equipment-folder event-notifiers (per-equipmentsourceName). 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_historizedTagnamesneeds noLock— 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()insidetry). Map eachDataValueSnapshot→Opc.Ua.DataValue(new Variant(Value), new StatusCode(StatusCode), SourceTs, ServerTs); wrap the list inHistoryData { DataValues = … }; setresults[i].HistoryData = new ExtensionObject(historyData),results[i].StatusCode = samples.Count == 0 ? GoodNoData : Good,ContinuationPoint = null,Processed = true. - Events (equipment-folder notifier nodes):
ReadEventDetailstarget folder →_eventNotifierSources[folderNodeId](sourceName, default = the folder's string id = equipment id, registered whenEnsureFolderIsEventNotifierpromotes it) →ReadEventsAsync(sourceName, start, end, maxEvents, ct). Project eachHistoricalEventinto aHistoryEventFieldListper the request'sReadEventDetails.FilterSelectClauses — support the standardBaseEventTypeoperands (EventId,SourceName,Time,Message,Severity); an unsupported operand yields anullfield (spec-conformant). Wrap inHistoryEvent { Events = … }. - Errors never escape. A backend throw / timeout →
errors[i] = BadHistoryOperationUnsupported(orBadUnexpectedError) + 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 —
EnsureVariablesetsHistorizing = historianTagname is not nulland ORsAccessLevels.HistoryReadinto bothAccessLevelandUserAccessLevelwhen historized; registers_historizedTagnames.CreateVariable(lazy, never historized) staysHistorizing = false. - Event-notifier folders —
EnsureFolderIsEventNotifierORsEventNotifiers.HistoryReadinto the promoted folder'sEventNotifierwhen a non-Null historian is wired, and registers_eventNotifierSources[folder.NodeId-string] = folder.NodeId.Identifier(the equipment id). OnRebuildAddressSpace, clear_historizedTagnames+_eventNotifierSourcesalongside the existing guards.
Wiring + DI
NullHistorianDataSource(Core.Abstractions/Historian) — emptySamples/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 fromAddOtOpcUaRuntime's newTryAddSingleton<IHistorianDataSource>(NullHistorianDataSource.Instance));Enabled=true⇒AddSingletonthe factory result;Validate()warnings mirrorAlarmHistorian.OtOpcUaSdkServer.SetHistorianDataSource(IHistorianDataSource?)— mirrorsSetNodeWriteGateway(null-coalesces to the Null default; returns false if NodeManager not up yet).- Host —
Program.csaddsAddServerHistorian(config, (opts,sp) => new WonderwareHistorianClient( new WonderwareHistorianClientOptions(opts.Host, opts.Port, opts.SharedSecret){ UseTls=…, ServerCertThumbprint=… }, sp.GetService<ILogger<WonderwareHistorianClient>>()));OtOpcUaServerHostedService.StartAsyncresolvesIHistorianDataSourcefrom DI and calls_server.SetHistorianDataSource(...)(right afterSetNodeWriteGateway);StopAsyncclears it.
Error handling / edge cases
- Byte-parity invariant —
Phase7Composer.ExtractTagHistorizeandDeploymentArtifact.ExtractTagHistorizemust parse identically; a parity round-trip test guards it (precedent:DeploymentArtifactAliasParityTests). - Non-historized HistoryRead →
BadHistoryOperationUnsupported(node not in_historizedTagnames); the SDK's ownAccessLevelcheck already blocks most before we see them. - Null / unconfigured historian →
GoodNoDataempty (not an error). - Backend timeout / throw → per-node
BadHistoryOperationUnsupported+ log; batch survives. - Redeploy →
RebuildAddressSpaceclears 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+HistoryReadaccess 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→DataValuemapping (value / status / timestamps),GoodNoDataon empty,BadHistoryOperationUnsupportedon non-historized,BadAggregateNotSupportedon an unknown aggregate. - HistoryRead override (events) — fake source; assert folder→sourceName resolution + event-field projection for the standard select clauses + empty/Null.
- DI —
AddServerHistorianleaves Null when disabled, swaps the real source when enabled;NullHistorianDataSourcereturns 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 stagesql_login.txt,src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/,pending.md,current.md, ordocker-dev/docker-compose.yml. Never echo the gateway API key or the historianSharedSecretinto a tracked file. No force-push, no--no-verify. - NO EF/Configuration entity or migration change — the flag rides in the existing
TagConfigblob (the Phase ANamespaceKindmigration was a one-off already shipped; Phase C adds none). - Build on
feat/galaxy-phase-c-historianoff masterc9a0f627.
Authoritative touched-code list
src/Server/.../OpcUaServer/Phase7Composer.cs—EquipmentTagPlan+2 fields;ExtractTagHistorize.src/Server/.../Runtime/Drivers/DeploymentArtifact.cs— byte-parityExtractTagHistorize+ thread.src/Core/.../Commons/OpcUa/IOpcUaAddressSpaceSink.cs(+NullOpcUaAddressSpaceSink,DeferredAddressSpaceSink) —EnsureVariablegainsstring? historianTagname.src/Server/.../OpcUaServer/SdkAddressSpaceSink.cs— pass-through.src/Server/.../OpcUaServer/Phase7Applier.cs— resolve + thread the tagname.src/Server/.../OpcUaServer/OtOpcUaNodeManager.cs—HistorianDataSourceprop,_historizedTagnames,_eventNotifierSources,EnsureVariable/EnsureFolderIsEventNotifier/RebuildAddressSpaceedits, 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 inAddOtOpcUaRuntime.src/Server/.../Host/Program.cs+Host/OpcUa/OtOpcUaServerHostedService.cs— wire the data source.- Docs: new
docs/Historian.md(or a Galaxy-driver note) + aCLAUDE.mdpointer;appsettings.jsonServerHistorianblock (disabled).