Ran the full item-1 grind (authored a live Modbus equipment tag, connected to the sim, LDAP-authed writes, docker pause the peer). 4 failing wrapped writes produced 0 retry/breaker lines on either central. Root cause (corrects the issue's hypothesis): ModbusDriver.WriteAsync swallows all exceptions and returns WriteResult(StatusBadInternalError) — never throws; CapabilityInvoker feeds only exceptions to Polly; breaker ShouldHandle is Handle<Exception>; Write retry pinned to 0 (R2-02/S-8). So a failing wrapped write emits no line by construction, for any polling driver. The line is reachable ONLY for a session driver (OpcUaClient) faulting mid-Discover/Subscribe (30s Polly timeout throws) — a production timing race, not deterministically forcible on the rig. Behaviour stays unit-proven by the pipeline-builder test. - New: archreview/plans/artifacts/456-retry-breaker-live-finding-2026-07-15.md - FOLLOWUP-10 updated with the structural finding + recommendation to close item 1.
6.4 KiB
#456 item 1 — live retry/breaker log-line capture: structural finding (2026-07-15)
Outcome: the log line was not captured, but the "grind" produced a definitive structural
finding that supersedes the issue's acceptance framing: the trigger the issue proposes (an idempotent
WriteAsync to a dead endpoint) is structurally incapable of emitting a retry/breaker line, and the line
is reachable in production only for a session-based driver (OpcUaClient) faulting mid-Discover/Subscribe
— a racy transient that cannot be deterministically forced on the dev rig. This is why three sessions with
polling-driver fixtures never observed it.
What was actually driven live (the grind)
Full end-to-end wrapped-dispatch exercise on the docker-dev central cluster (central-1/central-2, MAIN):
- Brought up the modbus sim on the shared host (
10.100.0.35:5020,otopcua-pymodbus-standard). - Authored a complete Modbus equipment hierarchy via SQL (MAIN cluster): Equipment-kind
Namespacens-mb-eq→DriverInstance drv-mb(Modbus,{"Host":"10.100.0.35","Port":5020,"UnitId":1}) →Device→UnsArea/UnsLine→Equipment EQ-111111111111→ writableTag reg1({"region":"HoldingRegisters","address":1,"dataType":"Int16","byteOrder":"BigEndian","writable":true},AccessLevel=ReadWrite,WriteIdempotent=1). - Deployed headless (
POST :9200/api/deployments). Runtime log:spawned Modbus driver drv-mb→DriverInstance drv-mb: connected→subscribed to 1 refs. Node materialized atns=2;s=EQ-111111111111/reg1. - Baseline (sim up): live read
Good(value 1); LDAP-authed (multi-role,WriteOperaterole via the shared GLAuth data-planeGroupToRole) write= 123→Write successful. The wrapped write path is fully live end-to-end (OPC UA write →NodeWriteRouter→DriverHostActor.RouteNodeWrite→DriverInstanceActor.WriteAsyncinvoker-wrapped site →ModbusDriver→ sim). - Fault:
docker pause otopcua-pymodbus-standard(frozen peer — driver staysConnected), then drove 4 LDAP-authed writes into the frozen peer. - Result: every write logged
Operator write to EQ-111111111111/reg1 rejected: StatusCode=0x80020000(BadInternalError), node reverted.grep "Driver resilience retry|circuit-breaker OPENED"on BOTH centrals = 0 matches despite 4 exercised, failing wrapped writes.
Why the write trigger is structurally impossible
ModbusDriver.WriteAsyncswallows every exception and returns a badWriteResultstatus — it never throws (ModbusDriver.cs:964-967:catch (Exception) { results[i] = new WriteResult(StatusBadInternalError); }→0x80020000, exactly the observed reject status).- The
CapabilityInvokeronly feeds exceptions to the Polly pipeline (CapabilityInvoker.cs:84/112/147:await pipeline.ExecuteAsync(callSite, …)— thecallSiteis the driver'sWriteAsync; a non-throwing return is a pipeline success). The invoker does not inspect the returnedWriteResultstatus. - The circuit breaker's
ShouldHandleisHandle<Exception>(DriverResiliencePipelineBuilder.cs), andWriteretry is pinned to 0 as an invariant (R2-02/S-8,DriverResilienceOptions.cs:64-66). - ∴ a failing wrapped write produces no exception → no retry, no breaker count, no log line — by
construction, for any driver that maps write failures to a status (all polling drivers do). The issue's
hypothesis ("the only invoker-wrapped Modbus site that throws is an idempotent
WriteAsyncto a dead endpoint") is incorrect — even an idempotent write to a truly dead endpoint returnsStatusBadInternalErrorrather than throwing.
Where the line IS reachable (and why it's elusive)
The retry/breaker pipeline fires only when a wrapped capability call throws. Of the 6 wrapped sites in
DriverInstanceActor (Write, Acknowledge, Subscribe, Unsubscribe, SubscribeAlarms, Discover):
- Polling drivers (Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas):
WriteAsync/AcknowledgeAsyncswallow → status;SubscribeAsyncis a lazy poll-registration (no connect, no throw);DiscoverAsyncis offline/config-driven (enumerates authored tags, no connect). None of their wrapped sites throw a transient exception through the seam. - Session drivers (OpcUaClient):
DiscoverAsync(remote browse) andSubscribeAsync(CreateSubscription/CreateMonitoredItems) require a live session and do throw on a session fault. The post-connect discovery loop (DriverInstanceActor.StartDiscovery, honouringRediscoverPolicy) runsDiscoverAsyncunder a 30 s Polly timeout on eachConnectedentry.
So the only way to emit the line is a session driver whose session faults while a wrapped
Discover/Subscribe is in flight (e.g. docker pause of a live OPC UA endpoint mid-discovery → the
30 s timeout throws TimeoutRejectedException → Discover retry ×2 → Driver resilience retry). This is a
genuine production event under real network faults, but it is a timing race — connect must succeed, then
the fault must land during the wrapped call, before the session-keepalive drops the driver to Reconnecting
(where the wrapped sites no longer fire). It cannot be deterministically forced on the rig with the available
fixtures (opc-plc was down; no on-demand rediscovery trigger).
Conclusion / recommendation
- Item 2 (the real code gap —
ResilienceConfigin the deploy artifact) is done + live-verified on both nodes (PR #461). - Item 1 is deterministically proven by the
DriverResiliencePipelineBuilderlogging unit test (theOnRetry/OnOpenedhandlers emit the exact lines) plusCapabilityInvokerretry tests and the factory override test. The live capture is not deterministically achievable from the dev rig — and, more importantly, the grind proved the issue's proposed write-trigger is structurally impossible. The line is an OpcUaClient-only, transient-fault-mid-Discover/Subscribeevent. - Recommend closing item 1 on this finding: the observability surface is unit-proven and the wrapped
dispatch path is now live-confirmed end-to-end (authored driver → connected → LDAP write → wrapped
WriteAsync→ faulted → surfaced). The remaining "eyeball the raw line" is an operator/staging observation during a real OpcUaClient session fault, not a code deliverable.