feat(mtconnect): driver shell IDriver lifecycle over agent-client seam (Task 9)
MTConnectDriver implements IDriver only: connection-free ctor, initialize (deadline-bounded /probe + /current, caching the device model, the agent instanceId, and a primed observation index), reinitialize, shutdown, health, footprint, and cache flush. Decisions worth knowing: - The driverConfigJson argument is HONOURED, not ignored. DriverInstanceActor delivers a config change only via ReinitializeAsync(json) on the live instance, so a driver reading only its ctor options would turn every MTConnect config edit into a silent no-op that still sealed the deployment green. ParseOptions lives on the driver; Task 15's factory must delegate to it rather than deserialize a second DTO. - /probe ok but priming /current failing is Faulted, not Healthy-with-no-data: the index IS the read surface and the /sample cursor comes from /current. - ReinitializeAsync rebuilds the client whenever ANY option the client reads at construction changed - AgentUri, DeviceName, RequestTimeoutMs, HeartbeatMs, SampleIntervalMs, SampleCount - not just the first two. The timing knobs are baked into the deadlines, the watchdog window, and the /sample query string. - An unparseable config faults a cold start but leaves a RUNNING driver serving its previous valid configuration (the deployment fails instead). - IMTConnectAgentClient now extends IDisposable rather than the driver doing `as IDisposable`, which would silently no-op against a non-disposable fake and leak an HttpClient + socket handler per re-deploy. Sync, not async: every resource behind the seam is sync-disposable, and the async unwind belongs to the SampleAsync enumerator the pump owns. - Flush drops the probe/browse cache and keeps the index; every model consumer goes through GetOrFetchProbeModelAsync so a flush cannot wedge browse. Tests: CannedAgentClient (shared, deterministic - channel-backed scripted /sample with no timers, call counts, disposal tracking, failure injection) + 27 lifecycle tests. 308/308 in the project.
This commit is contained in:
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
|||||||
/// canned probe/current/sample XML, with no sockets involved.
|
/// canned probe/current/sample XML, with no sockets involved.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// The production implementation, <see cref="MTConnectAgentClient"/>, carries no backend NuGet
|
/// The production implementation, <see cref="MTConnectAgentClient"/>, carries no backend NuGet
|
||||||
/// at all: it is an <see cref="HttpClient"/> over the Agent's three request paths, feeding
|
/// at all: it is an <see cref="HttpClient"/> over the Agent's three request paths, feeding
|
||||||
/// hand-rolled <c>System.Xml.Linq</c> parsers (<c>MTConnectProbeParser</c> /
|
/// hand-rolled <c>System.Xml.Linq</c> parsers (<c>MTConnectProbeParser</c> /
|
||||||
@@ -19,8 +20,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
|||||||
/// needs to implement these three instance members; <see cref="IsSequenceGap"/> is a static
|
/// needs to implement these three instance members; <see cref="IsSequenceGap"/> is a static
|
||||||
/// protocol rule that lives here, rather than on the implementation, so a consumer written
|
/// protocol rule that lives here, rather than on the implementation, so a consumer written
|
||||||
/// against this seam never has to reference the concrete client.
|
/// against this seam never has to reference the concrete client.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why the seam is <see cref="IDisposable"/> rather than
|
||||||
|
/// <see cref="IAsyncDisposable"/>.</b> The driver obtains its client through a
|
||||||
|
/// <c>Func<MTConnectDriverOptions, IMTConnectAgentClient></c> and must release it on
|
||||||
|
/// <c>ShutdownAsync</c> and on every endpoint-changing <c>ReinitializeAsync</c>. Declaring
|
||||||
|
/// disposal here — instead of the driver doing <c>as IDisposable</c> + a null-conditional
|
||||||
|
/// call — is deliberate: that idiom silently no-ops against any future non-disposable
|
||||||
|
/// implementation, and the thing it would leak is an <see cref="HttpClient"/> and its socket
|
||||||
|
/// handler, once per re-deploy, for the life of the process.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Synchronous disposal is the honest shape because <i>every</i> resource behind this seam is
|
||||||
|
/// synchronously disposable — two <see cref="HttpClient"/>s and a
|
||||||
|
/// <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
|
||||||
|
/// 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"/>.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public interface IMTConnectAgentClient
|
public interface IMTConnectAgentClient : IDisposable
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Issues an Agent <c>/probe</c> request and returns the parsed device hierarchy. Called
|
/// Issues an Agent <c>/probe</c> request and returns the parsed device hierarchy. Called
|
||||||
|
|||||||
@@ -0,0 +1,729 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MTConnect Agent implementation of <see cref="IDriver"/> — the lifecycle half. Everything the
|
||||||
|
/// driver serves sits behind one <see cref="IMTConnectAgentClient"/>, so the whole class is
|
||||||
|
/// exercised against canned XML with no socket.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The constructor opens nothing and builds nothing.</b> It does not even call
|
||||||
|
/// <c>agentClientFactory</c> — the Wave-0 universal discovery browser constructs a throwaway
|
||||||
|
/// instance purely to ask whether the driver can browse, and a constructor that materialized
|
||||||
|
/// a client would open a connection pool per browse probe against a possibly-unreachable
|
||||||
|
/// Agent. Every connection is owned by <see cref="InitializeAsync"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The <c>driverConfigJson</c> argument is honoured, not decorative.</b> The runtime hands
|
||||||
|
/// a config change to a <i>live</i> instance — <c>DriverInstanceActor</c>'s <c>ApplyDelta</c>
|
||||||
|
/// calls <see cref="ReinitializeAsync"/> with the new document and never rebuilds the object
|
||||||
|
/// — so a driver that read only its constructor options would turn every MTConnect config
|
||||||
|
/// edit into a silent no-op that still seals the deployment green. This class therefore
|
||||||
|
/// re-parses the document (<see cref="ParseOptions"/>) whenever it carries a real body,
|
||||||
|
/// matching S7/AbCip/TwinCAT/Galaxy rather than the Modbus/FOCAS/OpcUaClient drivers that
|
||||||
|
/// ignore the argument. A blank / <c>{}</c> / <c>[]</c> document means "no config supplied"
|
||||||
|
/// and keeps the constructor options, which is what unit tests and the factory path rely on.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Failure is never dressed as success (#485).</b> A failed initialize disposes whatever
|
||||||
|
/// client it built, drops the probe cache and the observation index, publishes
|
||||||
|
/// <see cref="DriverState.Faulted"/> with the error text, and rethrows — the runtime's
|
||||||
|
/// retry/backoff loop is the recovery path. In particular a <c>/probe</c> that succeeds while
|
||||||
|
/// the priming <c>/current</c> fails is Faulted, not "Healthy with no data": the index IS the
|
||||||
|
/// read surface, so serving that state would render every tag
|
||||||
|
/// <c>BadWaitingForInitialData</c> indefinitely behind a green driver, and the
|
||||||
|
/// <c>/sample</c> cursor (Task 11) only exists because <c>/current</c> reported a
|
||||||
|
/// <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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class MTConnectDriver : IDriver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Coarse per-entry byte weights behind <see cref="GetMemoryFootprint"/>. The contract asks
|
||||||
|
/// for an <i>approximate</i> driver-attributable footprint that Core polls every 30 s to
|
||||||
|
/// decide whether to ask for a flush, so an order-of-magnitude estimate from the two things
|
||||||
|
/// that actually scale with a plant (declared data items, live observations) is the right
|
||||||
|
/// precision. Measuring real retained size would need a heap walk per poll.
|
||||||
|
/// </summary>
|
||||||
|
private const int ProbeModelBytesPerDataItem = 512;
|
||||||
|
|
||||||
|
/// <summary>Estimated retained bytes per live <c>dataItemId → snapshot</c> entry.</summary>
|
||||||
|
private const int IndexBytesPerEntry = 256;
|
||||||
|
|
||||||
|
/// <summary>Estimated retained bytes per authored <see cref="MTConnectTagDefinition"/>.</summary>
|
||||||
|
private const int TagBytesPerEntry = 256;
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
/// <c>string?</c> and go through <see cref="ParseEnum{T}"/>, which is the project-wide
|
||||||
|
/// guard against the numerically-serialized-enum trap that faults a driver at deploy time.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly string _driverInstanceId;
|
||||||
|
private readonly ILogger<MTConnectDriver> _logger;
|
||||||
|
private readonly Func<MTConnectDriverOptions, IMTConnectAgentClient> _agentClientFactory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes the three lifecycle entry points against each other. Initialize / Reinitialize
|
||||||
|
/// / Shutdown all swap the client and the served caches; overlapping them would let a
|
||||||
|
/// shutdown dispose a client an in-flight initialize is about to publish.
|
||||||
|
/// </summary>
|
||||||
|
private readonly SemaphoreSlim _lifecycle = new(1, 1);
|
||||||
|
|
||||||
|
private MTConnectDriverOptions _options;
|
||||||
|
private IMTConnectAgentClient? _client;
|
||||||
|
private MTConnectObservationIndex _index;
|
||||||
|
private MTConnectProbeModel? _probeModel;
|
||||||
|
private int _probeModelDataItemCount;
|
||||||
|
private long? _agentInstanceId;
|
||||||
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
|
|
||||||
|
/// <summary>Creates a driver instance. Opens no connection and builds no agent client.</summary>
|
||||||
|
/// <param name="options">
|
||||||
|
/// The typed configuration this instance starts from. A config document supplied to
|
||||||
|
/// <see cref="InitializeAsync"/> / <see cref="ReinitializeAsync"/> supersedes it.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="driverInstanceId">Stable logical id of this driver instance, from the config DB.</param>
|
||||||
|
/// <param name="agentClientFactory">
|
||||||
|
/// Builds the Agent client from the effective options. Defaults to the production
|
||||||
|
/// <see cref="MTConnectAgentClient"/>; tests inject a canned-XML fake. Called from
|
||||||
|
/// <see cref="InitializeAsync"/>, never from this constructor.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="logger">Optional logger; defaults to the null logger.</param>
|
||||||
|
public MTConnectDriver(
|
||||||
|
MTConnectDriverOptions options,
|
||||||
|
string driverInstanceId,
|
||||||
|
Func<MTConnectDriverOptions, IMTConnectAgentClient>? agentClientFactory = null,
|
||||||
|
ILogger<MTConnectDriver>? logger = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
|
|
||||||
|
_options = options;
|
||||||
|
_driverInstanceId = driverInstanceId;
|
||||||
|
_logger = logger ?? NullLogger<MTConnectDriver>.Instance;
|
||||||
|
_agentClientFactory = agentClientFactory ?? (o => new MTConnectAgentClient(o, logger));
|
||||||
|
|
||||||
|
// Never null, so every consumer (Task 10's ReadAsync included) can ask it for a snapshot
|
||||||
|
// without a null check: an un-primed index answers BadWaitingForInitialData for an authored
|
||||||
|
// tag and BadNodeIdUnknown otherwise, which is exactly the truth before Initialize runs.
|
||||||
|
_index = new MTConnectObservationIndex(options.Tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string DriverInstanceId => _driverInstanceId;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
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.
|
||||||
|
/// </summary>
|
||||||
|
internal MTConnectObservationIndex ObservationIndex => Volatile.Read(ref _index);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The cached <c>/probe</c> device model, or <c>null</c> when it has not been fetched or was
|
||||||
|
/// dropped by <see cref="FlushOptionalCachesAsync"/>. Prefer
|
||||||
|
/// <see cref="GetOrFetchProbeModelAsync"/>, which cannot observe the flushed hole.
|
||||||
|
/// </summary>
|
||||||
|
internal MTConnectProbeModel? CachedProbeModel => _probeModel;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Agent's <c>instanceId</c> as of the last successful <c>/current</c>. Task 13 watches
|
||||||
|
/// this for change to raise rediscovery (an Agent restart invalidates every cached id).
|
||||||
|
/// </summary>
|
||||||
|
internal long? AgentInstanceId => _agentInstanceId;
|
||||||
|
|
||||||
|
/// <summary>The options currently in force — the constructor's, or the last document applied.</summary>
|
||||||
|
internal MTConnectDriverOptions EffectiveOptions => _options;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
/// <remarks>
|
||||||
|
/// Builds the Agent client from the effective options, runs one deadline-bounded
|
||||||
|
/// <c>/probe</c> (caching the device model and its data-item count), then one
|
||||||
|
/// deadline-bounded <c>/current</c> to prime the observation index and record the Agent's
|
||||||
|
/// <c>instanceId</c>. Publishes <see cref="DriverState.Healthy"/> on success; on any failure
|
||||||
|
/// disposes the client, clears the caches, publishes <see cref="DriverState.Faulted"/> with
|
||||||
|
/// the error text, and rethrows.
|
||||||
|
/// </remarks>
|
||||||
|
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _lifecycle.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// A re-Initialize on a live instance must not orphan the previous client.
|
||||||
|
await TeardownCoreAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
MTConnectDriverOptions options;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
options = ResolveOptions(driverConfigJson);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A config document that will not parse is just as fatal to a cold start as an
|
||||||
|
// unreachable Agent, and it must reach GetHealth() the same way — otherwise the
|
||||||
|
// dashboard keeps showing whatever state the driver was in before.
|
||||||
|
WriteHealth(new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, ex.Message));
|
||||||
|
_logger.LogError(
|
||||||
|
ex,
|
||||||
|
"MTConnect driver {DriverInstanceId} could not read its driver config document; the driver is Faulted.",
|
||||||
|
_driverInstanceId);
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
await StartCoreAsync(options, existingClient: null, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lifecycle.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Stops the <c>/sample</c> stream, then takes one of two paths depending on whether the
|
||||||
|
/// new document changes anything the <see cref="IMTConnectAgentClient"/> reads at
|
||||||
|
/// <b>construction</b>:
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Endpoint/transport changed</b> (<c>AgentUri</c>, <c>DeviceName</c>,
|
||||||
|
/// <c>RequestTimeoutMs</c>, <c>HeartbeatMs</c>, <c>SampleIntervalMs</c>,
|
||||||
|
/// <c>SampleCount</c>) ⇒ full teardown and rebuild. The plan names only the first two,
|
||||||
|
/// but the other four are baked into the client's deadlines, its watchdog window, and its
|
||||||
|
/// <c>/sample</c> query string, so keeping the old client because the URI happened not to
|
||||||
|
/// change would silently discard the operator's edit — the same class of defect as
|
||||||
|
/// ignoring the config document altogether.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Otherwise</b> ("config-only": authored tags, probe cadence, reconnect backoff) ⇒
|
||||||
|
/// the live client and its connection pool are kept, and everything derived from config
|
||||||
|
/// is rebuilt: the probe model is re-fetched, the observation index is recreated against
|
||||||
|
/// the new tag types, and one <c>/current</c> re-primes it. Nothing config-derived
|
||||||
|
/// survives, so a retyped tag cannot keep coercing to its old type.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _lifecycle.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MTConnectDriverOptions incoming;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
incoming = ResolveOptions(driverConfigJson);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Deliberately NOT Faulted, and deliberately before any teardown: the running
|
||||||
|
// driver is still serving its previous, valid configuration. Rejecting the edit
|
||||||
|
// fails the deployment (DriverInstanceActor turns this into ApplyResult(false));
|
||||||
|
// downing a working driver over an unreadable document would be a far larger blast
|
||||||
|
// radius than the change that was refused.
|
||||||
|
_logger.LogError(
|
||||||
|
ex,
|
||||||
|
"MTConnect driver {DriverInstanceId} rejected a new driver config document and kept running its previous configuration.",
|
||||||
|
_driverInstanceId);
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = _client;
|
||||||
|
|
||||||
|
await StopSampleStreamAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (existing is null || RequiresClientRebuild(_options, incoming))
|
||||||
|
{
|
||||||
|
await TeardownCoreAsync().ConfigureAwait(false);
|
||||||
|
await StartCoreAsync(incoming, existingClient: null, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await StartCoreAsync(incoming, existing, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lifecycle.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
/// <remarks>
|
||||||
|
/// Stops the <c>/sample</c> stream, disposes the Agent client, and drops the probe cache and
|
||||||
|
/// the observation index — values sourced from a torn-down connection must never keep
|
||||||
|
/// reading Good. <c>LastSuccessfulRead</c> is retained because it is diagnostic ("when did
|
||||||
|
/// this driver last hear from the Agent"), not served data. Idempotent.
|
||||||
|
/// </remarks>
|
||||||
|
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _lifecycle.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lastRead = ReadHealth().LastSuccessfulRead;
|
||||||
|
|
||||||
|
await StopSampleStreamAsync().ConfigureAwait(false);
|
||||||
|
await TeardownCoreAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lifecycle.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public DriverHealth GetHealth() => ReadHealth();
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
/// <remarks>
|
||||||
|
/// Sums the three caches that scale with plant size: the <c>/probe</c> device model (by
|
||||||
|
/// declared data item), the live observation index (by indexed data item), and the authored
|
||||||
|
/// tag table. See <see cref="ProbeModelBytesPerDataItem"/> for why the weights are coarse
|
||||||
|
/// constants.
|
||||||
|
/// </remarks>
|
||||||
|
public long GetMemoryFootprint() =>
|
||||||
|
((long)_probeModelDataItemCount * ProbeModelBytesPerDataItem)
|
||||||
|
+ ((long)ObservationIndex.Count * IndexBytesPerEntry)
|
||||||
|
+ ((long)_options.Tags.Count * TagBytesPerEntry);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
/// <remarks>
|
||||||
|
/// Drops the <c>/probe</c> device model — the browse cache, which is re-derivable from the
|
||||||
|
/// Agent at any time — and keeps the observation index, which is required for correctness
|
||||||
|
/// (it is the read surface, and re-deriving it would mean a full re-prime the caller did not
|
||||||
|
/// ask for). Flushing cannot wedge the driver: every consumer of the model goes through
|
||||||
|
/// <see cref="GetOrFetchProbeModelAsync"/>, which re-fetches and re-caches on a miss.
|
||||||
|
/// </remarks>
|
||||||
|
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var dropped = _probeModelDataItemCount;
|
||||||
|
|
||||||
|
_probeModel = null;
|
||||||
|
_probeModelDataItemCount = 0;
|
||||||
|
|
||||||
|
if (dropped > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"MTConnect driver {DriverInstanceId} flushed its cached /probe device model ({DataItemCount} data items); it is re-fetched on the next browse.",
|
||||||
|
_driverInstanceId, dropped);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
/// model consumer (Task 12's <c>DiscoverAsync</c>) must use, so that
|
||||||
|
/// <see cref="FlushOptionalCachesAsync"/> can never leave browse permanently disabled.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The Agent's device model.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">The driver is not initialized.</exception>
|
||||||
|
internal async Task<MTConnectProbeModel> GetOrFetchProbeModelAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cached = _probeModel;
|
||||||
|
if (cached is not null)
|
||||||
|
{
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = _client
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"MTConnect driver '{_driverInstanceId}' has no agent client; the /probe device model cannot be fetched before InitializeAsync succeeds.");
|
||||||
|
|
||||||
|
var model = await BoundedAsync(client.ProbeAsync, "/probe", _options.RequestTimeoutMs, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
CacheProbeModel(model);
|
||||||
|
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds driver options from a <c>DriverConfig</c> JSON document.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Lives here rather than on the factory because the driver itself needs it: the runtime
|
||||||
|
/// delivers config changes as JSON to a live instance (see the class remarks). Task 15's
|
||||||
|
/// <c>MTConnectDriverFactoryExtensions.CreateInstance</c> should <b>delegate to this method</b>
|
||||||
|
/// rather than deserializing a second DTO — two parsers for one document drift.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="driverInstanceId">Driver instance id, used only in error text.</param>
|
||||||
|
/// <param name="driverConfigJson">The driver's <c>DriverConfig</c> JSON.</param>
|
||||||
|
/// <returns>The parsed options.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">The document is unusable or omits <c>agentUri</c>.</exception>
|
||||||
|
internal static MTConnectDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
|
|
||||||
|
MTConnectDriverConfigDto? dto;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
dto = JsonSerializer.Deserialize<MTConnectDriverConfigDto>(driverConfigJson, JsonOptions);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"MTConnect driver config for '{driverInstanceId}' is not valid JSON: {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"MTConnect driver config for '{driverInstanceId}' deserialised to null");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(dto.AgentUri))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"MTConnect driver config for '{driverInstanceId}' missing required AgentUri");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MTConnectDriverOptions
|
||||||
|
{
|
||||||
|
AgentUri = dto.AgentUri.Trim(),
|
||||||
|
DeviceName = string.IsNullOrWhiteSpace(dto.DeviceName) ? null : dto.DeviceName.Trim(),
|
||||||
|
RequestTimeoutMs = dto.RequestTimeoutMs ?? 5_000,
|
||||||
|
SampleIntervalMs = dto.SampleIntervalMs ?? 1_000,
|
||||||
|
SampleCount = dto.SampleCount ?? 1_000,
|
||||||
|
HeartbeatMs = dto.HeartbeatMs ?? 10_000,
|
||||||
|
Tags = dto.Tags is { Count: > 0 }
|
||||||
|
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
|
||||||
|
: [],
|
||||||
|
Probe = new MTConnectProbeOptions
|
||||||
|
{
|
||||||
|
Enabled = dto.Probe?.Enabled ?? true,
|
||||||
|
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
|
||||||
|
},
|
||||||
|
Reconnect = new MTConnectReconnectOptions
|
||||||
|
{
|
||||||
|
MinBackoffMs = dto.Reconnect?.MinBackoffMs ?? 0,
|
||||||
|
MaxBackoffMs = dto.Reconnect?.MaxBackoffMs ?? 30_000,
|
||||||
|
BackoffMultiplier = dto.Reconnect?.BackoffMultiplier ?? 2.0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- lifecycle internals (all called with _lifecycle held) ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Brings the driver up against <paramref name="options"/>, reusing
|
||||||
|
/// <paramref name="existingClient"/> when the caller determined the transport is unchanged.
|
||||||
|
/// Nothing is published until BOTH agent calls have succeeded, so a failure can never leave a
|
||||||
|
/// half-initialized instance behind.
|
||||||
|
/// </summary>
|
||||||
|
private async Task StartCoreAsync(
|
||||||
|
MTConnectDriverOptions options, IMTConnectAgentClient? existingClient, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var lastRead = ReadHealth().LastSuccessfulRead;
|
||||||
|
WriteHealth(new DriverHealth(DriverState.Initializing, lastRead, null));
|
||||||
|
|
||||||
|
IMTConnectAgentClient? built = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// arch-review 01/S-6: a 0 authored here must never come to mean "wait forever". Checked
|
||||||
|
// in the driver, not only in the production client's constructor, so the guard holds for
|
||||||
|
// any client implementation behind the seam.
|
||||||
|
RequirePositive(options.RequestTimeoutMs, nameof(MTConnectDriverOptions.RequestTimeoutMs));
|
||||||
|
RequirePositive(options.HeartbeatMs, nameof(MTConnectDriverOptions.HeartbeatMs));
|
||||||
|
RequirePositive(options.SampleIntervalMs, nameof(MTConnectDriverOptions.SampleIntervalMs));
|
||||||
|
RequirePositive(options.SampleCount, nameof(MTConnectDriverOptions.SampleCount));
|
||||||
|
|
||||||
|
var client = existingClient;
|
||||||
|
if (client is null)
|
||||||
|
{
|
||||||
|
built = _agentClientFactory(options)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"The MTConnect agent-client factory for driver '{_driverInstanceId}' returned null.");
|
||||||
|
client = built;
|
||||||
|
}
|
||||||
|
|
||||||
|
var probe = await BoundedAsync(client.ProbeAsync, "/probe", options.RequestTimeoutMs, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
var current = await BoundedAsync(client.CurrentAsync, "/current", options.RequestTimeoutMs, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// ---- commit point: everything below is non-throwing bookkeeping ----
|
||||||
|
_options = options;
|
||||||
|
_client = client;
|
||||||
|
built = null;
|
||||||
|
|
||||||
|
CacheProbeModel(probe);
|
||||||
|
_agentInstanceId = current.InstanceId;
|
||||||
|
|
||||||
|
var index = new MTConnectObservationIndex(options.Tags);
|
||||||
|
index.Apply(current);
|
||||||
|
Volatile.Write(ref _index, index);
|
||||||
|
|
||||||
|
WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"MTConnect driver {DriverInstanceId} initialized against {AgentUri}{DeviceScope}: {DeviceCount} device(s), {ObservationCount} primed observation(s), agent instanceId {InstanceId}.",
|
||||||
|
_driverInstanceId,
|
||||||
|
options.AgentUri,
|
||||||
|
options.DeviceName is null ? string.Empty : $"/{options.DeviceName}",
|
||||||
|
probe.Devices.Count,
|
||||||
|
index.Count,
|
||||||
|
current.InstanceId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A client this attempt built is unreachable from anywhere else; one the caller handed in
|
||||||
|
// is disposed by the teardown below. Either way nothing survives a failed start.
|
||||||
|
SafeDispose(built);
|
||||||
|
await TeardownCoreAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
WriteHealth(new DriverHealth(DriverState.Faulted, lastRead, ex.Message));
|
||||||
|
|
||||||
|
_logger.LogError(
|
||||||
|
ex,
|
||||||
|
"MTConnect driver {DriverInstanceId} failed to initialize against {AgentUri}; the driver is Faulted and serves no values.",
|
||||||
|
_driverInstanceId, options.AgentUri);
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases the agent client and drops every piece of served state. Never throws — it runs
|
||||||
|
/// on the failure path of <see cref="StartCoreAsync"/>, where a second exception would mask
|
||||||
|
/// the real one.
|
||||||
|
/// </summary>
|
||||||
|
private Task TeardownCoreAsync()
|
||||||
|
{
|
||||||
|
var client = _client;
|
||||||
|
_client = null;
|
||||||
|
SafeDispose(client);
|
||||||
|
|
||||||
|
_probeModel = null;
|
||||||
|
_probeModelDataItemCount = 0;
|
||||||
|
_agentInstanceId = null;
|
||||||
|
|
||||||
|
// A fresh index rather than Clear(): the tag table it coerces against belongs to the options
|
||||||
|
// that are being torn down.
|
||||||
|
Volatile.Write(ref _index, new MTConnectObservationIndex(_options.Tags));
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the shared <c>/sample</c> long-poll stream. A no-op until Task 11 introduces the
|
||||||
|
/// pump; it exists now because both <see cref="ReinitializeAsync"/> and
|
||||||
|
/// <see cref="ShutdownAsync"/> must stop the stream <b>before</b> the client they are about
|
||||||
|
/// to dispose goes away, and that ordering is easy to lose when the pump is bolted on later.
|
||||||
|
/// </summary>
|
||||||
|
private static Task StopSampleStreamAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <summary>The document's options when it carries a body; otherwise the ones already in force.</summary>
|
||||||
|
private MTConnectDriverOptions ResolveOptions(string driverConfigJson) =>
|
||||||
|
HasConfigBody(driverConfigJson) ? ParseOptions(_driverInstanceId, driverConfigJson) : _options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when <paramref name="driverConfigJson"/> carries a real config body. The
|
||||||
|
/// bootstrapper always passes a populated document; tests and probe-only callers pass
|
||||||
|
/// <c>"{}"</c>, <c>"[]"</c> or blank, which keep the constructor-supplied options.
|
||||||
|
/// </summary>
|
||||||
|
private static bool HasConfigBody(string? driverConfigJson)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(driverConfigJson))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = driverConfigJson.Trim();
|
||||||
|
|
||||||
|
return trimmed is not "{}" and not "[]";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the new options change anything <see cref="IMTConnectAgentClient"/> reads at
|
||||||
|
/// construction, and therefore cannot be applied to a live client. See
|
||||||
|
/// <see cref="ReinitializeAsync"/> for why this is wider than the plan's AgentUri/DeviceName.
|
||||||
|
/// </summary>
|
||||||
|
private static bool RequiresClientRebuild(MTConnectDriverOptions current, MTConnectDriverOptions incoming) =>
|
||||||
|
!string.Equals(current.AgentUri, incoming.AgentUri, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(current.DeviceName, incoming.DeviceName, StringComparison.Ordinal)
|
||||||
|
|| current.RequestTimeoutMs != incoming.RequestTimeoutMs
|
||||||
|
|| current.HeartbeatMs != incoming.HeartbeatMs
|
||||||
|
|| current.SampleIntervalMs != incoming.SampleIntervalMs
|
||||||
|
|| current.SampleCount != incoming.SampleCount;
|
||||||
|
|
||||||
|
private void CacheProbeModel(MTConnectProbeModel model)
|
||||||
|
{
|
||||||
|
_probeModel = model;
|
||||||
|
|
||||||
|
// Counted once here rather than re-walked on every 30 s footprint poll.
|
||||||
|
_probeModelDataItemCount = model.Devices.Sum(d => d.AllDataItems().Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs one unary agent call under a deadline linked to the caller's token. Belt-and-braces
|
||||||
|
/// over the production client's own per-call deadline: the seam does not oblige an
|
||||||
|
/// implementation to bound itself, and an unbounded initialize would wedge the driver-host
|
||||||
|
/// actor (arch-review R2-01).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<T> BoundedAsync<T>(
|
||||||
|
Func<CancellationToken, Task<T>> call, string request, int timeoutMs, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
deadline.CancelAfter(TimeSpan.FromMilliseconds(timeoutMs));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await call(deadline.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// A bare "A task was canceled" names neither the Agent nor the request that stalled.
|
||||||
|
throw new TimeoutException(
|
||||||
|
$"MTConnect {request} request for driver '{_driverInstanceId}' against '{_options.AgentUri}' did not complete within {timeoutMs} ms.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SafeDispose(IMTConnectAgentClient? client)
|
||||||
|
{
|
||||||
|
if (client is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
client.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Teardown runs on failure paths; a disposal fault must not replace the original error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RequirePositive(int value, string optionName)
|
||||||
|
{
|
||||||
|
if (value <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(MTConnectDriverOptions),
|
||||||
|
value,
|
||||||
|
$"{optionName} must be positive; a non-positive value would either un-bound a deadline or ask the Agent for nothing.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Barrier-protected read of the cross-thread health snapshot.</summary>
|
||||||
|
private DriverHealth ReadHealth() => Volatile.Read(ref _health);
|
||||||
|
|
||||||
|
/// <summary>Barrier-protected publish of a new health snapshot.</summary>
|
||||||
|
private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
|
||||||
|
|
||||||
|
private static MTConnectTagDefinition BuildTag(MTConnectTagDto dto, string driverInstanceId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(dto.FullName))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"MTConnect driver config for '{driverInstanceId}' has a tag with no FullName (the Agent DataItem id it binds).");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MTConnectTagDefinition(
|
||||||
|
dto.FullName.Trim(),
|
||||||
|
// Absent means "no declared type": String is the one coercion that cannot fail, and it
|
||||||
|
// carries the Agent's text through untouched.
|
||||||
|
dto.DriverDataType is null
|
||||||
|
? DriverDataType.String
|
||||||
|
: ParseEnum<DriverDataType>(dto.DriverDataType, dto.FullName, driverInstanceId, nameof(MTConnectTagDefinition.DriverDataType)),
|
||||||
|
dto.IsArray ?? false,
|
||||||
|
dto.ArrayDim ?? 0,
|
||||||
|
dto.MtCategory,
|
||||||
|
dto.MtType,
|
||||||
|
dto.MtSubType,
|
||||||
|
dto.Units);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses an enum authored as a <b>name</b>. Config surfaces serialize enums as names
|
||||||
|
/// project-wide; a numerically-serialized enum is the known trap that faults a driver at
|
||||||
|
/// deploy, so this reports the offending field rather than silently taking member 0.
|
||||||
|
/// </summary>
|
||||||
|
private static T ParseEnum<T>(string raw, string tagName, string driverInstanceId, string field)
|
||||||
|
where T : struct, Enum =>
|
||||||
|
Enum.TryParse<T>(raw, ignoreCase: true, out var parsed) && Enum.IsDefined(parsed)
|
||||||
|
? parsed
|
||||||
|
: throw new InvalidOperationException(string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"MTConnect driver config for '{driverInstanceId}' tag '{tagName}' has an unrecognised {field} '{raw}'. Expected one of: {string.Join(", ", Enum.GetNames<T>())}."));
|
||||||
|
|
||||||
|
// ---- config DTOs ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nullable-init mirror of the driver's <c>DriverConfig</c> JSON. Every property is nullable
|
||||||
|
/// so an omitted key means "use the default" rather than "reset to zero".
|
||||||
|
/// </summary>
|
||||||
|
private sealed class MTConnectDriverConfigDto
|
||||||
|
{
|
||||||
|
public string? AgentUri { get; init; }
|
||||||
|
public string? DeviceName { get; init; }
|
||||||
|
public int? RequestTimeoutMs { get; init; }
|
||||||
|
public int? SampleIntervalMs { get; init; }
|
||||||
|
public int? SampleCount { get; init; }
|
||||||
|
public int? HeartbeatMs { get; init; }
|
||||||
|
public List<MTConnectTagDto>? Tags { get; init; }
|
||||||
|
public MTConnectProbeDto? Probe { get; init; }
|
||||||
|
public MTConnectReconnectDto? Reconnect { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One authored tag. <c>DriverDataType</c> stays a string — see <see cref="ParseEnum{T}"/>.</summary>
|
||||||
|
private sealed class MTConnectTagDto
|
||||||
|
{
|
||||||
|
public string? FullName { get; init; }
|
||||||
|
public string? DriverDataType { get; init; }
|
||||||
|
public bool? IsArray { get; init; }
|
||||||
|
public int? ArrayDim { get; init; }
|
||||||
|
public string? MtCategory { get; init; }
|
||||||
|
public string? MtType { get; init; }
|
||||||
|
public string? MtSubType { get; init; }
|
||||||
|
public string? Units { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Background connectivity-probe knobs (Task 13 consumes them).</summary>
|
||||||
|
private sealed class MTConnectProbeDto
|
||||||
|
{
|
||||||
|
public bool? Enabled { get; init; }
|
||||||
|
public int? IntervalMs { get; init; }
|
||||||
|
public int? TimeoutMs { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reconnect-backoff knobs (Task 11's pump consumes them).</summary>
|
||||||
|
private sealed class MTConnectReconnectDto
|
||||||
|
{
|
||||||
|
public int? MinBackoffMs { get; init; }
|
||||||
|
public int? MaxBackoffMs { get; init; }
|
||||||
|
public double? BackoffMultiplier { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The shared <see cref="IMTConnectAgentClient"/> test double: an Agent that serves the canned
|
||||||
|
/// <c>Fixtures/probe.xml</c> + <c>Fixtures/current.xml</c> documents, counts every call, records
|
||||||
|
/// its own disposal, and lets a test drive the <c>/sample</c> chunk sequence by hand.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Deterministic by construction — no timers, no sleeps, no polling.</b> The
|
||||||
|
/// <c>/sample</c> leg reads from an unbounded <see cref="Channel{T}"/> that only a test
|
||||||
|
/// fills, and <see cref="PumpAsync"/> does not return until the driver has actually consumed
|
||||||
|
/// the chunk it wrote (each chunk carries its own completion source, signalled after the
|
||||||
|
/// enumerator's <c>yield return</c> resumes). A subscription test can therefore say "one
|
||||||
|
/// chunk has now been fully processed" as a fact rather than as a timing hope.
|
||||||
|
/// </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
|
||||||
|
/// 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
|
||||||
|
/// fake cannot silently pass while mishandling the real one.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Disposal is observable</b> (<see cref="DisposeCount"/>) because "did the driver
|
||||||
|
/// actually release the client?" is a behaviour, not an implementation detail: a
|
||||||
|
/// <c>ReinitializeAsync</c> that re-points the Agent but leaks the old client keeps a live
|
||||||
|
/// connection pool per re-deploy, and nothing else in the test surface would notice.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
||||||
|
{
|
||||||
|
private readonly Channel<ScriptedChunk> _chunks = Channel.CreateUnbounded<ScriptedChunk>();
|
||||||
|
private readonly CancellationTokenSource _disposeCts = new();
|
||||||
|
|
||||||
|
private int _probeCallCount;
|
||||||
|
private int _currentCallCount;
|
||||||
|
private int _sampleCallCount;
|
||||||
|
private int _disposeCount;
|
||||||
|
|
||||||
|
private CannedAgentClient(MTConnectProbeModel probe, MTConnectStreamsResult current)
|
||||||
|
{
|
||||||
|
Probe = probe;
|
||||||
|
Current = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a client serving the repo's canned fixtures — <c>Fixtures/probe.xml</c> for
|
||||||
|
/// <c>/probe</c> and <c>Fixtures/current.xml</c> for <c>/current</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="probeFixture">Probe-document fixture path, relative to the test binaries.</param>
|
||||||
|
/// <param name="currentFixture">Streams-document fixture path, relative to the test binaries.</param>
|
||||||
|
public static CannedAgentClient FromFixtures(
|
||||||
|
string probeFixture = "Fixtures/probe.xml",
|
||||||
|
string currentFixture = "Fixtures/current.xml") =>
|
||||||
|
new(
|
||||||
|
MTConnectProbeParser.Parse(File.ReadAllText(probeFixture)),
|
||||||
|
MTConnectStreamsParser.Parse(File.ReadAllText(currentFixture)));
|
||||||
|
|
||||||
|
/// <summary>Parses a streams fixture into a chunk a test can script onto the sample stream.</summary>
|
||||||
|
/// <param name="fixture">Streams-document fixture path, relative to the test binaries.</param>
|
||||||
|
public static MTConnectStreamsResult Chunk(string fixture) =>
|
||||||
|
MTConnectStreamsParser.Parse(File.ReadAllText(fixture));
|
||||||
|
|
||||||
|
/// <summary>The document <c>/probe</c> answers with. Settable so a test can re-shape the model.</summary>
|
||||||
|
public MTConnectProbeModel Probe { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The document <c>/current</c> answers with. Settable so a re-baseline (or an agent-restart
|
||||||
|
/// <c>instanceId</c> change) can be scripted between calls.
|
||||||
|
/// </summary>
|
||||||
|
public MTConnectStreamsResult Current { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When set, <c>/probe</c> throws this instead of answering.</summary>
|
||||||
|
public Exception? ProbeFailure { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When set, <c>/current</c> throws this instead of answering.</summary>
|
||||||
|
public Exception? CurrentFailure { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Number of <c>/probe</c> requests issued against this client.</summary>
|
||||||
|
public int ProbeCallCount => Volatile.Read(ref _probeCallCount);
|
||||||
|
|
||||||
|
/// <summary>Number of <c>/current</c> requests issued against this client.</summary>
|
||||||
|
public int CurrentCallCount => Volatile.Read(ref _currentCallCount);
|
||||||
|
|
||||||
|
/// <summary>Number of <c>/sample</c> enumerations started against this client.</summary>
|
||||||
|
public int SampleCallCount => Volatile.Read(ref _sampleCallCount);
|
||||||
|
|
||||||
|
/// <summary>Number of <see cref="Dispose"/> calls (not clamped — a double-dispose is visible).</summary>
|
||||||
|
public int DisposeCount => Volatile.Read(ref _disposeCount);
|
||||||
|
|
||||||
|
/// <summary>Whether the client has been disposed at least once.</summary>
|
||||||
|
public bool IsDisposed => DisposeCount > 0;
|
||||||
|
|
||||||
|
/// <summary>The <c>from</c> sequence the most recent <c>/sample</c> enumeration was opened at.</summary>
|
||||||
|
public long? LastSampleFrom { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
Interlocked.Increment(ref _probeCallCount);
|
||||||
|
|
||||||
|
return ProbeFailure is null ? Task.FromResult(Probe) : Task.FromException<MTConnectProbeModel>(ProbeFailure);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
Interlocked.Increment(ref _currentCallCount);
|
||||||
|
|
||||||
|
return CurrentFailure is null
|
||||||
|
? Task.FromResult(Current)
|
||||||
|
: Task.FromException<MTConnectStreamsResult>(CurrentFailure);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(
|
||||||
|
long from, [EnumeratorCancellation] CancellationToken ct)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||||
|
Interlocked.Increment(ref _sampleCallCount);
|
||||||
|
LastSampleFrom = from;
|
||||||
|
|
||||||
|
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
|
||||||
|
var delivered = 0L;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
ScriptedChunk scripted;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
scripted = await _chunks.Reader.ReadAsync(lifetime.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (ChannelClosedException)
|
||||||
|
{
|
||||||
|
// Mirrors the production client: a stream that stops for any reason other than the
|
||||||
|
// caller cancelling is an exception, never a quiet end of enumeration.
|
||||||
|
throw new MTConnectStreamEndedException(
|
||||||
|
MTConnectStreamEndReason.ConnectionClosed, "canned://agent", delivered);
|
||||||
|
}
|
||||||
|
|
||||||
|
delivered++;
|
||||||
|
|
||||||
|
yield return scripted.Result;
|
||||||
|
|
||||||
|
scripted.Consumed.TrySetResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queues a chunk onto the scripted <c>/sample</c> stream and waits until the consumer has
|
||||||
|
/// processed it. This is the deterministic replacement for "wait a bit and hope the pump
|
||||||
|
/// ran".
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="chunk">The chunk the Agent should send next.</param>
|
||||||
|
/// <returns>A task completing once the enumerating consumer has moved past <paramref name="chunk"/>.</returns>
|
||||||
|
public Task PumpAsync(MTConnectStreamsResult chunk)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(chunk);
|
||||||
|
|
||||||
|
var scripted = new ScriptedChunk(
|
||||||
|
chunk, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
|
||||||
|
|
||||||
|
if (!_chunks.Writer.TryWrite(scripted))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The scripted /sample stream is already closed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return scripted.Consumed.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes the scripted <c>/sample</c> stream, which surfaces to the consumer as an
|
||||||
|
/// <see cref="MTConnectStreamEndedException"/> — the transient, reconnectable end.
|
||||||
|
/// </summary>
|
||||||
|
public void EndStream() => _chunks.Writer.TryComplete();
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _disposeCount);
|
||||||
|
|
||||||
|
if (DisposeCount > 1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposeCts.Cancel();
|
||||||
|
_chunks.Writer.TryComplete();
|
||||||
|
_disposeCts.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record ScriptedChunk(MTConnectStreamsResult Result, TaskCompletionSource Consumed);
|
||||||
|
}
|
||||||
+548
@@ -0,0 +1,548 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 9 — <see cref="MTConnectDriver"/>'s <see cref="IDriver"/> lifecycle over the
|
||||||
|
/// <see cref="IMTConnectAgentClient"/> seam: construction, initialize, reinitialize, shutdown,
|
||||||
|
/// health, footprint, and cache flush. Every test runs against
|
||||||
|
/// <see cref="CannedAgentClient"/>; no socket is opened anywhere in this file.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The load-bearing assertions here are the ones about <b>what must NOT happen</b>: a
|
||||||
|
/// constructor that dials, an initialize that faults but still reports Healthy, a re-point that
|
||||||
|
/// keeps talking to the old Agent, and a cache flush that wedges browse. Each of those is a
|
||||||
|
/// defect the happy-path assertions alone would pass straight over.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class MTConnectDriverLifecycleTests
|
||||||
|
{
|
||||||
|
private const string AgentUri = "http://fixture-agent:5000";
|
||||||
|
|
||||||
|
/// <summary>Every dataItemId the canned probe declares — the browse-cache footprint basis.</summary>
|
||||||
|
private const int FixtureDataItemCount = 7;
|
||||||
|
|
||||||
|
private static MTConnectDriverOptions Opts(
|
||||||
|
string agentUri = AgentUri,
|
||||||
|
string? deviceName = null,
|
||||||
|
int requestTimeoutMs = 5000) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
AgentUri = agentUri,
|
||||||
|
DeviceName = deviceName,
|
||||||
|
RequestTimeoutMs = requestTimeoutMs,
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64),
|
||||||
|
new MTConnectTagDefinition("dev1_execution", DriverDataType.String),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>A driver wired to one canned client, plus that client, for the common case.</summary>
|
||||||
|
private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver(
|
||||||
|
MTConnectDriverOptions? options = null)
|
||||||
|
{
|
||||||
|
var client = CannedAgentClient.FromFixtures();
|
||||||
|
|
||||||
|
return (new MTConnectDriver(options ?? Opts(), "mt1", _ => client), client);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync()
|
||||||
|
{
|
||||||
|
var (driver, client) = NewDriver();
|
||||||
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
return (driver, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- connection-free construction ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_dials_nothing_and_does_not_even_build_a_client()
|
||||||
|
{
|
||||||
|
var factoryCalls = 0;
|
||||||
|
var client = CannedAgentClient.FromFixtures();
|
||||||
|
|
||||||
|
var driver = new MTConnectDriver(Opts(), "mt1", _ =>
|
||||||
|
{
|
||||||
|
factoryCalls++;
|
||||||
|
|
||||||
|
return client;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The universal discovery browser constructs a throwaway instance purely to ask CanBrowse.
|
||||||
|
// If the ctor built (or worse, dialled) a client, every browse probe would open a connection
|
||||||
|
// pool against a possibly-unreachable Agent.
|
||||||
|
factoryCalls.ShouldBe(0);
|
||||||
|
client.ProbeCallCount.ShouldBe(0);
|
||||||
|
client.CurrentCallCount.ShouldBe(0);
|
||||||
|
client.SampleCallCount.ShouldBe(0);
|
||||||
|
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Identity_is_the_constructor_arguments()
|
||||||
|
{
|
||||||
|
var (driver, _) = NewDriver();
|
||||||
|
|
||||||
|
driver.DriverInstanceId.ShouldBe("mt1");
|
||||||
|
driver.DriverType.ShouldBe("MTConnect");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- initialize ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Initialize_primes_index_and_reports_healthy()
|
||||||
|
{
|
||||||
|
var (driver, client) = NewDriver();
|
||||||
|
|
||||||
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||||
|
client.ProbeCallCount.ShouldBe(1);
|
||||||
|
client.CurrentCallCount.ShouldBe(1);
|
||||||
|
|
||||||
|
// The /current snapshot is what makes the driver able to answer a read at all.
|
||||||
|
driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
|
||||||
|
driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Initialize_records_the_agent_instance_id_and_the_probe_model()
|
||||||
|
{
|
||||||
|
var (driver, client) = NewDriver();
|
||||||
|
|
||||||
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
driver.AgentInstanceId.ShouldBe(client.Current.InstanceId);
|
||||||
|
driver.CachedProbeModel.ShouldNotBeNull();
|
||||||
|
driver.CachedProbeModel!.Devices.Count.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Initialize_stamps_last_successful_read_between_the_call_boundaries()
|
||||||
|
{
|
||||||
|
var (driver, _) = NewDriver();
|
||||||
|
|
||||||
|
var before = DateTime.UtcNow;
|
||||||
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
var after = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var lastRead = driver.GetHealth().LastSuccessfulRead;
|
||||||
|
lastRead.ShouldNotBeNull();
|
||||||
|
lastRead!.Value.ShouldBeGreaterThanOrEqualTo(before);
|
||||||
|
lastRead.Value.ShouldBeLessThanOrEqualTo(after);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Initialize_faults_and_rethrows_when_probe_fails()
|
||||||
|
{
|
||||||
|
var (driver, client) = NewDriver();
|
||||||
|
client.ProbeFailure = new HttpRequestException("agent unreachable");
|
||||||
|
|
||||||
|
var thrown = await Should.ThrowAsync<HttpRequestException>(
|
||||||
|
() => driver.InitializeAsync("{}", CancellationToken.None));
|
||||||
|
|
||||||
|
thrown.Message.ShouldBe("agent unreachable");
|
||||||
|
|
||||||
|
var health = driver.GetHealth();
|
||||||
|
health.State.ShouldBe(DriverState.Faulted);
|
||||||
|
health.LastError.ShouldNotBeNull();
|
||||||
|
health.LastError!.ShouldContain("agent unreachable");
|
||||||
|
|
||||||
|
// No half-initialized object: the client it built must not survive the failure.
|
||||||
|
client.IsDisposed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Initialize_faults_when_probe_succeeds_but_the_priming_current_fails()
|
||||||
|
{
|
||||||
|
// Deliberate posture (documented on InitializeAsync): a driver whose /probe worked but whose
|
||||||
|
// priming /current did not has an EMPTY value index and no sample cursor. Reporting Healthy
|
||||||
|
// there would render every tag BadWaitingForInitialData forever while the dashboard shows a
|
||||||
|
// green driver — the #485 "a failure that looks like success" shape.
|
||||||
|
var (driver, client) = NewDriver();
|
||||||
|
client.CurrentFailure = new InvalidDataException("current unparseable");
|
||||||
|
|
||||||
|
await Should.ThrowAsync<InvalidDataException>(
|
||||||
|
() => driver.InitializeAsync("{}", CancellationToken.None));
|
||||||
|
|
||||||
|
client.ProbeCallCount.ShouldBe(1);
|
||||||
|
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||||
|
driver.GetHealth().State.ShouldNotBe(DriverState.Healthy);
|
||||||
|
driver.CachedProbeModel.ShouldBeNull();
|
||||||
|
driver.ObservationIndex.Count.ShouldBe(0);
|
||||||
|
client.IsDisposed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Initialize_rejects_a_non_positive_request_timeout_before_dialling()
|
||||||
|
{
|
||||||
|
// arch-review 01/S-6: an operator-authorable 0 must never come to mean "wait forever".
|
||||||
|
// Asserted through a FAKE client factory, so this proves the DRIVER validates rather than
|
||||||
|
// relying on the production client's own constructor guard.
|
||||||
|
var factoryCalls = 0;
|
||||||
|
var driver = new MTConnectDriver(Opts(requestTimeoutMs: 0), "mt1", _ =>
|
||||||
|
{
|
||||||
|
factoryCalls++;
|
||||||
|
|
||||||
|
return CannedAgentClient.FromFixtures();
|
||||||
|
});
|
||||||
|
|
||||||
|
await Should.ThrowAsync<ArgumentOutOfRangeException>(
|
||||||
|
() => driver.InitializeAsync("{}", CancellationToken.None));
|
||||||
|
|
||||||
|
factoryCalls.ShouldBe(0);
|
||||||
|
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the driverConfigJson argument ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Initialize_honours_the_config_json_over_the_constructor_options()
|
||||||
|
{
|
||||||
|
// The runtime delivers a config change ONLY through this argument (DriverInstanceActor
|
||||||
|
// ApplyDelta -> ReinitializeAsync(json) on the LIVE instance). Ignoring it would make every
|
||||||
|
// MTConnect config change a silent no-op that still seals the deployment green.
|
||||||
|
MTConnectDriverOptions? seen = null;
|
||||||
|
var driver = new MTConnectDriver(Opts(), "mt1", o =>
|
||||||
|
{
|
||||||
|
seen = o;
|
||||||
|
|
||||||
|
return CannedAgentClient.FromFixtures();
|
||||||
|
});
|
||||||
|
|
||||||
|
await driver.InitializeAsync(
|
||||||
|
"""{"agentUri":"http://other-agent:5001","deviceName":"VMC-3Axis"}""",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
seen.ShouldNotBeNull();
|
||||||
|
seen!.AgentUri.ShouldBe("http://other-agent:5001");
|
||||||
|
seen.DeviceName.ShouldBe("VMC-3Axis");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Initialize_with_an_empty_config_body_keeps_the_constructor_options()
|
||||||
|
{
|
||||||
|
MTConnectDriverOptions? seen = null;
|
||||||
|
var driver = new MTConnectDriver(Opts(), "mt1", o =>
|
||||||
|
{
|
||||||
|
seen = o;
|
||||||
|
|
||||||
|
return CannedAgentClient.FromFixtures();
|
||||||
|
});
|
||||||
|
|
||||||
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
seen.ShouldNotBeNull();
|
||||||
|
seen!.AgentUri.ShouldBe(AgentUri);
|
||||||
|
seen.Tags.Count.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Initialize_faults_on_a_config_body_with_no_agent_uri()
|
||||||
|
{
|
||||||
|
var (driver, _) = NewDriver();
|
||||||
|
|
||||||
|
await Should.ThrowAsync<InvalidOperationException>(
|
||||||
|
() => driver.InitializeAsync("""{"deviceName":"VMC-3Axis"}""", CancellationToken.None));
|
||||||
|
|
||||||
|
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Initialize_parses_authored_tags_out_of_the_config_json()
|
||||||
|
{
|
||||||
|
MTConnectDriverOptions? seen = null;
|
||||||
|
var driver = new MTConnectDriver(Opts(), "mt1", o =>
|
||||||
|
{
|
||||||
|
seen = o;
|
||||||
|
|
||||||
|
return CannedAgentClient.FromFixtures();
|
||||||
|
});
|
||||||
|
|
||||||
|
await driver.InitializeAsync(
|
||||||
|
"""
|
||||||
|
{"agentUri":"http://a:5000",
|
||||||
|
"tags":[{"fullName":"dev1_partcount","driverDataType":"Int32"}]}
|
||||||
|
""",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
seen.ShouldNotBeNull();
|
||||||
|
seen!.Tags.Count.ShouldBe(1);
|
||||||
|
seen.Tags[0].FullName.ShouldBe("dev1_partcount");
|
||||||
|
|
||||||
|
// Enum-serialization trap (project-wide MEMORY): enum-carrying config fields are NAMES.
|
||||||
|
seen.Tags[0].DriverDataType.ShouldBe(DriverDataType.Int32);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- reinitialize ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reinitialize_with_unchanged_endpoint_config_keeps_the_same_client()
|
||||||
|
{
|
||||||
|
var built = new List<CannedAgentClient>();
|
||||||
|
var driver = new MTConnectDriver(Opts(), "mt1", _ =>
|
||||||
|
{
|
||||||
|
var c = CannedAgentClient.FromFixtures();
|
||||||
|
built.Add(c);
|
||||||
|
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
|
||||||
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await driver.ReinitializeAsync(
|
||||||
|
$$"""{"agentUri":"{{AgentUri}}","tags":[{"fullName":"dev1_pos","driverDataType":"Float64"}]}""",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Config-only: the live connection pool survives, so no client churn...
|
||||||
|
built.Count.ShouldBe(1);
|
||||||
|
built[0].IsDisposed.ShouldBeFalse();
|
||||||
|
|
||||||
|
// ...but everything DERIVED from config is genuinely rebuilt against the new document.
|
||||||
|
built[0].ProbeCallCount.ShouldBe(2);
|
||||||
|
built[0].CurrentCallCount.ShouldBe(2);
|
||||||
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reinitialize_with_a_changed_agent_uri_tears_down_and_rebuilds_the_client()
|
||||||
|
{
|
||||||
|
var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>();
|
||||||
|
var driver = new MTConnectDriver(Opts(), "mt1", o =>
|
||||||
|
{
|
||||||
|
var c = CannedAgentClient.FromFixtures();
|
||||||
|
built.Add((o, c));
|
||||||
|
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
|
||||||
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await driver.ReinitializeAsync(
|
||||||
|
"""{"agentUri":"http://relocated-agent:5000"}""", CancellationToken.None);
|
||||||
|
|
||||||
|
// A re-pointed driver that kept the old client would keep reading the OLD machine while the
|
||||||
|
// deployment reports success.
|
||||||
|
built.Count.ShouldBe(2);
|
||||||
|
built[0].Client.IsDisposed.ShouldBeTrue();
|
||||||
|
built[1].Options.AgentUri.ShouldBe("http://relocated-agent:5000");
|
||||||
|
built[1].Client.IsDisposed.ShouldBeFalse();
|
||||||
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reinitialize_with_a_changed_device_scope_rebuilds_the_client()
|
||||||
|
{
|
||||||
|
var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>();
|
||||||
|
var driver = new MTConnectDriver(Opts(), "mt1", o =>
|
||||||
|
{
|
||||||
|
var c = CannedAgentClient.FromFixtures();
|
||||||
|
built.Add((o, c));
|
||||||
|
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
|
||||||
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await driver.ReinitializeAsync(
|
||||||
|
$$"""{"agentUri":"{{AgentUri}}","deviceName":"VMC-3Axis"}""", CancellationToken.None);
|
||||||
|
|
||||||
|
// DeviceName is baked into every request URI at client construction.
|
||||||
|
built.Count.ShouldBe(2);
|
||||||
|
built[0].Client.IsDisposed.ShouldBeTrue();
|
||||||
|
built[1].Options.DeviceName.ShouldBe("VMC-3Axis");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reinitialize_with_changed_request_knobs_rebuilds_the_client()
|
||||||
|
{
|
||||||
|
// RequestTimeoutMs / HeartbeatMs / SampleIntervalMs / SampleCount are all read by the client
|
||||||
|
// CONSTRUCTOR (deadlines, watchdog window, /sample query string). Keeping the old client
|
||||||
|
// because the URI happened not to change would silently discard the operator's edit.
|
||||||
|
var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>();
|
||||||
|
var driver = new MTConnectDriver(Opts(), "mt1", o =>
|
||||||
|
{
|
||||||
|
var c = CannedAgentClient.FromFixtures();
|
||||||
|
built.Add((o, c));
|
||||||
|
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
|
||||||
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
await driver.ReinitializeAsync(
|
||||||
|
$$"""{"agentUri":"{{AgentUri}}","heartbeatMs":2500}""", CancellationToken.None);
|
||||||
|
|
||||||
|
built.Count.ShouldBe(2);
|
||||||
|
built[1].Options.HeartbeatMs.ShouldBe(2500);
|
||||||
|
built[0].Client.IsDisposed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reinitialize_before_initialize_behaves_as_initialize()
|
||||||
|
{
|
||||||
|
var (driver, client) = NewDriver();
|
||||||
|
|
||||||
|
await driver.ReinitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||||
|
client.ProbeCallCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reinitialize_that_fails_leaves_the_driver_faulted_not_healthy()
|
||||||
|
{
|
||||||
|
var built = new List<CannedAgentClient>();
|
||||||
|
var driver = new MTConnectDriver(Opts(), "mt1", _ =>
|
||||||
|
{
|
||||||
|
var c = CannedAgentClient.FromFixtures();
|
||||||
|
built.Add(c);
|
||||||
|
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
|
||||||
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
built[0].CurrentFailure = new HttpRequestException("agent went away");
|
||||||
|
|
||||||
|
// Config-only shape (only the tag list changes), so the failing leg is the re-prime against
|
||||||
|
// the client that is already live — the path a rebuild would otherwise mask.
|
||||||
|
await Should.ThrowAsync<HttpRequestException>(
|
||||||
|
() => driver.ReinitializeAsync(
|
||||||
|
$$"""{"agentUri":"{{AgentUri}}","tags":[{"fullName":"dev1_pos"}]}""", CancellationToken.None));
|
||||||
|
|
||||||
|
built.Count.ShouldBe(1);
|
||||||
|
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||||
|
driver.GetHealth().LastError.ShouldNotBeNull().ShouldContain("agent went away");
|
||||||
|
|
||||||
|
// A failed refresh must not leave a live client nobody owns.
|
||||||
|
built[0].IsDisposed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reinitialize_with_an_unreadable_config_keeps_the_running_driver_serving()
|
||||||
|
{
|
||||||
|
// Blast radius: an unparseable config edit fails the DEPLOYMENT (ApplyResult(false)); it must
|
||||||
|
// not down a driver that is happily serving its previous, valid configuration.
|
||||||
|
var (driver, client) = await InitializedDriverAsync();
|
||||||
|
|
||||||
|
await Should.ThrowAsync<InvalidOperationException>(
|
||||||
|
() => driver.ReinitializeAsync("""{"deviceName":"no-agent-uri"}""", CancellationToken.None));
|
||||||
|
|
||||||
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||||
|
driver.EffectiveOptions.AgentUri.ShouldBe(AgentUri);
|
||||||
|
client.IsDisposed.ShouldBeFalse();
|
||||||
|
driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- shutdown ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Shutdown_disposes_the_client_and_drops_the_served_state()
|
||||||
|
{
|
||||||
|
var (driver, client) = await InitializedDriverAsync();
|
||||||
|
var lastRead = driver.GetHealth().LastSuccessfulRead;
|
||||||
|
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
client.DisposeCount.ShouldBe(1);
|
||||||
|
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||||
|
|
||||||
|
// Retained: it is diagnostic ("when did we last hear from the Agent"), not served data.
|
||||||
|
driver.GetHealth().LastSuccessfulRead.ShouldBe(lastRead);
|
||||||
|
|
||||||
|
// Dropped: values from a torn-down connection must never keep reading Good.
|
||||||
|
driver.ObservationIndex.Count.ShouldBe(0);
|
||||||
|
driver.CachedProbeModel.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Shutdown_is_idempotent()
|
||||||
|
{
|
||||||
|
var (driver, client) = await InitializedDriverAsync();
|
||||||
|
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
client.DisposeCount.ShouldBe(1);
|
||||||
|
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Shutdown_before_initialize_is_a_no_op()
|
||||||
|
{
|
||||||
|
var (driver, client) = NewDriver();
|
||||||
|
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
client.DisposeCount.ShouldBe(0);
|
||||||
|
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- footprint + flush ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Memory_footprint_is_zero_before_initialize_and_positive_after()
|
||||||
|
{
|
||||||
|
var (driver, _) = NewDriver();
|
||||||
|
|
||||||
|
var before = driver.GetMemoryFootprint();
|
||||||
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
var after = driver.GetMemoryFootprint();
|
||||||
|
|
||||||
|
// Before init the only config-attributable state is the two authored tags.
|
||||||
|
before.ShouldBeLessThan(after);
|
||||||
|
after.ShouldBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Flush_drops_the_probe_cache_and_keeps_the_observation_index()
|
||||||
|
{
|
||||||
|
var (driver, _) = await InitializedDriverAsync();
|
||||||
|
var before = driver.GetMemoryFootprint();
|
||||||
|
|
||||||
|
await driver.FlushOptionalCachesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
driver.GetMemoryFootprint().ShouldBeLessThan(before);
|
||||||
|
driver.CachedProbeModel.ShouldBeNull();
|
||||||
|
|
||||||
|
// The index is required for correctness (it IS the read surface) — never flushable.
|
||||||
|
driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
|
||||||
|
driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u);
|
||||||
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Flush_does_not_wedge_the_driver_the_probe_model_is_refetched_on_demand()
|
||||||
|
{
|
||||||
|
// Task 12's DiscoverAsync reads the probe model. If flushing left no way back to it, a
|
||||||
|
// cache-budget breach would permanently disable browse on that driver instance.
|
||||||
|
var (driver, client) = await InitializedDriverAsync();
|
||||||
|
await driver.FlushOptionalCachesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
client.ProbeCallCount.ShouldBe(1);
|
||||||
|
|
||||||
|
var model = await driver.GetOrFetchProbeModelAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
model.Devices.Count.ShouldBe(1);
|
||||||
|
client.ProbeCallCount.ShouldBe(2);
|
||||||
|
driver.CachedProbeModel.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Probe_model_is_served_from_cache_while_it_is_warm()
|
||||||
|
{
|
||||||
|
var (driver, client) = await InitializedDriverAsync();
|
||||||
|
|
||||||
|
_ = await driver.GetOrFetchProbeModelAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
client.ProbeCallCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Fetching_the_probe_model_before_initialize_is_rejected_rather_than_silently_empty()
|
||||||
|
{
|
||||||
|
var (driver, _) = NewDriver();
|
||||||
|
|
||||||
|
await Should.ThrowAsync<InvalidOperationException>(
|
||||||
|
() => driver.GetOrFetchProbeModelAsync(CancellationToken.None));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user