From 0b85c499e36b8cb4a3c30b496139aedd5c5b15cb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 16:30:19 -0400 Subject: [PATCH] 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 --- docs/Historian.md | 22 ++++- .../GatewayHistorianDataSource.cs | 34 +++++++- ...wayHistorianServiceCollectionExtensions.cs | 4 +- .../Historian/ServerHistorianOptions.cs | 9 ++ .../GatewayHistorianDataSourceTests.cs | 6 +- .../GatewayMissingTagTests.cs | 3 +- .../GatewayNativeAggregateGridTests.cs | 2 +- .../GatewayNativeAggregateRowsTests.cs | 2 +- .../GatewayNativeTimeWeightedGateTests.cs | 85 +++++++++++++++++++ .../GatewayRawAggregateTests.cs | 2 +- .../Mapping/AggregateModeMapperTests.cs | 4 +- 11 files changed, 157 insertions(+), 16 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayNativeTimeWeightedGateTests.cs diff --git a/docs/Historian.md b/docs/Historian.md index e6801b31e..214a59df5 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -156,6 +156,13 @@ Processed read is `BadAggregateNotSupported` and no history aggregate functions | `HistoryReadDeadline` | TimeSpan | `00:01:00` | Per-request wall-clock deadline for a HistoryRead (and HistoryUpdate) batch (arch-review 03/S3). A node still in flight at the deadline surfaces `BadTimeout`, so one request can never hold an SDK request thread longer than this regardless of node count. Non-positive = unbounded (a `Validate()` warning). | | `HistoryUpdateMaxFutureSkew` | TimeSpan | `1.00:00:00` | #674. How far past the server clock a HistoryUpdate (Insert / Replace / Update) item's `SourceTimestamp` may lie; an item beyond `now + skew` is refused **`BadOutOfRange`** by the node manager before any backend sees it (every backend). Guards against a far-future typo (a 2062 timestamp). The live recorder is not bounded. Non-positive disables the bound (no warning — a deliberate choice). See [HistoryUpdate](#historyupdate). | +**`ServerHistorian:Gateway`** (read only when `Backend=Gateway`): knobs that exist because of the HistorianGateway's wire contract. + +| Key | Type | Default | Description | +|---|---|---|---| +| `Gateway:EnableNativeTimeWeighted` | bool | `false` | Serve HistoryRead-Processed `TimeAverage` / `Total` through AVEVA's native retrieval modes. ⚠️ **Off by default (client-visible):** the gateway sends AVEVA the interval in the wrong unit (historiangw#27), so AVEVA answers one Good-status whole-span value on the earliest interval and nothing for the rest — wrong values a client cannot tell from right ones. Off, both are `BadAggregateNotSupported` and are **not advertised** (`AggregateFunctions`), like the other five time-weighted aggregates on this backend. Turn it on only after a fixed gateway is live-verified. | +| `Gateway:EventDrainBudget` | int | `10000` | Stream rows a capped HistoryReadEvents read consumes (and discards) past its page cap before it abandons the gateway stream. The gateway's `ReadEvents` has no max-events field, so the cap is applied client-side; an **abandoned** stream cancels the RPC and the gateway evicts its pooled historian session (the next read reconnects; concurrent reads on that session fail). The usual small overshoot is drained to the stream's end; past the budget the stream is abandoned, so a page over a window far larger than the page does not stream the whole window. Rows of another source never count before the "more events" signal is seen, so an abandoned stream never answers a page as complete. `0` = abandon at the cap (the previous behaviour); negative counts as `0` (a `Validate()` warning). | + **`ServerHistorian:SqlServer`** (read only when `Backend=SqlServer`; bound by `HistorianBackendRegistry.BindSqlServer`): | Key | Type | Default | Validation | Description | @@ -987,6 +994,10 @@ says is complete carries no continuation point. `NumValuesPerNode == 0` stays un - **Tie exactness depends on the backend.** SqlServer and QuestDB order by `(EventTimeUtc, EventId)`, so the skip count is exact. The Gateway backend has no stable intra-timestamp order (and only a truncation flag), so ties at a page boundary of a **forward** read are best-effort there; a walked-back **reverse** read (below) sorts them by `EventId` itself and is exact. +- **Gateway streams are drained, not abandoned.** The gateway's `ReadEvents` carries no max-events field, so a capped page is cut + client-side; the rest of the stream is consumed (uncollected) up to `ServerHistorian:Gateway:EventDrainBudget` rows (default + 10 000) so the gateway does not evict its historian session for a cancelled stream. A page over a window much larger than the + budget still abandons its stream. A caller cancellation (client abort, `HistoryReadDeadline`) is not counted as a backend failure. - **Reverse event reads page newest-first (#675).** `StartTime > EndTime` reads the Part 11 reverse window `(EndTime, StartTime]` with events in DESCENDING `(EventTimeUtc, EventId)` order, so a cap keeps the **latest** N and a continuation point is issued when the source flags more. The direction is stored **in the cursor**: a reverse point resumes reverse (and a forward one @@ -1171,6 +1182,8 @@ gateway refuses `start > end` and `interval <= 0`): intervals (e.g. 1 s over 2 days) is refused up front with `BadHistoryOperationUnsupported`, as on the SQL backends — it used to be served up to the gateway's own `MaxAggregateIntervals` (1,000,000). Not counted as a backend failure. A clipped interval shorter than 1 ms (a sub-ms window end) is sent as a sub-ms interval request; re-check it in the post-historiangw#27 live leg. +- ⚠️ **Native TimeAverage / Total are off by default** (`ServerHistorian:Gateway:EnableNativeTimeWeighted=false` ⇒ + `BadAggregateNotSupported`, unsent, unadvertised) because of the next point; everything in this section describes the opted-in path. - ⚠️ **Until historiangw#27 is fixed, native values are wrong in BOTH directions with a Good status:** AVEVA answers one row per request (the whole span's value), which lands on that request's first interval as a Good, Calculated value while the other intervals are absent. Pinned against a native-mode fake only — re-run the forward and reverse native legs live once the gateway is fixed. Since #681 every native bucket is stamped with its interval **start** @@ -1192,9 +1205,12 @@ field (`tail + 43`) its own reverse-engineering notes still mark unverified (His > statuses may differ from AVEVA's own modes; every per-sample result now carries the Calculated info bits; a per-sample read is > bounded by **`ServerHistorian:MaxTimeWeightedRawRows`** (in-window raw rows; past it ⇒ `BadTooManyOperations` for that node — read > a long window over a dense tag in shorter windows). `TimeAverage` and `Total` stay AVEVA-native (`TimeWeightedAverage` / -> `Integral`), now stamped with the interval start (was its end) and carrying the Calculated bit; `TimeAverage2`, `Total2`, -> `Interpolative`, `PercentGood` and `PercentBad` remain `BadAggregateNotSupported` on Gateway. The served set is the single -> static `GatewayHistorianDataSource.ServedAggregates`. +> `Integral`) — ⚠️ **and are refused `BadAggregateNotSupported` unless `ServerHistorian:Gateway:EnableNativeTimeWeighted` is +> `true`** (default `false`, until historiangw#27 is live-verified; the default set is the ten raw-evaluated aggregates, +> `GatewayHistorianDataSource.RawEvaluatedAggregates`) — now stamped with the interval start (was its end) and carrying the Calculated bit; `TimeAverage2`, `Total2`, +> `Interpolative`, `PercentGood` and `PercentBad` remain `BadAggregateNotSupported` on Gateway. The most the backend can serve is +> `GatewayHistorianDataSource.ServedAggregates` (12); an instance serves and advertises `SupportedAggregates` — those 12 with the +> flag on, the 10 raw-evaluated ones without it. **How the Gateway raw evaluation works (#681).** Forward `ReadRaw` reads only (the gateway streams oldest-first and refuses `end < start`; `BucketPlan` assembles a reverse result newest-first), O(buckets) memory, and **every gateway stream is consumed to diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs index 90ebabd6a..1bf418e15 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs @@ -38,11 +38,27 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis /// returns, so the advertised AggregateFunctions folders and the server's /// up-front BadAggregateNotSupported read it too. /// + /// + /// The two native members are served only with ServerHistorian:Gateway:EnableNativeTimeWeighted: 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) only — see + /// . + /// public static IReadOnlySet ServedAggregates { get; } = Enum.GetValues() .Where(a => RawAggregateReader.Serves(a) || AggregateModeMapper.NativeAggregates.Contains(a)) .ToFrozenSet(); + /// + /// The per-sample aggregates this backend evaluates from raw reads — what an instance serves without + /// ServerHistorian:Gateway:EnableNativeTimeWeighted (the default). + /// + public static IReadOnlySet RawEvaluatedAggregates { get; } = + Enum.GetValues().Where(RawAggregateReader.Serves).ToFrozenSet(); + + /// Whether AVEVA's native TimeAverage / Total are served (opt-in; see ). + private readonly bool _enableNativeTimeWeighted; + private readonly IHistorianGatewayClient _client; private readonly ILogger _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. <= 0 = unbounded (the pre-#736 /// behaviour: an uncapped read is sent as ). /// + /// + /// Serve TimeAverage / Total through AVEVA's native retrieval modes (ServerHistorian:Gateway:EnableNativeTimeWeighted, + /// default false): until historiangw#27 is live-verified they answer Good-status wrong values, so by default they + /// are refused (BadAggregateNotSupported) and not advertised. + /// /// /// Stream rows a capped events read consumes (uncollected) past its cap before it abandons the gateway stream /// (ServerHistorian:Gateway:EventDrainBudget). Negative counts as 0 = abandon at the cap. @@ -134,8 +155,9 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis public GatewayHistorianDataSource(IHistorianGatewayClient client, ILogger 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; /// - // ServedAggregates; every other member answers BadAggregateNotSupported and is not advertised. - public IReadOnlySet SupportedAggregates => ServedAggregates; + // ServedAggregates with EnableNativeTimeWeighted, else RawEvaluatedAggregates; every other member answers + // BadAggregateNotSupported and is not advertised. + public IReadOnlySet SupportedAggregates => + _enableNativeTimeWeighted ? ServedAggregates : RawEvaluatedAggregates; /// // A reverse (newest-first) read is refused with NotSupportedException — the gateway's diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs index ea2ff2efb..98f7273ce 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs @@ -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); } /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs index 81d5bdf10..c44b9c67c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs @@ -282,4 +282,13 @@ public sealed class GatewayHistorianSettings /// does not stream the whole window. Default 10 000; 0 = abandon at the cap (the previous behaviour). /// public int EventDrainBudget { get; init; } = DefaultEventDrainBudget; + + /// + /// Serve HistoryRead-Processed TimeAverage / Total through AVEVA's native retrieval modes. Default + /// false: the gateway hands AVEVA the interval in the wrong unit (historiangw#27), so AVEVA answers ONE + /// Good-status whole-span value on the earliest interval and nothing for the rest — wrong values a client cannot + /// tell from right ones. Off, both are BadAggregateNotSupported and are not advertised (like the other five + /// time-weighted aggregates on this backend). Turn it on only once a fixed gateway is live-verified. + /// + public bool EnableNativeTimeWeighted { get; init; } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayHistorianDataSourceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayHistorianDataSourceTests.cs index e5406dc1e..9deb01ffd 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayHistorianDataSourceTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayHistorianDataSourceTests.cs @@ -35,7 +35,7 @@ public sealed class GatewayHistorianDataSourceTests public async Task ReadProcessed_uses_aggregate_mode_mapping() { var fake = new FakeHistorianGatewayClient(); - var ds = new GatewayHistorianDataSource(fake, NullLogger.Instance); + var ds = new GatewayHistorianDataSource(fake, NullLogger.Instance, enableNativeTimeWeighted: true); await ds.ReadProcessedAsync("T", Hour0, Hour0.AddHours(1), TimeSpan.FromSeconds(60), HistoryAggregateType.Total, TestContext.Current.CancellationToken); // an empty window sends nothing Assert.Equal(RetrievalMode.Integral, fake.LastAggregateMode); Assert.Equal(TimeSpan.FromSeconds(60), fake.LastAggregateInterval); @@ -53,7 +53,7 @@ public sealed class GatewayHistorianDataSourceTests new HistorianAggregateSample { OpcQuality = 0, StartTime = Timestamp.FromDateTime(start.AddMinutes(1)), EndTime = Timestamp.FromDateTime(start.AddMinutes(2)) }, }, }; - var ds = new GatewayHistorianDataSource(fake, NullLogger.Instance); + var ds = new GatewayHistorianDataSource(fake, NullLogger.Instance, enableNativeTimeWeighted: true); var r = await ds.ReadProcessedAsync("T", start, start.AddMinutes(2), TimeSpan.FromMinutes(1), HistoryAggregateType.TimeAverage, TestContext.Current.CancellationToken); Assert.Equal(new DateTime?[] { start, start.AddMinutes(1) }, r.Samples.Select(x => x.SourceTimestampUtc).ToArray()); Assert.Equal(new[] { 0x00000401u, 0x809B0401u }, r.Samples.Select(x => x.StatusCode).ToArray()); @@ -63,7 +63,7 @@ public sealed class GatewayHistorianDataSourceTests public async Task ReadProcessed_TimeAverage_requests_TimeWeightedAverage() { var fake = new FakeHistorianGatewayClient(); - var ds = new GatewayHistorianDataSource(fake, NullLogger.Instance); + var ds = new GatewayHistorianDataSource(fake, NullLogger.Instance, enableNativeTimeWeighted: true); await ds.ReadProcessedAsync("T", Hour0, Hour0.AddHours(1), TimeSpan.FromSeconds(60), HistoryAggregateType.TimeAverage, TestContext.Current.CancellationToken); // an empty window sends nothing Assert.Equal(RetrievalMode.TimeWeightedAverage, fake.LastAggregateMode); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayMissingTagTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayMissingTagTests.cs index 992ae6fd3..d47b853c6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayMissingTagTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayMissingTagTests.cs @@ -18,7 +18,8 @@ public sealed class GatewayMissingTagTests public override DateTimeOffset GetUtcNow() => new(Current); } private static GatewayHistorianDataSource Source(FakeHistorianGatewayClient fake, Clock? clock = null, bool native = false) => - new(fake, NullLogger.Instance, timeProvider: clock, atTimeFromRaw: !native); + new(fake, NullLogger.Instance, timeProvider: clock, atTimeFromRaw: !native, + enableNativeTimeWeighted: true); [Theory] [InlineData("Raw")] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayNativeAggregateGridTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayNativeAggregateGridTests.cs index de71843d2..5f5f8d808 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayNativeAggregateGridTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayNativeAggregateGridTests.cs @@ -77,7 +77,7 @@ public sealed class GatewayNativeAggregateGridTests }; private static GatewayHistorianDataSource Source(FakeHistorianGatewayClient fake) => - new(fake, NullLogger.Instance); + new(fake, NullLogger.Instance, enableNativeTimeWeighted: true); [Theory] [InlineData(HistoryAggregateType.TimeAverage, RetrievalMode.TimeWeightedAverage)] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayNativeAggregateRowsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayNativeAggregateRowsTests.cs index 43950ff72..805f5eec6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayNativeAggregateRowsTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayNativeAggregateRowsTests.cs @@ -40,7 +40,7 @@ public sealed class GatewayNativeAggregateRowsTests DateTime start, DateTime end, HistoryAggregateType aggregate = HistoryAggregateType.TimeAverage) { var fake = new FakeHistorianGatewayClient { AggregateSamples = wire }; - var ds = new GatewayHistorianDataSource(fake, NullLogger.Instance); + var ds = new GatewayHistorianDataSource(fake, NullLogger.Instance, enableNativeTimeWeighted: true); return (await ds.ReadProcessedAsync("T", start, end, I20, aggregate, Ct)).Samples; } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayNativeTimeWeightedGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayNativeTimeWeightedGateTests.cs new file mode 100644 index 000000000..9e10914ac --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayNativeTimeWeightedGateTests.cs @@ -0,0 +1,85 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests; + +/// +/// AVEVA's native TimeAverage / Total answer Good-status wrong values until the gateway's interval fix +/// (historiangw#27) is live-verified, so they are refused BadAggregateNotSupported unless +/// ServerHistorian:Gateway:EnableNativeTimeWeighted opts back in. +/// +public sealed class GatewayNativeTimeWeightedGateTests +{ + private static readonly DateTime T0 = new(2026, 10, 22, 0, 0, 0, DateTimeKind.Utc); + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData(HistoryAggregateType.TimeAverage)] + [InlineData(HistoryAggregateType.Total)] + public async Task By_default_the_native_time_weighted_pair_is_refused_unsent_and_unadvertised(HistoryAggregateType a) + { + var fake = new FakeHistorianGatewayClient(); + var ds = new GatewayHistorianDataSource(fake, NullLogger.Instance); + + ds.SupportedAggregates.ShouldNotContain(a); + await Should.ThrowAsync( + () => ds.ReadProcessedAsync("T", T0, T0.AddHours(1), TimeSpan.FromMinutes(1), a, Ct)); + fake.ReadAggregateCallCount.ShouldBe(0); + fake.TagExistsCallCount.ShouldBe(0); + ds.GetHealthSnapshot().TotalQueries.ShouldBe(0, "a refused aggregate is a client request, not a backend read"); + + // Positive control: a raw-evaluated per-sample aggregate on the same source is still served. + ds.SupportedAggregates.ShouldContain(HistoryAggregateType.Average); + var avg = await ds.ReadProcessedAsync("T", T0, T0.AddHours(1), TimeSpan.FromMinutes(30), HistoryAggregateType.Average, Ct); + avg.Samples.Count.ShouldBe(2); + fake.ReadRawCallCount.ShouldBeGreaterThan(0); + } + + [Theory] + [InlineData(HistoryAggregateType.TimeAverage)] + [InlineData(HistoryAggregateType.Total)] + public async Task Opting_in_serves_and_advertises_the_native_pair(HistoryAggregateType a) + { + var fake = new FakeHistorianGatewayClient(); + var ds = new GatewayHistorianDataSource(fake, NullLogger.Instance, enableNativeTimeWeighted: true); + + ds.SupportedAggregates.ShouldBe(GatewayHistorianDataSource.ServedAggregates); + await ds.ReadProcessedAsync("T", T0, T0.AddHours(1), TimeSpan.FromMinutes(1), a, Ct); + fake.ReadAggregateCallCount.ShouldBeGreaterThan(0); + } + + [Fact] + public void The_default_set_is_the_ten_raw_evaluated_aggregates() + { + var ds = new GatewayHistorianDataSource(new FakeHistorianGatewayClient(), NullLogger.Instance); + ds.SupportedAggregates.Count.ShouldBe(10); + ds.SupportedAggregates.ShouldBe(GatewayHistorianDataSource.RawEvaluatedAggregates); + GatewayHistorianDataSource.ServedAggregates.Except(ds.SupportedAggregates).OrderBy(x => x) + .ShouldBe([HistoryAggregateType.Total, HistoryAggregateType.TimeAverage]); + } + + [Theory] + [InlineData(null, false)] + [InlineData("false", false)] + [InlineData("true", true)] + public void The_flag_binds_from_configuration_and_reaches_the_data_source(string? configured, bool expected) + { + var values = new Dictionary + { + ["ServerHistorian:Enabled"] = "true", + ["ServerHistorian:Endpoint"] = "https://localhost:5222", + ["ServerHistorian:ApiKey"] = "histgw_x_y", + }; + if (configured is not null) values["ServerHistorian:Gateway:EnableNativeTimeWeighted"] = configured; + var opts = new ConfigurationBuilder().AddInMemoryCollection(values).Build() + .GetSection(ServerHistorianOptions.SectionName).Get()!; + using var services = new ServiceCollection().BuildServiceProvider(); + var ds = Assert.IsType(GatewayHistorian.CreateDataSource(opts, services)); + ds.SupportedAggregates.Contains(HistoryAggregateType.TimeAverage).ShouldBe(expected); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayRawAggregateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayRawAggregateTests.cs index d1759e114..2a390a46c 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayRawAggregateTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayRawAggregateTests.cs @@ -572,7 +572,7 @@ public sealed class GatewayRawAggregateTests { var fake = Store(A); var ds = Source(fake); - if (GatewayHistorianDataSource.ServedAggregates.Contains(agg)) + if (ds.SupportedAggregates.Contains(agg)) { await ds.ReadProcessedAsync("A", At(0), At(40), TimeSpan.FromSeconds(20), agg, Ct); (fake.ReadRawCallCount + fake.ReadAggregateCallCount).ShouldBeGreaterThan(0, agg.ToString()); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AggregateModeMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AggregateModeMapperTests.cs index 369db52d2..04fadbd22 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AggregateModeMapperTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/AggregateModeMapperTests.cs @@ -51,8 +51,10 @@ public sealed class AggregateModeMapperTests } Assert.Equal(12, GatewayHistorianDataSource.ServedAggregates.Count); + // The native pair is opt-in (ServerHistorian:Gateway:EnableNativeTimeWeighted) — see GatewayNativeTimeWeightedGateTests. var source = new GatewayHistorianDataSource( - new FakeHistorianGatewayClient(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + new FakeHistorianGatewayClient(), Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, + enableNativeTimeWeighted: true); Assert.Same(GatewayHistorianDataSource.ServedAggregates, source.SupportedAggregates); }