fix(historian): #674 review follow-ups — HistoryUpdate boot-window gate, node-level v1 refusal, plan-before-gate, UTC skew compare
- 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>
This commit is contained in:
+133
@@ -0,0 +1,133 @@
|
||||
using Akka.Actor;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #674 review — the OPC UA HistoryUpdate verdict (<see cref="IHistoryUpdateRoleView"/>) is the Primary
|
||||
/// data-plane verdict but CLOSED until this node's first redundancy snapshot, so both nodes of a pair cannot accept a
|
||||
/// HistoryUpdate during the pre-join boot window (where the data-plane verdict allows: member count 0/1). A single node
|
||||
/// still opens — its own redundancy singleton names it Primary in its first snapshot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Assertions are on what was PUBLISHED, in order, so a missing publish cannot pass for a closed seed.
|
||||
/// </remarks>
|
||||
public sealed class DriverHostActorHistoryUpdateRoleViewTests : RuntimeActorTestBase
|
||||
{
|
||||
private static readonly NodeId TestNode = NodeId.Parse("driver-history-update-test");
|
||||
private static readonly TimeSpan Timeout = PresenceBudget;
|
||||
|
||||
/// <summary>The boot window: a single-driver node (data-plane verdict ALLOW) publishes CLOSED for HistoryUpdate until
|
||||
/// a snapshot arrives; its first snapshot naming it Primary opens it.</summary>
|
||||
[Fact]
|
||||
public void A_single_node_is_closed_until_its_first_snapshot_then_opens()
|
||||
{
|
||||
var view = new RecordingHistoryUpdateRoleView();
|
||||
var host = SpawnHost(view, () => 1);
|
||||
|
||||
AwaitAssert(() => view.Published.ShouldBe([false]), duration: Timeout);
|
||||
|
||||
host.Tell(Snapshot(RedundancyRole.Primary));
|
||||
AwaitAssert(() => view.Published[^1].ShouldBeTrue(), duration: Timeout);
|
||||
}
|
||||
|
||||
/// <summary>Once a snapshot has arrived, an unknown role on a single-driver cluster (a snapshot that omits this
|
||||
/// node) follows the data-plane rule and opens — a real verdict exists, it just does not name us.</summary>
|
||||
[Fact]
|
||||
public void After_a_snapshot_an_unknown_role_follows_the_data_plane_rule()
|
||||
{
|
||||
var view = new RecordingHistoryUpdateRoleView();
|
||||
var host = SpawnHost(view, () => 1);
|
||||
AwaitAssert(() => view.Published.ShouldBe([false]), duration: Timeout);
|
||||
|
||||
host.Tell(new RedundancyStateChanged([], CorrelationId.NewId()));
|
||||
AwaitAssert(() => view.Published[^1].ShouldBeTrue(), duration: Timeout);
|
||||
}
|
||||
|
||||
/// <summary>A Secondary stays closed after its snapshot; becoming Primary opens it.</summary>
|
||||
[Fact]
|
||||
public void A_secondary_stays_closed_and_a_promotion_opens()
|
||||
{
|
||||
var view = new RecordingHistoryUpdateRoleView();
|
||||
var host = SpawnHost(view, () => 2);
|
||||
AwaitAssert(() => view.Published.ShouldBe([false]), duration: Timeout);
|
||||
|
||||
host.Tell(Snapshot(RedundancyRole.Secondary));
|
||||
AwaitAssert(() => view.Published.Count.ShouldBeGreaterThanOrEqualTo(2), duration: Timeout);
|
||||
view.Published.ShouldAllBe(v => !v);
|
||||
|
||||
host.Tell(Snapshot(RedundancyRole.Primary));
|
||||
AwaitAssert(() => view.Published[^1].ShouldBeTrue(), duration: Timeout);
|
||||
}
|
||||
|
||||
/// <summary>The policy itself: no snapshot ⇒ closed whatever the role/count; with one ⇒ the data-plane verdict.</summary>
|
||||
[Theory]
|
||||
[InlineData(false, null, 0, false)]
|
||||
[InlineData(false, null, 1, false)]
|
||||
[InlineData(false, RedundancyRole.Primary, 1, false)]
|
||||
[InlineData(true, null, 1, true)]
|
||||
[InlineData(true, null, 2, false)]
|
||||
[InlineData(true, RedundancyRole.Primary, 2, true)]
|
||||
[InlineData(true, RedundancyRole.Secondary, 2, false)]
|
||||
[InlineData(true, RedundancyRole.Detached, 1, false)]
|
||||
public void Policy_is_the_data_plane_verdict_gated_on_a_snapshot(
|
||||
bool snapshot, RedundancyRole? role, int members, bool expected) =>
|
||||
PrimaryGatePolicy.ShouldServeHistoryUpdate(snapshot, role, members).ShouldBe(expected);
|
||||
|
||||
/// <summary>The production view is seeded CLOSED (unlike the data-plane verdict's allow seed).</summary>
|
||||
[Fact]
|
||||
public void The_role_view_is_seeded_closed()
|
||||
{
|
||||
var view = new RedundancyRoleView();
|
||||
view.ShouldServeHistoryUpdate.ShouldBeFalse();
|
||||
view.ShouldActAsPrimary.ShouldBeTrue("control: the data-plane seed is the single-node allow");
|
||||
view.PublishHistoryUpdateRole(true);
|
||||
view.ShouldServeHistoryUpdate.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private IActorRef SpawnHost(IHistoryUpdateRoleView view, Func<int> driverMemberCount) =>
|
||||
Sys.ActorOf(DriverHostActor.Props(
|
||||
NewInMemoryDbFactory(),
|
||||
TestNode,
|
||||
ackRouter: null,
|
||||
driverMemberCountProvider: driverMemberCount,
|
||||
historyUpdateRoleView: view));
|
||||
|
||||
private static RedundancyStateChanged Snapshot(RedundancyRole role) =>
|
||||
new(
|
||||
[
|
||||
new NodeRedundancyState(TestNode, role,
|
||||
IsClusterLeader: role == RedundancyRole.Primary,
|
||||
IsDriverPrimary: role == RedundancyRole.Primary,
|
||||
AsOfUtc: DateTime.UtcNow),
|
||||
],
|
||||
CorrelationId.NewId());
|
||||
|
||||
/// <summary>Remembers every published verdict, in order.</summary>
|
||||
private sealed class RecordingHistoryUpdateRoleView : IHistoryUpdateRoleView
|
||||
{
|
||||
private readonly List<bool> _published = [];
|
||||
private readonly Lock _gate = new();
|
||||
|
||||
public IReadOnlyList<bool> Published
|
||||
{
|
||||
get { lock (_gate) return _published.ToArray(); }
|
||||
}
|
||||
|
||||
public bool ShouldServeHistoryUpdate
|
||||
{
|
||||
get { lock (_gate) return _published.Count > 0 && _published[^1]; }
|
||||
}
|
||||
|
||||
public void PublishHistoryUpdateRole(bool shouldServeHistoryUpdate)
|
||||
{
|
||||
lock (_gate) _published.Add(shouldServeHistoryUpdate);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user