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
@@ -0,0 +1,91 @@
using System.Reflection;
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// Gitea #682 — make the two advertised aggregate-function folders, <c>ServerCapabilities/AggregateFunctions</c>
/// (i=2997) and <c>HistoryServerCapabilities/AggregateFunctions</c> (i=11201), list exactly the aggregates the wired
/// historian backend serves (<see cref="IHistorianDataSource.SupportedAggregates"/>), instead of the SDK's full
/// registered set.
/// </summary>
/// <remarks>
/// <para>
/// The SDK's <c>AggregateManager.RegisterFactory</c> both registers a calculator factory AND adds an
/// <c>Organizes</c> reference from each folder to the aggregate's object node. Only the REFERENCES are trimmed
/// here: the factories stay, because a live-data MonitoredItem with an <c>AggregateFilter</c> is validated
/// against (and computed by) the AggregateManager, not the historian — removing a factory would break live
/// aggregate subscriptions for every aggregate the historian happens not to serve.
/// </para>
/// <para>
/// The two folders are separate nodes that reference the same aggregate object nodes (both directions). The
/// aggregate nodes themselves are kept (they are standard type-system nodes a client may still Read); only the
/// folder ⇄ aggregate <c>Organizes</c> pair is removed or restored. Idempotent and reversible, so re-wiring a
/// different source re-syncs both folders.
/// </para>
/// </remarks>
internal static class AdvertisedAggregateFunctions
{
/// <summary>Every standard aggregate-function NodeId the SDK's nodeset knows (<c>ObjectIds.AggregateFunction_*</c>).</summary>
private static readonly NodeId[] StandardAggregateIds = typeof(ObjectIds)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.FieldType == typeof(NodeId) && f.Name.StartsWith("AggregateFunction_", StringComparison.Ordinal))
.Select(f => (NodeId)f.GetValue(null)!)
.ToArray();
/// <summary>The two advertised folders (both are trimmed — they are separate nodes).</summary>
internal static readonly NodeId[] Folders =
[
ObjectIds.Server_ServerCapabilities_AggregateFunctions,
ObjectIds.HistoryServerCapabilities_AggregateFunctions,
];
/// <summary>
/// Sync both folders to <paramref name="served"/>: an aggregate-function node is organized under a folder iff the
/// server maps it (<see cref="AggregateFunctionMap"/>) AND <paramref name="served"/> contains it.
/// </summary>
/// <param name="server">The running server.</param>
/// <param name="served">The backend's served aggregates.</param>
/// <returns>The advertised NodeIds, or <c>null</c> when the diagnostics node manager is not available yet.</returns>
public static IReadOnlyList<NodeId>? Apply(IServerInternal server, IReadOnlySet<HistoryAggregateType> served)
{
if (server?.DiagnosticsNodeManager is not CustomNodeManager2 diagnostics) return null;
var advertised = new List<NodeId>();
lock (diagnostics.Lock)
{
var folders = Folders
.Select(id => diagnostics.FindPredefinedNode(id, typeof(BaseObjectState)))
.Where(f => f is not null)
.ToList();
foreach (var aggregateId in StandardAggregateIds)
{
var aggregateNode = diagnostics.FindPredefinedNode(aggregateId, null);
if (aggregateNode is null) continue;
var advertise = AggregateFunctionMap.Map(aggregateId) is { } aggregate && served.Contains(aggregate);
if (advertise) advertised.Add(aggregateId);
foreach (var folder in folders)
{
var linked = folder!.ReferenceExists(ReferenceTypeIds.Organizes, false, aggregateId);
if (advertise && !linked)
{
folder.AddReference(ReferenceTypeIds.Organizes, false, aggregateId);
aggregateNode.AddReference(ReferenceTypeIds.Organizes, true, folder.NodeId);
}
else if (!advertise && linked)
{
folder.RemoveReference(ReferenceTypeIds.Organizes, false, aggregateId);
aggregateNode.RemoveReference(ReferenceTypeIds.Organizes, true, folder.NodeId);
}
}
}
}
return advertised;
}
}