From f05e3bf6f4c0e51a503c7676000114cf547380a5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:55:37 -0400 Subject: [PATCH] perf(mtconnect): one Agent write request per device (R06:T-6) Writes were one POST per slot although the Agent's PUT surface accepts several id=value pairs. The slots surviving the local refusals now go out as one WriteObservationsAsync(device, pairs) per device (new IMTConnectAgentClient method; the default loops the single-pair call). A batch closes at another device or a repeated DataItem so same-item writes keep request order; a refused multi-slot request is retried per slot so one refused DataItem costs only its own slot. Health contract: any Agent answer (accepted or refused) advances LastSuccessfulRead with the state following the read / stream flags. Refs #743 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- docs/drivers/MTConnect.md | 14 +- .../IMTConnectAgentClient.cs | 28 +++ .../MTConnectAgentClient.cs | 32 +++- .../MTConnectDriver.cs | 172 ++++++++++++++---- .../CannedAgentClient.cs | 29 ++- .../MTConnectAgentClientWriteTests.cs | 17 ++ .../MTConnectBatchedWriteTests.cs | 107 +++++++++++ 7 files changed, 356 insertions(+), 43 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectBatchedWriteTests.cs diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index 49f2c72b..beeca1a4 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -419,8 +419,18 @@ step answers a different operator question: different `level|text` wire format, and are out of scope for v1. **The Agent's refusal text is logged at Warning and never stored.** It stays out of -`DriverHealth.LastError`, which is type-only by repo-wide discipline, and a refused write -is not evidence the read path degraded — so a refusal leaves driver health untouched. +`DriverHealth.LastError`, which is type-only by repo-wide discipline. A refused write is not +evidence the read path degraded, but it **is** the Agent answering: any answer — accepted or +refused — advances `LastSuccessfulRead`, with the state following the read / `/sample` flags +(a write cannot clear either path's degradation). A write that got **no** answer leaves health +to those two paths, which own liveness. + +**One request per device (review 2026-10-21 T-6).** The slots that survive the refusals above +go out as one `POST {agent}/{device}?id1=v1&id2=v2…` per device (the Agent's PUT surface takes +several pairs) instead of one POST per slot. A batch closes at a slot naming another device or +repeating a DataItem already in it, so two writes to one DataItem still land in request order. +An Agent refusal of a multi-slot request is retried slot by slot, so a refused DataItem costs +only its own slot (`BadCommunicationError`); a transport failure fails that request's slots. **Device resolution needs no extra config.** `TagBinding.WriteTargetsByDataItemId`, built at bind time from a walk of the cached probe model, remembers which device declared diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs index 3a1e7155..f5d9cf03 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs @@ -188,6 +188,34 @@ public interface IMTConnectAgentClient : IDisposable Task WriteObservationAsync( string deviceName, string dataItemId, string value, CancellationToken cancellationToken); + /// + /// Sets several observations on ONE device in one request — the Agent's PUT surface takes any number of + /// {dataItemId}={value} query pairs (POST {agent}/{device}?a=1&b=2) — so a multi-write + /// batch costs one round trip per device instead of one per slot (R06:T-6). Same answer contract as + /// : one outcome for the whole request, an Agent refusal is a value, an + /// unreachable Agent throws. The ids must be distinct (a repeated query key's meaning is the Agent's choice). + /// The default issues the single-observation call per pair and reports the first refusal. + /// + /// The device that owns every DataItem in . + /// The (dataItemId, value) pairs, unescaped, ids distinct. + /// Cancellation token. + /// What the Agent said about the request. + async Task WriteObservationsAsync( + string deviceName, IReadOnlyList> observations, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(observations); + foreach (var (dataItemId, value) in observations) + { + var outcome = await WriteObservationAsync(deviceName, dataItemId, value, cancellationToken).ConfigureAwait(false); + if (!outcome.Accepted) + { + return outcome; + } + } + + return new MTConnectWriteOutcome(Accepted: true, AgentError: null); + } + /// /// Has the Agent's ring buffer overflowed past the sequence the caller expected next? True /// when the chunk's oldest retained sequence is strictly newer than diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs index c16339c2..0d8dea7c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs @@ -283,8 +283,18 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable } /// - public async Task WriteObservationAsync( + public Task WriteObservationAsync( string deviceName, string dataItemId, string value, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId); + ArgumentNullException.ThrowIfNull(value); + + return WriteObservationsAsync(deviceName, [new KeyValuePair(dataItemId, value)], cancellationToken); + } + + /// + public async Task WriteObservationsAsync( + string deviceName, IReadOnlyList> observations, CancellationToken cancellationToken) { // Issued on _http, the unary client — the write is a short request/response // and must be bounded by RequestTimeoutMs like the other unary legs. Sending it on @@ -293,10 +303,19 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable // watchdog protects a multipart body, not a one-shot POST). ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); ArgumentException.ThrowIfNullOrWhiteSpace(deviceName); - ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId); - ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(observations); + if (observations.Count == 0) + { + throw new ArgumentException("at least one observation is required", nameof(observations)); + } - var uri = BuildWriteUri(deviceName, dataItemId, value); + foreach (var (dataItemId, value) in observations) + { + ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId, nameof(observations)); + ArgumentNullException.ThrowIfNull(value, nameof(observations)); + } + + var uri = BuildWriteUri(deviceName, observations); using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token); using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token); @@ -351,10 +370,11 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable /// as a query pair. Every caller-supplied part is percent-encoded here — see the seam's /// contract for why escaping cannot be the caller's job. /// - private Uri BuildWriteUri(string deviceName, string dataItemId, string value) + private Uri BuildWriteUri(string deviceName, IReadOnlyList> observations) { var device = Uri.EscapeDataString(deviceName.Trim()); - var query = $"{Uri.EscapeDataString(dataItemId)}={Uri.EscapeDataString(value)}"; + var query = string.Join( + "&", observations.Select(o => $"{Uri.EscapeDataString(o.Key)}={Uri.EscapeDataString(o.Value)}")); return new Uri($"{_agentRoot}/{device}?{query}", UriKind.Absolute); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs index 4949de28..ec469619 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -845,9 +845,11 @@ public sealed partial class MTConnectDriver // form ⇒ BadTypeMismatch; and only then the Agent, whose refusal or whose // unreachability ⇒ BadCommunicationError. // The Agent's refusal text is logged, never stored. It goes to a Warning line and - // nowhere near , which is type-only by repo-wide - // discipline — and a refused write is not evidence the read path degraded, so health is - // left exactly as it is (unlike ). + // nowhere near LastError, which is type-only by repo-wide discipline. A refused write is + // not evidence the read path degraded — but it IS the Agent answering, so any answer + // (accepted or refused) advances LastSuccessfulRead with the state following the read / + // stream flags; a write that got no answer leaves health to those paths, which own liveness. + // Slots that survive the local refusals go out as ONE request per device (R06:T-6). // is null — a caller bug. public async Task> WriteAsync( IReadOnlyList writes, CancellationToken cancellationToken) @@ -868,6 +870,9 @@ public sealed partial class MTConnectDriver var results = new WriteResult[writes.Count]; + // Pass 1 — every local refusal, slot by slot, in the fixed order (see above). What survives is a pending + // Agent write. + var pending = new List(writes.Count); for (var i = 0; i < writes.Count; i++) { var request = writes[i]; @@ -903,50 +908,153 @@ public sealed partial class MTConnectDriver continue; } + pending.Add(new PendingWrite(i, target.DeviceName, dataItemId, text)); + } + + if (pending.Count == 0) + { + return results; + } + + // Pass 2 — R06:T-6: the Agent's PUT surface takes several id=value pairs per device, so the pending slots go + // out as ONE request per device (it used to be one POST per slot). Request order is kept: a batch closes at + // the first slot naming a different device or repeating an id already in it, so two writes to one DataItem + // land in the order the caller sent them. + var batches = new List>(); + foreach (var write in pending) + { + var open = batches.Count > 0 ? batches[^1] : null; + if (open is null + || !string.Equals(open[0].DeviceName, write.DeviceName, StringComparison.Ordinal) + || open.Exists(w => string.Equals(w.DataItemId, write.DataItemId, StringComparison.Ordinal))) + { + batches.Add([write]); + continue; + } + + open.Add(write); + } + + for (var b = 0; b < batches.Count; b++) + { + var batch = batches[b]; try { - var outcome = await client - .WriteObservationAsync(target.DeviceName, dataItemId, text, cancellationToken) - .ConfigureAwait(false); - - if (outcome.Accepted) - { - results[i] = new WriteResult(0u); - continue; - } - - _logger.LogWarning( - "MTConnect driver {DriverInstanceId}: {AgentUri} refused the write of '{Value}' to DataItem '{DataItemId}' on device '{DeviceName}' — {AgentError}. Check the Agent's AllowPut / AllowPutFrom settings and that the DataItem exists.", - _driverInstanceId, options.AgentUri, text, dataItemId, target.DeviceName, outcome.AgentError); - - results[i] = new WriteResult(BadCommunicationError); + await WriteBatchAsync(client!, options, batch, results, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - // Caller-cancelled: fill the remainder rather than throwing, so the batch shape holds. - for (var j = i; j < writes.Count; j++) + // Caller-cancelled: fill every slot not yet answered rather than throwing, so the batch shape holds + // and a slot that already landed keeps its real outcome. + for (var r = b; r < batches.Count; r++) { - results[j] = new WriteResult(BadCommunicationError); + foreach (var write in batches[r]) + { + results[write.Slot] ??= new WriteResult(BadCommunicationError); + } } return results; } - catch (Exception ex) - { - // A transport failure, a blown per-call deadline, or a disposed client. Logged with the - // exception (its TYPE is the diagnosis) and reported per slot; health is untouched. - _logger.LogWarning( - ex, - "MTConnect driver {DriverInstanceId} could not write DataItem '{DataItemId}' on device '{DeviceName}' to {AgentUri}.", - _driverInstanceId, dataItemId, target.DeviceName, options.AgentUri); - - results[i] = new WriteResult(BadCommunicationError); - } } return results; } + /// One slot that survived the local refusals, waiting for the Agent. + private sealed record PendingWrite(int Slot, string DeviceName, string DataItemId, string Text); + + /// + /// Sends one same-device batch and maps the Agent's answer onto its slots. Accepted ⇒ every slot Good. Refused + /// ⇒ a single-slot batch is that slot's BadCommunicationError (the Agent's text logged); a multi-slot + /// batch is retried slot by slot so one refused DataItem cannot fail the others — the batch contract above. A + /// transport failure fails the batch's slots. Caller cancellation propagates to . + /// + private async Task WriteBatchAsync( + IMTConnectAgentClient client, + MTConnectDriverOptions options, + List batch, + WriteResult[] results, + CancellationToken cancellationToken) + { + var device = batch[0].DeviceName; + MTConnectWriteOutcome outcome; + try + { + outcome = batch.Count == 1 + ? await client.WriteObservationAsync(device, batch[0].DataItemId, batch[0].Text, cancellationToken) + .ConfigureAwait(false) + : await client.WriteObservationsAsync( + device, + [.. batch.Select(w => new KeyValuePair(w.DataItemId, w.Text))], + cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + // A transport failure, a blown per-call deadline, or a disposed client — no answer arrived. Logged with + // the exception (its TYPE is the diagnosis) and reported per slot; health is left to the read and + // stream paths, which own liveness. + _logger.LogWarning( + ex, + "MTConnect driver {DriverInstanceId} could not write {Count} DataItem(s) ({DataItemIds}) on device '{DeviceName}' to {AgentUri}.", + _driverInstanceId, batch.Count, string.Join(", ", batch.Select(w => w.DataItemId)), device, options.AgentUri); + + foreach (var write in batch) + { + results[write.Slot] = new WriteResult(BadCommunicationError); + } + + return; + } + + // An answer — accepted or refused — is the Agent talking (the health contract: a refusal is a reply). + RecordWriteAnswered(); + + if (outcome.Accepted) + { + foreach (var write in batch) + { + results[write.Slot] = new WriteResult(0u); + } + + return; + } + + if (batch.Count == 1) + { + _logger.LogWarning( + "MTConnect driver {DriverInstanceId}: {AgentUri} refused the write of '{Value}' to DataItem '{DataItemId}' on device '{DeviceName}' — {AgentError}. Check the Agent's AllowPut / AllowPutFrom settings and that the DataItem exists.", + _driverInstanceId, options.AgentUri, batch[0].Text, batch[0].DataItemId, device, outcome.AgentError); + results[batch[0].Slot] = new WriteResult(BadCommunicationError); + + return; + } + + // The Agent refused the request as a whole: find out which slot(s) it objects to, one by one. + foreach (var write in batch) + { + await WriteBatchAsync(client, options, [write], results, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// A write got an answer from the Agent: advances, and the state + /// follows the read / stream flags exactly as their own successes do ( — a write + /// cannot clear either path's degradation, but it is evidence the Agent is reachable). + /// + private void RecordWriteAnswered() + { + if (ReadHealth().State is DriverState.Healthy or DriverState.Degraded) + { + PublishHealthy(DateTime.UtcNow); + } + } + /// /// Renders a value to the invariant string a PUT query carries, or reports that it has no such /// form. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs index 9d3cdbcb..1a80e651 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs @@ -391,16 +391,39 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable /// public Action? AfterWrite { get; set; } + /// Every Agent write REQUEST, in order: the device and the (id, value) pairs it carried. + public ConcurrentQueue<(string DeviceName, IReadOnlyList> Pairs)> WriteRequests { get; } = new(); + + /// DataItem ids the Agent refuses: a request naming any of them is refused as a whole. + public HashSet RefusedDataItems { get; } = new(StringComparer.Ordinal); + /// public Task WriteObservationAsync( - string deviceName, string dataItemId, string value, CancellationToken cancellationToken) + string deviceName, string dataItemId, string value, CancellationToken cancellationToken) => + WriteObservationsAsync(deviceName, [new KeyValuePair(dataItemId, value)], cancellationToken); + + /// + public Task WriteObservationsAsync( + string deviceName, IReadOnlyList> observations, CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(IsDisposed, this); cancellationToken.ThrowIfCancellationRequested(); - Writes.Enqueue((deviceName, dataItemId, value)); + WriteRequests.Enqueue((deviceName, observations)); + foreach (var (dataItemId, value) in observations) + { + Writes.Enqueue((deviceName, dataItemId, value)); + } + AfterWrite?.Invoke(); - return WriteFailure is null ? Task.FromResult(WriteOutcome) : throw WriteFailure; + if (WriteFailure is not null) + { + throw WriteFailure; + } + + return Task.FromResult(observations.Any(o => RefusedDataItems.Contains(o.Key)) + ? new MTConnectWriteOutcome(Accepted: false, AgentError: "UNSUPPORTED: refused data item") + : WriteOutcome); } /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAgentClientWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAgentClientWriteTests.cs index b431eccb..4dd59021 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAgentClientWriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAgentClientWriteTests.cs @@ -108,6 +108,23 @@ public sealed class MTConnectAgentClientWriteTests .ShouldBe("http://stub-agent:5000/StubDevice?dev1_msg=hello"); } + /// R06:T-6 — several observations on one device are one POST carrying one escaped pair each. + [Fact] + public async Task Several_observations_are_one_request_with_one_pair_each() + { + var handler = new RecordingHandler(_ => Ok()); + using var client = new MTConnectAgentClient(Options(), handler); + + var outcome = await client.WriteObservationsAsync( + "StubDevice", + [new KeyValuePair("dev1_msg", "a&b"), new KeyValuePair("dev1_count", "7")], + CancellationToken.None); + + outcome.Accepted.ShouldBeTrue(); + handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri + .ShouldBe("http://stub-agent:5000/StubDevice?dev1_msg=a%26b&dev1_count=7"); + } + /// /// A value carrying a space, an & and a = — each of which silently ends the /// query pair early if it reaches the wire raw — plus a DataItem id needing the same diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectBatchedWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectBatchedWriteTests.cs new file mode 100644 index 00000000..5ea2268b --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectBatchedWriteTests.cs @@ -0,0 +1,107 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; + +/// +/// Review 2026-10-21 R06:T-6 — writes were one POST per slot although the Agent's PUT surface accepts several +/// id=value pairs per device. Surviving slots now go out as one request per device, with a refused +/// multi-slot request retried slot by slot so one refused DataItem still costs only its own slot. And the health +/// contract: an Agent answer (accepted or refused) is the Agent talking, so it advances LastSuccessfulRead. +/// +public sealed class MTConnectBatchedWriteTests +{ + private const string FixtureDeviceName = "VMC-3Axis"; + private const string PosRaw = "Plant/MTConnect/dev1/Position"; + private const string CountRaw = "Plant/MTConnect/dev1/PartCount"; + private const string ProgramRaw = "Plant/MTConnect/dev1/Program"; + private const uint Good = 0x00000000u; + private const uint BadCommunicationError = 0x80050000u; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static RawTagEntry Entry(string rawPath, string dataItemId) => + new(rawPath, $$"""{"fullName":"{{dataItemId}}"}""", WriteIdempotent: false, DeviceName: "dev1"); + + private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> WritableAsync() + { + var client = CannedAgentClient.FromFixtures(); + var driver = new MTConnectDriver( + new MTConnectDriverOptions + { + AgentUri = "http://fixture-agent:5000", + Tags = [], + RawTags = + [ + Entry(PosRaw, "dev1_pos"), + Entry(CountRaw, "dev1_partcount"), + Entry(ProgramRaw, "dev1_program"), + ], + Write = new MTConnectWriteOptions { Enabled = true }, + }, + "mt-t6", + _ => client); + await driver.InitializeAsync("{}", Ct); + return (driver, client); + } + + /// Three writes to three DataItems of one device are ONE Agent request carrying three pairs. + [Fact] + public async Task Writes_to_one_device_go_out_as_one_request() + { + var (driver, client) = await WritableAsync(); + + var results = await driver.WriteAsync( + [new WriteRequest(PosRaw, 1.5), new WriteRequest(CountRaw, 7), new WriteRequest(ProgramRaw, "O1000")], Ct); + + results.ShouldAllBe(r => r.StatusCode == Good); + var request = client.WriteRequests.ShouldHaveSingleItem(); + request.DeviceName.ShouldBe(FixtureDeviceName); + request.Pairs.Select(p => (p.Key, p.Value)).ShouldBe( + [("dev1_pos", "1.5"), ("dev1_partcount", "7"), ("dev1_program", "O1000")]); + } + + /// Two writes to the SAME DataItem are never merged into one request — they land in request order. + [Fact] + public async Task Two_writes_to_one_data_item_stay_separate_and_ordered() + { + var (driver, client) = await WritableAsync(); + + await driver.WriteAsync([new WriteRequest(PosRaw, 1.0), new WriteRequest(CountRaw, 3), new WriteRequest(PosRaw, 2.0)], Ct); + + client.WriteRequests.Count.ShouldBe(2); + client.Writes.Where(w => w.DataItemId == "dev1_pos").Select(w => w.Value).ShouldBe(["1", "2"]); + } + + /// A refused multi-slot request is retried per slot: only the refused DataItem's slot fails. + [Fact] + public async Task A_refused_batch_is_retried_per_slot_so_only_the_refused_item_fails() + { + var (driver, client) = await WritableAsync(); + client.RefusedDataItems.Add("dev1_partcount"); + + var results = await driver.WriteAsync( + [new WriteRequest(PosRaw, 1.5), new WriteRequest(CountRaw, 7), new WriteRequest(ProgramRaw, "O1000")], Ct); + + results.Select(r => r.StatusCode).ShouldBe([Good, BadCommunicationError, Good]); + client.WriteRequests.Count.ShouldBe(4, "the batch, then one retry per slot"); + } + + /// A refusal is a reply: it advances LastSuccessfulRead (the Agent answered). + [Fact] + public async Task A_refused_write_is_an_answer_and_advances_the_stamp() + { + var (driver, client) = await WritableAsync(); + client.RefusedDataItems.Add("dev1_pos"); + var before = driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull(); + // The stamp is DateTime.UtcNow: wait (bounded) for the clock to tick past it so an advance is observable. + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (DateTime.UtcNow <= before && DateTime.UtcNow < deadline) await Task.Yield(); + + await driver.WriteAsync([new WriteRequest(PosRaw, 1.5)], Ct); + + driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before); + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + } +}