Merge commit '82380d2b' into remed/integration-w3

This commit is contained in:
Joseph Doherty
2026-10-22 17:21:38 -04:00
60 changed files with 5954 additions and 752 deletions
+65 -7
View File
@@ -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
+39 -1
View File
@@ -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);
@@ -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&amp;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>&lt;Entry key="…"&gt;</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>&lt;Header&gt;</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 =
@@ -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 */ }
}
}
@@ -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,
/// &gt;1 N dimensions. Anything &gt;= 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;
}
@@ -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);
}
}
@@ -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);
}
@@ -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; }
}
}
@@ -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()
@@ -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>
@@ -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>&amp;</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);
}
}
}
@@ -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");
}
}
@@ -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);
}
@@ -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();
}
@@ -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}");
}
}
@@ -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");
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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");
@@ -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);
}
}
@@ -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>
@@ -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();
}
}
}
@@ -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");
}
}
@@ -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();
}
}
}
@@ -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));
}
}
@@ -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");
}
}
@@ -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]
@@ -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);
}
}
@@ -48,6 +48,7 @@ public sealed class LocalDbAlarmConditionStateStoreTests : IDisposable
}
db.RegisterReplicated(AlarmConditionStateSchema.StateTable);
db.RegisterReplicated(AlarmConditionStateSchema.CommentsTable);
})
.BuildServiceProvider();