Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/OtOpcUaServerHostedServiceHistoryUpdateTests.cs
T
Joseph Doherty 3c3513ea3e fix(historian): #694 page raw reads at a strict gateway's learned ceiling; opt-in EventReadHorizon
Item 1: IHistorianDataSource.MaxRawValuesPerRead (Gateway: learned ceiling - 16 = L; 0 elsewhere, guard-tested).
RawPageLimit plans every raw page on top of #692's lookahead: with L > 0 the read (page P + boundary ties k
+ lookahead) is <= L, P = min(N, L - k - 1) (uncapped: L - k - 1); no room => straight to the tie-cluster
over-fetch (never an uncapped or underflowing request). One re-plan + re-read when the read taught a new
limit or was refused for crowding pre-window rows (HistoryReadLimitExceededException.PreWindowRows): the
page shrinks by them. Over-fetch bound min(MaxTieClusterOverfetch, L - 1); slice size never 0.
Review fixes: F1 a window starting at/before the epoch counts AVEVA's synthetic row as pre-window (a
ceiling-truncated read was reported complete); F2 the page path disables ReverseRawReader's own re-run
(cold end-only ceiling 20/N=3000 cost 3,023 reads, now <= 40); F3 crowded look-back re-read around, not
refused; F4 the #687 crowding re-read room at least doubles (up to 8 re-reads), so a cluster of hundreds
just before a resume point no longer fails on a default gateway; a crowding refusal that leaves no room
is BadTooManyOperations, never a tick-by-tick cursor.

Item 2: ServerHistorian:EventReadHorizon (default 00:00:00 = exact, unchanged). When set, every fresh
HistoryReadEvents window's earlier bound is raised to now - horizon (both directions, all backends).
Lossy by design; documented.

Item 3 (native TimeAverage/Total re-check) stays blocked on historiangw#27.
2026-10-13 01:39:32 -04:00

141 lines
5.6 KiB
C#

using Akka.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
/// <summary>
/// Gitea #674 review — pins what the hosted service wires onto the node manager for HistoryUpdate: the Primary gate
/// from <see cref="IHistoryUpdateRoleView"/> (read live on every call), and the future-skew bound + clock from
/// <c>ServerHistorian</c> options. Asserted through <see cref="OtOpcUaServerHostedService.BuildHistoryUpdatePosture"/>
/// (what <c>StartAsync</c> applies) so the SDK need not boot.
/// </summary>
public sealed class OtOpcUaServerHostedServiceHistoryUpdateTests
{
[Fact]
public void A_registered_view_wires_a_gate_that_reads_it_live()
{
var view = new FlippableView { Serve = false };
var posture = Service(view).BuildHistoryUpdatePosture();
posture.PrimaryGate.ShouldNotBeNull();
posture.PrimaryGate().ShouldBeFalse();
view.Serve = true;
posture.PrimaryGate().ShouldBeTrue("the gate must read the view on every call, not capture its value");
}
/// <summary>The gate comes from the HistoryUpdate view, NOT the data-plane source — a source saying "Primary" must not
/// open it while the HistoryUpdate verdict is closed (the boot-window distinction).</summary>
[Fact]
public void The_gate_ignores_the_data_plane_primary_source()
{
var posture = Service(new FlippableView { Serve = false }, new AlwaysPrimary()).BuildHistoryUpdatePosture();
posture.PrimaryGate!().ShouldBeFalse();
}
/// <summary>No view (an admin-only node: no redundancy manager) ⇒ no gate ⇒ HistoryUpdate allowed.</summary>
[Fact]
public void No_view_wires_no_gate()
{
Service(view: null).BuildHistoryUpdatePosture().PrimaryGate.ShouldBeNull();
}
[Fact]
public void Skew_and_clock_come_from_options_and_the_host_clock()
{
var clock = new FixedClock();
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ServerHistorian:HistoryUpdateMaxFutureSkew"] = "00:07:00",
}).Build();
var posture = Service(view: null, config: config, clock: clock).BuildHistoryUpdatePosture();
posture.MaxFutureSkew.ShouldBe(TimeSpan.FromMinutes(7));
posture.Clock.ShouldBeSameAs(clock);
}
[Fact]
public void An_absent_section_keeps_the_one_day_default()
{
Service(view: null).BuildHistoryUpdatePosture().MaxFutureSkew.ShouldBe(TimeSpan.FromDays(1));
}
/// <summary>#691: the ReturnBounds kill switch is bound from <c>ServerHistorian:HonorReturnBounds</c>, default on.</summary>
[Fact]
public void The_return_bounds_kill_switch_comes_from_options_and_defaults_on()
{
Service(view: null).HonorReturnBounds.ShouldBeTrue();
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ServerHistorian:HonorReturnBounds"] = "false",
}).Build();
Service(view: null, config: config).HonorReturnBounds.ShouldBeFalse();
}
/// <summary>#694: the events horizon is bound from <c>ServerHistorian:EventReadHorizon</c>, default none.</summary>
[Fact]
public void The_event_read_horizon_comes_from_options_and_defaults_off()
{
Service(view: null).EventReadHorizon.ShouldBe(TimeSpan.Zero);
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ServerHistorian:EventReadHorizon"] = "365.00:00:00",
}).Build();
Service(view: null, config: config).EventReadHorizon.ShouldBe(TimeSpan.FromDays(365));
}
private static OtOpcUaServerHostedService Service(
IHistoryUpdateRoleView? view, IDriverPrimaryRoleSource? primary = null, IConfiguration? config = null,
TimeProvider? clock = null) => new(
Options.Create(new OpcUaApplicationHostOptions()),
new DeferredAddressSpaceSink(),
new DeferredServiceLevelPublisher(),
NullOpcUaUserAuthenticator.Instance,
NullHistorianDataSource.Instance,
EmptyDataPlaneAuthoritySource.Instance,
config ?? new ConfigurationBuilder().Build(),
() => throw new NotSupportedException("no ActorSystem is needed for the HistoryUpdate posture"),
new ActorRegistry(),
dataPlaneAuthorizer: null,
Options.Create(new AclOptions()),
clock ?? TimeProvider.System,
NullLoggerFactory.Instance,
primaryRoleSource: primary,
historyUpdateRoleView: view);
private sealed class FlippableView : IHistoryUpdateRoleView
{
public volatile bool Serve;
public bool ShouldServeHistoryUpdate => Serve;
public void PublishHistoryUpdateRole(bool shouldServeHistoryUpdate) => Serve = shouldServeHistoryUpdate;
}
private sealed class AlwaysPrimary : IDriverPrimaryRoleSource
{
public bool ShouldActAsPrimary => true;
public void PublishPrimaryRole(bool shouldActAsPrimary)
{
}
}
private sealed class FixedClock : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(2026, 10, 11, 0, 0, 0, TimeSpan.Zero);
}
}