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:
+9
-8
@@ -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())
|
||||
|
||||
+3
-2
@@ -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).
|
||||
|
||||
+817
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -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" })
|
||||
|
||||
Reference in New Issue
Block a user