fix(historian): refuse aggregates not valid for the node's DataType up front (#897)
v2-ci / unit-tests (push) Has been cancelled

TimeAverage / Total on a Boolean node over the Gateway backend (AVEVA discrete tag, live
SysPulse) went to the gateway since native TimeAverage/Total became default-on (#694). AVEVA
refuses both modes at StartQuery; the gateway reports that only as Internal /
HistorianServerOperationException (the same answer as a historian storage fault), so the
node answered BadHistoryOperationUnsupported and the read counted as a backend failure.

HistoryRead-Processed now checks the node's scalar DataType before calling any backend and
answers BadAggregateNotSupported (Part 11 6.5.4: "If the Aggregate is not valid for the Node
then the StatusCode shall be Bad_AggregateNotSupported"). Nothing is sent, nothing is counted.
- Shared, every backend: HistoryAggregateDataTypes (Core.Abstractions) applies Part 13
  5.4.2.3 Table 50: the numeric-only aggregates (Average, TimeAverage(2), Total(2), Min/Max,
  ActualTime, Range, Delta, DeltaBounds, Interpolative, stddev/variance) on a String or
  DateTime node are refused. Client-visible: the SQL backends used to answer every interval
  BadAggregateInvalidInputs.
- Per backend: IHistorianDataSource.SupportsAggregateFor(aggregate, TypeCode?) (default true;
  every production backend declares it, guard-tested). Gateway: false for native
  TimeAverage/Total on a Boolean node. SqlServer/QuestDb: true, since they serve Boolean as
  0/1 (stepped), so Boolean stays served there, as do the Gateway's raw-evaluated aggregates.

The residual case (a node declared numeric whose AVEVA tag is discrete) cannot be told apart
from a historian fault on the wire and still counts; that needs the gateway to surface
AVEVA's native error code as a deterministic status.

Tests: HistoryAggregateDataTypesTests, guard test, node-manager String/DateTime and per-type
refusals, Gateway capability matrix, DiscreteTagTimeWeightedWireTests (production Gateway
backend over an AVEVA-shaped fake: unsent, 0 health failures, positive controls). Each
mutation (no check, no shared rule, no backend hint, Gateway hint true, shared rule always
true, SQL inheriting the default) fails at least one. Live gate re-run read-only against
AVEVA 2023 R2 via a private HistorianGateway fa9cf31 container: SysPulse TimeAverage on the
wire = BadAggregateNotSupported, health failures unchanged; #801 gate 244 cases pass.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-13 00:41:32 -05:00
parent 2e537dc6ca
commit ee39c169df
17 changed files with 506 additions and 6 deletions
@@ -67,6 +67,64 @@ public static class HistoryAggregateTypes
System.Collections.Frozen.FrozenSet<HistoryAggregateType>.Empty;
}
/// <summary>
/// Which <see cref="HistoryAggregateType"/>s apply to a node's DataType — OPC UA Part 13 §5.4.2.3 Table 50, the shared
/// rule every backend's HistoryRead-Processed is checked against before the backend is called (lmxopcua#897).
/// </summary>
/// <remarks>
/// <para>
/// Table 50 puts the averaging / extremum / integral aggregates (Average, TimeAverage, TimeAverage2, Total, Total2,
/// Minimum, Maximum, the ActualTime pair, Range, Delta, DeltaBounds, Interpolative, the standard deviation /
/// variance family) under "numeric only — dates, strings, arrays, etc. are not supported", and Part 11 §6.5.4 says
/// that "if the Aggregate is not valid for the Node then the StatusCode shall be <c>Bad_AggregateNotSupported</c>".
/// So a String or DateTime node asked for one of them is refused for the node, unsent, on every backend.
/// (A SQL backend used to answer every interval <c>Bad_AggregateInvalidInputs</c> instead — the per-value status
/// Part 13 keeps for data that cannot be aggregated, which is still what a usable String/DateTime sample inside a
/// numeric node's interval gets.)
/// </para>
/// <para>
/// Boolean is deliberately NOT refused here, although Table 50 lists it apart from numeric: the SQL backends
/// serve a Boolean node's numeric aggregates as 0/1 (stepped for the time-weighted ones; the fraction of time true
/// is a common reading of TimeAverage) and have since #671. A backend that cannot aggregate a Boolean says so per
/// aggregate through <see cref="IHistorianDataSource.SupportsAggregateFor"/> (the Gateway: AVEVA refuses
/// TimeAverage / Total on a discrete tag).
/// </para>
/// <para>An unknown node type (<c>null</c>: BaseDataType, an array, a type with no CLR mapping) is not refused.</para>
/// </remarks>
public static class HistoryAggregateDataTypes
{
/// <summary>The aggregates Part 13 Table 50 defines for numeric data only.</summary>
public static readonly IReadOnlySet<HistoryAggregateType> NumericOnly =
System.Collections.Frozen.FrozenSet.ToFrozenSet(
[
HistoryAggregateType.Average,
HistoryAggregateType.Minimum,
HistoryAggregateType.Maximum,
HistoryAggregateType.Total,
HistoryAggregateType.Interpolative,
HistoryAggregateType.TimeAverage,
HistoryAggregateType.Delta,
HistoryAggregateType.Range,
HistoryAggregateType.MinimumActualTime,
HistoryAggregateType.MaximumActualTime,
HistoryAggregateType.DeltaBounds,
HistoryAggregateType.StandardDeviationPopulation,
HistoryAggregateType.VariancePopulation,
HistoryAggregateType.TimeAverage2,
HistoryAggregateType.Total2,
]);
/// <summary>
/// Whether <paramref name="aggregate"/> is valid for a node whose scalar value type is <paramref name="valueType"/>:
/// false only for a <see cref="NumericOnly"/> aggregate on a String or DateTime node.
/// </summary>
/// <param name="aggregate">The requested aggregate.</param>
/// <param name="valueType">The node's scalar value type, or <c>null</c> when unknown (never refused).</param>
/// <returns><see langword="false"/> when the server must answer <c>Bad_AggregateNotSupported</c> for the node.</returns>
public static bool AppliesTo(HistoryAggregateType aggregate, TypeCode? valueType) =>
valueType is not (TypeCode.String or TypeCode.DateTime) || !NumericOnly.Contains(aggregate);
}
/// <summary>
/// Thrown by a history backend when it cannot serve the requested <see cref="HistoryAggregateType"/>.
/// The Server HistoryRead path translates it to <c>BadAggregateNotSupported</c> for that node only.
@@ -250,6 +250,26 @@ public interface IHistorianDataSource : IDisposable
/// </remarks>
IReadOnlySet<HistoryAggregateType> SupportedAggregates => HistoryAggregateTypes.All;
/// <summary>
/// Whether this backend can serve <paramref name="aggregate"/> (a member of <see cref="SupportedAggregates"/>) on a
/// node whose scalar value type is <paramref name="valueType"/> — a per-type refinement of
/// <see cref="SupportedAggregates"/>. <see langword="false"/> ⇒ HistoryRead-Processed answers
/// <c>BadAggregateNotSupported</c> for that node up front (Part 11 §6.5.4), without calling the backend and without
/// counting a backend failure (lmxopcua#897).
/// </summary>
/// <remarks>
/// The Part 13 Table 50 data-type rule shared by every backend (no numeric aggregate on a String / DateTime node) is
/// <see cref="HistoryAggregateDataTypes.AppliesTo"/>, checked by the server before this; this member is only for
/// what one backend cannot do on top of it — the Gateway's native TimeAverage / Total on a Boolean node, which AVEVA
/// refuses for a discrete tag. <paramref name="valueType"/> is <c>null</c> when the node's type is unknown. The
/// default (<see langword="true"/>) keeps a test fake or out-of-tree source unchanged; every production backend
/// declares it itself (a reflection guard pins that). Called on every processed read: it must be cheap and pure.
/// </remarks>
/// <param name="aggregate">The requested aggregate.</param>
/// <param name="valueType">The node's scalar value type, or <c>null</c> when unknown.</param>
/// <returns><see langword="true"/> when the backend can serve the aggregate on such a node.</returns>
bool SupportsAggregateFor(HistoryAggregateType aggregate, TypeCode? valueType) => true;
/// <summary>
/// What this backend serves for HistoryRead — the authority for the <c>AccessHistoryDataCapability</c>,
/// <c>AccessHistoryEventsCapability</c> and <c>ServerTimestampSupported</c> flags the server advertises under
@@ -87,6 +87,10 @@ public sealed class NullHistorianDataSource : IHistorianDataSource
// historian misled capability discovery. Raw / AtTime / Events reads still return the empty GoodNoData result.
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => HistoryAggregateTypes.None;
/// <inheritdoc />
// true: no type narrows an empty SupportedAggregates further (every processed read is refused by it already).
public bool SupportsAggregateFor(HistoryAggregateType aggregate, TypeCode? valueType) => true;
/// <inheritdoc />
// None: no history is served, so HistoryServerCapabilities advertises no data / event access (lmxopcua#881) and no
// folder is promoted to an event-history notifier.
@@ -722,6 +722,15 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
public IReadOnlySet<HistoryAggregateType> SupportedAggregates =>
_enableNativeTimeWeighted ? ServedAggregates : RawEvaluatedAggregates;
/// <inheritdoc />
// false for AVEVA's native TimeAverage / Total on a Boolean node (lmxopcua#897): AVEVA refuses both retrieval modes on a
// discrete tag at StartQuery, and the gateway answers that only as Internal / HistorianServerOperationException — the
// same answer as a historian storage fault — so it read as BadHistoryOperationUnsupported and a backend failure. The
// server now answers BadAggregateNotSupported up front and nothing is sent. The raw-evaluated aggregates serve a
// Boolean as 0/1 (as the SQL backends do); a String / DateTime node is refused by the shared HistoryAggregateDataTypes rule.
public bool SupportsAggregateFor(HistoryAggregateType aggregate, TypeCode? valueType) =>
!(valueType == TypeCode.Boolean && AggregateModeMapper.NativeAggregates.Contains(aggregate));
/// <inheritdoc />
// Data + events, but NOT ServerTimestamps: AVEVA keeps no per-sample server time, so SampleMapper stamps the read
// time — advertising ServerTimestampSupported would promise a stored value we do not have (lmxopcua#881).
@@ -438,6 +438,11 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
/// <inheritdoc />
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => AggregateFamilies.Served;
/// <inheritdoc />
// true for every type: a Boolean node's numeric aggregates are served as 0/1 (stepped for the time-weighted ones), and a
// String / DateTime node's numeric ones are refused by the server's shared HistoryAggregateDataTypes rule before this.
public bool SupportsAggregateFor(HistoryAggregateType aggregate, TypeCode? valueType) => true;
/// <inheritdoc />
// Data + events; every sample row stores its server time and a raw read returns it (lmxopcua#881).
public HistorianReadCapabilities ReadCapabilities =>
@@ -363,6 +363,11 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
/// <inheritdoc />
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => AggregateFamilies.Served;
/// <inheritdoc />
// true for every type: a Boolean node's numeric aggregates are served as 0/1 (stepped for the time-weighted ones), and a
// String / DateTime node's numeric ones are refused by the server's shared HistoryAggregateDataTypes rule before this.
public bool SupportsAggregateFor(HistoryAggregateType aggregate, TypeCode? valueType) => true;
/// <inheritdoc />
// Data + events; every sample row stores ServerTimeUtc (written by the recorder / HistoryUpdate) and a raw read
// returns it (lmxopcua#881).
@@ -7627,6 +7627,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
continue;
}
// An aggregate not valid for THIS node's DataType (lmxopcua#897) — Part 11 §6.5.4: "If the Aggregate is not
// valid for the Node then the StatusCode shall be Bad_AggregateNotSupported". The shared Part 13 Table 50
// rule (no numeric aggregate on a String / DateTime node, every backend), then the backend's own per-type
// limit (the Gateway's native TimeAverage / Total on a Boolean node). Unsent, so never a backend failure.
var nodeValueType = AtTimeValueType(handle.NodeId);
if (!HistoryAggregateDataTypes.AppliesTo(aggregate.Value, nodeValueType)
|| !HistorianDataSource.SupportsAggregateFor(aggregate.Value, nodeValueType))
{
errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
continue;
}
if (!configurationServable)
{
errors[handle.Index] = StatusCodes.BadAggregateConfigurationRejected;