7f2108dcf2
`ActAsPrimaryHost` was accepted by config and by the AdminUI form and did nothing but
log a warning. It now delivers the whole three-part contract or none of it: a retained
`ONLINE` on `spBv1.0/STATE/{hostId}`, a matching retained `OFFLINE` armed as the MQTT
Last Will, and an explicit `OFFLINE` on a clean stand-down.
**The claim is a dedicated connection, not a message.** A Will can only be registered in
the CONNECT packet — it cannot be added to a live session, changed, or withdrawn. So a
claim that is acquired and released at runtime cannot ride the long-lived ingest session.
`SparkplugPrimaryHost` opens its own connection (client id `{clientId}-spbhost`, forced
clean-session) when the node gains Primary and closes it when the node loses Primary; the
connection's lifetime *is* the claim, so every leg follows from its lifecycle. Ingest is
untouched by a role change — no dropped subscriptions, no lost birth cache, no data gap at
the Secondary->Primary transition.
**Primary-gated** (owner decision, recorded on the issue). Both pair nodes subscribe
independently, so an ungated claim means both claim the same host id — and the sharp
failure is not the two fighting over the retained STATE, it is that the SECONDARY's death
fires its Will and publishes OFFLINE while the Primary is healthy, stopping every edge node
in the group. Registering the Will only on whichever node is Primary at connect time fails
the other way: a node that later gains Primary would publish ONLINE with no Will behind it.
New seam: `IDriverPrimaryRoleSource` (`Core.Abstractions`) carrying the *data-plane* verdict
(`PrimaryGatePolicy.ShouldServiceAsPrimary`), not the alarm-drain verdict — an unknown role
with a driver peer present must deny. Registered unconditionally by `AddOtOpcUaRuntime`,
deliberately NOT alongside `IRedundancyRoleView`, which `AddAlarmHistorian` registers only
when that section is enabled: piggybacking would leave a pair with no alarm historian
falling back to "claim unconditionally". Polled on a 1s timer rather than evented — a
process singleton raising events at per-instance drivers outlives every driver it holds.
Accepted trade: a brief edge-visible OFFLINE window during failover. That is a genuine
state transition, not a glitch to hide.
Live-gated 5/5 against the Mosquitto fixture, including the Will leg — `AbandonForTests`
drops the socket with no DISCONNECT, because a clean disconnect makes the broker discard
the Will by protocol rule, so a test driven through `StopAsync` would observe the
stand-down OFFLINE and pass against an implementation that never armed a Will. Falsified:
removing the Will turns that leg and only that leg red.
Also corrects two stale claims found en route: the AdminUI form still said MQTT tags are
read-only (#508 shipped), and the driver doc still said nothing consumes `IRediscoverable`.
Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
91 lines
4.6 KiB
C#
91 lines
4.6 KiB
C#
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
/// <summary>
|
|
/// A driver-facing snapshot of "should this node be the one asserting an exclusive identity on a
|
|
/// shared external system right now?".
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Why drivers need this at all.</b> The driver layer has no notion of redundancy, by
|
|
/// design — a driver talks to a device and the Primary gate is applied above it, in
|
|
/// <c>DriverHostActor</c>, on the way in. That works because every gated operation is
|
|
/// <i>inbound</i>: a write, an alarm ack, an alerts publish. A Sparkplug primary-host claim is
|
|
/// not inbound. It is a standing assertion the driver makes to the broker, on its own
|
|
/// connection, for as long as it holds one — there is no message for the host actor to
|
|
/// intercept. So the decision has to reach the driver instead (Gitea #511).
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The decision, not the role.</b> Same discipline as the alarm-drain view in
|
|
/// <c>Runtime</c>: this publishes the verdict a single policy produced, so the policy stays the
|
|
/// one place that decides and a driver can never re-derive a subtly different rule from a raw
|
|
/// role. The verdict fed here is the <i>data-plane</i> one (an unknown role on a cluster that
|
|
/// has a driver peer denies), because two nodes claiming one Sparkplug host id is the harm the
|
|
/// gate exists to prevent — not a duplicate row, but a Secondary's Last Will publishing
|
|
/// <c>OFFLINE</c> over a live Primary's <c>ONLINE</c> and stopping every edge node in the
|
|
/// group.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Polled, not evented, and that is deliberate.</b> A process-wide singleton raising an
|
|
/// event onto per-instance drivers outlives every driver it hands a reference to — the exact
|
|
/// missing-<c>-=</c> leak shape the rediscovery signal had to be careful about. Consumers
|
|
/// sample this instead, so a driver that is torn down simply stops asking. The cost is up to
|
|
/// one poll interval of latency on a failover, which is inside the edge-visible
|
|
/// <c>OFFLINE</c> window a failover already implies.
|
|
/// </para>
|
|
/// </remarks>
|
|
public interface IDriverPrimaryRoleSource
|
|
{
|
|
/// <summary>
|
|
/// Whether this node currently holds the Primary data-plane role — and may therefore claim an
|
|
/// exclusive external identity such as a Sparkplug host id.
|
|
/// </summary>
|
|
bool ShouldActAsPrimary { get; }
|
|
|
|
/// <summary>
|
|
/// Records a fresh decision. Called by <c>DriverHostActor</c> on every redundancy snapshot.
|
|
/// </summary>
|
|
/// <param name="shouldActAsPrimary">
|
|
/// The verdict <c>PrimaryGatePolicy.ShouldServiceAsPrimary</c> produced.
|
|
/// </param>
|
|
void PublishPrimaryRole(bool shouldActAsPrimary);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The <see cref="IDriverPrimaryRoleSource"/> a driver uses when nothing publishes one — a
|
|
/// standalone host, a unit test, or any deployment with no redundancy manager wired.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>It answers <see langword="true"/>, and the alternative is worse.</b> Defaulting closed
|
|
/// would be the reflexive "fail-safe" choice, but there is no safety to gain: with nothing
|
|
/// publishing a role there is by definition no peer to collide with, and a closed default
|
|
/// would make an exclusive-identity claim silently never happen on the single-node deployment
|
|
/// that is the common case — an inert feature with no error, which is precisely the failure
|
|
/// mode Gitea #511 was filed about.
|
|
/// </para>
|
|
/// <para>
|
|
/// The risk this shifts onto the wiring is real and is guarded there instead: a redundant
|
|
/// deployment whose composition root forgets to supply the live source would fall back to this
|
|
/// and let both nodes claim. <c>DriverFactoryBootstrapPrimaryRoleSourceTests</c> pins that the
|
|
/// Host resolves a real one.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class UnmanagedDriverPrimaryRoleSource : IDriverPrimaryRoleSource
|
|
{
|
|
/// <summary>The shared instance — it holds no state.</summary>
|
|
public static readonly UnmanagedDriverPrimaryRoleSource Instance = new();
|
|
|
|
private UnmanagedDriverPrimaryRoleSource()
|
|
{
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public bool ShouldActAsPrimary => true;
|
|
|
|
/// <inheritdoc/>
|
|
/// <remarks>No-op: there is no redundancy manager to publish anything here.</remarks>
|
|
public void PublishPrimaryRole(bool shouldActAsPrimary)
|
|
{
|
|
}
|
|
}
|