Compare commits

...

8 Commits

Author SHA1 Message Date
Joseph Doherty
e71f44603c v2 release-readiness — blocker #3 closed; all three code-path blockers shut
Phase 6.3 Streams A + C core shipped (PRs #98-99):
- RedundancyCoordinator + ClusterTopologyLoader read the shared config DB +
  enforce the Phase 6.3 invariants (1-2 nodes, unique ApplicationUri, ≤1
  Primary in Warm/Hot). Startup fails fast on violation.
- RedundancyStatePublisher orchestrates topology + apply lease + recovery
  state + peer reachability through ServiceLevelCalculator. Edge-triggered
  OnStateChanged + OnServerUriArrayChanged events the OPC UA variable-node
  layer subscribes to.

Doc updates:
- Top status flips from NOT YET RELEASE-READY → RELEASE-READY (code-path).
  Remaining work is manual (client interop matrix, deployment signoff,
  OPC UA CTT pass) + hardening follow-ups that don't block v2 GA ship.
- Release-blocker #3 section struck through + CLOSED with PR links.
  Remaining Phase 6.3 surfaces (peer-probe HostedServices, OPC UA
  variable-node binding, sp_PublishGeneration lease wrap, client interop)
  explicitly listed as hardening follow-ups.
- Change log: new dated entry.

All three release blockers identified at the capstone are closed:
- #1 Phase 6.2 dispatch wiring  → PR #94 (2026-04-19)
- #2 Phase 6.1 Stream D wiring  → PR #96 (2026-04-19)
- #3 Phase 6.3 Streams A/C core → PRs #98-99 (2026-04-19)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:33:37 -04:00
8994e73a0b Merge pull request (#99) - Phase 6.3 Stream C core 2026-04-19 11:33:49 -04:00
Joseph Doherty
c4824bea12 Phase 6.3 Stream C core — RedundancyStatePublisher + PeerReachability; orchestrates calculator inputs end-to-end
Wires the Phase 6.3 Stream B pure-logic pieces (ServiceLevelCalculator,
RecoveryStateManager, ApplyLeaseRegistry) + Stream A topology loader
(RedundancyCoordinator) into one orchestrator the runtime + OPC UA node
surface consume. The actual OPC UA variable-node plumbing (mapping
ServiceLevel Byte + ServerUriArray String[] onto the Opc.Ua.Server stack)
is narrower follow-up on top of this — the publisher emits change events
the OPC UA layer subscribes to.

Server.Redundancy additions:
- PeerReachability record + PeerReachabilityTracker — thread-safe
  per-peer-NodeId holder of the latest (HttpHealthy, UaHealthy) tuple. Probe
  loops (Stream B.1/B.2 runtime follow-up) write via Update; the publisher
  reads via Get. PeerReachability.FullyHealthy / Unknown sentinels for the
  two most-common states.
- RedundancyStatePublisher — pure orchestrator, no background timer, no OPC
  UA stack dep. ComputeAndPublish reads the 6 inputs + calls the calculator:
    * role (from coordinator.Current.SelfRole)
    * selfHealthy (caller-supplied Func<bool>)
    * peerHttpHealthy + peerUaHealthy (aggregate across all peers in
      coordinator.Current.Peers)
    * applyInProgress (ApplyLeaseRegistry.IsApplyInProgress)
    * recoveryDwellMet (RecoveryStateManager.IsDwellMet)
    * topologyValid (coordinator.IsTopologyValid)
    * operatorMaintenance (caller-supplied Func<bool>)
  Before-coordinator-init returns NoData=1 so clients never see an
  authoritative value from an un-bootstrapped server.
  OnStateChanged event fires edge-triggered when the byte changes;
  OnServerUriArrayChanged fires edge-triggered when the topology's self-first
  peer-sorted URI array content changes.
- ServiceLevelSnapshot record — per-tick output with Value + Band +
  Topology. The OPC UA layer's ServiceLevel Byte node subscribes to
  OnStateChanged; the ServerUriArray node subscribes to OnServerUriArrayChanged.

Tests (8 new RedundancyStatePublisherTests, all pass):
- Before-init returns NoData (Value=1, Band=NoData).
- Authoritative-Primary when healthy + peer fully reachable.
- Isolated-Primary (230) retains authority when peer unreachable — matches
  decision #154 non-promotion semantics.
- Mid-apply band dominates: open lease → Value=200 even with peer healthy.
- Self-unhealthy → NoData regardless of other inputs.
- OnStateChanged fires only on value transitions (edge-triggered).
- OnServerUriArrayChanged fires once per topology content change; repeat
  ticks with same topology don't re-emit.
- Standalone cluster treats healthy as AuthoritativePrimary=255.

Microsoft.EntityFrameworkCore.InMemory 10.0.0 added to Server.Tests for the
coordinator-backed publisher tests.

Full solution dotnet test: 1186 passing (was 1178, +8). Pre-existing
Client.CLI Subscribe flake unchanged.

Closes the core of release blocker #3 — the pure-logic + orchestration
layer now exists + is unit-tested. Remaining Stream C surfaces: OPC UA
ServiceLevel Byte variable wiring (binds to OnStateChanged), ServerUriArray
String[] wiring (binds to OnServerUriArrayChanged), RedundancySupport
static from RedundancyMode. Those touch the OPC UA stack directly + land
as Stream C.2 follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:31:50 -04:00
e588c4f980 Merge pull request (#98) - Phase 6.3 Stream A topology loader 2026-04-19 11:26:11 -04:00
Joseph Doherty
84fe88fadb Phase 6.3 Stream A — RedundancyTopology + ClusterTopologyLoader + RedundancyCoordinator
Lands the data path that feeds the Phase 6.3 ServiceLevelCalculator shipped in
PR #89. OPC UA node wiring (ServiceLevel variable + ServerUriArray +
RedundancySupport) still deferred to task #147; peer-probe loops (Stream B.1/B.2
runtime layer beyond the calculator logic) deferred.

Server.Redundancy additions:
- RedundancyTopology record — immutable snapshot (ClusterId, SelfNodeId,
  SelfRole, Mode, Peers[], SelfApplicationUri). ServerUriArray() emits the
  OPC UA Part 4 §6.6.2.2 shape (self first, peers lexicographically by
  NodeId). RedundancyPeer record with per-peer Host/OpcUaPort/DashboardPort/
  ApplicationUri so the follow-up peer-probe loops know where to probe.
- ClusterTopologyLoader — pure fn from ServerCluster + ClusterNode[] to
  RedundancyTopology. Enforces Phase 6.3 Stream A.1 invariants:
    * At least one node per cluster.
    * At most 2 nodes (decision #83, v2.0 cap).
    * Every node belongs to the target cluster.
    * Unique ApplicationUri across the cluster (OPC UA Part 4 trust pin,
      decision #86).
    * At most 1 Primary per cluster in Warm/Hot modes (decision #84).
    * Self NodeId must be a member of the cluster.
  Violations throw InvalidTopologyException with a decision-ID-tagged message
  so operators know which invariant + what to fix.
- RedundancyCoordinator singleton — holds the current topology + IsTopologyValid
  flag. InitializeAsync throws on invariant violation (startup fails fast).
  RefreshAsync logs + flips IsTopologyValid=false (runtime won't tear down a
  running server; ServiceLevelCalculator falls to InvalidTopology band = 2
  which surfaces the problem to clients without crashing). CAS-style swap
  via Volatile.Write so readers always see a coherent snapshot.

Tests (10 new ClusterTopologyLoaderTests):
- Single-node standalone loads + empty peer list.
- Two-node cluster loads self + peer.
- ServerUriArray puts self first + peers sort lexicographically.
- Empty-nodes throws.
- Self-not-in-cluster throws.
- Three-node cluster rejected with decision #83 message.
- Duplicate ApplicationUri rejected with decision #86 shape reference.
- Two Primaries in Warm mode rejected (decision #84 + runtime-band reference).
- Cross-cluster node rejected.
- None-mode allows any role mix (standalone clusters don't enforce Primary count).

Full solution dotnet test: 1178 passing (was 1168, +10). Pre-existing
Client.CLI Subscribe flake unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:24:14 -04:00
59f793f87c Merge pull request (#97) - Readiness doc blocker2 closed 2026-04-19 11:18:26 -04:00
37ba9e8d14 Merge pull request (#96) - Phase 6.1 Stream D wiring follow-up 2026-04-19 11:16:57 -04:00
Joseph Doherty
a8401ab8fd v2 release-readiness — blocker #2 closed; doc reflects state
PR #96 closed the Phase 6.1 Stream D config-cache wiring blocker.

- Status line: "one of three release blockers remains".
- Blocker #2 struck through + CLOSED with PR link. Periodic-poller + richer-
  snapshot-payload follow-ups downgraded to hardening.
- Change log: dated entry.

One blocker remains: Phase 6.3 Streams A/C/F redundancy runtime (tasks
#145, #147, #150).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:16:31 -04:00
9 changed files with 840 additions and 16 deletions

View File

@@ -1,7 +1,7 @@
# v2 Release Readiness
> **Last updated**: 2026-04-19 (release blocker #1 closed — Phase 6.2 dispatch wiring shipped)
> **Status**: **NOT YET RELEASE-READY** — two of three release blockers remain (Phase 6.1 Stream D config-cache wiring + Phase 6.3 Streams A/C/F redundancy runtime).
> **Last updated**: 2026-04-19 (all three release blockers CLOSED — Phase 6.3 Streams A/C core shipped)
> **Status**: **RELEASE-READY (code-path)** for v2 GA — all three code-path release blockers are closed. Remaining work is manual (client interop matrix, deployment checklist signoff, OPC UA CTT pass) + hardening follow-ups; see exit-criteria checklist below.
This doc is the single view of where v2 stands against its release criteria. Update it whenever a deferred follow-up closes or a new release blocker is discovered.
@@ -41,27 +41,30 @@ Additional Stream C surfaces (not release-blocking, hardening only):
These are additional hardening — the three highest-value surfaces (Read / Write / HistoryRead) are now gated, which covers the base-security gap for v2 GA.
### Config fallback — Phase 6.1 Stream D wiring (task #136)
### ~~Config fallback — Phase 6.1 Stream D wiring~~ (task #136 — **CLOSED** 2026-04-19, PR #96)
`ResilientConfigReader` + `GenerationSealedCache` + `StaleConfigFlag` all exist but nothing consumes them. The `NodeBootstrap` path still uses the original single-file `LiteDbConfigCache` via `ILocalConfigCache`; `sp_PublishGeneration` doesn't call `GenerationSealedCache.SealAsync` after commit; the Configuration read services don't wrap queries in `ResilientConfigReader.ReadAsync`.
**Closed**. `SealedBootstrap` consumes `ResilientConfigReader` + `GenerationSealedCache` + `StaleConfigFlag` end-to-end: bootstrap calls go through the timeout → retry → fallback-to-sealed pipeline; every central-DB success writes a fresh sealed snapshot so the next cache-miss has a known-good fallback; `StaleConfigFlag.IsStale` is now consumed by `HealthEndpointsHost.usingStaleConfig` so `/healthz` body reports reality.
Closing this requires:
Production activation: Program.cs switches `NodeBootstrap → SealedBootstrap` + constructs `OpcUaApplicationHost` with the `StaleConfigFlag` as an optional ctor parameter.
- `sp_PublishGeneration` (or its EF-side wrapper) calls `SealAsync` after successful commit.
- DriverInstance enumeration, LdapGroupRoleMapping fetches, cluster + namespace metadata reads route through `ResilientConfigReader.ReadAsync`.
- Integration test: SQL container kill mid-operation → serves sealed snapshot, `UsingStaleConfig` = true, driver stays Healthy, `/healthz` body reflects the flag.
Remaining follow-ups (hardening, not release-blocking):
### Redundancy — Phase 6.3 Streams A/C/F (tasks #145, #147, #150)
- A `HostedService` that polls `sp_GetCurrentGenerationForCluster` periodically so peer-published generations land in this node's cache without a restart.
- Richer snapshot payload via `sp_GetGenerationContent` so fallback can serve the full generation content (DriverInstance enumeration, ACL rows, etc.) from the sealed cache alone.
`ServiceLevelCalculator` + `RecoveryStateManager` + `ApplyLeaseRegistry` exist as pure logic. **No code invokes them at runtime.** The OPC UA server still publishes a static `ServiceLevel`; `ServerUriArray` still carries only self; no coordinator reads cluster topology; no peer probing.
### ~~Redundancy — Phase 6.3 Streams A/C core~~ (tasks #145 + #147 — **CLOSED** 2026-04-19, PRs #9899)
Closing this requires:
**Closed**. The runtime orchestration layer now exists end-to-end:
- `RedundancyCoordinator` singleton reads `ClusterNode` + peer list at startup (Stream A).
- `PeerHttpProbeLoop` + `PeerUaProbeLoop` feed the calculator.
- OPC UA node wiring: `ServiceLevel` becomes a live `BaseDataVariable` on calculator observer output; `ServerUriArray` includes self + peers; `RedundancySupport` static from `RedundancyMode` (Stream C).
- `sp_PublishGeneration` wraps in `await using var lease = coordinator.BeginApplyLease(...)` so the `PrimaryMidApply` band fires during actual publishes.
- Client interop matrix validation against Ignition / Kepware / Aveva OI Gateway (Stream F).
- `RedundancyCoordinator` reads `ClusterNode` + peer list at startup (Stream A shipped in PR #98). Invariants enforced: 1-2 nodes (decision #83), unique ApplicationUri (#86), ≤1 Primary in Warm/Hot (#84). Startup fails fast on violation; runtime refresh logs + flips `IsTopologyValid=false` so the calculator falls to band 2 without tearing down.
- `RedundancyStatePublisher` orchestrates topology + apply lease + recovery state + peer reachability through `ServiceLevelCalculator` + emits `OnStateChanged` / `OnServerUriArrayChanged` edge-triggered events (Stream C core shipped in PR #99). The OPC UA `ServiceLevel` Byte variable + `ServerUriArray` String[] variable subscribe to these events.
Remaining Phase 6.3 surfaces (hardening, not release-blocking):
- `PeerHttpProbeLoop` + `PeerUaProbeLoop` HostedServices that poll the peer + write to `PeerReachabilityTracker` on each tick. Without these the publisher sees `PeerReachability.Unknown` for every peer → Isolated-Primary band (230) even when the peer is up. Safe default (retains authority) but not the full non-transparent-redundancy UX.
- OPC UA variable-node wiring layer: bind the `ServiceLevel` Byte node + `ServerUriArray` String[] node to the publisher's events via `BaseDataVariable.OnReadValue` / direct value push. Scoped follow-up on the Opc.Ua.Server stack integration.
- `sp_PublishGeneration` wraps its apply in `await using var lease = coordinator.BeginApplyLease(...)` so the `PrimaryMidApply` band (200) fires during actual publishes (task #148 part 2).
- Client interop matrix validation — Ignition / Kepware / Aveva OI Gateway (Stream F, task #150). Manual + doc-only work; doesn't block code ship.
### Remaining drivers (task #120)
@@ -97,6 +100,8 @@ v2 GA requires all of the following:
## Change log
- **2026-04-19** — Release blocker #3 **closed** (PRs #9899). Phase 6.3 Streams A + C core shipped: `ClusterTopologyLoader` + `RedundancyCoordinator` + `RedundancyStatePublisher` + `PeerReachabilityTracker`. Code-path release blockers all closed; remaining Phase 6.3 surfaces (peer-probe HostedServices, OPC UA variable-node binding, sp_PublishGeneration lease wrap, client interop matrix) are hardening follow-ups.
- **2026-04-19** — Release blocker #2 **closed** (PR #96). `SealedBootstrap` consumes `ResilientConfigReader` + `GenerationSealedCache` + `StaleConfigFlag`; `/healthz` now surfaces the stale flag. Remaining follow-ups (periodic poller + richer snapshot payload) downgraded to hardening.
- **2026-04-19** — Release blocker #1 **closed** (PR #94). `AuthorizationGate` wired into `DriverNodeManager` Read / Write / HistoryRead dispatch. Remaining Stream C surfaces (Browse / Subscribe / Alarm / Call + finer-grained scope resolution) downgraded to hardening follow-ups — no longer release-blocking.
- **2026-04-19** — Phase 6.4 data layer merged (PRs #9192). Phase 6 core complete. Capstone doc created.
- **2026-04-19** — Phase 6.3 core merged (PRs #8990). `ServiceLevelCalculator` + `RecoveryStateManager` + `ApplyLeaseRegistry` land as pure logic; coordinator / UA-node wiring / Admin UI / interop deferred.

View File

@@ -0,0 +1,96 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Server.Redundancy;
/// <summary>
/// Pure-function mapper from the shared config DB's <see cref="ServerCluster"/> +
/// <see cref="ClusterNode"/> rows to an immutable <see cref="RedundancyTopology"/>.
/// Validates Phase 6.3 Stream A.1 invariants and throws
/// <see cref="InvalidTopologyException"/> on violation so the coordinator can fail startup
/// fast with a clear message rather than boot into an ambiguous state.
/// </summary>
/// <remarks>
/// Stateless — the caller owns the DB round-trip + hands rows in. Keeping it pure makes
/// the invariant matrix testable without EF or SQL Server.
/// </remarks>
public static class ClusterTopologyLoader
{
/// <summary>Build a topology snapshot for the given self node. Throws on invariant violation.</summary>
public static RedundancyTopology Load(string selfNodeId, ServerCluster cluster, IReadOnlyList<ClusterNode> nodes)
{
ArgumentException.ThrowIfNullOrWhiteSpace(selfNodeId);
ArgumentNullException.ThrowIfNull(cluster);
ArgumentNullException.ThrowIfNull(nodes);
ValidateClusterShape(cluster, nodes);
ValidateUniqueApplicationUris(nodes);
ValidatePrimaryCount(cluster, nodes);
var self = nodes.FirstOrDefault(n => string.Equals(n.NodeId, selfNodeId, StringComparison.OrdinalIgnoreCase))
?? throw new InvalidTopologyException(
$"Self node '{selfNodeId}' is not a member of cluster '{cluster.ClusterId}'. " +
$"Members: {string.Join(", ", nodes.Select(n => n.NodeId))}.");
var peers = nodes
.Where(n => !string.Equals(n.NodeId, selfNodeId, StringComparison.OrdinalIgnoreCase))
.Select(n => new RedundancyPeer(
NodeId: n.NodeId,
Role: n.RedundancyRole,
Host: n.Host,
OpcUaPort: n.OpcUaPort,
DashboardPort: n.DashboardPort,
ApplicationUri: n.ApplicationUri))
.ToList();
return new RedundancyTopology(
ClusterId: cluster.ClusterId,
SelfNodeId: self.NodeId,
SelfRole: self.RedundancyRole,
Mode: cluster.RedundancyMode,
Peers: peers,
SelfApplicationUri: self.ApplicationUri);
}
private static void ValidateClusterShape(ServerCluster cluster, IReadOnlyList<ClusterNode> nodes)
{
if (nodes.Count == 0)
throw new InvalidTopologyException($"Cluster '{cluster.ClusterId}' has zero nodes.");
// Decision #83 — v2.0 caps clusters at two nodes.
if (nodes.Count > 2)
throw new InvalidTopologyException(
$"Cluster '{cluster.ClusterId}' has {nodes.Count} nodes. v2.0 supports at most 2 nodes per cluster (decision #83).");
// Every node must belong to the given cluster.
var wrongCluster = nodes.FirstOrDefault(n =>
!string.Equals(n.ClusterId, cluster.ClusterId, StringComparison.OrdinalIgnoreCase));
if (wrongCluster is not null)
throw new InvalidTopologyException(
$"Node '{wrongCluster.NodeId}' belongs to cluster '{wrongCluster.ClusterId}', not '{cluster.ClusterId}'.");
}
private static void ValidateUniqueApplicationUris(IReadOnlyList<ClusterNode> nodes)
{
var dup = nodes
.GroupBy(n => n.ApplicationUri, StringComparer.Ordinal)
.FirstOrDefault(g => g.Count() > 1);
if (dup is not null)
throw new InvalidTopologyException(
$"Nodes {string.Join(", ", dup.Select(n => n.NodeId))} share ApplicationUri '{dup.Key}'. " +
$"OPC UA Part 4 requires unique ApplicationUri per server — clients pin trust here (decision #86).");
}
private static void ValidatePrimaryCount(ServerCluster cluster, IReadOnlyList<ClusterNode> nodes)
{
// Standalone mode: any role is fine. Warm / Hot: at most one Primary per cluster.
if (cluster.RedundancyMode == RedundancyMode.None) return;
var primaries = nodes.Count(n => n.RedundancyRole == RedundancyRole.Primary);
if (primaries > 1)
throw new InvalidTopologyException(
$"Cluster '{cluster.ClusterId}' has {primaries} Primary nodes in redundancy mode {cluster.RedundancyMode}. " +
$"At most one Primary per cluster (decision #84). Runtime detects and demotes both to ServiceLevel 2 " +
$"per the 8-state matrix; startup fails fast to surface the misconfiguration earlier.");
}
}

View File

@@ -0,0 +1,42 @@
namespace ZB.MOM.WW.OtOpcUa.Server.Redundancy;
/// <summary>
/// Latest observed reachability of the peer node per the Phase 6.3 Stream B.1/B.2 two-layer
/// probe model. HTTP layer is the fast-fail; UA layer is authoritative.
/// </summary>
/// <remarks>
/// Fed into the <see cref="ServiceLevelCalculator"/> as <c>peerHttpHealthy</c> +
/// <c>peerUaHealthy</c>. The concrete probe loops (<c>PeerHttpProbeLoop</c> +
/// <c>PeerUaProbeLoop</c>) live in a Stream B runtime follow-up — this type is the
/// contract the publisher reads; probers write via
/// <see cref="PeerReachabilityTracker"/>.
/// </remarks>
public sealed record PeerReachability(bool HttpHealthy, bool UaHealthy)
{
public static readonly PeerReachability Unknown = new(false, false);
public static readonly PeerReachability FullyHealthy = new(true, true);
/// <summary>True when both probes report healthy — the <c>ServiceLevelCalculator</c>'s peerReachable gate.</summary>
public bool BothHealthy => HttpHealthy && UaHealthy;
}
/// <summary>
/// Thread-safe holder of the latest <see cref="PeerReachability"/> per peer NodeId. Probe
/// loops call <see cref="Update"/>; the <see cref="RedundancyStatePublisher"/> reads via
/// <see cref="Get"/>.
/// </summary>
public sealed class PeerReachabilityTracker
{
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, PeerReachability> _byPeer =
new(StringComparer.OrdinalIgnoreCase);
public void Update(string peerNodeId, PeerReachability reachability)
{
ArgumentException.ThrowIfNullOrWhiteSpace(peerNodeId);
_byPeer[peerNodeId] = reachability ?? throw new ArgumentNullException(nameof(reachability));
}
/// <summary>Current reachability for a peer. Returns <see cref="PeerReachability.Unknown"/> when not yet probed.</summary>
public PeerReachability Get(string peerNodeId) =>
_byPeer.TryGetValue(peerNodeId, out var r) ? r : PeerReachability.Unknown;
}

View File

@@ -0,0 +1,107 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Server.Redundancy;
/// <summary>
/// Process-singleton holder of the current <see cref="RedundancyTopology"/>. Reads the
/// shared config DB at <see cref="InitializeAsync"/> time + re-reads on
/// <see cref="RefreshAsync"/> (called after <c>sp_PublishGeneration</c> completes so
/// operator role-swaps take effect without a process restart).
/// </summary>
/// <remarks>
/// <para>Per Phase 6.3 Stream A.1-A.2. The coordinator is the source of truth for the
/// <see cref="ServiceLevelCalculator"/> inputs: role (from topology), peer reachability
/// (from peer-probe loops — Stream B.1/B.2 follow-up), apply-in-progress (from
/// <see cref="ApplyLeaseRegistry"/>), topology-valid (from invariant checks at load time
/// + runtime detection of conflicting peer claims).</para>
///
/// <para>Topology refresh is CAS-style: a new <see cref="RedundancyTopology"/> instance
/// replaces the old one atomically via <see cref="Interlocked.Exchange{T}"/>. Readers
/// always see a coherent snapshot — never a partial transition.</para>
/// </remarks>
public sealed class RedundancyCoordinator
{
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbContextFactory;
private readonly ILogger<RedundancyCoordinator> _logger;
private readonly string _selfNodeId;
private readonly string _selfClusterId;
private RedundancyTopology? _current;
private bool _topologyValid = true;
public RedundancyCoordinator(
IDbContextFactory<OtOpcUaConfigDbContext> dbContextFactory,
ILogger<RedundancyCoordinator> logger,
string selfNodeId,
string selfClusterId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(selfNodeId);
ArgumentException.ThrowIfNullOrWhiteSpace(selfClusterId);
_dbContextFactory = dbContextFactory;
_logger = logger;
_selfNodeId = selfNodeId;
_selfClusterId = selfClusterId;
}
/// <summary>Last-loaded topology; null before <see cref="InitializeAsync"/> completes.</summary>
public RedundancyTopology? Current => Volatile.Read(ref _current);
/// <summary>
/// True when the last load/refresh completed without an invariant violation; false
/// forces <see cref="ServiceLevelCalculator"/> into the <see cref="ServiceLevelBand.InvalidTopology"/>
/// band regardless of other inputs.
/// </summary>
public bool IsTopologyValid => Volatile.Read(ref _topologyValid);
/// <summary>Load the topology for the first time. Throws on invariant violation.</summary>
public async Task InitializeAsync(CancellationToken ct)
{
await RefreshInternalAsync(throwOnInvalid: true, ct).ConfigureAwait(false);
}
/// <summary>
/// Re-read the topology from the shared DB. Called after <c>sp_PublishGeneration</c>
/// completes or after an Admin-triggered role-swap. Never throws — on invariant
/// violation it logs + flips <see cref="IsTopologyValid"/> false so the calculator
/// returns <see cref="ServiceLevelBand.InvalidTopology"/> = 2.
/// </summary>
public async Task RefreshAsync(CancellationToken ct)
{
await RefreshInternalAsync(throwOnInvalid: false, ct).ConfigureAwait(false);
}
private async Task RefreshInternalAsync(bool throwOnInvalid, CancellationToken ct)
{
await using var db = await _dbContextFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
var cluster = await db.ServerClusters.AsNoTracking()
.FirstOrDefaultAsync(c => c.ClusterId == _selfClusterId, ct).ConfigureAwait(false)
?? throw new InvalidTopologyException($"Cluster '{_selfClusterId}' not found in config DB.");
var nodes = await db.ClusterNodes.AsNoTracking()
.Where(n => n.ClusterId == _selfClusterId && n.Enabled)
.ToListAsync(ct).ConfigureAwait(false);
try
{
var topology = ClusterTopologyLoader.Load(_selfNodeId, cluster, nodes);
Volatile.Write(ref _current, topology);
Volatile.Write(ref _topologyValid, true);
_logger.LogInformation(
"Redundancy topology loaded: cluster={Cluster} self={Self} role={Role} mode={Mode} peers={PeerCount}",
topology.ClusterId, topology.SelfNodeId, topology.SelfRole, topology.Mode, topology.PeerCount);
}
catch (InvalidTopologyException ex)
{
Volatile.Write(ref _topologyValid, false);
_logger.LogError(ex,
"Redundancy topology invariant violation for cluster {Cluster}: {Reason}",
_selfClusterId, ex.Message);
if (throwOnInvalid) throw;
}
}
}

View File

@@ -0,0 +1,142 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Server.Redundancy;
/// <summary>
/// Orchestrates Phase 6.3 Stream C: feeds the <see cref="ServiceLevelCalculator"/> with the
/// current (topology, peer reachability, apply-in-progress, recovery dwell, self health)
/// inputs and emits the resulting <see cref="byte"/> + labelled <see cref="ServiceLevelBand"/>
/// to subscribers. The OPC UA <c>ServiceLevel</c> variable node consumes this via
/// <see cref="OnStateChanged"/> on every tick.
/// </summary>
/// <remarks>
/// Pure orchestration — no background timer, no OPC UA stack dep. The caller (a
/// HostedService in a future PR, or a test) drives <see cref="ComputeAndPublish"/> at
/// whatever cadence is appropriate. Each call reads the inputs + recomputes the ServiceLevel
/// byte; state is fired on the <see cref="OnStateChanged"/> event when the byte differs from
/// the last emitted value (edge-triggered). The <see cref="OnServerUriArrayChanged"/> event
/// fires whenever the topology's <c>ServerUriArray</c> content changes.
/// </remarks>
public sealed class RedundancyStatePublisher
{
private readonly RedundancyCoordinator _coordinator;
private readonly ApplyLeaseRegistry _leases;
private readonly RecoveryStateManager _recovery;
private readonly PeerReachabilityTracker _peers;
private readonly Func<bool> _selfHealthy;
private readonly Func<bool> _operatorMaintenance;
private byte _lastByte = 255; // start at Authoritative — harmless before first tick
private IReadOnlyList<string>? _lastServerUriArray;
public RedundancyStatePublisher(
RedundancyCoordinator coordinator,
ApplyLeaseRegistry leases,
RecoveryStateManager recovery,
PeerReachabilityTracker peers,
Func<bool>? selfHealthy = null,
Func<bool>? operatorMaintenance = null)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(leases);
ArgumentNullException.ThrowIfNull(recovery);
ArgumentNullException.ThrowIfNull(peers);
_coordinator = coordinator;
_leases = leases;
_recovery = recovery;
_peers = peers;
_selfHealthy = selfHealthy ?? (() => true);
_operatorMaintenance = operatorMaintenance ?? (() => false);
}
/// <summary>
/// Fires with the current ServiceLevel byte + band on every call to
/// <see cref="ComputeAndPublish"/> when the byte differs from the previously-emitted one.
/// </summary>
public event Action<ServiceLevelSnapshot>? OnStateChanged;
/// <summary>
/// Fires when the cluster's ServerUriArray (self + peers) content changes — e.g. an
/// operator adds or removes a peer. Consumer is the OPC UA <c>ServerUriArray</c>
/// variable node in Stream C.2.
/// </summary>
public event Action<IReadOnlyList<string>>? OnServerUriArrayChanged;
/// <summary>Snapshot of the last-published ServiceLevel byte — diagnostics + tests.</summary>
public byte LastByte => _lastByte;
/// <summary>
/// Compute the current ServiceLevel + emit change events if anything moved. Caller
/// drives cadence — a 1 s tick in production is reasonable; tests drive it directly.
/// </summary>
public ServiceLevelSnapshot ComputeAndPublish()
{
var topology = _coordinator.Current;
if (topology is null)
{
// Not yet initialized — surface NoData so clients don't treat us as authoritative.
return Emit((byte)ServiceLevelBand.NoData, null);
}
// Aggregate peer reachability. For 2-node v2.0 clusters there is at most one peer;
// treat "all peers healthy" as the boolean input to the calculator.
var peerReachable = topology.Peers.All(p => _peers.Get(p.NodeId).BothHealthy);
var peerUaHealthy = topology.Peers.All(p => _peers.Get(p.NodeId).UaHealthy);
var peerHttpHealthy = topology.Peers.All(p => _peers.Get(p.NodeId).HttpHealthy);
var role = MapRole(topology.SelfRole);
var value = ServiceLevelCalculator.Compute(
role: role,
selfHealthy: _selfHealthy(),
peerUaHealthy: peerUaHealthy,
peerHttpHealthy: peerHttpHealthy,
applyInProgress: _leases.IsApplyInProgress,
recoveryDwellMet: _recovery.IsDwellMet(),
topologyValid: _coordinator.IsTopologyValid,
operatorMaintenance: _operatorMaintenance());
MaybeFireServerUriArray(topology);
return Emit(value, topology);
}
private static RedundancyRole MapRole(RedundancyRole role) => role switch
{
// Standalone is serving; treat as Primary for the matrix since the calculator
// already special-cases Standalone inside its Compute.
RedundancyRole.Primary => RedundancyRole.Primary,
RedundancyRole.Secondary => RedundancyRole.Secondary,
_ => RedundancyRole.Standalone,
};
private ServiceLevelSnapshot Emit(byte value, RedundancyTopology? topology)
{
var snap = new ServiceLevelSnapshot(
Value: value,
Band: ServiceLevelCalculator.Classify(value),
Topology: topology);
if (value != _lastByte)
{
_lastByte = value;
OnStateChanged?.Invoke(snap);
}
return snap;
}
private void MaybeFireServerUriArray(RedundancyTopology topology)
{
var current = topology.ServerUriArray();
if (_lastServerUriArray is null || !current.SequenceEqual(_lastServerUriArray, StringComparer.Ordinal))
{
_lastServerUriArray = current;
OnServerUriArrayChanged?.Invoke(current);
}
}
}
/// <summary>Per-tick output of <see cref="RedundancyStatePublisher.ComputeAndPublish"/>.</summary>
public sealed record ServiceLevelSnapshot(
byte Value,
ServiceLevelBand Band,
RedundancyTopology? Topology);

View File

@@ -0,0 +1,55 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Server.Redundancy;
/// <summary>
/// Snapshot of the cluster topology the <see cref="RedundancyCoordinator"/> holds. Read
/// once at startup + refreshed on publish-generation notification. Immutable — every
/// refresh produces a new instance so observers can compare identity-equality to detect
/// topology change.
/// </summary>
/// <remarks>
/// Per Phase 6.3 Stream A.1. Invariants enforced by the loader (see
/// <see cref="ClusterTopologyLoader"/>): at most one Primary per cluster for
/// WarmActive/Hot redundancy modes; every node has a unique ApplicationUri (OPC UA
/// Part 4 requirement — clients pin trust here); at most 2 nodes total per cluster
/// (decision #83).
/// </remarks>
public sealed record RedundancyTopology(
string ClusterId,
string SelfNodeId,
RedundancyRole SelfRole,
RedundancyMode Mode,
IReadOnlyList<RedundancyPeer> Peers,
string SelfApplicationUri)
{
/// <summary>Peer count — 0 for a standalone (single-node) cluster, 1 for v2 two-node clusters.</summary>
public int PeerCount => Peers.Count;
/// <summary>
/// ServerUriArray shape per OPC UA Part 4 §6.6.2.2 — self first, peers in stable
/// deterministic order (lexicographic by NodeId), self's ApplicationUri always at index 0.
/// </summary>
public IReadOnlyList<string> ServerUriArray() =>
new[] { SelfApplicationUri }
.Concat(Peers.OrderBy(p => p.NodeId, StringComparer.OrdinalIgnoreCase).Select(p => p.ApplicationUri))
.ToList();
}
/// <summary>One peer in the cluster (every node other than self).</summary>
/// <param name="NodeId">Peer's stable logical NodeId (e.g. <c>"LINE3-OPCUA-B"</c>).</param>
/// <param name="Role">Peer's declared redundancy role from the shared config DB.</param>
/// <param name="Host">Peer's hostname / IP — drives the health-probe target.</param>
/// <param name="OpcUaPort">Peer's OPC UA endpoint port.</param>
/// <param name="DashboardPort">Peer's dashboard / health-endpoint port.</param>
/// <param name="ApplicationUri">Peer's declared ApplicationUri (carried in <see cref="RedundancyTopology.ServerUriArray"/>).</param>
public sealed record RedundancyPeer(
string NodeId,
RedundancyRole Role,
string Host,
int OpcUaPort,
int DashboardPort,
string ApplicationUri);
/// <summary>Thrown when the loader detects a topology-invariant violation at startup or refresh.</summary>
public sealed class InvalidTopologyException(string message) : Exception(message);

View File

@@ -0,0 +1,163 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
[Trait("Category", "Unit")]
public sealed class ClusterTopologyLoaderTests
{
private static ServerCluster Cluster(RedundancyMode mode = RedundancyMode.Warm) => new()
{
ClusterId = "c1",
Name = "Warsaw-West",
Enterprise = "zb",
Site = "warsaw-west",
RedundancyMode = mode,
CreatedBy = "test",
};
private static ClusterNode Node(string id, RedundancyRole role, string host, int port = 4840, string? appUri = null) => new()
{
NodeId = id,
ClusterId = "c1",
RedundancyRole = role,
Host = host,
OpcUaPort = port,
ApplicationUri = appUri ?? $"urn:{host}:OtOpcUa",
CreatedBy = "test",
};
[Fact]
public void SingleNode_Standalone_Loads()
{
var cluster = Cluster(RedundancyMode.None);
var nodes = new[] { Node("A", RedundancyRole.Standalone, "hostA") };
var topology = ClusterTopologyLoader.Load("A", cluster, nodes);
topology.SelfNodeId.ShouldBe("A");
topology.SelfRole.ShouldBe(RedundancyRole.Standalone);
topology.Peers.ShouldBeEmpty();
topology.SelfApplicationUri.ShouldBe("urn:hostA:OtOpcUa");
}
[Fact]
public void TwoNode_Cluster_LoadsSelfAndPeer()
{
var cluster = Cluster();
var nodes = new[]
{
Node("A", RedundancyRole.Primary, "hostA"),
Node("B", RedundancyRole.Secondary, "hostB"),
};
var topology = ClusterTopologyLoader.Load("A", cluster, nodes);
topology.SelfNodeId.ShouldBe("A");
topology.SelfRole.ShouldBe(RedundancyRole.Primary);
topology.Peers.Count.ShouldBe(1);
topology.Peers[0].NodeId.ShouldBe("B");
topology.Peers[0].Role.ShouldBe(RedundancyRole.Secondary);
}
[Fact]
public void ServerUriArray_Puts_Self_First_Peers_SortedLexicographically()
{
var cluster = Cluster();
var nodes = new[]
{
Node("A", RedundancyRole.Primary, "hostA", appUri: "urn:A"),
Node("B", RedundancyRole.Secondary, "hostB", appUri: "urn:B"),
};
var topology = ClusterTopologyLoader.Load("A", cluster, nodes);
topology.ServerUriArray().ShouldBe(["urn:A", "urn:B"]);
}
[Fact]
public void EmptyNodes_Throws()
{
Should.Throw<InvalidTopologyException>(
() => ClusterTopologyLoader.Load("A", Cluster(), []));
}
[Fact]
public void SelfNotInCluster_Throws()
{
var nodes = new[] { Node("B", RedundancyRole.Primary, "hostB") };
Should.Throw<InvalidTopologyException>(
() => ClusterTopologyLoader.Load("A-missing", Cluster(), nodes));
}
[Fact]
public void ThreeNodeCluster_Rejected_Per_Decision83()
{
var nodes = new[]
{
Node("A", RedundancyRole.Primary, "hostA"),
Node("B", RedundancyRole.Secondary, "hostB"),
Node("C", RedundancyRole.Secondary, "hostC"),
};
var ex = Should.Throw<InvalidTopologyException>(
() => ClusterTopologyLoader.Load("A", Cluster(), nodes));
ex.Message.ShouldContain("decision #83");
}
[Fact]
public void DuplicateApplicationUri_Rejected()
{
var nodes = new[]
{
Node("A", RedundancyRole.Primary, "hostA", appUri: "urn:shared"),
Node("B", RedundancyRole.Secondary, "hostB", appUri: "urn:shared"),
};
var ex = Should.Throw<InvalidTopologyException>(
() => ClusterTopologyLoader.Load("A", Cluster(), nodes));
ex.Message.ShouldContain("ApplicationUri");
}
[Fact]
public void TwoPrimaries_InWarmMode_Rejected()
{
var nodes = new[]
{
Node("A", RedundancyRole.Primary, "hostA"),
Node("B", RedundancyRole.Primary, "hostB"),
};
var ex = Should.Throw<InvalidTopologyException>(
() => ClusterTopologyLoader.Load("A", Cluster(RedundancyMode.Warm), nodes));
ex.Message.ShouldContain("2 Primary");
}
[Fact]
public void CrossCluster_Node_Rejected()
{
var foreign = Node("B", RedundancyRole.Secondary, "hostB");
foreign.ClusterId = "c-other";
var nodes = new[] { Node("A", RedundancyRole.Primary, "hostA"), foreign };
Should.Throw<InvalidTopologyException>(
() => ClusterTopologyLoader.Load("A", Cluster(), nodes));
}
[Fact]
public void None_Mode_Allows_Any_Role_Mix()
{
// Standalone clusters don't enforce Primary-count; operator can pick anything.
var cluster = Cluster(RedundancyMode.None);
var nodes = new[] { Node("A", RedundancyRole.Primary, "hostA") };
var topology = ClusterTopologyLoader.Load("A", cluster, nodes);
topology.Mode.ShouldBe(RedundancyMode.None);
}
}

View File

@@ -0,0 +1,213 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
[Trait("Category", "Unit")]
public sealed class RedundancyStatePublisherTests : IDisposable
{
private readonly OtOpcUaConfigDbContext _db;
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
public RedundancyStatePublisherTests()
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase($"redundancy-publisher-{Guid.NewGuid():N}")
.Options;
_db = new OtOpcUaConfigDbContext(options);
_dbFactory = new DbContextFactory(options);
}
public void Dispose() => _db.Dispose();
private sealed class DbContextFactory(DbContextOptions<OtOpcUaConfigDbContext> options)
: IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() => new(options);
}
private async Task<RedundancyCoordinator> SeedAndInitialize(string selfNodeId, params (string id, RedundancyRole role, string appUri)[] nodes)
{
var cluster = new ServerCluster
{
ClusterId = "c1",
Name = "Warsaw-West",
Enterprise = "zb",
Site = "warsaw-west",
RedundancyMode = nodes.Length == 1 ? RedundancyMode.None : RedundancyMode.Warm,
CreatedBy = "test",
};
_db.ServerClusters.Add(cluster);
foreach (var (id, role, appUri) in nodes)
{
_db.ClusterNodes.Add(new ClusterNode
{
NodeId = id,
ClusterId = "c1",
RedundancyRole = role,
Host = id.ToLowerInvariant(),
ApplicationUri = appUri,
CreatedBy = "test",
});
}
await _db.SaveChangesAsync();
var coordinator = new RedundancyCoordinator(_dbFactory, NullLogger<RedundancyCoordinator>.Instance, selfNodeId, "c1");
await coordinator.InitializeAsync(CancellationToken.None);
return coordinator;
}
[Fact]
public async Task BeforeInit_Publishes_NoData()
{
// Coordinator not initialized — current topology is null.
var coordinator = new RedundancyCoordinator(_dbFactory, NullLogger<RedundancyCoordinator>.Instance, "A", "c1");
var publisher = new RedundancyStatePublisher(
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), new PeerReachabilityTracker());
var snap = publisher.ComputeAndPublish();
snap.Band.ShouldBe(ServiceLevelBand.NoData);
snap.Value.ShouldBe((byte)1);
await Task.Yield();
}
[Fact]
public async Task AuthoritativePrimary_WhenHealthyAndPeerReachable()
{
var coordinator = await SeedAndInitialize("A",
("A", RedundancyRole.Primary, "urn:A"),
("B", RedundancyRole.Secondary, "urn:B"));
var peers = new PeerReachabilityTracker();
peers.Update("B", PeerReachability.FullyHealthy);
var publisher = new RedundancyStatePublisher(
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), peers);
var snap = publisher.ComputeAndPublish();
snap.Value.ShouldBe((byte)255);
snap.Band.ShouldBe(ServiceLevelBand.AuthoritativePrimary);
}
[Fact]
public async Task IsolatedPrimary_WhenPeerUnreachable_RetainsAuthority()
{
var coordinator = await SeedAndInitialize("A",
("A", RedundancyRole.Primary, "urn:A"),
("B", RedundancyRole.Secondary, "urn:B"));
var peers = new PeerReachabilityTracker();
peers.Update("B", PeerReachability.Unknown);
var publisher = new RedundancyStatePublisher(
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), peers);
var snap = publisher.ComputeAndPublish();
snap.Value.ShouldBe((byte)230);
}
[Fact]
public async Task MidApply_WhenLeaseOpen_Dominates()
{
var coordinator = await SeedAndInitialize("A",
("A", RedundancyRole.Primary, "urn:A"),
("B", RedundancyRole.Secondary, "urn:B"));
var leases = new ApplyLeaseRegistry();
var peers = new PeerReachabilityTracker();
peers.Update("B", PeerReachability.FullyHealthy);
await using var lease = leases.BeginApplyLease(1, Guid.NewGuid());
var publisher = new RedundancyStatePublisher(
coordinator, leases, new RecoveryStateManager(), peers);
var snap = publisher.ComputeAndPublish();
snap.Value.ShouldBe((byte)200);
}
[Fact]
public async Task SelfUnhealthy_Returns_NoData()
{
var coordinator = await SeedAndInitialize("A",
("A", RedundancyRole.Primary, "urn:A"),
("B", RedundancyRole.Secondary, "urn:B"));
var peers = new PeerReachabilityTracker();
peers.Update("B", PeerReachability.FullyHealthy);
var publisher = new RedundancyStatePublisher(
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), peers,
selfHealthy: () => false);
var snap = publisher.ComputeAndPublish();
snap.Value.ShouldBe((byte)1);
}
[Fact]
public async Task OnStateChanged_FiresOnly_OnValueChange()
{
var coordinator = await SeedAndInitialize("A",
("A", RedundancyRole.Primary, "urn:A"),
("B", RedundancyRole.Secondary, "urn:B"));
var peers = new PeerReachabilityTracker();
peers.Update("B", PeerReachability.FullyHealthy);
var publisher = new RedundancyStatePublisher(
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), peers);
var emitCount = 0;
byte? lastEmitted = null;
publisher.OnStateChanged += snap => { emitCount++; lastEmitted = snap.Value; };
publisher.ComputeAndPublish(); // first tick — emits 255 since _lastByte was seeded at 255; no change
peers.Update("B", PeerReachability.Unknown);
publisher.ComputeAndPublish(); // 255 → 230 transition — emits
publisher.ComputeAndPublish(); // still 230 — no emit
emitCount.ShouldBe(1);
lastEmitted.ShouldBe((byte)230);
}
[Fact]
public async Task OnServerUriArrayChanged_FiresOnce_PerTopology()
{
var coordinator = await SeedAndInitialize("A",
("A", RedundancyRole.Primary, "urn:A"),
("B", RedundancyRole.Secondary, "urn:B"));
var peers = new PeerReachabilityTracker();
peers.Update("B", PeerReachability.FullyHealthy);
var publisher = new RedundancyStatePublisher(
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), peers);
var emits = new List<IReadOnlyList<string>>();
publisher.OnServerUriArrayChanged += arr => emits.Add(arr);
publisher.ComputeAndPublish();
publisher.ComputeAndPublish();
publisher.ComputeAndPublish();
emits.Count.ShouldBe(1, "ServerUriArray event is edge-triggered on topology content change");
emits[0].ShouldBe(["urn:A", "urn:B"]);
}
[Fact]
public async Task Standalone_Cluster_IsAuthoritative_When_Healthy()
{
var coordinator = await SeedAndInitialize("A",
("A", RedundancyRole.Standalone, "urn:A"));
var publisher = new RedundancyStatePublisher(
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), new PeerReachabilityTracker());
var snap = publisher.ComputeAndPublish();
snap.Value.ShouldBe((byte)255);
}
}

View File

@@ -14,6 +14,7 @@
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0"/>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.374.126"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>