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
@@ -278,6 +278,54 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
await host.DisposeAsync();
}
/// <summary>
/// #682: an aggregate the server maps but the wired backend does not declare in
/// <see cref="IHistorianDataSource.SupportedAggregates"/> is refused <c>BadAggregateNotSupported</c> UP FRONT — the
/// backend is never called (the same set is what the AggregateFunctions folders advertise). The Null default
/// declares none, so a processed read with no historian is refused too.
/// </summary>
[Fact]
public async Task Processed_aggregate_outside_the_backend_set_is_refused_without_a_backend_call()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource
{
ProcessedResult = Empty(),
Supported = new HashSet<HistoryAggregateType> { HistoryAggregateType.TimeAverage },
};
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/gw", parentFolderNodeId: null, displayName: "Gw", dataType: "Float",
writable: false, historianTagname: "WW.Gw", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/gw")!.NodeId;
ReadProcessedDetails Details(NodeId aggregate) => new()
{
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { aggregate },
};
// Average is mapped by the server but not in this backend's set ⇒ refused, no call.
InvokeHistoryRead(server, nm, Details(ObjectIds.AggregateFunction_Average), nodeId).Item2[0].StatusCode.Code
.ShouldBe(StatusCodes.BadAggregateNotSupported);
fake.LastCall.ShouldBeNull();
// Positive control: a member of the set reaches the backend.
InvokeHistoryRead(server, nm, Details(ObjectIds.AggregateFunction_TimeAverage), nodeId);
fake.LastCall.ShouldBe("Processed");
fake.LastAggregate.ShouldBe(HistoryAggregateType.TimeAverage);
// No historian: the Null default serves no aggregate.
nm.HistorianDataSource = NullHistorianDataSource.Instance;
InvokeHistoryRead(server, nm, Details(ObjectIds.AggregateFunction_TimeAverage), nodeId).Item2[0].StatusCode.Code
.ShouldBe(StatusCodes.BadAggregateNotSupported);
await host.DisposeAsync();
}
/// <summary>
/// #677: a client <c>AggregateConfiguration</c> that differs from the server defaults is refused per node with
/// <c>BadAggregateConfigurationRejected</c> (it used to be silently ignored); the source is never invoked.
@@ -626,6 +674,8 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
public bool ThrowOnRead { get; init; }
public bool AggregateNotSupported { get; init; }
public bool LimitExceeded { get; init; }
public IReadOnlySet<HistoryAggregateType> Supported { get; init; } = HistoryAggregateTypes.All;
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => Supported;
public HistorianRead RawResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);
public HistorianRead ProcessedResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);
public HistorianRead AtTimeResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);