perf(mtconnect): headers-only connectivity probe (R06:T-7)
The connectivity probe parsed the full /probe device model every 5 s and discarded it. It now calls a new IMTConnectAgentClient.PingAsync: one GET /probe read with ResponseHeadersRead and a status check; the body is dropped unread. Same failure surface as ProbeAsync (the default implementation delegates to it). The probe-tick tests now count pings and pin that no tick issues a full /probe; the shutdown test's absence assertion gained a positive control. Refs #743 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -90,7 +90,7 @@ request/response handshake — is a different mechanism and remains deferred; se
|
||||
| `IReadable` | `ReadAsync` → the live `/sample` pump's index while it is delivering and healthy, else one `/current` per call | **Not the production data path** — see below |
|
||||
| `IWritable` | `WriteAsync` → `POST {agent}/{device}?{dataItemId}={value}` on the Agent's `AllowPut` surface | **Off by default** — needs `Write.Enabled` **and** the Agent's `AllowPut`; CONDITION items are refused |
|
||||
| `ISubscribable` | `SubscribeAsync`/`OnDataChange` — the shared `/sample` long-poll pump | **The production data path** |
|
||||
| `IHostConnectivityProbe` | periodic `/probe` under `Probe.*` | No consumer wires it today — see [Known limitations](#known-limitations) |
|
||||
| `IHostConnectivityProbe` | periodic headers-only `/probe` ping under `Probe.*` — the status is read, the device model is neither downloaded in full nor parsed (review 2026-10-21 T-7; it used to parse the whole model every tick) | Consumed — feeds `/hosts` (see [Known limitations](#known-limitations)) |
|
||||
| `IRediscoverable` | watches the Agent's `Header.instanceId` | No consumer wires it today — see [Known limitations](#known-limitations) |
|
||||
| `IDriverDiagnostics` | `GetDiagnostics()` — `/sample` effective count, lag, saturated chunks, count raises | Shown on `/hosts` (review 2026-10-21 T-5) |
|
||||
| `IAlarmSource` | CONDITION observations off the same `/sample` pump → Part 9 transitions keyed by the alarm tag's RawPath | Opt-in per raw tag (`alarm` object) — see [CONDITION alarms](#condition-alarms-626) |
|
||||
|
||||
@@ -76,6 +76,17 @@ public interface IMTConnectAgentClient : IDisposable
|
||||
/// <returns>The parsed device hierarchy.</returns>
|
||||
Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// A connectivity check: does the Agent answer <c>/probe</c> with a success status? Reads the response
|
||||
/// headers only — the device model is neither downloaded in full nor parsed (R06:T-7: the connectivity
|
||||
/// probe used to parse the whole <c>/probe</c> document every tick and throw it away). Throws exactly when
|
||||
/// <see cref="ProbeAsync"/> would for an unreachable or refusing Agent. The default delegates to
|
||||
/// <see cref="ProbeAsync"/> and discards the model.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token (the caller's deadline).</param>
|
||||
/// <returns>A task completing when the Agent has answered with a success status.</returns>
|
||||
async Task PingAsync(CancellationToken ct) => _ = await ProbeAsync(ct).ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Issues an Agent <c>/current</c> request and returns the latest observed value for every
|
||||
/// data item. Used both to prime the observation index at startup and to re-baseline after
|
||||
|
||||
@@ -182,6 +182,35 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
return MTConnectProbeParser.Parse(body);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task PingAsync(CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
||||
|
||||
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
|
||||
deadline.CancelAfter(_requestTimeout);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, _probeUri);
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
// ResponseHeadersRead: the status line is the whole answer this needs. The body (the device model —
|
||||
// the largest document the Agent serves) is never buffered or parsed; disposing the response drops it.
|
||||
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
|
||||
{
|
||||
throw TimedOut(_probeUri);
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -1632,9 +1632,9 @@ public sealed partial class MTConnectDriver
|
||||
/// free, but it only exists while something is subscribed — an instance with no
|
||||
/// subscriptions would report <see cref="HostState.Unknown"/> indefinitely, and host
|
||||
/// reachability would become a function of OPC UA client behaviour. A dedicated tick is
|
||||
/// the one signal that is true whether or not anyone is watching. Its cost is a parse of
|
||||
/// the device model per interval, which is why <see cref="MTConnectProbeOptions.Interval"/>
|
||||
/// exists.
|
||||
/// the one signal that is true whether or not anyone is watching. Its cost used to be a parse
|
||||
/// of the whole device model per interval; it is now one request whose response headers are
|
||||
/// read and whose body is dropped (<see cref="IMTConnectAgentClient.PingAsync"/>, R06:T-7).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It waits first, then probes.</b> <see cref="StartCoreAsync"/> has just completed a
|
||||
@@ -1674,8 +1674,8 @@ public sealed partial class MTConnectDriver
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
deadline.CancelAfter(options.Probe.Timeout);
|
||||
|
||||
// The answer is deliberately discarded — see the remarks.
|
||||
_ = await client.ProbeAsync(deadline.Token).ConfigureAwait(false);
|
||||
// Headers only — the device model is neither downloaded in full nor parsed (R06:T-7).
|
||||
await client.PingAsync(deadline.Token).ConfigureAwait(false);
|
||||
reachable = true;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
|
||||
@@ -272,6 +272,22 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
||||
return answer;
|
||||
}
|
||||
|
||||
/// <summary>Number of connectivity pings (headers-only <c>/probe</c>) issued against this client.</summary>
|
||||
public int PingCallCount => Volatile.Read(ref _pingCallCount);
|
||||
|
||||
private int _pingCallCount;
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>Same failure surface as <see cref="ProbeAsync"/> (<see cref="ProbeFailure"/>), counted separately.</remarks>
|
||||
public Task PingAsync(CancellationToken ct)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
Interlocked.Increment(ref _pingCallCount);
|
||||
|
||||
return ProbeFailure is null ? Task.CompletedTask : throw ProbeFailure;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
|
||||
{
|
||||
|
||||
+17
-13
@@ -385,19 +385,22 @@ public sealed class MTConnectHostAndRediscoverTests
|
||||
{
|
||||
var (_, client, ticker) = await ProbingDriverAsync();
|
||||
|
||||
// Parked before the first tick: the loop waits, THEN probes, so initialize's own /probe is
|
||||
// the only call so far.
|
||||
// Parked before the first tick: the loop waits, THEN probes, so nothing has been pinged yet.
|
||||
client.PingCallCount.ShouldBe(0);
|
||||
|
||||
await TickAsync(ticker);
|
||||
client.PingCallCount.ShouldBe(1);
|
||||
|
||||
await TickAsync(ticker);
|
||||
client.PingCallCount.ShouldBe(2);
|
||||
|
||||
await TickAsync(ticker);
|
||||
client.PingCallCount.ShouldBe(3);
|
||||
|
||||
// Review 2026-10-21 T-7: a tick is a headers-only ping — the full /probe (download + parse of the device
|
||||
// model) is still initialize's one call, never a tick's.
|
||||
client.ProbeCallCount.ShouldBe(1);
|
||||
|
||||
await TickAsync(ticker);
|
||||
client.ProbeCallCount.ShouldBe(2);
|
||||
|
||||
await TickAsync(ticker);
|
||||
client.ProbeCallCount.ShouldBe(3);
|
||||
|
||||
await TickAsync(ticker);
|
||||
client.ProbeCallCount.ShouldBe(4);
|
||||
|
||||
// …and it waits the authored interval between them, rather than a hard-coded cadence.
|
||||
ticker.LastInterval.ShouldBe(AuthoredInterval);
|
||||
}
|
||||
@@ -535,11 +538,12 @@ public sealed class MTConnectHostAndRediscoverTests
|
||||
|
||||
// The loop is provably gone (ShutdownAsync awaited it), so releasing another tick can reach
|
||||
// nobody — no probe against a disposed client, and no further waits on the scheduler.
|
||||
var calls = client.ProbeCallCount;
|
||||
var calls = client.PingCallCount;
|
||||
calls.ShouldBeGreaterThan(0, "positive control: the tick before shutdown did reach the Agent");
|
||||
var waits = ticker.Waits;
|
||||
ticker.Tick();
|
||||
|
||||
client.ProbeCallCount.ShouldBe(calls);
|
||||
client.PingCallCount.ShouldBe(calls);
|
||||
ticker.Waits.ShouldBe(waits);
|
||||
}
|
||||
|
||||
|
||||
@@ -572,6 +572,32 @@ public sealed class MTConnectStreamsParseTests
|
||||
await Should.ThrowAsync<HttpRequestException>(async () => await client.CurrentAsync(Ct));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- MTConnectAgentClient.PingAsync
|
||||
|
||||
/// <summary>R06:T-7 — the ping reads the status only: a body that is not a device model does not matter.</summary>
|
||||
[Fact]
|
||||
public async Task PingAsync_succeeds_on_a_success_status_without_parsing_the_body()
|
||||
{
|
||||
var handler = StubHandler.RespondingWith("this is not an MTConnectDevices document");
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
await client.PingAsync(Ct);
|
||||
|
||||
handler.LastRequestUri!.AbsoluteUri.ShouldBe("http://agent:5000/probe");
|
||||
// Positive control: the full probe DOES parse that body, and refuses it.
|
||||
await Should.ThrowAsync<Exception>(async () => await client.ProbeAsync(Ct));
|
||||
}
|
||||
|
||||
/// <summary>A non-success status is an unreachable / refusing Agent, exactly as for the full probe.</summary>
|
||||
[Fact]
|
||||
public async Task PingAsync_throws_on_a_non_success_status()
|
||||
{
|
||||
var handler = StubHandler.RespondingWith("nope", HttpStatusCode.ServiceUnavailable);
|
||||
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
|
||||
|
||||
await Should.ThrowAsync<HttpRequestException>(async () => await client.PingAsync(Ct));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- MTConnectAgentClient.SampleAsync
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user