fix(opcuaclient): review fixes - drop driver-side OperationLimits chunking (SDK batches), diagnostics as OTel counter, safe discovery selection, options validation at load (#273 #275 #276 #277 #278)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+31
-25
@@ -112,14 +112,15 @@ interface doesn't yet carry the EventFilter surface needed to forward it.
|
||||
|
||||
`AutoAcceptCertificates` accepts any self-signed / untrusted server certificate.
|
||||
It is **dev-only** — leave it `false` in production so a MITM against the
|
||||
opc.tcp channel fails closed. A **revoked** certificate (`BadCertificateRevoked` /
|
||||
`BadCertificateIssuerRevoked`) is never auto-accepted (#277).
|
||||
opc.tcp channel fails closed. The SDK rejects a **revoked** certificate
|
||||
(`BadCertificateRevoked` / `BadCertificateIssuerRevoked`) before the auto-accept handler runs, and the handler
|
||||
also refuses them explicitly — defence in depth (#277), not a behaviour change.
|
||||
|
||||
`CertificateValidation` (defaults change nothing):
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `rejectUnknownRevocationStatus` | `false` | Reject a server certificate whose revocation status cannot be determined (no CRL for its issuer, or a stale one). Leave `false` for brownfield sites without CRL infrastructure. |
|
||||
| `rejectUnknownRevocationStatus` | `false` | Reject a server certificate whose revocation status cannot be determined (no CRL for its issuer, or a stale one). Leave `false` for brownfield sites without CRL infrastructure. **Only effective with `autoAcceptCertificates` off** — the auto-accept handler accepts every suppressible error, including revocation-unknown. |
|
||||
| `issuerStorePath` | `null` (= `%LocalAppData%/OtOpcUa/pki/issuers`) | Directory used as the trusted-issuer store. The SDK reads issuer CRLs (`*.crl`) from its `crl/` sub-folder, so this is how an operator points the driver at a managed CRL set. |
|
||||
|
||||
### Subscription tuning (#273)
|
||||
@@ -128,45 +129,50 @@ The per-subscription parameters that used to be hardcoded are driver options; th
|
||||
|
||||
| Key | Default | Notes |
|
||||
|---|---|---|
|
||||
| `subscriptionPublishingInterval` | `null` | `null` = the interval the runtime requests per subscribe (floored at 50 ms). When set it overrides that value (floor still applies); monitored-item sampling follows it. |
|
||||
| `subscriptionPublishingInterval` | `null` | A .NET `TimeSpan` string (`"00:00:02"`), not a number. `null` = the interval the runtime requests per subscribe (floored at 50 ms). When set it overrides that value (floor still applies); monitored-item sampling follows it. |
|
||||
| `subscriptionKeepAliveCount` | `10` | `>= 1`. |
|
||||
| `subscriptionLifetimeCount` | `1000` | Must be `>= 3 x` keep-alive count (Part 4 §5.13.2). |
|
||||
| `subscriptionMaxNotificationsPerPublish` | `0` | `0` = server decides. |
|
||||
| `subscriptionPriority` | `0` | `0`-`255`. |
|
||||
|
||||
Out-of-range values fail `InitializeAsync` (`ValidateTuning`). The alarm subscription uses the same counts.
|
||||
Out-of-range values (and a non-absolute `discoveryUrl`) are rejected when the options are loaded — driver creation / `InitializeAsync` fails once with a clear message rather than the driver faulting and retrying forever. The alarm subscription uses the same counts.
|
||||
*Per-tag* tuning (deadband / sampling interval / queue size, #274) is not implemented — it needs an
|
||||
`ISubscribable` signature change on Core.
|
||||
|
||||
### Server OperationLimits (#275)
|
||||
|
||||
After connect the driver reads `Server/ServerCapabilities/OperationLimits`
|
||||
(`MaxNodesPerRead`, `MaxNodesPerWrite`, `MaxNodesPerBrowse`, `MaxMonitoredItemsPerCall`,
|
||||
`MaxNodesPerHistoryReadData/Events`; `0` or unreadable = no limit) and splits requests that exceed them:
|
||||
|
||||
- `ReadAsync`, `WriteAsync` and the discovery attribute-enrichment read go out in `MaxNodesPerRead` /
|
||||
`MaxNodesPerWrite`-sized chunks, results re-stitched positionally. A failed Read chunk marks only its own tags
|
||||
`BadCommunicationError`; a Write that times out marks that chunk and every later one `BadTimeout`, while
|
||||
earlier chunks keep their real results (those writes happened).
|
||||
- `SubscribeAsync` creates monitored items in `MaxMonitoredItemsPerCall` batches.
|
||||
- Browse and HistoryRead already carry one node per call, so a limit `>= 1` is always met and nothing is split.
|
||||
|
||||
`honorServerOperationLimits` (default `true`) switches it off. The limits are re-read after a successful reconnect.
|
||||
Nothing driver-side: the SDK session already honours the remote server's
|
||||
`ServerCapabilities/OperationLimits`. `Opc.Ua.Client.Session` derives from `Opc.Ua.SessionClientBatched`,
|
||||
fetches the limits on open (`FetchOperationLimitsAsync`) and splits Read / Write / CreateMonitoredItems / Browse /
|
||||
HistoryRead requests that exceed them (a limit of `0` = unlimited). The driver issues one logical call per
|
||||
operation and a unit test pins the `Session : SessionClientBatched` relationship so an SDK change that dropped
|
||||
it is caught. (A driver-side splitter was tried and removed: it duplicated the SDK and, on a stalled link, held
|
||||
the driver gate for chunks x timeout.)
|
||||
|
||||
### Discovery URL (#278)
|
||||
|
||||
`discoveryUrl` (default `null`, e.g. `opc.tcp://lds:4840`): at `InitializeAsync` the driver runs `FindServers` on it
|
||||
and `GetEndpoints` on every returned server, keeps the endpoints matching `securityPolicy` + `securityMode`, and
|
||||
tries them **before** `endpointUrls`/`endpointUrl` (case-insensitive de-dup). A discovery failure is logged and
|
||||
non-fatal — the static list is used. The discovery channel itself is always unsecured (spec).
|
||||
and `GetEndpoints` on every returned server, keeping the endpoints matching `securityPolicy` + `securityMode`.
|
||||
Discovered URLs are tried **after** `endpointUrls`/`endpointUrl` (case-insensitive de-dup) — discovery can add
|
||||
fallbacks but never outranks or replaces the configured server. Because an LDS commonly registers several servers
|
||||
(identical line PLCs, a gateway plus a historian) and the first to accept a connection would be bound silently:
|
||||
|
||||
- `discoveryServerUri` set: only endpoints of the server with that ApplicationUri are used.
|
||||
- unset and exactly one distinct server discovered: that server is used.
|
||||
- unset and more than one: the connect attempt **fails** with an error naming the discovered ApplicationUris and
|
||||
telling the operator to set `discoveryServerUri`.
|
||||
|
||||
A discovery *network* failure (LDS down) is logged and non-fatal — the static list is used (note it is retried on
|
||||
every `InitializeAsync`, including comms-loss reconnects). The discovery channel itself is always unsecured (spec).
|
||||
|
||||
### Diagnostics counters (#276)
|
||||
|
||||
`GetHealth().Diagnostics` (a `name -> double` map, not part of the health-change fingerprint) carries:
|
||||
`PublishResponseCount`, `NotificationCount`, `DataChangeCount`, `BadStatusValueCount`, `PublishErrorCount`,
|
||||
`KeepAliveMissCount`, `ReconnectAttemptCount`, `ReconnectSuccessCount`, `ReconnectExhaustedCount`,
|
||||
`LastReconnectUtcTicks`, `ReadCallCount`, `ReadFailureCount`, `WriteCallCount`, `WriteFailureCount`,
|
||||
`ChunkedRequestCount`. All counters are `Interlocked`; they are cumulative for the driver instance's lifetime.
|
||||
Session/publish events are published on the shared OTel meter as the counter
|
||||
`otopcua.driver.opcuaclient.events`, tagged `driver_instance` (the driver instance id) and `event` =
|
||||
`publish_response`, `notification` (items), `data_change`, `bad_status_value`, `publish_error`,
|
||||
`keepalive_miss`, `reconnect_attempt`, `reconnect_success`, `reconnect_exhausted`. A climbing `keepalive_miss` /
|
||||
`reconnect_attempt` rate is the signature of a flapping upstream. They are not part of `DriverHealth` or the
|
||||
`/hosts` panel.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
Reference in New Issue
Block a user