docs: the stale-Good quality contract, stated once (R3-02)
This commit is contained in:
@@ -297,7 +297,32 @@ non-`Healthy` state **and** its `LastSuccessfulRead` has been frozen for 90 s. T
|
|||||||
system — it is a local `GetHealth()` read, and only reaches the network once a driver has already stopped
|
system — it is a local `GetHealth()` read, and only reaches the network once a driver has already stopped
|
||||||
reading.
|
reading.
|
||||||
|
|
||||||
See `docs/DriverLifecycle.md` §"The comms-loss watchdog".
|
- **What comms loss now does downstream — one contract, six seams (archreview R3-02).** "A value consumer
|
||||||
|
never reports a quality better than its source's" is stated once in `docs/DriverLifecycle.md` §"The
|
||||||
|
quality contract" and implemented by `Core.Abstractions.InputQuality` (`WorstOf`/`IsUsable`). Three
|
||||||
|
things changed with it, all client-visible:
|
||||||
|
- ⚠️ **Value nodes of a comms-lost driver now go Bad.** `DriverHostActor.FanCommsLossToValueNodes`
|
||||||
|
stamps `BadNoCommunication` on the raw NodeId **and every referencing UNS NodeId**, **value retained**,
|
||||||
|
via the new status-only sink primitive `IOpcUaAddressSpaceSink.WriteStatuses` (the host holds no value
|
||||||
|
cache, so only the node can keep the value). #481 had wired comms loss to the mux and to Part 9
|
||||||
|
conditions and stopped one seam short of the external data plane. A consumer that treats Bad as "drop
|
||||||
|
the tag" (ScadaBridge) will see more Bads — that is the fix working.
|
||||||
|
- **Nothing Good is written on reconnect** — recovery is the driver's own re-publish (the poll engine's
|
||||||
|
`forceRaise`, MQTT's new initial push). Instead the host **audits**: refs still latched 30 s after a
|
||||||
|
reconnect log a Warning and increment `otopcua.driver.comms_latched_refs`. Non-zero on that counter
|
||||||
|
means a driver reconnects without re-publishing.
|
||||||
|
- **Every dedup on the recovery path is keyed on (value, status), not value.** A device that comes back
|
||||||
|
usually comes back holding the same reading, so a value-only dedup swallows the recovery and the node
|
||||||
|
stays Bad forever — the v3-gate trap, now closed in the Calculation driver, the virtual-tag actor and
|
||||||
|
(via a connection-loss stale) MQTT's plain-mode suppression. **Any new consumer of driver values must
|
||||||
|
repeat this**; a regression test for it must replay the IDENTICAL value or it proves nothing.
|
||||||
|
- Consequences per seam: MQTT/Sparkplug stale on connection loss and push cached snapshots on every
|
||||||
|
(re)subscribe (`docs/drivers/Mqtt.md`); the Calculation driver and virtual tags **hold** while any
|
||||||
|
input is Bad and inherit the worst input's sub-code, evaluate through Uncertain, and hand scripts the
|
||||||
|
input's real `StatusCode`/`SourceTimestampUtc` (`docs/drivers/Calculation.md`, `docs/ScriptEditor.md`)
|
||||||
|
— so a script that relied on receiving `null` for a dead input no longer runs at all while it is Bad.
|
||||||
|
|
||||||
|
See `docs/DriverLifecycle.md` §"The comms-loss watchdog" and §"The quality contract".
|
||||||
|
|
||||||
## mxaccessgw
|
## mxaccessgw
|
||||||
|
|
||||||
|
|||||||
@@ -806,3 +806,13 @@ test could only pass once the production change landed.
|
|||||||
- `_lastPublishedBad` became `_hasPublished` + `_lastPublishedStatus`, which is also what makes
|
- `_lastPublishedBad` became `_hasPublished` + `_lastPublishedStatus`, which is also what makes
|
||||||
`ReassertValue` re-emit `BadNoCommunication` after a deploy instead of guessing Good.
|
`ReassertValue` re-emit `BadNoCommunication` after a deploy instead of guessing Good.
|
||||||
- Docs: `docs/ScriptEditor.md` §"Input quality: what a script sees, and when it runs at all".
|
- Docs: `docs/ScriptEditor.md` §"Input quality: what a script sees, and when it runs at all".
|
||||||
|
|
||||||
|
### T17 — Docs: the contract stated once
|
||||||
|
|
||||||
|
- `docs/DriverLifecycle.md` gains §"The quality contract" (the contract quoted verbatim + a table of the
|
||||||
|
six seams that implement it + the consumer-visible warning) and a new sub-section under the comms-loss
|
||||||
|
fan-out covering the value-node fan, `WriteStatuses`, and the recovery audit.
|
||||||
|
- Pointers added, each linking back rather than restating: `docs/Raw.md`, `docs/Uns.md`,
|
||||||
|
`docs/ScriptEditor.md` (T16), `docs/drivers/Calculation.md` (T14), `docs/drivers/Mqtt.md` (T5).
|
||||||
|
- `CLAUDE.md`: one bullet under the comms-loss watchdog section — value nodes go Bad, nothing Good on
|
||||||
|
reconnect (+ the audit meter), and the **(value, status)** dedup rule any future consumer must repeat.
|
||||||
|
|||||||
@@ -125,7 +125,7 @@
|
|||||||
{
|
{
|
||||||
"id": "T17",
|
"id": "T17",
|
||||||
"subject": "Docs: the quality contract stated once (DriverLifecycle.md) + Raw/Uns/ScriptEditor/Calculation pointers + CLAUDE.md bullet",
|
"subject": "Docs: the quality contract stated once (DriverLifecycle.md) + Raw/Uns/ScriptEditor/Calculation pointers + CLAUDE.md bullet",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"blockedBy": []
|
"blockedBy": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+64
-2
@@ -219,6 +219,37 @@ So a driver whose `InitializeAsync` disposes anything on its failure path must b
|
|||||||
`MTConnectDriver` does today, because it is the only one that tears down inside a `catch`. The
|
`MTConnectDriver` does today, because it is the only one that tears down inside a `catch`. The
|
||||||
shutdown paths deliberately keep the single trailing write.
|
shutdown paths deliberately keep the single trailing write.
|
||||||
|
|
||||||
|
### The quality contract
|
||||||
|
|
||||||
|
> **A value consumer never reports a quality better than its source's.** A node derived from a driver
|
||||||
|
> value — the raw node, every UNS mirror, a Calculation output, a virtual tag, a scripted condition's
|
||||||
|
> quality — carries, at worst, the worst status among its inputs: a Bad input makes the derived node Bad
|
||||||
|
> **with that input's sub-code and the last-known value**; an Uncertain input makes it Uncertain; a source
|
||||||
|
> that has stopped communicating is a Bad input (`BadNoCommunication`) for everything downstream of it,
|
||||||
|
> **including the external OPC UA data plane**. Recovery is a *publish*, never an assumption: the first
|
||||||
|
> post-recovery value must land even when it equals the value held before the outage, so every dedup on
|
||||||
|
> the path — driver caches, the node's own status, a consumer's last-published memory — must be keyed on
|
||||||
|
> **(value, status)**, not value alone.
|
||||||
|
|
||||||
|
This is one rule with one implementation of its comparison,
|
||||||
|
`ZB.MOM.WW.OtOpcUa.Core.Abstractions.InputQuality` (`WorstOf` / `IsUsable`), used by every derived-value
|
||||||
|
seam. It was written down because the same lie had been fixed four times in four places and re-opened in
|
||||||
|
a fifth (archreview round 3, theme "the stale-Good family"). The seams that implement it:
|
||||||
|
|
||||||
|
| Seam | What it does | Where |
|
||||||
|
|---|---|---|
|
||||||
|
| Driver comms loss → **value nodes** | one status-only batch stamps `BadNoCommunication` on the driver's raw NodeIds and every referencing UNS NodeId, **value retained** | `DriverHostActor.FanCommsLossToValueNodes` (below) |
|
||||||
|
| Driver comms loss → dependency mux | synthetic Bad per ref, so scripts and alarms see the source die | `DriverHostActor.FanCommsLossToDependencyMux` |
|
||||||
|
| Driver comms loss → Part 9 conditions | condition quality annotated out of band | `DriverHostActor.OnDriverConnectivityChanged` |
|
||||||
|
| MQTT / Sparkplug | connection loss stales observed refs; every (re)subscribe pushes the cached snapshot | [`docs/drivers/Mqtt.md`](drivers/Mqtt.md#quality-on-connection-loss-and-re-subscribe) |
|
||||||
|
| Calculation driver | holds on Bad, propagates the worst input status | [`docs/drivers/Calculation.md`](drivers/Calculation.md#input-quality) |
|
||||||
|
| Virtual tags | holds on Bad, inherits the worst input status, reassert carries it | [`docs/ScriptEditor.md`](ScriptEditor.md#input-quality-what-a-script-sees-and-when-it-runs-at-all) |
|
||||||
|
| Scripted alarms | worst-of-input quality annotated out of band; Bad inputs hold the condition | [`docs/ScriptedAlarms.md`](ScriptedAlarms.md) |
|
||||||
|
|
||||||
|
⚠️ **Consumer-visible:** value nodes of a comms-lost driver now go **Bad**, where they used to serve
|
||||||
|
their last value at Good. A consumer that treats a Bad value as "drop the tag" (ScadaBridge's data
|
||||||
|
connection layer, say) will see more Bads than before — that is the fix working, not a regression.
|
||||||
|
|
||||||
### The comms-loss watchdog (Gitea #527) — `GetHealth()` is now load-bearing
|
### The comms-loss watchdog (Gitea #527) — `GetHealth()` is now load-bearing
|
||||||
|
|
||||||
`GetHealth()` used to be **reporting-only**: `DriverInstanceActor.PublishHealthSnapshot` read it every 30 s
|
`GetHealth()` used to be **reporting-only**: `DriverInstanceActor.PublishHealthSnapshot` read it every 30 s
|
||||||
@@ -283,8 +314,8 @@ on it still gets its initialise attempt.
|
|||||||
#### The fan-out that watchdog triggers: indexed per driver, and one batch
|
#### The fan-out that watchdog triggers: indexed per driver, and one batch
|
||||||
|
|
||||||
`DisconnectObserved` raises `ConnectivityChanged`, and `DriverHostActor` answers it by pushing
|
`DisconnectObserved` raises `ConnectivityChanged`, and `DriverHostActor` answers it by pushing
|
||||||
`BadNoCommunication` at every ref the flapping driver owns — the value fan to the dependency mux, and the
|
`BadNoCommunication` at every ref the flapping driver owns — **three** fans: the value nodes (the
|
||||||
alarm fan to the publish actor's condition quality. Both used to answer "which refs are this driver's?"
|
external OPC UA surface), the dependency mux, and the publish actor's condition quality. Both used to answer "which refs are this driver's?"
|
||||||
by **scanning the node's entire ref map and filtering** — the maps are keyed by the composite
|
by **scanning the node's entire ref map and filtering** — the maps are keyed by the composite
|
||||||
`(DriverInstanceId, RawPath)`, so one driver's flap walked all ~50k keys on a large node to find its own
|
`(DriverInstanceId, RawPath)`, so one driver's flap walked all ~50k keys on a large node to find its own
|
||||||
few thousand. Each now has a secondary index, `DriverInstanceId → [(RawPath, node set)]`
|
few thousand. Each now has a secondary index, `DriverInstanceId → [(RawPath, node set)]`
|
||||||
@@ -310,6 +341,37 @@ its last one — everything on the subscription path was already wrapped in `Att
|
|||||||
`DependencyMuxActor` still registers the per-value receive, but in production it is dead code; it stays
|
`DependencyMuxActor` still registers the per-value receive, but in production it is dead code; it stays
|
||||||
for tests and direct senders.
|
for tests and direct senders.
|
||||||
|
|
||||||
|
#### The third fan: the value nodes (archreview 03/S15)
|
||||||
|
|
||||||
|
The two fans above reach *internal* consumers and *conditions*. Until archreview round 3 the driver's raw
|
||||||
|
value variables and their UNS mirrors — what an OPC UA client actually subscribes to — got nothing, so a
|
||||||
|
client saw the last value at **Good** quality for the whole outage. That was invisible on Modbus (which
|
||||||
|
degrades per tag within one poll interval on its own) and total on every driver whose failure shape is
|
||||||
|
"the reader stops producing": a coalesced-read throw, a dropped push session, or simply a driver whose
|
||||||
|
last act was a Good publish.
|
||||||
|
|
||||||
|
`FanCommsLossToValueNodes` closes it with **one status-only batch** — a new sink primitive,
|
||||||
|
`IOpcUaAddressSpaceSink.WriteStatuses` → `OtOpcUaNodeManager.WriteStatuses` — that changes each node's
|
||||||
|
StatusCode and Timestamp and **leaves its Value alone**. Status-only is not an optimisation: the host
|
||||||
|
holds no value cache (v3's single-source design deliberately removed the independent buffer), so only the
|
||||||
|
node itself can retain the last known value, and a Bad node still carrying its last reading is the OPC UA
|
||||||
|
Part 8 convention a SCADA consumer expects. Two details:
|
||||||
|
|
||||||
|
- It is **ungated by redundancy role**, like the condition annotation: a Secondary keeps its address space
|
||||||
|
honest for failover.
|
||||||
|
- An unmaterialised NodeId is **skipped, never created** — a status carries nothing to create a node from,
|
||||||
|
and the per-driver index can legitimately name a node a surgical remove has just dropped.
|
||||||
|
|
||||||
|
**Nothing is written on reconnect.** Publishing Good there would assert a quality the host has not
|
||||||
|
observed. Recovery is the driver's own re-publish: for a poll driver the watchdog's `DetachSubscription`
|
||||||
|
forces a fresh `PollGroupEngine` subscription whose initial poll runs `forceRaise: true`; for MQTT it is
|
||||||
|
the initial push added by 05/STAB-18. What the host does instead is **audit** it — `CommsRecoveryAudit`
|
||||||
|
fires `DefaultCommsRecoveryAuditDelay` (30 s) after the driver reconnects and reports any ref still
|
||||||
|
latched Bad, as a Warning naming the driver plus the `otopcua.driver.comms_latched_refs` counter (tagged
|
||||||
|
`driver`, `driver_type`). That counter going non-zero means a driver reconnected without re-publishing —
|
||||||
|
a push driver missing its initial push, or a value-only dedup swallowing an unchanged value. It is a
|
||||||
|
tripwire, not a repair: nothing downstream depends on it.
|
||||||
|
|
||||||
### The connect path feeds the circuit breaker (R2-10 T10 closure)
|
### The connect path feeds the circuit breaker (R2-10 T10 closure)
|
||||||
|
|
||||||
`IDriver.InitializeAsync` is routed through the capability invoker as
|
`IDriver.InitializeAsync` is routed through the capability invoker as
|
||||||
|
|||||||
+11
@@ -123,6 +123,17 @@ the full design. Highlights:
|
|||||||
rejects an A→B→A oscillation loop naming the members. Compile is not hard-gated (VirtualTag
|
rejects an A→B→A oscillation loop naming the members. Compile is not hard-gated (VirtualTag
|
||||||
parity); a runtime compile/throw/timeout lands as Bad quality + a `script-logs` entry.
|
parity); a runtime compile/throw/timeout lands as Bad quality + a `script-logs` entry.
|
||||||
|
|
||||||
|
## Quality: a raw tag's node when its driver stops communicating
|
||||||
|
|
||||||
|
A raw tag's node — and every UNS mirror of it — goes **`BadNoCommunication` with its last value
|
||||||
|
retained** when the owning driver is declared comms-lost, and clears when the driver re-publishes. It is
|
||||||
|
never cleared by the server on reconnect: recovery is a value, never an assumption. The rule and the
|
||||||
|
seams that implement it are stated once in
|
||||||
|
[`docs/DriverLifecycle.md` §"The quality contract"](DriverLifecycle.md#the-quality-contract).
|
||||||
|
|
||||||
|
Calc tags authored here inherit the same rule from their inputs — see
|
||||||
|
[`docs/drivers/Calculation.md` §"Input quality"](drivers/Calculation.md#input-quality).
|
||||||
|
|
||||||
## Retirement note
|
## Retirement note
|
||||||
|
|
||||||
The routed `/clusters/{id}/drivers` driver-authoring flow (`DriverTypePicker`,
|
The routed `/clusters/{id}/drivers` driver-authoring flow (`DriverTypePicker`,
|
||||||
|
|||||||
+11
@@ -476,6 +476,17 @@ not — clients pick those up on their next browse. And a **degraded** non-rebui
|
|||||||
(some node failed to materialise) deliberately does not record its composition as
|
(some node failed to materialise) deliberately does not record its composition as
|
||||||
applied, so the next deploy re-diffs and repairs it.
|
applied, so the next deploy re-diffs and repairs it.
|
||||||
|
|
||||||
|
## Quality on a UNS node
|
||||||
|
|
||||||
|
A UNS reference variable mirrors its backing raw tag exactly — value, status and timestamps — so when
|
||||||
|
that tag's driver stops communicating the mirror goes **`BadNoCommunication` with its last value
|
||||||
|
retained**, in the same fan-out message as the raw node, and clears only when the driver re-publishes.
|
||||||
|
A VirtualTag on the same equipment follows the same rule from its own inputs: it holds while any input
|
||||||
|
is Bad and reports that input's sub-code. The contract, and every seam that implements it, is stated
|
||||||
|
once in [`docs/DriverLifecycle.md` §"The quality contract"](DriverLifecycle.md#the-quality-contract);
|
||||||
|
what a script sees is in
|
||||||
|
[`docs/ScriptEditor.md`](ScriptEditor.md#input-quality-what-a-script-sees-and-when-it-runs-at-all).
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
- [Configuration.md](Configuration.md) — the underlying config entities.
|
- [Configuration.md](Configuration.md) — the underlying config entities.
|
||||||
|
|||||||
Reference in New Issue
Block a user