fix(historian): refuse native Gateway TimeAverage/Total until historiangw#27 (lmxopcua#746)

AVEVA's native TimeAverage / Total are answered with Good-status wrong values
while the gateway sends the interval in the wrong unit (historiangw#27): one
whole-span value on the earliest interval, the rest absent. Nothing on the
wire marks the answer as degraded.

Both are now refused BadAggregateNotSupported (unsent, not counted as a
backend failure, not advertised in AggregateFunctions) unless
ServerHistorian:Gateway:EnableNativeTimeWeighted is true. The default
Gateway set is the ten raw-evaluated aggregates
(GatewayHistorianDataSource.RawEvaluatedAggregates); ServedAggregates stays
the 12 the backend can serve with the flag on. The native-path tests opt in.

Also documents the ServerHistorian:Gateway sub-section (EventDrainBudget from
the previous commit) and the events drain in docs/Historian.md.

Rebased from the unpushed remed/lane-H2 branch (originally authored 2026-10-22
in session_01Ub9hGmYLQvn9XETzw7CMsT) onto current master.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-10-22 16:30:19 -04:00
parent 24609ececa
commit 0b85c499e3
11 changed files with 157 additions and 16 deletions
@@ -38,11 +38,27 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
/// <see cref="SupportedAggregates"/> returns, so the advertised <c>AggregateFunctions</c> folders and the server's
/// up-front <c>BadAggregateNotSupported</c> read it too.
/// </summary>
/// <remarks>
/// The two native members are served only with <c>ServerHistorian:Gateway:EnableNativeTimeWeighted</c>: until the
/// gateway's interval fix (historiangw#27) is live-verified AVEVA answers them with Good-status wrong values, so by
/// default an instance serves (and advertises) <see cref="RawEvaluatedAggregates"/> only — see
/// <see cref="SupportedAggregates"/>.
/// </remarks>
public static IReadOnlySet<HistoryAggregateType> ServedAggregates { get; } =
Enum.GetValues<HistoryAggregateType>()
.Where(a => RawAggregateReader.Serves(a) || AggregateModeMapper.NativeAggregates.Contains(a))
.ToFrozenSet();
/// <summary>
/// The per-sample aggregates this backend evaluates from raw reads — what an instance serves without
/// <c>ServerHistorian:Gateway:EnableNativeTimeWeighted</c> (the default).
/// </summary>
public static IReadOnlySet<HistoryAggregateType> RawEvaluatedAggregates { get; } =
Enum.GetValues<HistoryAggregateType>().Where(RawAggregateReader.Serves).ToFrozenSet();
/// <summary>Whether AVEVA's native TimeAverage / Total are served (opt-in; see <see cref="ServedAggregates"/>).</summary>
private readonly bool _enableNativeTimeWeighted;
private readonly IHistorianGatewayClient _client;
private readonly ILogger<GatewayHistorianDataSource> _logger;
private readonly int _maxRawRows;
@@ -127,6 +143,11 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
/// for cap + 1 rows and is refused when the window holds more, never truncated. &lt;= 0 = unbounded (the pre-#736
/// behaviour: an uncapped read is sent as <see cref="int.MaxValue"/>).
/// </param>
/// <param name="enableNativeTimeWeighted">
/// Serve TimeAverage / Total through AVEVA's native retrieval modes (<c>ServerHistorian:Gateway:EnableNativeTimeWeighted</c>,
/// default <c>false</c>): until historiangw#27 is live-verified they answer Good-status wrong values, so by default they
/// are refused <see cref="HistoryAggregateNotSupportedException"/> (<c>BadAggregateNotSupported</c>) and not advertised.
/// </param>
/// <param name="eventDrainBudget">
/// Stream rows a capped events read consumes (uncollected) past its cap before it abandons the gateway stream
/// (<c>ServerHistorian:Gateway:EventDrainBudget</c>). Negative counts as 0 = abandon at the cap.
@@ -134,8 +155,9 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
public GatewayHistorianDataSource(IHistorianGatewayClient client, ILogger<GatewayHistorianDataSource> logger,
int maxRawRows = TimeWeightedAggregator.DefaultMaxWindowRows, TimeProvider? timeProvider = null,
bool atTimeFromRaw = true, int maxPreWindowRows = DefaultMaxPreWindowRows, int maxRawValuesPerRead = 0,
int eventDrainBudget = DefaultEventDrainBudget)
int eventDrainBudget = DefaultEventDrainBudget, bool enableNativeTimeWeighted = false)
{
_enableNativeTimeWeighted = enableNativeTimeWeighted;
_eventDrainBudget = Math.Max(0, eventDrainBudget);
_maxPreWindowRows = Math.Max(1, maxPreWindowRows);
_maxRawValuesPerRead = Math.Max(0, maxRawValuesPerRead);
@@ -400,8 +422,10 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
.ConfigureAwait(false);
// Mapped OUTSIDE the failure-recording try: an aggregate the gateway has no mode for is a client
// request the server answers BadAggregateNotSupported, not a backend fault to count.
// request the server answers BadAggregateNotSupported, not a backend fault to count. The native pair is refused
// the same way unless opted in (Good-status wrong values until historiangw#27).
var mode = AggregateModeMapper.ToRetrievalMode(aggregate);
if (!_enableNativeTimeWeighted) throw new HistoryAggregateNotSupportedException(aggregate);
// Planned OUTSIDE the failure-recording try: too many intervals is a client request limit, not a backend fault.
return await ReadNativeAsync(fullReference, BucketPlan.Create(startUtc, endUtc, interval), mode, cancellationToken)
.ConfigureAwait(false);
@@ -621,8 +645,10 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
public bool SupportsReverseEvents => false;
/// <inheritdoc />
// ServedAggregates; every other member answers BadAggregateNotSupported and is not advertised.
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => ServedAggregates;
// ServedAggregates with EnableNativeTimeWeighted, else RawEvaluatedAggregates; every other member answers
// BadAggregateNotSupported and is not advertised.
public IReadOnlySet<HistoryAggregateType> SupportedAggregates =>
_enableNativeTimeWeighted ? ServedAggregates : RawEvaluatedAggregates;
/// <inheritdoc />
// A reverse (newest-first) read is refused with NotSupportedException — the gateway's
@@ -48,7 +48,9 @@ public static class GatewayHistorian
// The server raw cap (R08:1.1): the page limit the server reads at, and the bound on every raw read.
maxRawValuesPerRead: options.EffectiveMaxRawValuesPerRead,
// How far a capped events read drains its stream past the cap before abandoning it.
eventDrainBudget: options.Gateway.EventDrainBudget);
eventDrainBudget: options.Gateway.EventDrainBudget,
// AVEVA's native TimeAverage / Total — off until historiangw#27 is live-verified.
enableNativeTimeWeighted: options.Gateway.EnableNativeTimeWeighted);
}
/// <summary>