feat(mtconnect): CommsLossConfirmMs AdminUI field, refused-connection fast path reaches native conditions, dedicated live-gate Agent (#852)

- AdminUI MTConnect driver form: "Comms-loss confirm (ms)" in the Reconnect section, always written
  (0 = first refusal, negative = off is never dropped), case-insensitive load, unknown keys preserved,
  advisory at >= 90000 ms (the watchdog fires first). Form output byte-pinned and parsed through
  MTConnectDriver.ParseOptions on the driver side.
- Driver: a confirmed refused-connection outage now annotates every told CONDITION quality-only
  BadNoCommunication (state/ack kept, once per outage, UNAVAILABLE-Bad skipped); the first delivery
  afterwards re-diffs all subscribed conditions so unchanged ones get a quality-only Good.
- docker-dev/docker-compose.mtconnect-gate.yml: throwaway Agent in its adapter's network namespace so a
  stop is a genuine ConnectionRefused (a plain stopped container is a DNS failure = ambiguous).
- Docs: MTConnect.md comms-loss section + live-gate recipe, docker-dev README, drivers.md rule.

Refs #852
This commit is contained in:
Joseph Doherty
2026-11-11 06:23:18 -05:00
parent d1b6cad299
commit 340d9d26b7
11 changed files with 635 additions and 35 deletions
+57 -6
View File
@@ -184,7 +184,7 @@ Read off `MTConnectDriverOptions` / `MTConnectDriverConfigDto`
| `SampleIntervalMs` | `1000` | The Agent's `/sample?interval=` query param |
| `SampleCount` | `1000` | The Agent's `/sample?count=` query param. **Caps subscription throughput** at `SampleCount / SampleIntervalMs` — see [Subscribe](#subscribe--the-sample-pump) for the adaptive raise |
| `HeartbeatMs` | `10000` | The Agent's `/sample?heartbeat=` query param — also the watchdog's liveness window |
| `CommsLossConfirmMs` | `2000` | #846: how long the Agent must keep **refusing** connections before every subscribed value goes `BadNoCommunication` (see [Comms loss](#comms-loss-refused-vs-ambiguous-846)). `0` = on the first refusal; negative = fast path off (watchdog only). JSON-only — the AdminUI form preserves it but has no field |
| `CommsLossConfirmMs` | `2000` | #846: how long the Agent must keep **refusing** connections before every subscribed value — and, since #852, every native CONDITION (quality only) — goes `BadNoCommunication` (see [Comms loss](#comms-loss-refused-vs-ambiguous-846)). `0` = on the first refusal; negative = fast path off (watchdog only). AdminUI: "Comms-loss confirm (ms)" in the driver modal's Reconnect section (#852; any integer is saved — a negative is never dropped, which would re-enable the 2000 default; ≥ 90000 shows an advisory, as the watchdog always fires first) |
| `Probe.Enabled` / `Probe.IntervalMs` / `Probe.TimeoutMs` | `true` / `5000` / `2000` | Background connectivity-probe knobs (mirrors `ModbusProbeOptions`) |
| `Reconnect.MinBackoffMs` / `MaxBackoffMs` / `BackoffMultiplier` | `0` / `30000` / `2.0` | Geometric backoff after a failed request or a dropped `/sample` stream |
| `Write.Enabled` | `false` | The driver-side half of the write-back opt-in. An absent block means disabled — see [Write-back](#write-back--observation-injection-off-by-default) |
@@ -744,16 +744,67 @@ the `/sample` pump now handles it itself:
- **Then every subscribed value is published `BadNoCommunication`** (value null) **once per outage**, with one Warning.
Health was already Degraded with `LastSuccessfulRead` frozen by the stream failure, so the watchdog still re-initializes
at 90 s exactly as before — this only stops stale values reading Good in between. Reads already fall back to `/current`
on a degraded stream and fail on their own. Native CONDITIONS are untouched (the host annotates them on the
connectivity edge, as before).
on a degraded stream and fail on their own.
- **Native CONDITIONS too (#852):** at the same moment, every subscribed condition the server holds a statement for gets
a quality-only `BadNoCommunication` annotation — exactly what the host's connectivity edge does at the watchdog: Active
state, severity, message and acknowledgement untouched, no `/alerts` row, once per outage (a condition already Bad
because the Agent reported it UNAVAILABLE is skipped). Recorded in the driver's told record, so recovery lifts it.
- **Recovery republishes everything:** the first delivered chunk (a heartbeat counts) or re-baseline after a marked outage
republishes every subscribed value from the index — a resumed `/sample` carries only what changed, so an unchanged item
would otherwise stay Bad (the (value, status) recovery rule).
would otherwise stay Bad (the (value, status) recovery rule). The same delivery re-diffs **every** subscribed condition:
an unchanged one gets a quality-only Good (never a re-raise — that would un-acknowledge it); one the Agent still reports
UNAVAILABLE stays Bad; one that changed during the outage gets its ordinary Raise/Retrigger/Clear.
- Consequence for an Agent **restart** that refuses for longer than the window (the #823 restart refused for ~1 s, inside
it): values go Bad for the restart and come back on its first chunk / re-baseline. That is the honest answer — the
Agent was down.
Tests: `MTConnectCommsLossTests` (incl. an ambiguous-failure falsification control and the confirmation window).
Tests: `MTConnectCommsLossTests` (incl. an ambiguous-failure falsification control and the confirmation window),
`MTConnectConditionCommsLossTests` (#852 — conditions, incl. the UNAVAILABLE and ambiguous-failure controls).
### Live gate: refused-connection fast path (#846/#852) — recipe
**Not yet run.** Uses a dedicated, throwaway Agent on the local docker-dev rig — never the shared fixture on
`10.100.0.35`. `docker-dev/docker-compose.mtconnect-gate.yml` adds two services to the rig's network: `mtconnect-gate`
(the fixture's SHDR adapter, which also owns the network namespace and publishes host `:5155`) and
`mtconnect-gate-agent` (`mtconnect/agent:2.7.0.12` with the fixture's `agent.cfg`/`Devices.xml`, running **inside**
`mtconnect-gate`'s namespace).
⚠️ **Why not just `docker stop` an ordinary Agent container:** a stopped compose container also drops out of Docker's
embedded DNS, so the driver sees a DNS failure — ambiguous by design, watchdog only — and the gate fails for the wrong
reason. With the shared namespace the name `mtconnect-gate` keeps resolving while the Agent is down, and the connect
is genuinely refused (smoke-checked: Agent stopped ⇒ `Connection refused` from inside the network; both stopped ⇒
`bad address`).
```bash
dcg() { docker compose -f docker-dev/docker-compose.yml -f docker-dev/docker-compose.mtconnect-gate.yml "$@"; } # bash + zsh
dcg up -d --no-deps mtconnect-gate mtconnect-gate-agent
curl -s http://127.0.0.1:5155/probe | head -3 # Agent answering (Mac side)
```
1. **Driver.** In AdminUI `/raw` (http://localhost:9200, MAIN cluster) add an MTConnect driver, Agent base URI
`http://mtconnect-gate:5000`, **Comms-loss confirm (ms) = 2000** (the default — the new field in the Reconnect
section), one Device. Browse-commit `fixture_x_pos` (Double) and `fixture_execution` (String), and the CONDITION
`fixture_x_travel` with an `alarm` object (optional, for the #852 leg). Optionally reference the two value tags into
a UNS equipment. Deploy.
2. **Watch.** Client CLI against central-1 (`opc.tcp://localhost:4840`):
`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=<RawPath of fixture_x_pos>" -i 250`
(and a second one on the UNS NodeId, `ns=3;s=<Area>/<Line>/<Equipment>/<name>`, if referenced). Values Good and moving.
For the condition leg, `alarms -u opc.tcp://localhost:4840 --refresh` in another shell (or read the condition's
`Quality` child found with `browse`).
3. **Refuse.** Note the wall clock, then `dcg stop mtconnect-gate-agent`.
**Expect:** both NodeIds `BadNoCommunication` (value null) **within ~2–3 s** (2000 ms confirmation + the first
reconnect attempt), not ~90–118 s; host log `has refused every connection for … ms (definite loss); marking N
subscribed value(s) BadNoCommunication`, and with a condition authored `annotated 1 native condition(s)
BadNoCommunication`; the condition stays in its Active/acked state. `/hosts` shows the driver Degraded. If you wait
past 90 s the #527 watchdog also re-initializes the driver as before — so run step 4 within ~60 s to keep the legs separate.
4. **Recover.** `dcg start mtconnect-gate-agent`. **Expect:** values Good again within a few seconds of the
Agent answering (first chunk/heartbeat or re-baseline republishes every value, unchanged ones included); the
condition's quality Good with no re-raise / no new `/alerts` row. The restarted Agent has a new `instanceId`, so a
**RE-BROWSE** chip on `/hosts` is expected and unrelated.
5. **Control (optional):** `dcg stop mtconnect-gate-agent mtconnect-gate` — now the name no longer
resolves (ambiguous): values must stay Good-but-stale until the ~90 s watchdog. Start `mtconnect-gate` FIRST, then the
agent.
6. **Clean up:** remove the driver from `/raw` and deploy, then `dcg rm -sf mtconnect-gate-agent mtconnect-gate`.
### End-to-end gate through a deployed server (#823, 2026-11-11, docker-dev)
@@ -766,7 +817,7 @@ NodeIds moved together in every leg. Fixture containers were restored healthy af
|---|---|
| Adapter stop | Both NodeIds `BadNoCommunication` within 1 s of the adapter dying (Agent marks items UNAVAILABLE); driver stayed Healthy. Adapter start ⇒ Good 1–2 s later. |
| Agent restart (`docker restart`, ~12 s) | Stream lost and reconnected ~1 s; the stale cursor was refused `OUT_OF_RANGE`, the re-baseline saw the new `instanceId`, **rediscovery raised** — `/hosts` shows the **RE-BROWSE** chip. Values stayed Good (outage shorter than the comms-loss window). |
| Agent stopped ~2 min | Values held Good-but-stale; **Bad at 118 s** (comms-loss watchdog, connection refused). Agent start ⇒ re-init on the 10 s retry, Good ~8 s after the Agent was back. **Since #846** a refused connection marks the values `BadNoCommunication` after `CommsLossConfirmMs` (2 s) instead — unit-tested, not yet re-gated live. |
| Agent stopped ~2 min | Values held Good-but-stale; **Bad at 118 s** (comms-loss watchdog, connection refused). Agent start ⇒ re-init on the 10 s retry, Good ~8 s after the Agent was back. **Since #846** a refused connection marks the values `BadNoCommunication` after `CommsLossConfirmMs` (2 s) instead — unit-tested, not yet re-gated live (recipe: [Live gate: refused-connection fast path](#live-gate-refused-connection-fast-path-846852--recipe)). |
| Agent paused (`docker pause`) | Stream idle watchdog at 24 s (2 × 10000 + 5000); **Bad at 94 s** (watchdog); unpause ⇒ Good within ~3 s. |
| Genuine buffer overrun | A stalling TCP proxy froze the driver's transport while 32 000 observations were injected (`AllowPut`) — the Agent's `firstSequence` passed the driver's cursor (75 856 → 77 083). On release the resume was refused `OUT_OF_RANGE`, the driver re-baselined from `/current` within 1 s and resumed; values Good, no restart misdiagnosed. A raised-count stream (count 16, interval 5 s) was **not** lapped by a 266 obs/s burst: chunks are sent back-to-back while data is pending, so `interval` does not cap throughput. |
| `SampleCount` vs `bufferSize` (#825) | Test Connect rendered the amber "OK with warning" chip and a deploy logged the Warning (`/hosts` shows `agentBufferSize`). The same gate measured the Agent's real edge — `count = 16384` streams, `16385` is `OUT_OF_RANGE` — so the first build's "at or above" threshold was corrected to "above" (pinned live by `Test_Connect_warns_when_SampleCount_exceeds_the_live_agents_buffer_size`). |