perf(opcuaclient): split the read gate from subscription management; no gate on writes or the discovery browse (R06:O-6)

One SemaphoreSlim serialised reads, writes, subscribes, acks and the whole recursive
discovery browse, so a stalled write (up to the operation timeout) blocked every
read and subscription create, and a discovery browse would block the data plane for
its full duration. Reads/acks keep the read gate; writes and DiscoverAsync are
ungated (the SDK session is safe for concurrent service calls, the host bounds write
concurrency); subscription management takes its own gate. Dispose drains both
(DisposeAsync's drain no longer over-releases on a timed-out wait).

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:24:40 -04:00
parent afafd483dd
commit 66d100a9e9
3 changed files with 293 additions and 118 deletions
+15
View File
@@ -70,6 +70,21 @@ A single `Session` per driver instance; subscriptions multiplex onto it. Stored
session-relative `ns=N` index) so a remote namespace-table reorder across a
restart doesn't silently re-point references at the wrong namespace.
**Concurrency (review 2026-10-21 O-6).** Reads (including the post-outage recovery / reconcile reads) and acks
share one gate; **writes and the discovery browse take none** (the SDK session is safe for concurrent service
calls, and the host already bounds write concurrency and serialises per tag); subscription management — data and
alarm subscribe/unsubscribe and ConditionRefresh sweeps — has its own gate. One gate used to serialise all of it,
so a write stalled for the operation timeout blocked every read and subscription create, and a discovery browse
would have blocked the data plane for its whole duration.
**Lifecycle generation.** Every `InitializeAsync` / `ShutdownAsync` takes a new generation; the session, namespace
map and every health write land only while their generation is current. An init the host abandoned that finishes
after the next shutdown discards (closes + disposes) its session instead of installing it, and a read whose
response arrives after a shutdown does not write `Healthy` over it. A throw after the session activated closes
that local session (O-2). `ShutdownAsync` spends **one** 4 s budget on wire calls — every subscription delete
concurrently, then the close with what is left — and abandons waits the SDK does not cancel (O-9; it used to be
5 s per subscription, serially, inside the actor's `PostStop`).
### Upstream outage and recovery (Gitea #670)
The session keep-alive (`KeepAliveInterval`, default 5 s) is the liveness signal: