feat(mtconnect): IReadable via /current, ordered per-ref snapshots (Task 10)

One deadline-bounded /current per batch, answered out of that one whole-device
document: N references cost one round-trip, one snapshot each, in request order,
duplicates included.

DEVIATION (deliberate, from the plan's implied shared-index write): the response
is indexed into a PER-CALL snapshot index, never into the driver's shared
observation index. That index is written by Task 11's /sample pump and is
last-write-wins with no timestamp arbitration; a read folding its /current into
it would be a second writer racing the first, and /current is frequently the
OLDER document (the Agent composes it while the pump's newer delta is in
flight). Losing that race rolls a subscribed value backwards and leaves it there
until the data item next changes. Both paths coerce through the same
MTConnectObservationIndex logic, so a read and a subscription report a given
observation identically -- the read path is simply a pure function of
(document, authored tags).

Failure posture is the inverse of InitializeAsync: nothing but genuine caller
cancellation crosses the capability boundary. Unreachable Agent / blown deadline
/ unusable answer => every ref codes BadCommunicationError and the driver
degrades (never downgrading Faulted); no client at all (pre-initialize, faulted,
post-shutdown) => BadNotConnected with health untouched. The index's
BadWaitingForInitialData / BadNodeIdUnknown / BadNoCommunication / BadNotSupported
distinctions pass through unmodified.

21 tests pinning arity, order, duplicates, empty batch, one-round-trip, each Bad
code, read-time freshness, the anti-clobber invariant, the deadline, and
cancellation-propagates-but-agent-failure-does-not. CannedAgentClient gains a
cancellation-observing CurrentGate (still no timers or sleeps of its own).
Test csproj takes the AbCip/S7 OTOPCUA0001 NoWarn -- this suite calls the
capability directly by design.

329/329 green.
This commit is contained in:
Joseph Doherty
2026-07-24 15:38:55 -04:00
parent 4fe0af3693
commit 1bfc1722b3
4 changed files with 658 additions and 13 deletions
@@ -42,14 +42,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <c>nextSequence</c>.
/// </para>
/// <para>
/// <b>Scope.</b> This type currently implements <see cref="IDriver"/> only.
/// <c>IReadable</c> (Task 10), <c>ISubscribable</c> (Task 11), <c>ITagDiscovery</c>
/// (Task 12) and <c>IHostConnectivityProbe</c>/<c>IRediscoverable</c> (Task 13) are added on
/// top of the state this class already caches — the probe model, the Agent
/// <c>instanceId</c>, and the observation index.
/// <b>Scope.</b> This type currently implements <see cref="IDriver"/> and
/// <see cref="IReadable"/>. <c>ISubscribable</c> (Task 11), <c>ITagDiscovery</c> (Task 12)
/// and <c>IHostConnectivityProbe</c>/<c>IRediscoverable</c> (Task 13) are added on top of
/// the state this class already caches — the probe model, the Agent <c>instanceId</c>, and
/// the observation index.
/// </para>
/// <para>
/// <b>The read path never writes the shared observation index</b> — see
/// <see cref="ReadAsync"/>. That index has exactly one writer, and Task 11's <c>/sample</c>
/// pump is it.
/// </para>
/// </remarks>
public sealed class MTConnectDriver : IDriver
public sealed class MTConnectDriver : IDriver, IReadable
{
/// <summary>
/// Coarse per-entry byte weights behind <see cref="GetMemoryFootprint"/>. The contract asks
@@ -66,6 +71,20 @@ public sealed class MTConnectDriver : IDriver
/// <summary>Estimated retained bytes per authored <see cref="MTConnectTagDefinition"/>.</summary>
private const int TagBytesPerEntry = 256;
/// <summary>
/// The Agent could not be reached, answered unusably, or blew the per-call deadline. Distinct
/// from the index's <c>BadNoCommunication</c>, which means the Agent answered and said the
/// machine has no value — the two failures need different operator responses.
/// </summary>
private const uint BadCommunicationError = 0x80050000u;
/// <summary>
/// There is no Agent client at all: the driver has not been initialized, its initialize
/// faulted, or it has been shut down. Deliberately not one of the index's codes — none of
/// them would say "this driver is not running", which is the only true statement available.
/// </summary>
private const uint BadNotConnected = 0x808A0000u;
/// <summary>
/// Config-JSON reader options, mirroring the sibling driver factories. Note there is
/// deliberately <b>no</b> <c>JsonStringEnumConverter</c>: enum-carrying DTO fields stay
@@ -137,10 +156,16 @@ public sealed class MTConnectDriver : IDriver
public string DriverType => "MTConnect";
/// <summary>
/// The live <c>dataItemId → snapshot</c> map. Consumed by <c>IReadable</c> (Task 10) and the
/// <c>/sample</c> pump (Task 11); exposed to tests as the observable proof that
/// <see cref="InitializeAsync"/> actually primed it.
/// The live <c>dataItemId → snapshot</c> map that backs the <b>subscription</b> surface:
/// primed by <see cref="InitializeAsync"/> and thereafter written only by the <c>/sample</c>
/// pump (Task 11). Exposed to tests as the observable proof that initialize actually primed
/// it — and, in <c>MTConnectReadTests</c>, that <see cref="ReadAsync"/> leaves it alone.
/// </summary>
/// <remarks>
/// <b>Read does not consume this.</b> <see cref="ReadAsync"/> indexes its own <c>/current</c>
/// into a per-call snapshot instead, so the pump keeps a single writer — see the failure
/// analysis in <see cref="ReadAsync"/>'s remarks.
/// </remarks>
internal MTConnectObservationIndex ObservationIndex => Volatile.Read(ref _index);
/// <summary>
@@ -337,6 +362,110 @@ public sealed class MTConnectDriver : IDriver
return Task.CompletedTask;
}
/// <inheritdoc/>
/// <remarks>
/// <para>
/// <b>One <c>/current</c> per batch, whatever the batch size.</b> MTConnect's
/// <c>/current</c> answers for the whole device (or the whole Agent), so N references
/// cost one deadline-bounded round-trip and are answered out of that one document. There
/// is no per-reference request to make and none is made.
/// </para>
/// <para>
/// <b>The response is indexed into a per-call snapshot, NOT into the driver's shared
/// observation index.</b> That index is written by the <c>/sample</c> pump (Task 11) and
/// is deliberately last-write-wins with no timestamp arbitration. A read that folded its
/// <c>/current</c> into it would be a second writer racing the first, and the
/// <c>/current</c> document is frequently the <i>older</i> of the two — the Agent
/// composes it while the pump's newer delta is already in flight. Losing that race rolls
/// a subscribed value backwards and <b>leaves it there until the data item next
/// changes</b>, which for an EVENT such as <c>Execution</c> can be hours. Keeping the
/// read path a pure function of (document, authored tags) costs one index construction
/// per batch — negligible beside the HTTP round-trip that precedes it — and makes it
/// impossible for a read to corrupt subscription state. The reference source-of-truth is
/// unchanged either way: both paths coerce through the same
/// <see cref="MTConnectObservationIndex"/> logic, so a read and a subscription report a
/// given observation identically.
/// </para>
/// <para>
/// <b>Nothing but genuine caller cancellation crosses this boundary.</b> An unreachable
/// Agent, a blown deadline, an unusable answer — each fails the batch's <i>values</i>
/// (every reference codes <see cref="BadCommunicationError"/>) rather than the batch: an
/// exception here would also fail the references that were perfectly readable, and the
/// node-manager cannot tell a thrown read from a broken driver. This is the opposite
/// posture to <see cref="InitializeAsync"/>, which is specified to throw so the
/// runtime's retry/backoff loop can own recovery.
/// </para>
/// <para>
/// The status-code distinctions the index draws — <c>BadWaitingForInitialData</c> for an
/// authored-but-unreported id, <c>BadNodeIdUnknown</c> for an unknown one,
/// <c>BadNoCommunication</c> for the Agent's <c>UNAVAILABLE</c> — are passed through
/// unmodified. They tell an operator three different things.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException">
/// <paramref name="fullReferences"/> is <c>null</c> — a caller bug, not device data, and the
/// one thing this method is loud about (matching <see cref="MTConnectObservationIndex"/>'s
/// documented posture).
/// </exception>
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
// Checked before anything else: an empty batch must cost the Agent nothing, and there is no
// failure it could report.
if (fullReferences.Count == 0)
{
return [];
}
var client = _client;
var options = _options;
if (client is null)
{
// Before InitializeAsync, after a faulted one, or after ShutdownAsync. Health is left
// exactly as it is: a read attempted against a driver that was never started (or was
// deliberately stopped) is not evidence of degradation.
return BadBatch(fullReferences.Count, BadNotConnected);
}
try
{
var current = await BoundedAsync(client.CurrentAsync, "/current", options.RequestTimeoutMs, cancellationToken)
.ConfigureAwait(false);
// Built from the SAME authored tags as the shared index, so the authored-vs-unknown
// distinction and every coercion behave identically — it is a snapshot, not a variant.
var snapshot = new MTConnectObservationIndex(options.Tags);
snapshot.Apply(current);
var results = new DataValueSnapshot[fullReferences.Count];
for (var i = 0; i < results.Length; i++)
{
// Get never throws and never returns null, including for a null/blank reference.
results[i] = snapshot.Get(fullReferences[i]);
}
WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
return results;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// The caller asked us to stop. This is the ONLY exception that may propagate: a blown
// per-call deadline arrives as the TimeoutException BoundedAsync raises instead, and is
// handled below as the Agent failure it is.
throw;
}
catch (Exception ex)
{
DegradeAfterReadFailure(ex);
return BadBatch(fullReferences.Count, BadCommunicationError);
}
}
/// <summary>
/// The <c>/probe</c> device model: the cached copy when warm, otherwise a fresh
/// deadline-bounded <c>/probe</c> that re-populates the cache. This is the accessor every
@@ -609,6 +738,45 @@ public sealed class MTConnectDriver : IDriver
}
}
/// <summary>
/// One Bad-coded snapshot per requested reference — the shape every read failure degrades
/// to. Arity is preserved because the caller indexes the result positionally against the
/// references it asked for.
/// </summary>
private static DataValueSnapshot[] BadBatch(int count, uint status)
{
// No source timestamp: nothing was observed. The server timestamp is when we gave up.
var serverTimestamp = DateTime.UtcNow;
var results = new DataValueSnapshot[count];
for (var i = 0; i < count; i++)
{
results[i] = new DataValueSnapshot(null, status, null, serverTimestamp);
}
return results;
}
/// <summary>
/// Routes a read failure to the health surface (05/STAB-9's fleet-wide shape): log it and
/// degrade, preserving <c>LastSuccessfulRead</c> — the "when did this driver last hear from
/// the Agent" diagnostic. Never downgrades <see cref="DriverState.Faulted"/>, which is a
/// stronger, config-level signal that a transient read failure must not paper over.
/// </summary>
private void DegradeAfterReadFailure(Exception ex)
{
var current = ReadHealth();
if (current.State != DriverState.Faulted)
{
WriteHealth(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message));
}
_logger.LogWarning(
ex,
"MTConnect driver {DriverInstanceId} could not read /current from {AgentUri}; the batch is reported Bad.",
_driverInstanceId, _options.AgentUri);
}
private static void SafeDispose(IMTConnectAgentClient? client)
{
if (client is null)