Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttLastWill.cs
T
Joseph Doherty 7f2108dcf2 feat(mqtt): Sparkplug primary-host STATE publishing, Primary-gated (#511)
`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
2026-07-31 07:17:17 -04:00

38 lines
2.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
/// <summary>
/// An MQTT Last Will and Testament, as the client options factory wants it (Gitea #511).
/// </summary>
/// <remarks>
/// <para>
/// <b>A Will can only be registered at CONNECT, and that single fact drives the whole design
/// of the Sparkplug primary-host claim.</b> It rides in the CONNECT packet, so it cannot be
/// added, changed or withdrawn on a live session. A node that gains the Primary role after
/// connecting therefore cannot simply start publishing <c>ONLINE</c> — it would have no Will,
/// and a crash would leave a retained <c>ONLINE</c> asserting forever that a dead host is
/// alive. Equally, a node that registers a Will and later loses the role cannot withdraw it,
/// so its eventual disconnect would publish <c>OFFLINE</c> over the new Primary's live claim
/// and stop every edge node in the group.
/// </para>
/// <para>
/// The consequence is that <b>the claim must be bound to the lifetime of a connection</b>:
/// <c>SparkplugPrimaryHost</c> opens a dedicated connection when the node gains Primary and
/// closes it when the node loses Primary, rather than trying to mutate the ingest session it
/// shares with the rest of the driver.
/// </para>
/// <para>
/// <b>Clean session matters too.</b> A broker that resumes an existing session for the same
/// client id may discard the Will registered by the new CONNECT. The primary-host connection
/// forces clean-session on for that reason, regardless of the driver's configured value.
/// </para>
/// </remarks>
/// <param name="Topic">The topic the broker publishes the Will to.</param>
/// <param name="Payload">The Will body.</param>
/// <param name="Qos">Requested QoS, 0–2.</param>
/// <param name="Retain">
/// Whether the broker retains the Will. For a Sparkplug host death certificate this is
/// <see langword="true"/>: an edge node that connects <i>after</i> the host died must still learn
/// the host is offline, and only a retained message reaches it.
/// </param>
public sealed record MqttLastWill(string Topic, byte[] Payload, int Qos, bool Retain);