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
@@ -345,7 +345,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// node-manager <c>Lock</c>, so the override may block-bridge to this (async) source.
/// </para>
/// <para>
/// Gitea #682: assigning a source also re-syncs the advertised <c>AggregateFunctions</c> folders to its
/// Gitea #682: assigning a source also re-syncs <c>HistoryServerCapabilities/AggregateFunctions</c> to its
/// <see cref="IHistorianDataSource.SupportedAggregates"/> (see <see cref="SyncAdvertisedAggregates"/>).
/// </para>
/// </summary>
@@ -360,9 +360,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
/// <summary>
/// Gitea #682 — trim <c>ServerCapabilities/AggregateFunctions</c> (i=2997) and
/// <c>HistoryServerCapabilities/AggregateFunctions</c> (i=11201) to the current source's
/// <see cref="IHistorianDataSource.SupportedAggregates"/>. Called on every <see cref="HistorianDataSource"/>
/// Gitea #682 — trim <c>HistoryServerCapabilities/AggregateFunctions</c> (i=11201) to the current source's
/// <see cref="IHistorianDataSource.SupportedAggregates"/> (null ⇒ all, <see cref="AdvertisedAggregateFunctions.ServedBy"/>).
/// <c>ServerCapabilities/AggregateFunctions</c> (i=2997) keeps the SDK's full set — the server still computes every
/// one of them for live <c>AggregateFilter</c> subscriptions. Called on every <see cref="HistorianDataSource"/>
/// assignment and by <see cref="OtOpcUaSdkServer"/> right after the SDK registers its aggregate factories, so the
/// Null default is applied from boot. Only the folder references change — the AggregateManager's calculator
/// factories (live <c>AggregateFilter</c> subscriptions) are untouched. Never throws: a failure leaves the previous
@@ -373,11 +374,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
try
{
var source = _historianDataSource;
var advertised = AdvertisedAggregateFunctions.Apply(Server, source.SupportedAggregates);
var advertised = AdvertisedAggregateFunctions.Apply(Server, AdvertisedAggregateFunctions.ServedBy(source));
if (advertised is not null)
{
_logger.LogInformation(
"OtOpcUaNodeManager: advertising {Count} aggregate function(s) for historian {Source}: {Aggregates}",
"OtOpcUaNodeManager: advertising {Count} history aggregate function(s) for historian {Source}: {Aggregates}",
advertised.Count, source.GetType().Name,
string.Join(", ", advertised.Select(id => AggregateFunctionMap.Map(id)?.ToString() ?? id.ToString())));
}
@@ -5091,7 +5092,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// #677: a client AggregateConfiguration other than the server defaults is refused per node, never silently ignored.
var configurationServable = ServerAggregateConfiguration.IsServable(details.AggregateConfiguration);
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
var source = HistorianDataSource;
var served = AdvertisedAggregateFunctions.ServedBy(HistorianDataSource);
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
{
using var deadline = CreateDeadline();
@@ -5102,11 +5103,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// AggregateType is a per-node parallel collection (same length as nodesToRead, enforced by
// the base dispatcher). handle.Index is the node's position in that collection.
var aggregateNodeId = details.AggregateType[handle.Index];
// #682: one authority — the source's SupportedAggregates (also what the AggregateFunctions folders
// advertise). An aggregate the server cannot name, or the backend does not serve, is refused up front
// without a backend call.
// #682: one authority — the source's SupportedAggregates (also what HistoryServerCapabilities/
// AggregateFunctions advertises; null ⇒ all, the same rule). An aggregate the server cannot name, or the
// backend does not serve, is refused up front without a backend call.
var aggregate = AggregateFunctionMap.Map(aggregateNodeId);
if (aggregate is null || !source.SupportedAggregates.Contains(aggregate.Value))
if (aggregate is null || !served.Contains(aggregate.Value))
{
errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
continue;