fix(r2-02): write dispatch defaults to non-idempotent — no-retry arm authoritative (03/S12 layer 2)

This commit is contained in:
Joseph Doherty
2026-07-13 10:04:09 -04:00
parent eab0b6be12
commit 496f97da20
3 changed files with 14 additions and 7 deletions
@@ -589,14 +589,16 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
// Route through the resilience invoker (Write pipeline: timeout, no retry by default,
// per-host breaker). An OPC UA attribute set is idempotent (writing value X twice == once),
// so a configured Write retry is safe to apply; the Write tier default is RetryCount=0, so
// this stays behavior-neutral until an operator opts in via ResilienceConfig. The outer cts
// is retained as a hard backstop above the (typically tighter) tier timeout.
// Route through the resilience invoker (Write pipeline: timeout, per-host breaker). Writes
// default to NON-idempotent (03/S12): a timed-out write may already have committed at the
// device, and replaying a command-shaped write (pulse coil, counter increment, Galaxy
// supervisory write) can duplicate an irreversible field action. The invoker's no-retry arm is
// therefore authoritative regardless of any configured Write retry. Per-tag opt-in to retries via
// WriteIdempotentAttribute is the unshipped forward path (see WriteIdempotentAttribute). The outer
// cts is retained as a hard backstop above the (typically tighter) tier timeout.
var results = await _invoker.ExecuteWriteAsync(
ResolveHost(msg.TagId),
isIdempotent: true,
isIdempotent: false,
async ct => await writable.WriteAsync(request, ct),
cts.Token);
if (results is { Count: 1 } && IsGoodStatus(results[0].StatusCode))