3c3513ea3e
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.
141 lines
5.6 KiB
C#
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);
|
|
}
|
|
}
|