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:
+94
-20
@@ -2,7 +2,8 @@
|
||||
|
||||
In-process MQTT **broker-subscribe** driver. It holds one MQTTnet 5 client against one broker,
|
||||
subscribes to the topics its authored tags name, and forwards each PUBLISH straight to
|
||||
`ISubscribable.OnDataChange`. Ingest is **push, not poll**, and **one-way** — there is no `IWritable`.
|
||||
`ISubscribable.OnDataChange`. Ingest is **push, not poll**; writes go back out as ordinary publishes
|
||||
(see [Write-through](#write-through)).
|
||||
|
||||
**Two ingest shapes, selected by the driver's `Mode`:** `Plain` binds a tag to a concrete MQTT topic;
|
||||
`SparkplugB` binds it to a `group / edge node / device? / metric` tuple and decodes Eclipse-Tahu
|
||||
@@ -12,18 +13,18 @@ See [Mqtt-Test-Fixture.md](Mqtt-Test-Fixture.md) for the harness and
|
||||
[`../plans/2026-07-15-mqtt-sparkplug-driver-design.md`](../plans/2026-07-15-mqtt-sparkplug-driver-design.md)
|
||||
for the design.
|
||||
|
||||
> **Status.** P1 (plain MQTT) and P2 (Sparkplug B ingest) are both shipped and live-gated against the
|
||||
> Mosquitto + Sparkplug-simulator fixture. Write-through (`IWritable` — NCMD/DCMD/plain publish) is
|
||||
> **deferred** ([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508)); every MQTT node
|
||||
> materializes read-only. See [Known gaps](#known-gaps) — every entry there carries a tracking issue
|
||||
> (#507–#515).
|
||||
> **Status.** P1 (plain MQTT), P2 (Sparkplug B ingest), write-through
|
||||
> ([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508)) and the Sparkplug primary-host
|
||||
> claim ([#511](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/511)) are all shipped and
|
||||
> live-gated against the Mosquitto + Sparkplug-simulator fixture. See
|
||||
> [Known gaps](#known-gaps) — every entry there carries a tracking issue.
|
||||
|
||||
## Project Layout
|
||||
|
||||
| Project | Holds |
|
||||
|---|---|
|
||||
| `Driver.Mqtt.Contracts` | Config records, enums, `MqttTagDefinition` + the pure `MqttTagDefinitionFactory`, and the one shared `MqttJson.Options`. **Deliberately transport-free** — no MQTTnet reference |
|
||||
| `Driver.Mqtt` | Runtime: `MqttDriver`, `MqttConnection` (connect + reconnect supervisor), `MqttSubscriptionManager`, `LastValueCache`, factory registration, `MqttDriverProbe` |
|
||||
| `Driver.Mqtt` | Runtime: `MqttDriver`, `MqttConnection` (connect + reconnect supervisor), `MqttSubscriptionManager`, `LastValueCache`, `SparkplugIngestor`, `SparkplugPrimaryHost`, factory registration, `MqttDriverProbe` |
|
||||
| `Driver.Mqtt.Transport` | `MqttClientOptionsFactory` — TLS posture, PEM CA-pin chain validator, credentials, protocol mapping. Connection **policy** only; nothing here connects |
|
||||
| `Driver.Mqtt.Browser` | `MqttDriverBrowser` + `MqttBrowseSession` — the AdminUI address picker's `#`-observation session |
|
||||
|
||||
@@ -113,8 +114,8 @@ driver are pure organisational grouping in the RawPath.
|
||||
"Mode": "SparkplugB",
|
||||
"Sparkplug": {
|
||||
"GroupId": "OtOpcUaSim", // REQUIRED — the driver's whole subscription scope
|
||||
"HostId": "", // Host Application identity; receive-only (see below)
|
||||
"ActAsPrimaryHost": false, // NOT IMPLEMENTED — logs a warning when true
|
||||
"HostId": "", // Host Application identity — spBv1.0/STATE/{HostId}
|
||||
"ActAsPrimaryHost": false, // claim the primary host id (Primary-gated; see below)
|
||||
"RequestRebirthOnGap": true, // a seq gap asks the edge node to re-announce (NCMD)
|
||||
"BirthObservationWindowSeconds": 15 // how long discovery collects births before calling it stable
|
||||
}
|
||||
@@ -124,8 +125,8 @@ driver are pure organisational grouping in the RawPath.
|
||||
| Key | Default | Notes |
|
||||
|---|---|---|
|
||||
| `GroupId` | `""` | **Required.** The driver subscribes exactly `spBv1.0/{GroupId}/#`. Blank ⇒ it connects, reports `Healthy`, and ingests **nothing**. It is a literal topic segment, so `/`, `+`, `#` are refused by the form |
|
||||
| `HostId` | `""` | Sparkplug Host Application identity. **Receive-only** — the driver observes `STATE` and never publishes one |
|
||||
| `ActAsPrimaryHost` | `false` | **Not implemented.** Setting it logs a startup **warning** rather than being silently inert — being a primary host is a three-part contract (retained `ONLINE`, a matching `OFFLINE` registered as the MQTT Will *at connect time*, an explicit `OFFLINE` on clean shutdown), and shipping the `ONLINE` half without the Will is strictly worse than shipping neither: a crashed node would leave a retained `ONLINE` asserting forever that a dead host is alive |
|
||||
| `HostId` | `""` | Sparkplug Host Application identity — the `spBv1.0/STATE/{HostId}` topic. Optional for observation; **required** to claim. Set without `ActAsPrimaryHost`, the driver only observes |
|
||||
| `ActAsPrimaryHost` | `false` | Claim the Sparkplug Primary Host Application identity. **Primary-gated** — only the node holding the redundancy Primary role claims it. See [Primary host](#primary-host-actasprimaryhost) |
|
||||
| `RequestRebirthOnGap` | `true` | On a detected sequence-number gap, publish a rebirth-request NCMD instead of running on stale metric state |
|
||||
| `BirthObservationWindowSeconds` | `15` | Discovery's birth-collection window. Must be ≥ 1 |
|
||||
|
||||
@@ -252,12 +253,80 @@ One subscription (`spBv1.0/{GroupId}/#`) feeds a per-edge-node state machine
|
||||
anti-storm, not optimisations: rediscovery triggers an address-space rebuild, and an edge node
|
||||
re-births freely — on its own schedule, on every reconnect, and once per rebirth NCMD.
|
||||
|
||||
> ⚠️ **Nothing consumes the signal today.** No runtime component subscribes to
|
||||
> `IRediscoverable.OnRediscoveryNeeded`, and `DriverHostActor.HandleDiscoveredNodes` hard-returns —
|
||||
> discovered-node injection is dormant in v3. The served address space comes from the deploy artifact,
|
||||
> so **a DBIRTH introducing a new metric does not change the OPC UA tree**; you must redeploy. The
|
||||
> driver's decision is observable only as an Information log line
|
||||
> (`… requesting rediscovery.`). This is a v3 platform gap, not an MQTT one.
|
||||
> ⚠️ **The signal is an operator prompt, not a rebuild.** `DriverInstanceActor` subscribes and the
|
||||
> raise rides the driver-health snapshot to the AdminUI `/hosts` page as a **re-browse chip** — but the
|
||||
> served address space still comes from the deploy artifact, so **a DBIRTH introducing a new metric does
|
||||
> not change the OPC UA tree**. Re-browse the device and commit. That is deliberate: v3 authors raw tags
|
||||
> through the `/raw` browse-commit flow, and grafting nodes at runtime would materialise ones nobody
|
||||
> approved and no deployment artifact records.
|
||||
|
||||
## Primary host (`actAsPrimaryHost`)
|
||||
|
||||
A Sparkplug **Primary Host Application** is the thing edge nodes watch to decide whether anyone is
|
||||
listening — many stop publishing while their host is offline. Claiming it is a three-part contract,
|
||||
and this driver ships all three or none of it
|
||||
([#511](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/511)):
|
||||
|
||||
1. a **retained** `{"online":true,"timestamp":…}` on `spBv1.0/STATE/{hostId}`, QoS 1, published after CONNACK;
|
||||
2. a matching **retained `OFFLINE` armed as the MQTT Last Will**, so a crash still reports the truth;
|
||||
3. an explicit `OFFLINE` on a clean stand-down, so edge nodes do not wait out a keep-alive timeout.
|
||||
|
||||
Set `Sparkplug.HostId` and `Sparkplug.ActAsPrimaryHost: true`. A blank host id is **refused** with a
|
||||
warning — the driver does not publish to `spBv1.0/STATE/`, which is a legal topic reaching nobody.
|
||||
|
||||
### The claim is a whole 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 the claim gets its **own** broker connection (client id `{clientId}-spbhost`, forced
|
||||
clean-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.
|
||||
|
||||
The ingest session is untouched by a role change — no dropped subscriptions, no lost birth cache, and
|
||||
no data gap at the Secondary→Primary transition, which is the moment a plant most needs values flowing.
|
||||
|
||||
### Primary-gated — and why the obvious alternatives are wrong
|
||||
|
||||
Both nodes of a redundant pair subscribe independently (MQTT fan-out is per-connection, and the Primary
|
||||
gate applies downstream of ingest), so an ungated claim means both claim the same host id. The sharp
|
||||
failure is not the two fighting over the retained `STATE`. It is that the **Secondary's** death fires
|
||||
*its* Will, publishing `OFFLINE` while the Primary is perfectly healthy — every edge node in the group
|
||||
stops publishing, and redundancy has caused the outage the mechanism exists to prevent.
|
||||
|
||||
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, and a crash would leave a
|
||||
retained `ONLINE` asserting forever that a dead host is alive.
|
||||
|
||||
Hence: bind the claim to a connection, and rebuild the connection on role change.
|
||||
|
||||
| Node state | STATE connection | Retained `STATE` |
|
||||
|---|---|---|
|
||||
| Primary | open, Will armed | `ONLINE` |
|
||||
| Secondary / Detached | **never opened** | — (whatever the Primary published) |
|
||||
| Role unknown, driver peer present | not opened (fail closed) | — |
|
||||
| Role unknown, no driver peer (single node) | open | `ONLINE` |
|
||||
|
||||
The verdict comes from `PrimaryGatePolicy.ShouldServiceAsPrimary` via `IDriverPrimaryRoleSource`
|
||||
(`Core.Abstractions`), published by `DriverHostActor` on every redundancy snapshot and **sampled on a
|
||||
1-second timer** rather than evented — a process-singleton raising events at per-instance drivers
|
||||
outlives every driver it holds a reference to.
|
||||
|
||||
> **Accepted trade: a brief edge-visible `OFFLINE` during failover**, while the old Primary stands down
|
||||
> and the new one connects and republishes. That is a genuine state transition — for that window there
|
||||
> really is no host — not a glitch worth hiding.
|
||||
|
||||
### Operational notes
|
||||
|
||||
- **At most one primary host per host id per broker.** Two separate OtOpcUa deployments pointing at one
|
||||
broker with the same `hostId` will fight, and nothing in either can detect the other. The gate covers
|
||||
a redundant *pair*, not two independent deployments.
|
||||
- **A reconnect republishes the birth.** A broker restart drops retained messages; without the
|
||||
republish the host would be connected and believed offline by everyone, silently.
|
||||
- **A failed claim never faults the driver.** A broker whose ACL denies `spBv1.0/STATE/#` costs a
|
||||
warning and a plant-visible missing host, not every tag's value. Ingest is the data plane; the claim
|
||||
is an assertion to other applications.
|
||||
- **The driver still observes its own `STATE`.** The ingestor subscribes the configured host id
|
||||
regardless, so a claiming node sees its own retained `ONLINE` come back. Nothing gates on it, so the
|
||||
echo costs one log line.
|
||||
|
||||
## Connection + Failure Semantics
|
||||
|
||||
@@ -446,9 +515,14 @@ identical words. See [TestConnectProbes.md](TestConnectProbes.md).
|
||||
reconnect/rebuild branches, CONNACK classification, and the Sparkplug half — golden-payload decode,
|
||||
topic parse/format, datatype map, alias/birth-cache rebuild, sequence + bdSeq-death ties, the
|
||||
rediscovery change gate, and the browse session's "publishes nothing" assertion.
|
||||
- **Live (env-gated)** — `tests/Drivers/…Driver.Mqtt.IntegrationTests` (**15 tests**) against the
|
||||
- **Live (env-gated)** — `tests/Drivers/…Driver.Mqtt.IntegrationTests` (**26 tests**) against the
|
||||
Mosquitto broker + the C# Sparkplug edge-node simulator; skips cleanly when `MQTT_FIXTURE_ENDPOINT`
|
||||
is unset. See [Mqtt-Test-Fixture.md](Mqtt-Test-Fixture.md) and `infra/README.md` §3.
|
||||
<br/>The primary-host legs (`MqttPrimaryHostLiveTests`) include the one the ordinary lifecycle cannot
|
||||
reach: `AbandonClaimForTests` 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 explicit stand-down `OFFLINE` and pass against an implementation that never armed a Will
|
||||
at all.
|
||||
- **AdminUI** — `MqttDriverFormModelTests` / `MqttTagConfigModelTests` cover both editors'
|
||||
round-trip, mode inference and validation. AdminUI has no bUnit, so the razor shells themselves are
|
||||
only ever proven by a live `/run` gate.
|
||||
@@ -466,7 +540,8 @@ identical words. See [TestConnectProbes.md](TestConnectProbes.md).
|
||||
|
||||
Every row is tracked. None is a silent failure — each either refuses in words or logs.
|
||||
|
||||
**Closed 2026-07-31:** write-through ([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508), live-gated), the slashed-metric browse-commit
|
||||
**Closed 2026-07-31:** write-through ([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508), live-gated), primary-host STATE publishing
|
||||
([#511](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/511), live-gated), the slashed-metric browse-commit
|
||||
([#509](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/509)), the stale rebirth affordance ([#510](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/510)), the `.Browser` layering exception
|
||||
([#512](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/512)), the stray Sparkplug `payloadFormat` ([#513](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/513)) and rebirth on a
|
||||
not-yet-birthed plant ([#514](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/514)).
|
||||
@@ -474,5 +549,4 @@ not-yet-birthed plant ([#514](https://gitea.dohertylan.com/dohertj2/lmxopcua/iss
|
||||
| Gap | Issue | Effect | Notes |
|
||||
|---|:---:|---|---|
|
||||
| **Rediscovery is advisory, not a rebuild** | [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507) (closed, superseded) | A DBIRTH introducing a metric does not change the served OPC UA tree; an operator must re-browse the device and commit | **No longer "inert".** `DriverInstanceActor` now subscribes to `OnRediscoveryNeeded` and the raise rides the driver-health snapshot to the AdminUI `/hosts` page as a **re-browse chip**. It deliberately does not inject nodes: v3 authors raw tags through the `/raw` browse-commit flow, and grafting nodes at runtime would materialise ones nobody approved and no deployment artifact records. #507's injection path was deleted rather than revived |
|
||||
| **Primary-host STATE publishing** | [#511](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/511) | `ActAsPrimaryHost` does nothing | Logs a startup warning rather than being silently inert — see the `Sparkplug` sub-object table |
|
||||
| **`DataSet` / `Template` / `PropertySet` metrics** | [#515](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/515) | Skipped with a warning | v1-unsupported; never coerced into a guessed type |
|
||||
|
||||
Reference in New Issue
Block a user