fix(runtime): run driver writes off the mailbox under an in-flight cap (A3)
WriteAttribute was a ReceiveAsync, suspending the child's mailbox for the whole device round-trip (up to 5 s): writes serialised, the second of two operator writes to a slow device missed the host's 8 s Ask and was reverted although the device took it, and every data change queued behind the write. Writes are now a synchronous Receive that launches the device call on the thread pool (sender captured, reply from the task, slot freed by a self-sent WriteCompleted) under a per-driver cap (default 4; excess writes queue in order). Queued writes fail fast on every exit from Connected. Subscribe/unsubscribe stay ReceiveAsync. Review ref: 02/P2. Refs #731 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -194,6 +194,22 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultShutdownTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Default cap on the writes this actor runs against its driver at once. Writes run OFF the mailbox
|
||||
/// (a <c>ReceiveAsync</c> used to suspend it for the whole device round-trip, so writes serialised and
|
||||
/// every data change queued behind them — the second of two operator writes to a slow device missed the
|
||||
/// host's 8 s Ask and was reverted although the device took it). The cap keeps a burst from flooding a
|
||||
/// device with concurrent commands; writes beyond it queue in order and start as slots free.
|
||||
/// </summary>
|
||||
public const int DefaultMaxWritesInFlight = 4;
|
||||
|
||||
/// <summary>Self-sent by a write task once it has replied, releasing its in-flight slot.</summary>
|
||||
private sealed class WriteCompleted
|
||||
{
|
||||
public static readonly WriteCompleted Instance = new();
|
||||
private WriteCompleted() { }
|
||||
}
|
||||
|
||||
private readonly IDriver _driver;
|
||||
|
||||
/// <summary>Phase 6.1 resilience seam: every guarded driver-capability call this actor makes is
|
||||
@@ -217,6 +233,17 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// <summary>Bound on the driver shutdown / dispose; see <see cref="DefaultShutdownTimeout"/>.</summary>
|
||||
private readonly TimeSpan _shutdownTimeout;
|
||||
|
||||
/// <summary>Cap on concurrent writes; see <see cref="DefaultMaxWritesInFlight"/>.</summary>
|
||||
private readonly int _maxWritesInFlight;
|
||||
|
||||
/// <summary>Writes currently inside the driver. Actor-thread only (incremented on launch, decremented on
|
||||
/// the self-sent <see cref="WriteCompleted"/>).</summary>
|
||||
private int _writesInFlight;
|
||||
|
||||
/// <summary>Writes waiting for an in-flight slot, in arrival order. Failed fast on every exit from
|
||||
/// <c>Connected</c>, so a queued write never reaches a driver that is reconnecting.</summary>
|
||||
private readonly Queue<(WriteAttribute Write, IActorRef ReplyTo)> _pendingWrites = new();
|
||||
|
||||
/// <summary>
|
||||
/// Set by <see cref="PreRestart"/> so the <see cref="PostStop"/> it triggers skips the dispose. A
|
||||
/// supervision restart recreates the actor object around the SAME <see cref="IDriver"/> (the Props closure
|
||||
@@ -347,6 +374,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// <param name="shutdownTimeout">Optional bound on the driver's shutdown / dispose on stop and on the
|
||||
/// pre-reconnect teardown; defaults to <see cref="DefaultShutdownTimeout"/>. Exists so a test can prove a
|
||||
/// hung shutdown is abandoned without waiting 5 s.</param>
|
||||
/// <param name="maxWritesInFlight">Optional cap on concurrent writes against the driver; defaults to
|
||||
/// <see cref="DefaultMaxWritesInFlight"/>. Writes beyond it queue in arrival order.</param>
|
||||
/// <returns>A <see cref="Akka.Actor.Props"/> instance configured to create the wrapped <see cref="DriverInstanceActor"/>.</returns>
|
||||
public static Props Props(
|
||||
IDriver driver,
|
||||
@@ -357,7 +386,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
IDriverCapabilityInvoker? invoker = null,
|
||||
TimeSpan? healthPollInterval = null,
|
||||
TimeSpan? commsLossTimeout = null,
|
||||
TimeSpan? shutdownTimeout = null) =>
|
||||
TimeSpan? shutdownTimeout = null,
|
||||
int? maxWritesInFlight = null) =>
|
||||
Akka.Actor.Props.Create(() => new DriverInstanceActor(
|
||||
driver,
|
||||
reconnectInterval ?? DefaultReconnectInterval,
|
||||
@@ -367,7 +397,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
invoker,
|
||||
healthPollInterval,
|
||||
commsLossTimeout,
|
||||
shutdownTimeout));
|
||||
shutdownTimeout,
|
||||
maxWritesInFlight));
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and
|
||||
@@ -406,6 +437,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// <see cref="DefaultCommsLossTimeout"/> when null, and is disabled when zero or negative.</param>
|
||||
/// <param name="shutdownTimeout">Bound on the driver's shutdown / dispose; defaults to
|
||||
/// <see cref="DefaultShutdownTimeout"/> when null.</param>
|
||||
/// <param name="maxWritesInFlight">Cap on concurrent writes; defaults to
|
||||
/// <see cref="DefaultMaxWritesInFlight"/> when null or non-positive.</param>
|
||||
public DriverInstanceActor(
|
||||
IDriver driver,
|
||||
TimeSpan reconnectInterval,
|
||||
@@ -415,8 +448,10 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
IDriverCapabilityInvoker? invoker = null,
|
||||
TimeSpan? healthPollInterval = null,
|
||||
TimeSpan? commsLossTimeout = null,
|
||||
TimeSpan? shutdownTimeout = null)
|
||||
TimeSpan? shutdownTimeout = null,
|
||||
int? maxWritesInFlight = null)
|
||||
{
|
||||
_maxWritesInFlight = maxWritesInFlight is > 0 ? maxWritesInFlight.Value : DefaultMaxWritesInFlight;
|
||||
_driver = driver;
|
||||
_invoker = invoker ?? NullDriverCapabilityInvoker.Instance;
|
||||
_driverInstanceId = driver.DriverInstanceId;
|
||||
@@ -506,6 +541,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// "write timeout". Synchronous Receive: Sender.Tell on the actor thread is safe.
|
||||
Receive<WriteAttribute>(_ =>
|
||||
Sender.Tell(new WriteAttributeResult(false, "driver not connected")));
|
||||
// A write launched while Connected can finish after the transition out of it; its slot still frees.
|
||||
Receive<WriteCompleted>(_ => OnWriteCompleted());
|
||||
// An ack arriving while still connecting can't reach the upstream alarm system; drop it (the ack is
|
||||
// fire-and-forget — no reply to surface — and the OPC UA condition state already committed locally).
|
||||
Receive<RouteAlarmAck>(_ =>
|
||||
@@ -574,10 +611,14 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
_log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId);
|
||||
DetachSubscription();
|
||||
FailQueuedWrites("driver not connected");
|
||||
Become(Reconnecting);
|
||||
PublishHealthSnapshot();
|
||||
});
|
||||
ReceiveAsync<WriteAttribute>(HandleWriteAsync);
|
||||
// Synchronous Receive: the device call runs off the mailbox (see StartOrQueueWrite), so data changes
|
||||
// and further writes are never head-of-line blocked behind a slow device.
|
||||
Receive<WriteAttribute>(msg => StartOrQueueWrite(msg, Sender));
|
||||
Receive<WriteCompleted>(_ => OnWriteCompleted());
|
||||
ReceiveAsync<RouteAlarmAck>(HandleAcknowledgeAsync);
|
||||
ReceiveAsync<Subscribe>(HandleSubscribeAsync);
|
||||
ReceiveAsync<Unsubscribe>(_ => UnsubscribeAsync());
|
||||
@@ -805,6 +846,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// timeout on an inbound write to a transiently-down driver). Synchronous Receive.
|
||||
Receive<WriteAttribute>(_ =>
|
||||
Sender.Tell(new WriteAttributeResult(false, "driver not connected")));
|
||||
Receive<WriteCompleted>(_ => OnWriteCompleted());
|
||||
// An ack arriving while reconnecting can't reach the upstream alarm system; drop it (fire-and-forget,
|
||||
// no reply — the OPC UA condition state already committed locally on the Part 9 ack).
|
||||
Receive<RouteAlarmAck>(_ =>
|
||||
@@ -956,17 +998,44 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
_initCts = null;
|
||||
}
|
||||
|
||||
private async Task HandleWriteAsync(WriteAttribute msg)
|
||||
/// <summary>
|
||||
/// Launches a write against the driver off the mailbox, or queues it when
|
||||
/// <see cref="_maxWritesInFlight"/> writes are already inside the driver. Runs on the actor thread; the
|
||||
/// only actor state it touches is the in-flight count and the queue. The device round-trip itself
|
||||
/// (<see cref="RunWriteAsync"/>) replies to the captured sender from the thread pool and then
|
||||
/// self-sends <see cref="WriteCompleted"/> to free its slot.
|
||||
/// </summary>
|
||||
private void StartOrQueueWrite(WriteAttribute msg, IActorRef replyTo)
|
||||
{
|
||||
var replyTo = Sender;
|
||||
if (_driver is not IWritable writable)
|
||||
{
|
||||
replyTo.Tell(new WriteAttributeResult(false, "Driver does not implement IWritable"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (_writesInFlight >= _maxWritesInFlight)
|
||||
{
|
||||
_pendingWrites.Enqueue((msg, replyTo));
|
||||
return;
|
||||
}
|
||||
|
||||
_writesInFlight++;
|
||||
// Everything the task needs is captured HERE, on the actor thread — ResolveHost reads the driver,
|
||||
// and Self must not be touched off-context.
|
||||
var self = Self;
|
||||
var hostKey = ResolveHost(msg.TagId);
|
||||
var invoker = _invoker;
|
||||
_ = RunWriteAsync(writable, invoker, hostKey, msg, replyTo, self);
|
||||
}
|
||||
|
||||
private static async Task RunWriteAsync(
|
||||
IWritable writable, IDriverCapabilityInvoker invoker, string hostKey,
|
||||
WriteAttribute msg, IActorRef replyTo, IActorRef self)
|
||||
{
|
||||
// Off the actor context from here on: the first await yields, and nothing below reads actor state.
|
||||
await Task.Yield();
|
||||
var request = new[] { new WriteRequest(msg.TagId, msg.Value) };
|
||||
// Bound the write so a hung backend can't pin this actor forever — retry stays
|
||||
// Bound the write so a hung backend can't hold a slot forever — retry stays
|
||||
// off by default, but a stalled call still needs an answer.
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
try
|
||||
@@ -978,8 +1047,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// 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),
|
||||
var results = await invoker.ExecuteWriteAsync(
|
||||
hostKey,
|
||||
isIdempotent: false,
|
||||
async ct => await writable.WriteAsync(request, ct),
|
||||
cts.Token);
|
||||
@@ -999,6 +1068,33 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
replyTo.Tell(new WriteAttributeResult(false, ex.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
self.Tell(WriteCompleted.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Frees a write slot and, while Connected, starts the next queued write.</summary>
|
||||
private void OnWriteCompleted()
|
||||
{
|
||||
if (_writesInFlight > 0) _writesInFlight--;
|
||||
if (_pendingWrites.Count > 0 && _driver is IWritable)
|
||||
{
|
||||
var (next, replyTo) = _pendingWrites.Dequeue();
|
||||
StartOrQueueWrite(next, replyTo);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Replies to every queued write with <paramref name="reason"/>; called on each exit from
|
||||
/// <c>Connected</c>. In-flight writes are left to finish — they already hold the device and reply with its
|
||||
/// real outcome.</summary>
|
||||
private void FailQueuedWrites(string reason)
|
||||
{
|
||||
while (_pendingWrites.Count > 0)
|
||||
{
|
||||
var (_, replyTo) = _pendingWrites.Dequeue();
|
||||
replyTo.Tell(new WriteAttributeResult(false, reason));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1246,6 +1342,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
_log.Warning("DriverInstance {Id}: disconnect observed ({Reason}); reconnecting", _driverInstanceId, reason);
|
||||
DetachSubscription();
|
||||
FailQueuedWrites("driver not connected");
|
||||
RecordFault();
|
||||
Become(Reconnecting);
|
||||
PublishHealthSnapshot();
|
||||
@@ -1643,6 +1740,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
|
||||
DetachSubscription();
|
||||
FailQueuedWrites("driver stopped");
|
||||
// MUST happen: the IDriver instance can outlive this actor (the host respawns a child around the
|
||||
// same driver object), so a missing unsubscribe accumulates a handler per respawn holding a dead Self.
|
||||
DetachRediscoverySource();
|
||||
|
||||
Reference in New Issue
Block a user