merge: MTConnect review findings resolved (Driver.MTConnect-004 write-back authoring, -005/-006/-007)
# Conflicts: # code-reviews/Driver.MTConnect/findings.md
This commit is contained in:
@@ -82,7 +82,16 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
private readonly string _sampleQuerySuffix;
|
||||
private readonly TimeSpan _requestTimeout;
|
||||
private readonly TimeSpan _streamIdleTimeout;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// <c>1</c> once <see cref="Dispose"/> has claimed the teardown. An <see cref="int"/> rather
|
||||
/// than a <c>bool</c> so the claim is an <see cref="Interlocked.Exchange(ref int, int)"/>:
|
||||
/// a check-then-set let two concurrent disposals both pass, and the loser then cancelled a
|
||||
/// <see cref="CancellationTokenSource"/> the winner had already disposed
|
||||
/// (Driver.MTConnect-007). Read with <c>Volatile.Read</c> at the request seams for the same
|
||||
/// reason.
|
||||
/// </summary>
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>Creates a client for the Agent described by <paramref name="options"/>.</summary>
|
||||
/// <param name="options">The driver options carrying the Agent URI, device scope, and per-call deadline.</param>
|
||||
@@ -157,7 +166,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
/// <inheritdoc/>
|
||||
public async Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||
|
||||
// ResponseContentRead (the default) buffers the whole body *under* this awaited, token-bound
|
||||
// call, so the parse below reads from memory — no network read can outlive the deadline.
|
||||
@@ -171,7 +180,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
/// <inheritdoc/>
|
||||
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||
|
||||
using var response = await SendAsync(_currentUri, ct).ConfigureAwait(false);
|
||||
|
||||
@@ -185,7 +194,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
long from,
|
||||
[EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||
|
||||
var uri = BuildSampleUri(from);
|
||||
|
||||
@@ -257,7 +266,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
public async Task<MTConnectWriteOutcome> WriteObservationAsync(
|
||||
string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
@@ -371,15 +380,18 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Idempotent, including under a race: the teardown is claimed with a single atomic exchange,
|
||||
/// so a second concurrent caller returns rather than cancelling an already-disposed
|
||||
/// <see cref="CancellationTokenSource"/> (Driver.MTConnect-007).
|
||||
/// </remarks>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
// Cancel BEFORE disposing the clients so an in-flight read observes cancellation rather
|
||||
// than a disposed handler.
|
||||
_disposeCts.Cancel();
|
||||
|
||||
@@ -292,6 +292,21 @@ public sealed class MTConnectDriver
|
||||
/// </summary>
|
||||
private long _announcedInstanceId;
|
||||
|
||||
/// <summary>
|
||||
/// The options document currently in force. Installed at the <c>StartCoreAsync</c> commit
|
||||
/// point alongside <see cref="_binding"/> / <see cref="_session"/> /
|
||||
/// <see cref="_announcedInstanceId"/>, and read lock-free from <c>ReadAsync</c>,
|
||||
/// <c>WriteAsync</c>, <c>DiscoverAsync</c>, <c>HostName</c> and the health paths.
|
||||
/// <para>
|
||||
/// <b>Published with <c>Volatile.Write</c> and read with <c>Volatile.Read</c>, exactly
|
||||
/// like its siblings</b>, so a reader that
|
||||
/// takes this field BEFORE it takes <see cref="_binding"/> — which both <c>ReadAsync</c>
|
||||
/// and <c>WriteAsync</c> do — has the ordering that stops it judging a batch against the
|
||||
/// previous document's <c>Write.Enabled</c> / <c>RequestTimeoutMs</c> paired with the new
|
||||
/// tag table (Driver.MTConnect-005). Pinned by
|
||||
/// <c>MTConnectOptionsPublicationTests</c>, which fails on any unfenced touch.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private MTConnectDriverOptions _options;
|
||||
|
||||
/// <summary>
|
||||
@@ -418,7 +433,7 @@ public sealed class MTConnectDriver
|
||||
// Sets _options AND the tag tables derived from it. No /probe model is available here (the
|
||||
// constructor dials nothing), so a raw tag whose blob declares no type falls back to String
|
||||
// until InitializeAsync rebuilds the binding against the Agent's own declaration.
|
||||
_options = options;
|
||||
Volatile.Write(ref _options, options);
|
||||
_binding = BuildBinding(options, probeModel: null);
|
||||
|
||||
// Never null, so every consumer (Task 10's ReadAsync included) can ask it for a snapshot
|
||||
@@ -506,7 +521,7 @@ public sealed class MTConnectDriver
|
||||
internal Task? ProbeLoopTask => Volatile.Read(ref _probeTask);
|
||||
|
||||
/// <summary>The options currently in force — the constructor's, or the last document applied.</summary>
|
||||
internal MTConnectDriverOptions EffectiveOptions => _options;
|
||||
internal MTConnectDriverOptions EffectiveOptions => Volatile.Read(ref _options);
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
@@ -581,7 +596,7 @@ public sealed class MTConnectDriver
|
||||
await StopSampleStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
await StopProbeLoopAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (existing is null || RequiresClientRebuild(_options, incoming))
|
||||
if (existing is null || RequiresClientRebuild(Volatile.Read(ref _options), incoming))
|
||||
{
|
||||
await TeardownCoreAsync().ConfigureAwait(false);
|
||||
await StartCoreAsync(incoming, existingClient: null, cancellationToken).ConfigureAwait(false);
|
||||
@@ -686,7 +701,7 @@ public sealed class MTConnectDriver
|
||||
// One read of the session reference: the client used below is the one that was live at this
|
||||
// instant, never a half-swapped pairing (see the _session remarks).
|
||||
var client = Volatile.Read(ref _session)?.Client;
|
||||
var options = _options;
|
||||
var options = Volatile.Read(ref _options);
|
||||
|
||||
// Read once, for the same reason the session is: every reference in this batch must be
|
||||
// resolved against ONE tag table, not half against the table a concurrent re-initialize
|
||||
@@ -794,7 +809,7 @@ public sealed class MTConnectDriver
|
||||
// Read once each, for the same reason ReadAsync does: every slot in this batch is judged
|
||||
// against ONE options document and ONE tag table, never half against a table a concurrent
|
||||
// re-initialize just replaced.
|
||||
var options = _options;
|
||||
var options = Volatile.Read(ref _options);
|
||||
var client = Volatile.Read(ref _session)?.Client;
|
||||
var binding = Volatile.Read(ref _binding);
|
||||
|
||||
@@ -895,12 +910,18 @@ public sealed class MTConnectDriver
|
||||
/// it is what an MTConnect consumer will see verbatim.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Arrays are refused rather than rendered.</b>
|
||||
/// <b>Anything that is not <see cref="IConvertible"/> is refused rather than rendered.</b>
|
||||
/// <see cref="Convert.ToString(object, IFormatProvider)"/> falls back to
|
||||
/// <see cref="object.ToString"/> for a non-<see cref="IConvertible"/> value, so an array
|
||||
/// would be written as the literal text <c>System.Double[]</c> — which the Agent stores
|
||||
/// happily, making the corruption invisible until someone reads the observation back.
|
||||
/// TIME_SERIES / DATA_SET writes are out of scope for v1.
|
||||
/// The guard is therefore written against that property itself rather than against
|
||||
/// <see cref="Array"/>, the one type it was first noticed on: an
|
||||
/// <c>Opc.Ua.ExtensionObject</c>, an <c>XmlElement</c> or a <c>List<T></c> from a
|
||||
/// non-SDK caller corrupts an observation in exactly the same way (Driver.MTConnect-006).
|
||||
/// <c>string</c>, <c>bool</c>, <see cref="DateTime"/> and every numeric type ARE
|
||||
/// <see cref="IConvertible"/>, so the accepted set is unchanged. TIME_SERIES / DATA_SET
|
||||
/// writes are out of scope for v1.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every formatting throw is caught, not just the documented conversion ones.</b> The
|
||||
@@ -916,8 +937,10 @@ public sealed class MTConnectDriver
|
||||
text = string.Empty;
|
||||
|
||||
// A PUT query has no null to send, and the empty string is a real observation rather than an
|
||||
// absence — so "no value" is refused instead of being silently turned into one.
|
||||
if (value is null or Array)
|
||||
// absence — so "no value" is refused instead of being silently turned into one. Everything
|
||||
// that is not IConvertible is refused with it: Convert.ToString renders such a value as its
|
||||
// TYPE NAME, which the Agent stores under a Good status (see the remarks).
|
||||
if (value is null or Array or not IConvertible)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -1202,7 +1225,7 @@ public sealed class MTConnectDriver
|
||||
// to another.
|
||||
// Both read once, together: a concurrent re-initialize must not scope half this walk to one
|
||||
// device and half to another, nor stamp half its leaves writable and half not.
|
||||
var options = _options;
|
||||
var options = Volatile.Read(ref _options);
|
||||
var deviceScope = options.DeviceName?.Trim();
|
||||
var writeEnabled = options.Write.Enabled;
|
||||
|
||||
@@ -1422,7 +1445,7 @@ public sealed class MTConnectDriver
|
||||
/// <c>host:port</c> convention: several MTConnect drivers in one server disambiguate on the
|
||||
/// Admin dashboard by endpoint rather than by driver-instance id.
|
||||
/// </summary>
|
||||
public string HostName => _options.AgentUri;
|
||||
public string HostName => Volatile.Read(ref _options).AgentUri;
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
@@ -1796,12 +1819,14 @@ public sealed class MTConnectDriver
|
||||
// that is not running; without this, the resubscribe returns a handle, the caller is
|
||||
// told SubscriptionEstablished, and the next successful read reports a perfectly green
|
||||
// driver over a subscription that can never deliver a value (#485).
|
||||
var agentUri = Volatile.Read(ref _options).AgentUri;
|
||||
|
||||
_streamDegraded = true;
|
||||
Degrade($"The MTConnect Agent at '{_options.AgentUri}' does not serve a framed /sample stream; subscriptions cannot deliver until the driver is re-initialized.");
|
||||
Degrade($"The MTConnect Agent at '{agentUri}' does not serve a framed /sample stream; subscriptions cannot deliver until the driver is re-initialized.");
|
||||
|
||||
_logger.LogWarning(
|
||||
"MTConnect driver {DriverInstanceId} refused to start a /sample stream for {AgentUri}: the endpoint was already found not to stream. The subscription is registered but will receive NO updates until the driver is re-initialized.",
|
||||
_driverInstanceId, _options.AgentUri);
|
||||
_driverInstanceId, agentUri);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -1828,7 +1853,7 @@ public sealed class MTConnectDriver
|
||||
// _subscriberFaultLogged).
|
||||
Interlocked.Exchange(ref _subscriberFaultLogged, 0);
|
||||
|
||||
var options = _options;
|
||||
var options = Volatile.Read(ref _options);
|
||||
var cts = new CancellationTokenSource();
|
||||
_pumpCts = cts;
|
||||
_pumpTask = Task.Run(
|
||||
@@ -2497,7 +2522,8 @@ public sealed class MTConnectDriver
|
||||
MTConnectProbeModel model;
|
||||
try
|
||||
{
|
||||
model = await BoundedAsync(session.Client.ProbeAsync, "/probe", _options.RequestTimeoutMs, cancellationToken)
|
||||
model = await BoundedAsync(
|
||||
session.Client.ProbeAsync, "/probe", Volatile.Read(ref _options).RequestTimeoutMs, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (ObjectDisposedException ex)
|
||||
@@ -2652,7 +2678,7 @@ public sealed class MTConnectDriver
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// ---- commit point: everything below is non-throwing bookkeeping ----
|
||||
_options = options;
|
||||
Volatile.Write(ref _options, options);
|
||||
|
||||
// Derived from the options AND the /probe model just fetched: a raw tag whose TagConfig
|
||||
// blob declares no type takes the type the Agent itself declares for that DataItem, so a
|
||||
@@ -2904,7 +2930,7 @@ public sealed class MTConnectDriver
|
||||
{
|
||||
return HasConfigBody(driverConfigJson)
|
||||
? ParseOptions(_driverInstanceId, driverConfigJson)
|
||||
: _options;
|
||||
: Volatile.Read(ref _options);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -3013,7 +3039,7 @@ public sealed class MTConnectDriver
|
||||
{
|
||||
// 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.");
|
||||
$"MTConnect {request} request for driver '{_driverInstanceId}' against '{Volatile.Read(ref _options).AgentUri}' did not complete within {timeoutMs} ms.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3050,7 +3076,7 @@ public sealed class MTConnectDriver
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"MTConnect driver {DriverInstanceId} could not read /current from {AgentUri}; the batch is reported Bad.",
|
||||
_driverInstanceId, _options.AgentUri);
|
||||
_driverInstanceId, Volatile.Read(ref _options).AgentUri);
|
||||
}
|
||||
|
||||
/// <summary>Publishes <see cref="DriverState.Degraded"/> without downgrading a Faulted driver.</summary>
|
||||
|
||||
+54
-3
@@ -3,7 +3,9 @@
|
||||
paths (/probe, /current, /sample) to it and optionally narrows to one DeviceName, so unlike Modbus/S7
|
||||
there is no host/port to split onto the device.
|
||||
|
||||
MTConnect is read-only — there are no write knobs to author. *@
|
||||
Write-back is authored here too (Driver.MTConnect-004): the driver gained IWritable behind
|
||||
write.enabled, and until this section existed the documented enablement path was a hand edit of
|
||||
DriverInstance.DriverConfig in the config DB. *@
|
||||
@using System.Text.Json
|
||||
@using System.Text.Json.Nodes
|
||||
@using System.Text.Json.Serialization
|
||||
@@ -109,6 +111,34 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@* Write-back *@
|
||||
<section class="panel rise mt-3" style="animation-delay:.14s">
|
||||
<div class="panel-head">Write-back</div>
|
||||
<div style="padding:1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch">
|
||||
<InputCheckbox @bind-Value="_form.WriteEnabled" @bind-Value:after="EmitAsync" class="form-check-input" id="mtcWriteEnabled" />
|
||||
<label class="form-check-label" for="mtcWriteEnabled">Allow write-back</label>
|
||||
</div>
|
||||
<div class="form-text mt-0">Default off.</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="form-text">
|
||||
An MTConnect write is <strong>observation injection</strong>: it sets what the Agent
|
||||
<em>reports</em> for a DataItem, not the machine itself, and the next adapter update overwrites it.
|
||||
<br />
|
||||
This is only one of two gates — the Agent's own <span class="mono">AllowPut</span> (its
|
||||
<span class="mono">agent.cfg</span>) must be on as well, and neither gate can arm the other.
|
||||
Turning this on does not by itself make any node writable over OPC UA: the tag's
|
||||
<span class="mono">AccessLevel</span> still composes the <span class="mono">CurrentWrite</span> bit.
|
||||
CONDITION items are never writable.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (_form.Validate() is { } error)
|
||||
{
|
||||
<div class="panel notice mt-3" style="border-color:var(--alert)">@error</div>
|
||||
@@ -165,8 +195,8 @@
|
||||
/// <para>
|
||||
/// Unknown TOP-LEVEL keys in <paramref name="existingJson"/> are preserved (notably the
|
||||
/// driver's hand-authored <c>tags[]</c> surface, which this form does not expose and must not
|
||||
/// silently delete). The <c>probe</c> and <c>reconnect</c> sub-objects are replaced wholesale
|
||||
/// — the form owns every field in both.
|
||||
/// silently delete). The <c>probe</c>, <c>reconnect</c> and <c>write</c> sub-objects are
|
||||
/// replaced wholesale — the form owns every field in all three.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A key whose form value is unusable (blank URI, non-positive timing knob) is REMOVED rather
|
||||
@@ -268,6 +298,12 @@
|
||||
public int ReconnectMaxBackoffMs { get; set; } = 30_000;
|
||||
/// <summary>Multiplier applied to the reconnect backoff each retry.</summary>
|
||||
public double ReconnectBackoffMultiplier { get; set; } = 2.0;
|
||||
/// <summary>
|
||||
/// Whether the driver's half of the write-back gate is armed. Default <c>false</c> — the
|
||||
/// driver shipped read-only, and the Agent's own <c>AllowPut</c> is an independent second
|
||||
/// gate that this cannot arm.
|
||||
/// </summary>
|
||||
public bool WriteEnabled { get; set; }
|
||||
|
||||
/// <summary>Loads a form model from a DriverConfig JSON document, falling back to the driver's
|
||||
/// own defaults for anything absent or unusable.</summary>
|
||||
@@ -293,6 +329,7 @@
|
||||
ReconnectMinBackoffMs = dto.Reconnect?.MinBackoffMs ?? defaults.ReconnectMinBackoffMs,
|
||||
ReconnectMaxBackoffMs = dto.Reconnect?.MaxBackoffMs ?? defaults.ReconnectMaxBackoffMs,
|
||||
ReconnectBackoffMultiplier = dto.Reconnect?.BackoffMultiplier ?? defaults.ReconnectBackoffMultiplier,
|
||||
WriteEnabled = dto.Write?.Enabled ?? defaults.WriteEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -319,6 +356,11 @@
|
||||
MaxBackoffMs = Positive(ReconnectMaxBackoffMs),
|
||||
BackoffMultiplier = ReconnectBackoffMultiplier > 0 ? ReconnectBackoffMultiplier : null,
|
||||
},
|
||||
|
||||
// Written even when false, unlike the timing knobs: those are omitted so the driver
|
||||
// substitutes its own positive default, whereas an omitted write block would leave a
|
||||
// previously-authored "enabled": true standing and the switch could never turn it off.
|
||||
Write = new MTConnectWriteConfigDto { Enabled = WriteEnabled },
|
||||
};
|
||||
|
||||
/// <summary>Returns an operator-facing message describing why this config would not start, or
|
||||
@@ -380,6 +422,8 @@
|
||||
public MTConnectProbeConfigDto? Probe { get; init; }
|
||||
/// <summary>Reconnect backoff knobs.</summary>
|
||||
public MTConnectReconnectConfigDto? Reconnect { get; init; }
|
||||
/// <summary>Write-back knobs — the driver's half of the two-layer opt-in on observation injection.</summary>
|
||||
public MTConnectWriteConfigDto? Write { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Wire mirror of the driver's probe config block.</summary>
|
||||
@@ -403,4 +447,11 @@
|
||||
/// <summary>Multiplier applied each retry.</summary>
|
||||
public double? BackoffMultiplier { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Wire mirror of the driver's write-back config block.</summary>
|
||||
public sealed class MTConnectWriteConfigDto
|
||||
{
|
||||
/// <summary>Whether the driver accepts write-back at all.</summary>
|
||||
public bool? Enabled { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user