feat(mqtt): Sparkplug primary-host STATE publishing (ActAsPrimaryHost)
#511
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
MqttSparkplugOptions.ActAsPrimaryHost(Driver.Mqtt.Contracts/MqttDriverOptions.cs:193) is accepted byconfig and by the AdminUI driver form, but does nothing. Setting it logs a startup warning
(
MqttDriver.cs:933) rather than sitting silently inert.Why it shipped unimplemented rather than half-implemented. Being a Sparkplug primary host is a three-part
contract:
STATEmessage assertingONLINE,OFFLINEregistered as the MQTT Last Will, at connect time,OFFLINEpublished on clean shutdown.Shipping (1) without (2) is strictly worse than shipping neither: a crashed node leaves a retained
ONLINEon the broker asserting forever that a dead host is alive, and edge nodes trusting that assertion will not
take their own offline actions. The warning is the deliberate middle ground.
Extra consideration for this server: both redundancy pair nodes subscribe independently (MQTT fan-out is
per-connection, and the Primary gate applies downstream of ingest, not to it). Two nodes both claiming primary
host on the same host id would fight over the retained
STATE. An implementation must decide whetherprimary-host duty is Primary-gated — and if it is, whether a failover republishes
ONLINEfast enough toavoid an edge-visible flap.
Acceptance. With
ActAsPrimaryHost: true, the broker holds a retainedONLINEwhile the node runs; akill -9of the node leavesOFFLINE(the Will fires); a clean shutdown leavesOFFLINE. Verified liveagainst the Mosquitto fixture, and the pair-node question answered explicitly rather than left to chance.
Decision (owner, 2026-07-31): primary-host duty is Primary-gated. Not yet implemented — recording
the decision and a constraint the issue does not cover, found while scoping.
The pair-node question, answered
Gating is not optional. The issue notes two nodes would "fight over the retained STATE", but the sharper
failure is this: if both nodes claim the host id and the Secondary dies, its Will publishes
OFFLINE, edge nodes stop publishing, and the surviving Primary is still perfectly healthy. Redundancywould cause the plant outage the mechanism exists to prevent.
⚠️ Constraint the issue misses: the Will cannot simply be registered on both nodes
The Will is registered at CONNECT time, but the Primary role changes after connect. The two
obvious shortcuts are both wrong:
OFFLINEand clobbers thePrimary's retained
ONLINE. This is the failure above.that later gains Primary and publishes
ONLINEhas no Will, so if it crashes it leaves a retainedONLINEasserting a dead host is alive. That is precisely the state the three-part contract exists toprevent, and it is what makes a partial implementation worse than none.
So the STATE claim must be bound to the connection, and the connection rebuilt on role change: on
gaining Primary, reconnect with the Will registered and publish retained
ONLINE; on losing it,reconnect without a Will so a later disconnect cannot clobber the new Primary's
ONLINE. The driveralready has a session-rebuild path (
ReinitializeAsyncrebuilds when the broker identity changes).Accepted trade, explicitly: a brief edge-visible
OFFLINEwindow during failover while the new Primaryreconnects and republishes. That is a genuine state transition, not a glitch to hide.
Seam required
The driver layer has no notion of redundancy, by design.
IRedundancyRoleView— the existing precedent,used for the alarm store-and-forward drain gate — lives in
Runtime, which drivers cannot reference. Sothis needs a small driver-facing role source in
Core.Abstractions, implemented inRuntimeoff thesnapshot
DriverHostActoralready publishes, and injected throughMqttDriverFactoryExtensions.Do not ship any leg of this without all three. The retained
ONLINE, the Will, and the clean-shutdownOFFLINEare one contract.Implemented and live-gated.
master857a10b4(feature commit7f2108dc).All three legs, or none — as the issue required.
SparkplugPrimaryHostpublishes a retained{"online":true,...}onspBv1.0/STATE/{hostId}at QoS 1, arms the matchingOFFLINEas the MQTTLast Will, and publishes an explicit
OFFLINEon a clean stand-down.The design the constraint forced
A Will can only be registered in the CONNECT packet — it cannot be added to a live session, changed,
or withdrawn. So the claim gets its own connection (client id
{clientId}-spbhost, forcedclean-session) whose lifetime is the claim: opened on gaining Primary, closed on losing it. Every
leg then follows from the connection's lifecycle rather than from bookkeeping that could drift.
That also turned out better than the "rebuild the ingest connection on role change" shape recorded
earlier: rebuilding ingest drops every subscription and the whole birth cache on each role change,
producing a data gap at the Secondary→Primary transition — the moment a plant most needs values
flowing. A second connection costs one client id and leaves ingest untouched.
Primary-gated
New seam
IDriverPrimaryRoleSourceinCore.Abstractions, carrying the data-plane verdict(
PrimaryGatePolicy.ShouldServiceAsPrimary) rather than the alarm-drain one — an unknown role with adriver peer present must deny, because two claimants is the harm. Polled on a 1s timer rather than
evented: a process singleton raising events at per-instance drivers outlives every driver it holds a
reference to.
⚠️ One wiring trap worth recording. It is registered by
AddOtOpcUaRuntime, deliberately notalongside the alarm drain's
IRedundancyRoleView— whichAddAlarmHistorianregisters only when thatsection is enabled. Piggybacking would have left a redundant pair with no alarm historian configured
falling back to the documented no-source default, which is claim. Both nodes would then claim the
same host id, reached purely by a registration accident in an unrelated section.
Live gate — 5/5 against the Mosquitto fixture
ONLINEvisible to a subscriber that joins after the birth;OFFLINE— the Will fires, and its timestamp matches the birth's;OFFLINEpromptly, without waiting out a keep-alive;attempts, not on
IsClaimed— see below);OFFLINEthen re-publishesONLINEon failback.Leg 2 needed a seam:
DisposeAsyncsends a cleanDISCONNECT, which by protocol rule makes thebroker discard the Will — so a test driven through the ordinary lifecycle can only ever observe
the explicit stand-down
OFFLINE, and would pass against an implementation that never armed a Will.AbandonForTestsdrops the socket with no DISCONNECT. Falsified: removing the Will turns that leg,and only that leg, red.
Leg 4 had the same problem in miniature. "Start a Secondary, assert
IsClaimedis false" passesidentically whether the gate works or the gate is deleted and the connect merely fails against an
unreachable broker — so it asserts on attempt count, with the Primary case as the falsifying
control on the same broker.
Accepted trade
A brief edge-visible
OFFLINEwindow during failover, while the old Primary stands down and the newone connects and republishes. That is a genuine state transition — for that window there really is no
host — not a glitch to hide. Stated on the AdminUI form, which also replaces the "not implemented"
badge.
Docs:
docs/drivers/Mqtt.md§"Primary host". Two stale claims corrected en route: the form still saidMQTT tags are read-only (#508 shipped), and the driver doc still said nothing consumes
IRediscoverable.