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:
Joseph Doherty
2026-10-22 15:57:43 -04:00
parent f05e3bf6f4
commit d4b9fa1c29
7 changed files with 105 additions and 19 deletions
+1 -1
View File
@@ -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)
{
@@ -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]