feat(historian): advertise only the backend's aggregates under AggregateFunctions (#682)

IHistorianDataSource.SupportedAggregates (default: every HistoryAggregateType)
is the one authority for both ServerCapabilities/AggregateFunctions (i=2997) and
HistoryServerCapabilities/AggregateFunctions (i=11201) and for the up-front
BadAggregateNotSupported in HistoryRead-Processed. Gateway declares
AggregateModeMapper.Served (4), SqlServer/QuestDB AggregateFamilies.Served (17),
Null none. Only the folder references are trimmed; the SDK's calculator
factories stay so live AggregateFilter subscriptions keep working.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-10-12 08:52:47 -04:00
parent 8ebee2ac40
commit 6ea3ba44da
20 changed files with 654 additions and 58 deletions
+42 -8
View File
@@ -95,7 +95,9 @@ HistoryRead is served from any node (the database is shared). HistoryUpdate is *
The `ServerHistorian` section in `appsettings.json` controls the historian read path.
`Enabled` defaults to `false`; when disabled, the server registers `NullHistorianDataSource`
and all HistoryRead calls on historized nodes return `GoodNoData` (empty, not an error).
and Raw / AtTime / Events HistoryRead calls on historized nodes return `GoodNoData` (empty, not an error); a
Processed read is `BadAggregateNotSupported` and no aggregate functions are advertised
([#682](#advertised-aggregates-aggregatefunctions-682)).
```json
{
@@ -563,7 +565,7 @@ node without `isHistorized` has no HistoryRead access bit and answers
|---|---|
| Historized node, historian configured and reachable, results found | `Good` |
| Historized node, historian configured, time range empty | `GoodNoData` |
| Historized node, historian NOT configured (`Enabled=false` / Null source) | `GoodNoData` (empty) |
| Historized node, historian NOT configured (`Enabled=false` / Null source) | `GoodNoData` (empty) for Raw / AtTime; Processed ⇒ `BadAggregateNotSupported` (#682) |
| Non-historized node | `BadHistoryOperationUnsupported` |
| Backend timeout or exception | `BadHistoryOperationUnsupported` per node; other nodes in the same batch are unaffected |
| `Backend=SqlServer`, `AutoCreateSchema=false`, schema missing/behind | every read/write fails with a clear error naming the missing schema version (health `LastError`); reads answer `BadHistoryOperationUnsupported` per node; no host crash |
@@ -764,14 +766,46 @@ VPN. Reverse processed reads on the `Gateway` backend are answered by AVEVA and
`HistoryAggregateType` carries the wider Part 13 set (Interpolative, TimeAverage, Start, End, Delta, Range,
Min/MaxActualTime, Start/EndBound, DeltaBounds, DurationGood/Bad, PercentGood/Bad, WorstQuality,
NumberOfTransitions, AnnotationCount, StandardDeviationPopulation, VariancePopulation); `OtOpcUaNodeManager.MapAggregate`
maps each standard aggregate NodeId (`TimeAverage2` and `Total2` were appended for #671). The **`SqlServer`** and
NumberOfTransitions, AnnotationCount, StandardDeviationPopulation, VariancePopulation); `AggregateFunctionMap`
(`OpcUaServer`) maps each standard aggregate NodeId (`TimeAverage2` and `Total2` were appended for #671). The **`SqlServer`** and
**`QuestDb`** backends serve the seventeen listed in the [aggregate table](#aggregates-processed-reads); everything else
(e.g. `StartBound`, `DurationGood`, `StandardDeviationPopulation`) is `BadAggregateNotSupported` for that node. The **HistorianGateway** backend only has retrieval modes for the four
(TimeAverage, Minimum, Maximum, Total): for any other member `AggregateModeMapper` throws
`HistoryAggregateNotSupportedException`, which the HistoryRead path turns into `BadAggregateNotSupported` for that
node only (not a generic failure, and not counted as a backend fault). The OPC UA Client driver forwards every
member upstream. A NodeId outside the mapped set (e.g. `StandardDeviationSample`) is still `BadAggregateNotSupported`.
(TimeAverage, Minimum, Maximum, Total). A NodeId outside the mapped set (e.g. `StandardDeviationSample`) is still
`BadAggregateNotSupported`.
### Advertised aggregates (`AggregateFunctions`, #682)
Each backend declares the aggregates it serves as `IHistorianDataSource.SupportedAggregates` — **the single authority** for
two things:
- **What the server advertises.** `Server/ServerCapabilities/AggregateFunctions` (`i=2997`) and
`HistoryServerCapabilities/AggregateFunctions` (`i=11201`) — two separate folder nodes that organize the same
aggregate-function objects — list exactly that set. Before #682 both listed the SDK's full 37 regardless of backend.
- **What HistoryRead-Processed refuses up front.** An aggregate outside the set is `BadAggregateNotSupported` for that node
without a backend call (the backend still throws `HistoryAggregateNotSupportedException` for one as defence in depth —
derived from the same table, never a second list).
| `ServerHistorian:Backend` | Advertised | Declared by |
|---|---|---|
| `Gateway` | 4 — TimeAverage, Minimum, Maximum, Total | `AggregateModeMapper.Served` (derived from its mapping switch) |
| `SqlServer`, `QuestDb` | 17 — the [aggregate table](#aggregates-processed-reads) | `AggregateFamilies.Served` (Sql.Common; every member with a family) |
| historian disabled (Null source) | **none** | `NullHistorianDataSource` |
⚠️ **Client-visible: with no historian configured a processed HistoryRead is now `BadAggregateNotSupported`, not
`GoodNoData`.** An aggregate is a statistic the server cannot compute without a history, and the empty folders now say so;
Raw / AtTime / Events reads on the Null source still return the empty `GoodNoData` result.
How it is wired: `OtOpcUaSdkServer` keeps the SDK's `CreateAggregateManager` (all 37 calculator factories) and then trims
only the folder ⇄ aggregate `Organizes` references to the node manager's current source — the Null default at boot; the
Host's `SetHistorianDataSource` (the backend resolved once from `ServerHistorian:Backend`) re-syncs it right after start,
and every later assignment re-syncs both directions. ⚠️ **The calculator factories are deliberately NOT removed**: a
live-data MonitoredItem with an `AggregateFilter` is validated and computed by the SDK's `AggregateManager`, not the
historian, so live aggregate subscriptions keep working for every SDK aggregate, advertised or not (pinned over the wire
by `AdvertisedAggregateFunctionsWireTests`). The aggregate-function object nodes themselves stay readable. A boot log line
names the advertised set.
Not changed: the `HistoryServerCapabilities` booleans (`AccessHistoryDataCapability`, `InsertDataCapability`, …) are still
whatever the SDK sets — #682 touches only the two folders (follow-up candidate).
### Known limitations