232c03ed1b
- HistoryUpdate Primary gate now reads IHistoryUpdateRoleView (PrimaryGatePolicy.ShouldServeHistoryUpdate): the data-plane verdict but CLOSED until this node's first redundancy snapshot, so both pair nodes cannot accept in the pre-join boot window. Single nodes open on their own singleton's first snapshot; admin-only nodes register no view => no gate. DriverHostActor publishes it at every Primary-verdict publish point. - SqlServer v1: the updater throws NotSupportedException on every arm; the node manager maps it to a node-level BadHistoryOperationUnsupported (was per-item under a Good node for UpdateData/DeleteAtTime). HistoryWrite bit still advertised on v1 (not knowable at materialise time) — documented. - The update plan is evaluated before the Primary gate: an unservable request (Remove, IsDeleteModified, not historized, no updater) is BadHistoryOperationUnsupported off-Primary, not BadInvalidState. - Local-kind SourceTimestamp converted to UTC before the future-skew compare and sent as UTC. - Hosted-service wiring extracted to BuildHistoryUpdatePosture and tested (gate source, skew, clock). - Docs: Historian.md (boot window, ordering, node-level v1, HistoryWrite bit), CLAUDE.md. Co-Authored-By: Claude <noreply@anthropic.com>
117 lines
4.4 KiB
C#
117 lines
4.4 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));
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|