test+docs(mqtt): end-to-end Bad-then-identical-replay recovery

This commit is contained in:
Joseph Doherty
2026-08-20 09:55:11 -04:00
parent b7d20d14a8
commit 6874a562f3
4 changed files with 125 additions and 1 deletions
+35
View File
@@ -415,6 +415,41 @@ one session, and carrying it across made the host's re-subscribe resolve an empt
SUBSCRIBE at all, and leave the driver Connected/Healthy and permanently deaf. Sparkplug's
`SparkplugIngestor.AttachTo` resets its own session state for the same reason.
## Quality on connection loss and re-subscribe
The quality contract this implements is stated once, in
[`docs/DriverLifecycle.md` §"The quality contract"](../DriverLifecycle.md#the-quality-contract).
Two edge behaviours make MQTT — a push-model driver — honour it. A poll driver gets both free from
`PollGroupEngine` (a fresh subscription's first poll runs with `forceRaise: true`); MQTT had neither,
which is archreview 05/STAB-18.
- **Connection loss stales, keeping the value.** On the `Connected → Reconnecting` edge
`MqttConnection.ConnectionLost` fires, and both ingest paths mark every **already-observed**
reference `BadNoCommunication` with its **last value retained** and publish the change. A
never-observed reference (still `BadWaitingForInitialData`) and an already-Bad one are left alone.
The event does **not** fire for a failed *initial* connect or during dispose — neither is an outage.
- **Every (re)subscribe pushes the cached snapshots.** `SubscribeAsync` republishes each observed
reference's cached snapshot onto the new handle before any broker traffic. This is what refills a
node an address-space rebuild reseeded `BadWaitingForInitialData` on a quiet topic — a rebuild
triggers no retained replay and, in Sparkplug, no rebirth.
**Why the stale is what makes recovery work.** The plain path suppresses a message whose value *and*
status equal the driver-owned cache, and that cache survives reconnects and redeploys. So the retained
message a broker replays on reconnect — routinely the identical payload — was suppressed, and every
node the host had latched Bad stayed Bad until the plant value happened to change. Staling the status
first makes the identical replay a change. (A regression test for this must replay the **identical**
payload; a different value defeats the suppression on its own and proves nothing.)
**Two "not communicating" codes, deliberately.** `BadNoCommunication` (0x80310000) means *the link is
down* — the connection-loss stale, and what the host's comms-loss fan-out writes onto the value nodes.
`BadCommunicationError` (0x80050000) keeps its narrower Sparkplug meaning: the **source announced its
own death** (NDEATH/DDEATH), or a birth voided the scope feeding the tag. A refused SUBSCRIBE filter
also degrades to `BadCommunicationError`.
**Operator-visible change:** an MQTT tag now goes Bad when the broker session drops, rather than
holding its last value at Good until the host's 90 s comms-loss watchdog notices — which, since
MQTTnet usually reconnects well inside that window, in practice meant never.
## Write-through
`IWritable` landed with [#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508). Before it,