Merge commit '82380d2b' into remed/integration-w3
This commit is contained in:
@@ -73,7 +73,7 @@ below).
|
||||
|
||||
## Capability surface
|
||||
|
||||
`MTConnectDriver : IDriver, IReadable, IWritable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable`
|
||||
`MTConnectDriver : IDriver, IReadable, IWritable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable, IDriverDiagnostics`
|
||||
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`).
|
||||
|
||||
`IWritable` landed 2026-08-05 (`b29623cf`), against the Agent's `AllowPut`
|
||||
@@ -87,11 +87,12 @@ request/response handshake — is a different mechanism and remains deferred; se
|
||||
| Capability | Path | Notes |
|
||||
|------------|------|-------|
|
||||
| `ITagDiscovery` | `DiscoverAsync` — streams `/probe`'s Device→Component→DataItem tree into the address-space builder | `SupportsOnlineDiscovery = true`, `RediscoverPolicy = Once` |
|
||||
| `IReadable` | `ReadAsync` → one `/current` per call | **Not the production data path** — see below |
|
||||
| `IReadable` | `ReadAsync` → the live `/sample` pump's index while it is delivering and healthy, else one `/current` per call | **Not the production data path** — see below |
|
||||
| `IWritable` | `WriteAsync` → `POST {agent}/{device}?{dataItemId}={value}` on the Agent's `AllowPut` surface | **Off by default** — needs `Write.Enabled` **and** the Agent's `AllowPut`; CONDITION items are refused |
|
||||
| `ISubscribable` | `SubscribeAsync`/`OnDataChange` — the shared `/sample` long-poll pump | **The production data path** |
|
||||
| `IHostConnectivityProbe` | periodic `/probe` under `Probe.*` | No consumer wires it today — see [Known limitations](#known-limitations) |
|
||||
| `IHostConnectivityProbe` | periodic headers-only `/probe` ping under `Probe.*` — the status is read, the device model is neither downloaded in full nor parsed (review 2026-10-21 T-7; it used to parse the whole model every tick). Trade-off: a 200 from something that is not an Agent (a proxy's HTML page) now reads as reachable; the data paths still fail on it | Consumed — feeds `/hosts` (see [Known limitations](#known-limitations)) |
|
||||
| `IRediscoverable` | watches the Agent's `Header.instanceId` | No consumer wires it today — see [Known limitations](#known-limitations) |
|
||||
| `IDriverDiagnostics` | `GetDiagnostics()` — `/sample` effective count, lag, saturated chunks, count raises | Shown on `/hosts` (review 2026-10-21 T-5) |
|
||||
| `IAlarmSource` | CONDITION observations off the same `/sample` pump → Part 9 transitions keyed by the alarm tag's RawPath | Opt-in per raw tag (`alarm` object) — see [CONDITION alarms](#condition-alarms-626) |
|
||||
|
||||
### Feature status at a glance (#613)
|
||||
@@ -113,6 +114,25 @@ subscribes once and lives off `OnDataChange`. `ReadAsync` exists for the
|
||||
Client CLI (`... read -n ...`) and for the unit/integration suites; it is
|
||||
correct and tested, just not on the hot path.
|
||||
|
||||
**Read path (review 2026-10-21 T-4 / T-2).** While the current-generation `/sample` pump is running, has
|
||||
delivered since it started (a chunk, heartbeat or re-baseline) and its stream is not degraded, a read is answered
|
||||
from the pump's observation index — **read only**; the pump stays its single writer — with no Agent round trip.
|
||||
Otherwise (no subscription, a pump still catching up after a resubscribe, a degraded stream) it falls back to one
|
||||
`/current`, whose per-batch index is now built over a tag map frozen once per binding. **Stale window:** a
|
||||
stream that stalls without failing (no chunk, no heartbeat) still serves Good reads from the index until the client's
|
||||
idle watchdog fires (2 × `HeartbeatMs` + `RequestTimeoutMs`) — the same bounded window subscriptions already have. During
|
||||
an Agent-restart re-baseline reads fall back to `/current`. A served read also clears a
|
||||
read-path degradation an earlier failed `/current` left (the read path is answering). The `/current` fallback now
|
||||
checks the answer's `instanceId`: a changed one (an Agent restart seen by a browse-only driver with no pump) drops the
|
||||
cached `/probe` model and raises `OnRediscoveryNeeded` once per distinct id — it used to be detected only by the pump.
|
||||
|
||||
**An Agent refusal is a reply (health contract).** An `MTConnectError` document answering `/current` (e.g.
|
||||
`NO_DEVICE` for a wrong `DeviceName` scope) still fails the batch (`BadCommunicationError` per ref), but it is the
|
||||
Agent answering, not a comms fault: the read path is not degraded by it (that would let the comms-loss watchdog
|
||||
reconnect-loop the driver over a configuration problem), `LastSuccessfulRead` advances, a prior read degradation
|
||||
clears, and the Agent's text is logged once per distinct refusal. An answer that is not an MTConnect document at all
|
||||
(a proxy's HTML page, truncated XML) stays a comms fault.
|
||||
|
||||
## Minimum deployment
|
||||
|
||||
```jsonc
|
||||
@@ -146,11 +166,11 @@ Read off `MTConnectDriverOptions` / `MTConnectDriverConfigDto`
|
||||
|
||||
| Key | Default | Notes |
|
||||
|---|---|---|
|
||||
| `AgentUri` | — (required) | Agent base URI; the driver appends `/probe`, `/current`, `/sample` |
|
||||
| `AgentUri` | — (required) | Agent base URI; the driver appends `/probe`, `/current`, `/sample`. Any `user:pass@` (and query) is **redacted** from `HostName` (the `/hosts` key), every log line and every exception / health message (review 2026-10-21 T-8); a URI without credentials is shown unchanged |
|
||||
| `DeviceName` | `null` | **Double duty.** (1) Scopes requests to `{AgentUri}/{DeviceName}/...`; `null` = whole Agent. (2) The write path's fallback device — the device a write is addressed to when the probe model does not say which device declared the DataItem (see [Write-back](#write-back--observation-injection-off-by-default)) |
|
||||
| `RequestTimeoutMs` | `5000` | Per-call deadline for `/probe` and `/current` |
|
||||
| `SampleIntervalMs` | `1000` | The Agent's `/sample?interval=` query param |
|
||||
| `SampleCount` | `1000` | The Agent's `/sample?count=` 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 |
|
||||
| `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 |
|
||||
@@ -324,6 +344,26 @@ the two need disambiguating, not just OR-ing together). On a changed
|
||||
`/hosts` page — see [Known limitations](#known-limitations) for what that does
|
||||
and does not do.
|
||||
|
||||
**Lag measurement and the adaptive `count` (review 2026-10-21 T-5).** Throughput is capped at `SampleCount`
|
||||
observations per chunk, one chunk per `SampleIntervalMs`; a machine producing more used to fall behind the ring
|
||||
buffer forever, re-baselining every buffer-length (dropping intermediate transitions) with nothing surfacing it.
|
||||
Each chunk is now measured: `lag = lastSequence + 1 − nextSequence` (unknown when the Agent omits `lastSequence`)
|
||||
and whether it was **saturated** (carried the full `count`). Three consecutive saturated chunks with a positive (or
|
||||
unknown) lag reopen the stream at the cursor reached with `count` doubled, up to 16× `SampleCount` (max 65 536). A
|
||||
raised stream that delivers nothing (an Agent refusing the larger count) steps back to the last count that delivered
|
||||
and stops raising; at the ceiling a still-behind stream logs one Warning per pump. Every new pump starts from the
|
||||
configured `SampleCount`. An Agent that refuses the raised count with an `MTConnectError` under HTTP 200 goes through
|
||||
the ordinary unusable-answer path first — one transient Degraded blip and a backoff — before the step-back. The
|
||||
driver now implements `IDriverDiagnostics`: `sampleCount` (effective), `sampleLag`,
|
||||
`sampleSaturatedChunks` and `sampleCountRaises` ride the `/hosts` diagnostics bag.
|
||||
|
||||
**A pump carries the session generation it was started against (review 2026-10-21 T-1).** A lifecycle restart
|
||||
retires the running pump (or abandons it after its bounded teardown wait); if that pump finishes after the new
|
||||
session is installed, its cursor publish, a late chunk and a late re-baseline are all dropped — checked under the
|
||||
same lock the new session is installed and the generation bumped under. It used to land the OLD Agent's
|
||||
`instanceId` + sequence on the new session, so the next pump read its first chunk as an Agent restart (index
|
||||
cleared, one Bad flicker on every subscribed tag, a needless re-baseline).
|
||||
|
||||
## Write-back — observation injection, off by default
|
||||
|
||||
> ⚠️ **A write sets what the Agent *reports*, not what the machine does.** The value
|
||||
@@ -391,8 +431,26 @@ step answers a different operator question:
|
||||
different `level|text` wire format, and are out of scope for v1.
|
||||
|
||||
**The Agent's refusal text is logged at Warning and never stored.** It stays out of
|
||||
`DriverHealth.LastError`, which is type-only by repo-wide discipline, and a refused write
|
||||
is not evidence the read path degraded — so a refusal leaves driver health untouched.
|
||||
`DriverHealth.LastError`, which is type-only by repo-wide discipline. A refused write is not
|
||||
evidence the read path degraded, but it **is** the Agent answering: any answer — accepted or
|
||||
refused — advances `LastSuccessfulRead`, with the state following the read / `/sample` flags
|
||||
(a write cannot clear either of those paths' degradation). A write that got **no** answer
|
||||
(transport failure, deadline) sets **Degraded** with the stamp preserved and a type-only
|
||||
`LastError` (review 2026-10-21 I-3, coordinator ruling). One success writer is shared: that
|
||||
degradation is cleared by the next answered **write**, the next answered **`/current` read**, or
|
||||
the next **`/sample` chunk** the current session's pump delivers (review r2 N-5: a delivered
|
||||
chunk proves the Agent is alive — the analogue of OpcUaClient's good keep-alive — so it cannot
|
||||
stick on a subscription-only driver). A read served from the live pump index does not clear it
|
||||
by itself; the chunks behind that index do.
|
||||
|
||||
**One request per device (review 2026-10-21 T-6).** The slots that survive the refusals above
|
||||
go out as one `POST {agent}/{device}?id1=v1&id2=v2…` per device (the Agent's PUT surface takes
|
||||
several pairs) instead of one POST per slot. A batch closes at a slot naming another device or
|
||||
repeating a DataItem already in it, so two writes to one DataItem still land in request order.
|
||||
An Agent refusal of a multi-slot request is retried slot by slot, so a refused DataItem costs
|
||||
only its own slot (`BadCommunicationError`); a transport failure fails that request's slots. An Agent may apply
|
||||
some pairs of a multi-pair PUT before refusing it, so the per-slot retry can re-send a pair that already landed —
|
||||
harmless for value-setting (the Agent drops an identical repeat).
|
||||
|
||||
**Device resolution needs no extra config.** `TagBinding.WriteTargetsByDataItemId`,
|
||||
built at bind time from a walk of the cached probe model, remembers which device declared
|
||||
|
||||
@@ -25,7 +25,9 @@ out-of-process isolation.
|
||||
| Project | Target | Role |
|
||||
|---------|--------|------|
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/` | net10.0 | In-process driver — session lifetime, read / write / subscribe / alarm / history passthrough |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/` | net10.0 | `IDriverBrowser` — live address-picker browse used by the AdminUI |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/` | net10.0 | `IDriverBrowser` — live address-picker browse used by the AdminUI. Its side-panel `AttributesAsync` reads a variable's DataType / ValueRank / UserAccessLevel and maps them through the same `UpstreamAttributeMapping` discovery uses; it authenticates Anonymous, Username **or Certificate** through the driver's own `OpcUaUserIdentityFactory`, resolving `secret:` credentials through the host's secret store (fail-closed) — review 2026-10-21 O-5 (it was a stub, and Certificate was refused) |
|
||||
| | | ⚠️ Browse Certificate auth reads `UserCertificatePath` on the **AdminUI host's** filesystem, which may differ from the driver node's; the browse session disposes the certificate it loaded when it closes. |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/` | net10.0 | The SDK-typed layer the driver AND the browser share: `NamespaceMap`, `UpstreamAttributeMapping`, `OpcUaUserIdentityFactory` |
|
||||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/` | net10.0 | Config records + enums bound from `DriverConfig` JSON |
|
||||
|
||||
## Minimum deployment
|
||||
@@ -53,6 +55,8 @@ for every field (security policy/mode, auth type, session timeout, keep-alive,
|
||||
browse root, node/depth caps). `ReconnectPeriod` was **deleted** (#676 — unused since #670 removed the SDK reconnect
|
||||
handler, and the AdminUI no longer shows it); stored configs that still carry `reconnectPeriod` keep parsing in the
|
||||
factory, the Test-Connect probe and the browse picker (unknown keys are skipped), and the form drops it on the next save.
|
||||
`ProbeTimeoutSeconds` (default 15, AdminUI "Probe timeout") caps the Test-Connect probe's deadline — it can shorten
|
||||
the budget the host hands the probe, never extend it (review 2026-10-21 O-4; it used to be authored and consumed by nothing).
|
||||
|
||||
### Endpoint address: the configured host and port win (#676)
|
||||
|
||||
@@ -70,12 +74,40 @@ 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:
|
||||
|
||||
- **Good keep-alive / any data or event notification** advances `DriverHealth.LastSuccessfulRead` — a
|
||||
subscription-only driver never calls `ReadAsync`, so without this its read stamp froze at connect time.
|
||||
A good keep-alive with no outage latched also **clears a `Degraded` left by a failed read/write** (it used to move
|
||||
only the stamp, so a subscription-only driver stayed `Degraded` until its next re-initialise).
|
||||
- **Read / write / ack responses are replies.** Any service response — per-node refusals such as `BadNotWritable`
|
||||
included — restores `Healthy` and advances the stamp; only a call that got **no** response (transport failure,
|
||||
timeout — including an SDK request timeout surfacing as a cancellation the caller did not cause) writes `Degraded`
|
||||
with the stamp preserved; acks follow the same rule. **Deliberate deviation:** a call the *caller* cancelled touches
|
||||
no health — including a write the host cancelled at its own write deadline against a hung upstream; the session
|
||||
keep-alive owns liveness and reports that outage itself. A reply that was in flight across a bad keep-alive advances
|
||||
the stamp but does not write `Healthy` while the outage is published — the keep-alive recovery ends the outage.
|
||||
An ack none of whose condition ids resolves sends nothing and logs a Warning — it does **not** throw (a deterministic
|
||||
per-ack refusal must not count toward the host's AlarmAcknowledge circuit breaker, or five re-acks of one
|
||||
unresolvable condition would block every genuine ack). Only a call with no reply throws; with no session an ack
|
||||
fails `BadNotConnected` (before or inside the gate alike).
|
||||
- **Bad keep-alive** ⇒ health `Degraded` (`LastError = "keep-alive: <status>"`, read stamp preserved), host status
|
||||
`Stopped`, and — **once per outage**, not per keep-alive — every subscribed ref is published `BadNoCommunication`
|
||||
with its last value retained and **source + server timestamp = the detection time** (live-gate F1: stamped with the
|
||||
@@ -231,6 +263,12 @@ initialise (the AdminUI form drops them on the next save). Pinned by
|
||||
> forgotten when the subscribed set changes (the host rebuilt, recreating the nodes inactive). Index keys are server-stable (`nsu=`), so a namespace reorder across a re-initialise keeps
|
||||
> matching. docker-dev seeds an opc-plc driver (`MAIN-opcua`); the alarm tag is
|
||||
> created only when `@OpcPlcAlarmSourceNodeId` is set in `docker-dev/seed/seed-clusters.sql`.
|
||||
>
|
||||
> **An ack that never got a reply fails (review 2026-10-21 O-8).** A transport failure on the `Acknowledge` call
|
||||
> (closed channel, faulted session) — or a session lost while the ack queued — now throws with its status
|
||||
> (`ServiceResultException`, e.g. `BadConnectionClosed` / `BadNotConnected`), so the host logs the ack as failed
|
||||
> instead of applied. A *reply* still returns normally, including per-ack refusals such as
|
||||
> `BadConditionBranchAlreadyAcked` (logged per ack): a refusal is the server answering.
|
||||
|
||||
> This driver does **not** implement `IRediscoverable` — there is no
|
||||
> push-driven rediscovery signal from a remote OPC UA server in this driver.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
|
||||
/// <summary>
|
||||
/// DDL for the Part 9 scripted-alarm condition state: one row per alarm identity holding the
|
||||
/// operator-supplied state (Enabled / Acked / Confirmed / Shelving) plus the ack/confirm audit
|
||||
/// trail and comment history.
|
||||
/// DDL for the Part 9 scripted-alarm condition state: one fixed-size row per alarm identity
|
||||
/// holding the operator-supplied state (Enabled / Acked / Confirmed / Shelving) plus the
|
||||
/// ack/confirm audit fields, and a separate append-only table holding each alarm's comment trail.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -34,14 +36,58 @@ namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
/// the last transition. Timestamps are round-trip ("O") TEXT so lexicographic order is
|
||||
/// chronological; nullable timestamps and audit fields are stored as NULL.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The comment trail is NOT on the condition row.</b> It used to be serialized whole into
|
||||
/// <c>comments_json</c>, so every transition rewrote — and replicated — the alarm's entire
|
||||
/// history: O(n) per transition, O(n²) over the alarm's life (review R05:4). It now lives in
|
||||
/// <see cref="CommentsTable"/>, one row per entry keyed by <c>(scripted_alarm_id, seq)</c>,
|
||||
/// where <c>seq</c> is the entry's ordinal in the alarm's trail. A transition inserts only the
|
||||
/// entries it appended, so the condition row stays a fixed size and a transition's write is
|
||||
/// O(1) however long the history is. <c>comments_json</c> is kept, always written as
|
||||
/// <c>"[]"</c>, so the condition table's shape — and with it the pair's replication digest
|
||||
/// for that table — does not change and an old-shape row stays readable;
|
||||
/// <see cref="MigrateInlineComments"/> copies any inline trail across (the alarm's next
|
||||
/// save clears the column).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why the ordinal, and not a content hash, is the key.</b> Both halves of a redundant pair
|
||||
/// run the engine and both apply every operator command, each stamping the entry with its own
|
||||
/// clock. A content-derived id would keep TWO rows for every logical entry once the pair
|
||||
/// converged. Keyed by ordinal, the two nodes' copies of entry <i>n</i> share one primary key
|
||||
/// and LWW collapses them to one row — the convergence the whole-row LWW gave before, now per
|
||||
/// entry. Entries go in with <c>INSERT OR IGNORE</c> (<see cref="InsertCommentSql"/>), so an
|
||||
/// entry already held — written locally, or arrived from the peer — is never rewritten. A
|
||||
/// node that MISSED a peer command keeps its own colliding entry by appending it after the
|
||||
/// stored trail, with a Warning (see the store's divergence handling).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class AlarmConditionStateSchema
|
||||
{
|
||||
/// <summary>Table holding one condition-state row per scripted-alarm identity.</summary>
|
||||
public const string StateTable = "alarm_condition_state";
|
||||
|
||||
/// <summary>Append-only table holding one row per comment-trail entry, keyed by alarm id + ordinal.</summary>
|
||||
public const string CommentsTable = "alarm_condition_comment";
|
||||
|
||||
/// <summary>What <c>comments_json</c> holds once the trail lives in <see cref="CommentsTable"/>.</summary>
|
||||
public const string EmptyInlineComments = "[]";
|
||||
|
||||
/// <summary>
|
||||
/// Creates the state table if it does not already exist. Idempotent.
|
||||
/// The one append statement for <see cref="CommentsTable"/>, shared by the migration and the
|
||||
/// store. <c>OR IGNORE</c> is what makes the trail append-only: an ordinal already held is
|
||||
/// never rewritten. Parameters: <c>@Id @Seq @Ts @User @Kind @Text</c>.
|
||||
/// </summary>
|
||||
public const string InsertCommentSql =
|
||||
"""
|
||||
INSERT OR IGNORE INTO alarm_condition_comment
|
||||
(scripted_alarm_id, seq, timestamp_utc, user_name, kind, text)
|
||||
VALUES (@Id, @Seq, @Ts, @User, @Kind, @Text)
|
||||
""";
|
||||
|
||||
private static readonly JsonSerializerOptions InlineJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Creates the state table and the comment table if they do not already exist. Idempotent.
|
||||
/// </summary>
|
||||
/// <param name="connection">
|
||||
/// An already-open connection. <c>ILocalDb.CreateConnection()</c> hands out open,
|
||||
@@ -55,10 +101,11 @@ public static class AlarmConditionStateSchema
|
||||
|
||||
// The four *_state columns are always written to a mapped, non-null string (Enabled/Disabled,
|
||||
// Acknowledged/Unacknowledged, Confirmed/Unconfirmed, Unshelved/OneShotShelved/TimedShelved),
|
||||
// so they are NOT NULL. comments_json is never null either — an empty trail is the literal
|
||||
// "[]". The audit user/comment/utc columns and shelving_expires_utc are NULL when the alarm
|
||||
// has no such history yet. updated_at_utc carries LastTransitionUtc (there is no dedicated
|
||||
// transition column).
|
||||
// so they are NOT NULL. comments_json is never null either — it is the literal "[]" since the
|
||||
// trail moved to alarm_condition_comment (an old-shape row may still carry an inline trail
|
||||
// until the alarm's next save; MigrateInlineComments copies it without touching this row).
|
||||
// The audit user/comment/utc columns and shelving_expires_utc are NULL when the alarm has no such history yet. updated_at_utc
|
||||
// carries LastTransitionUtc (there is no dedicated transition column).
|
||||
cmd.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS alarm_condition_state (
|
||||
scripted_alarm_id TEXT NOT NULL PRIMARY KEY,
|
||||
@@ -76,7 +123,161 @@ public static class AlarmConditionStateSchema
|
||||
comments_json TEXT NOT NULL,
|
||||
updated_at_utc TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alarm_condition_comment (
|
||||
scripted_alarm_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
timestamp_utc TEXT NOT NULL,
|
||||
user_name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
PRIMARY KEY (scripted_alarm_id, seq)
|
||||
);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>What one <see cref="MigrateInlineComments"/> run did.</summary>
|
||||
/// <param name="RowsMoved">Condition rows for which at least one inline entry was copied across.</param>
|
||||
/// <param name="UnparseableAlarmIds">
|
||||
/// Alarms whose inline trail is not valid JSON and was left where it is — the caller logs these,
|
||||
/// so an unmigratable history is visible rather than skipped silently on every boot.
|
||||
/// </param>
|
||||
public sealed record InlineCommentMigrationResult(int RowsMoved, IReadOnlyList<string> UnparseableAlarmIds);
|
||||
|
||||
/// <summary>
|
||||
/// Copies every condition row's inline <c>comments_json</c> trail into
|
||||
/// <see cref="CommentsTable"/>. Idempotent; it never writes the condition row itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Run it AFTER the tables are registered for replication</b> — it writes rows, and a
|
||||
/// row written before the capture triggers exist never reaches the peer (the same
|
||||
/// DDL → register → writes rule <c>AlarmSfLegacyMigrator</c> follows).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Copy, don't clear.</b> Resetting <c>comments_json</c> here would be a full-row UPDATE
|
||||
/// that re-stamps the condition row with a fresh HLC — during a pair's rolling upgrade
|
||||
/// that stale-but-newly-stamped copy would beat a newer peer state (an ack made on the
|
||||
/// already-upgraded node) under LWW (review m1). The column is left as it is: the store's
|
||||
/// load merges an inline trail with the table by ordinal (the table wins), and the
|
||||
/// engine's next save of that alarm writes <c>"[]"</c> with the alarm's real state.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each entry lands at its ordinal in the inline array via <see cref="InsertCommentSql"/>,
|
||||
/// so a re-run — or a row whose trail was already (partly) copied — neither duplicates nor
|
||||
/// reorders anything and writes nothing for an ordinal already held. Each row copies in its
|
||||
/// own transaction, so a crash leaves it fully copied or untouched. A row whose inline JSON
|
||||
/// does not parse is left exactly as it is (the pre-split store could not load it either)
|
||||
/// and reported in <see cref="InlineCommentMigrationResult.UnparseableAlarmIds"/> rather
|
||||
/// than failing host start.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="connection">An already-open connection from <c>ILocalDb.CreateConnection()</c>.</param>
|
||||
/// <returns>What was copied, and which rows could not be read.</returns>
|
||||
public static InlineCommentMigrationResult MigrateInlineComments(SqliteConnection connection)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(connection);
|
||||
|
||||
var pending = new List<(string AlarmId, string Json)>();
|
||||
using (var select = connection.CreateCommand())
|
||||
{
|
||||
select.CommandText =
|
||||
"SELECT scripted_alarm_id, comments_json FROM alarm_condition_state WHERE comments_json <> @Empty";
|
||||
select.Parameters.AddWithValue("@Empty", EmptyInlineComments);
|
||||
using var reader = select.ExecuteReader();
|
||||
while (reader.Read())
|
||||
pending.Add((reader.GetString(0), reader.GetString(1)));
|
||||
}
|
||||
|
||||
var migrated = 0;
|
||||
var unparseable = new List<string>();
|
||||
foreach (var (alarmId, json) in pending)
|
||||
{
|
||||
IReadOnlyList<InlineComment> entries;
|
||||
try
|
||||
{
|
||||
entries = ParseInlineComments(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
unparseable.Add(alarmId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var inserted = 0;
|
||||
using var tx = connection.BeginTransaction();
|
||||
for (var seq = 0; seq < entries.Count; seq++)
|
||||
{
|
||||
using var insert = connection.CreateCommand();
|
||||
insert.Transaction = tx;
|
||||
insert.CommandText = InsertCommentSql;
|
||||
insert.Parameters.AddWithValue("@Id", alarmId);
|
||||
insert.Parameters.AddWithValue("@Seq", seq);
|
||||
insert.Parameters.AddWithValue("@Ts", FormatTimestamp(entries[seq].TimestampUtc));
|
||||
insert.Parameters.AddWithValue("@User", entries[seq].User);
|
||||
insert.Parameters.AddWithValue("@Kind", entries[seq].Kind);
|
||||
insert.Parameters.AddWithValue("@Text", entries[seq].Text);
|
||||
inserted += insert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
tx.Commit();
|
||||
if (inserted > 0)
|
||||
migrated++;
|
||||
}
|
||||
|
||||
return new InlineCommentMigrationResult(migrated, unparseable);
|
||||
}
|
||||
|
||||
/// <summary>Formats a comment timestamp for <see cref="CommentsTable"/> (round-trip "O" text).</summary>
|
||||
/// <param name="value">The timestamp.</param>
|
||||
/// <returns>The round-trip text form.</returns>
|
||||
public static string FormatTimestamp(DateTime value) => value.ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>Parses a <see cref="CommentsTable"/> timestamp, preserving its <see cref="DateTimeKind"/>.</summary>
|
||||
/// <param name="value">The round-trip text form.</param>
|
||||
/// <returns>The timestamp.</returns>
|
||||
public static DateTime ParseTimestamp(string value) =>
|
||||
DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
|
||||
|
||||
/// <summary>
|
||||
/// Parses a legacy inline <c>comments_json</c> trail exactly as the pre-split store did
|
||||
/// (<see cref="JsonSerializerDefaults.Web"/>; null strings become empty).
|
||||
/// </summary>
|
||||
/// <param name="json">The column value.</param>
|
||||
/// <returns>The entries in trail order; empty for a blank or <c>"[]"</c> column.</returns>
|
||||
/// <exception cref="JsonException">The column is not a valid trail.</exception>
|
||||
public static IReadOnlyList<InlineComment> ParseInlineComments(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return [];
|
||||
var dtos = JsonSerializer.Deserialize<List<InlineCommentDto>>(json, InlineJsonOptions);
|
||||
if (dtos is null || dtos.Count == 0) return [];
|
||||
return dtos
|
||||
.Select(d => new InlineComment(
|
||||
d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>One comment-trail entry as stored — the persistence-side mirror of the engine's <c>AlarmComment</c>.</summary>
|
||||
/// <param name="TimestampUtc">When the action happened.</param>
|
||||
/// <param name="User">Identity of the actor.</param>
|
||||
/// <param name="Kind">Classification (Acknowledge, Confirm, AddComment, …).</param>
|
||||
/// <param name="Text">Operator-supplied or engine-generated text.</param>
|
||||
public sealed record InlineComment(DateTime TimestampUtc, string User, string Kind, string Text);
|
||||
|
||||
/// <summary>The pre-split on-disk shape of one entry in <c>comments_json</c>.</summary>
|
||||
private sealed class InlineCommentDto
|
||||
{
|
||||
/// <summary>When the comment was recorded.</summary>
|
||||
public DateTime TimestampUtc { get; set; }
|
||||
|
||||
/// <summary>Identity of the actor.</summary>
|
||||
public string? User { get; set; }
|
||||
|
||||
/// <summary>Classification of the entry.</summary>
|
||||
public string? Kind { get; set; }
|
||||
|
||||
/// <summary>Entry text.</summary>
|
||||
public string? Text { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,13 @@ public sealed class MTConnectDriverOptions
|
||||
/// The <c>count</c> query parameter passed to the Agent's <c>/sample</c> request — the
|
||||
/// maximum number of DataItem observations returned per chunk. Default <c>1000</c>, the
|
||||
/// MTConnect Agent's own conventional default.
|
||||
/// <para>
|
||||
/// <b>It caps subscription throughput</b> at <c>SampleCount / SampleIntervalMs</c> observations per
|
||||
/// millisecond (default 1000 a second): a machine producing more falls behind the Agent's ring buffer. The
|
||||
/// pump measures that lag and, when it is persistently behind, reopens the stream with the count doubled — up
|
||||
/// to 16× this value, at most 65 536 — and reports the effective count and the lag on the <c>/hosts</c>
|
||||
/// diagnostics (review 2026-10-21 T-5). Each new stream starts again from this value.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public int SampleCount { get; init; } = 1000;
|
||||
|
||||
|
||||
@@ -26,27 +26,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// </param>
|
||||
/// <param name="MtCategory">
|
||||
/// The DataItem's MTConnect <c>category</c> attribute — <c>SAMPLE</c>, <c>EVENT</c>, or
|
||||
/// <c>CONDITION</c>. Optional metadata carried through for browse / display; not consulted by
|
||||
/// the value-mapping path. Default <c>null</c>.
|
||||
/// </param>
|
||||
/// <param name="MtType">
|
||||
/// The DataItem's MTConnect <c>type</c> attribute (e.g. <c>POSITION</c>, <c>EXECUTION</c>).
|
||||
/// Optional metadata. Default <c>null</c>.
|
||||
/// </param>
|
||||
/// <param name="MtSubType">
|
||||
/// The DataItem's MTConnect <c>subType</c> attribute (e.g. <c>ACTUAL</c>, <c>COMMANDED</c>).
|
||||
/// Optional metadata. Default <c>null</c>.
|
||||
/// </param>
|
||||
/// <param name="Units">
|
||||
/// The DataItem's MTConnect <c>units</c> attribute (e.g. <c>MILLIMETER</c>,
|
||||
/// <c>REVOLUTION/MINUTE</c>). Optional metadata. Default <c>null</c>.
|
||||
/// <c>CONDITION</c>. Read by the write path (a CONDITION is never writable). Default <c>null</c>.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// Review 2026-10-21 T-3: the <c>MtType</c> / <c>MtSubType</c> / <c>Units</c> members were carried here and
|
||||
/// read by nothing — deleted. A stored blob or <c>tags[]</c> entry that still carries <c>mtType</c> /
|
||||
/// <c>mtSubType</c> / <c>units</c> keeps loading (unknown JSON keys are skipped); the AdminUI tag editor reads
|
||||
/// those keys straight off the blob for its display and type-inference advisory, not through this record.
|
||||
/// </remarks>
|
||||
public sealed record MTConnectTagDefinition(
|
||||
string FullName,
|
||||
DriverDataType DriverDataType,
|
||||
bool IsArray = false,
|
||||
int ArrayDim = 0,
|
||||
string? MtCategory = null,
|
||||
string? MtType = null,
|
||||
string? MtSubType = null,
|
||||
string? Units = null);
|
||||
string? MtCategory = null);
|
||||
|
||||
+1
-10
@@ -87,10 +87,7 @@ public static class MTConnectTagDefinitionFactory
|
||||
parsed,
|
||||
blob.IsArray ?? false,
|
||||
blob.ArrayDim ?? 0,
|
||||
blob.MtCategory,
|
||||
blob.MtType,
|
||||
blob.MtSubType,
|
||||
blob.Units);
|
||||
blob.MtCategory);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -264,11 +261,5 @@ public static class MTConnectTagDefinitionFactory
|
||||
public int? ArrayDim { get; init; }
|
||||
/// <summary>The MTConnect DataItem category.</summary>
|
||||
public string? MtCategory { get; init; }
|
||||
/// <summary>The MTConnect DataItem type.</summary>
|
||||
public string? MtType { get; init; }
|
||||
/// <summary>The MTConnect DataItem sub-type.</summary>
|
||||
public string? MtSubType { get; init; }
|
||||
/// <summary>The engineering units, when authored.</summary>
|
||||
public string? Units { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ public sealed record MTConnectWriteOutcome(bool Accepted, string? AgentError);
|
||||
/// <see cref="CancellationTokenSource"/>. An <see cref="IAsyncDisposable"/> here would be a
|
||||
/// synchronous method wearing a <c>ValueTask</c>, and it would oblige every canned-XML fake
|
||||
/// to grow one too. The <b>async</b> unwind that a streaming client does need is already
|
||||
/// covered elsewhere and is not this method's job: the <see cref="SampleAsync"/> enumerator
|
||||
/// covered elsewhere and is not this method's job: the <see cref="SampleAsync(long, CancellationToken)"/> enumerator
|
||||
/// is itself <see cref="IAsyncDisposable"/> and is owned by the pump that enumerates it,
|
||||
/// while <see cref="IDisposable.Dispose"/> on the client converts any read still in flight
|
||||
/// into an ordinary <see cref="OperationCanceledException"/>.
|
||||
@@ -76,10 +76,26 @@ public interface IMTConnectAgentClient : IDisposable
|
||||
/// <returns>The parsed device hierarchy.</returns>
|
||||
Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// A connectivity check: does the Agent answer <c>/probe</c> with a success status? Reads the response
|
||||
/// headers only — the device model is neither downloaded in full nor parsed (R06:T-7: the connectivity
|
||||
/// probe used to parse the whole <c>/probe</c> document every tick and throw it away). Throws exactly when
|
||||
/// <see cref="ProbeAsync"/> would for an unreachable or refusing Agent. The default delegates to
|
||||
/// <see cref="ProbeAsync"/> and discards the model.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Trade-off (review m5): a success status from something that is NOT an Agent — a proxy serving an HTML page
|
||||
/// under 200 — now reads as reachable, where <see cref="ProbeAsync"/> would have failed to parse it. The data
|
||||
/// paths still report that misconfiguration (the read / stream fail to parse).
|
||||
/// </remarks>
|
||||
/// <param name="ct">Cancellation token (the caller's deadline).</param>
|
||||
/// <returns>A task completing when the Agent has answered with a success status.</returns>
|
||||
async Task PingAsync(CancellationToken ct) => _ = await ProbeAsync(ct).ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Issues an Agent <c>/current</c> request and returns the latest observed value for every
|
||||
/// data item. Used both to prime the observation index at startup and to re-baseline after
|
||||
/// a detected sequence gap in <see cref="SampleAsync"/>.
|
||||
/// a detected sequence gap in <see cref="SampleAsync(long, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The latest observation for every data item.</returns>
|
||||
@@ -113,6 +129,20 @@ public interface IMTConnectAgentClient : IDisposable
|
||||
/// <returns>An asynchronous sequence of streams results, one per multipart chunk.</returns>
|
||||
IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="SampleAsync(long, CancellationToken)"/>, but asking the Agent for up to
|
||||
/// <paramref name="count"/> observations per chunk instead of the configured
|
||||
/// <see cref="MTConnectDriverOptions.SampleCount"/> — how the pump catches up when it falls behind an Agent
|
||||
/// producing more than <c>SampleCount / SampleIntervalMs</c> observations a second (R06:T-5). The default
|
||||
/// ignores <paramref name="count"/>, so a client that cannot honour it keeps working at the configured one.
|
||||
/// </summary>
|
||||
/// <param name="from">The sequence to open the stream at.</param>
|
||||
/// <param name="count">The per-chunk observation cap for this stream.</param>
|
||||
/// <param name="ct">Cancellation token; the only contracted way the enumeration ends quietly.</param>
|
||||
/// <returns>The stream's chunks.</returns>
|
||||
IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, int count, CancellationToken ct) =>
|
||||
SampleAsync(from, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the Agent's observation for one DataItem, through the Agent's <c>AllowPut</c>
|
||||
/// surface (<c>POST {agent}/{deviceName}?{dataItemId}={value}</c>).
|
||||
@@ -174,6 +204,34 @@ public interface IMTConnectAgentClient : IDisposable
|
||||
Task<MTConnectWriteOutcome> WriteObservationAsync(
|
||||
string deviceName, string dataItemId, string value, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sets several observations on ONE device in one request — the Agent's PUT surface takes any number of
|
||||
/// <c>{dataItemId}={value}</c> query pairs (<c>POST {agent}/{device}?a=1&b=2</c>) — so a multi-write
|
||||
/// batch costs one round trip per device instead of one per slot (R06:T-6). Same answer contract as
|
||||
/// <see cref="WriteObservationAsync"/>: one outcome for the whole request, an Agent refusal is a value, an
|
||||
/// unreachable Agent throws. The ids must be distinct (a repeated query key's meaning is the Agent's choice).
|
||||
/// The default issues the single-observation call per pair and reports the first refusal.
|
||||
/// </summary>
|
||||
/// <param name="deviceName">The device that owns every DataItem in <paramref name="observations"/>.</param>
|
||||
/// <param name="observations">The <c>(dataItemId, value)</c> pairs, unescaped, ids distinct.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>What the Agent said about the request.</returns>
|
||||
async Task<MTConnectWriteOutcome> WriteObservationsAsync(
|
||||
string deviceName, IReadOnlyList<KeyValuePair<string, string>> observations, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observations);
|
||||
foreach (var (dataItemId, value) in observations)
|
||||
{
|
||||
var outcome = await WriteObservationAsync(deviceName, dataItemId, value, cancellationToken).ConfigureAwait(false);
|
||||
if (!outcome.Accepted)
|
||||
{
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
|
||||
return new MTConnectWriteOutcome(Accepted: true, AgentError: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Has the Agent's ring buffer overflowed past the sequence the caller expected next? True
|
||||
/// when the chunk's oldest retained sequence is <i>strictly</i> newer than
|
||||
@@ -183,7 +241,7 @@ public interface IMTConnectAgentClient : IDisposable
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b><paramref name="expectedFrom"/> is a running cursor, not the original
|
||||
/// <c>from</c>.</b> It equals the <c>from</c> passed to <see cref="SampleAsync"/> only
|
||||
/// <c>from</c>.</b> It equals the <c>from</c> passed to <see cref="SampleAsync(long, CancellationToken)"/> only
|
||||
/// for the <i>first</i> chunk; from the second chunk on it is the <b>previous</b>
|
||||
/// chunk's <see cref="MTConnectStreamsResult.NextSequence"/>. Comparing every chunk
|
||||
/// against the original <c>from</c> instead reports a gap on every chunk once the
|
||||
@@ -199,7 +257,7 @@ public interface IMTConnectAgentClient : IDisposable
|
||||
/// a chunk the Agent was willing to send — and cppagent answers a <c>from</c> that has
|
||||
/// already fallen out of its buffer with an <c>MTConnectError</c> / <c>OUT_OF_RANGE</c>
|
||||
/// document served under <b>HTTP 200</b>, which this client surfaces as an
|
||||
/// <see cref="InvalidDataException"/> out of <see cref="SampleAsync"/>, never as a chunk.
|
||||
/// <see cref="InvalidDataException"/> out of <see cref="SampleAsync(long, CancellationToken)"/>, never as a chunk.
|
||||
/// A pump that re-baselines only on <c>IsSequenceGap</c> will therefore fault instead of
|
||||
/// recovering exactly when it has fallen furthest behind. Treat an <c>OUT_OF_RANGE</c>
|
||||
/// parse failure as a re-baseline trigger (re-prime from <see cref="CurrentAsync"/>,
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// <b>The constructor opens no connection.</b> It only composes the request URIs and builds
|
||||
/// its <see cref="HttpClient"/>s (which themselves dial nothing until a request is issued),
|
||||
/// so a throwaway instance is safe to construct — the universal discovery browser's
|
||||
/// <c>CanBrowse</c> pattern depends on that. <see cref="SampleAsync"/> likewise dials
|
||||
/// <c>CanBrowse</c> pattern depends on that. <see cref="SampleAsync(long, CancellationToken)"/> likewise dials
|
||||
/// nothing until it is enumerated.
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -54,7 +54,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Disposal is safe against an in-flight enumeration.</b> Re-initialization disposes and rebuilds this client, which can happen while a pump is still
|
||||
/// enumerating <see cref="SampleAsync"/>. Rather than letting the in-flight read fault with
|
||||
/// enumerating <see cref="SampleAsync(long, CancellationToken)"/>. Rather than letting the in-flight read fault with
|
||||
/// an unclassified <see cref="ObjectDisposedException"/> or <see cref="IOException"/> that no
|
||||
/// caller is written to expect, every request runs under a token linked to an internal
|
||||
/// dispose token, so disposal surfaces as an ordinary
|
||||
@@ -79,6 +79,8 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
private readonly string _agentRoot;
|
||||
private readonly string _sampleUriBase;
|
||||
private readonly string _sampleQuerySuffix;
|
||||
private readonly int _sampleCount;
|
||||
private readonly int _heartbeatMs;
|
||||
private readonly TimeSpan _requestTimeout;
|
||||
private readonly TimeSpan _streamIdleTimeout;
|
||||
|
||||
@@ -151,9 +153,10 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
_probeUri = BuildRequestUri(options, "probe");
|
||||
_currentUri = BuildRequestUri(options, "current");
|
||||
_sampleUriBase = BuildRequestUri(options, "sample").AbsoluteUri;
|
||||
_sampleQuerySuffix = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"&interval={options.SampleIntervalMs}&count={options.SampleCount}&heartbeat={options.HeartbeatMs}");
|
||||
// count is appended per request: the driver may raise it for one stream when it falls behind (R06:T-5).
|
||||
_sampleQuerySuffix = string.Create(CultureInfo.InvariantCulture, $"&interval={options.SampleIntervalMs}");
|
||||
_sampleCount = options.SampleCount;
|
||||
_heartbeatMs = options.HeartbeatMs;
|
||||
|
||||
_http = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
|
||||
_http.Timeout = _requestTimeout;
|
||||
@@ -179,6 +182,35 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
return MTConnectProbeParser.Parse(body);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task PingAsync(CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||
|
||||
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
|
||||
deadline.CancelAfter(_requestTimeout);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, _probeUri);
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
// ResponseHeadersRead: the status line is the whole answer this needs. The body (the device model —
|
||||
// the largest document the Agent serves) is never buffered or parsed; disposing the response drops it.
|
||||
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
|
||||
{
|
||||
throw TimedOut(_probeUri);
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
|
||||
{
|
||||
@@ -195,10 +227,32 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
public async IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(
|
||||
long from,
|
||||
[EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
await foreach (var chunk in SampleAsync(from, count: null, ct).ConfigureAwait(false))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, int count, CancellationToken ct)
|
||||
{
|
||||
if (count <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(count), count, "a /sample count must be positive");
|
||||
}
|
||||
|
||||
return SampleAsync(from, (int?)count, ct);
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(
|
||||
long from,
|
||||
int? count,
|
||||
[EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||
|
||||
var uri = BuildSampleUri(from);
|
||||
var uri = BuildSampleUri(from, count);
|
||||
|
||||
// Linked to the caller's token AND to disposal, so tearing this client down mid-enumeration
|
||||
// surfaces as a cancellation rather than as a raw ObjectDisposedException from the socket.
|
||||
@@ -226,7 +280,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
_ = MTConnectStreamsParser.Parse(single);
|
||||
|
||||
throw new MTConnectStreamNotSupportedException(
|
||||
$"MTConnect Agent answered the /sample request '{uri}' with a single '{response.Content.Headers.ContentType?.MediaType ?? "unknown"}' document instead of a multipart/x-mixed-replace stream. It parsed as a valid MTConnectStreams document but will never stream, so no subscription is possible; check that the URI addresses an MTConnect Agent directly and is not fronted by a proxy that buffers streaming responses.");
|
||||
$"MTConnect Agent answered the /sample request '{MTConnectUriRedaction.Redact(uri)}' with a single '{response.Content.Headers.ContentType?.MediaType ?? "unknown"}' document instead of a multipart/x-mixed-replace stream. It parsed as a valid MTConnectStreams document but will never stream, so no subscription is possible; check that the URI addresses an MTConnect Agent directly and is not fronted by a proxy that buffers streaming responses.");
|
||||
}
|
||||
|
||||
var reader = new MultipartStreamReader(body, boundary, _streamIdleTimeout, uri, _logger);
|
||||
@@ -239,7 +293,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
// The caller cancelling is the ONE contracted way out; anything else is reported.
|
||||
live.ThrowIfCancellationRequested();
|
||||
|
||||
throw new MTConnectStreamEndedException(reader.EndReason, uri.AbsoluteUri, delivered);
|
||||
throw new MTConnectStreamEndedException(reader.EndReason, MTConnectUriRedaction.Redact(uri), delivered);
|
||||
}
|
||||
|
||||
// A frame carrying nothing but whitespace is transport filler, not a document. The
|
||||
@@ -258,8 +312,18 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<MTConnectWriteOutcome> WriteObservationAsync(
|
||||
public Task<MTConnectWriteOutcome> WriteObservationAsync(
|
||||
string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
return WriteObservationsAsync(deviceName, [new KeyValuePair<string, string>(dataItemId, value)], cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<MTConnectWriteOutcome> WriteObservationsAsync(
|
||||
string deviceName, IReadOnlyList<KeyValuePair<string, string>> observations, CancellationToken cancellationToken)
|
||||
{
|
||||
// Issued on _http, the unary client — the write is a short request/response
|
||||
// and must be bounded by RequestTimeoutMs like the other unary legs. Sending it on
|
||||
@@ -268,10 +332,19 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
// watchdog protects a multipart body, not a one-shot POST).
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
ArgumentNullException.ThrowIfNull(observations);
|
||||
if (observations.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("at least one observation is required", nameof(observations));
|
||||
}
|
||||
|
||||
var uri = BuildWriteUri(deviceName, dataItemId, value);
|
||||
foreach (var (dataItemId, value) in observations)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId, nameof(observations));
|
||||
ArgumentNullException.ThrowIfNull(value, nameof(observations));
|
||||
}
|
||||
|
||||
var uri = BuildWriteUri(deviceName, observations);
|
||||
|
||||
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token);
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
|
||||
@@ -326,10 +399,11 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
/// as a query pair. Every caller-supplied part is percent-encoded here — see the seam's
|
||||
/// contract for why escaping cannot be the caller's job.
|
||||
/// </summary>
|
||||
private Uri BuildWriteUri(string deviceName, string dataItemId, string value)
|
||||
private Uri BuildWriteUri(string deviceName, IReadOnlyList<KeyValuePair<string, string>> observations)
|
||||
{
|
||||
var device = Uri.EscapeDataString(deviceName.Trim());
|
||||
var query = $"{Uri.EscapeDataString(dataItemId)}={Uri.EscapeDataString(value)}";
|
||||
var query = string.Join(
|
||||
"&", observations.Select(o => $"{Uri.EscapeDataString(o.Key)}={Uri.EscapeDataString(o.Value)}"));
|
||||
|
||||
return new Uri($"{_agentRoot}/{device}?{query}", UriKind.Absolute);
|
||||
}
|
||||
@@ -411,8 +485,10 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private Uri BuildSampleUri(long from) =>
|
||||
new(string.Create(CultureInfo.InvariantCulture, $"{_sampleUriBase}?from={from}{_sampleQuerySuffix}"),
|
||||
private Uri BuildSampleUri(long from, int? count) =>
|
||||
new(string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{_sampleUriBase}?from={from}{_sampleQuerySuffix}&count={count ?? _sampleCount}&heartbeat={_heartbeatMs}"),
|
||||
UriKind.Absolute);
|
||||
|
||||
private async Task<HttpResponseMessage> SendAsync(Uri uri, CancellationToken ct)
|
||||
@@ -484,7 +560,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
}
|
||||
|
||||
private TimeoutException TimedOut(Uri uri) =>
|
||||
new($"MTConnect Agent request to '{uri}' did not complete within {_requestTimeout.TotalMilliseconds:F0} ms.");
|
||||
new($"MTConnect Agent request to '{MTConnectUriRedaction.Redact(uri)}' did not complete within {_requestTimeout.TotalMilliseconds:F0} ms.");
|
||||
|
||||
/// <summary>
|
||||
/// The <c>boundary</c> parameter of a <c>multipart/*</c> response, or <c>null</c> when the
|
||||
@@ -513,7 +589,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
if (string.IsNullOrEmpty(boundary))
|
||||
{
|
||||
throw new MTConnectStreamNotSupportedException(
|
||||
$"MTConnect Agent answered the /sample request '{uri}' with Content-Type '{contentType.MediaType}' but no 'boundary' parameter, so the multipart stream cannot be framed.");
|
||||
$"MTConnect Agent answered the /sample request '{MTConnectUriRedaction.Redact(uri)}' with Content-Type '{contentType.MediaType}' but no 'boundary' parameter, so the multipart stream cannot be framed.");
|
||||
}
|
||||
|
||||
return boundary;
|
||||
@@ -556,7 +632,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
(parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"{nameof(MTConnectDriverOptions.AgentUri)} '{agentUri}' is not an absolute http(s) URI.", nameof(options));
|
||||
$"{nameof(MTConnectDriverOptions.AgentUri)} '{MTConnectUriRedaction.Redact(agentUri)}' is not an absolute http(s) URI.", nameof(options));
|
||||
}
|
||||
|
||||
return parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
||||
@@ -692,7 +768,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
|
||||
logger?.LogWarning(
|
||||
"MTConnect /sample stream from '{AgentUri}' sends parts with no Content-length header. Falling back to boundary-scan framing, which cannot emit a chunk until the NEXT chunk begins, so every observation is delayed by up to one Agent heartbeat. Values will read stale; this is the Agent's framing, not a driver fault.",
|
||||
uri);
|
||||
MTConnectUriRedaction.Redact(uri));
|
||||
}
|
||||
|
||||
private byte[]? Finish(MTConnectStreamEndReason reason)
|
||||
@@ -861,7 +937,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
if (_buffer.Length >= MaxBufferBytes)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"MTConnect /sample stream from '{uri}' sent a part larger than {MaxBufferBytes} bytes without a boundary; refusing to buffer further.");
|
||||
$"MTConnect /sample stream from '{MTConnectUriRedaction.Redact(uri)}' sent a part larger than {MaxBufferBytes} bytes without a boundary; refusing to buffer further.");
|
||||
}
|
||||
|
||||
Array.Resize(ref _buffer, Math.Min(_buffer.Length * 2, MaxBufferBytes));
|
||||
@@ -878,7 +954,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
$"MTConnect /sample stream from '{uri}' sent no data (not even a heartbeat) within {idleTimeout.TotalMilliseconds:F0} ms; treating the Agent as unreachable rather than waiting indefinitely.");
|
||||
$"MTConnect /sample stream from '{MTConnectUriRedaction.Redact(uri)}' sent no data (not even a heartbeat) within {idleTimeout.TotalMilliseconds:F0} ms; treating the Agent as unreachable rather than waiting indefinitely.");
|
||||
}
|
||||
|
||||
if (read <= 0)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:T-5 — <c>/sample</c> throughput is capped at
|
||||
/// <see cref="MTConnectDriverOptions.SampleCount"/> observations per chunk, one chunk per
|
||||
/// <see cref="MTConnectDriverOptions.SampleIntervalMs"/>. A machine producing more than that falls steadily behind
|
||||
/// the Agent's ring buffer: every buffer-length the driver re-baselines (losing every intermediate transition) and
|
||||
/// every delivered value is backlog-stale. This half measures the lag, surfaces it, and raises the per-stream
|
||||
/// <c>count</c> so the pump can catch up.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Measured per chunk.</b> <c>lag = lastSequence + 1 − nextSequence</c> — the observations the Agent holds
|
||||
/// that this stream has not been sent yet (unknown when the Agent omits <c>lastSequence</c>). A chunk is
|
||||
/// <b>saturated</b> when it carries the full <c>count</c>: the Agent had more and stopped. A stream is behind
|
||||
/// when <see cref="BehindChunksBeforeRaise"/> consecutive chunks are saturated with a positive (or unknown) lag.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Adapted per pump.</b> A behind stream is reopened at the cursor it reached with <c>count</c> doubled, up
|
||||
/// to <see cref="SampleCountCeiling"/>. A raised stream that then delivers nothing (an Agent that refuses the
|
||||
/// larger count) steps back to the last count that delivered and stops raising for that pump. At the ceiling,
|
||||
/// a stream still behind logs one Warning per pump. A new pump (a re-initialize, a resubscribe) starts again
|
||||
/// from the configured <see cref="MTConnectDriverOptions.SampleCount"/> — the count is an upper bound, so a
|
||||
/// raised one costs nothing while the Agent has little to send.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Surfaced</b> on the <see cref="IDriverDiagnostics"/> bag the <c>/hosts</c> page shows: the effective
|
||||
/// count, the last measured lag, saturated chunks and count raises.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed partial class MTConnectDriver : IDriverDiagnostics
|
||||
{
|
||||
/// <summary>Consecutive behind chunks before the count is raised — one saturated chunk is just a burst.</summary>
|
||||
internal const int BehindChunksBeforeRaise = 3;
|
||||
|
||||
/// <summary>The most the per-stream count is ever raised to, relative to the configured one.</summary>
|
||||
internal const int SampleCountMaxGrowth = 16;
|
||||
|
||||
/// <summary>Absolute ceiling on a raised count (a stock cppagent buffer holds 131072 observations).</summary>
|
||||
internal const int SampleCountAbsoluteCeiling = 65_536;
|
||||
|
||||
/// <summary>The count the current pump is streaming with. Diagnostics only.</summary>
|
||||
private int _effectiveSampleCount;
|
||||
|
||||
/// <summary>The last measured lag (observations), or -1 when unknown. Diagnostics only.</summary>
|
||||
private long _sampleLag = -1;
|
||||
|
||||
/// <summary>Chunks that carried the full count. Diagnostics only.</summary>
|
||||
private long _saturatedChunks;
|
||||
|
||||
/// <summary>Times a pump raised its count. Diagnostics only.</summary>
|
||||
private long _sampleCountRaises;
|
||||
|
||||
/// <summary>The ceiling a pump started with <paramref name="configured"/> may raise its count to.</summary>
|
||||
/// <param name="configured">The configured <see cref="MTConnectDriverOptions.SampleCount"/>.</param>
|
||||
/// <returns>The ceiling — never below <paramref name="configured"/>.</returns>
|
||||
internal static int SampleCountCeiling(int configured) =>
|
||||
Math.Max(configured, (int)Math.Min((long)configured * SampleCountMaxGrowth, SampleCountAbsoluteCeiling));
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<string, string> GetDiagnostics()
|
||||
{
|
||||
var bag = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var count = Volatile.Read(ref _effectiveSampleCount);
|
||||
if (count > 0)
|
||||
{
|
||||
bag["sampleCount"] = count.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
var lag = Interlocked.Read(ref _sampleLag);
|
||||
if (lag >= 0)
|
||||
{
|
||||
bag["sampleLag"] = lag.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
bag["sampleSaturatedChunks"] = Interlocked.Read(ref _saturatedChunks).ToString(CultureInfo.InvariantCulture);
|
||||
bag["sampleCountRaises"] = Interlocked.Read(ref _sampleCountRaises).ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
return bag;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Measures one delivered chunk against the count its stream was opened with and records the result.
|
||||
/// </summary>
|
||||
/// <param name="chunk">The chunk just applied.</param>
|
||||
/// <param name="sampleCount">The count the stream was opened with.</param>
|
||||
/// <returns>Whether this chunk says the stream is behind the Agent.</returns>
|
||||
private bool MeasureSampleLag(MTConnectStreamsResult chunk, int sampleCount)
|
||||
{
|
||||
long? lag = chunk.LastSequence is { } last ? Math.Max(0, last + 1 - chunk.NextSequence) : null;
|
||||
Interlocked.Exchange(ref _sampleLag, lag ?? -1);
|
||||
|
||||
var saturated = chunk.Observations.Count >= sampleCount;
|
||||
if (saturated)
|
||||
{
|
||||
Interlocked.Increment(ref _saturatedChunks);
|
||||
}
|
||||
|
||||
return saturated && lag is not 0;
|
||||
}
|
||||
|
||||
/// <summary>One pump's adaptive-count state. Only that pump's task touches it.</summary>
|
||||
private sealed class SampleCountState(int configured)
|
||||
{
|
||||
/// <summary>The count the next stream opens with.</summary>
|
||||
public int Count { get; set; } = configured;
|
||||
|
||||
/// <summary>The last count a stream actually delivered at.</summary>
|
||||
public int LastDelivered { get; set; } = configured;
|
||||
|
||||
/// <summary>The most <see cref="Count"/> may be raised to; lowered to the last good count on a refusal.</summary>
|
||||
public int Ceiling { get; set; } = SampleCountCeiling(configured);
|
||||
|
||||
/// <summary>Whether the at-the-ceiling Warning has been logged for this pump.</summary>
|
||||
public bool CeilingWarned { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The count a behind stream should reopen with, or <see langword="null"/> when it is already at its ceiling
|
||||
/// (logged once per pump).
|
||||
/// </summary>
|
||||
private int? NextSampleCount(SampleCountState state, MTConnectDriverOptions options, long cursor)
|
||||
{
|
||||
var sampleCount = state.Count;
|
||||
var next = (int)Math.Min((long)sampleCount * 2, state.Ceiling);
|
||||
if (next > sampleCount)
|
||||
{
|
||||
Interlocked.Increment(ref _sampleCountRaises);
|
||||
_logger.LogInformation(
|
||||
"MTConnect driver {DriverInstanceId} is falling behind {AgentUri}'s /sample stream (chunks full at count {Count}, lag {Lag} observation(s)); reopening at sequence {Cursor} with count {NextCount}.",
|
||||
_driverInstanceId, AgentDisplay(options), sampleCount, DescribeLag(), cursor, next);
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
if (!state.CeilingWarned)
|
||||
{
|
||||
state.CeilingWarned = true;
|
||||
_logger.LogWarning(
|
||||
"MTConnect driver {DriverInstanceId} is still behind {AgentUri}'s /sample stream at its count ceiling {Count} (lag {Lag} observation(s)): the machine produces more than count / SampleIntervalMs observations a second, so values are backlog-stale and the stream will re-baseline (dropping intermediate transitions) whenever it falls out of the Agent's buffer. Lower SampleIntervalMs or raise SampleCount.",
|
||||
_driverInstanceId, AgentDisplay(options), sampleCount, DescribeLag());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private string DescribeLag()
|
||||
{
|
||||
var lag = Interlocked.Read(ref _sampleLag);
|
||||
return lag >= 0 ? lag.ToString(CultureInfo.InvariantCulture) : "unknown";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -56,7 +56,7 @@ public sealed record MTConnectComponent(
|
||||
/// <summary>
|
||||
/// One MTConnect DataItem declaration from the Agent's probe response. This is pure metadata —
|
||||
/// it carries no observed value; values arrive separately via
|
||||
/// <see cref="IMTConnectAgentClient.CurrentAsync"/> / <see cref="IMTConnectAgentClient.SampleAsync"/>
|
||||
/// <see cref="IMTConnectAgentClient.CurrentAsync"/> / <see cref="IMTConnectAgentClient.SampleAsync(long, CancellationToken)"/>
|
||||
/// and are correlated back to a data item by <see cref="Id"/>.
|
||||
/// </summary>
|
||||
/// <param name="Id">
|
||||
@@ -89,7 +89,7 @@ public sealed record MTConnectDataItem(
|
||||
/// <summary>
|
||||
/// Result of an Agent <c>/current</c> request, or of a single multipart chunk from a
|
||||
/// <c>/sample</c> stream. Produced by <see cref="IMTConnectAgentClient.CurrentAsync"/> and
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync"/>.
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync(long, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <param name="InstanceId">
|
||||
/// The Agent's current instance id (its MTConnectStreams header <c>instanceId</c>). Changes
|
||||
@@ -98,7 +98,7 @@ public sealed record MTConnectDataItem(
|
||||
/// </param>
|
||||
/// <param name="NextSequence">
|
||||
/// The header <c>nextSequence</c> — the sequence number to resume a subsequent
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync"/> call <c>from</c>.
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync(long, CancellationToken)"/> call <c>from</c>.
|
||||
/// </param>
|
||||
/// <param name="FirstSequence">
|
||||
/// The header <c>firstSequence</c> — the oldest sequence number still held in the Agent's
|
||||
@@ -110,11 +110,17 @@ public sealed record MTConnectDataItem(
|
||||
/// The observations carried in this snapshot/chunk, in Agent-supplied order. Never <c>null</c>;
|
||||
/// empty when the chunk carries no new observations (e.g. a heartbeat).
|
||||
/// </param>
|
||||
/// <param name="LastSequence">
|
||||
/// The header <c>lastSequence</c> — the newest sequence number the Agent holds, or <c>null</c> when the
|
||||
/// document omits it. With <see cref="NextSequence"/> it measures how far a <c>/sample</c> consumer is behind
|
||||
/// the Agent: <c>LastSequence + 1 - NextSequence</c> observations it has not been sent yet (R06:T-5).
|
||||
/// </param>
|
||||
public sealed record MTConnectStreamsResult(
|
||||
long InstanceId,
|
||||
long NextSequence,
|
||||
long FirstSequence,
|
||||
IReadOnlyList<MTConnectObservation> Observations);
|
||||
IReadOnlyList<MTConnectObservation> Observations,
|
||||
long? LastSequence = null);
|
||||
|
||||
/// <summary>
|
||||
/// One observed value for a single DataItem, as reported in a <c>/current</c> snapshot or a
|
||||
@@ -162,11 +168,6 @@ public sealed record MTConnectStreamsResult(
|
||||
/// The observation's own <c>sampleCount</c> attribute — the number of values a <c>TIME_SERIES</c>
|
||||
/// vector claims to carry. The index checks the parsed vector against it. <c>null</c> when absent.
|
||||
/// </param>
|
||||
/// <param name="SampleRate">
|
||||
/// The observation's own <c>sampleRate</c> attribute (samples per second) on a <c>TIME_SERIES</c>
|
||||
/// observation. Carried for the parser contract; not yet surfaced on the served node (see
|
||||
/// docs/drivers/MTConnect.md). <c>null</c> when absent.
|
||||
/// </param>
|
||||
/// <param name="Entries">
|
||||
/// The parsed <c><Entry key="…"></c> children of a <c>DATA_SET</c> / <c>TABLE</c> observation, in
|
||||
/// document order, or <c>null</c> when the observation had none. A <c>TABLE</c> entry carries
|
||||
@@ -195,7 +196,6 @@ public sealed record MTConnectObservation(
|
||||
DateTime TimestampUtc,
|
||||
bool IsStructured = false,
|
||||
int? SampleCount = null,
|
||||
double? SampleRate = null,
|
||||
MTConnectConditionDetail? Condition = null,
|
||||
IReadOnlyList<MTConnectDataSetEntry>? Entries = null,
|
||||
bool ResetTriggered = false,
|
||||
|
||||
@@ -151,7 +151,34 @@ internal sealed class MTConnectObservationIndex
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_tags = FreezeTags(tags);
|
||||
}
|
||||
|
||||
/// <summary>Builds an index over an already-frozen tag map (see <see cref="FreezeTags"/>).</summary>
|
||||
private MTConnectObservationIndex(FrozenDictionary<string, MTConnectTagDefinition> frozenTags, TimeProvider? timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_tags = frozenTags;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R06:T-4 — an index over a tag map frozen ONCE (per binding) rather than per construction: the
|
||||
/// <c>/current</c> read fallback builds a fresh index per batch, and re-freezing a 2–5k-entry tag table on
|
||||
/// every read was a measurable share of that batch.
|
||||
/// </summary>
|
||||
/// <param name="frozenTags">A map produced by <see cref="FreezeTags"/>.</param>
|
||||
/// <returns>A fresh, empty index coercing against <paramref name="frozenTags"/>.</returns>
|
||||
internal static MTConnectObservationIndex OverFrozenTags(FrozenDictionary<string, MTConnectTagDefinition> frozenTags) =>
|
||||
new(frozenTags, null);
|
||||
|
||||
/// <summary>
|
||||
/// The authored tags keyed by <see cref="MTConnectTagDefinition.FullName"/>, last definition winning on a
|
||||
/// duplicate (see the constructor), blank / null entries skipped.
|
||||
/// </summary>
|
||||
/// <param name="tags">The authored tags.</param>
|
||||
/// <returns>The frozen map an index coerces against.</returns>
|
||||
internal static FrozenDictionary<string, MTConnectTagDefinition> FreezeTags(IEnumerable<MTConnectTagDefinition>? tags)
|
||||
{
|
||||
var map = new Dictionary<string, MTConnectTagDefinition>(StringComparer.Ordinal);
|
||||
foreach (var tag in tags ?? [])
|
||||
{
|
||||
@@ -160,7 +187,7 @@ internal sealed class MTConnectObservationIndex
|
||||
map[tag.FullName] = tag;
|
||||
}
|
||||
|
||||
_tags = map.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
return map.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>The number of distinct dataItemIds currently indexed.</summary>
|
||||
|
||||
@@ -118,7 +118,7 @@ internal static class MTConnectProbeParser
|
||||
|
||||
if (!MTConnectXml.IsNamed(root, "MTConnectDevices"))
|
||||
{
|
||||
throw new InvalidDataException(MTConnectXml.DescribeUnexpectedRoot(root, "MTConnectDevices", Subject));
|
||||
throw MTConnectXml.UnexpectedRoot(root, "MTConnectDevices", Subject);
|
||||
}
|
||||
|
||||
var devicesContainer = MTConnectXml.ChildrenNamed(root, "Devices").FirstOrDefault()
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why these are exceptions rather than a normal end of enumeration.</b>
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync"/> is contracted to yield indefinitely until
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync(long, CancellationToken)"/> is contracted to yield indefinitely until
|
||||
/// its token is cancelled, so a pump written to that contract has no reason to handle normal
|
||||
/// completion — it would either drop the subscription silently or hot-loop reconnecting on
|
||||
/// an enumerator that ends immediately. Returning normally would make "the data path
|
||||
@@ -159,3 +159,4 @@ public sealed class MTConnectStreamNotSupportedException : MTConnectStreamExcept
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -295,8 +295,7 @@ internal static class MTConnectStreamsParser
|
||||
// exactly this shape (Agents serve one under HTTP 200), and its <Error> elements — the
|
||||
// Agent's own account of what it objected to — are the most useful thing in the whole
|
||||
// response. Materialising the root is how the shared extraction can reach them.
|
||||
throw new InvalidDataException(
|
||||
MTConnectXml.DescribeUnexpectedRoot((XElement)XNode.ReadFrom(reader), "MTConnectStreams", Subject));
|
||||
throw MTConnectXml.UnexpectedRoot((XElement)XNode.ReadFrom(reader), "MTConnectStreams", Subject);
|
||||
}
|
||||
|
||||
var observations = new List<MTConnectObservation>();
|
||||
@@ -330,7 +329,8 @@ internal static class MTConnectStreamsParser
|
||||
sequences.InstanceId,
|
||||
sequences.NextSequence,
|
||||
sequences.FirstSequence,
|
||||
observations);
|
||||
observations,
|
||||
sequences.LastSequence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -342,7 +342,14 @@ internal static class MTConnectStreamsParser
|
||||
new(
|
||||
MTConnectXml.RequiredLongAttribute(reader, "instanceId", HeaderOwner, Subject),
|
||||
MTConnectXml.RequiredLongAttribute(reader, "nextSequence", HeaderOwner, Subject),
|
||||
MTConnectXml.RequiredLongAttribute(reader, "firstSequence", HeaderOwner, Subject));
|
||||
MTConnectXml.RequiredLongAttribute(reader, "firstSequence", HeaderOwner, Subject),
|
||||
// Optional on purpose: only the /sample lag measurement reads it, and a document without it must
|
||||
// still parse (the pump then falls back to chunk saturation alone — R06:T-5).
|
||||
long.TryParse(
|
||||
MTConnectXml.OptionalAttribute(reader, "lastSequence"),
|
||||
NumberStyles.Integer,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var last) ? last : null);
|
||||
|
||||
/// <summary>
|
||||
/// Consumes the element the reader is positioned on, collecting any observation it is and
|
||||
@@ -383,7 +390,6 @@ internal static class MTConnectStreamsParser
|
||||
var timestampUtc = default(DateTime);
|
||||
string? conditionState = null;
|
||||
int? sampleCount = null;
|
||||
double? sampleRate = null;
|
||||
var resetTriggered = false;
|
||||
var hasCount = false;
|
||||
List<MTConnectDataSetEntry>? entries = null;
|
||||
@@ -421,11 +427,6 @@ internal static class MTConnectStreamsParser
|
||||
NumberStyles.Integer,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var count) && count >= 0 ? count : null;
|
||||
sampleRate = double.TryParse(
|
||||
MTConnectXml.OptionalAttribute(reader, "sampleRate"),
|
||||
NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var rate) && double.IsFinite(rate) ? rate : null;
|
||||
}
|
||||
|
||||
// The slot is reserved BEFORE the subtree is read, and filled after: an observation is
|
||||
@@ -485,7 +486,6 @@ internal static class MTConnectStreamsParser
|
||||
// away a perfectly well-determined state.
|
||||
IsStructured: !isCondition && hasElements,
|
||||
SampleCount: sampleCount,
|
||||
SampleRate: sampleRate,
|
||||
Condition: condition,
|
||||
Entries: entries is { Count: > 0 } ? entries : null,
|
||||
|
||||
@@ -660,7 +660,7 @@ internal static class MTConnectStreamsParser
|
||||
}
|
||||
|
||||
/// <summary>The three sequence numbers read off a <c><Header></c>.</summary>
|
||||
private readonly record struct HeaderSequences(long InstanceId, long NextSequence, long FirstSequence);
|
||||
private readonly record struct HeaderSequences(long InstanceId, long NextSequence, long FirstSequence, long? LastSequence);
|
||||
|
||||
/// <summary>
|
||||
/// Accumulates one observation's text across however many nodes the parser hands it, without
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:T-8 — the display form of an Agent URI for every log line, exception message,
|
||||
/// <c>DriverHealth.LastError</c> and <c>HostName</c> this driver produces. An <c>AgentUri</c> may carry
|
||||
/// <c>user:pass@</c>; the Test-Connect probe already redacted it (<c>MTConnectDriverProbe.RedactedDisplay</c>)
|
||||
/// while the driver itself logged and surfaced the raw string — and every request URI built from it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A URI with no user-info (and, for the configured string, no query or fragment) is returned <b>unchanged</b>,
|
||||
/// so the common case — and every host key derived from it — is byte-identical to what it was. One that does
|
||||
/// carry user-info loses it (and, for the configured string, its query / fragment, which a reverse proxy may use
|
||||
/// for a token). A configured string that is not an absolute URI but contains an <c>@</c> is replaced wholesale:
|
||||
/// there is no reliable way to find the credential in it.
|
||||
/// </remarks>
|
||||
internal static class MTConnectUriRedaction
|
||||
{
|
||||
/// <summary>Placeholder for a configured value that cannot be parsed and might carry a credential.</summary>
|
||||
internal const string Unparseable = "(agentUri withheld: not an absolute URI)";
|
||||
|
||||
/// <summary>The display form of a configured <c>AgentUri</c>.</summary>
|
||||
/// <param name="agentUri">The configured value.</param>
|
||||
/// <returns>The value with any user-info (and query / fragment) removed.</returns>
|
||||
public static string Redact(string? agentUri)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(agentUri))
|
||||
{
|
||||
return agentUri ?? string.Empty;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(agentUri.Trim(), UriKind.Absolute, out var uri))
|
||||
{
|
||||
return agentUri.Contains('@', StringComparison.Ordinal) ? Unparseable : agentUri;
|
||||
}
|
||||
|
||||
if (uri.UserInfo.Length == 0 && uri.Query.Length == 0 && uri.Fragment.Length == 0)
|
||||
{
|
||||
return agentUri;
|
||||
}
|
||||
|
||||
var port = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
|
||||
var path = uri.AbsolutePath == "/" ? string.Empty : uri.AbsolutePath;
|
||||
|
||||
return $"{uri.Scheme}://{uri.Host}{port}{path}";
|
||||
}
|
||||
|
||||
/// <summary>The display form of a request URI built from the <c>AgentUri</c> (its query is the request's own).</summary>
|
||||
/// <param name="uri">The request URI.</param>
|
||||
/// <returns>The URI as text, without user-info.</returns>
|
||||
public static string Redact(Uri uri)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(uri);
|
||||
if (!uri.IsAbsoluteUri || uri.UserInfo.Length == 0)
|
||||
{
|
||||
return uri.ToString();
|
||||
}
|
||||
|
||||
return new UriBuilder(uri) { UserName = string.Empty, Password = string.Empty }.Uri.ToString();
|
||||
}
|
||||
}
|
||||
@@ -190,7 +190,44 @@ internal static class MTConnectXml
|
||||
/// <param name="root">The document's actual root element.</param>
|
||||
/// <param name="expectedRootName">The root element name the caller required.</param>
|
||||
/// <param name="subject">How to describe the document in the error message.</param>
|
||||
/// <returns>The error message describing the unexpected root.</returns>
|
||||
/// <returns>
|
||||
/// The exception to throw. For an <c>MTConnectError</c> root (the Agent REFUSED the request) it is marked
|
||||
/// with <see cref="AgentRefusalDataKey"/> — see <see cref="IsAgentRefusal"/>.
|
||||
/// </returns>
|
||||
public static InvalidDataException UnexpectedRoot(XElement root, string expectedRootName, string subject)
|
||||
{
|
||||
var ex = new InvalidDataException(DescribeUnexpectedRoot(root, expectedRootName, subject));
|
||||
|
||||
// Marked (InvalidDataException is sealed, so not a subtype) so the read path can tell a refusal — the
|
||||
// Agent answered — from an answer it could not parse at all. Every other handler sees the same exception.
|
||||
if (IsNamed(root, "MTConnectError"))
|
||||
{
|
||||
ex.Data[AgentRefusalDataKey] = true;
|
||||
}
|
||||
|
||||
return ex;
|
||||
}
|
||||
|
||||
/// <summary><see cref="Exception.Data"/> key marking an <c>MTConnectError</c> answer (see <see cref="IsAgentRefusal"/>).</summary>
|
||||
internal const string AgentRefusalDataKey = "MTConnect.AgentRefusal";
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="ex"/> is the Agent answering with an <c>MTConnectError</c> document (e.g.
|
||||
/// <c>NO_DEVICE</c>, <c>INVALID_REQUEST</c>) — a refusal, as opposed to an answer that could not be parsed.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception a parse raised.</param>
|
||||
/// <returns><see langword="true"/> for a refusal.</returns>
|
||||
public static bool IsAgentRefusal(Exception ex) =>
|
||||
ex is InvalidDataException && ex.Data[AgentRefusalDataKey] is true;
|
||||
|
||||
/// <summary>
|
||||
/// The text of <see cref="UnexpectedRoot"/>: the unexpected root, plus the Agent's own error text for an
|
||||
/// <c>MTConnectError</c> document.
|
||||
/// </summary>
|
||||
/// <param name="root">The document's root element.</param>
|
||||
/// <param name="expectedRootName">The root the caller expected.</param>
|
||||
/// <param name="subject">How to describe the document.</param>
|
||||
/// <returns>The message.</returns>
|
||||
public static string DescribeUnexpectedRoot(XElement root, string expectedRootName, string subject)
|
||||
{
|
||||
var message =
|
||||
|
||||
+63
-3
@@ -17,6 +17,7 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
||||
private readonly NodeId _rootNodeId;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private volatile bool _disposed;
|
||||
private readonly IDisposable? _ownedCredential;
|
||||
|
||||
/// <summary>
|
||||
/// Construct a browse session bound to an already-connected <paramref name="session"/>.
|
||||
@@ -25,8 +26,10 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
||||
/// <param name="nsMap">Namespace snapshot taken at connect time; used to render outbound
|
||||
/// NodeIds in the server-stable <c>nsu=…</c> form.</param>
|
||||
/// <param name="rootNodeId">The node under which <see cref="RootAsync"/> browses one level.</param>
|
||||
internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId)
|
||||
/// <param name="ownedCredential">A credential (the user certificate) this session owns and disposes with itself.</param>
|
||||
internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId, IDisposable? ownedCredential = null)
|
||||
{
|
||||
_ownedCredential = ownedCredential;
|
||||
_session = session;
|
||||
_nsMap = nsMap;
|
||||
_rootNodeId = rootNodeId;
|
||||
@@ -54,10 +57,64 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
|
||||
// R06:O-5 — reads the node's NodeClass / DisplayName / DataType / ValueRank / UserAccessLevel in one request and
|
||||
// maps them through the same UpstreamAttributeMapping the runtime driver's discovery registers with, so the
|
||||
// side-panel shows what a browse-commit would author. An Object (folder) has no value attributes → empty.
|
||||
// Throws ArgumentException when nodeId cannot be resolved against the live session's namespace table.
|
||||
public async Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!NamespaceMap.TryResolve(_session, nodeId, out var resolved))
|
||||
throw new ArgumentException(
|
||||
$"Cannot resolve NodeId '{nodeId}' against the live session.", nameof(nodeId));
|
||||
|
||||
uint[] attributeIds =
|
||||
[
|
||||
Attributes.NodeClass, Attributes.DisplayName, Attributes.DataType, Attributes.ValueRank,
|
||||
Attributes.UserAccessLevel,
|
||||
];
|
||||
var toRead = new ReadValueIdCollection(attributeIds.Length);
|
||||
foreach (var id in attributeIds) toRead.Add(new ReadValueId { NodeId = resolved, AttributeId = id });
|
||||
|
||||
DataValueCollection values;
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var resp = await _session.ReadAsync(
|
||||
requestHeader: null,
|
||||
maxAge: 0,
|
||||
timestampsToReturn: TimestampsToReturn.Neither,
|
||||
nodesToRead: toRead,
|
||||
ct: cancellationToken).ConfigureAwait(false);
|
||||
values = resp?.Results ?? [];
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
LastUsedUtc = DateTime.UtcNow;
|
||||
return Task.FromResult<IReadOnlyList<AttributeInfo>>(Array.Empty<AttributeInfo>());
|
||||
|
||||
DataValue? At(int i) => i < values.Count && StatusCode.IsGood(values[i].StatusCode)
|
||||
? values[i] : null;
|
||||
|
||||
var nodeClass = At(0)?.Value is int nc ? (NodeClass)nc : NodeClass.Unspecified;
|
||||
if (nodeClass != NodeClass.Variable) return Array.Empty<AttributeInfo>();
|
||||
|
||||
var name = (At(1)?.Value as LocalizedText)?.Text ?? resolved.ToString() ?? nodeId;
|
||||
var dataType = At(2)?.Value is NodeId dt
|
||||
? UpstreamAttributeMapping.MapDataType(dt)
|
||||
: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.String;
|
||||
var valueRank = At(3)?.Value is int vr ? vr : ValueRanks.Scalar;
|
||||
var access = At(4)?.Value is byte ab ? ab : (byte)0;
|
||||
|
||||
return
|
||||
[
|
||||
new AttributeInfo(
|
||||
Name: name,
|
||||
DriverDataType: dataType.ToString(),
|
||||
IsArray: UpstreamAttributeMapping.IsArray(valueRank),
|
||||
SecurityClass: UpstreamAttributeMapping.MapAccessLevel(access).ToString()),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>Issue a single-level Browse (plus continuation-point follow-ups) under the
|
||||
@@ -188,5 +245,8 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
|
||||
|
||||
try { _gate.Dispose(); }
|
||||
catch { /* best-effort */ }
|
||||
|
||||
try { _ownedCredential?.Dispose(); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
+96
-54
@@ -7,7 +7,8 @@ using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; // OpcUaClientDriverOptions + NamespaceMap
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; // OpcUaClientDriverOptions + NamespaceMap + OpcUaUserIdentityFactory
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
||||
|
||||
@@ -33,12 +34,19 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
};
|
||||
|
||||
private readonly ILogger<OpcUaClientDriverBrowser> _logger;
|
||||
private readonly ISecretResolver? _secretResolver;
|
||||
|
||||
/// <summary>Creates a new browser. Logger defaults to NullLogger when not supplied.</summary>
|
||||
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/>.</param>
|
||||
public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null)
|
||||
/// <param name="secretResolver">
|
||||
/// The host's shared secret resolver (DI-injected where one is registered), used to resolve
|
||||
/// <c>secret:</c>-prefixed <c>Password</c> / <c>UserCertificatePassword</c> exactly as the runtime driver does.
|
||||
/// Null ⇒ a <c>secret:</c> ref fails closed (it is never sent as the literal).
|
||||
/// </param>
|
||||
public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null, ISecretResolver? secretResolver = null)
|
||||
{
|
||||
_logger = logger ?? NullLogger<OpcUaClientDriverBrowser>.Instance;
|
||||
_secretResolver = secretResolver;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -58,58 +66,69 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
throw new InvalidOperationException("OpcUaClient browser requires EndpointUrl or EndpointUrls[0].");
|
||||
|
||||
if (opts.AuthType == OpcUaAuthType.Certificate)
|
||||
throw new InvalidOperationException(
|
||||
"Browser does not support OpcUaAuthType.Certificate in v1; use Anonymous or Username.");
|
||||
|
||||
if (opts.AutoAcceptCertificates)
|
||||
_logger.LogWarning(
|
||||
"AdminUI browse session opens against {Endpoint} with form's AutoAcceptCertificates=true — " +
|
||||
"browse uses its own cert store and does NOT auto-accept; trust the cert via the runtime " +
|
||||
"driver's PKI store instead.",
|
||||
endpoint);
|
||||
|
||||
var appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||
var identity = BuildBrowseUserIdentity(opts);
|
||||
|
||||
var perEndpointBudget = TimeSpan.FromSeconds(
|
||||
Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
|
||||
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
connectCts.CancelAfter(perEndpointBudget);
|
||||
|
||||
var endpointDesc = await SelectEndpointAsync(
|
||||
appConfig, endpoint, opts.SecurityPolicy, opts.SecurityMode, connectCts.Token).ConfigureAwait(false);
|
||||
var endpointCfg = EndpointConfiguration.Create(appConfig);
|
||||
endpointCfg.OperationTimeout = (int)opts.Timeout.TotalMilliseconds;
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpointDesc, endpointCfg);
|
||||
|
||||
var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
|
||||
appConfig,
|
||||
configuredEndpoint,
|
||||
updateBeforeConnect: false,
|
||||
sessionName: "OtOpcUa AdminUI Browse",
|
||||
(uint)opts.SessionTimeout.TotalMilliseconds,
|
||||
identity,
|
||||
preferredLocales: null,
|
||||
connectCts.Token).ConfigureAwait(false);
|
||||
// R06:O-5 — Certificate auth is supported: the identity is built (and any secret: credential resolved) by the
|
||||
// same factory the runtime driver uses, BEFORE any network I/O, so a bad certificate path / password fails fast
|
||||
// with its own message.
|
||||
var (identity, ownedCertificate) = await BuildBrowseCredentialAsync(opts, _secretResolver, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var nsMap = NamespaceMap.FromSession(session);
|
||||
var rootNodeId = string.IsNullOrEmpty(opts.BrowseRoot)
|
||||
? ObjectIds.ObjectsFolder
|
||||
: NodeId.Parse(session.MessageContext, opts.BrowseRoot);
|
||||
if (opts.AutoAcceptCertificates)
|
||||
_logger.LogWarning(
|
||||
"AdminUI browse session opens against {Endpoint} with form's AutoAcceptCertificates=true — " +
|
||||
"browse uses its own cert store and does NOT auto-accept; trust the cert via the runtime " +
|
||||
"driver's PKI store instead.",
|
||||
endpoint);
|
||||
|
||||
_logger.LogInformation(
|
||||
"AdminUI OPC UA browse session opened against {Endpoint} (policy {Policy}, mode {Mode})",
|
||||
endpoint, opts.SecurityPolicy, opts.SecurityMode);
|
||||
var appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new OpcUaClientBrowseSession(session, nsMap, rootNodeId);
|
||||
var perEndpointBudget = TimeSpan.FromSeconds(
|
||||
Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
|
||||
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
connectCts.CancelAfter(perEndpointBudget);
|
||||
|
||||
var endpointDesc = await SelectEndpointAsync(
|
||||
appConfig, endpoint, opts.SecurityPolicy, opts.SecurityMode, connectCts.Token).ConfigureAwait(false);
|
||||
var endpointCfg = EndpointConfiguration.Create(appConfig);
|
||||
endpointCfg.OperationTimeout = (int)opts.Timeout.TotalMilliseconds;
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpointDesc, endpointCfg);
|
||||
|
||||
var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
|
||||
appConfig,
|
||||
configuredEndpoint,
|
||||
updateBeforeConnect: false,
|
||||
sessionName: "OtOpcUa AdminUI Browse",
|
||||
(uint)opts.SessionTimeout.TotalMilliseconds,
|
||||
identity,
|
||||
preferredLocales: null,
|
||||
connectCts.Token).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var nsMap = NamespaceMap.FromSession(session);
|
||||
var rootNodeId = string.IsNullOrEmpty(opts.BrowseRoot)
|
||||
? ObjectIds.ObjectsFolder
|
||||
: NodeId.Parse(session.MessageContext, opts.BrowseRoot);
|
||||
|
||||
_logger.LogInformation(
|
||||
"AdminUI OPC UA browse session opened against {Endpoint} (policy {Policy}, mode {Mode})",
|
||||
endpoint, opts.SecurityPolicy, opts.SecurityMode);
|
||||
|
||||
return new OpcUaClientBrowseSession(session, nsMap, rootNodeId, ownedCertificate);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { if (session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { /* best-effort */ }
|
||||
try { session.Dispose(); } catch { /* best-effort */ }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { if (session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { /* best-effort */ }
|
||||
try { session.Dispose(); } catch { /* best-effort */ }
|
||||
// Review m9: the certificate (and its private-key handle) is owned by the browse session once handed
|
||||
// over; any failure before that releases it here instead of leaving it to the GC.
|
||||
ownedCertificate?.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -175,16 +194,39 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>Build the OPC UA user identity from the form's auth fields.</summary>
|
||||
/// <summary>
|
||||
/// Build the OPC UA user identity from the form's auth fields — Anonymous, Username or Certificate — through
|
||||
/// the factory the runtime driver uses, after resolving any <c>secret:</c>-prefixed credential (fail-closed).
|
||||
/// </summary>
|
||||
/// <param name="opts">Driver options carrying the form's auth fields.</param>
|
||||
private static UserIdentity BuildBrowseUserIdentity(OpcUaClientDriverOptions opts) => opts.AuthType switch
|
||||
/// <param name="secretResolver">The secret store, or null when none is available.</param>
|
||||
/// <param name="ct">Cancellation token for the secret resolution.</param>
|
||||
/// <returns>The identity the browse session activates with.</returns>
|
||||
internal static async Task<UserIdentity> BuildBrowseUserIdentityAsync(
|
||||
OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct) =>
|
||||
(await BuildBrowseCredentialAsync(opts, secretResolver, ct).ConfigureAwait(false)).Identity;
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="BuildBrowseUserIdentityAsync"/>, also handing back the user certificate a Certificate-auth
|
||||
/// identity holds, so the browse session that uses it can dispose it (review m9: the browse opens one per picker
|
||||
/// session, and an undisposed <c>X509Certificate2</c> pins a private-key handle until GC).
|
||||
/// </summary>
|
||||
/// <param name="opts">Driver options carrying the form's auth fields.</param>
|
||||
/// <param name="secretResolver">The secret store, or null when none is available.</param>
|
||||
/// <param name="ct">Cancellation token for the secret resolution.</param>
|
||||
/// <returns>The identity, and the certificate it owns (null for Anonymous / Username).</returns>
|
||||
internal static async Task<(UserIdentity Identity, System.Security.Cryptography.X509Certificates.X509Certificate2? Certificate)>
|
||||
BuildBrowseCredentialAsync(OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct)
|
||||
{
|
||||
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
|
||||
OpcUaAuthType.Username => new UserIdentity(
|
||||
opts.Username ?? string.Empty,
|
||||
System.Text.Encoding.UTF8.GetBytes(opts.Password ?? string.Empty)),
|
||||
_ => new UserIdentity(new AnonymousIdentityToken()),
|
||||
};
|
||||
var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(opts, secretResolver, ct).ConfigureAwait(false);
|
||||
if (resolved.AuthType != OpcUaAuthType.Certificate)
|
||||
{
|
||||
return (OpcUaUserIdentityFactory.Build(resolved), null);
|
||||
}
|
||||
|
||||
var certificate = OpcUaUserIdentityFactory.LoadCertificate(resolved);
|
||||
return (new UserIdentity(certificate), certificate);
|
||||
}
|
||||
|
||||
/// <summary>Select the endpoint matching the requested SecurityPolicy + SecurityMode pair.</summary>
|
||||
/// <param name="appConfig">Application configuration used by the discovery client.</param>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// The ONE place the OpcUaClient user identity is built from <see cref="OpcUaClientDriverOptions"/> — shared by
|
||||
/// the runtime driver's session open and the AdminUI live browse, so a certificate-authenticated upstream is
|
||||
/// browsable with exactly the credentials the deployed driver will present. Includes the <c>secret:</c>
|
||||
/// resolution of the two credential fields (fail-closed).
|
||||
/// </summary>
|
||||
public static class OpcUaUserIdentityFactory
|
||||
{
|
||||
private const string SecretPrefix = "secret:";
|
||||
|
||||
/// <summary>
|
||||
/// Build the user-identity token from options whose credential fields are already resolved plaintext
|
||||
/// (see <see cref="ResolveSecretRefsAsync"/>).
|
||||
/// </summary>
|
||||
/// <param name="options">Driver options containing authentication configuration.</param>
|
||||
/// <returns>The constructed <see cref="UserIdentity"/> for session activation.</returns>
|
||||
public static UserIdentity Build(OpcUaClientDriverOptions options) =>
|
||||
options.AuthType switch
|
||||
{
|
||||
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
|
||||
OpcUaAuthType.Username => new UserIdentity(
|
||||
options.Username ?? string.Empty,
|
||||
System.Text.Encoding.UTF8.GetBytes(options.Password ?? string.Empty)),
|
||||
OpcUaAuthType.Certificate => FromCertificate(options),
|
||||
_ => new UserIdentity(new AnonymousIdentityToken()),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="UserIdentity"/> carrying a client user-authentication certificate loaded from
|
||||
/// <see cref="OpcUaClientDriverOptions.UserCertificatePath"/>. Fails fast if the path is missing, the file
|
||||
/// doesn't exist, or the certificate lacks a private key (required to sign the user-token challenge).
|
||||
/// </summary>
|
||||
/// <param name="options">Driver options containing certificate configuration.</param>
|
||||
/// <returns>The certificate-backed <see cref="UserIdentity"/>.</returns>
|
||||
public static UserIdentity FromCertificate(OpcUaClientDriverOptions options) => new(LoadCertificate(options));
|
||||
|
||||
/// <summary>
|
||||
/// Loads (and validates) the user-authentication certificate <see cref="FromCertificate"/> wraps. The caller owns
|
||||
/// the returned certificate — dispose it when the session that presented it is gone.
|
||||
/// </summary>
|
||||
/// <param name="options">Driver options containing certificate configuration.</param>
|
||||
/// <returns>The certificate, with its private key.</returns>
|
||||
public static System.Security.Cryptography.X509Certificates.X509Certificate2 LoadCertificate(OpcUaClientDriverOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.UserCertificatePath))
|
||||
throw new InvalidOperationException(
|
||||
"OpcUaAuthType.Certificate requires OpcUaClientDriverOptions.UserCertificatePath to be set.");
|
||||
if (!File.Exists(options.UserCertificatePath))
|
||||
throw new FileNotFoundException(
|
||||
$"User certificate not found at '{options.UserCertificatePath}'.",
|
||||
options.UserCertificatePath);
|
||||
|
||||
// X509CertificateLoader (new in .NET 9) is the only non-obsolete way to load a PFX
|
||||
// since the legacy X509Certificate2 ctors are marked obsolete on net10.
|
||||
var cert = System.Security.Cryptography.X509Certificates.X509CertificateLoader
|
||||
.LoadPkcs12FromFile(options.UserCertificatePath, options.UserCertificatePassword);
|
||||
|
||||
if (!cert.HasPrivateKey)
|
||||
{
|
||||
cert.Dispose();
|
||||
throw new InvalidOperationException(
|
||||
$"User certificate at '{options.UserCertificatePath}' has no private key — " +
|
||||
"the private key is required to sign the OPC UA user-token challenge at session activation.");
|
||||
}
|
||||
|
||||
return cert;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
|
||||
/// <see cref="OpcUaClientDriverOptions.Password"/> / <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/>
|
||||
/// resolved through <paramref name="resolver"/>. Non-secret / null / empty fields pass through unchanged (the
|
||||
/// reference-equal original is returned when nothing changed). Fail-closed: an absent secret, or a
|
||||
/// <c>secret:</c> ref with no resolver available, throws rather than sending the literal as a credential.
|
||||
/// </summary>
|
||||
/// <param name="options">The raw options (credential fields may carry <c>secret:</c> refs).</param>
|
||||
/// <param name="resolver">The shared secret resolver, or null when none is available.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
|
||||
public static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
|
||||
OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct)
|
||||
.ConfigureAwait(false);
|
||||
var certPassword = await ResolveFieldAsync(
|
||||
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (ReferenceEquals(password, options.Password)
|
||||
&& ReferenceEquals(certPassword, options.UserCertificatePassword))
|
||||
{
|
||||
return options;
|
||||
}
|
||||
|
||||
return options with { Password = password, UserCertificatePassword = certPassword };
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolveFieldAsync(
|
||||
string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)
|
||||
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var name = value[SecretPrefix.Length..];
|
||||
if (resolver is null)
|
||||
throw new InvalidOperationException(
|
||||
$"OpcUaClientDriverOptions.{fieldName}='{value}' references secret '{name}', but no secret store is " +
|
||||
"available here (fail-closed).");
|
||||
var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
|
||||
return !string.IsNullOrEmpty(resolved)
|
||||
? resolved
|
||||
: throw new InvalidOperationException(
|
||||
$"OpcUaClientDriverOptions.{fieldName}='{value}' resolves secret '{name}', but it is " +
|
||||
"absent from the store (fail-closed).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// The ONE mapping from an upstream variable's OPC UA attributes (DataType, ValueRank, AccessLevel) to the
|
||||
/// driver-agnostic shape — shared by the runtime driver's discovery enrichment and the AdminUI live browse
|
||||
/// side-panel, so the picker shows exactly what a browse-commit / discovery would register.
|
||||
/// </summary>
|
||||
public static class UpstreamAttributeMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// Map an upstream OPC UA built-in DataType NodeId (via <c>DataTypeIds.*</c>) to a
|
||||
/// <see cref="DriverDataType"/>. Unknown / custom types fall through to
|
||||
/// <see cref="DriverDataType.String"/> which is the safest passthrough for
|
||||
/// Variant-wrapped structs + enums + extension objects.
|
||||
/// </summary>
|
||||
/// <param name="dataType">The OPC UA data type NodeId to map.</param>
|
||||
/// <returns>The mapped <see cref="DriverDataType"/>.</returns>
|
||||
public static DriverDataType MapDataType(NodeId dataType)
|
||||
{
|
||||
if (dataType == DataTypeIds.Boolean) return DriverDataType.Boolean;
|
||||
// SByte (signed 8-bit) shares Int16 — DriverDataType has no narrower signed type.
|
||||
// Byte (unsigned 8-bit) belongs in the unsigned family → UInt16, not Int16
|
||||
// (mapping an unsigned 0-255 type onto Int16 misrepresents type metadata and
|
||||
// confuses range/validation logic keyed off DriverDataType).
|
||||
if (dataType == DataTypeIds.SByte || dataType == DataTypeIds.Int16) return DriverDataType.Int16;
|
||||
if (dataType == DataTypeIds.Byte || dataType == DataTypeIds.UInt16) return DriverDataType.UInt16;
|
||||
if (dataType == DataTypeIds.Int32) return DriverDataType.Int32;
|
||||
if (dataType == DataTypeIds.UInt32) return DriverDataType.UInt32;
|
||||
if (dataType == DataTypeIds.Int64) return DriverDataType.Int64;
|
||||
if (dataType == DataTypeIds.UInt64) return DriverDataType.UInt64;
|
||||
if (dataType == DataTypeIds.Float) return DriverDataType.Float32;
|
||||
if (dataType == DataTypeIds.Double) return DriverDataType.Float64;
|
||||
if (dataType == DataTypeIds.String) return DriverDataType.String;
|
||||
if (dataType == DataTypeIds.DateTime || dataType == DataTypeIds.UtcTime)
|
||||
return DriverDataType.DateTime;
|
||||
return DriverDataType.String;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map an OPC UA AccessLevel/UserAccessLevel attribute value (<c>AccessLevels</c> bitmask) to a
|
||||
/// <see cref="SecurityClassification"/>: CurrentWrite-capable variables surface as
|
||||
/// <see cref="SecurityClassification.Operate"/>; read-only as <see cref="SecurityClassification.ViewOnly"/>.
|
||||
/// </summary>
|
||||
/// <param name="accessLevel">The OPC UA access level bitmask.</param>
|
||||
/// <returns>The mapped security classification.</returns>
|
||||
public static SecurityClassification MapAccessLevel(byte accessLevel)
|
||||
{
|
||||
const byte CurrentWrite = 2; // AccessLevels.CurrentWrite = 0x02
|
||||
return (accessLevel & CurrentWrite) != 0
|
||||
? SecurityClassification.Operate
|
||||
: SecurityClassification.ViewOnly;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA Part 3 ValueRank: -3 ScalarOrOneDimension, -2 Any, -1 Scalar, 0 OneOrMoreDimensions, 1 OneDimension,
|
||||
/// >1 N dimensions. Anything >= 0 is an array; -3 / -2 are conservatively scalar until the upstream variable
|
||||
/// carries a concrete dimensioned rank.
|
||||
/// </summary>
|
||||
/// <param name="valueRank">The ValueRank attribute.</param>
|
||||
/// <returns>Whether the variable is exposed as an array.</returns>
|
||||
public static bool IsArray(int valueRank) => valueRank >= 0;
|
||||
}
|
||||
+7
-2
@@ -11,8 +11,9 @@
|
||||
</PropertyGroup>
|
||||
<!--
|
||||
The live-OPC-UA-session layer of the OpcUaClient driver: everything the driver AND the AdminUI's
|
||||
browser both need that is typed in ISession / NodeId / NamespaceTable. Today that is exactly
|
||||
NamespaceMap.
|
||||
browser both need that is typed in ISession / NodeId / NamespaceTable / UserIdentity: NamespaceMap,
|
||||
UpstreamAttributeMapping (DataType/AccessLevel/ValueRank -> driver shape) and OpcUaUserIdentityFactory
|
||||
(credential secret: resolution + the SDK UserIdentity, incl. certificate auth).
|
||||
|
||||
WHY IT IS NOT IN .Contracts. It used to be, and .Contracts therefore carried
|
||||
OPCFoundation.NetStandard.Opc.Ua.Client. When G-7 wired OpcUaClientTagDefinitionFactory.Inspect into
|
||||
@@ -35,6 +36,10 @@
|
||||
-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
|
||||
<!-- OpcUaUserIdentityFactory: the secret: resolution of the credential fields feeding the SDK UserIdentity is
|
||||
shared by the driver and the AdminUI browse (review 2026-10-21 O-5), so the browse presents exactly the
|
||||
credentials the deployed driver will. Abstractions only (one interface + a name type). -->
|
||||
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- RawTagEntry (the v3 authored-tag identity NamespaceMap indexes) lives in the Core.Abstractions
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,8 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe
|
||||
|
||||
// Bound the whole probe (both phases) by the caller timeout, independent of `ct` — a
|
||||
// stalled OPC UA endpoint that accepts TCP but never answers GetEndpoints must not block
|
||||
// the probe when the caller passes CancellationToken.None.
|
||||
// the probe when the caller passes CancellationToken.None. The config's ProbeTimeoutSeconds caps it (O-4).
|
||||
timeout = EffectiveTimeout(timeout, opts.ProbeTimeoutSeconds);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(timeout);
|
||||
|
||||
@@ -106,6 +107,23 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R06:O-4 — the deadline the probe actually runs under: the caller's budget, tightened by the config's
|
||||
/// <see cref="OpcUaClientDriverOptions.ProbeTimeoutSeconds"/> when that is positive and shorter (it can only
|
||||
/// shorten the budget — the caller's own deadline fires regardless). A non-positive caller budget is floored
|
||||
/// to 1 s so the probe runs briefly rather than cancelling before it starts. Same rule as the Galaxy probe.
|
||||
/// </summary>
|
||||
/// <param name="callerTimeout">The budget the host handed the probe.</param>
|
||||
/// <param name="configuredSeconds">The config's <c>ProbeTimeoutSeconds</c>; ignored when not positive.</param>
|
||||
/// <returns>The effective probe deadline.</returns>
|
||||
internal static TimeSpan EffectiveTimeout(TimeSpan callerTimeout, int configuredSeconds)
|
||||
{
|
||||
var caller = callerTimeout > TimeSpan.Zero ? callerTimeout : TimeSpan.FromSeconds(1);
|
||||
if (configuredSeconds <= 0) return caller;
|
||||
var configured = TimeSpan.FromSeconds(configuredSeconds);
|
||||
return configured < caller ? configured : caller;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a minimal <see cref="ApplicationConfiguration"/> suitable for unauthenticated
|
||||
/// discovery (GetEndpoints). No PKI stores, no session certificate — discovery traffic
|
||||
|
||||
@@ -21,8 +21,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
/// </remarks>
|
||||
internal static class OpcUaClientSecretResolution
|
||||
{
|
||||
private const string SecretPrefix = "secret:";
|
||||
|
||||
/// <summary>
|
||||
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
|
||||
/// <see cref="OpcUaClientDriverOptions.Password"/> /
|
||||
@@ -37,54 +35,12 @@ internal static class OpcUaClientSecretResolution
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// A <c>secret:</c> ref names a secret that is absent/tombstoned in the store (fail-closed).
|
||||
/// </exception>
|
||||
internal static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
|
||||
internal static Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
|
||||
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(resolver);
|
||||
|
||||
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct)
|
||||
.ConfigureAwait(false);
|
||||
var certPassword = await ResolveFieldAsync(
|
||||
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Only re-materialize when something actually changed — a `with` on the reference-equal
|
||||
// strings is harmless, but skipping it keeps the common (no-secret) case allocation-free.
|
||||
if (ReferenceEquals(password, options.Password)
|
||||
&& ReferenceEquals(certPassword, options.UserCertificatePassword))
|
||||
{
|
||||
return options;
|
||||
}
|
||||
|
||||
return options with { Password = password, UserCertificatePassword = certPassword };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a single credential field. Null/empty or non-<c>secret:</c> values pass through
|
||||
/// unchanged (the reference-equal original is returned). A <c>secret:NAME</c> value is
|
||||
/// resolved through <paramref name="resolver"/> and is fail-closed when the secret is absent.
|
||||
/// </summary>
|
||||
/// <param name="value">The raw field value (may be a <c>secret:</c> ref).</param>
|
||||
/// <param name="fieldName">The field name, used in the fail-closed exception message.</param>
|
||||
/// <param name="resolver">The shared secret resolver.</param>
|
||||
/// <param name="ct">Cancellation token for the async resolution.</param>
|
||||
/// <returns>The resolved plaintext, or the original value when it is not a <c>secret:</c> ref.</returns>
|
||||
private static async Task<string?> ResolveFieldAsync(
|
||||
string? value, string fieldName, ISecretResolver resolver, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)
|
||||
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var name = value[SecretPrefix.Length..];
|
||||
var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
|
||||
return !string.IsNullOrEmpty(resolved)
|
||||
? resolved
|
||||
: throw new InvalidOperationException(
|
||||
$"OpcUaClientDriverOptions.{fieldName}='{value}' resolves secret '{name}', but it is " +
|
||||
"absent from the store (fail-closed).");
|
||||
// One authority, shared with the AdminUI live browse (Driver.OpcUaClient.Session).
|
||||
return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -219,7 +219,7 @@
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Admin UI probe timeout (seconds)</label>
|
||||
<InputNumber @bind-Value="_form.OpcUa.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Max 60. Used by Test Connect. Default 15.</div>
|
||||
<div class="form-text">Caps Test Connect: it can only shorten the 10 s Test Connect budget, never extend it.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>onReady</c> callback handed to <c>AddZbLocalDb</c>: creates the deployment-cache,
|
||||
/// alarm store-and-forward, and scripted-alarm condition-state tables and — when this node
|
||||
/// alarm store-and-forward, and scripted-alarm condition-state + comment tables and — when this node
|
||||
/// actually replicates — opts them into change capture.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
public static class LocalDbSetup
|
||||
{
|
||||
/// <summary>
|
||||
/// The four tables that participate in replication — registered when this node is configured
|
||||
/// The five tables that participate in replication — registered when this node is configured
|
||||
/// to replicate, and deregistered (stale capture triggers dropped) when it is not.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -36,6 +36,7 @@ public static class LocalDbSetup
|
||||
DeploymentCacheSchema.PointerTable,
|
||||
AlarmSfSchema.EventsTable,
|
||||
AlarmConditionStateSchema.StateTable,
|
||||
AlarmConditionStateSchema.CommentsTable,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
@@ -47,8 +48,9 @@ public static class LocalDbSetup
|
||||
/// <c>RegisterReplicated</c> is what installs the three AFTER triggers that capture
|
||||
/// changes into the oplog. Any row written before that call is never captured, so it
|
||||
/// never reaches the peer — silently, and permanently, because nothing ever revisits
|
||||
/// history. <see cref="AlarmSfLegacyMigrator"/> therefore runs <b>last</b>, after every
|
||||
/// registration — it writes rows, and they must be captured like any other write.
|
||||
/// history. <see cref="AlarmConditionStateSchema.MigrateInlineComments"/> and
|
||||
/// <see cref="AlarmSfLegacyMigrator"/> therefore run <b>last</b>, after every
|
||||
/// registration — they write rows, and those must be captured like any other write.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Registration is CONDITIONAL on <see cref="ReplicationIsConfigured"/>.</b> It used
|
||||
@@ -68,7 +70,7 @@ public static class LocalDbSetup
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A failure part-way through either loop fails host start, and that is deliberate.</b>
|
||||
/// Neither branch catches, so a throw on the third of four tables leaves two flipped and
|
||||
/// Neither branch catches, so a throw on the third of five tables leaves two flipped and
|
||||
/// propagates out of the <c>AddZbLocalDb</c> singleton factory — a node that came up
|
||||
/// anyway would be capturing (or not) on an arbitrary subset, which is silently partial
|
||||
/// replication with nothing in the log to say so. Both library calls are idempotent, so
|
||||
@@ -116,7 +118,36 @@ public static class LocalDbSetup
|
||||
else
|
||||
RemoveStaleCapture(db, logger ?? Serilog.Log.Logger);
|
||||
|
||||
// LAST, and only here. This is the one call in OnReady that writes rows.
|
||||
// LAST, and only here. These are the only calls in OnReady that write rows.
|
||||
// Copies any pre-split inline comment trail (alarm_condition_state.comments_json) into the
|
||||
// append-only alarm_condition_comment table (review R05:4). Idempotent: an ordinal already
|
||||
// held is never rewritten, and the condition row itself is never touched (the alarm's next
|
||||
// save clears the column). An unreadable trail is reported, not skipped silently.
|
||||
AlarmConditionStateSchema.InlineCommentMigrationResult commentMigration;
|
||||
using (var connection = db.CreateConnection())
|
||||
{
|
||||
commentMigration = AlarmConditionStateSchema.MigrateInlineComments(connection);
|
||||
}
|
||||
|
||||
var migrationLog = logger ?? Serilog.Log.Logger;
|
||||
if (commentMigration.RowsMoved > 0)
|
||||
{
|
||||
migrationLog.Information(
|
||||
"LocalDb: copied the inline comment trail of {AlarmCount} scripted alarm(s) into {Table}.",
|
||||
commentMigration.RowsMoved,
|
||||
AlarmConditionStateSchema.CommentsTable);
|
||||
}
|
||||
|
||||
foreach (var alarmId in commentMigration.UnparseableAlarmIds)
|
||||
{
|
||||
migrationLog.Warning(
|
||||
"LocalDb: scripted alarm {AlarmId} has an unreadable inline comment trail "
|
||||
+ "(alarm_condition_state.comments_json is not valid JSON); its history was NOT moved to {Table} "
|
||||
+ "and the alarm's state will not load until the row is repaired.",
|
||||
alarmId,
|
||||
AlarmConditionStateSchema.CommentsTable);
|
||||
}
|
||||
|
||||
AlarmSfLegacyMigrator.Migrate(db, configuration);
|
||||
}
|
||||
|
||||
|
||||
+296
-98
@@ -1,6 +1,4 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.LocalDb;
|
||||
@@ -11,9 +9,11 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Node-local <see cref="IAlarmStateStore"/> backed by the replicated
|
||||
/// <see cref="AlarmConditionStateSchema.StateTable"/> table in the node's consolidated LocalDb.
|
||||
/// Maps the full Part 9 <see cref="AlarmConditionState"/> — Enabled / Acked / Confirmed /
|
||||
/// Shelving + the ack/confirm audit trail + operator comments.
|
||||
/// <see cref="AlarmConditionStateSchema.StateTable"/> and
|
||||
/// <see cref="AlarmConditionStateSchema.CommentsTable"/> tables in the node's consolidated
|
||||
/// LocalDb. Maps the full Part 9 <see cref="AlarmConditionState"/> — Enabled / Acked / Confirmed /
|
||||
/// Shelving + the ack/confirm audit fields on the condition row, and the append-only comment
|
||||
/// trail one row per entry.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -24,44 +24,75 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
/// state then mirrors to its redundant pair peer instead of depending on central SQL.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The round-trip is byte-identical to the EF store</b> — the enum↔string mappers and the
|
||||
/// comment serialization below are duplicated verbatim from it (it is deleted in a later
|
||||
/// task; the ported round-trip tests prove parity). The persistence rules carry over
|
||||
/// unchanged:
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>ActiveState is NOT persisted</b> — there is no column; on <see cref="LoadAsync"/> it is
|
||||
/// restored as <see cref="AlarmActiveState.Inactive"/> and the engine re-derives it from the
|
||||
/// live predicate. <b>LastTransitionUtc ↔ updated_at_utc</b>: no dedicated transition column,
|
||||
/// so the last transition rides the row-write timestamp. <b>LastActiveUtc / LastClearedUtc</b>
|
||||
/// have no columns and default to <c>null</c> on load. <see cref="AlarmConditionState.Comments"/>
|
||||
/// serializes to/from <c>comments_json</c>; an empty list round-trips as <c>"[]"</c>.
|
||||
/// have no columns and default to <c>null</c> on load.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Save is a single unconditional PK upsert.</b> No application-level last-write-wins or
|
||||
/// HLC comparison is layered on top — the LocalDb replication HLC handles convergence, and an
|
||||
/// unconditional upsert keyed by the primary key is HLC-safe (the same discipline the
|
||||
/// alarm-sf sink and the deployment pointer follow).
|
||||
/// <b>The comment trail is written incrementally (review R05:4).</b> Each save writes only
|
||||
/// the entries the table does not yet hold, so a transition's write — and its replication
|
||||
/// payload — is constant however long the history is; the condition row's
|
||||
/// <c>comments_json</c> is always written as <c>"[]"</c>. What is "already stored" is read
|
||||
/// from the table inside the save's transaction (a bounded read of the trail's tail) rather
|
||||
/// than cached, so the store holds no per-alarm state. The pair peer's copy of the same
|
||||
/// command is recognised and not duplicated; a local entry that collides with a peer entry
|
||||
/// this node never applied is appended after it with a Warning, never dropped — see
|
||||
/// <c>AppendNewCommentsAsync</c> and, for why the ordinal is the key,
|
||||
/// <see cref="AlarmConditionStateSchema"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Remove vs. a lagging peer.</b> <see cref="RemoveAsync"/> deletes the whole trail; if the
|
||||
/// pair peer has not yet applied the same deploy, its next save finds no stored trail and
|
||||
/// re-inserts every entry, which replicates back. The next deploy removes them again — the
|
||||
/// same resurrection class the single-row store had, with n+1 rows instead of one (review m3).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Old-shape rows load exactly as before.</b> A row still carrying an inline
|
||||
/// <c>comments_json</c> trail (written by the pre-split store and not yet moved by
|
||||
/// <see cref="AlarmConditionStateSchema.MigrateInlineComments"/>) is read through the same
|
||||
/// parser the old store used, merged by ordinal with whatever the comment table holds (the
|
||||
/// table wins an ordinal both hold), and moved to the table on that alarm's next save.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Save is one transaction:</b> the comment append and an unconditional PK upsert of the
|
||||
/// condition row. No application-level last-write-wins or HLC comparison is layered on top —
|
||||
/// the LocalDb replication HLC handles convergence.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
// Fixed column order shared by every read — the reader map below indexes by these ordinals.
|
||||
private const string SelectColumns =
|
||||
"scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state, " +
|
||||
"shelving_expires_utc, last_ack_user, last_ack_comment, last_ack_utc, " +
|
||||
"last_confirm_user, last_confirm_comment, last_confirm_utc, comments_json, updated_at_utc";
|
||||
|
||||
private const string SelectCommentColumns = "scripted_alarm_id, seq, timestamp_utc, user_name, kind, text";
|
||||
|
||||
/// <summary>
|
||||
/// How many trailing in-memory entries each save reconciles against the stored trail by
|
||||
/// content. The engine appends at most one entry per save, so this comfortably covers a few
|
||||
/// missed commands while keeping each save's read bounded.
|
||||
/// </summary>
|
||||
private const int MatchWindow = 16;
|
||||
|
||||
/// <summary>
|
||||
/// How far apart two nodes' timestamps for the SAME command may be and still count as one
|
||||
/// entry: command fan-out latency plus inter-node clock skew. Beyond it both copies are kept
|
||||
/// (the safe direction — a duplicate, never a loss).
|
||||
/// </summary>
|
||||
private static readonly TimeSpan SameCommandSkew = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly ILocalDb _db;
|
||||
private readonly ILogger<LocalDbAlarmConditionStateStore> _logger;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="LocalDbAlarmConditionStateStore"/> class.</summary>
|
||||
/// <param name="db">
|
||||
/// The node's local database. Its <see cref="AlarmConditionStateSchema.StateTable"/> table
|
||||
/// must already exist and be registered for replication — the host does both in
|
||||
/// <c>LocalDbSetup.OnReady</c>, before any consumer can resolve this store.
|
||||
/// The node's local database. Its condition-state and comment tables must already exist and
|
||||
/// be registered for replication — the host does both in <c>LocalDbSetup.OnReady</c>, before
|
||||
/// any consumer can resolve this store.
|
||||
/// </param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public LocalDbAlarmConditionStateStore(ILocalDb db, ILogger<LocalDbAlarmConditionStateStore> logger)
|
||||
@@ -79,7 +110,15 @@ public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
|
||||
MapRow,
|
||||
new { Id = alarmId },
|
||||
ct).ConfigureAwait(false);
|
||||
return rows.Count > 0 ? rows[0] : null;
|
||||
if (rows.Count == 0) return null;
|
||||
|
||||
var comments = await _db.QueryAsync(
|
||||
$"SELECT {SelectCommentColumns} FROM alarm_condition_comment WHERE scripted_alarm_id = @Id ORDER BY seq",
|
||||
MapComment,
|
||||
new { Id = alarmId },
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
return WithComments(rows[0], comments);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -90,7 +129,19 @@ public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
|
||||
MapRow,
|
||||
parameters: null,
|
||||
ct).ConfigureAwait(false);
|
||||
return rows;
|
||||
|
||||
var comments = await _db.QueryAsync(
|
||||
$"SELECT {SelectCommentColumns} FROM alarm_condition_comment ORDER BY scripted_alarm_id, seq",
|
||||
MapComment,
|
||||
parameters: null,
|
||||
ct).ConfigureAwait(false);
|
||||
var byAlarm = comments
|
||||
.GroupBy(c => c.AlarmId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyList<StoredComment>)g.ToList(), StringComparer.Ordinal);
|
||||
|
||||
return rows
|
||||
.Select(r => WithComments(r, byAlarm.TryGetValue(r.State.AlarmId, out var c) ? c : []))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -98,10 +149,15 @@ public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
|
||||
await using var tx = await _db.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await AppendNewCommentsAsync(tx, state, ct).ConfigureAwait(false);
|
||||
|
||||
// A single unconditional upsert keyed on the alarm identity. Convergence across the pair is
|
||||
// last-writer-wins over the primary key, handled by the LocalDb replication HLC — no
|
||||
// app-level LWW here (see the class remarks).
|
||||
await _db.ExecuteAsync(
|
||||
// app-level LWW here (see the class remarks). comments_json is always "[]": the trail lives
|
||||
// in alarm_condition_comment, which is what keeps this row a fixed size.
|
||||
await tx.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO alarm_condition_state
|
||||
(scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
|
||||
@@ -140,60 +196,243 @@ public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
|
||||
LastConfirmUser = state.LastConfirmUser,
|
||||
LastConfirmComment = state.LastConfirmComment,
|
||||
LastConfirmUtc = FormatNullable(state.LastConfirmUtc),
|
||||
CommentsJson = SerializeComments(state.Comments),
|
||||
CommentsJson = AlarmConditionStateSchema.EmptyInlineComments,
|
||||
// No dedicated transition column — persist LastTransitionUtc into updated_at_utc.
|
||||
UpdatedAtUtc = Format(state.LastTransitionUtc),
|
||||
},
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogTrace("Persisted alarm-condition state for {AlarmId}", state.AlarmId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the trail entries <paramref name="state"/> holds that the table does not, writing
|
||||
/// a bounded number of rows however long the trail is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Normal case.</b> The in-memory trail and the stored trail agree position by
|
||||
/// position; the only new entries are past <c>MAX(seq)</c> and land at their own ordinal.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Divergence (review I-1).</b> If this node missed a command its peer applied, the
|
||||
/// peer's entry arrives in this table at an ordinal this node's in-memory trail does not
|
||||
/// know about, and this node's NEXT entry has an in-memory index at or below
|
||||
/// <c>MAX(seq)</c>. Positional "already stored" would silently drop it — the survivor's
|
||||
/// newest operator action, in the failover case. So the tail of the in-memory trail
|
||||
/// (the last <see cref="MatchWindow"/> entries) is matched against the stored rows from
|
||||
/// that position on, by content: same Kind, User and Text, timestamps within
|
||||
/// <see cref="SameCommandSkew"/> (each node stamps the same command with its own clock).
|
||||
/// A matched stored row is consumed, so repeated identical entries are counted, not
|
||||
/// merged. An entry with no match is new: at its own ordinal when that is past the stored
|
||||
/// trail, otherwise appended after it (<c>MAX(seq)+1</c>) with a Warning — kept, never
|
||||
/// dropped, and never silent.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why this cannot double-count.</b> Both pair nodes apply every command; the second
|
||||
/// node to save finds the first's copy among the stored rows (same Kind/User/Text, close
|
||||
/// timestamp) and consumes it instead of appending. A node's own earlier divergence-append
|
||||
/// is found the same way, so a re-save writes nothing. What it cannot tell apart: a peer
|
||||
/// entry this node missed and a byte-identical entry this node later records within the
|
||||
/// skew window — that pair is kept once. A clock skew between the nodes beyond the window
|
||||
/// errs the other way (both copies kept), never towards loss. True union semantics need an
|
||||
/// originator-assigned entry id on the alarm command (follow-up F3).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>The in-memory index is the durable ordinal.</b> Trimming
|
||||
/// <see cref="AlarmConditionState.Comments"/> in the engine without carrying a persisted
|
||||
/// base offset would make every later save look "already stored" — do not bound that list
|
||||
/// without changing this method.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task AppendNewCommentsAsync(ILocalDbTransaction tx, AlarmConditionState state, CancellationToken ct)
|
||||
{
|
||||
var comments = state.Comments;
|
||||
var count = comments.Count;
|
||||
|
||||
// An indexed MAX over the (scripted_alarm_id, seq) primary key — a read, so no replication cost.
|
||||
var max = (await tx.QueryAsync(
|
||||
"SELECT COALESCE(MAX(seq), -1) FROM alarm_condition_comment WHERE scripted_alarm_id = @Id",
|
||||
static r => r.GetInt64(0),
|
||||
new { Id = state.AlarmId },
|
||||
ct).ConfigureAwait(false))[0];
|
||||
|
||||
var windowStart = Math.Max(0, count - MatchWindow);
|
||||
|
||||
// Entries below the window that lie past the stored trail (a first save of a long trail, an
|
||||
// old-shape row's lazy move): plain appends at their own ordinal — nothing is stored there.
|
||||
for (var seq = (int)Math.Max(max + 1, 0); seq < windowStart; seq++)
|
||||
{
|
||||
await InsertCommentAsync(tx, state.AlarmId, seq, comments[seq], ct).ConfigureAwait(false);
|
||||
max = seq;
|
||||
}
|
||||
|
||||
if (windowStart >= count) return;
|
||||
|
||||
// The stored rows the window can match: ordinal >= windowStart. Bounded by the window plus the
|
||||
// number of commands this node missed.
|
||||
var stored = max >= windowStart
|
||||
? await tx.QueryAsync(
|
||||
$"SELECT {SelectCommentColumns} FROM alarm_condition_comment WHERE scripted_alarm_id = @Id AND seq >= @Lo ORDER BY seq",
|
||||
MapComment,
|
||||
new { Id = state.AlarmId, Lo = windowStart },
|
||||
ct).ConfigureAwait(false)
|
||||
: [];
|
||||
var consumed = new bool[stored.Count];
|
||||
|
||||
for (var i = windowStart; i < count; i++)
|
||||
{
|
||||
var entry = comments[i];
|
||||
var match = -1;
|
||||
for (var j = 0; j < stored.Count; j++)
|
||||
{
|
||||
if (!consumed[j] && IsSameEntry(stored[j].Comment, entry))
|
||||
{
|
||||
match = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (match >= 0)
|
||||
{
|
||||
consumed[match] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i > max)
|
||||
{
|
||||
await InsertCommentAsync(tx, state.AlarmId, i, entry, ct).ConfigureAwait(false);
|
||||
max = i;
|
||||
continue;
|
||||
}
|
||||
|
||||
var target = max + 1;
|
||||
await InsertCommentAsync(tx, state.AlarmId, target, entry, ct).ConfigureAwait(false);
|
||||
max = target;
|
||||
_logger.LogWarning(
|
||||
"Alarm {AlarmId}: comment trail diverged from the pair peer — the {Kind} entry by {User} at in-memory "
|
||||
+ "position {Position} collides with a stored entry this node never applied (a missed command). "
|
||||
+ "Kept: appended at ordinal {Ordinal}.",
|
||||
state.AlarmId, entry.Kind, entry.User, i, target);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSameEntry(AlarmComment stored, AlarmComment local) =>
|
||||
string.Equals(stored.Kind, local.Kind, StringComparison.Ordinal)
|
||||
&& string.Equals(stored.User, local.User, StringComparison.Ordinal)
|
||||
&& string.Equals(stored.Text, local.Text, StringComparison.Ordinal)
|
||||
&& (stored.TimestampUtc - local.TimestampUtc).Duration() <= SameCommandSkew;
|
||||
|
||||
private static Task<int> InsertCommentAsync(
|
||||
ILocalDbTransaction tx, string alarmId, long seq, AlarmComment c, CancellationToken ct) =>
|
||||
tx.ExecuteAsync(
|
||||
AlarmConditionStateSchema.InsertCommentSql,
|
||||
new
|
||||
{
|
||||
Id = alarmId,
|
||||
Seq = seq,
|
||||
// AlarmComment.TimestampUtc must be DateTimeKind.Utc for a Utc round-trip; the engine
|
||||
// always creates comments with Utc kind.
|
||||
Ts = AlarmConditionStateSchema.FormatTimestamp(c.TimestampUtc),
|
||||
User = c.User,
|
||||
Kind = c.Kind,
|
||||
Text = c.Text,
|
||||
},
|
||||
ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RemoveAsync(string alarmId, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(alarmId);
|
||||
await _db.ExecuteAsync(
|
||||
|
||||
// The trail goes with the condition, as it did when it was a column of the row.
|
||||
await using var tx = await _db.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
await tx.ExecuteAsync(
|
||||
"DELETE FROM alarm_condition_comment WHERE scripted_alarm_id = @Id",
|
||||
new { Id = alarmId },
|
||||
ct).ConfigureAwait(false);
|
||||
await tx.ExecuteAsync(
|
||||
"DELETE FROM alarm_condition_state WHERE scripted_alarm_id = @Id",
|
||||
new { Id = alarmId },
|
||||
ct).ConfigureAwait(false);
|
||||
await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static AlarmConditionState MapRow(SqliteDataReader r) => new(
|
||||
/// <summary>A condition row as read, before its trail is attached.</summary>
|
||||
private sealed record ConditionRow(AlarmConditionState State, string InlineCommentsJson);
|
||||
|
||||
/// <summary>One <c>alarm_condition_comment</c> row.</summary>
|
||||
private sealed record StoredComment(string AlarmId, long Seq, AlarmComment Comment);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches the trail: the comment table's entries by ordinal, plus — for an old-shape row not
|
||||
/// yet migrated — any inline entry at an ordinal the table does not hold.
|
||||
/// </summary>
|
||||
private static AlarmConditionState WithComments(ConditionRow row, IReadOnlyList<StoredComment> stored)
|
||||
{
|
||||
var inline = AlarmConditionStateSchema.ParseInlineComments(row.InlineCommentsJson);
|
||||
if (inline.Count == 0)
|
||||
return row.State with { Comments = stored.Select(s => s.Comment).ToImmutableList() };
|
||||
|
||||
var merged = new SortedDictionary<long, AlarmComment>();
|
||||
foreach (var s in stored)
|
||||
merged[s.Seq] = s.Comment;
|
||||
for (var i = 0; i < inline.Count; i++)
|
||||
{
|
||||
var c = inline[i];
|
||||
merged.TryAdd(i, new AlarmComment(c.TimestampUtc, c.User, c.Kind, c.Text));
|
||||
}
|
||||
|
||||
return row.State with { Comments = merged.Values.ToImmutableList() };
|
||||
}
|
||||
|
||||
private static StoredComment MapComment(SqliteDataReader r) => new(
|
||||
AlarmId: r.GetString(0),
|
||||
Enabled: string.Equals(r.GetString(1), "Disabled", StringComparison.Ordinal)
|
||||
? AlarmEnabledState.Disabled
|
||||
: AlarmEnabledState.Enabled, // unknown string → Enabled (safe default)
|
||||
// Active is not persisted — the engine re-derives it from the predicate at startup.
|
||||
Active: AlarmActiveState.Inactive,
|
||||
Acked: string.Equals(r.GetString(2), "Acknowledged", StringComparison.Ordinal)
|
||||
? AlarmAckedState.Acknowledged
|
||||
: AlarmAckedState.Unacknowledged, // unknown string → Unacknowledged (safe default)
|
||||
Confirmed: string.Equals(r.GetString(3), "Confirmed", StringComparison.Ordinal)
|
||||
? AlarmConfirmedState.Confirmed
|
||||
: AlarmConfirmedState.Unconfirmed, // unknown string → Unconfirmed (safe default)
|
||||
Shelving: new ShelvingState(MapShelvingFromColumn(r.GetString(4)), ParseNullable(r, 5)),
|
||||
// No transition column — updated_at_utc carries the last transition timestamp.
|
||||
LastTransitionUtc: Parse(r.GetString(13)),
|
||||
// LastActiveUtc / LastClearedUtc have no columns — they re-derive with Active, so null on load.
|
||||
LastActiveUtc: null,
|
||||
LastClearedUtc: null,
|
||||
LastAckUtc: ParseNullable(r, 8),
|
||||
LastAckUser: r.IsDBNull(6) ? null : r.GetString(6),
|
||||
LastAckComment: r.IsDBNull(7) ? null : r.GetString(7),
|
||||
LastConfirmUtc: ParseNullable(r, 11),
|
||||
LastConfirmUser: r.IsDBNull(9) ? null : r.GetString(9),
|
||||
LastConfirmComment: r.IsDBNull(10) ? null : r.GetString(10),
|
||||
Comments: DeserializeComments(r.GetString(12)));
|
||||
Seq: r.GetInt64(1),
|
||||
Comment: new AlarmComment(
|
||||
AlarmConditionStateSchema.ParseTimestamp(r.GetString(2)),
|
||||
r.GetString(3),
|
||||
r.GetString(4),
|
||||
r.GetString(5)));
|
||||
|
||||
private static ConditionRow MapRow(SqliteDataReader r) => new(
|
||||
new AlarmConditionState(
|
||||
AlarmId: r.GetString(0),
|
||||
Enabled: string.Equals(r.GetString(1), "Disabled", StringComparison.Ordinal)
|
||||
? AlarmEnabledState.Disabled
|
||||
: AlarmEnabledState.Enabled, // unknown string → Enabled (safe default)
|
||||
// Active is not persisted — the engine re-derives it from the predicate at startup.
|
||||
Active: AlarmActiveState.Inactive,
|
||||
Acked: string.Equals(r.GetString(2), "Acknowledged", StringComparison.Ordinal)
|
||||
? AlarmAckedState.Acknowledged
|
||||
: AlarmAckedState.Unacknowledged, // unknown string → Unacknowledged (safe default)
|
||||
Confirmed: string.Equals(r.GetString(3), "Confirmed", StringComparison.Ordinal)
|
||||
? AlarmConfirmedState.Confirmed
|
||||
: AlarmConfirmedState.Unconfirmed, // unknown string → Unconfirmed (safe default)
|
||||
Shelving: new ShelvingState(MapShelvingFromColumn(r.GetString(4)), ParseNullable(r, 5)),
|
||||
// No transition column — updated_at_utc carries the last transition timestamp.
|
||||
LastTransitionUtc: Parse(r.GetString(13)),
|
||||
// LastActiveUtc / LastClearedUtc have no columns — they re-derive with Active, so null on load.
|
||||
LastActiveUtc: null,
|
||||
LastClearedUtc: null,
|
||||
LastAckUtc: ParseNullable(r, 8),
|
||||
LastAckUser: r.IsDBNull(6) ? null : r.GetString(6),
|
||||
LastAckComment: r.IsDBNull(7) ? null : r.GetString(7),
|
||||
LastConfirmUtc: ParseNullable(r, 11),
|
||||
LastConfirmUser: r.IsDBNull(9) ? null : r.GetString(9),
|
||||
LastConfirmComment: r.IsDBNull(10) ? null : r.GetString(10),
|
||||
Comments: ImmutableList<AlarmComment>.Empty),
|
||||
InlineCommentsJson: r.GetString(12));
|
||||
|
||||
// Timestamps are stored as round-trip ("O") TEXT so lexicographic order stays chronological and
|
||||
// the DateTimeKind.Utc round-trips exactly.
|
||||
private static string Format(DateTime value) => value.ToString("O", CultureInfo.InvariantCulture);
|
||||
private static string Format(DateTime value) => AlarmConditionStateSchema.FormatTimestamp(value);
|
||||
|
||||
private static string? FormatNullable(DateTime? value) => value?.ToString("O", CultureInfo.InvariantCulture);
|
||||
private static string? FormatNullable(DateTime? value) => value is { } v ? Format(v) : null;
|
||||
|
||||
private static DateTime Parse(string value) =>
|
||||
DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
|
||||
private static DateTime Parse(string value) => AlarmConditionStateSchema.ParseTimestamp(value);
|
||||
|
||||
private static DateTime? ParseNullable(SqliteDataReader r, int ordinal) =>
|
||||
r.IsDBNull(ordinal) ? null : Parse(r.GetString(ordinal));
|
||||
@@ -220,45 +459,4 @@ public sealed class LocalDbAlarmConditionStateStore : IAlarmStateStore
|
||||
"TimedShelved" => ShelvingKind.Timed,
|
||||
_ => ShelvingKind.Unshelved, // unknown string → Unshelved (safe default)
|
||||
};
|
||||
|
||||
private static string SerializeComments(ImmutableList<AlarmComment> comments)
|
||||
{
|
||||
if (comments.IsEmpty) return "[]";
|
||||
var dtos = comments.Select(c => new CommentDto
|
||||
{
|
||||
// AlarmComment.TimestampUtc must be DateTimeKind.Utc for correct ISO-8601 round-trip;
|
||||
// the engine always creates AlarmComment instances with Utc kind.
|
||||
TimestampUtc = c.TimestampUtc,
|
||||
User = c.User,
|
||||
Kind = c.Kind,
|
||||
Text = c.Text,
|
||||
});
|
||||
return JsonSerializer.Serialize(dtos, JsonOptions);
|
||||
}
|
||||
|
||||
private static ImmutableList<AlarmComment> DeserializeComments(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return ImmutableList<AlarmComment>.Empty;
|
||||
var dtos = JsonSerializer.Deserialize<List<CommentDto>>(json, JsonOptions);
|
||||
if (dtos is null || dtos.Count == 0) return ImmutableList<AlarmComment>.Empty;
|
||||
return dtos
|
||||
.Select(d => new AlarmComment(d.TimestampUtc, d.User ?? string.Empty, d.Kind ?? string.Empty, d.Text ?? string.Empty))
|
||||
.ToImmutableList();
|
||||
}
|
||||
|
||||
/// <summary>Stable on-disk shape for a persisted <see cref="AlarmComment"/> in <c>comments_json</c>.</summary>
|
||||
private sealed class CommentDto
|
||||
{
|
||||
/// <summary>When the comment was recorded (UTC).</summary>
|
||||
public DateTime TimestampUtc { get; set; }
|
||||
|
||||
/// <summary>Identity of the actor that wrote the comment.</summary>
|
||||
public string? User { get; set; }
|
||||
|
||||
/// <summary>Human-readable classification of the comment (Acknowledge, Confirm, …).</summary>
|
||||
public string? Kind { get; set; }
|
||||
|
||||
/// <summary>Operator-supplied or engine-generated comment text.</summary>
|
||||
public string? Text { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -34,6 +34,24 @@ public sealed class AlarmConditionStateSchemaTests
|
||||
((long)cmd.ExecuteScalar()!).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Apply also creates the append-only comment table, keyed by alarm id + ordinal.</summary>
|
||||
[Fact]
|
||||
public void Apply_creates_the_comment_table_with_a_composite_key()
|
||||
{
|
||||
using var connection = OpenInMemory();
|
||||
|
||||
AlarmConditionStateSchema.Apply(connection);
|
||||
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT name FROM pragma_table_info(@Name) WHERE pk > 0 ORDER BY pk";
|
||||
cmd.Parameters.AddWithValue("@Name", AlarmConditionStateSchema.CommentsTable);
|
||||
var pk = new List<string>();
|
||||
using (var reader = cmd.ExecuteReader())
|
||||
while (reader.Read()) pk.Add(reader.GetString(0));
|
||||
|
||||
pk.ShouldBe(["scripted_alarm_id", "seq"]);
|
||||
}
|
||||
|
||||
/// <summary>Applying twice on the same connection does not throw (CREATE TABLE IF NOT EXISTS).</summary>
|
||||
[Fact]
|
||||
public void Apply_is_idempotent()
|
||||
|
||||
+53
@@ -165,6 +165,59 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 T-7 — the connectivity probe's headers-only ping (no device-model download/parse) answers
|
||||
/// against the live Agent, and fails against a closed port (the falsifiability control).
|
||||
/// </summary>
|
||||
[Fact(Timeout = TestTimeoutMs)]
|
||||
public async Task Ping_reaches_the_live_agent_and_not_a_dead_port()
|
||||
{
|
||||
SkipIfDown();
|
||||
|
||||
using (var client = new MTConnectAgentClient(LiveOptions()))
|
||||
{
|
||||
await client.PingAsync(Ct);
|
||||
}
|
||||
|
||||
using var dead = new MTConnectAgentClient(
|
||||
new MTConnectDriverOptions { AgentUri = $"http://127.0.0.1:{ClosedLoopbackPort()}", RequestTimeoutMs = 5_000 });
|
||||
await Should.ThrowAsync<Exception>(async () => await dead.PingAsync(Ct));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 T-4 / T-5 against the live Agent: once the <c>/sample</c> stream is delivering, a read is
|
||||
/// answered (from the pump's live index) with Good values, and the stream's lag measurement — the real Agent
|
||||
/// sends <c>lastSequence</c> — is on the diagnostics bag beside the effective count.
|
||||
/// </summary>
|
||||
[Fact(Timeout = TestTimeoutMs)]
|
||||
public async Task A_delivering_stream_serves_reads_and_reports_its_lag()
|
||||
{
|
||||
SkipIfDown();
|
||||
|
||||
var ids = await NumericSampleIdsAsync();
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
Assert.Skip($"The Agent at {agent.AgentUri} is currently reporting no numeric SAMPLE value.");
|
||||
}
|
||||
|
||||
await using var live = await LiveDriverAsync(ids.Select(id => Tag(id, DriverDataType.Float64)));
|
||||
await live.Driver.SubscribeAsync([.. ids], TimeSpan.FromMilliseconds(200), Ct);
|
||||
|
||||
var deadline = DateTime.UtcNow + StreamWait;
|
||||
while (!live.Driver.GetDiagnostics().ContainsKey("sampleLag") && DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(100, Ct);
|
||||
}
|
||||
|
||||
var diag = live.Driver.GetDiagnostics();
|
||||
diag.ShouldContainKey("sampleLag", "the live stream must have delivered a measured chunk");
|
||||
diag["sampleCount"].ShouldBe("500");
|
||||
long.Parse(diag["sampleLag"], System.Globalization.CultureInfo.InvariantCulture).ShouldBeGreaterThanOrEqualTo(0);
|
||||
|
||||
var results = await live.Driver.ReadAsync([.. ids], Ct);
|
||||
results.Where(r => r.StatusCode == Good).ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
// ---- discovery: the /probe device model ----
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -20,7 +20,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It honours the seam's stream-end contract</b> (see
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync"/>): cancelling the token is the only way the
|
||||
/// <see cref="IMTConnectAgentClient.SampleAsync(long, CancellationToken)"/>): cancelling the token is the only way the
|
||||
/// enumeration ends without throwing. Closing the scripted stream via
|
||||
/// <see cref="EndStream"/> raises <see cref="MTConnectStreamEndedException"/>, exactly as the
|
||||
/// production client does when an Agent drops the connection — so a pump written against the
|
||||
@@ -178,6 +178,13 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
||||
/// </summary>
|
||||
public TaskCompletionSource? CurrentGate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, a <c>/sample</c> enumeration that is cancelled or whose client is disposed does NOT end until this
|
||||
/// source completes — a pump that cannot unwind (a wedged socket read). The deterministic way to keep a retired
|
||||
/// pump alive past the point a lifecycle restart retires it.
|
||||
/// </summary>
|
||||
public TaskCompletionSource? HoldSampleExit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Signalled the moment a <c>/current</c> request <b>lands</b> — before
|
||||
/// <see cref="CurrentGate"/> parks it. This is the deterministic "the caller is now inside
|
||||
@@ -272,6 +279,22 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
||||
return answer;
|
||||
}
|
||||
|
||||
/// <summary>Number of connectivity pings (headers-only <c>/probe</c>) issued against this client.</summary>
|
||||
public int PingCallCount => Volatile.Read(ref _pingCallCount);
|
||||
|
||||
private int _pingCallCount;
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>Same failure surface as <see cref="ProbeAsync"/> (<see cref="ProbeFailure"/>), counted separately.</remarks>
|
||||
public Task PingAsync(CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
Interlocked.Increment(ref _pingCallCount);
|
||||
|
||||
return ProbeFailure is null ? Task.CompletedTask : throw ProbeFailure;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
|
||||
{
|
||||
@@ -297,6 +320,20 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
||||
return CurrentFailure is null ? Current : throw CurrentFailure;
|
||||
}
|
||||
|
||||
/// <summary>The <c>count</c> the most recent <c>/sample</c> enumeration was opened with (null = the 2-arg overload).</summary>
|
||||
public int? LastSampleCount { get; private set; }
|
||||
|
||||
/// <summary>Every <c>count</c> a <c>/sample</c> enumeration was opened with, in order.</summary>
|
||||
public ConcurrentQueue<int> SampleCounts { get; } = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, int count, CancellationToken ct)
|
||||
{
|
||||
LastSampleCount = count;
|
||||
SampleCounts.Enqueue(count);
|
||||
return SampleAsync(from, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(
|
||||
long from, [EnumeratorCancellation] CancellationToken ct)
|
||||
@@ -330,6 +367,11 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
||||
{
|
||||
scripted = await chunks.Reader.ReadAsync(lifetime.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (HoldSampleExit is { } hold && ex is OperationCanceledException or ChannelClosedException)
|
||||
{
|
||||
await hold.Task.ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
catch (ChannelClosedException)
|
||||
{
|
||||
// Mirrors the production client: a stream that stops for any reason other than the
|
||||
@@ -377,16 +419,39 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
||||
/// </summary>
|
||||
public Action? AfterWrite { get; set; }
|
||||
|
||||
/// <summary>Every Agent write REQUEST, in order: the device and the (id, value) pairs it carried.</summary>
|
||||
public ConcurrentQueue<(string DeviceName, IReadOnlyList<KeyValuePair<string, string>> Pairs)> WriteRequests { get; } = new();
|
||||
|
||||
/// <summary>DataItem ids the Agent refuses: a request naming any of them is refused as a whole.</summary>
|
||||
public HashSet<string> RefusedDataItems { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<MTConnectWriteOutcome> WriteObservationAsync(
|
||||
string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
|
||||
string deviceName, string dataItemId, string value, CancellationToken cancellationToken) =>
|
||||
WriteObservationsAsync(deviceName, [new KeyValuePair<string, string>(dataItemId, value)], cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<MTConnectWriteOutcome> WriteObservationsAsync(
|
||||
string deviceName, IReadOnlyList<KeyValuePair<string, string>> observations, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Writes.Enqueue((deviceName, dataItemId, value));
|
||||
WriteRequests.Enqueue((deviceName, observations));
|
||||
foreach (var (dataItemId, value) in observations)
|
||||
{
|
||||
Writes.Enqueue((deviceName, dataItemId, value));
|
||||
}
|
||||
|
||||
AfterWrite?.Invoke();
|
||||
|
||||
return WriteFailure is null ? Task.FromResult(WriteOutcome) : throw WriteFailure;
|
||||
if (WriteFailure is not null)
|
||||
{
|
||||
throw WriteFailure;
|
||||
}
|
||||
|
||||
return Task.FromResult(observations.Any(o => RefusedDataItems.Contains(o.Key))
|
||||
? new MTConnectWriteOutcome(Accepted: false, AgentError: "UNSUPPORTED: refused data item")
|
||||
: WriteOutcome);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+17
@@ -108,6 +108,23 @@ public sealed class MTConnectAgentClientWriteTests
|
||||
.ShouldBe("http://stub-agent:5000/StubDevice?dev1_msg=hello");
|
||||
}
|
||||
|
||||
/// <summary>R06:T-6 — several observations on one device are one POST carrying one escaped pair each.</summary>
|
||||
[Fact]
|
||||
public async Task Several_observations_are_one_request_with_one_pair_each()
|
||||
{
|
||||
var handler = new RecordingHandler(_ => Ok());
|
||||
using var client = new MTConnectAgentClient(Options(), handler);
|
||||
|
||||
var outcome = await client.WriteObservationsAsync(
|
||||
"StubDevice",
|
||||
[new KeyValuePair<string, string>("dev1_msg", "a&b"), new KeyValuePair<string, string>("dev1_count", "7")],
|
||||
CancellationToken.None);
|
||||
|
||||
outcome.Accepted.ShouldBeTrue();
|
||||
handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri
|
||||
.ShouldBe("http://stub-agent:5000/StubDevice?dev1_msg=a%26b&dev1_count=7");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A value carrying a space, an <c>&</c> and a <c>=</c> — each of which silently ends the
|
||||
/// query pair early if it reaches the wire raw — plus a DataItem id needing the same
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:T-6 — writes were one POST per slot although the Agent's PUT surface accepts several
|
||||
/// <c>id=value</c> pairs per device. Surviving slots now go out as one request per device, with a refused
|
||||
/// multi-slot request retried slot by slot so one refused DataItem still costs only its own slot. And the health
|
||||
/// contract: an Agent answer (accepted or refused) is the Agent talking, so it advances LastSuccessfulRead.
|
||||
/// </summary>
|
||||
public sealed class MTConnectBatchedWriteTests
|
||||
{
|
||||
private const string FixtureDeviceName = "VMC-3Axis";
|
||||
private const string PosRaw = "Plant/MTConnect/dev1/Position";
|
||||
private const string CountRaw = "Plant/MTConnect/dev1/PartCount";
|
||||
private const string ProgramRaw = "Plant/MTConnect/dev1/Program";
|
||||
private const uint Good = 0x00000000u;
|
||||
private const uint BadCommunicationError = 0x80050000u;
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static RawTagEntry Entry(string rawPath, string dataItemId) =>
|
||||
new(rawPath, $$"""{"fullName":"{{dataItemId}}"}""", WriteIdempotent: false, DeviceName: "dev1");
|
||||
|
||||
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> WritableAsync()
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(
|
||||
new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://fixture-agent:5000",
|
||||
Tags = [],
|
||||
RawTags =
|
||||
[
|
||||
Entry(PosRaw, "dev1_pos"),
|
||||
Entry(CountRaw, "dev1_partcount"),
|
||||
Entry(ProgramRaw, "dev1_program"),
|
||||
],
|
||||
Write = new MTConnectWriteOptions { Enabled = true },
|
||||
},
|
||||
"mt-t6",
|
||||
_ => client);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
return (driver, client);
|
||||
}
|
||||
|
||||
/// <summary>Three writes to three DataItems of one device are ONE Agent request carrying three pairs.</summary>
|
||||
[Fact]
|
||||
public async Task Writes_to_one_device_go_out_as_one_request()
|
||||
{
|
||||
var (driver, client) = await WritableAsync();
|
||||
|
||||
var results = await driver.WriteAsync(
|
||||
[new WriteRequest(PosRaw, 1.5), new WriteRequest(CountRaw, 7), new WriteRequest(ProgramRaw, "O1000")], Ct);
|
||||
|
||||
results.ShouldAllBe(r => r.StatusCode == Good);
|
||||
var request = client.WriteRequests.ShouldHaveSingleItem();
|
||||
request.DeviceName.ShouldBe(FixtureDeviceName);
|
||||
request.Pairs.Select(p => (p.Key, p.Value)).ShouldBe(
|
||||
[("dev1_pos", "1.5"), ("dev1_partcount", "7"), ("dev1_program", "O1000")]);
|
||||
}
|
||||
|
||||
/// <summary>Two writes to the SAME DataItem are never merged into one request — they land in request order.</summary>
|
||||
[Fact]
|
||||
public async Task Two_writes_to_one_data_item_stay_separate_and_ordered()
|
||||
{
|
||||
var (driver, client) = await WritableAsync();
|
||||
|
||||
await driver.WriteAsync([new WriteRequest(PosRaw, 1.0), new WriteRequest(CountRaw, 3), new WriteRequest(PosRaw, 2.0)], Ct);
|
||||
|
||||
client.WriteRequests.Count.ShouldBe(2);
|
||||
client.Writes.Where(w => w.DataItemId == "dev1_pos").Select(w => w.Value).ShouldBe(["1", "2"]);
|
||||
}
|
||||
|
||||
/// <summary>A refused multi-slot request is retried per slot: only the refused DataItem's slot fails.</summary>
|
||||
[Fact]
|
||||
public async Task A_refused_batch_is_retried_per_slot_so_only_the_refused_item_fails()
|
||||
{
|
||||
var (driver, client) = await WritableAsync();
|
||||
client.RefusedDataItems.Add("dev1_partcount");
|
||||
|
||||
var results = await driver.WriteAsync(
|
||||
[new WriteRequest(PosRaw, 1.5), new WriteRequest(CountRaw, 7), new WriteRequest(ProgramRaw, "O1000")], Ct);
|
||||
|
||||
results.Select(r => r.StatusCode).ShouldBe([Good, BadCommunicationError, Good]);
|
||||
client.WriteRequests.Count.ShouldBe(4, "the batch, then one retry per slot");
|
||||
}
|
||||
|
||||
/// <summary>A refusal is a reply: it advances LastSuccessfulRead (the Agent answered).</summary>
|
||||
[Fact]
|
||||
public async Task A_refused_write_is_an_answer_and_advances_the_stamp()
|
||||
{
|
||||
var (driver, client) = await WritableAsync();
|
||||
client.RefusedDataItems.Add("dev1_pos");
|
||||
var before = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
||||
// The stamp is DateTime.UtcNow: wait (bounded) for the clock to tick past it so an advance is observable.
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow <= before && DateTime.UtcNow < deadline) await Task.Yield();
|
||||
|
||||
await driver.WriteAsync([new WriteRequest(PosRaw, 1.5)], Ct);
|
||||
|
||||
driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>Review I-3 (coordinator ruling): a write that got NO reply degrades health, with the stamp preserved.</summary>
|
||||
[Fact]
|
||||
public async Task A_write_with_no_reply_degrades_and_preserves_the_stamp()
|
||||
{
|
||||
var (driver, client) = await WritableAsync();
|
||||
var before = driver.GetHealth();
|
||||
before.State.ShouldBe(DriverState.Healthy, "positive control: Healthy before the failed write");
|
||||
client.WriteFailure = new HttpRequestException("connection refused");
|
||||
|
||||
var results = await driver.WriteAsync([new WriteRequest(PosRaw, 1.5)], Ct);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(BadCommunicationError);
|
||||
var h = driver.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Degraded);
|
||||
h.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead);
|
||||
h.LastError.ShouldNotBeNull().ShouldContain(nameof(HttpRequestException));
|
||||
}
|
||||
|
||||
/// <summary>The unanswered-write degradation is cleared by the next ANSWERED write (it cannot latch).</summary>
|
||||
[Fact]
|
||||
public async Task An_answered_write_clears_an_unanswered_write_degradation()
|
||||
{
|
||||
var (driver, client) = await WritableAsync();
|
||||
client.WriteFailure = new HttpRequestException("connection refused");
|
||||
await driver.WriteAsync([new WriteRequest(PosRaw, 1.5)], Ct);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
client.WriteFailure = null;
|
||||
await driver.WriteAsync([new WriteRequest(PosRaw, 2.5)], Ct);
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>…and by the next good READ (the other direction of the shared health writer).</summary>
|
||||
[Fact]
|
||||
public async Task A_good_read_clears_an_unanswered_write_degradation()
|
||||
{
|
||||
var (driver, client) = await WritableAsync();
|
||||
client.WriteFailure = new HttpRequestException("connection refused");
|
||||
await driver.WriteAsync([new WriteRequest(PosRaw, 1.5)], Ct);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
(await driver.ReadAsync([PosRaw], Ct)).Single().StatusCode.ShouldBe(Good);
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>The converse: a failed READ is not cleared by an answered write (each path clears only what it proves).</summary>
|
||||
[Fact]
|
||||
public async Task An_answered_write_does_not_clear_a_failed_read()
|
||||
{
|
||||
var (driver, client) = await WritableAsync();
|
||||
client.CurrentFailure = new HttpRequestException("connection refused");
|
||||
await driver.ReadAsync([PosRaw], Ct);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
await driver.WriteAsync([new WriteRequest(PosRaw, 2.5)], Ct);
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// r2 N-5 (coordinator ruling): on a subscription-only driver an unanswered write degrades, and the next delivered
|
||||
/// /sample chunk restores Healthy (a delivered chunk proves the Agent is alive). Positive control: with no chunk
|
||||
/// it stays Degraded.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_delivered_chunk_clears_an_unanswered_write_and_without_one_it_stays_degraded()
|
||||
{
|
||||
var (driver, client) = await WritableAsync();
|
||||
try
|
||||
{
|
||||
await driver.SubscribeAsync([PosRaw], TimeSpan.FromMilliseconds(50), Ct);
|
||||
await client.PumpAsync(MTConnectPumpGenerationChunks.Pos(108, 113, "1.5")).WaitAsync(TimeSpan.FromSeconds(30), Ct);
|
||||
client.WriteFailure = new HttpRequestException("connection refused");
|
||||
await driver.WriteAsync([new WriteRequest(PosRaw, 9.5)], Ct);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
|
||||
// Positive control: nothing else happens — it stays Degraded (no read, no write, no chunk).
|
||||
var currentCalls = client.CurrentCallCount;
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
client.CurrentCallCount.ShouldBe(currentCalls);
|
||||
|
||||
await client.PumpAsync(MTConnectPumpGenerationChunks.Pos(113, 118, "2.5")).WaitAsync(TimeSpan.FromSeconds(30), Ct);
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy, "a delivered chunk proves the Agent is alive");
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.WriteFailure = null;
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-14
@@ -95,14 +95,11 @@ public sealed class MTConnectDriverOptionsTests
|
||||
var pos = new MTConnectTagDefinition(
|
||||
FullName: "dev1_pos",
|
||||
DriverDataType: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float64,
|
||||
MtCategory: "SAMPLE",
|
||||
MtType: "POSITION",
|
||||
Units: "MILLIMETER");
|
||||
MtCategory: "SAMPLE");
|
||||
var partCount = new MTConnectTagDefinition(
|
||||
FullName: "dev1_partcount",
|
||||
DriverDataType: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int64,
|
||||
MtCategory: "EVENT",
|
||||
MtType: "PART_COUNT");
|
||||
MtCategory: "EVENT");
|
||||
|
||||
var o = new MTConnectDriverOptions { AgentUri = "http://agent:5000", Tags = [pos, partCount] };
|
||||
|
||||
@@ -111,25 +108,47 @@ public sealed class MTConnectDriverOptionsTests
|
||||
o.Tags[1].ShouldBe(partCount);
|
||||
}
|
||||
|
||||
/// <summary>MTConnectTagDefinition round-trips its positional fields, including MTConnect metadata.</summary>
|
||||
/// <summary>MTConnectTagDefinition round-trips its positional fields, including the category the write path reads.</summary>
|
||||
[Fact]
|
||||
public void TagDefinition_carries_dataItemId_and_mtconnect_metadata()
|
||||
public void TagDefinition_carries_dataItemId_and_mtconnect_category()
|
||||
{
|
||||
var tag = new MTConnectTagDefinition(
|
||||
FullName: "dtop_2",
|
||||
DriverDataType: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float64,
|
||||
MtCategory: "SAMPLE",
|
||||
MtType: "POSITION",
|
||||
MtSubType: "ACTUAL",
|
||||
Units: "MILLIMETER");
|
||||
MtCategory: "SAMPLE");
|
||||
|
||||
tag.FullName.ShouldBe("dtop_2");
|
||||
tag.DriverDataType.ShouldBe(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float64);
|
||||
tag.IsArray.ShouldBeFalse();
|
||||
tag.ArrayDim.ShouldBe(0);
|
||||
tag.MtCategory.ShouldBe("SAMPLE");
|
||||
tag.MtType.ShouldBe("POSITION");
|
||||
tag.MtSubType.ShouldBe("ACTUAL");
|
||||
tag.Units.ShouldBe("MILLIMETER");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 T-3: mtType / mtSubType / units were deleted from the definition as dead, but a stored
|
||||
/// config that still carries them — in tags[] or in a raw tag's TagConfig blob — must keep loading.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Stored_configs_carrying_the_deleted_metadata_keys_still_load()
|
||||
{
|
||||
const string config = """
|
||||
{
|
||||
"agentUri": "http://agent:5000",
|
||||
"tags": [ { "fullName": "dev1_pos", "driverDataType": "Float64", "mtCategory": "SAMPLE",
|
||||
"mtType": "POSITION", "mtSubType": "ACTUAL", "units": "MILLIMETER" } ]
|
||||
}
|
||||
""";
|
||||
|
||||
var options = MTConnectDriver.ParseOptions("mt-t3", config);
|
||||
|
||||
options.Tags.ShouldHaveSingleItem().ShouldBe(new MTConnectTagDefinition(
|
||||
"dev1_pos", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float64, MtCategory: "SAMPLE"));
|
||||
|
||||
MTConnectTagDefinitionFactory.TryFromTagConfig(
|
||||
"""{ "dataItemId": "dev1_pos", "dataType": "Float64", "mtCategory": "SAMPLE", "mtType": "POSITION", "mtSubType": "ACTUAL", "units": "MILLIMETER" }""",
|
||||
out var dataItemId, out var authored)
|
||||
.ShouldBeTrue();
|
||||
dataItemId.ShouldBe("dev1_pos");
|
||||
authored.ShouldNotBeNull().MtCategory.ShouldBe("SAMPLE");
|
||||
}
|
||||
}
|
||||
|
||||
+17
-13
@@ -385,19 +385,22 @@ public sealed class MTConnectHostAndRediscoverTests
|
||||
{
|
||||
var (_, client, ticker) = await ProbingDriverAsync();
|
||||
|
||||
// Parked before the first tick: the loop waits, THEN probes, so initialize's own /probe is
|
||||
// the only call so far.
|
||||
// Parked before the first tick: the loop waits, THEN probes, so nothing has been pinged yet.
|
||||
client.PingCallCount.ShouldBe(0);
|
||||
|
||||
await TickAsync(ticker);
|
||||
client.PingCallCount.ShouldBe(1);
|
||||
|
||||
await TickAsync(ticker);
|
||||
client.PingCallCount.ShouldBe(2);
|
||||
|
||||
await TickAsync(ticker);
|
||||
client.PingCallCount.ShouldBe(3);
|
||||
|
||||
// Review 2026-10-21 T-7: a tick is a headers-only ping — the full /probe (download + parse of the device
|
||||
// model) is still initialize's one call, never a tick's.
|
||||
client.ProbeCallCount.ShouldBe(1);
|
||||
|
||||
await TickAsync(ticker);
|
||||
client.ProbeCallCount.ShouldBe(2);
|
||||
|
||||
await TickAsync(ticker);
|
||||
client.ProbeCallCount.ShouldBe(3);
|
||||
|
||||
await TickAsync(ticker);
|
||||
client.ProbeCallCount.ShouldBe(4);
|
||||
|
||||
// …and it waits the authored interval between them, rather than a hard-coded cadence.
|
||||
ticker.LastInterval.ShouldBe(AuthoredInterval);
|
||||
}
|
||||
@@ -535,11 +538,12 @@ public sealed class MTConnectHostAndRediscoverTests
|
||||
|
||||
// The loop is provably gone (ShutdownAsync awaited it), so releasing another tick can reach
|
||||
// nobody — no probe against a disposed client, and no further waits on the scheduler.
|
||||
var calls = client.ProbeCallCount;
|
||||
var calls = client.PingCallCount;
|
||||
calls.ShouldBeGreaterThan(0, "positive control: the tick before shutdown did reach the Agent");
|
||||
var waits = ticker.Waits;
|
||||
ticker.Tick();
|
||||
|
||||
client.ProbeCallCount.ShouldBe(calls);
|
||||
client.PingCallCount.ShouldBe(calls);
|
||||
ticker.Waits.ShouldBe(waits);
|
||||
}
|
||||
|
||||
|
||||
+2
-3
@@ -502,14 +502,13 @@ public sealed class MTConnectObservationIndexTests
|
||||
idx.Get("dev1_vibration_ts").StatusCode.ShouldBe(BadNotSupported);
|
||||
}
|
||||
|
||||
/// <summary>The parser carries the observation's own sampleCount / sampleRate attributes.</summary>
|
||||
/// <summary>The parser carries the observation's own sampleCount (sampleRate is not parsed — T-3: nothing read it).</summary>
|
||||
[Fact]
|
||||
public void Parser_carries_time_series_sampleCount_and_sampleRate()
|
||||
public void Parser_carries_time_series_sampleCount()
|
||||
{
|
||||
var obs = ParseFixture(CurrentFixture).Observations.Single(o => o.DataItemId == "dev1_vibration_ts");
|
||||
|
||||
obs.SampleCount.ShouldBe(10);
|
||||
obs.SampleRate.ShouldBe(100.0);
|
||||
ParseFixture(CurrentFixture).Observations.Single(o => o.DataItemId == "dev1_pos").SampleCount.ShouldBeNull();
|
||||
}
|
||||
|
||||
|
||||
+12
-3
@@ -37,6 +37,7 @@ public sealed class MTConnectPumpGenerationTests
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
Task[] retired = [];
|
||||
|
||||
var holdStalePump = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var blockOnce = 0;
|
||||
var blocking = new ManualResetEventSlim(false);
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
@@ -68,8 +69,12 @@ public sealed class MTConnectPumpGenerationTests
|
||||
(++spins).ShouldBeLessThan(500, "restart never cleared the pump slot");
|
||||
}
|
||||
|
||||
// A second subscriber arrives mid-restart: it starts a pump on the (still-live) old client.
|
||||
// A second subscriber arrives mid-restart: it starts a pump on the (still-live) old client. That pump is
|
||||
// held alive past the retire point (it cannot unwind until released), so the retention is observable
|
||||
// deterministically rather than racing the pump's own exit.
|
||||
clientA.HoldSampleExit = holdStalePump;
|
||||
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
|
||||
var staleSessionPump = driver.SampleStreamTask.ShouldNotBeNull();
|
||||
await clientA.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct);
|
||||
restart.IsCompleted.ShouldBeFalse();
|
||||
|
||||
@@ -77,9 +82,12 @@ public sealed class MTConnectPumpGenerationTests
|
||||
await restart.WaitAsync(Watchdog, Ct);
|
||||
|
||||
// The pump retired by the restart is retained so shutdown can join it (it used to be dropped
|
||||
// un-awaited and could write health after the new session was Healthy).
|
||||
// un-awaited and could write health after the new session was Healthy). Since review 2026-10-21 T-1 a
|
||||
// pump also stops by itself once it sees its session generation is gone, so it may already have
|
||||
// finished before the restart got to retire it — either way it is not left running unjoined.
|
||||
retired = driver.SupersededPumpTasks;
|
||||
retired.ShouldNotBeEmpty();
|
||||
staleSessionPump.IsCompleted.ShouldBeFalse("positive control: the stale pump is still running here");
|
||||
retired.ShouldContain(staleSessionPump, "a retired pump that is still running must be retained for shutdown to join");
|
||||
|
||||
// The restart installed a new client; the standing subscription must be streaming from IT.
|
||||
await clientB.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
|
||||
@@ -88,6 +96,7 @@ public sealed class MTConnectPumpGenerationTests
|
||||
finally
|
||||
{
|
||||
blocking.Set();
|
||||
holdStalePump.TrySetResult();
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:T-4 and T-2 on the read path.
|
||||
/// <list type="bullet">
|
||||
/// <item>T-4 — every <c>ReadAsync</c> batch fetched + parsed the whole device <c>/current</c> even while the
|
||||
/// <c>/sample</c> pump held a live, healthy index. A read now answers from that index (READ only — the pump
|
||||
/// stays its single writer) while the pump is live and not degraded, and keeps <c>/current</c> as the
|
||||
/// fallback.</item>
|
||||
/// <item>T-2 — Agent-restart detection lived only on the pump, and the read path discarded the
|
||||
/// <c>/current</c> answer's <c>instanceId</c>: a browse-only driver kept a cached <c>/probe</c> model across an
|
||||
/// Agent restart and never raised rediscovery. The fallback read now checks it.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class MTConnectReadPathTests
|
||||
{
|
||||
private const long RestartedInstance = 1655000777L;
|
||||
private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(30);
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static MTConnectDriverOptions Opts() => new()
|
||||
{
|
||||
AgentUri = "http://fixture-agent:5000",
|
||||
RequestTimeoutMs = 5000,
|
||||
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
|
||||
};
|
||||
|
||||
/// <summary>T-4: with a live, healthy pump the read is answered from its index — no /current round trip.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_with_a_live_pump_answers_from_the_index_without_a_current_call()
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(Opts(), "mt-t4", _ => client);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
try
|
||||
{
|
||||
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
|
||||
await client.PumpAsync(MTConnectPumpGenerationChunks.Pos(108, 113, "7.25")).WaitAsync(Watchdog, Ct);
|
||||
var currentCalls = client.CurrentCallCount;
|
||||
|
||||
var result = (await driver.ReadAsync(["dev1_pos"], Ct)).Single();
|
||||
|
||||
result.Value.ShouldBe(7.25d, "the read must serve the pump's newest value");
|
||||
client.CurrentCallCount.ShouldBe(currentCalls, "no /current while the pump's index is live");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Positive control for T-4: with no pump the read still issues exactly one /current.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_with_no_pump_falls_back_to_one_current_call()
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(Opts(), "mt-t4-fallback", _ => client);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
try
|
||||
{
|
||||
var currentCalls = client.CurrentCallCount;
|
||||
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
|
||||
client.CurrentCallCount.ShouldBe(currentCalls + 1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>T-4: a pump whose stream is failing does not serve reads — they fall back to /current.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_with_a_degraded_pump_falls_back_to_current()
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
// Every /sample fails: the pump stays alive (reconnecting under backoff) but its stream is degraded.
|
||||
client.SampleFailure = new IOException("connection refused");
|
||||
var options = new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://fixture-agent:5000",
|
||||
RequestTimeoutMs = 5000,
|
||||
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
|
||||
Reconnect = new MTConnectReconnectOptions { MinBackoffMs = 1000, MaxBackoffMs = 1000 },
|
||||
};
|
||||
var driver = new MTConnectDriver(options, "mt-t4-degraded", _ => client);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
try
|
||||
{
|
||||
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
|
||||
await client.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
|
||||
var deadline = DateTime.UtcNow + Watchdog;
|
||||
while (driver.GetHealth().State != DriverState.Degraded && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20, Ct);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
var currentCalls = client.CurrentCallCount;
|
||||
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
|
||||
client.CurrentCallCount.ShouldBe(currentCalls + 1, "a degraded stream's index is not trusted for reads");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>T-2: a browse-only driver's read sees the Agent restart — once — and drops the stale model.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_that_sees_a_new_instance_id_raises_rediscovery_once_and_drops_the_probe_model()
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(Opts(), "mt-t2", _ => client);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
var raised = 0;
|
||||
driver.OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref raised);
|
||||
try
|
||||
{
|
||||
driver.CachedProbeModel.ShouldNotBeNull("positive control: initialize cached the model");
|
||||
client.Current = new MTConnectStreamsResult(RestartedInstance, 5, 1, client.Current.Observations);
|
||||
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
|
||||
raised.ShouldBe(1, "once per distinct instanceId, not once per read");
|
||||
driver.CachedProbeModel.ShouldBeNull("the model describes a process that no longer exists");
|
||||
// Review m6: the session's cursor moves to the new process, so a later subscription does not open at the
|
||||
// dead instance's sequence (and flicker through a clear + re-baseline on its first chunk).
|
||||
driver.AgentInstanceId.ShouldBe(RestartedInstance);
|
||||
driver.AgentNextSequence.ShouldBe(5);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>T-2 control: reads against the same Agent process never raise rediscovery.</summary>
|
||||
[Fact]
|
||||
public async Task Reads_against_the_same_instance_raise_nothing()
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(Opts(), "mt-t2-same", _ => client);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
var raised = 0;
|
||||
driver.OnRediscoveryNeeded += (_, _) => Interlocked.Increment(ref raised);
|
||||
try
|
||||
{
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
|
||||
raised.ShouldBe(0);
|
||||
driver.CachedProbeModel.ShouldNotBeNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private const string NoDeviceError =
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
|
||||
<Header creationTime="2026-10-22T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
|
||||
<Errors><Error errorCode="NO_DEVICE">Could not find the device 'VMC-9'</Error></Errors>
|
||||
</MTConnectError>
|
||||
""";
|
||||
|
||||
private static Exception ParseFailure(string xml)
|
||||
{
|
||||
try
|
||||
{
|
||||
MTConnectStreamsParser.Parse(xml);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ex;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("expected the parse to fail");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health contract: an MTConnectError answer to /current is the Agent REFUSING (it answered) — the batch is Bad,
|
||||
/// but health is restored / kept Healthy with the stamp advanced, and the refusal is logged once, not per read.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_agent_refusal_of_current_is_a_reply_not_a_comms_fault()
|
||||
{
|
||||
var logger = new RecordingDriverLogger();
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(Opts(), "mt-refusal", _ => client, logger);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
try
|
||||
{
|
||||
// A comms fault first, so the refusal has a Degraded to restore.
|
||||
client.CurrentFailure = new HttpRequestException("connection refused");
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: a comms fault does degrade");
|
||||
var stamp = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
||||
|
||||
var refusal = ParseFailure(NoDeviceError);
|
||||
MTConnectXml.IsAgentRefusal(refusal).ShouldBeTrue();
|
||||
client.CurrentFailure = refusal;
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow <= stamp && DateTime.UtcNow < deadline) await Task.Yield();
|
||||
|
||||
var first = await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
|
||||
first.Single().StatusCode.ShouldBe(0x80050000u, "the batch is still Bad");
|
||||
var h = driver.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Healthy);
|
||||
h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(stamp);
|
||||
logger.WarningsSnapshot().Count(w => w.Contains("NO_DEVICE")).ShouldBe(1, "a persistent refusal warns once");
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.CurrentFailure = null;
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An answer that is not an MTConnect document at all is NOT marked a refusal (it stays a comms fault).</summary>
|
||||
[Fact]
|
||||
public void A_garbage_answer_is_not_an_agent_refusal() =>
|
||||
MTConnectXml.IsAgentRefusal(ParseFailure("<html><body>proxy error</body></html>")).ShouldBeFalse();
|
||||
|
||||
/// <summary>Review m7: a refusal that recurs AFTER a good read is news again and is logged again.</summary>
|
||||
[Fact]
|
||||
public async Task A_refusal_recurring_after_a_good_read_is_logged_again()
|
||||
{
|
||||
var logger = new RecordingDriverLogger();
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(Opts(), "mt-refusal-again", _ => client, logger);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
try
|
||||
{
|
||||
var refusal = ParseFailure(NoDeviceError);
|
||||
client.CurrentFailure = refusal;
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
logger.WarningsSnapshot().Count(w => w.Contains("NO_DEVICE")).ShouldBe(1, "positive control: deduplicated while it persists");
|
||||
|
||||
client.CurrentFailure = null;
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
client.CurrentFailure = refusal;
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
|
||||
logger.WarningsSnapshot().Count(w => w.Contains("NO_DEVICE")).ShouldBe(2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.CurrentFailure = null;
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:T-5 — <c>/sample</c> throughput was hard-capped at <c>SampleCount / SampleIntervalMs</c>
|
||||
/// with no lag detection: a machine producing more fell behind the Agent's ring buffer forever, re-baselining
|
||||
/// every buffer-length with every value backlog-stale and nothing surfacing it. The pump now measures the lag
|
||||
/// (<c>lastSequence + 1 − nextSequence</c>, plus chunk saturation), surfaces it on the diagnostics bag, and raises
|
||||
/// the per-stream <c>count</c> when it is persistently behind.
|
||||
/// </summary>
|
||||
public sealed class MTConnectSampleLagTests
|
||||
{
|
||||
private const long Instance = 1655000000L;
|
||||
private const long Primed = 108L;
|
||||
private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(30);
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <summary>A chunk of <paramref name="observations"/> dev1_pos values from <paramref name="first"/>.</summary>
|
||||
private static MTConnectStreamsResult Chunk(long first, int observations, long? last)
|
||||
{
|
||||
var obs = Enumerable.Range(0, observations)
|
||||
.Select(i => new MTConnectObservation(
|
||||
"dev1_pos", (first + i).ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
new DateTime(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc).AddSeconds(first + i)))
|
||||
.ToList();
|
||||
return new MTConnectStreamsResult(Instance, first + observations, first, obs, last);
|
||||
}
|
||||
|
||||
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> SubscribedDriverAsync(int sampleCount)
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(
|
||||
new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://fixture-agent:5000",
|
||||
RequestTimeoutMs = 5000,
|
||||
SampleCount = sampleCount,
|
||||
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
|
||||
},
|
||||
"mt-t5",
|
||||
_ => client);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
|
||||
await client.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
|
||||
return (driver, client);
|
||||
}
|
||||
|
||||
/// <summary>Three full chunks with a positive lag reopen the stream, at the cursor reached, with count doubled.</summary>
|
||||
[Fact]
|
||||
public async Task A_persistently_behind_stream_reopens_with_a_doubled_count()
|
||||
{
|
||||
var (driver, client) = await SubscribedDriverAsync(sampleCount: 2);
|
||||
try
|
||||
{
|
||||
await client.PumpAsync(Chunk(Primed, 2, last: 900)).WaitAsync(Watchdog, Ct);
|
||||
await client.PumpAsync(Chunk(Primed + 2, 2, last: 900)).WaitAsync(Watchdog, Ct);
|
||||
await client.PumpAsync(Chunk(Primed + 4, 2, last: 900)).WaitAsync(Watchdog, Ct);
|
||||
|
||||
await client.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct);
|
||||
client.SampleCounts.ShouldBe([2, 4]);
|
||||
client.LastSampleFrom.ShouldBe(Primed + 6, "the raised stream resumes where the behind one got to");
|
||||
|
||||
var diag = driver.GetDiagnostics();
|
||||
diag["sampleCount"].ShouldBe("4");
|
||||
diag["sampleLag"].ShouldBe((900 + 1 - (Primed + 6)).ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
diag["sampleSaturatedChunks"].ShouldBe("3");
|
||||
diag["sampleCountRaises"].ShouldBe("1");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An Agent that omits lastSequence: persistent saturation alone is enough.</summary>
|
||||
[Fact]
|
||||
public async Task Persistent_saturation_without_a_lastSequence_still_raises_the_count()
|
||||
{
|
||||
var (driver, client) = await SubscribedDriverAsync(sampleCount: 2);
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 3; i++)
|
||||
await client.PumpAsync(Chunk(Primed + (2 * i), 2, last: null)).WaitAsync(Watchdog, Ct);
|
||||
|
||||
await client.WaitForSampleCallsAsync(2).WaitAsync(Watchdog, Ct);
|
||||
client.SampleCounts.ShouldBe([2, 4]);
|
||||
driver.GetDiagnostics().ContainsKey("sampleLag").ShouldBeFalse("an unknown lag is not reported as a number");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full chunks with NO lag (the Agent happened to have exactly count observations) are not "behind": the stream
|
||||
/// is kept. Positive control: the first test, through the same pump, does reopen.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Full_chunks_with_no_lag_keep_the_stream()
|
||||
{
|
||||
var (driver, client) = await SubscribedDriverAsync(sampleCount: 2);
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
var first = Primed + (2 * i);
|
||||
await client.PumpAsync(Chunk(first, 2, last: first + 1)).WaitAsync(Watchdog, Ct);
|
||||
}
|
||||
|
||||
client.SampleCallCount.ShouldBe(1, "a caught-up stream must not be reopened");
|
||||
driver.GetDiagnostics()["sampleLag"].ShouldBe("0");
|
||||
driver.GetDiagnostics()["sampleCountRaises"].ShouldBe("0");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A raised count the Agent refuses (its stream delivers nothing) steps back to the last count that worked.</summary>
|
||||
[Fact]
|
||||
public async Task A_refused_raised_count_steps_back_to_the_last_count_that_delivered()
|
||||
{
|
||||
var (driver, client) = await SubscribedDriverAsync(sampleCount: 2);
|
||||
try
|
||||
{
|
||||
// The NEXT enumeration (the raised one) is refused the way a real Agent refuses a count above its buffer.
|
||||
// Enqueued only once the first stream has consumed a chunk — i.e. is past its own one-shot-failure check —
|
||||
// so the failure can only ever hit the raised stream (enqueueing earlier raced the first enumeration).
|
||||
await client.PumpAsync(Chunk(Primed, 2, last: 900)).WaitAsync(Watchdog, Ct);
|
||||
client.SampleFailures.Enqueue(new InvalidDataException("MTConnectError: INVALID_REQUEST count too large"));
|
||||
for (var i = 1; i < 3; i++)
|
||||
await client.PumpAsync(Chunk(Primed + (2 * i), 2, last: 900)).WaitAsync(Watchdog, Ct);
|
||||
|
||||
await client.WaitForSampleCallsAsync(3).WaitAsync(Watchdog, Ct);
|
||||
client.SampleCounts.Take(3).ShouldBe([2, 4, 2]);
|
||||
driver.GetDiagnostics()["sampleCount"].ShouldBe("2");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The ceiling is 16× the configured count, capped at 65 536, and never below the configured count.</summary>
|
||||
[Theory]
|
||||
[InlineData(1000, 16000)]
|
||||
[InlineData(10_000, 65_536)]
|
||||
[InlineData(100_000, 100_000)]
|
||||
public void The_count_ceiling_is_bounded(int configured, int expected) =>
|
||||
MTConnectDriver.SampleCountCeiling(configured).ShouldBe(expected);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:T-1 — a pump the lifecycle abandoned (its teardown wait timed out) that finished after
|
||||
/// the restart installed the NEW session published its stale cursor onto it: the old Agent's instanceId and
|
||||
/// sequence replaced the new session's, so the next pump to start treated its first chunk as an Agent restart
|
||||
/// (index cleared, a Bad flicker on every subscribed tag, a needless re-baseline). The pump now carries the
|
||||
/// session generation it was started against and its cursor publish is dropped once that generation is gone.
|
||||
/// </summary>
|
||||
public sealed class MTConnectStaleCursorTests
|
||||
{
|
||||
private const long InstanceB = 1655000099L;
|
||||
private const long NextSequenceB = 500L;
|
||||
private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(30);
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <summary>The abandoned pump's late cursor publish leaves the new session's cursor alone.</summary>
|
||||
[Fact]
|
||||
public async Task An_abandoned_pump_does_not_publish_its_cursor_onto_the_new_session()
|
||||
{
|
||||
var clientA = CannedAgentClient.FromFixtures();
|
||||
var clientB = CannedAgentClient.FromFixtures();
|
||||
clientB.Current = new MTConnectStreamsResult(InstanceB, NextSequenceB, NextSequenceB - 10, clientB.Current.Observations);
|
||||
var clients = new Queue<CannedAgentClient>([clientA, clientB]);
|
||||
|
||||
var driver = new MTConnectDriver(
|
||||
new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://fixture-agent:5000",
|
||||
RequestTimeoutMs = 5000,
|
||||
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
|
||||
},
|
||||
"mt-t1",
|
||||
_ => clients.Dequeue());
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
|
||||
var wedgeOnce = 0;
|
||||
var wedge = new ManualResetEventSlim(false);
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
driver.OnDataChange += (_, e) =>
|
||||
{
|
||||
if (e.Snapshot.Value is not 1.5d || Interlocked.Exchange(ref wedgeOnce, 1) != 0) return;
|
||||
entered.TrySetResult();
|
||||
wedge.Wait();
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
|
||||
var pumpA = driver.SampleStreamTask.ShouldNotBeNull();
|
||||
_ = clientA.PumpAsync(MTConnectPumpGenerationChunks.Pos(108, 113, "1.5"));
|
||||
await entered.Task.WaitAsync(Watchdog, Ct);
|
||||
|
||||
// The restart gives up on the wedged pump (bounded teardown wait), retires clientA and installs B.
|
||||
await driver.InitializeAsync("{}", Ct).WaitAsync(Watchdog, Ct);
|
||||
driver.AgentInstanceId.ShouldBe(InstanceB, "positive control: the new session is installed");
|
||||
pumpA.IsCompleted.ShouldBeFalse("pump A must still be alive — it is the late writer under test");
|
||||
|
||||
// Now let the abandoned pump finish: its finally publishes (instanceA, 113).
|
||||
wedge.Set();
|
||||
await pumpA.WaitAsync(Watchdog, Ct);
|
||||
|
||||
driver.AgentInstanceId.ShouldBe(InstanceB, "a superseded pump must not write the old Agent's instanceId");
|
||||
driver.AgentNextSequence.ShouldBe(NextSequenceB, "nor its cursor");
|
||||
}
|
||||
finally
|
||||
{
|
||||
wedge.Set();
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Positive control: the CURRENT pump's own cursor publish still lands — a last unsubscribe stops it, and the
|
||||
/// session's cursor moves to where that pump got to.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task The_current_pump_still_publishes_its_cursor_when_it_stops()
|
||||
{
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(
|
||||
new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://fixture-agent:5000",
|
||||
RequestTimeoutMs = 5000,
|
||||
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
|
||||
},
|
||||
"mt-t1-ok",
|
||||
_ => client);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
try
|
||||
{
|
||||
var handle = await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
|
||||
await client.PumpAsync(MTConnectPumpGenerationChunks.Pos(108, 113, "1.5")).WaitAsync(Watchdog, Ct);
|
||||
|
||||
await driver.UnsubscribeAsync(handle, Ct);
|
||||
|
||||
driver.AgentNextSequence.ShouldBe(113);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review I-1: a pump wedged inside its fan-out that comes back after the restart must not restore the NEW
|
||||
/// session's health (nor mark its stream live for the read path) while the new pump's stream is down. Positive
|
||||
/// control: the CURRENT pump's own chunk still restores Healthy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_abandoned_pump_does_not_restore_the_new_sessions_health()
|
||||
{
|
||||
var clientA = CannedAgentClient.FromFixtures();
|
||||
var clientB = CannedAgentClient.FromFixtures();
|
||||
clientB.SampleFailure = new IOException("connection refused");
|
||||
var clients = new Queue<CannedAgentClient>([clientA, clientB]);
|
||||
|
||||
var driver = new MTConnectDriver(
|
||||
new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://fixture-agent:5000",
|
||||
RequestTimeoutMs = 5000,
|
||||
Reconnect = new MTConnectReconnectOptions { MinBackoffMs = 50, MaxBackoffMs = 200 },
|
||||
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
|
||||
},
|
||||
"mt-i1",
|
||||
_ => clients.Dequeue());
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
|
||||
var wedgeOnce = 0;
|
||||
var wedge = new ManualResetEventSlim(false);
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
driver.OnDataChange += (_, e) =>
|
||||
{
|
||||
if (e.Snapshot.Value is not 1.5d || Interlocked.Exchange(ref wedgeOnce, 1) != 0) return;
|
||||
entered.TrySetResult();
|
||||
wedge.Wait();
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
|
||||
var pumpA = driver.SampleStreamTask.ShouldNotBeNull();
|
||||
_ = clientA.PumpAsync(MTConnectPumpGenerationChunks.Pos(108, 113, "1.5"));
|
||||
await entered.Task.WaitAsync(Watchdog, Ct);
|
||||
|
||||
await driver.InitializeAsync("{}", Ct).WaitAsync(Watchdog, Ct);
|
||||
|
||||
// The new session's pump fails its /sample: the driver goes Degraded.
|
||||
await clientB.WaitForSampleCallsAsync(1).WaitAsync(Watchdog, Ct);
|
||||
await WaitUntilAsync(() => driver.GetHealth().State == DriverState.Degraded, "the new stream is down");
|
||||
|
||||
wedge.Set();
|
||||
await pumpA.WaitAsync(Watchdog, Ct);
|
||||
|
||||
driver.GetHealth().State.ShouldBe(
|
||||
DriverState.Degraded, "a superseded pump must not report the new session's stream healthy");
|
||||
|
||||
// Positive control: the new session's own stream recovering does restore Healthy.
|
||||
clientB.SampleFailure = null;
|
||||
var calls = clientB.SampleCallCount;
|
||||
await clientB.WaitForSampleCallsAsync(calls + 1).WaitAsync(Watchdog, Ct);
|
||||
await clientB.PumpAsync(MTConnectPumpGenerationChunks.Pos(108, 113, "2.5")).WaitAsync(Watchdog, Ct);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
finally
|
||||
{
|
||||
wedge.Set();
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, string because)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + Watchdog;
|
||||
while (!condition() && DateTime.UtcNow < deadline) await Task.Delay(20, Ct);
|
||||
condition().ShouldBeTrue(because);
|
||||
}
|
||||
}
|
||||
@@ -572,6 +572,32 @@ public sealed class MTConnectStreamsParseTests
|
||||
await Should.ThrowAsync<HttpRequestException>(async () => await client.CurrentAsync(Ct));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- MTConnectAgentClient.PingAsync
|
||||
|
||||
/// <summary>R06:T-7 — the ping reads the status only: a body that is not a device model does not matter.</summary>
|
||||
[Fact]
|
||||
public async Task PingAsync_succeeds_on_a_success_status_without_parsing_the_body()
|
||||
{
|
||||
var handler = StubHandler.RespondingWith("this is not an MTConnectDevices document");
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
await client.PingAsync(Ct);
|
||||
|
||||
handler.LastRequestUri!.AbsoluteUri.ShouldBe("http://agent:5000/probe");
|
||||
// Positive control: the full probe DOES parse that body, and refuses it.
|
||||
await Should.ThrowAsync<Exception>(async () => await client.ProbeAsync(Ct));
|
||||
}
|
||||
|
||||
/// <summary>A non-success status is an unreachable / refusing Agent, exactly as for the full probe.</summary>
|
||||
[Fact]
|
||||
public async Task PingAsync_throws_on_a_non_success_status()
|
||||
{
|
||||
var handler = StubHandler.RespondingWith("nope", HttpStatusCode.ServiceUnavailable);
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
await Should.ThrowAsync<HttpRequestException>(async () => await client.PingAsync(Ct));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- MTConnectAgentClient.SampleAsync
|
||||
|
||||
[Fact]
|
||||
@@ -593,6 +619,38 @@ public sealed class MTConnectStreamsParseTests
|
||||
"http://agent:5000/VMC-3Axis/sample?from=108&interval=250&count=64&heartbeat=7000");
|
||||
}
|
||||
|
||||
/// <summary>R06:T-5 — the count overload sends the raised count in place of the configured one.</summary>
|
||||
[Fact]
|
||||
public async Task SampleAsync_with_a_count_sends_that_count_instead_of_the_configured_one()
|
||||
{
|
||||
var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct));
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = "http://agent:5000",
|
||||
DeviceName = "VMC-3Axis",
|
||||
SampleIntervalMs = 250,
|
||||
SampleCount = 64,
|
||||
HeartbeatMs = 7000
|
||||
});
|
||||
|
||||
await DrainAsync(client.SampleAsync(108, 256, Ct));
|
||||
|
||||
handler.LastRequestUri!.AbsoluteUri.ShouldBe(
|
||||
"http://agent:5000/VMC-3Axis/sample?from=108&interval=250&count=256&heartbeat=7000");
|
||||
}
|
||||
|
||||
/// <summary>R06:T-5 — the header's lastSequence is carried (it measures the stream's lag); absent ⇒ null.</summary>
|
||||
[Fact]
|
||||
public async Task The_header_lastSequence_is_carried_when_present_and_null_when_absent()
|
||||
{
|
||||
var current = MTConnectStreamsParser.Parse(await File.ReadAllTextAsync("Fixtures/current.xml", Ct));
|
||||
current.LastSequence.ShouldBe(107);
|
||||
|
||||
var stripped = (await File.ReadAllTextAsync("Fixtures/current.xml", Ct)).Replace("lastSequence=\"107\"", string.Empty);
|
||||
stripped.ShouldNotContain("lastSequence");
|
||||
MTConnectStreamsParser.Parse(stripped).LastSequence.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SampleAsync_yields_one_result_per_multipart_chunk()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Net;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:T-8 — the driver logged and surfaced the raw <c>AgentUri</c> (which may carry
|
||||
/// <c>user:pass@</c>) as its <c>HostName</c>, in every log line and in exception messages that reach
|
||||
/// <c>DriverHealth.LastError</c> → <c>/hosts</c>, while its own Test-Connect probe deliberately redacted it.
|
||||
/// </summary>
|
||||
public sealed class MTConnectUriRedactionTests
|
||||
{
|
||||
private const string Secret = "s3cretPw";
|
||||
private const string CredentialedUri = "http://operator:" + Secret + "@fixture-agent:5000";
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
[Theory]
|
||||
[InlineData("http://fixture-agent:5000", "http://fixture-agent:5000")] // unchanged when clean
|
||||
[InlineData("http://agent:5000/prefix", "http://agent:5000/prefix")]
|
||||
[InlineData("http://u:p@agent:5000", "http://agent:5000")]
|
||||
[InlineData("https://u:p@agent/prefix?token=abc", "https://agent/prefix")]
|
||||
[InlineData("not a uri with u:p@host", MTConnectUriRedaction.Unparseable)]
|
||||
[InlineData("not-a-uri", "not-a-uri")]
|
||||
public void The_configured_uri_loses_its_user_info(string raw, string expected) =>
|
||||
MTConnectUriRedaction.Redact(raw).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void A_request_uri_loses_its_user_info_and_keeps_its_query() =>
|
||||
MTConnectUriRedaction.Redact(new Uri("http://u:p@agent:5000/sample?from=5&count=10"))
|
||||
.ShouldBe("http://agent:5000/sample?from=5&count=10");
|
||||
|
||||
/// <summary>The host key, every log line and the health text of a credentialed driver carry no password.</summary>
|
||||
[Fact]
|
||||
public async Task A_credentialed_agent_uri_never_reaches_host_name_logs_or_health()
|
||||
{
|
||||
var logger = new RecordingDriverLogger();
|
||||
var client = CannedAgentClient.FromFixtures();
|
||||
var driver = new MTConnectDriver(
|
||||
new MTConnectDriverOptions
|
||||
{
|
||||
AgentUri = CredentialedUri,
|
||||
RequestTimeoutMs = 200,
|
||||
Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
|
||||
},
|
||||
"mt-t8",
|
||||
_ => client,
|
||||
logger);
|
||||
await driver.InitializeAsync("{}", Ct);
|
||||
try
|
||||
{
|
||||
// A /current that never answers: the read times out, degrades health and logs a Warning.
|
||||
client.CurrentGate = new TaskCompletionSource();
|
||||
await driver.ReadAsync(["dev1_pos"], Ct);
|
||||
|
||||
var health = driver.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Degraded, "positive control: the failure path that carries the URI ran");
|
||||
health.LastError.ShouldNotBeNull().ShouldNotContain(Secret);
|
||||
health.LastError.ShouldContain("fixture-agent:5000", Case.Sensitive, "the host is still named");
|
||||
|
||||
driver.HostName.ShouldBe("http://fixture-agent:5000");
|
||||
((IHostConnectivityProbe)driver).GetHostStatuses().ShouldAllBe(s => !s.HostName.Contains(Secret));
|
||||
|
||||
var lines = logger.AllSnapshot();
|
||||
lines.ShouldNotBeEmpty("positive control: the initialize line and the read warning were logged");
|
||||
lines.ShouldContain(l => l.Contains("fixture-agent:5000"));
|
||||
lines.ShouldAllBe(l => !l.Contains(Secret));
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.CurrentGate?.TrySetResult();
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The production client's own exception messages (they become health text) carry no password.</summary>
|
||||
[Fact]
|
||||
public async Task The_client_timeout_message_carries_no_password()
|
||||
{
|
||||
using var handler = new HangingHandler();
|
||||
using var client = new MTConnectAgentClient(
|
||||
new MTConnectDriverOptions { AgentUri = CredentialedUri, RequestTimeoutMs = 200 }, handler);
|
||||
|
||||
var ex = await Should.ThrowAsync<TimeoutException>(async () => await client.CurrentAsync(Ct));
|
||||
|
||||
ex.Message.ShouldContain("fixture-agent:5000");
|
||||
ex.Message.ShouldNotContain(Secret);
|
||||
}
|
||||
|
||||
/// <summary>The stream-ended exception (it becomes the pump's Degrade text) carries no password.</summary>
|
||||
[Fact]
|
||||
public async Task The_client_stream_messages_carry_no_password()
|
||||
{
|
||||
using var handler = new SingleDocumentHandler();
|
||||
using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = CredentialedUri }, handler);
|
||||
|
||||
var ex = await Should.ThrowAsync<MTConnectStreamNotSupportedException>(async () =>
|
||||
{
|
||||
await foreach (var _ in client.SampleAsync(1, Ct)) { }
|
||||
});
|
||||
|
||||
ex.Message.ShouldContain("fixture-agent:5000");
|
||||
ex.Message.ShouldNotContain(Secret);
|
||||
}
|
||||
|
||||
private sealed class HangingHandler : HttpMessageHandler
|
||||
{
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SingleDocumentHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(File.ReadAllText("Fixtures/current.xml"), System.Text.Encoding.UTF8, "text/xml"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,24 @@ internal sealed class RecordingDriverLogger : ILogger<MTConnectDriver>
|
||||
/// <summary>Formatted <see cref="LogLevel.Error"/> lines, in order.</summary>
|
||||
public List<string> Errors { get; } = [];
|
||||
|
||||
/// <summary>Formatted <see cref="LogLevel.Information"/> lines, in order.</summary>
|
||||
public List<string> Infos { get; } = [];
|
||||
|
||||
/// <summary>Every recorded line at Information and above, with its exception's message appended.</summary>
|
||||
public IReadOnlyList<string> AllSnapshot()
|
||||
{
|
||||
var all = new List<string>();
|
||||
foreach (var sink in new[] { Infos, Warnings, Errors })
|
||||
{
|
||||
lock (sink)
|
||||
{
|
||||
all.AddRange(sink);
|
||||
}
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IDisposable? BeginScope<TState>(TState state)
|
||||
where TState : notnull => null;
|
||||
@@ -39,6 +57,7 @@ internal sealed class RecordingDriverLogger : ILogger<MTConnectDriver>
|
||||
{
|
||||
LogLevel.Warning => Warnings,
|
||||
LogLevel.Error => Errors,
|
||||
LogLevel.Information => Infos,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@@ -50,7 +69,7 @@ internal sealed class RecordingDriverLogger : ILogger<MTConnectDriver>
|
||||
|
||||
lock (sink)
|
||||
{
|
||||
sink.Add(formatter(state, exception));
|
||||
sink.Add(exception is null ? formatter(state, exception) : $"{formatter(state, exception)} | {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
-3
@@ -63,10 +63,10 @@ public sealed class OpcUaClientBrowseSessionTests
|
||||
children.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>The OPC UA picker treats variables as terminal leaves, so
|
||||
/// AttributesAsync must always be empty for this driver.</summary>
|
||||
/// <summary>Review 2026-10-21 O-5: an Object (folder) has no value attributes, so AttributesAsync is empty for
|
||||
/// it. (It used to be empty for EVERYTHING — the side-panel was a stub; see the variable test below.)</summary>
|
||||
[Fact]
|
||||
public async Task AttributesAsync_is_empty_for_opcuaclient()
|
||||
public async Task AttributesAsync_is_empty_for_an_object_node()
|
||||
{
|
||||
OpcPlcProbe.SkipUnlessReachable();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
@@ -75,4 +75,25 @@ public sealed class OpcUaClientBrowseSessionTests
|
||||
var attrs = await session.AttributesAsync("nsu=http://opcfoundation.org/UA/;i=85", ct);
|
||||
attrs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 O-5 against the live server: a standard variable (Server/ServerStatus/CurrentTime — a
|
||||
/// read-only scalar UtcTime every server exposes) reports its mapped type, shape and access.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AttributesAsync_reports_a_live_variables_type_shape_and_access()
|
||||
{
|
||||
OpcPlcProbe.SkipUnlessReachable();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var browser = new OpcUaClientDriverBrowser();
|
||||
await using var session = await browser.OpenAsync(ConfigJson, ct);
|
||||
|
||||
var attrs = await session.AttributesAsync("nsu=http://opcfoundation.org/UA/;i=2258", ct);
|
||||
|
||||
var a = attrs.ShouldHaveSingleItem();
|
||||
a.Name.ShouldBe("CurrentTime");
|
||||
a.DriverDataType.ShouldBe("DateTime");
|
||||
a.IsArray.ShouldBeFalse();
|
||||
a.SecurityClass.ShouldBe("ViewOnly");
|
||||
}
|
||||
}
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
using Moq;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:O-5 — the live picker's side-panel <c>AttributesAsync</c> was a stub returning nothing,
|
||||
/// so the operator saw no DataType / array / access information for an upstream variable. It now reads the
|
||||
/// variable's attributes and maps them through the same <see cref="UpstreamAttributeMapping"/> the runtime
|
||||
/// driver's discovery uses.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientBrowseSessionAttributesTests
|
||||
{
|
||||
private const string Speed = "ns=0;s=Speed";
|
||||
|
||||
private static Mock<ISession> SessionReturning(NodeClass nodeClass, NodeId dataType, int valueRank, byte userAccess)
|
||||
{
|
||||
var mock = new Mock<ISession>(MockBehavior.Loose);
|
||||
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext());
|
||||
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||
mock.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
||||
new ReadResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [.. nodes.Select(n => n.AttributeId switch
|
||||
{
|
||||
Attributes.NodeClass => new DataValue(new Variant((int)nodeClass)),
|
||||
Attributes.DisplayName => new DataValue(new Variant(new LocalizedText("Speed"))),
|
||||
Attributes.DataType => new DataValue(new Variant(dataType)),
|
||||
Attributes.ValueRank => new DataValue(new Variant(valueRank)),
|
||||
Attributes.UserAccessLevel => new DataValue(new Variant(userAccess)),
|
||||
_ => new DataValue(StatusCodes.BadAttributeIdInvalid),
|
||||
})],
|
||||
DiagnosticInfos = [],
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static OpcUaClientBrowseSession NewSession(Mock<ISession> mock)
|
||||
=> new(mock.Object, NamespaceMap.FromTable(new NamespaceTable()), ObjectIds.ObjectsFolder);
|
||||
|
||||
/// <summary>A writable Double array variable reports Float64 / array / Operate.</summary>
|
||||
[Fact]
|
||||
public async Task A_variable_reports_its_mapped_data_type_array_shape_and_access()
|
||||
{
|
||||
var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Double, valueRank: 1, userAccess: 0x03);
|
||||
await using var session = NewSession(mock);
|
||||
|
||||
var attrs = await session.AttributesAsync(Speed, TestContext.Current.CancellationToken);
|
||||
|
||||
var a = attrs.ShouldHaveSingleItem();
|
||||
a.Name.ShouldBe("Speed");
|
||||
a.DriverDataType.ShouldBe("Float64");
|
||||
a.IsArray.ShouldBeTrue();
|
||||
a.SecurityClass.ShouldBe("Operate");
|
||||
a.IsAlarm.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A read-only scalar Int32 variable reports Int32 / scalar / ViewOnly.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_only_scalar_reports_view_only()
|
||||
{
|
||||
var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Int32, valueRank: -1, userAccess: 0x01);
|
||||
await using var session = NewSession(mock);
|
||||
|
||||
var a = (await session.AttributesAsync(Speed, TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
|
||||
|
||||
a.DriverDataType.ShouldBe("Int32");
|
||||
a.IsArray.ShouldBeFalse();
|
||||
a.SecurityClass.ShouldBe("ViewOnly");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An Object (folder) has no value attributes to show. The positive control is the variable case above,
|
||||
/// through the same mock and read.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_object_node_has_no_attributes()
|
||||
{
|
||||
var mock = SessionReturning(NodeClass.Object, DataTypeIds.Int32, valueRank: -1, userAccess: 0x01);
|
||||
await using var session = NewSession(mock);
|
||||
|
||||
(await session.AttributesAsync(Speed, TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>An unresolvable node id is refused like <c>ExpandAsync</c> does.</summary>
|
||||
[Fact]
|
||||
public async Task An_unresolvable_node_id_throws()
|
||||
{
|
||||
var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Int32, -1, 1);
|
||||
await using var session = NewSession(mock);
|
||||
|
||||
await Should.ThrowAsync<ArgumentException>(() =>
|
||||
session.AttributesAsync("nsu=urn:not-in-table;s=X", TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
private sealed class TrackingCredential : IDisposable
|
||||
{
|
||||
public int Disposed { get; private set; }
|
||||
|
||||
public void Dispose() => Disposed++;
|
||||
}
|
||||
|
||||
/// <summary>Review m9: the browse session disposes the user certificate it was handed, with itself.</summary>
|
||||
[Fact]
|
||||
public async Task The_session_disposes_the_credential_it_owns()
|
||||
{
|
||||
var mock = SessionReturning(NodeClass.Variable, DataTypeIds.Int32, -1, 1);
|
||||
var credential = new TrackingCredential();
|
||||
var session = new OpcUaClientBrowseSession(
|
||||
mock.Object, NamespaceMap.FromTable(new NamespaceTable()), ObjectIds.ObjectsFolder, credential);
|
||||
|
||||
credential.Disposed.ShouldBe(0, "positive control: still owned while the session is open");
|
||||
await session.DisposeAsync();
|
||||
await session.DisposeAsync();
|
||||
|
||||
credential.Disposed.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:O-5 — the AdminUI browse refused <c>Certificate</c> auth "in v1", so a
|
||||
/// certificate-authenticated upstream could not be browsed at all. The browse now builds its identity through
|
||||
/// the same <see cref="OpcUaUserIdentityFactory"/> the runtime driver uses (incl. the <c>secret:</c> resolution
|
||||
/// of the credential fields, fail-closed), so it presents exactly the credentials the deployed driver will.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientBrowserCertificateAuthTests
|
||||
{
|
||||
private sealed class OneSecret(string name, string value) : ISecretResolver
|
||||
{
|
||||
public Task<string?> GetAsync(SecretName secretName, CancellationToken ct) =>
|
||||
Task.FromResult<string?>(secretName.Value == name ? value : null);
|
||||
}
|
||||
|
||||
private static string WritePfx(string password)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest("CN=OpcUaClientBrowserCertAuthTests", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
|
||||
var path = Path.Combine(Path.GetTempPath(), $"opcua-browse-cert-{Guid.NewGuid():N}.pfx");
|
||||
File.WriteAllBytes(path, cert.Export(X509ContentType.Pfx, password));
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>Certificate auth is no longer refused: a missing cert path fails on the certificate itself.</summary>
|
||||
[Fact]
|
||||
public async Task OpenAsync_with_certificate_auth_fails_on_the_certificate_not_a_refusal()
|
||||
{
|
||||
var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","AuthType":"Certificate"}""";
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => new OpcUaClientDriverBrowser().OpenAsync(json, TestContext.Current.CancellationToken));
|
||||
|
||||
ex.Message.ShouldContain("UserCertificatePath");
|
||||
ex.Message.ShouldNotContain("does not support");
|
||||
}
|
||||
|
||||
/// <summary>A valid PFX whose password is a <c>secret:</c> ref resolves through the injected store.</summary>
|
||||
[Fact]
|
||||
public async Task A_certificate_identity_is_built_with_its_secret_password_resolved()
|
||||
{
|
||||
var path = WritePfx("s3cret-pw");
|
||||
try
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
AuthType = OpcUaAuthType.Certificate,
|
||||
UserCertificatePath = path,
|
||||
UserCertificatePassword = "secret:opcua/browse-cert",
|
||||
};
|
||||
|
||||
var identity = await OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(
|
||||
opts, new OneSecret("opcua/browse-cert", "s3cret-pw"), TestContext.Current.CancellationToken);
|
||||
|
||||
identity.TokenType.ShouldBe(UserTokenType.Certificate);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(path); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>With no secret store, a <c>secret:</c> ref fails closed instead of being sent as the literal.</summary>
|
||||
[Fact]
|
||||
public async Task A_secret_ref_with_no_secret_store_fails_closed()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
AuthType = OpcUaAuthType.Username,
|
||||
Username = "op",
|
||||
Password = "secret:opcua/pw",
|
||||
};
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(opts, null, TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("fail-closed");
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a literal password is used as-is (no store needed).</summary>
|
||||
[Fact]
|
||||
public async Task A_literal_username_password_needs_no_store()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Username, Username = "op", Password = "pw" };
|
||||
|
||||
var identity = await OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(opts, null, TestContext.Current.CancellationToken);
|
||||
|
||||
identity.TokenType.ShouldBe(UserTokenType.UserName);
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -41,10 +41,9 @@ public sealed class OpcUaClientDriverBrowserTests
|
||||
ex.Message.ShouldContain("null");
|
||||
}
|
||||
|
||||
/// <summary>Certificate auth is not supported by the browser; the failure message
|
||||
/// must say so explicitly rather than surfacing a downstream COM/SDK error.
|
||||
/// <c>OpcUaAuthType.Certificate</c> serializes as the numeric value 2 under the
|
||||
/// browser's default System.Text.Json options (no string-enum converter).</summary>
|
||||
/// <summary>Certificate auth with no certificate configured fails before any network I/O with a message
|
||||
/// naming the certificate (review 2026-10-21 O-5: Certificate auth is browsable now; it used to be refused),
|
||||
/// rather than surfacing a downstream SDK error. Numeric enum form (<c>OpcUaAuthType.Certificate</c> == 2).</summary>
|
||||
[Fact]
|
||||
public async Task OpenAsync_with_certificate_auth_throws_clear_message()
|
||||
{
|
||||
@@ -76,7 +75,7 @@ public sealed class OpcUaClientDriverBrowserTests
|
||||
{
|
||||
// "AuthType":"Certificate" — STRING form, as AdminUI emits.
|
||||
var json = """{"EndpointUrl":"opc.tcp://127.0.0.1:1","AuthType":"Certificate"}""";
|
||||
// The browser should reach its own Certificate-auth guard and throw
|
||||
// The browser should reach its own Certificate-identity check (no UserCertificatePath) and throw
|
||||
// InvalidOperationException with a message that mentions "Certificate".
|
||||
// Without JsonStringEnumConverter the deserialization itself throws JsonException,
|
||||
// which means this Should.ThrowAsync<InvalidOperationException> assertion fails.
|
||||
@@ -118,8 +117,10 @@ public sealed class OpcUaClientDriverBrowserTests
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Arrange: build a minimal OpcUaClientBrowseSession without a live server.
|
||||
// AttributesAsync does not call ISession — MockBehavior.Loose is safe.
|
||||
// A loose mock answers the attribute read with no response, so nothing is known about the node.
|
||||
var mockSession = new Mock<ISession>(MockBehavior.Loose);
|
||||
mockSession.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||
mockSession.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext());
|
||||
var nsMap = NamespaceMap.FromTable(new NamespaceTable());
|
||||
var sut = new OpcUaClientBrowseSession(mockSession.Object, nsMap, ObjectIds.ObjectsFolder);
|
||||
|
||||
@@ -132,7 +133,7 @@ public sealed class OpcUaClientDriverBrowserTests
|
||||
var attrs = await sut.AttributesAsync("nsu=http://opcfoundation.org/UA/;i=85", ct);
|
||||
|
||||
// Assert: LastUsedUtc must have been refreshed, and the result must be empty
|
||||
// (OPC UA picker treats variables as leaves, no attribute side-panel).
|
||||
// (a node not known to be a Variable has no value attributes to show).
|
||||
attrs.ShouldBeEmpty();
|
||||
sut.LastUsedUtc.ShouldBeGreaterThan(before,
|
||||
"AttributesAsync must refresh LastUsedUtc to satisfy the IBrowseSession contract");
|
||||
|
||||
+57
@@ -92,4 +92,61 @@ public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim)
|
||||
|
||||
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 O-9 + lifecycle generation, through the real SDK: a shutdown with several live
|
||||
/// subscriptions completes inside the one teardown budget, lands Unknown, and a re-initialise of the same
|
||||
/// instance reconnects and reads.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Shutdown_with_live_subscriptions_is_bounded_and_the_instance_reinitialises()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
|
||||
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-lifecycle");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
for (var i = 0; i < 3; i++)
|
||||
await drv.SubscribeAsync([OpcPlcProfile.FastUInt1Ref], TimeSpan.FromMilliseconds(250), ct);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
await drv.ShutdownAsync(ct);
|
||||
sw.Stop();
|
||||
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10), "one 4 s budget bounds the whole teardown");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
var read = await drv.ReadAsync([OpcPlcProfile.StepUpRef], ct);
|
||||
read.Single().StatusCode.ShouldBe(0u, "the re-initialised instance must be fully usable");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The health contract against a real server: a write the server REFUSES (a string into the UInt32 StepUp —
|
||||
/// BadTypeMismatch) is a reply — the refusal status passes through and the driver stays Healthy with its stamp
|
||||
/// advanced.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_server_refused_write_is_a_reply_and_keeps_the_driver_healthy()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
|
||||
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-refused-write");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
var before = drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow <= before && DateTime.UtcNow < deadline) await Task.Yield();
|
||||
|
||||
var result = await drv.WriteAsync([new WriteRequest(OpcPlcProfile.StepUpRef, "not-a-number")], ct);
|
||||
|
||||
(result.Single().StatusCode & 0x80000000u).ShouldNotBe(0u, "opc-plc refuses a string written into a UInt32");
|
||||
result.Single().StatusCode.ShouldNotBe(0x80050000u, "a server refusal is not a local comms error");
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Healthy);
|
||||
h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before);
|
||||
}
|
||||
}
|
||||
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
using Moq;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:O-8 — <c>AcknowledgeAsync</c> swallowed every non-cancellation transport failure, so
|
||||
/// the host logged the Part 9 ack as applied while upstream still showed the condition unacknowledged. A
|
||||
/// transport failure (no reply) now propagates with its status; a reply — even one carrying per-ack refusals —
|
||||
/// still returns normally (that is the server answering, logged per ack).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientAcknowledgeFailureTests
|
||||
{
|
||||
private const string Condition = "ns=2;s=Pump17";
|
||||
|
||||
private static Mock<ISession> NewSession()
|
||||
{
|
||||
var mock = new Mock<ISession>(MockBehavior.Loose);
|
||||
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
||||
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static AlarmAcknowledgeRequest[] Ack() => [new(Condition, Condition, "operator ack")];
|
||||
|
||||
/// <summary>A transport failure on the Acknowledge call reaches the caller with the transport status.</summary>
|
||||
[Fact]
|
||||
public async Task A_transport_failure_propagates_with_its_status()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-o8");
|
||||
var session = NewSession();
|
||||
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed));
|
||||
drv.SetSessionForTest(session.Object);
|
||||
|
||||
var ex = await Should.ThrowAsync<ServiceResultException>(() =>
|
||||
drv.AcknowledgeAsync(Ack(), TestContext.Current.CancellationToken));
|
||||
|
||||
ex.StatusCode.ShouldBe(StatusCodes.BadConnectionClosed);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: a reply returns normally and the call reached the wire.</summary>
|
||||
[Fact]
|
||||
public async Task Positive_control_a_reply_returns_normally()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-o8-ok");
|
||||
var session = NewSession();
|
||||
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CallResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [new CallMethodResult { StatusCode = StatusCodes.BadConditionBranchAlreadyAcked }],
|
||||
DiagnosticInfos = [],
|
||||
});
|
||||
drv.SetSessionForTest(session.Object);
|
||||
|
||||
await drv.AcknowledgeAsync(Ack(), TestContext.Current.CancellationToken);
|
||||
|
||||
session.Verify(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>A session lost while the ack queued on the gate fails the ack rather than reporting it applied.</summary>
|
||||
[Fact]
|
||||
public async Task A_session_lost_while_queued_fails_the_ack()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-o8-lost");
|
||||
drv.SetSessionForTest(NewSession().Object);
|
||||
|
||||
await drv.Gate.WaitAsync(ct);
|
||||
var call = drv.AcknowledgeAsync(Ack(), ct);
|
||||
drv.SetSessionForTest(null);
|
||||
drv.Gate.Release();
|
||||
|
||||
await Should.ThrowAsync<ServiceResultException>(() => call);
|
||||
}
|
||||
|
||||
private sealed class ListLogger : Microsoft.Extensions.Logging.ILogger<OpcUaClientDriver>
|
||||
{
|
||||
public List<(Microsoft.Extensions.Logging.LogLevel Level, string Message)> Entries { get; } = [];
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
|
||||
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
|
||||
TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
|
||||
private static AlarmAcknowledgeRequest[] Unresolvable() =>
|
||||
[new("nsu=urn:not-in-table;s=X", "nsu=urn:not-in-table;s=X", "ack")];
|
||||
|
||||
/// <summary>
|
||||
/// Review m3 / r1 N-1: an ack none of whose condition ids resolves sends nothing upstream and says so at Warning —
|
||||
/// but does NOT throw (a deterministic per-ack refusal must not count toward the AlarmAcknowledge breaker).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_ack_with_no_resolvable_condition_warns_and_does_not_throw()
|
||||
{
|
||||
var logger = new ListLogger();
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-m3", logger);
|
||||
var session = NewSession();
|
||||
drv.SetSessionForTest(session.Object);
|
||||
|
||||
await drv.AcknowledgeAsync(Unresolvable(), TestContext.Current.CancellationToken);
|
||||
|
||||
session.Verify(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
lock (logger.Entries)
|
||||
logger.Entries.ShouldContain(e => e.Level == Microsoft.Extensions.Logging.LogLevel.Warning && e.Message.Contains("resolved none"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// r1 N-1 through the host's real resilience pipeline: six unresolvable acks (more than the AlarmAcknowledge
|
||||
/// breaker's threshold of five) must not open the breaker — a genuine ack that follows is still delivered
|
||||
/// upstream (the positive control).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Unresolvable_acks_do_not_open_the_ack_breaker_for_real_acks()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-n1");
|
||||
var session = NewSession();
|
||||
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new CallResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [new CallMethodResult { StatusCode = StatusCodes.Good }],
|
||||
DiagnosticInfos = [],
|
||||
});
|
||||
drv.SetSessionForTest(session.Object);
|
||||
var options = new ZB.MOM.WW.OtOpcUa.Core.Resilience.DriverResilienceOptions { Tier = DriverTier.A };
|
||||
var invoker = new ZB.MOM.WW.OtOpcUa.Core.Resilience.CapabilityInvoker(
|
||||
new ZB.MOM.WW.OtOpcUa.Core.Resilience.DriverResiliencePipelineBuilder(), "opcua-n1", () => options, "OpcUaClient");
|
||||
|
||||
// Failures are tolerated here on purpose (the host logs and drops them): what is asserted is that the six do
|
||||
// not trip the breaker — a driver that THREW for them would fail the genuine ack below with an open circuit.
|
||||
for (var i = 0; i < 6; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await invoker.ExecuteAsync(DriverCapability.AlarmAcknowledge, "host",
|
||||
async c => await drv.AcknowledgeAsync(Unresolvable(), c), ct);
|
||||
}
|
||||
catch (ServiceResultException) { }
|
||||
}
|
||||
|
||||
await invoker.ExecuteAsync(DriverCapability.AlarmAcknowledge, "host",
|
||||
async c => await drv.AcknowledgeAsync(Ack(), c), ct);
|
||||
|
||||
session.Verify(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once, "the genuine ack must still reach the server");
|
||||
}
|
||||
|
||||
/// <summary>Review m4: an ack with no reply is a comms fault like a read or write — Degraded, stamp preserved.</summary>
|
||||
[Fact]
|
||||
public async Task An_ack_transport_failure_degrades_and_preserves_the_stamp()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-m4");
|
||||
var stamp = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, stamp, null));
|
||||
var session = NewSession();
|
||||
session.Setup(s => s.CallAsync(It.IsAny<RequestHeader>(), It.IsAny<CallMethodRequestCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed));
|
||||
drv.SetSessionForTest(session.Object);
|
||||
|
||||
await Should.ThrowAsync<ServiceResultException>(() => drv.AcknowledgeAsync(Ack(), TestContext.Current.CancellationToken));
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldBe(stamp);
|
||||
}
|
||||
}
|
||||
@@ -51,15 +51,17 @@ public sealed class OpcUaClientAlarmTests
|
||||
await drv.UnsubscribeAlarmsAsync(new FakeAlarmHandle(), TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AcknowledgeAsync without initialize throws InvalidOperationException.</summary>
|
||||
/// <summary>Verifies that AcknowledgeAsync without initialize throws ServiceResultException(BadNotConnected).</summary>
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_without_initialize_throws_InvalidOperationException()
|
||||
public async Task AcknowledgeAsync_without_initialize_throws_BadNotConnected()
|
||||
{
|
||||
// Review m5: the same status as a session lost while the ack queued (one "no session" answer).
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-ack-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
var ex = await Should.ThrowAsync<Opc.Ua.ServiceResultException>(async () =>
|
||||
await drv.AcknowledgeAsync(
|
||||
[new AlarmAcknowledgeRequest("ns=2;s=Src", "ns=2;s=Cond", "operator ack")],
|
||||
TestContext.Current.CancellationToken));
|
||||
ex.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadNotConnected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that AcknowledgeAsync with empty batch is noop even without init.</summary>
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
using System.Text.Json;
|
||||
using Moq;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:O-6 — one <c>SemaphoreSlim</c> 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 blocked the data plane for its full duration. Reads (and acks)
|
||||
/// keep their gate; writes and the discovery browse take none (the SDK session is thread-safe for concurrent
|
||||
/// service calls); subscription management has its own gate.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientGateSplitTests
|
||||
{
|
||||
private const string SpeedRef = "Plant/dev/Speed";
|
||||
private static readonly TimeSpan Bound = TimeSpan.FromSeconds(10);
|
||||
|
||||
private static readonly IReadOnlyList<RawTagEntry> Tags =
|
||||
[new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)];
|
||||
|
||||
private static (OpcUaClientDriver Driver, Mock<ISession> Session) NewConnectedDriver()
|
||||
{
|
||||
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-o6");
|
||||
var session = new Mock<ISession>(MockBehavior.Loose);
|
||||
session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
||||
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||
session.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
||||
new ReadResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [.. nodes.Select(_ => new DataValue(new Variant(42), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow))],
|
||||
DiagnosticInfos = [],
|
||||
});
|
||||
drv.SetSessionForTest(session.Object);
|
||||
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
|
||||
drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
|
||||
return (drv, session);
|
||||
}
|
||||
|
||||
private static async Task ReadCompletes(OpcUaClientDriver drv, string because)
|
||||
{
|
||||
var result = await drv.ReadAsync([SpeedRef], TestContext.Current.CancellationToken).WaitAsync(Bound, TestContext.Current.CancellationToken);
|
||||
result.Single().Value.ShouldBe(42, because);
|
||||
}
|
||||
|
||||
/// <summary>A write stalled on the wire does not hold up a read.</summary>
|
||||
[Fact]
|
||||
public async Task A_stalled_write_does_not_block_a_read()
|
||||
{
|
||||
var (drv, session) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
var writeOnWire = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseWrite = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (RequestHeader _, WriteValueCollection nodes, CancellationToken _) =>
|
||||
{
|
||||
writeOnWire.TrySetResult();
|
||||
await releaseWrite.Task;
|
||||
return new WriteResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [.. nodes.Select(_ => (StatusCode)StatusCodes.Good)],
|
||||
DiagnosticInfos = [],
|
||||
};
|
||||
});
|
||||
|
||||
var write = drv.WriteAsync([new ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest(SpeedRef, 7)], TestContext.Current.CancellationToken);
|
||||
await writeOnWire.Task.WaitAsync(Bound, TestContext.Current.CancellationToken);
|
||||
|
||||
await ReadCompletes(drv, "a read must not queue behind a stalled write");
|
||||
|
||||
releaseWrite.SetResult();
|
||||
(await write).Single().StatusCode.ShouldBe(0u);
|
||||
}
|
||||
|
||||
/// <summary>Subscription management queues on its own gate, not the read gate.</summary>
|
||||
[Fact]
|
||||
public async Task Subscription_management_does_not_share_the_read_gate()
|
||||
{
|
||||
var (drv, _) = NewConnectedDriver();
|
||||
using var __ = drv;
|
||||
|
||||
await drv.SubscriptionGate.WaitAsync(TestContext.Current.CancellationToken);
|
||||
var subscribe = drv.SubscribeAsync([SpeedRef], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
// Positive control: the subscribe really is parked on the subscription gate.
|
||||
subscribe.IsCompleted.ShouldBeFalse();
|
||||
await ReadCompletes(drv, "a read must not queue behind subscription management");
|
||||
|
||||
drv.SubscriptionGate.Release();
|
||||
// The mock never attaches the subscription, so the create itself throws — what matters is that it ran.
|
||||
await Should.ThrowAsync<Exception>(() => subscribe.WaitAsync(Bound, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>A discovery browse in progress does not hold the read gate.</summary>
|
||||
[Fact]
|
||||
public async Task A_discovery_browse_in_progress_does_not_block_a_read()
|
||||
{
|
||||
var (drv, session) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
var browseOnWire = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseBrowse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
session.Setup(s => s.BrowseAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<ViewDescription>(), It.IsAny<uint>(),
|
||||
It.IsAny<BrowseDescriptionCollection>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (RequestHeader _, ViewDescription _, uint _, BrowseDescriptionCollection _, CancellationToken _) =>
|
||||
{
|
||||
browseOnWire.TrySetResult();
|
||||
await releaseBrowse.Task;
|
||||
return new BrowseResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [new BrowseResult { References = [], ContinuationPoint = [] }],
|
||||
DiagnosticInfos = [],
|
||||
};
|
||||
});
|
||||
|
||||
var discover = drv.DiscoverAsync(new NullAddressSpaceBuilder(), TestContext.Current.CancellationToken);
|
||||
await browseOnWire.Task.WaitAsync(Bound, TestContext.Current.CancellationToken);
|
||||
|
||||
await ReadCompletes(drv, "a read must not queue behind a recursive discovery browse");
|
||||
|
||||
releaseBrowse.SetResult();
|
||||
await discover.WaitAsync(Bound, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
private sealed class NullAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
=> new StubHandle();
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { }
|
||||
|
||||
private sealed class StubHandle : IVariableHandle
|
||||
{
|
||||
public string FullReference => "stub";
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Moq;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:O-2 / O-9 and the lifecycle-generation contract: an initialise that throws after its
|
||||
/// session activated closes that LOCAL session; an initialise the host abandoned that finishes after the next
|
||||
/// shutdown discards its session instead of installing it; a read that outlives a shutdown does not overwrite the
|
||||
/// shutdown's health; and the whole shutdown spends one bounded budget on wire calls, issued concurrently.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientLifecycleGenerationTests
|
||||
{
|
||||
private const string SpeedRef = "Plant/dev/Speed";
|
||||
|
||||
private static readonly IReadOnlyList<RawTagEntry> Tags =
|
||||
[new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)];
|
||||
|
||||
private static OpcUaClientDriverOptions Options() =>
|
||||
new() { EndpointUrl = "opc.tcp://127.0.0.1:1", RawTags = Tags };
|
||||
|
||||
private static Mock<ISession> NewSession()
|
||||
{
|
||||
var mock = new Mock<ISession>(MockBehavior.Loose);
|
||||
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
||||
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||
return mock;
|
||||
}
|
||||
|
||||
private static ReadResponse GoodRead(ReadValueIdCollection nodes) => new()
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [.. nodes.Select(_ => new DataValue(new Variant(1), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow))],
|
||||
DiagnosticInfos = [],
|
||||
};
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, string because)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (condition()) return;
|
||||
await Task.Delay(10);
|
||||
}
|
||||
condition().ShouldBeTrue(because);
|
||||
}
|
||||
|
||||
/// <summary>O-2: a throw after the session opened (here: building the namespace map) closes + disposes it.</summary>
|
||||
[Fact]
|
||||
public async Task An_init_that_throws_after_the_session_opened_closes_and_disposes_that_session()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(Options(), "opcua-o2");
|
||||
var session = NewSession();
|
||||
session.SetupGet(s => s.NamespaceUris).Throws(new InvalidOperationException("namespace table unavailable"));
|
||||
drv.OpenSessionForTest = (_, _) => Task.FromResult(session.Object);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
|
||||
session.Verify(s => s.CloseAsync(It.IsAny<int>(), true, It.IsAny<CancellationToken>()), Times.Once);
|
||||
session.Verify(s => s.Dispose(), Times.Once, "the activated session must not be orphaned until GC");
|
||||
drv.Session.ShouldBeNull();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
/// <summary>Positive control for the init seam: an uninterrupted init installs the session and goes Healthy.</summary>
|
||||
[Fact]
|
||||
public async Task Positive_control_an_uninterrupted_init_installs_its_session()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(Options(), "opcua-gen-ok");
|
||||
var session = NewSession();
|
||||
drv.OpenSessionForTest = (_, _) => Task.FromResult(session.Object);
|
||||
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
drv.Session.ShouldBeSameAs(session.Object);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
session.Verify(s => s.Dispose(), Times.Never);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An init abandoned by the host whose connect completes AFTER the next ShutdownAsync must not install its
|
||||
/// session (it would be live with nothing to ever close it) nor overwrite the shutdown's health.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_init_superseded_by_a_shutdown_discards_its_session_and_leaves_the_shutdown_health()
|
||||
{
|
||||
var drv = new OpcUaClientDriver(Options(), "opcua-gen-superseded");
|
||||
using var _ = drv;
|
||||
var session = NewSession();
|
||||
var opened = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
drv.OpenSessionForTest = async (_, _) =>
|
||||
{
|
||||
opened.TrySetResult();
|
||||
await release.Task;
|
||||
return session.Object;
|
||||
};
|
||||
|
||||
var init = drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
await opened.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
|
||||
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
|
||||
release.SetResult();
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => init);
|
||||
|
||||
drv.Session.ShouldBeNull("a superseded init must not resurrect a session behind the shutdown");
|
||||
session.Verify(s => s.Dispose(), Times.Once);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown, "the abandoned init must not overwrite the newer health");
|
||||
}
|
||||
|
||||
/// <summary>A read whose response arrives after a shutdown does not write Healthy over the shutdown.</summary>
|
||||
[Fact]
|
||||
public async Task A_read_that_outlives_a_shutdown_does_not_overwrite_the_shutdown_health()
|
||||
{
|
||||
var (drv, session, respond) = ConnectedDriverWithParkedRead();
|
||||
using var _ = drv;
|
||||
|
||||
var read = drv.ReadAsync([SpeedRef], TestContext.Current.CancellationToken);
|
||||
await WaitForAsync(() => respond.Count == 1, "the read must reach the wire");
|
||||
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
respond.Single().SetResult();
|
||||
await read;
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: the same parked read with no shutdown in between writes Healthy + a new stamp.</summary>
|
||||
[Fact]
|
||||
public async Task Positive_control_a_read_with_no_intervening_shutdown_writes_healthy()
|
||||
{
|
||||
var (drv, _, respond) = ConnectedDriverWithParkedRead();
|
||||
using var __ = drv;
|
||||
drv.SetHealthForTest(new DriverHealth(DriverState.Degraded, new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc), "x"));
|
||||
|
||||
var read = drv.ReadAsync([SpeedRef], TestContext.Current.CancellationToken);
|
||||
await WaitForAsync(() => respond.Count == 1, "the read must reach the wire");
|
||||
respond.Single().SetResult();
|
||||
await read;
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull().Year.ShouldBeGreaterThan(2020);
|
||||
}
|
||||
|
||||
private static (OpcUaClientDriver Driver, Mock<ISession> Session, System.Collections.Concurrent.ConcurrentQueue<TaskCompletionSource> Respond)
|
||||
ConnectedDriverWithParkedRead()
|
||||
{
|
||||
var drv = new OpcUaClientDriver(Options(), "opcua-gen-read");
|
||||
var session = NewSession();
|
||||
var respond = new System.Collections.Concurrent.ConcurrentQueue<TaskCompletionSource>();
|
||||
session.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) =>
|
||||
{
|
||||
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
respond.Enqueue(gate);
|
||||
await gate.Task;
|
||||
return GoodRead(nodes);
|
||||
});
|
||||
drv.SetSessionForTest(session.Object);
|
||||
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
|
||||
drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
|
||||
return (drv, session, respond);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// O-9: against a half-dead upstream every subscription delete and the session close hang. They are issued
|
||||
/// concurrently under ONE budget, so the shutdown ends near the budget rather than N × 5 s.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Shutdown_spends_one_budget_on_concurrent_subscription_deletes_and_the_close()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(Options(), "opcua-o9");
|
||||
var session = NewSession();
|
||||
// A close that ignores its token entirely — only the driver's own abandonment can bound it.
|
||||
session.Setup(s => s.CloseAsync(It.IsAny<int>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(new TaskCompletionSource<StatusCode>().Task);
|
||||
drv.SetSessionForTest(session.Object);
|
||||
drv.TeardownBudget = TimeSpan.FromSeconds(1);
|
||||
|
||||
var inFlight = 0;
|
||||
var maxInFlight = 0;
|
||||
drv.DeleteSubscriptionAsync = async (_, ct) =>
|
||||
{
|
||||
var now = Interlocked.Increment(ref inFlight);
|
||||
InterlockedMax(ref maxInFlight, now);
|
||||
try { await Task.Delay(Timeout.Infinite, ct); }
|
||||
finally { Interlocked.Decrement(ref inFlight); }
|
||||
};
|
||||
for (var i = 0; i < 4; i++) drv.AddSubscriptionForTest([SpeedRef]);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
sw.Stop();
|
||||
|
||||
maxInFlight.ShouldBe(4, "the four deletes must be in flight together, not one after another");
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(3.5),
|
||||
"one 1 s budget bounds the whole teardown; serial deletes alone would take 4 × the budget");
|
||||
session.Verify(s => s.Dispose(), Times.Once, "the session is still disposed when the close was abandoned");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
private static void InterlockedMax(ref int target, int value)
|
||||
{
|
||||
int current;
|
||||
while ((current = Volatile.Read(ref target)) < value)
|
||||
if (Interlocked.CompareExchange(ref target, value, current) == current) return;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// r1 N-2: a subscribe that arrives while shutdown is in its delete phase must not create a subscription on the
|
||||
/// closing session (it would register after the registry was enumerated and outlive the shutdown). It waits for
|
||||
/// the teardown and then finds no session. Positive control: before the shutdown the same subscribe reaches the
|
||||
/// session.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_subscribe_during_the_shutdown_delete_phase_never_touches_the_closing_session()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(Options(), "opcua-n2");
|
||||
var session = NewSession();
|
||||
drv.SetSessionForTest(session.Object);
|
||||
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
|
||||
|
||||
// Positive control: an ordinary subscribe does reach the session (the mock then fails the create).
|
||||
await Should.ThrowAsync<Exception>(() =>
|
||||
drv.SubscribeAsync([SpeedRef], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
|
||||
session.Verify(s => s.AddSubscription(It.IsAny<Subscription>()), Times.Once);
|
||||
|
||||
var deleting = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseDelete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
drv.DeleteSubscriptionAsync = async (_, _) =>
|
||||
{
|
||||
deleting.TrySetResult();
|
||||
await releaseDelete.Task;
|
||||
};
|
||||
drv.AddSubscriptionForTest([SpeedRef]);
|
||||
|
||||
var shutdown = drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
await deleting.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
|
||||
var lateSubscribe = drv.SubscribeAsync([SpeedRef], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
releaseDelete.SetResult();
|
||||
await shutdown.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
await Should.ThrowAsync<Exception>(() => lateSubscribe.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
|
||||
|
||||
session.Verify(s => s.AddSubscription(It.IsAny<Subscription>()), Times.Once,
|
||||
"the late subscribe must not have created anything on the closing session");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// r2 R2-2: PostStop runs ShutdownAsync then DisposeAsync. With subscription management contended (a subscribe
|
||||
/// still holding the gate), the shutdown spends its budget waiting on the gate; the dispose that follows must not
|
||||
/// re-run a nested shutdown and wait all over again. Positive control: the shutdown itself did wait.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Dispose_after_a_shutdown_of_the_same_lifecycle_does_not_rewait_the_gate()
|
||||
{
|
||||
var drv = new OpcUaClientDriver(Options(), "opcua-r22") { TeardownBudget = TimeSpan.FromSeconds(3) };
|
||||
drv.SetSessionForTest(NewSession().Object);
|
||||
await drv.SubscriptionGate.WaitAsync(TestContext.Current.CancellationToken); // a "subscribe" that never finishes
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
sw.Elapsed.ShouldBeGreaterThan(TimeSpan.FromSeconds(2), "positive control: the shutdown waited on the held gate");
|
||||
|
||||
sw.Restart();
|
||||
await drv.DisposeAsync();
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2), "the dispose must not wait the 3 s gate budget a second time");
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:O-4 — <c>ProbeTimeoutSeconds</c> was authored by the AdminUI and parsed, but consumed
|
||||
/// by nothing: the Test Connect probe ran on the caller's budget alone. It now caps the probe deadline (it can
|
||||
/// shorten the caller's budget, never extend it) — the same rule as the Galaxy probe.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientProbeTimeoutTests
|
||||
{
|
||||
/// <summary>The effective deadline is the shorter of the caller's budget and the configured cap.</summary>
|
||||
[Theory]
|
||||
[InlineData(10, 30, 10)] // caller shorter → caller wins
|
||||
[InlineData(10, 2, 2)] // config shorter → config caps
|
||||
[InlineData(10, 0, 10)] // unset config → caller
|
||||
[InlineData(10, -1, 10)] // nonsense config → caller
|
||||
[InlineData(0, 5, 1)] // no caller budget → 1 s floor beats a longer config
|
||||
public void EffectiveTimeout_is_the_shorter_of_caller_and_config(int callerSeconds, int configSeconds, int expectedSeconds)
|
||||
=> OpcUaClientDriverProbe.EffectiveTimeout(TimeSpan.FromSeconds(callerSeconds), configSeconds)
|
||||
.ShouldBe(TimeSpan.FromSeconds(expectedSeconds));
|
||||
|
||||
/// <summary>
|
||||
/// Against a port that accepts TCP but never speaks OPC UA, a 1 s configured cap returns long before the
|
||||
/// caller's 30 s budget — the discovery handshake does the waiting, so this exercises the real deadline path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Configured_cap_shortens_the_probe_against_a_silent_listener()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var config = $$"""{ "endpointUrl": "opc.tcp://127.0.0.1:{{port}}", "probeTimeoutSeconds": 1 }""";
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var result = await new OpcUaClientDriverProbe().ProbeAsync(config, TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
|
||||
sw.Stop();
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(15), $"the 1 s configured cap must bound the probe (message: {result.Message})");
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
using System.Text.Json;
|
||||
using Moq;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:O-1 — a data <c>SubscribeAsync</c> whose wire create throws used to leave the
|
||||
/// half-built <see cref="Subscription"/> on the session (untracked, so neither Unsubscribe nor Shutdown
|
||||
/// ever deleted it). The host's reconcile retries every health tick, so a server that refused the create
|
||||
/// accumulated one live subscription per retry. The alarm path already cleaned up; the data path now does too.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A Moq session never attaches the subscription (<c>AddSubscription</c> is a no-op), so the SDK's
|
||||
/// <c>Subscription.CreateAsync</c> throws — the exact "create failed after AddSubscription" shape.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientSubscribeCleanupTests
|
||||
{
|
||||
private const string SpeedRef = "Plant/dev/Speed";
|
||||
|
||||
private static readonly IReadOnlyList<RawTagEntry> Tags =
|
||||
[new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)];
|
||||
|
||||
private static (OpcUaClientDriver Driver, Mock<ISession> Session) NewConnectedDriver()
|
||||
{
|
||||
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-o1");
|
||||
var session = new Mock<ISession>(MockBehavior.Loose);
|
||||
session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
||||
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||
drv.SetSessionForTest(session.Object);
|
||||
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
|
||||
return (drv, session);
|
||||
}
|
||||
|
||||
/// <summary>A failed data subscribe removes the subscription it added to the session, then rethrows.</summary>
|
||||
[Fact]
|
||||
public async Task A_failed_data_subscribe_removes_the_half_built_subscription_from_the_session()
|
||||
{
|
||||
var (drv, session) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
|
||||
await Should.ThrowAsync<Exception>(() =>
|
||||
drv.SubscribeAsync([SpeedRef], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
|
||||
|
||||
session.Verify(s => s.AddSubscription(It.IsAny<Subscription>()), Times.Once);
|
||||
session.Verify(s => s.RemoveSubscriptionAsync(It.IsAny<Subscription>(), It.IsAny<CancellationToken>()), Times.Once,
|
||||
"the subscription added before the failing create must not stay on the session");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Positive control for the observation above: the alarm path (which always cleaned up) is seen removing
|
||||
/// its subscription through the same mock, so a "Times.Once" pass on the data path is not vacuous.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Positive_control_the_alarm_path_is_observed_removing_its_half_built_subscription()
|
||||
{
|
||||
var (drv, session) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
|
||||
await Should.ThrowAsync<Exception>(() =>
|
||||
drv.SubscribeAlarmsAsync([SpeedRef], TestContext.Current.CancellationToken));
|
||||
|
||||
session.Verify(s => s.RemoveSubscriptionAsync(It.IsAny<Subscription>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>Repeated failing subscribes (the host's reconcile retry) each clean up their own subscription.</summary>
|
||||
[Fact]
|
||||
public async Task Each_retry_of_a_failing_subscribe_cleans_up_after_itself()
|
||||
{
|
||||
var (drv, session) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
await Should.ThrowAsync<Exception>(() =>
|
||||
drv.SubscribeAsync([SpeedRef], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken));
|
||||
|
||||
session.Verify(s => s.AddSubscription(It.IsAny<Subscription>()), Times.Exactly(3));
|
||||
session.Verify(s => s.RemoveSubscriptionAsync(It.IsAny<Subscription>(), It.IsAny<CancellationToken>()), Times.Exactly(3));
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
using System.Text.Json;
|
||||
using Moq;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The health contract for the write path and the steady keep-alive: a write RESPONSE — whatever per-node
|
||||
/// statuses it carries, a refusal included — is the server answering, so it restores Healthy and advances
|
||||
/// <see cref="DriverHealth.LastSuccessfulRead"/>; a write that got NO response degrades health with the stamp
|
||||
/// preserved; a caller-cancelled write is not a comms fault. A good keep-alive clears a Degraded left by a failed
|
||||
/// read/write (it used to only move the stamp, leaving a subscription-only driver Degraded forever).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientWriteHealthTests
|
||||
{
|
||||
private const string SpeedRef = "Plant/dev/Speed";
|
||||
private static readonly DateTime Stale = new(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static readonly IReadOnlyList<RawTagEntry> Tags =
|
||||
[new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)];
|
||||
|
||||
private static (OpcUaClientDriver Driver, Mock<ISession> Session) NewConnectedDriver(DriverHealth health)
|
||||
{
|
||||
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-write-health");
|
||||
var session = new Mock<ISession>(MockBehavior.Loose);
|
||||
session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context));
|
||||
session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
||||
drv.SetSessionForTest(session.Object);
|
||||
drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags));
|
||||
drv.SetHealthForTest(health);
|
||||
return (drv, session);
|
||||
}
|
||||
|
||||
private static ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest[] Write() => [new(SpeedRef, 7)];
|
||||
|
||||
/// <summary>A refused write (BadNotWritable) is a reply: Healthy again, stamp advanced.</summary>
|
||||
[Fact]
|
||||
public async Task A_refused_write_is_a_reply_and_restores_healthy()
|
||||
{
|
||||
var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Degraded, Stale, "read failed"));
|
||||
using var _ = drv;
|
||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new WriteResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [StatusCodes.BadNotWritable],
|
||||
DiagnosticInfos = [],
|
||||
});
|
||||
|
||||
var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(StatusCodes.BadNotWritable, "the upstream refusal still passes through verbatim");
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Healthy);
|
||||
h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(Stale);
|
||||
h.LastError.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>A write with no reply (transport failure) degrades health and preserves the stamp.</summary>
|
||||
[Fact]
|
||||
public async Task A_write_with_no_reply_degrades_and_preserves_the_stamp()
|
||||
{
|
||||
var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Healthy, Stale, null));
|
||||
using var _ = drv;
|
||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new ServiceResultException(StatusCodes.BadConnectionClosed));
|
||||
|
||||
var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(0x80050000u); // BadCommunicationError
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Degraded);
|
||||
h.LastSuccessfulRead.ShouldBe(Stale, "the comms-loss watchdog measures silence from this stamp");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A write the CALLER cancelled says nothing about the upstream: health is left alone. (The positive control is
|
||||
/// the test above — the same throw-path does write health when the failure is the transport's.)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_caller_cancelled_write_leaves_health_alone()
|
||||
{
|
||||
var healthy = new DriverHealth(DriverState.Healthy, Stale, null);
|
||||
var (drv, session) = NewConnectedDriver(healthy);
|
||||
using var _ = drv;
|
||||
using var cts = new CancellationTokenSource();
|
||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (RequestHeader _, WriteValueCollection _, CancellationToken ct) =>
|
||||
{
|
||||
cts.Cancel();
|
||||
await Task.Delay(Timeout.Infinite, ct);
|
||||
return new WriteResponse();
|
||||
});
|
||||
|
||||
var results = await drv.WriteAsync(Write(), cts.Token);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(0x800A0000u); // BadTimeout — outcome unknown
|
||||
drv.GetHealth().ShouldBe(healthy);
|
||||
}
|
||||
|
||||
/// <summary>A good keep-alive is a reply: it clears a Degraded a failed read left behind.</summary>
|
||||
[Fact]
|
||||
public void A_good_keep_alive_clears_a_degraded_left_by_a_failed_read()
|
||||
{
|
||||
var (drv, _) = NewConnectedDriver(new DriverHealth(DriverState.Degraded, Stale, "read: BadTimeout"));
|
||||
using var __ = drv;
|
||||
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.Good);
|
||||
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Healthy);
|
||||
h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(Stale);
|
||||
h.LastError.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review m8: a write that TIMED OUT (an OperationCanceledException the caller did not cause) is a call with no
|
||||
/// reply — Degraded, stamp preserved. The genuine positive control for the caller-cancelled test above (same
|
||||
/// catch branch, the caller's token is the only difference).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_write_that_timed_out_without_caller_cancellation_degrades()
|
||||
{
|
||||
var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Healthy, Stale, null));
|
||||
using var _ = drv;
|
||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException("SDK request timeout"));
|
||||
|
||||
var results = await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(0x800A0000u); // BadTimeout
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldBe(Stale);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review m7: a reply that was in flight across a bad keep-alive advances the stamp (it is evidence) but does not
|
||||
/// declare the published outage over — health stays Degraded until the keep-alive recovers.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_reply_during_a_published_outage_advances_the_stamp_but_keeps_degraded()
|
||||
{
|
||||
var (drv, session) = NewConnectedDriver(new DriverHealth(DriverState.Healthy, Stale, null));
|
||||
using var _ = drv;
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: the outage is published");
|
||||
session.Setup(s => s.WriteAsync(It.IsAny<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new WriteResponse { ResponseHeader = new ResponseHeader(), Results = [StatusCodes.Good], DiagnosticInfos = [] });
|
||||
|
||||
await drv.WriteAsync(Write(), TestContext.Current.CancellationToken);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(Stale);
|
||||
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.Good);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "the keep-alive recovery is what ends the outage");
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LocalDb;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.LocalDb;
|
||||
|
||||
/// <summary>
|
||||
/// Review R05:4 — the scripted-alarm comment trail moved off the condition row into its own
|
||||
/// append-only <c>alarm_condition_comment</c> table. This proves the move kept the property that
|
||||
/// mattered: the trail still reaches the redundant pair peer, over a real loopback h2c transport
|
||||
/// through the real fail-closed interceptor, with both nodes initialised by the production
|
||||
/// <c>LocalDbSetup.OnReady</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// State is written and read through the production <see cref="LocalDbAlarmConditionStateStore"/>
|
||||
/// on each node, so the ordinal keying, the incremental append and the load-side merge under
|
||||
/// test are the ones production runs.
|
||||
/// </remarks>
|
||||
[Collection("LocalDbPairConvergence")]
|
||||
public sealed class AlarmConditionCommentConvergenceTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 06, 10, 12, 00, 00, DateTimeKind.Utc);
|
||||
|
||||
private static LocalDbAlarmConditionStateStore Store(ILocalDb db) =>
|
||||
new(db, NullLogger<LocalDbAlarmConditionStateStore>.Instance);
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static async Task<int> CommentCountAsync(ILocalDb db, string alarmId) =>
|
||||
(await Store(db).LoadAsync(alarmId, Ct))?.Comments.Count ?? -1;
|
||||
|
||||
[Fact]
|
||||
public async Task CommentsWrittenOnA_ArriveOnB_AndBsAppendArrivesBackOnA()
|
||||
{
|
||||
await using var pair = new LocalDbPairHarness();
|
||||
await pair.StartAsync();
|
||||
|
||||
var storeA = Store(pair.A);
|
||||
var state = AlarmConditionState.Fresh("eq/alarm-1", T0) with
|
||||
{
|
||||
Active = AlarmActiveState.Active,
|
||||
Acked = AlarmAckedState.Unacknowledged,
|
||||
};
|
||||
state = Part9StateMachine.ApplyAddComment(state, "jane", "first look", T0.AddMinutes(1)).State;
|
||||
state = Part9StateMachine.ApplyAcknowledge(state, "bob", "on it", T0.AddMinutes(2)).State;
|
||||
await storeA.SaveAsync(state, Ct);
|
||||
|
||||
await LocalDbPairHarness.WaitUntilAsync(
|
||||
async () => await CommentCountAsync(pair.B, "eq/alarm-1") == 2,
|
||||
"node B to hold node A's two comment-trail entries");
|
||||
|
||||
var onB = await Store(pair.B).LoadAsync("eq/alarm-1", Ct);
|
||||
onB.ShouldNotBeNull();
|
||||
onB.Comments.ShouldBe(state.Comments);
|
||||
onB.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||
|
||||
// The reverse direction, as an incremental append: B adds one entry to the trail it loaded,
|
||||
// and only that entry has to travel for A to see the full trail.
|
||||
var appended = Part9StateMachine.ApplyAddComment(onB, "amy", "from B", T0.AddMinutes(3)).State;
|
||||
await Store(pair.B).SaveAsync(appended, Ct);
|
||||
|
||||
await LocalDbPairHarness.WaitUntilAsync(
|
||||
async () => await CommentCountAsync(pair.A, "eq/alarm-1") == 3,
|
||||
"node A to receive the entry node B appended");
|
||||
|
||||
(await storeA.LoadAsync("eq/alarm-1", Ct))!.Comments.ShouldBe(appended.Comments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failover_AfterAMissedCommand_BothTheMissedEntryAndTheSurvivorsNextEntry_ReachBothPeers()
|
||||
{
|
||||
// Review I-1, end to end over the real pair:
|
||||
// 1. a shared trail on both nodes;
|
||||
// 2. an AddComment reaches only node A (B's command delivery failed) — A's entry replicates
|
||||
// into B's table, but B's engine never applied it, so B's IN-MEMORY trail is one short;
|
||||
// 3. A goes away (link down) and the operator acks on the survivor B — B's in-memory Ack
|
||||
// collides with A's entry at the same ordinal;
|
||||
// 4. the pair rejoins.
|
||||
// Both entries must survive on both peers. Before the fix B's Ack was silently dropped.
|
||||
await using var pair = new LocalDbPairHarness();
|
||||
await pair.StartAsync();
|
||||
|
||||
var shared = AlarmConditionState.Fresh("eq/alarm-fo", T0) with
|
||||
{
|
||||
Active = AlarmActiveState.Active,
|
||||
Acked = AlarmAckedState.Unacknowledged,
|
||||
};
|
||||
for (var i = 0; i < 3; i++)
|
||||
shared = Part9StateMachine.ApplyAddComment(shared, "jane", $"note {i}", T0.AddMinutes(i)).State;
|
||||
await Store(pair.A).SaveAsync(shared, Ct);
|
||||
await LocalDbPairHarness.WaitUntilAsync(
|
||||
async () => await CommentCountAsync(pair.B, "eq/alarm-fo") == 3,
|
||||
"node B to hold the shared trail");
|
||||
|
||||
var onlyOnA = Part9StateMachine.ApplyAddComment(shared, "jane", "seen on A only", T0.AddHours(1)).State;
|
||||
await Store(pair.A).SaveAsync(onlyOnA, Ct);
|
||||
await LocalDbPairHarness.WaitUntilAsync(
|
||||
async () => await CommentCountAsync(pair.B, "eq/alarm-fo") == 4,
|
||||
"node A's entry to replicate into node B's table");
|
||||
|
||||
await pair.StopPassiveAsync();
|
||||
|
||||
// B's engine still holds the 3-entry trail it applied; the operator acks there.
|
||||
var ackOnB = Part9StateMachine.ApplyAcknowledge(shared, "bob", "acked on B", T0.AddHours(2)).State;
|
||||
await Store(pair.B).SaveAsync(ackOnB, Ct);
|
||||
|
||||
await pair.RestartPairAsync();
|
||||
|
||||
await LocalDbPairHarness.WaitUntilAsync(
|
||||
async () => await CommentCountAsync(pair.A, "eq/alarm-fo") == 5
|
||||
&& await CommentCountAsync(pair.B, "eq/alarm-fo") == 5,
|
||||
"both nodes to hold the shared trail + A's missed-by-B entry + B's ack");
|
||||
|
||||
var onA = (await Store(pair.A).LoadAsync("eq/alarm-fo", Ct))!;
|
||||
var onB = (await Store(pair.B).LoadAsync("eq/alarm-fo", Ct))!;
|
||||
onA.Comments.ShouldBe(onB.Comments);
|
||||
onA.Comments[3].ShouldBe(onlyOnA.Comments[3]);
|
||||
onA.Comments[4].ShouldBe(ackOnB.Comments[3]);
|
||||
|
||||
// Held, not merely reached: nothing late duplicates either entry.
|
||||
(await LocalDbPairHarness.HeldWithinAsync(
|
||||
async () => await CommentCountAsync(pair.A, "eq/alarm-fo") != 5
|
||||
|| await CommentCountAsync(pair.B, "eq/alarm-fo") != 5,
|
||||
TimeSpan.FromSeconds(2)))
|
||||
.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BothNodesRecordingTheSameEntry_ConvergeToOneEntry_NotTwo()
|
||||
{
|
||||
// Both pair nodes run the engine and both apply every operator command, each stamping the
|
||||
// entry with its own clock. Ordinal keying is what collapses the two copies to one entry;
|
||||
// a content-derived key would leave the trail doubled on both nodes.
|
||||
await using var pair = new LocalDbPairHarness();
|
||||
await pair.StartAsync();
|
||||
|
||||
var fresh = AlarmConditionState.Fresh("eq/alarm-2", T0);
|
||||
var onA = Part9StateMachine.ApplyAddComment(fresh, "jane", "checked", T0.AddSeconds(1)).State;
|
||||
var onB = Part9StateMachine.ApplyAddComment(fresh, "jane", "checked", T0.AddSeconds(1).AddMilliseconds(7)).State;
|
||||
await Store(pair.A).SaveAsync(onA, Ct);
|
||||
await Store(pair.B).SaveAsync(onB, Ct);
|
||||
|
||||
// Positive control: a DISTINCT second alarm written only on B. Without it "one entry on
|
||||
// both" would also hold for two databases that never replicated at all.
|
||||
var onlyOnB = Part9StateMachine.ApplyAddComment(
|
||||
AlarmConditionState.Fresh("eq/only-on-b", T0), "amy", "b only", T0.AddSeconds(2)).State;
|
||||
await Store(pair.B).SaveAsync(onlyOnB, Ct);
|
||||
|
||||
await LocalDbPairHarness.WaitUntilAsync(
|
||||
async () => await CommentCountAsync(pair.A, "eq/only-on-b") == 1,
|
||||
"node A to receive node B's distinct alarm");
|
||||
|
||||
// LWW picks ONE node's copy and both nodes end up holding that same copy.
|
||||
await LocalDbPairHarness.WaitUntilAsync(
|
||||
async () =>
|
||||
{
|
||||
var a = await Store(pair.A).LoadAsync("eq/alarm-2", Ct);
|
||||
var b = await Store(pair.B).LoadAsync("eq/alarm-2", Ct);
|
||||
return a is not null && b is not null
|
||||
&& a.Comments.Count == 1 && b.Comments.Count == 1
|
||||
&& a.Comments[0] == b.Comments[0];
|
||||
},
|
||||
"both nodes to agree on a single copy of the shared entry");
|
||||
|
||||
// Held, not merely reached: nothing late may double the trail on either node.
|
||||
(await LocalDbPairHarness.HeldWithinAsync(
|
||||
async () => await CommentCountAsync(pair.A, "eq/alarm-2") != 1
|
||||
|| await CommentCountAsync(pair.B, "eq/alarm-2") != 1,
|
||||
TimeSpan.FromSeconds(2)))
|
||||
.ShouldBeFalse("the shared entry must stay one entry on both nodes, never two");
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,7 @@ public sealed class LocalDbSetupTests : IDisposable
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
[Fact]
|
||||
public void OnReady_WhenReplicationIsConfigured_RegistersExactlyTheFourReplicatedTables()
|
||||
public void OnReady_WhenReplicationIsConfigured_RegistersExactlyTheFiveReplicatedTables()
|
||||
{
|
||||
// Both directions are load-bearing. "No fewer" catches a dropped RegisterReplicated call
|
||||
// (that table then never replicates). "No more" catches an accidental registration —
|
||||
@@ -142,11 +142,11 @@ public sealed class LocalDbSetupTests : IDisposable
|
||||
var db = Boot(InitiatorReplication);
|
||||
|
||||
db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
|
||||
.ShouldBe(["alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
|
||||
.ShouldBe(["alarm_condition_comment", "alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnReady_WhenOnlyTheSyncApiKeyIsSet_StillRegistersTheFourTables()
|
||||
public void OnReady_WhenOnlyTheSyncApiKeyIsSet_StillRegistersTheFiveTables()
|
||||
{
|
||||
// The passive half of a pair carries the key and nothing else. If the predicate were
|
||||
// "PeerAddress is set", this node would install no capture triggers and its OWN writes
|
||||
@@ -155,7 +155,7 @@ public sealed class LocalDbSetupTests : IDisposable
|
||||
var db = Boot(PassiveReplication);
|
||||
|
||||
db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
|
||||
.ShouldBe(["alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
|
||||
.ShouldBe(["alarm_condition_comment", "alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -216,6 +216,56 @@ public sealed class LocalDbSetupTests : IDisposable
|
||||
db.ReplicatedTables["alarm_condition_state"].PkColumns.ShouldBe(["scripted_alarm_id"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlarmConditionComment_PkIsTheAlarmIdPlusTheTrailOrdinal()
|
||||
{
|
||||
// One row per trail entry. Keyed by ordinal (not content) so the pair's two copies of the
|
||||
// same logical entry — each stamped by its own node's clock — collapse to one row under LWW.
|
||||
var db = Boot(InitiatorReplication);
|
||||
|
||||
db.ReplicatedTables["alarm_condition_comment"].PkColumns.ShouldBe(["scripted_alarm_id", "seq"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnReady_MovesAnOldShapeInlineCommentTrail_AfterRegistration_SoTheMoveReplicates()
|
||||
{
|
||||
// A file written by the pre-split store: the trail is inline on the condition row.
|
||||
var first = Boot(InitiatorReplication);
|
||||
await first.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO alarm_condition_state
|
||||
(scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
|
||||
comments_json, updated_at_utc)
|
||||
VALUES ('eq/alarm-legacy', 'Enabled', 'Acknowledged', 'Confirmed', 'Unshelved',
|
||||
'[{"timestampUtc":"2026-06-10T10:00:00Z","user":"jane","kind":"Acknowledge","text":"a"},{"timestampUtc":"2026-06-10T10:05:00Z","user":"bob","kind":"Confirm","text":"b"}]',
|
||||
'2026-06-10T10:05:00.0000000Z')
|
||||
""",
|
||||
parameters: null,
|
||||
TestContext.Current.CancellationToken);
|
||||
// Positive control: before the restart the trail is inline and the comment table is empty.
|
||||
(await ScalarAsync(first, "SELECT COUNT(*) FROM alarm_condition_comment")).ShouldBe(0);
|
||||
|
||||
// The upgrade restart.
|
||||
var db = Boot(InitiatorReplication);
|
||||
|
||||
(await ScalarAsync(db, "SELECT COUNT(*) FROM alarm_condition_comment WHERE scripted_alarm_id = 'eq/alarm-legacy'"))
|
||||
.ShouldBe(2);
|
||||
// The condition row itself is NOT rewritten (review m1): a migration UPDATE would re-stamp it
|
||||
// with a fresh HLC and could beat a newer peer state under LWW. Only the seeding INSERT is in
|
||||
// the oplog for it; the alarm's next save clears the inline column.
|
||||
(await ScalarAsync(db, "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name = 'alarm_condition_state'"))
|
||||
.ShouldBe(1);
|
||||
// Captured: the migration ran after RegisterReplicated, so the moved entries reach the peer.
|
||||
(await ScalarAsync(db, "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name = 'alarm_condition_comment'"))
|
||||
.ShouldBe(2);
|
||||
|
||||
// Idempotent: a further boot writes nothing more.
|
||||
var third = Boot(InitiatorReplication);
|
||||
(await ScalarAsync(third, "SELECT COUNT(*) FROM __localdb_oplog WHERE table_name = 'alarm_condition_comment'"))
|
||||
.ShouldBe(2);
|
||||
(await ScalarAsync(third, "SELECT COUNT(*) FROM alarm_condition_comment")).ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlarmSfEventRows_EnterTheOplog()
|
||||
{
|
||||
@@ -296,7 +346,7 @@ public sealed class LocalDbSetupTests : IDisposable
|
||||
var replicated = Boot(InitiatorReplication);
|
||||
await InsertPointerAsync(replicated, "SITE-A");
|
||||
(await ScalarAsync(replicated, "SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger'"))
|
||||
.ShouldBe(12);
|
||||
.ShouldBe(15);
|
||||
|
||||
var unreplicated = Boot();
|
||||
|
||||
@@ -350,6 +400,45 @@ public sealed class LocalDbSetupTests : IDisposable
|
||||
sink.Events.Count(e => e.Level == LogEventLevel.Information).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnReady_WarnsOncePerAlarmWhoseInlineCommentTrailIsUnreadable()
|
||||
{
|
||||
// Review m2: an unmigratable history must be visible, not skipped silently on every boot.
|
||||
var first = Boot();
|
||||
await first.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO alarm_condition_state
|
||||
(scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
|
||||
comments_json, updated_at_utc)
|
||||
VALUES (@Id, 'Enabled', 'Acknowledged', 'Confirmed', 'Unshelved', @Json, '2026-06-10T10:05:00.0000000Z')
|
||||
""",
|
||||
new { Id = "eq/alarm-bad", Json = "{not json" },
|
||||
TestContext.Current.CancellationToken);
|
||||
await first.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO alarm_condition_state
|
||||
(scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
|
||||
comments_json, updated_at_utc)
|
||||
VALUES (@Id, 'Enabled', 'Acknowledged', 'Confirmed', 'Unshelved', @Json, '2026-06-10T10:05:00.0000000Z')
|
||||
""",
|
||||
new
|
||||
{
|
||||
Id = "eq/alarm-good",
|
||||
Json = """[{"timestampUtc":"2026-06-10T10:00:00Z","user":"jane","kind":"AddComment","text":"a"}]""",
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var sink = new CollectingSink();
|
||||
Boot(replication: null, logger: BuildLogger(sink));
|
||||
|
||||
var warnings = sink.Events.Where(e => e.Level == LogEventLevel.Warning).ToList();
|
||||
warnings.Count.ShouldBe(1);
|
||||
warnings[0].RenderMessage().ShouldContain("eq/alarm-bad");
|
||||
// Positive control: the readable row DID move, and said so.
|
||||
sink.Events.Count(e => e.Level == LogEventLevel.Information && e.RenderMessage().Contains("copied"))
|
||||
.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisablingReplication_SaysNothingWhenThereWasNothingToClean()
|
||||
{
|
||||
@@ -365,13 +454,13 @@ public sealed class LocalDbSetupTests : IDisposable
|
||||
[Fact]
|
||||
public void AFailureMidwayThroughRegistration_FailsBootLoudly_AndTheRetriedBootConverges()
|
||||
{
|
||||
// OnReady flips four tables in a loop with no try/catch, so a failure on the third leaves
|
||||
// two registered and two not. That is the RIGHT behaviour and this pins both halves of why:
|
||||
// OnReady flips five tables in a loop with no try/catch, so a failure on the third leaves
|
||||
// two registered and three not. That is the RIGHT behaviour and this pins both halves of why:
|
||||
// the throw propagates out of the AddZbLocalDb singleton factory and fails host start
|
||||
// (a node half-capturing is a node whose replication is silently partial), and because both
|
||||
// library APIs are idempotent the retried boot completes the flip rather than compounding it.
|
||||
//
|
||||
// Swallowing the throw would leave the node up, serving, and replicating two of four tables
|
||||
// Swallowing the throw would leave the node up, serving, and replicating two of five tables
|
||||
// with nothing in the log to say so.
|
||||
var real = Boot(InitiatorReplication);
|
||||
|
||||
@@ -387,11 +476,11 @@ public sealed class LocalDbSetupTests : IDisposable
|
||||
// pinning some unrelated throw, and the "partial flip" it claims to model never happened.
|
||||
faulty.Calls.ShouldBe(3);
|
||||
|
||||
// The retry: same file, no wrapper. Idempotent re-registration converges on all four.
|
||||
// The retry: same file, no wrapper. Idempotent re-registration converges on all five.
|
||||
LocalDbSetup.OnReady(real, configuration);
|
||||
|
||||
real.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
|
||||
.ShouldBe(["alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
|
||||
.ShouldBe(["alarm_condition_comment", "alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -108,7 +108,7 @@ public sealed class LocalDbWiringTests : IDisposable
|
||||
// Exact set, both directions — a dropped registration replicates nothing; an extra one costs
|
||||
// three triggers and oplog volume on every write.
|
||||
first.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal)
|
||||
.ShouldBe(["alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
|
||||
.ShouldBe(["alarm_condition_comment", "alarm_condition_state", "alarm_sf_events", "deployment_artifacts", "deployment_pointer"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+523
@@ -0,0 +1,523 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.LocalDb;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// The alarm comment audit trail lives in its own append-only table
|
||||
/// (<see cref="AlarmConditionStateSchema.CommentsTable"/>) keyed by alarm id, so the condition
|
||||
/// row stays a fixed size and a transition's write — and therefore its replication payload —
|
||||
/// does not grow with the alarm's history.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The payload is measured where it costs: the bytes the LocalDb capture triggers write into
|
||||
/// <c>__localdb_oplog</c> for one save, which is exactly what ships to the pair peer. Both
|
||||
/// tables are registered for capture here, as they are on a replicating node.
|
||||
/// </remarks>
|
||||
public sealed class LocalDbAlarmCommentAuditTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 06, 10, 12, 00, 00, DateTimeKind.Utc);
|
||||
|
||||
private readonly string _dbPath =
|
||||
Path.Combine(Path.GetTempPath(), $"otopcua-alarm-comments-{Guid.NewGuid():N}.db");
|
||||
|
||||
private readonly ServiceProvider _provider;
|
||||
private readonly ILocalDb _db;
|
||||
|
||||
public LocalDbAlarmCommentAuditTests()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = _dbPath })
|
||||
.Build();
|
||||
|
||||
_provider = new ServiceCollection()
|
||||
.AddZbLocalDb(configuration, db =>
|
||||
{
|
||||
using (var connection = db.CreateConnection())
|
||||
{
|
||||
AlarmConditionStateSchema.Apply(connection);
|
||||
}
|
||||
|
||||
db.RegisterReplicated(AlarmConditionStateSchema.StateTable);
|
||||
db.RegisterReplicated(AlarmConditionStateSchema.CommentsTable);
|
||||
})
|
||||
.BuildServiceProvider();
|
||||
|
||||
_db = _provider.GetRequiredService<ILocalDb>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_provider.Dispose();
|
||||
SqliteConnection.ClearAllPools();
|
||||
foreach (var path in new[] { _dbPath, $"{_dbPath}-wal", $"{_dbPath}-shm" })
|
||||
{
|
||||
try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDbAlarmConditionStateStore NewStore()
|
||||
=> new(_db, NullLogger<LocalDbAlarmConditionStateStore>.Instance);
|
||||
|
||||
private static CancellationToken Ct => CancellationToken.None;
|
||||
|
||||
private static ImmutableList<AlarmComment> Comments(int count, string prefix = "c")
|
||||
{
|
||||
var builder = ImmutableList.CreateBuilder<AlarmComment>();
|
||||
for (var i = 0; i < count; i++)
|
||||
builder.Add(new AlarmComment(T0.AddSeconds(i), "jane", "AddComment", $"{prefix}-{i:D6}"));
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
/// <summary>An unacknowledged state carrying <paramref name="comments"/>, ready to be acked.</summary>
|
||||
private static AlarmConditionState Unacked(string alarmId, ImmutableList<AlarmComment> comments) =>
|
||||
AlarmConditionState.Fresh(alarmId, T0) with
|
||||
{
|
||||
Active = AlarmActiveState.Active,
|
||||
Acked = AlarmAckedState.Unacknowledged,
|
||||
Comments = comments,
|
||||
};
|
||||
|
||||
private async Task<long> OplogHighWaterAsync() =>
|
||||
(await _db.QueryAsync("SELECT COALESCE(MAX(seq), 0) FROM __localdb_oplog", r => r.GetInt64(0), null, Ct))[0];
|
||||
|
||||
/// <summary>Bytes captured into the oplog above <paramref name="afterSeq"/> — the replication payload.</summary>
|
||||
private async Task<long> OplogBytesAboveAsync(long afterSeq) =>
|
||||
(await _db.QueryAsync(
|
||||
"SELECT COALESCE(SUM(LENGTH(pk_json) + COALESCE(LENGTH(row_json), 0)), 0) FROM __localdb_oplog WHERE seq > @S",
|
||||
r => r.GetInt64(0),
|
||||
new { S = afterSeq },
|
||||
Ct))[0];
|
||||
|
||||
private async Task<long> OplogEntriesAboveAsync(long afterSeq, string table) =>
|
||||
(await _db.QueryAsync(
|
||||
"SELECT COUNT(*) FROM __localdb_oplog WHERE seq > @S AND table_name = @T",
|
||||
r => r.GetInt64(0),
|
||||
new { S = afterSeq, T = table },
|
||||
Ct))[0];
|
||||
|
||||
private async Task<long> ScalarAsync(string sql) =>
|
||||
(await _db.QueryAsync(sql, r => r.GetInt64(0), null, Ct))[0];
|
||||
|
||||
/// <summary>
|
||||
/// Seeds <paramref name="alarmId"/> with <paramref name="historyLength"/> comments, then
|
||||
/// returns the oplog bytes one acknowledge transition (one new comment) costs.
|
||||
/// </summary>
|
||||
private async Task<long> AckTransitionBytesAsync(string alarmId, int historyLength, string ackComment)
|
||||
{
|
||||
var store = NewStore();
|
||||
var seeded = Unacked(alarmId, Comments(historyLength));
|
||||
await store.SaveAsync(seeded, Ct);
|
||||
|
||||
var before = await OplogHighWaterAsync();
|
||||
var acked = Part9StateMachine.ApplyAcknowledge(seeded, "bob", ackComment, T0.AddDays(1)).State;
|
||||
await store.SaveAsync(acked, Ct);
|
||||
return await OplogBytesAboveAsync(before);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TransitionPayload_IsConstant_WhetherTheAlarmCarries10OrTenThousandComments()
|
||||
{
|
||||
var small = await AckTransitionBytesAsync("alarm-small", 10, "ack");
|
||||
var large = await AckTransitionBytesAsync("alarm-large", 10_000, "ack");
|
||||
|
||||
// Constant: the only difference allowed is the width of the new comment's ordinal ("10" vs
|
||||
// "10000", in pk_json and row_json) — a handful of bytes, not O(n). The alarm ids are the
|
||||
// same length on purpose.
|
||||
small.ShouldBeGreaterThan(0);
|
||||
Math.Abs(large - small).ShouldBeLessThanOrEqualTo(16,
|
||||
$"one ack transition cost {small} bytes at 10 comments but {large} at 10,000");
|
||||
large.ShouldBeLessThan(2_048);
|
||||
|
||||
// Positive control on the instrument: the seeded history really is large and really was
|
||||
// captured — otherwise a "constant" result could just mean capture is off.
|
||||
(await ScalarAsync("SELECT COUNT(*) FROM alarm_condition_comment WHERE scripted_alarm_id = 'alarm-large'"))
|
||||
.ShouldBe(10_001);
|
||||
var historyBytes = await ScalarAsync(
|
||||
"SELECT SUM(LENGTH(row_json)) FROM __localdb_oplog WHERE table_name = 'alarm_condition_comment'");
|
||||
historyBytes.ShouldBeGreaterThan(1_000_000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TransitionPayload_PositiveControl_TheMeasurementSeesTheNewCommentsBytes()
|
||||
{
|
||||
// Proves the measurement above is not vacuous: the oplog delta of a transition does carry
|
||||
// the appended comment, so a 4 KB comment costs at least 4 KB.
|
||||
var plain = await AckTransitionBytesAsync("alarm-plain", 10, "ack");
|
||||
var heavy = await AckTransitionBytesAsync("alarm-heavy", 10, new string('x', 4_096));
|
||||
|
||||
(heavy - plain).ShouldBeGreaterThanOrEqualTo(4_096 * 2 - 16,
|
||||
"the comment text rides both the comment row and last_ack_comment on the condition row");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transition_WritesOneConditionRowAndOnlyTheAppendedCommentRow()
|
||||
{
|
||||
var store = NewStore();
|
||||
var seeded = Unacked("alarm-append", Comments(50));
|
||||
await store.SaveAsync(seeded, Ct);
|
||||
|
||||
var before = await OplogHighWaterAsync();
|
||||
var acked = Part9StateMachine.ApplyAcknowledge(seeded, "bob", "ack", T0.AddDays(1)).State;
|
||||
await store.SaveAsync(acked, Ct);
|
||||
|
||||
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.StateTable)).ShouldBe(1);
|
||||
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.CommentsTable)).ShouldBe(1);
|
||||
|
||||
var loaded = await store.LoadAsync("alarm-append", Ct);
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.Comments.ShouldBe(acked.Comments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConditionRow_StaysFixedSize_InlineColumnIsAlwaysEmpty()
|
||||
{
|
||||
var store = NewStore();
|
||||
await store.SaveAsync(Unacked("alarm-fixed", Comments(500)), Ct);
|
||||
|
||||
(await _db.QueryAsync(
|
||||
"SELECT comments_json FROM alarm_condition_state WHERE scripted_alarm_id = 'alarm-fixed'",
|
||||
r => r.GetString(0), null, Ct))[0]
|
||||
.ShouldBe("[]");
|
||||
(await store.LoadAsync("alarm-fixed", Ct))!.Comments.Count.ShouldBe(500);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnchangedSave_WritesNoCommentRows()
|
||||
{
|
||||
var store = NewStore();
|
||||
var state = Unacked("alarm-idle", Comments(20));
|
||||
await store.SaveAsync(state, Ct);
|
||||
|
||||
var before = await OplogHighWaterAsync();
|
||||
await store.SaveAsync(state with { LastTransitionUtc = T0.AddMinutes(1) }, Ct);
|
||||
|
||||
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.CommentsTable)).ShouldBe(0);
|
||||
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.StateTable)).ShouldBe(1,
|
||||
"positive control: the save itself was captured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAll_ReturnsEachAlarmsOwnTrailInOrder()
|
||||
{
|
||||
var store = NewStore();
|
||||
var a = Unacked("alarm-a", Comments(3, "a"));
|
||||
var b = Unacked("alarm-b", Comments(5, "b"));
|
||||
await store.SaveAsync(a, Ct);
|
||||
await store.SaveAsync(b, Ct);
|
||||
|
||||
var all = (await store.LoadAllAsync(Ct)).ToDictionary(s => s.AlarmId);
|
||||
|
||||
all["alarm-a"].Comments.ShouldBe(a.Comments);
|
||||
all["alarm-b"].Comments.ShouldBe(b.Comments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Remove_DeletesTheAlarmsTrail_AndAReCreatedAlarmStartsEmpty()
|
||||
{
|
||||
var store = NewStore();
|
||||
await store.SaveAsync(Unacked("alarm-gone", Comments(4)), Ct);
|
||||
await store.SaveAsync(Unacked("alarm-kept", Comments(2)), Ct);
|
||||
|
||||
await store.RemoveAsync("alarm-gone", Ct);
|
||||
|
||||
(await ScalarAsync("SELECT COUNT(*) FROM alarm_condition_comment WHERE scripted_alarm_id = 'alarm-gone'"))
|
||||
.ShouldBe(0);
|
||||
(await store.LoadAsync("alarm-kept", Ct))!.Comments.Count.ShouldBe(2, "positive control");
|
||||
|
||||
var recreated = AlarmConditionState.Fresh("alarm-gone", T0)
|
||||
with { Comments = ImmutableList.Create(new AlarmComment(T0, "amy", "AddComment", "new life")) };
|
||||
await store.SaveAsync(recreated, Ct);
|
||||
(await store.LoadAsync("alarm-gone", Ct))!.Comments.ShouldBe(recreated.Comments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CommentsAlreadyHeldAtAnOrdinal_AreNeverRewritten()
|
||||
{
|
||||
// Append-only: a save whose list disagrees with what is stored at an existing ordinal (the
|
||||
// peer's copy of the same logical entry already arrived) leaves the stored entry alone.
|
||||
var store = NewStore();
|
||||
var original = Unacked("alarm-ao", Comments(3));
|
||||
await store.SaveAsync(original, Ct);
|
||||
|
||||
var rewritten = original with { Comments = original.Comments.SetItem(1, new AlarmComment(T0, "eve", "AddComment", "forged")) };
|
||||
await store.SaveAsync(rewritten, Ct);
|
||||
|
||||
// The stored entry at ordinal 1 is untouched; the unrecognised local entry is kept by
|
||||
// appending it (never dropped), not by overwriting history.
|
||||
var loaded = (await store.LoadAsync("alarm-ao", Ct))!.Comments;
|
||||
loaded.Take(3).ShouldBe(original.Comments);
|
||||
loaded.Count.ShouldBe(4);
|
||||
loaded[3].Text.ShouldBe("forged");
|
||||
}
|
||||
|
||||
// ---- back-compat + migration -----------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// The exact shape the pre-split store wrote into <c>comments_json</c>
|
||||
/// (<c>JsonSerializerDefaults.Web</c>: camelCase, Utc timestamps with a trailing <c>Z</c>).
|
||||
/// </summary>
|
||||
private const string OldShapeCommentsJson =
|
||||
"""[{"timestampUtc":"2026-06-10T10:00:00Z","user":"jane","kind":"Acknowledge","text":"checking pump"},{"timestampUtc":"2026-06-10T11:30:00.1234567Z","user":"bob","kind":"Confirm","text":"all clear"},{"timestampUtc":"2026-06-10T11:30:00.1234567Z","user":"system","kind":"AutoUnshelve","text":""}]""";
|
||||
|
||||
private static readonly ImmutableList<AlarmComment> OldShapeComments = ImmutableList.Create(
|
||||
new AlarmComment(new DateTime(2026, 06, 10, 10, 00, 00, DateTimeKind.Utc), "jane", "Acknowledge", "checking pump"),
|
||||
new AlarmComment(new DateTime(2026, 06, 10, 11, 30, 00, DateTimeKind.Utc).AddTicks(1_234_567), "bob", "Confirm", "all clear"),
|
||||
new AlarmComment(new DateTime(2026, 06, 10, 11, 30, 00, DateTimeKind.Utc).AddTicks(1_234_567), "system", "AutoUnshelve", ""));
|
||||
|
||||
private async Task InsertOldShapeRowAsync(string alarmId, string commentsJson = OldShapeCommentsJson) =>
|
||||
await _db.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO alarm_condition_state
|
||||
(scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
|
||||
shelving_expires_utc, last_ack_user, last_ack_comment, last_ack_utc,
|
||||
last_confirm_user, last_confirm_comment, last_confirm_utc, comments_json, updated_at_utc)
|
||||
VALUES
|
||||
(@Id, 'Enabled', 'Acknowledged', 'Confirmed', 'Unshelved',
|
||||
NULL, 'jane', 'checking pump', '2026-06-10T10:00:00.0000000Z',
|
||||
'bob', 'all clear', '2026-06-10T11:30:00.1234567Z', @Json, '2026-06-10T11:30:00.1234567Z')
|
||||
""",
|
||||
new { Id = alarmId, Json = commentsJson },
|
||||
Ct);
|
||||
|
||||
private static void ShouldBeTheOldShapeState(AlarmConditionState? loaded, string alarmId)
|
||||
{
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.AlarmId.ShouldBe(alarmId);
|
||||
loaded.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||
loaded.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed);
|
||||
loaded.LastAckUser.ShouldBe("jane");
|
||||
loaded.LastConfirmComment.ShouldBe("all clear");
|
||||
loaded.Comments.ShouldBe(OldShapeComments);
|
||||
loaded.Comments.ShouldAllBe(c => c.TimestampUtc.Kind == DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OldShapeRow_WithInlineComments_LoadsExactlyAsBefore()
|
||||
{
|
||||
await InsertOldShapeRowAsync("alarm-legacy");
|
||||
|
||||
ShouldBeTheOldShapeState(await NewStore().LoadAsync("alarm-legacy", Ct), "alarm-legacy");
|
||||
ShouldBeTheOldShapeState((await NewStore().LoadAllAsync(Ct)).Single(), "alarm-legacy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Migration_CopiesInlineCommentsToTheAuditTable_AndNeverRewritesTheConditionRow()
|
||||
{
|
||||
await InsertOldShapeRowAsync("alarm-m1");
|
||||
await InsertOldShapeRowAsync("alarm-m2");
|
||||
await InsertOldShapeRowAsync("alarm-empty", "[]");
|
||||
|
||||
var before = await OplogHighWaterAsync();
|
||||
AlarmConditionStateSchema.InlineCommentMigrationResult result;
|
||||
using (var connection = _db.CreateConnection())
|
||||
result = AlarmConditionStateSchema.MigrateInlineComments(connection);
|
||||
|
||||
result.RowsMoved.ShouldBe(2);
|
||||
result.UnparseableAlarmIds.ShouldBeEmpty();
|
||||
(await ScalarAsync("SELECT COUNT(*) FROM alarm_condition_comment")).ShouldBe(6);
|
||||
|
||||
// Review m1: the migration must not touch the condition row. A full-row UPDATE re-stamps it
|
||||
// with a fresh HLC and can revert a newer peer state (e.g. an ack) under LWW. The next save
|
||||
// (the engine's boot save) resets the column instead.
|
||||
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.StateTable)).ShouldBe(0);
|
||||
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.CommentsTable)).ShouldBe(6,
|
||||
"positive control: the copied entries were captured");
|
||||
|
||||
ShouldBeTheOldShapeState(await NewStore().LoadAsync("alarm-m1", Ct), "alarm-m1");
|
||||
ShouldBeTheOldShapeState(await NewStore().LoadAsync("alarm-m2", Ct), "alarm-m2");
|
||||
(await NewStore().LoadAsync("alarm-empty", Ct))!.Comments.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Migration_IsIdempotent_ASecondRunChangesNothing()
|
||||
{
|
||||
await InsertOldShapeRowAsync("alarm-idem");
|
||||
using (var connection = _db.CreateConnection())
|
||||
AlarmConditionStateSchema.MigrateInlineComments(connection).RowsMoved.ShouldBe(1, "positive control");
|
||||
|
||||
var before = await OplogHighWaterAsync();
|
||||
using (var connection = _db.CreateConnection())
|
||||
AlarmConditionStateSchema.MigrateInlineComments(connection).RowsMoved.ShouldBe(0);
|
||||
|
||||
(await OplogHighWaterAsync()).ShouldBe(before, "a re-run must write nothing");
|
||||
(await ScalarAsync("SELECT COUNT(*) FROM alarm_condition_comment")).ShouldBe(3);
|
||||
ShouldBeTheOldShapeState(await NewStore().LoadAsync("alarm-idem", Ct), "alarm-idem");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Migration_OverAPartiallyMigratedRow_FillsOnlyTheMissingOrdinals()
|
||||
{
|
||||
// Only ordinal 0 made it across (e.g. a restored backup): the run fills 1 and 2, keeps 0,
|
||||
// duplicates nothing.
|
||||
await InsertOldShapeRowAsync("alarm-partial");
|
||||
await _db.ExecuteAsync(
|
||||
AlarmConditionStateSchema.InsertCommentSql,
|
||||
new
|
||||
{
|
||||
Id = "alarm-partial", Seq = 0,
|
||||
Ts = AlarmConditionStateSchema.FormatTimestamp(OldShapeComments[0].TimestampUtc),
|
||||
User = OldShapeComments[0].User, Kind = OldShapeComments[0].Kind, Text = OldShapeComments[0].Text,
|
||||
},
|
||||
Ct);
|
||||
|
||||
using (var connection = _db.CreateConnection())
|
||||
AlarmConditionStateSchema.MigrateInlineComments(connection).RowsMoved.ShouldBe(1);
|
||||
|
||||
(await ScalarAsync("SELECT COUNT(*) FROM alarm_condition_comment")).ShouldBe(3);
|
||||
ShouldBeTheOldShapeState(await NewStore().LoadAsync("alarm-partial", Ct), "alarm-partial");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Migration_ReportsAnUnparseableInlineTrail_AndLeavesItAlone()
|
||||
{
|
||||
// Review m2: an unreadable trail must surface (OnReady logs a Warning per id), not be
|
||||
// skipped silently on every boot.
|
||||
await InsertOldShapeRowAsync("alarm-bad", "{not json");
|
||||
await InsertOldShapeRowAsync("alarm-good");
|
||||
|
||||
AlarmConditionStateSchema.InlineCommentMigrationResult result;
|
||||
using (var connection = _db.CreateConnection())
|
||||
result = AlarmConditionStateSchema.MigrateInlineComments(connection);
|
||||
|
||||
result.UnparseableAlarmIds.ShouldBe(["alarm-bad"]);
|
||||
result.RowsMoved.ShouldBe(1, "positive control: the good row still moved");
|
||||
(await _db.QueryAsync(
|
||||
"SELECT comments_json FROM alarm_condition_state WHERE scripted_alarm_id = 'alarm-bad'",
|
||||
r => r.GetString(0), null, Ct))[0]
|
||||
.ShouldBe("{not json");
|
||||
}
|
||||
|
||||
// ---- divergence / failover (review I-1) -------------------------------------------------
|
||||
|
||||
/// <summary>Captures log entries so a divergence Warning can be asserted, not inferred.</summary>
|
||||
private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger<LocalDbAlarmConditionStateStore>
|
||||
{
|
||||
public List<(Microsoft.Extensions.Logging.LogLevel Level, string Message)> Entries { get; } = [];
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
|
||||
TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
|
||||
public int Warnings => Entries.Count(e => e.Level == Microsoft.Extensions.Logging.LogLevel.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failover_TheSurvivorsNextEntry_IsPersisted_AfterItMissedACommandThePeerApplied()
|
||||
{
|
||||
// The I-1 sequence on node B's database:
|
||||
// 1. a shared trail of 5 entries;
|
||||
// 2. an AddComment reaches only node A (B's command delivery failed once) and A's row at
|
||||
// ordinal 5 replicates into B's table — B's IN-MEMORY trail still has 5 entries;
|
||||
// 3. A dies and the operator acks on B: B's in-memory Ack sits at index 5, colliding with
|
||||
// A's entry at ordinal 5.
|
||||
// The Ack must be persisted (and say so), not silently treated as "already stored".
|
||||
var shared = Unacked("alarm-fo", Comments(5));
|
||||
var a = NewStore();
|
||||
await a.SaveAsync(shared, Ct);
|
||||
var onlyOnA = Part9StateMachine.ApplyAddComment(shared, "jane", "seen on A only", T0.AddHours(1)).State;
|
||||
await a.SaveAsync(onlyOnA, Ct);
|
||||
|
||||
var log = new CapturingLogger();
|
||||
var b = new LocalDbAlarmConditionStateStore(_db, log);
|
||||
var ackOnB = Part9StateMachine.ApplyAcknowledge(shared, "bob", "acked on B", T0.AddHours(2)).State;
|
||||
await b.SaveAsync(ackOnB, Ct);
|
||||
|
||||
var loaded = (await NewStore().LoadAsync("alarm-fo", Ct))!;
|
||||
loaded.Comments.Count.ShouldBe(7);
|
||||
loaded.Comments[5].ShouldBe(onlyOnA.Comments[5]);
|
||||
loaded.Comments[6].ShouldBe(ackOnB.Comments[5]);
|
||||
log.Warnings.ShouldBe(1, "a divergence is never silent");
|
||||
|
||||
// Re-saving with no new entry must not append the Ack a second time (its own earlier
|
||||
// divergence-append shifted it off its in-memory index).
|
||||
var before = await OplogHighWaterAsync();
|
||||
await b.SaveAsync(ackOnB with { LastTransitionUtc = T0.AddHours(3) }, Ct);
|
||||
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.CommentsTable)).ShouldBe(0);
|
||||
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.StateTable)).ShouldBe(1,
|
||||
"positive control: the re-save itself was captured");
|
||||
|
||||
// And B's following entry (still one behind in memory) is persisted too, exactly once.
|
||||
var confirmOnB = Part9StateMachine.ApplyAddComment(ackOnB, "bob", "follow-up on B", T0.AddHours(4)).State;
|
||||
await b.SaveAsync(confirmOnB, Ct);
|
||||
await b.SaveAsync(confirmOnB, Ct);
|
||||
var after = (await NewStore().LoadAsync("alarm-fo", Ct))!;
|
||||
after.Comments.Count.ShouldBe(8);
|
||||
after.Comments[7].ShouldBe(confirmOnB.Comments[6]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PeersCopyOfTheSameCommand_IsNotAppendedAgain_AndLogsNothing()
|
||||
{
|
||||
// The positive control for the divergence path: in normal operation both nodes apply every
|
||||
// command, each with its own clock. When the peer's copy is already at the ordinal, the
|
||||
// local copy is the same entry and must NOT be appended — otherwise the fix would double
|
||||
// every entry on a healthy pair.
|
||||
var shared = Unacked("alarm-dual", Comments(5));
|
||||
await NewStore().SaveAsync(shared, Ct);
|
||||
var ackOnA = Part9StateMachine.ApplyAcknowledge(shared, "bob", "on it", T0.AddHours(1)).State;
|
||||
await NewStore().SaveAsync(ackOnA, Ct);
|
||||
|
||||
var log = new CapturingLogger();
|
||||
var b = new LocalDbAlarmConditionStateStore(_db, log);
|
||||
var ackOnB = Part9StateMachine.ApplyAcknowledge(shared, "bob", "on it", T0.AddHours(1).AddMilliseconds(40)).State;
|
||||
var before = await OplogHighWaterAsync();
|
||||
await b.SaveAsync(ackOnB, Ct);
|
||||
|
||||
(await OplogEntriesAboveAsync(before, AlarmConditionStateSchema.CommentsTable)).ShouldBe(0);
|
||||
(await NewStore().LoadAsync("alarm-dual", Ct))!.Comments.Count.ShouldBe(6);
|
||||
log.Warnings.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARepeatedIdenticalComment_IsKept_AsItsOwnEntry()
|
||||
{
|
||||
// Matching ignores the timestamp only within the clock-skew window; an operator repeating the
|
||||
// same text later is a new entry, not a duplicate of the earlier one.
|
||||
var store = NewStore();
|
||||
var s = Unacked("alarm-repeat", Comments(2));
|
||||
s = Part9StateMachine.ApplyAddComment(s, "amy", "still high", T0.AddHours(1)).State;
|
||||
await store.SaveAsync(s, Ct);
|
||||
s = Part9StateMachine.ApplyAddComment(s, "amy", "still high", T0.AddHours(2)).State;
|
||||
await store.SaveAsync(s, Ct);
|
||||
|
||||
(await NewStore().LoadAsync("alarm-repeat", Ct))!.Comments.ShouldBe(s.Comments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SavingALoadedOldShapeState_MovesItsCommentsAndAppendsTheNewOne()
|
||||
{
|
||||
// The lazy path: an old-shape row the boot migration never saw (it was written before the
|
||||
// first save on this build) still converges on the first save.
|
||||
await InsertOldShapeRowAsync("alarm-lazy");
|
||||
var store = NewStore();
|
||||
var loaded = await store.LoadAsync("alarm-lazy", Ct);
|
||||
|
||||
var next = Part9StateMachine.ApplyAddComment(loaded!, "amy", "follow-up", T0.AddDays(2)).State;
|
||||
await store.SaveAsync(next, Ct);
|
||||
|
||||
(await ScalarAsync("SELECT COUNT(*) FROM alarm_condition_comment WHERE scripted_alarm_id = 'alarm-lazy'"))
|
||||
.ShouldBe(4);
|
||||
(await _db.QueryAsync(
|
||||
"SELECT comments_json FROM alarm_condition_state WHERE scripted_alarm_id = 'alarm-lazy'",
|
||||
r => r.GetString(0), null, Ct))[0]
|
||||
.ShouldBe("[]");
|
||||
(await NewStore().LoadAsync("alarm-lazy", Ct))!.Comments.ShouldBe(next.Comments);
|
||||
}
|
||||
}
|
||||
+1
@@ -48,6 +48,7 @@ public sealed class LocalDbAlarmConditionStateStoreTests : IDisposable
|
||||
}
|
||||
|
||||
db.RegisterReplicated(AlarmConditionStateSchema.StateTable);
|
||||
db.RegisterReplicated(AlarmConditionStateSchema.CommentsTable);
|
||||
})
|
||||
.BuildServiceProvider();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user