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
This commit is contained in:
@@ -75,6 +75,22 @@ public static class ServiceCollectionExtensions
|
||||
// telemetry (never DPS) to the Phase-5 gRPC streaming service central dials in on. AddOtOpcUaRuntime
|
||||
// runs only inside Program.cs's hasDriver block, so this lands on driver-role nodes only.
|
||||
services.TryAddSingleton<ITelemetryLocalHub, TelemetryLocalHub>();
|
||||
|
||||
// Gitea #511: the Primary data-plane verdict, published for drivers that assert a standing
|
||||
// exclusive identity on a shared external system (today the MQTT driver's Sparkplug host id).
|
||||
//
|
||||
// Registered HERE, unconditionally on every driver-role node, and NOT alongside the alarm
|
||||
// drain's IRedundancyRoleView — which AddAlarmHistorian registers only when that section is
|
||||
// enabled. Piggybacking on it would leave a redundant pair with no alarm historian configured
|
||||
// with no primary-role source at all, and the driver's documented no-source fallback is
|
||||
// "claim" — so both nodes of that pair would claim the same host id and their Wills would
|
||||
// fight over the retained STATE. Exactly the failure #511 exists to prevent, reached by a
|
||||
// registration accident in an unrelated section.
|
||||
//
|
||||
// Both interfaces are forwarded to ONE RedundancyRoleView instance, so a node that has both
|
||||
// publishes two verdicts into the same object rather than into two that disagree.
|
||||
services.TryAddSingleton<RedundancyRoleView>();
|
||||
services.TryAddSingleton<IDriverPrimaryRoleSource>(sp => sp.GetRequiredService<RedundancyRoleView>());
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -117,8 +133,10 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
// The view is a plain singleton so it exists even on nodes whose DriverHostActor never
|
||||
// publishes to it — an unpublished view reads as "no peer, drain", which is the correct
|
||||
// posture for a deployment that runs no redundancy at all.
|
||||
services.TryAddSingleton<IRedundancyRoleView, RedundancyRoleView>();
|
||||
// posture for a deployment that runs no redundancy at all. Forwarded to the same instance
|
||||
// AddOtOpcUaRuntime registered for IDriverPrimaryRoleSource: one object, two verdicts.
|
||||
services.TryAddSingleton<RedundancyRoleView>();
|
||||
services.TryAddSingleton<IRedundancyRoleView>(sp => sp.GetRequiredService<RedundancyRoleView>());
|
||||
|
||||
// THE GATE ONLY APPLIES TO A REPLICATED QUEUE. Standing down is only ever safe because some
|
||||
// other node holds the same rows and will send them instead — and the only node that does is
|
||||
@@ -499,7 +517,11 @@ public static class ServiceCollectionExtensions
|
||||
// Phase 5: the node-local live-telemetry hub (registered in AddOtOpcUaRuntime). The host
|
||||
// emits its Primary-gated native-alarm transitions into it and threads it into the
|
||||
// VirtualTag + ScriptedAlarm hosts it spawns.
|
||||
telemetryHub: resolver.GetService<ITelemetryLocalHub>()),
|
||||
telemetryHub: resolver.GetService<ITelemetryLocalHub>(),
|
||||
// Gitea #511: where this actor publishes its Primary DATA-PLANE verdict for drivers
|
||||
// that claim an exclusive external identity. Registered unconditionally by
|
||||
// AddOtOpcUaRuntime, unlike redundancyRoleView above — see the comment there.
|
||||
primaryRoleSource: resolver.GetService<IDriverPrimaryRoleSource>()),
|
||||
DriverHostActorName);
|
||||
registry.Register<DriverHostActorKey>(driverHost);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user