feat(historian-gateway): native TimeAverage/Total on by default, SendEvents batches, server-side max_events (#694, #746)

#694 item 3 — historiangw#27 is fixed and now live-verified through OtOpcUa:
- ServerHistorian:Gateway:EnableNativeTimeWeighted defaults to true (false is
  the kill switch for a gateway older than 9f711c1). TimeAverage / Total are
  served and advertised again (12 aggregates).
- The gateway sends the interval as max(1, round(ms)) and AVEVA clips its last
  interval at the request end (live). NativeAggregatePlan therefore sends a
  multi-interval grid only for whole-ms intervals; a fractional interval is read
  one interval per request (at most 1,000, else BadTooManyOperations), and every
  one-interval request goes out rounded UP to whole ms so AVEVA answers one
  clipped row. Live, a 3.0004 s clipped interval sent at its own width lost its
  last 0.4 ms (Total 123 vs 123.0172).

#746 — HistorianGateway Contracts/Client 1.0.0 surface:
- Alarm history: GatewayAlarmHistorianWriter sends SendEvents batches of <=200.
  A failed per-event result is re-sent alone via SendEvent (classified as
  before); a refused batch (InvalidArgument etc.) goes event by event so a
  poison event dead-letters alone; a systemic fault retries the rest without
  sending more; Unimplemented (older gateway) falls back to SendEvent for good.
  IHistorianGatewayClient.SendEventsAsync defaults to Unimplemented.
- Events reads: a capped page sends cap + 1 as ReadEventsRequest.max_events, so
  the gateway completes the stream instead of the client cancelling it. An
  ambiguous capped answer (foreign rows filtered out of the server's cut) or a
  refused cap is re-read uncapped with the existing EventDrainBudget drain.

Tests: GatewayServerCappedEventsTests, GatewayAlarmWriterBatchTests,
GatewayNativeWholeMillisecondGridTests (gateway-faithful rounding fake), gate
tests flipped to the new default; every new test mutation-checked (each
mutation of the production change fails at least one). Live harness
AvevaGatewayHistorianFollowupsLiveGateTests (read-only) for #694/#700/#746.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-12 23:57:38 -05:00
parent 09b373e4db
commit 53978ca638
22 changed files with 1847 additions and 176 deletions
@@ -150,24 +150,25 @@ public sealed class HistorianBackendRegistryTests
IHistorianDataSource Source(ServerHistorianOptions o, IConfiguration c) =>
HistorianBackendRegistry.Resolve(o, c).CreateDataSource(sp);
// #681: the ten per-sample aggregates (raw-evaluated). The two AVEVA-native ones (TimeAverage / Total) are
// refused and not advertised by default until historiangw#27 is fixed (#746, R08:2.1) ...
// #681: the ten per-sample aggregates (raw-evaluated), plus — by default since historiangw#27 was live-verified
// (lmxopcua#694) — the two AVEVA-native ones (TimeAverage / Total) ...
HistoryAggregateType[] gatewayRawEvaluated =
[
HistoryAggregateType.Average, HistoryAggregateType.Count, HistoryAggregateType.Minimum,
HistoryAggregateType.Maximum, HistoryAggregateType.Range, HistoryAggregateType.Start, HistoryAggregateType.End,
HistoryAggregateType.Delta, HistoryAggregateType.MinimumActualTime, HistoryAggregateType.MaximumActualTime,
];
Source(Gateway(), Cfg).SupportedAggregates.ShouldBe(gatewayRawEvaluated, ignoreOrder: true);
Source(Gateway(), Cfg).SupportedAggregates.ShouldBe(
[.. gatewayRawEvaluated, HistoryAggregateType.TimeAverage, HistoryAggregateType.Total], ignoreOrder: true);
// ... and served again, through the Host's resolve path, once ServerHistorian:Gateway:EnableNativeTimeWeighted is on.
var nativeOn = new ServerHistorianOptions
// ... and refused + unadvertised again, through the Host's resolve path, with the
// ServerHistorian:Gateway:EnableNativeTimeWeighted=false kill switch (a gateway older than the #27 fix).
var nativeOff = new ServerHistorianOptions
{
Backend = "Gateway", Enabled = true, Endpoint = "http://localhost:5222", ApiKey = "histgw_id_secret", UseTls = false,
Gateway = new GatewayHistorianSettings { EnableNativeTimeWeighted = true },
Gateway = new GatewayHistorianSettings { EnableNativeTimeWeighted = false },
};
Source(nativeOn, Cfg).SupportedAggregates.ShouldBe(
[.. gatewayRawEvaluated, HistoryAggregateType.TimeAverage, HistoryAggregateType.Total], ignoreOrder: true);
Source(nativeOff, Cfg).SupportedAggregates.ShouldBe(gatewayRawEvaluated, ignoreOrder: true);
Source(new ServerHistorianOptions { Backend = "SqlServer", Enabled = true }, SqlCfg())
.SupportedAggregates.ShouldBe(SqlServed, ignoreOrder: true);
Source(new ServerHistorianOptions { Backend = "QuestDb", Enabled = true }, QuestDbCfg())
@@ -20,11 +20,12 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// </summary>
public sealed class AdvertisedAggregateFunctionsWireTests
{
// #681: the ten per-sample aggregates (raw-evaluated). AVEVA-native TimeAverage / Total are no longer advertised by
// default (lmxopcua#746 refuses them until historiangw#27 is fixed and verified; EnableNativeTimeWeighted=true restores them).
// #681: the ten per-sample aggregates (raw-evaluated) plus the AVEVA-native TimeAverage / Total, advertised by default
// again since historiangw#27 was live-verified (lmxopcua#694; EnableNativeTimeWeighted=false is the kill switch).
private static readonly string[] GatewayNames =
[
"Average", "Count", "Delta", "End", "Maximum", "MaximumActualTime", "Minimum", "MinimumActualTime", "Range", "Start",
"TimeAverage", "Total",
];
// #820 added DurationBad / DurationGood (the PercentBad / PercentGood region times, in ms).
@@ -0,0 +1,817 @@
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// Live gate for the historian follow-ups lmxopcua#694 (item 3), #700 and #746 (item 1): the booted OtOpcUa server with
/// the <b>production</b> Gateway backend (<see cref="HistorianBackendRegistry"/>, default <see cref="ServerHistorianOptions"/>)
/// in front of a REAL HistorianGateway and the real AVEVA Historian, read over a real SDK client session.
/// </summary>
/// <remarks>
/// <para><b>Read-only.</b> Only HistoryRead (raw, processed, at-time, events) is sent; the Gateway backend has no updater,
/// and no provisioner, recorder or alarm writer is built.</para>
/// <para><b>What it checks.</b></para>
/// <list type="bullet">
/// <item>#694.3 — native <c>TimeAverage</c> / <c>Total</c> (now on by default): forward and reverse over whole
/// intervals, a clipped interval, a sub-millisecond clipped interval and a fractional-millisecond interval on
/// <c>SysTimeSec</c>, each bucket checked against an independent step-held integral of the tag's own raw rows; and
/// an outage window on a float tag whose middle intervals hold no good time at all (all-NULL ⇒ <c>BadNoData</c>).</item>
/// <item>#700 — missing and existing tag Raw / Processed / AtTime; history values typed as the node's DataType
/// (Int32, Boolean, Float); future AtTime keeps the requested timestamps and order; a transport outage answers
/// <c>BadNoCommunication</c> while unsupported operations keep their own status.</item>
/// <item>#746.1 — events paging over the wire equals the whole read, through a data source whose
/// <c>EventDrainBudget</c> is 0: with the server-side <c>max_events</c> no page ever abandons its gateway stream.</item>
/// </list>
/// <para><b>Env:</b> <c>HISTGW_GATEWAY_ENDPOINT</c> and <c>HISTGW_GATEWAY_APIKEY</c> (any value against a gateway with
/// API-key auth disabled) — skips when unset; optional <c>OTOPCUA_AVEVA_GATE_OUT</c>, <c>OTOPCUA_AVEVA_GATE_LABEL</c>.
/// The strict-ceiling fixture (<see cref="Strict_ceiling_fixture_stopped_tag_reverse_cost_and_raised_lowered_limits"/>)
/// additionally needs <c>OTOPCUA_AVEVA_GATE_STRICT_ENDPOINT</c> (a gateway with <c>AllowStreamingBeyondCeiling=false</c>)
/// and <c>OTOPCUA_AVEVA_GATE_STRICT_SET_CEILING</c> (a script that restarts that gateway with the ceiling given as its
/// argument). The windows are fixed past DEV (wonder-sql-vd03) windows.</para>
/// </remarks>
public sealed class AvevaGatewayHistorianFollowupsLiveGateTests
{
private const string SysTimeSec = "SysTimeSec";
private const string SysPulse = "SysPulse";
private const string FloatTag = "ZIPSEN.WTC1";
private const string MissingTag = "OtOpcUa.Gate694.DoesNotExist";
private const string EventSource = "DevWinPlatform";
private static readonly DateTime S0 = Utc("2026-11-11T14:00:00Z");
// W1 on DEV: ZIPSEN.WTC1 is stored NULL at 11:32:36.073 (platform stop), then nothing until 11:38:34.853.
private static readonly DateTime OutageStart = Utc("2026-11-10T11:31:00Z");
private static readonly DateTime OutageEnd = Utc("2026-11-10T11:40:00Z");
private static readonly (DateTime Start, DateTime End) EventWindow = (Utc("2026-11-05T15:00:00Z"), Utc("2026-11-12T00:00:00Z"));
private const uint GoodCalculated = 0x00000401u;
private const uint PartialBit = 0x00000004u;
[Fact]
public async Task Native_time_weighted_typed_history_outage_and_event_paging_over_the_wire()
{
var endpoint = Env("HISTGW_GATEWAY_ENDPOINT");
var apiKey = Env("HISTGW_GATEWAY_APIKEY");
if (endpoint is null || apiKey is null)
Assert.Skip("Set HISTGW_GATEWAY_ENDPOINT and HISTGW_GATEWAY_APIKEY to run the #694/#700/#746 AVEVA gate.");
var ct = TestContext.Current.CancellationToken;
var options = Options(endpoint!, apiKey!);
options.Gateway.EnableNativeTimeWeighted.ShouldBeTrue("native TimeAverage / Total are on by default since #694");
var provider = HistorianBackendRegistry.Resolve(options, new ConfigurationBuilder().Build());
provider.CreateUpdater(new ServiceCollection().BuildServiceProvider()).ShouldBeNull();
using var source = provider.CreateDataSource(new ServiceCollection().BuildServiceProvider());
var report = new Report(Env("OTOPCUA_AVEVA_GATE_LABEL") ?? "unlabelled", endpoint!);
var failures = new List<string>();
await using var rig = await StartAsync("AvevaGate694", encrypted: false, ct);
rig.Nm.HistorianDataSource = source;
rig.Nm.MaxTieClusterOverfetch = options.MaxTieClusterOverfetch;
rig.Nm.HonorReturnBounds = options.HonorReturnBounds;
rig.Nm.EventReadHorizon = options.EventReadHorizon;
rig.Nm.HistoryReadBatchParallelism = options.HistoryReadBatchParallelism;
rig.Nm.MaxConcurrentHistoryReadBatches = options.MaxConcurrentHistoryReadBatches;
rig.Nm.HistoryReadDeadline = options.HistoryReadDeadline;
rig.Enforce(false, [Key()]);
var intNode = rig.EnsureRawVariable("HistInt", "Int32", writable: false, SysTimeSec);
var boolNode = rig.EnsureRawVariable("HistBool", "Boolean", writable: false, SysPulse);
var floatNode = rig.EnsureRawVariable("HistFloat", "Float", writable: false, FloatTag);
var dblNode = rig.EnsureRawVariable("HistDbl", "Double", writable: false, SysTimeSec);
var missingNode = rig.EnsureRawVariable("HistMissing", "Double", writable: false, MissingTag);
rig.Sink.EnsureFolder(EventSource, parentNodeId: null, displayName: EventSource, realm: AddressSpaceRealm.Raw);
rig.Sink.MaterialiseAlarmCondition($"{EventSource}/gate694", EventSource, "gate694", "OffNormalAlarm", 500,
AddressSpaceRealm.Raw, isNative: true);
using var session = await rig.ConnectAsync(anonymous: true, ct);
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
NodeId Node(string id) => new(id, ns);
// ================================================================ #694.3 — native TimeAverage / Total
var caps = await AggregateFunctionsAsync(session, ct);
report.Add(new Case("694.advertised", null, null, "wire", "Good", caps, null));
Check(failures, caps.Split(',').Contains("TimeAverage") && caps.Split(',').Contains("Total"),
$"TimeAverage and Total must be advertised by default, got {caps}");
// The step-held reference: SysTimeSec is an integer tag, which AVEVA integrates as a step (live: [14:00:00, 14:00:20) of
// 0..19 answers TimeWeightedAverage 9.5 / Integral 190). Raw rows straddling every case window.
var refRows = (await source.ReadRawAsync(SysTimeSec, S0.AddSeconds(-5), S0.AddMinutes(4), 0, ct)).Samples
.Where(s => s.Value is not null).Select(s => (T: s.SourceTimestampUtc!.Value, V: Convert.ToDouble(s.Value, CultureInfo.InvariantCulture)))
.ToList();
Check(failures, refRows.Count >= 240, $"SysTimeSec reference: expected ~1 Hz rows, got {refRows.Count}");
double StepIntegral(DateTime a, DateTime b)
{
double sum = 0;
for (var i = 0; i < refRows.Count; i++)
{
var from = refRows[i].T;
var to = i + 1 < refRows.Count ? refRows[i + 1].T : DateTime.MaxValue;
var lo = from > a ? from : a;
var hi = to < b ? to : b;
if (hi > lo) sum += refRows[i].V * (hi - lo).TotalSeconds;
}
return sum;
}
var nativeCases = new (string Name, DateTime Start, DateTime End, double IntervalMs)[]
{
("forward.whole", S0, S0.AddMinutes(2), 20_000),
("reverse.whole", S0.AddMinutes(2), S0, 20_000),
("forward.clipped7s", S0, S0.AddSeconds(127), 20_000),
("reverse.clipped7s", S0.AddSeconds(127), S0, 20_000),
("forward.subMsClipped", S0, S0.AddSeconds(40).AddTicks(4_000), 20_000), // last interval 0.4 ms at value 40
("forward.nonWholeMsClipped", S0, S0.AddSeconds(43).AddTicks(4_000), 20_000), // last interval 3.0004 s
("reverse.subMsClipped", S0.AddSeconds(43).AddTicks(4_000), S0.AddSeconds(3), 20_000), // earliest (3, 3.0004] at value 3
("forward.fractionalMs", S0.AddSeconds(3).AddTicks(9_970_000), S0.AddSeconds(4).AddTicks(30_000), 1.5), // straddles :04
("reverse.fractionalMs", S0.AddSeconds(4).AddTicks(30_000), S0.AddSeconds(3).AddTicks(9_970_000), 1.5),
};
foreach (var (name, start, end, intervalMs) in nativeCases)
{
foreach (var (aggName, aggId, aggType) in new[]
{
("TimeAverage", ObjectIds.AggregateFunction_TimeAverage, HistoryAggregateType.TimeAverage),
("Total", ObjectIds.AggregateFunction_Total, HistoryAggregateType.Total),
})
{
var w = $"{start:o}/{end:o}@{intervalMs}ms";
var wire = await ReadProcessedAsync(session, Node(dblNode), start, end, intervalMs, aggId, ct);
var direct = await source.ReadProcessedAsync(SysTimeSec, start, end, TimeSpan.FromTicks((long)(intervalMs * TimeSpan.TicksPerMillisecond)), aggType, ct);
report.Add(new Case($"694.{aggName}.{name}", SysTimeSec, w, "wire", wire.Status, null, wire.Rows));
CheckSame(failures, $"694.{aggName}.{name}", wire.Rows, Rows(direct.Samples));
// The independent reference, interval by interval on the Part 13 grid (#677: reverse steps back from StartTime).
var expected = Grid(start, end, TimeSpan.FromTicks((long)(intervalMs * TimeSpan.TicksPerMillisecond)));
Check(failures, wire.Rows.Count == expected.Count, $"694.{aggName}.{name}: {wire.Rows.Count} buckets vs {expected.Count} intervals");
var reference = new List<string>();
for (var i = 0; i < Math.Min(wire.Rows.Count, expected.Count); i++)
{
var (lo, hi, stamp, clipped) = expected[i];
var integral = StepIntegral(lo, hi);
var want = aggType == HistoryAggregateType.Total ? integral : integral / (hi - lo).TotalSeconds;
var row = wire.Rows[i];
var got = double.Parse(row.V!, CultureInfo.InvariantCulture);
reference.Add($"[{lo:HH:mm:ss.ffffff},{hi:HH:mm:ss.ffffff}) want={want.ToString("R", CultureInfo.InvariantCulture)}");
Check(failures, Math.Abs(got - want) <= 1e-9 * Math.Max(1, Math.Abs(want)),
$"694.{aggName}.{name}[{i}] [{lo:o},{hi:o}): {got} vs step reference {want}");
Check(failures, row.Src == stamp, $"694.{aggName}.{name}[{i}]: stamp {row.Src:o} vs {stamp:o}");
Check(failures, row.Status == (clipped ? GoodCalculated | PartialBit : GoodCalculated),
$"694.{aggName}.{name}[{i}]: status 0x{row.Status:X8} (clipped={clipped})");
}
report.Add(new Case($"694.{aggName}.{name}", SysTimeSec, w, "reference", "Good", string.Join("; ", reference), null));
}
}
// All-NULL intervals: the float tag's outage (stored NULL at 11:32:36, nothing until 11:38:34).
foreach (var (aggName, aggId, aggType) in new[]
{
("TimeAverage", ObjectIds.AggregateFunction_TimeAverage, HistoryAggregateType.TimeAverage),
("Total", ObjectIds.AggregateFunction_Total, HistoryAggregateType.Total),
})
{
foreach (var (dir, start, end) in new[] { ("forward", OutageStart, OutageEnd), ("reverse", OutageEnd, OutageStart) })
{
var w = $"{start:o}/{end:o}@60000ms";
var wire = await ReadProcessedAsync(session, Node(floatNode), start, end, 60_000, aggId, ct);
var direct = await source.ReadProcessedAsync(FloatTag, start, end, TimeSpan.FromMinutes(1), aggType, ct);
report.Add(new Case($"694.{aggName}.allNull.{dir}", FloatTag, w, "wire", wire.Status, null, wire.Rows));
CheckSame(failures, $"694.{aggName}.allNull.{dir}", wire.Rows, Rows(direct.Samples));
Check(failures, wire.Rows.Count == 9, $"694.{aggName}.allNull.{dir}: {wire.Rows.Count} buckets vs 9");
// Interval start minute → expected class. 11:33–11:37 hold no good time at all; 11:32 and 11:38 hold some.
foreach (var row in wire.Rows)
{
var minuteStart = dir == "forward" ? row.Src!.Value : row.Src!.Value.AddMinutes(-1);
var m = minuteStart.Minute;
var cls = m is >= 33 and <= 37 ? "BadNoData" : m is 32 or 38 ? "Uncertain" : "Good";
var ok = cls switch
{
"BadNoData" => (row.Status & 0xFFFF0000u) == StatusCodes.BadNoData && row.V is null,
"Uncertain" => (row.Status & 0xC0000000u) == 0x40000000u && row.V is not null,
_ => (row.Status & 0xC0000000u) == 0 && row.V is not null,
};
Check(failures, ok, $"694.{aggName}.allNull.{dir} {minuteStart:HH:mm}: expected {cls}, got 0x{row.Status:X8} value {row.V ?? "null"}");
}
}
}
// ================================================================ #700 — missing / existing tag, DataTypes, AtTime
{
var (start, end) = (S0, S0.AddSeconds(30));
var w = $"{start:o}/{end:o}";
var raw = await ReadRawAsync(session, Node(missingNode), start, end, 0, ct);
var avg = await ReadProcessedAsync(session, Node(missingNode), start, end, 10_000, ObjectIds.AggregateFunction_Average, ct);
var tavg = await ReadProcessedAsync(session, Node(missingNode), start, end, 10_000, ObjectIds.AggregateFunction_TimeAverage, ct);
var at = await ReadAtTimeAsync(session, Node(missingNode), [start, start.AddSeconds(1.5)], ct);
report.Add(new Case("700.missing.raw", MissingTag, w, "wire", raw.Status, null, raw.Rows));
report.Add(new Case("700.missing.average", MissingTag, w, "wire", avg.Status, null, avg.Rows));
report.Add(new Case("700.missing.timeAverage", MissingTag, w, "wire", tavg.Status, null, tavg.Rows));
report.Add(new Case("700.missing.atTime", MissingTag, w, "wire", at.Status, null, at.Rows));
Check(failures, raw.Status == "GoodNoData" && raw.Rows.Count == 0, $"700.missing.raw: {raw.Status} rows={raw.Rows.Count}");
foreach (var (n, r) in new[] { ("average", avg), ("timeAverage", tavg) })
{
Check(failures, r.Rows.Count == 3, $"700.missing.{n}: {r.Rows.Count} buckets vs 3");
Check(failures, r.Rows.All(x => (x.Status & 0xFFFF0000u) == StatusCodes.BadNoData && x.V is null),
$"700.missing.{n}: every bucket must be BadNoData, got {string.Join(",", r.Rows.Select(x => $"0x{x.Status:X8}"))}");
}
Check(failures, at.Rows.Count == 2 && at.Rows.All(x => StatusCode.IsBad(x.Status)),
$"700.missing.atTime: expected two Bad values, got {at.Status} {string.Join(",", at.Rows.Select(x => $"0x{x.Status:X8}"))}");
}
foreach (var (label, node, tag, type, typeCode) in new[]
{
("int32", intNode, SysTimeSec, "Int32", TypeCode.Int32),
("boolean", boolNode, SysPulse, "Boolean", TypeCode.Boolean),
("float", floatNode, FloatTag, "Single", TypeCode.Single),
})
{
var (start, end) = label == "float"
? (Utc("2026-11-10T11:31:00Z"), Utc("2026-11-10T11:31:20Z"))
: label == "boolean" ? (S0, S0.AddMinutes(5)) : (S0, S0.AddSeconds(10));
var w = $"{start:o}/{end:o}";
var raw = await ReadRawAsync(session, Node(node), start, end, 0, ct);
var direct = await source.ReadRawAsync(tag, start, end, 0, ct);
report.Add(new Case($"700.existing.raw.{label}", tag, w, "wire", raw.Status, Types(raw.Rows), raw.Rows));
Check(failures, raw.Rows.Count > 0 && raw.Rows.Count == direct.Samples.Count,
$"700.existing.raw.{label}: wire {raw.Rows.Count} vs direct {direct.Samples.Count}");
Check(failures, raw.Rows.All(r => r.Type == type), $"700.existing.raw.{label}: value types {Types(raw.Rows)}, node DataType {type}");
for (var i = 0; i < Math.Min(raw.Rows.Count, direct.Samples.Count); i++)
Check(failures, raw.Rows[i].Src == direct.Samples[i].SourceTimestampUtc && raw.Rows[i].Status == direct.Samples[i].StatusCode,
$"700.existing.raw.{label}[{i}]: wire ({raw.Rows[i].Src:o}, 0x{raw.Rows[i].Status:X8}) vs direct");
// AtTime: a stored timestamp and a midpoint, typed as the node.
var times = new List<DateTime> { raw.Rows[0].Src!.Value };
if (raw.Rows.Count > 1) times.Add(raw.Rows[0].Src!.Value + (raw.Rows[1].Src!.Value - raw.Rows[0].Src!.Value) / 2);
var at = await ReadAtTimeAsync(session, Node(node), times, ct);
var atDirect = await source.ReadAtTimeAsync(tag, times, typeCode, ct);
report.Add(new Case($"700.existing.atTime.{label}", tag, string.Join(",", times.Select(t => t.ToString("o"))), "wire", at.Status,
Types(at.Rows), at.Rows));
CheckSame(failures, $"700.existing.atTime.{label}", at.Rows, Rows(atDirect.Samples));
Check(failures, at.Rows.Select(r => r.Src).SequenceEqual(times.Select(t => (DateTime?)t)),
$"700.existing.atTime.{label}: timestamps must be the requested ones, in order");
Check(failures, at.Rows.All(r => r.V is null || r.Type == type), $"700.existing.atTime.{label}: types {Types(at.Rows)} vs {type}");
// Processed: a per-sample and a native aggregate over the same window, wire vs the backend.
foreach (var (aggName, aggId, aggType) in new[]
{
("Average", ObjectIds.AggregateFunction_Average, HistoryAggregateType.Average),
("TimeAverage", ObjectIds.AggregateFunction_TimeAverage, HistoryAggregateType.TimeAverage),
})
{
var intervalMs = (end - start).TotalMilliseconds / 2;
var p = await ReadProcessedAsync(session, Node(node), start, end, intervalMs, aggId, ct);
ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult? d = null;
string? directError = null;
try { d = await source.ReadProcessedAsync(tag, start, end, TimeSpan.FromMilliseconds(intervalMs), aggType, ct); }
catch (Exception ex) { directError = $"{ex.GetType().Name}: {ex.Message}"; }
report.Add(new Case($"700.existing.processed.{aggName}.{label}", tag, w, "wire", p.Status,
$"{Types(p.Rows)} direct={directError ?? "ok"}", p.Rows));
if (label == "boolean" && aggType == HistoryAggregateType.TimeAverage)
{
// AVEVA refuses its native time-weighted modes on a discrete tag (StartQuery fails): recorded; the node
// must answer Bad, never a value.
Check(failures, p.Status.StartsWith("Bad", StringComparison.Ordinal) && p.Rows.Count == 0,
$"700.existing.processed.TimeAverage.boolean: {p.Status} rows={p.Rows.Count} (direct: {directError ?? "served"})");
continue;
}
if (d is null) { failures.Add($"700.existing.processed.{aggName}.{label}: direct read failed: {directError}"); continue; }
CheckSame(failures, $"700.existing.processed.{aggName}.{label}", p.Rows, Rows(d.Samples));
Check(failures, p.Rows.Count == 2, $"700.existing.processed.{aggName}.{label}: {p.Rows.Count} buckets");
}
}
// Future AtTime: requested timestamps and order preserved; cost recorded.
{
var now = DateTime.UtcNow;
var times = new[] { now.AddHours(1), now.AddMinutes(30), now.AddHours(2) };
var sw = Stopwatch.StartNew();
var at = await ReadAtTimeAsync(session, Node(intNode), times, ct);
sw.Stop();
report.Add(new Case("700.atTime.future", SysTimeSec, string.Join(",", times.Select(t => t.ToString("o"))), "wire", at.Status,
$"elapsedMs={sw.ElapsedMilliseconds} {Types(at.Rows)}", at.Rows));
Check(failures, at.Rows.Select(r => r.Src).SequenceEqual(times.Select(t => (DateTime?)t)),
"700.atTime.future: timestamps must be the requested ones, in request order");
Check(failures, at.Rows.All(r => !StatusCode.IsGood(r.Status)), "700.atTime.future: a future value is never Good");
}
// ================================================================ #746.1 — events paging, server-side max_events
{
// A second production data source whose EventDrainBudget is 0: without the server cap every truncated page would
// abandon (cancel) its gateway stream at the cap.
var budget0 = Options(endpoint!, apiKey!);
var eventsOptions = new ServerHistorianOptions
{
Backend = budget0.Backend, Enabled = true, Endpoint = budget0.Endpoint, ApiKey = budget0.ApiKey, UseTls = budget0.UseTls,
AllowUntrustedServerCertificate = budget0.AllowUntrustedServerCertificate,
Gateway = new GatewayHistorianSettings { EventDrainBudget = 0 },
};
using var eventsSource = HistorianBackendRegistry.Resolve(eventsOptions, new ConfigurationBuilder().Build())
.CreateDataSource(new ServiceCollection().BuildServiceProvider());
rig.Nm.HistorianDataSource = eventsSource;
var (start, end) = EventWindow;
var w = $"{start:o}/{end:o}";
var whole = await ReadEventsAsync(session, Node(EventSource), start, end, 0, ct);
var sw = Stopwatch.StartNew();
var paged1 = await ReadEventsAsync(session, Node(EventSource), start, end, 1, ct);
var paged7 = await ReadEventsAsync(session, Node(EventSource), start, end, 7, ct);
var reverse5 = await ReadEventsAsync(session, Node(EventSource), end, start, 5, ct);
sw.Stop();
var abandoned = Internal<long>(eventsSource, "EventStreamsAbandoned");
var reReads = Internal<long>(eventsSource, "EventServerCapReReads");
report.Add(new Case("746.events.whole", EventSource, w, "wire", whole.Status, $"events={whole.Rows.Count}", null, null, whole.Rows));
report.Add(new Case("746.events.paged1", EventSource, w, "wire", paged1.Status, $"pages={paged1.Pages}", null, null, paged1.Rows));
report.Add(new Case("746.events.paged7", EventSource, w, "wire", paged7.Status, $"pages={paged7.Pages}", null, null, paged7.Rows));
report.Add(new Case("746.events.reverse5", EventSource, w, "wire", reverse5.Status, $"pages={reverse5.Pages}", null, null, reverse5.Rows));
report.Add(new Case("746.events.streams", EventSource, w, "direct", "Good",
$"abandonedStreams={abandoned} serverCapReReads={reReads} pagedElapsedMs={sw.ElapsedMilliseconds}", null));
Check(failures, whole.Rows.Count > 7, $"746.events: expected more than one 7-event page, got {whole.Rows.Count}");
var ids = whole.Rows.Select(r => r.Id).ToList();
Check(failures, paged1.Rows.Select(r => r.Id).SequenceEqual(ids), "746.events: 1-event pages must equal the whole read");
Check(failures, paged7.Rows.Select(r => r.Id).SequenceEqual(ids), "746.events: 7-event pages must equal the whole read");
var reverseIds = whole.Rows.OrderByDescending(r => r.Time).ThenByDescending(r => r.Id, StringComparer.Ordinal).Select(r => r.Id);
Check(failures, reverse5.Rows.Select(r => r.Id).SequenceEqual(reverseIds), "746.events: 5-event reverse pages, newest first");
Check(failures, abandoned == 0, $"746.events: {abandoned} gateway streams abandoned with EventDrainBudget=0 — max_events not honoured");
rig.Nm.HistorianDataSource = source;
}
// ================================================================ #700 — transport outage vs unsupported
{
var dead = new ServerHistorianOptions
{
Backend = "Gateway", Enabled = true, Endpoint = "http://127.0.0.1:9", ApiKey = apiKey!, UseTls = false,
};
using var deadSource = HistorianBackendRegistry.Resolve(dead, new ConfigurationBuilder().Build())
.CreateDataSource(new ServiceCollection().BuildServiceProvider());
rig.Nm.HistorianDataSource = deadSource;
var (start, end) = (S0, S0.AddSeconds(30));
var w = $"{start:o}/{end:o}";
var results = new Dictionary<string, string>
{
["raw"] = (await ReadRawAsync(session, Node(intNode), start, end, 0, ct)).Status,
["processed.Average"] = (await ReadProcessedAsync(session, Node(intNode), start, end, 10_000, ObjectIds.AggregateFunction_Average, ct)).Status,
["processed.TimeAverage"] = (await ReadProcessedAsync(session, Node(intNode), start, end, 10_000, ObjectIds.AggregateFunction_TimeAverage, ct)).Status,
["atTime"] = (await ReadAtTimeAsync(session, Node(intNode), [start], ct)).Status,
["events"] = (await ReadEventsAsync(session, Node(EventSource), EventWindow.Start, EventWindow.End, 0, ct)).Status,
["modified"] = await ReadModifiedAsync(session, Node(intNode), start, end, ct),
["processed.TimeAverage2"] = (await ReadProcessedAsync(session, Node(intNode), start, end, 10_000, ObjectIds.AggregateFunction_TimeAverage2, ct)).Status,
};
report.Add(new Case("700.outage", SysTimeSec, w, "wire", "see note",
string.Join(" ", results.Select(kv => $"{kv.Key}={kv.Value}")), null));
foreach (var k in new[] { "raw", "processed.Average", "processed.TimeAverage", "atTime", "events" })
Check(failures, results[k] == "BadNoCommunication", $"700.outage.{k}: {results[k]} (expected BadNoCommunication)");
Check(failures, results["modified"] == "BadHistoryOperationUnsupported", $"700.outage.modified: {results["modified"]}");
Check(failures, results["processed.TimeAverage2"] == "BadAggregateNotSupported", $"700.outage.TimeAverage2: {results["processed.TimeAverage2"]}");
// Positive control: the same session, back on the live source, reads again.
rig.Nm.HistorianDataSource = source;
var back = await ReadRawAsync(session, Node(intNode), start, end, 0, ct);
report.Add(new Case("700.outage.recovered", SysTimeSec, w, "wire", back.Status, $"rows={back.Rows.Count}", null));
Check(failures, back.Rows.Count > 0 && !back.Status.StartsWith("Bad", StringComparison.Ordinal), $"700.outage.recovered: {back.Status}");
}
await WriteReportAsync(report, failures, "followups", ct);
failures.ShouldBeEmpty();
await session.CloseAsync(ct);
}
/// <summary>
/// #700, controlled fixture: a strict gateway (<c>AllowStreamingBeyondCeiling=false</c>) whose ceiling the test changes
/// by restarting it. Checks the strict-ceiling stopped-tag reverse read (order, ties, completeness, timestamps; cost in
/// gateway calls against #696's unit bound) and that one long-lived data source follows a lowered and a raised
/// ceiling, including the per-sample aggregate's 30 s cooldown.
/// </summary>
[Fact]
public async Task Strict_ceiling_fixture_stopped_tag_reverse_cost_and_raised_lowered_limits()
{
var endpoint = Env("OTOPCUA_AVEVA_GATE_STRICT_ENDPOINT");
var setCeiling = Env("OTOPCUA_AVEVA_GATE_STRICT_SET_CEILING");
var apiKey = Env("HISTGW_GATEWAY_APIKEY");
var referenceEndpoint = Env("HISTGW_GATEWAY_ENDPOINT");
if (endpoint is null || setCeiling is null || apiKey is null || referenceEndpoint is null)
Assert.Skip("Set OTOPCUA_AVEVA_GATE_STRICT_ENDPOINT, OTOPCUA_AVEVA_GATE_STRICT_SET_CEILING, HISTGW_GATEWAY_ENDPOINT (a " +
"streaming gateway for the reference reads) and HISTGW_GATEWAY_APIKEY to run the strict-ceiling fixture.");
var ct = TestContext.Current.CancellationToken;
var report = new Report(Env("OTOPCUA_AVEVA_GATE_LABEL") ?? "unlabelled", endpoint!);
var failures = new List<string>();
var calls = new GatewayCallCounter(Env("OTOPCUA_AVEVA_GATE_STRICT_CONTAINER"));
await SetCeilingAsync(setCeiling!, 1000, ct);
var options = Options(endpoint!, apiKey!);
using var source = HistorianBackendRegistry.Resolve(options, new ConfigurationBuilder().Build())
.CreateDataSource(new ServiceCollection().BuildServiceProvider());
// References come from a streaming gateway in front of the same historian (the strict one would refuse them).
using var reference = HistorianBackendRegistry.Resolve(Options(referenceEndpoint!, apiKey!), new ConfigurationBuilder().Build())
.CreateDataSource(new ServiceCollection().BuildServiceProvider());
await using var rig = await StartAsync("AvevaGate700Strict", encrypted: false, ct);
rig.Nm.HistorianDataSource = source;
rig.Nm.MaxTieClusterOverfetch = options.MaxTieClusterOverfetch;
rig.Nm.HonorReturnBounds = options.HonorReturnBounds;
rig.Nm.HistoryReadDeadline = options.HistoryReadDeadline;
rig.Enforce(false, [Key()]);
var floatNode = rig.EnsureRawVariable("HistFloat", "Float", writable: false, FloatTag);
var dblNode = rig.EnsureRawVariable("HistSec", "Double", writable: false, SysTimeSec);
using var session = await rig.ConnectAsync(anonymous: true, ct);
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
NodeId Node(string id) => new(id, ns);
// ---- Stopped-tag reverse read: the float tag stopped at 11:32:36 (NULL) and resumed at 11:38:34. Read the latest 3
// before 11:38:00 (end-only: StartTime unset, EndTime 11:38:00 — Part 11 reads back from EndTime), on the strict
// 1,000-row gateway.
{
var at = Utc("2026-11-10T11:38:00Z");
var stored = (await reference.ReadRawAsync(FloatTag, at.AddHours(-1), at, 0, ct)).Samples;
var expected = stored.Reverse().Take(3).Select(s => s.SourceTimestampUtc).ToList();
await calls.MarkAsync("ReadRaw", ct);
var sw = Stopwatch.StartNew();
var r = await ReadRawAsync(session, Node(floatNode), DateTime.MinValue, at, 3, ct);
sw.Stop();
var n = await calls.CountAsync("ReadRaw", ct);
report.Add(new Case("700.strict.stoppedTagReverse3", FloatTag, $"(end-only)/{at:o}", "wire", r.Status,
$"gatewayReadRawCalls={n} elapsedMs={sw.ElapsedMilliseconds} unitBound=12 (#696: 7,200 stopped 1 Hz rows, ceiling 1,000, one day later)",
r.Rows));
Check(failures, r.Rows.Select(x => x.Src).SequenceEqual(expected),
$"700.strict.stoppedTagReverse3: {string.Join(",", r.Rows.Select(x => x.Src?.ToString("o")))} vs {string.Join(",", expected.Select(x => x?.ToString("o")))}");
Check(failures, n is > 0 and <= 12, $"700.strict.stoppedTagReverse3: {n} gateway ReadRaw calls (unit bound 12)");
// A reverse read across the whole outage: newest first, paged 37, equal to the forward reference reversed.
var (s0, s1) = (Utc("2026-11-10T11:30:00Z"), Utc("2026-11-10T11:41:00Z"));
var fwd = (await reference.ReadRawAsync(FloatTag, s0.AddTicks(1), s1.AddTicks(1), 0, ct)).Samples;
var paged = await ReadRawPagedAsync(session, Node(floatNode), s1, s0, 37, ct);
report.Add(new Case("700.strict.reversePaged37", FloatTag, $"{s1:o}/{s0:o}", "wire", paged.Status, $"pages={paged.Pages}", paged.Rows));
CheckSame(failures, "700.strict.reversePaged37", paged.Rows, Rows(fwd).AsEnumerable().Reverse().ToList());
}
// ---- Lowered then raised limits, one long-lived data source. A 30-minute SysTimeSec raw read (1,800 rows) pages at
// the learned ceiling; an Average over it (one 30-minute interval) needs more rows than a 1,000 / 500 ceiling allows.
// The Average goes FIRST in each probe: a raw read that finds the gateway raised forgets the learned ceiling, which
// would end the per-sample aggregate's cooldown early.
var (a, b) = (S0, S0.AddMinutes(30));
async Task<(WireRead Avg, WireRead Raw)> Probe(string phase)
{
var avg = await ReadProcessedAsync(session, Node(dblNode), a, b, 30 * 60_000, ObjectIds.AggregateFunction_Average, ct);
var raw = await ReadRawPagedAsync(session, Node(dblNode), a, b, 0, ct);
report.Add(new Case($"700.limits.{phase}.average", SysTimeSec, $"{a:o}/{b:o}", "wire", avg.Status,
string.Join(",", avg.Rows.Select(r => $"0x{r.Status:X8}")), avg.Rows));
report.Add(new Case($"700.limits.{phase}.raw", SysTimeSec, $"{a:o}/{b:o}", "wire", raw.Status, $"pages={raw.Pages} rows={raw.Rows.Count}", null));
return (avg, raw);
}
static bool Refused(WireRead r) =>
r.Status == "BadTooManyOperations" || (r.Rows.Count == 1 && r.Rows[0].Status == StatusCodes.BadTooManyOperations);
var at1000 = await Probe("ceiling1000");
Check(failures, at1000.Raw.Rows.Count == 1800 && at1000.Raw.Pages >= 2, $"700.limits.1000: raw {at1000.Raw.Rows.Count} rows / {at1000.Raw.Pages} pages");
Check(failures, Refused(at1000.Avg), $"700.limits.1000: an Average over 1,800 rows on a 1,000-row strict gateway must be BadTooManyOperations, got {at1000.Avg.Status}");
await SetCeilingAsync(setCeiling!, 500, ct); // lowered: learned at once, from the next refusal
var lastRefusal = DateTime.UtcNow;
var at500 = await Probe("ceiling500");
Check(failures, Refused(at500.Avg), $"700.limits.500: Average must be BadTooManyOperations, got {at500.Avg.Status}");
Check(failures, at500.Raw.Rows.Count == 1800 && at500.Raw.Pages > at1000.Raw.Pages, $"700.limits.500: raw {at500.Raw.Rows.Count} rows / {at500.Raw.Pages} pages");
await SetCeilingAsync(setCeiling!, 5000, ct); // raised: the per-sample aggregate reuses the 500 refusal for 30 s
var sinceRefusal = DateTime.UtcNow - lastRefusal;
var inCooldown = await ReadProcessedAsync(session, Node(dblNode), a, b, 30 * 60_000, ObjectIds.AggregateFunction_Average, ct);
report.Add(new Case("700.limits.ceiling5000.averageInCooldown", SysTimeSec, $"{a:o}/{b:o}", "wire", inCooldown.Status,
$"secondsSinceLastRefusal={sinceRefusal.TotalSeconds:F1} {string.Join(",", inCooldown.Rows.Select(r => $"0x{r.Status:X8}"))}", inCooldown.Rows));
if (sinceRefusal < TimeSpan.FromSeconds(25))
Check(failures, Refused(inCooldown), $"700.limits.5000: inside the 30 s cooldown the Average stays a request limit, got {inCooldown.Status}");
var wait = lastRefusal.AddSeconds(31) - DateTime.UtcNow;
if (wait > TimeSpan.Zero) await Task.Delay(wait, ct);
var after = await Probe("ceiling5000.afterCooldown");
Check(failures, after.Avg.Rows.Count == 1 && StatusCode.IsGood(after.Avg.Rows[0].Status),
$"700.limits.5000: after the cooldown the Average is served, got {after.Avg.Status} {string.Join(",", after.Avg.Rows.Select(r => $"0x{r.Status:X8}"))}");
Check(failures, after.Raw.Rows.Count == 1800 && after.Raw.Pages == 1, $"700.limits.5000: raw {after.Raw.Rows.Count} rows / {after.Raw.Pages} pages");
await SetCeilingAsync(setCeiling!, 1000, ct);
await WriteReportAsync(report, failures, "strict", ct);
failures.ShouldBeEmpty();
await session.CloseAsync(ct);
}
// ------------------------------------------------------------------ fixture helpers
private static ServerHistorianOptions Options(string endpoint, string apiKey) => new()
{
Backend = "Gateway",
Enabled = true,
Endpoint = endpoint,
ApiKey = apiKey,
UseTls = endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase),
AllowUntrustedServerCertificate = string.Equals(Env("HISTGW_GATEWAY_ALLOW_UNTRUSTED"), "true", StringComparison.OrdinalIgnoreCase),
};
private static async Task SetCeilingAsync(string script, int ceiling, CancellationToken ct)
{
using var p = Process.Start(new ProcessStartInfo("bash", [script, ceiling.ToString(CultureInfo.InvariantCulture)])
{
RedirectStandardOutput = true, RedirectStandardError = true,
})!;
await p.WaitForExitAsync(ct);
p.ExitCode.ShouldBe(0, $"{script} {ceiling}: {await p.StandardError.ReadToEndAsync(ct)}");
}
/// <summary>
/// Counts a gateway container's gRPC calls by method from its request log: the calls since the last
/// <see cref="MarkAsync"/> (a delta of whole-log counts, so the Docker VM's clock does not matter).
/// </summary>
private sealed class GatewayCallCounter(string? container)
{
private readonly Dictionary<string, int> _marks = new(StringComparer.Ordinal);
public async Task MarkAsync(string method, CancellationToken ct) => _marks[method] = await TotalAsync(method, ct);
public async Task<int> CountAsync(string method, CancellationToken ct)
{
if (container is null) return -1;
await Task.Delay(TimeSpan.FromSeconds(2), ct); // let the container's request log reach `docker logs`
return await TotalAsync(method, ct) - _marks.GetValueOrDefault(method);
}
private async Task<int> TotalAsync(string method, CancellationToken ct)
{
if (container is null) return 0;
using var p = Process.Start(new ProcessStartInfo("docker", ["logs", container])
{
RedirectStandardOutput = true, RedirectStandardError = true,
})!;
var stdout = p.StandardOutput.ReadToEndAsync(ct);
var stderr = p.StandardError.ReadToEndAsync(ct);
await p.WaitForExitAsync(ct);
var text = await stdout + await stderr;
return text.Split('\n').Count(l => l.Contains("Request starting", StringComparison.Ordinal)
&& l.Contains($".HistorianRead/{method} ", StringComparison.Ordinal));
}
}
private static T Internal<T>(object instance, string property) =>
(T)instance.GetType().GetProperty(property, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
.GetValue(instance)!;
/// <summary>
/// The Part 13 interval grid (#677): forward — from StartTime, the clipped interval the latest, each stamped with its
/// earlier edge; reverse — stepping back from StartTime, the clipped interval the earliest, stamped with its later edge,
/// newest first. Each entry is (lo, hi, stamp, clipped).
/// </summary>
private static List<(DateTime Lo, DateTime Hi, DateTime Stamp, bool Clipped)> Grid(DateTime start, DateTime end, TimeSpan interval)
{
var list = new List<(DateTime, DateTime, DateTime, bool)>();
if (start <= end)
{
for (var lo = start; lo < end; lo += interval)
{
var hi = lo + interval > end ? end : lo + interval;
list.Add((lo, hi, lo, hi - lo < interval));
}
}
else
{
for (var hi = start; hi > end; hi -= interval)
{
var lo = hi - interval < end ? end : hi - interval;
list.Add((lo, hi, hi, hi - lo < interval));
}
}
return list;
}
private static async Task WriteReportAsync(Report report, List<string> failures, string kind, CancellationToken ct)
{
var outDir = Env("OTOPCUA_AVEVA_GATE_OUT") ?? Path.GetTempPath();
Directory.CreateDirectory(outDir);
var file = Path.Combine(outDir, $"aveva-gate-{kind}-{Sanitize(report.Label)}.json");
report.Failures = failures;
await File.WriteAllTextAsync(file, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }), ct);
TestContext.Current.TestOutputHelper?.WriteLine($"AVEVA gate report: {file} ({report.Cases.Count} cases, {failures.Count} failures)");
foreach (var f in failures) TestContext.Current.TestOutputHelper?.WriteLine($"FAIL {f}");
}
// ------------------------------------------------------------------ wire helpers
private static async Task<WireRead> ReadRawAsync(ISession session, NodeId node, DateTime start, DateTime end, uint max, CancellationToken ct)
{
var details = new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = max, IsReadModified = false, ReturnBounds = false };
var (status, rows, cp) = await HistoryReadAsync(session, details, node, default, ct);
await ReleaseAsync(session, details, node, cp, ct);
return new WireRead(status, rows, 1);
}
private static async Task<WireRead> ReadRawPagedAsync(ISession session, NodeId node, DateTime start, DateTime end, uint page, CancellationToken ct)
{
var details = new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = page, IsReadModified = false, ReturnBounds = false };
return await PageAsync(session, details, node, ct);
}
private static async Task<string> ReadModifiedAsync(ISession session, NodeId node, DateTime start, DateTime end, CancellationToken ct)
{
var details = new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = 0, IsReadModified = true, ReturnBounds = false };
var (status, _, cp) = await HistoryReadAsync(session, details, node, default, ct);
await ReleaseAsync(session, details, node, cp, ct);
return status;
}
private static async Task<WireRead> ReadProcessedAsync(ISession session, NodeId node, DateTime start, DateTime end, double intervalMs,
NodeId aggregate, CancellationToken ct)
{
var details = new ReadProcessedDetails
{
StartTime = start, EndTime = end, ProcessingInterval = intervalMs,
AggregateType = new List<NodeId> { aggregate },
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
};
return await PageAsync(session, details, node, ct);
}
private static async Task<WireRead> PageAsync(ISession session, IEncodeable details, NodeId node, CancellationToken ct)
{
var all = new List<Row>();
var pages = 0;
ByteString cp = default;
string status;
do
{
(status, var rows, cp) = await HistoryReadAsync(session, details, node, cp, ct);
all.AddRange(rows);
pages++;
if (status.StartsWith("Bad", StringComparison.Ordinal)) break;
}
while (!cp.IsEmpty && pages < 10_000);
return new WireRead(status, all, pages);
}
private static async Task<WireRead> ReadAtTimeAsync(ISession session, NodeId node, IReadOnlyList<DateTime> times, CancellationToken ct)
{
var details = new ReadAtTimeDetails { ReqTimes = times.Select(t => (DateTimeUtc)t).ToList(), UseSimpleBounds = true };
var (status, rows, cp) = await HistoryReadAsync(session, details, node, default, ct);
await ReleaseAsync(session, details, node, cp, ct);
return new WireRead(status, rows, 1);
}
private static async Task<(string Status, List<Row> Rows, ByteString Cp)> HistoryReadAsync(
ISession session, IEncodeable details, NodeId node, ByteString cp, CancellationToken ct)
{
var response = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, false,
new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp } }, ct);
var result = response.Results.ToList().ShouldHaveSingleItem();
var rows = new List<Row>();
if (!result.HistoryData.IsNull && ExtensionObject.ToEncodeable(result.HistoryData) is HistoryData data)
rows.AddRange(data.DataValues.ToList().Select(dv =>
{
var v = dv.ClrValue();
return new Row(Fmt(v), dv.StatusCode.Code, Ts(dv.SourceTimestamp), v?.GetType().Name);
}));
return (Name(result.StatusCode), rows, result.ContinuationPoint);
}
private static async Task ReleaseAsync(ISession session, IEncodeable details, NodeId node, ByteString cp, CancellationToken ct)
{
if (cp.IsEmpty) return;
await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, true,
new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp } }, ct);
}
private static async Task<EventRead> ReadEventsAsync(ISession session, NodeId node, DateTime start, DateTime end, uint page, CancellationToken ct)
{
var filter = new EventFilter();
foreach (var field in new[] { "EventId", "Time", "SourceName", "Severity" })
filter.SelectClauses += new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(field)) { AttributeId = Attributes.Value };
var details = new ReadEventDetails { StartTime = start, EndTime = end, NumValuesPerNode = page, Filter = filter };
var rows = new List<EventRow>();
var pages = 0;
ByteString cp = default;
string status;
do
{
var response = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, false,
new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp } }, ct);
var result = response.Results.ToList().ShouldHaveSingleItem();
status = Name(result.StatusCode);
cp = result.ContinuationPoint;
pages++;
if (!result.HistoryData.IsNull && ExtensionObject.ToEncodeable(result.HistoryData) is HistoryEvent he)
foreach (var e in he.Events.ToList())
{
var f = e.EventFields.ToList();
rows.Add(new EventRow(
f[0].ClrValue() is byte[] id ? System.Text.Encoding.UTF8.GetString(id) : Fmt(f[0].ClrValue()),
f[1].ClrValue() as DateTime? ?? default, f[2].ClrValue() as string,
Convert.ToUInt16(f[3].ClrValue() ?? 0, CultureInfo.InvariantCulture)));
}
if (status.StartsWith("Bad", StringComparison.Ordinal)) break;
}
while (!cp.IsEmpty && pages < 10_000);
return new EventRead(status, rows, pages);
}
private static async Task<string> AggregateFunctionsAsync(ISession session, CancellationToken ct)
{
var response = await session.BrowseAsync(null, null, 0,
new BrowseDescription[]
{
new()
{
NodeId = ObjectIds.HistoryServerCapabilities_AggregateFunctions, BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.Organizes, IncludeSubtypes = true, NodeClassMask = 0,
ResultMask = (uint)BrowseResultMask.All,
},
}, ct);
return string.Join(",", response.Results[0].References.ToList().Select(r => r.BrowseName.Name).OrderBy(n => n, StringComparer.Ordinal));
}
private static DateTime? Ts(DateTimeUtc t) => (DateTime)t == DateTime.MinValue ? null : DateTime.SpecifyKind((DateTime)t, DateTimeKind.Utc);
private static string Name(StatusCode code) => code.SymbolicId is { Length: > 0 } s ? s : $"0x{code.Code:X8}";
private static List<Row> Rows(IReadOnlyList<DataValueSnapshot> samples) =>
samples.Select(s => new Row(Fmt(s.Value), s.StatusCode, s.SourceTimestampUtc, s.Value?.GetType().Name)).ToList();
private static string Types(IEnumerable<Row> rows) =>
"types=" + string.Join("/", rows.Select(r => r.Type ?? "null").Distinct(StringComparer.Ordinal));
private static string? Fmt(object? value) => value switch
{
null => null,
double d => d.ToString("R", CultureInfo.InvariantCulture),
float f => ((double)f).ToString("R", CultureInfo.InvariantCulture),
bool b => b ? "true" : "false",
DateTime t => t.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture),
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
_ => value.ToString(),
};
/// <summary>Wire vs reference: value text, status and source timestamp (types may differ: the backend answers doubles).</summary>
private static void CheckSame(List<string> failures, string what, IReadOnlyList<Row> wire, IReadOnlyList<Row> reference)
{
if (wire.Count != reference.Count)
{
failures.Add($"{what}: wire {wire.Count} rows vs reference {reference.Count}");
return;
}
for (var i = 0; i < wire.Count; i++)
{
var (x, y) = (wire[i], reference[i]);
if (!SameValue(x.V, y.V) || x.Status != y.Status || x.Src != y.Src)
{
failures.Add($"{what}[{i}]: wire ({x.V}, 0x{x.Status:X8}, {x.Src:o}) vs reference ({y.V}, 0x{y.Status:X8}, {y.Src:o})");
return;
}
}
}
private static bool SameValue(string? a, string? b)
{
if (a == b) return true;
if (a is null || b is null) return false;
if (double.TryParse(a, NumberStyles.Float, CultureInfo.InvariantCulture, out var x)
&& double.TryParse(b, NumberStyles.Float, CultureInfo.InvariantCulture, out var y))
return x == y || Math.Abs(x - y) <= 1e-6 * Math.Max(1, Math.Abs(y)); // a Float node narrows the backend's double
return (a, b) is ("true", "1") or ("false", "0") or ("1", "true") or ("0", "false");
}
private static void Check(List<string> failures, bool ok, string message)
{
if (!ok) failures.Add(message);
}
private static DateTime Utc(string s) =>
DateTime.Parse(s, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
private static string? Env(string name) => Environment.GetEnvironmentVariable(name) is { Length: > 0 } v ? v.Trim() : null;
private static string Sanitize(string s) => string.Concat(s.Select(c => char.IsLetterOrDigit(c) || c is '.' or '-' ? c : '_'));
private sealed record Row(string? V, uint Status, DateTime? Src, string? Type);
private sealed record EventRow(string? Id, DateTime Time, string? Source, ushort Severity);
private sealed record WireRead(string Status, List<Row> Rows, int Pages);
private sealed record EventRead(string Status, List<EventRow> Rows, int Pages);
private sealed record Case(
string Name, string? Tag, string? Window, string Path, string Status, string? Note, List<Row>? Rows,
Dictionary<string, string?>? Attributes = null, List<EventRow>? Events = null);
private sealed class Report(string label, string endpoint)
{
public string Label { get; } = label;
public string Endpoint { get; } = endpoint;
public string SdkServerAssembly { get; } = typeof(Opc.Ua.Server.StandardServer).Assembly.GetName().Version?.ToString() ?? "?";
public DateTime RunUtc { get; } = DateTime.UtcNow;
public List<Case> Cases { get; } = [];
public List<string> Failures { get; set; } = [];
public void Add(Case c) => Cases.Add(c);
}
}
@@ -64,7 +64,8 @@ public sealed class AvevaGatewayHistoryLiveGateTests
private const string MissingTag = "OtOpcUa.Gate801.DoesNotExist";
private const double IntervalMs = 60_000;
// The ten per-sample aggregates the Gateway backend serves (#681) and a set it must refuse (time-weighted / never).
// The aggregates the Gateway backend serves: the ten per-sample ones (#681) plus AVEVA's native TimeAverage / Total (on by
// default since lmxopcua#694), and a set it must refuse (the other time-weighted ones / never).
private static readonly (string Name, NodeId Id, HistoryAggregateType? Domain)[] Served =
[
("Average", ObjectIds.AggregateFunction_Average, HistoryAggregateType.Average),
@@ -77,12 +78,12 @@ public sealed class AvevaGatewayHistoryLiveGateTests
("Delta", ObjectIds.AggregateFunction_Delta, HistoryAggregateType.Delta),
("MinimumActualTime", ObjectIds.AggregateFunction_MinimumActualTime, HistoryAggregateType.MinimumActualTime),
("MaximumActualTime", ObjectIds.AggregateFunction_MaximumActualTime, HistoryAggregateType.MaximumActualTime),
("TimeAverage", ObjectIds.AggregateFunction_TimeAverage, HistoryAggregateType.TimeAverage),
("Total", ObjectIds.AggregateFunction_Total, HistoryAggregateType.Total),
];
private static readonly (string Name, NodeId Id)[] Refused =
[
("TimeAverage", ObjectIds.AggregateFunction_TimeAverage),
("Total", ObjectIds.AggregateFunction_Total),
("TimeAverage2", ObjectIds.AggregateFunction_TimeAverage2),
("Interpolative", ObjectIds.AggregateFunction_Interpolative),
("PercentGood", ObjectIds.AggregateFunction_PercentGood),
@@ -186,7 +187,7 @@ public sealed class AvevaGatewayHistoryLiveGateTests
var caps = await ReadHistoryCapabilitiesAsync(session, ct);
report.Add(new Case("metadata.historyServerCapabilities", null, null, "wire", "Good", null, caps));
Check(failures, caps["AggregateFunctions"] == string.Join(",", Served.Select(a => a.Name).OrderBy(n => n, StringComparer.Ordinal)),
$"advertised history aggregates must be exactly the Gateway's ten, got {caps["AggregateFunctions"]}");
$"advertised history aggregates must be exactly the Gateway's twelve, got {caps["AggregateFunctions"]}");
// lmxopcua#881: the Boolean capabilities follow the Gateway backend — data + event reads, no HistoryUpdate path, no
// stored server timestamps, never event / annotation updates (this gate recorded all-false before the fix).
foreach (var flag in new[] { "AccessHistoryDataCapability", "AccessHistoryEventsCapability" })