fix(historian): trim only HistoryServerCapabilities/AggregateFunctions; null set = all (#682 review)

ServerCapabilities/AggregateFunctions (i=2997) is Part 5's list of every
aggregate the server supports, and live AggregateFilter subscriptions still
compute all 37 SDK aggregates, so it keeps the SDK's full set; only i=11201
follows the backend. A null SupportedAggregates is treated as the interface
default (all) by both the advertisement and HistoryRead-Processed (it used to
NRE inside the batch). Docs: i=2997/i=11201 split and the boot window.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-10-12 09:18:53 -04:00
parent ede8610804
commit c019d0fc2b
8 changed files with 165 additions and 93 deletions
+30 -19
View File
@@ -96,7 +96,7 @@ 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 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
Processed read is `BadAggregateNotSupported` and no history aggregate functions are advertised
([#682](#advertised-aggregates-aggregatefunctions-682)).
```json
@@ -778,34 +778,45 @@ NumberOfTransitions, AnnotationCount, StandardDeviationPopulation, VariancePopul
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 the server advertises as HISTORY aggregates.** `HistoryServerCapabilities/AggregateFunctions` (`i=11201`) lists
exactly that set. Before #682 it 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).
derived from the same table, never a second list). A source whose `SupportedAggregates` is `null` (out-of-tree, against
the contract) is treated as the interface default — **all** — by both consumers.
| `ServerHistorian:Backend` | Advertised | Declared by |
|---|---|---|
| `Gateway` | 4 — TimeAverage, Minimum, Maximum, Total | `GatewayHistorianDataSource.ServedAggregates` (today `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` |
⚠️ **`Server/ServerCapabilities/AggregateFunctions` (`i=2997`) is deliberately NOT trimmed** — it keeps the SDK's full 37 in
every configuration, including with no historian. Part 5 defines it as every aggregate the *server* supports, and the server
still computes all of them for live data: a MonitoredItem `AggregateFilter` is validated and computed by the SDK's
`AggregateManager`, not the historian. The SDK models the same split (`DiagnosticsNodeManager.AddAggregateFunction` always adds
to i=2997 and to i=11201 only for historical aggregates). The two are separate folder nodes organizing the same
aggregate-function objects. A client choosing a history aggregate should read **i=11201**.
| `ServerHistorian:Backend` | i=11201 (history) | i=2997 (server) | Declared by |
|---|---|---|---|
| `Gateway` | 4 — TimeAverage, Minimum, Maximum, Total | all 37 | `GatewayHistorianDataSource.ServedAggregates` (today `AggregateModeMapper.Served`, derived from its mapping switch) |
| `SqlServer`, `QuestDb` | 17 — the [aggregate table](#aggregates-processed-reads) | all 37 | `AggregateFamilies.Served` (Sql.Common; every member with a family) |
| historian disabled (Null source) | **none** | all 37 | `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;
`GoodNoData`.** An aggregate is a statistic the server cannot compute without a history, and the empty i=11201 now says 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.
only the i=11201 ⇄ aggregate `Organizes` references (both directions) 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 ways. ⚠️ **The calculator factories are deliberately NOT
removed**: live aggregate subscriptions keep working for every SDK aggregate (pinned over the wire by
`AdvertisedAggregateFunctionsWireTests`). The aggregate-function object nodes themselves stay readable. A boot log line
names the advertised history set.
⚠️ **Boot window.** `SetHistorianDataSource` runs right after the SDK server has started (`OtOpcUaServerHostedService.StartAsync`),
so for the few milliseconds between the endpoint opening and that call i=11201 is empty and a processed HistoryRead is
refused `BadAggregateNotSupported` — the Null default's answer. A client that browses i=11201 in that window sees an empty
list; re-browse after connect if it matters.
Not changed: the `HistoryServerCapabilities` booleans (`AccessHistoryDataCapability`, `InsertDataCapability`, …) are still
whatever the SDK sets — #682 touches only the two folders (follow-up candidate).
whatever the SDK sets — #682 touches only the i=11201 folder (follow-up candidate).
### Known limitations