feat(opcua): write-outcome self-correction — capture prior + compare-and-revert on failure

This commit is contained in:
Joseph Doherty
2026-06-14 01:30:20 -04:00
parent 526ddb6a57
commit 10efcf4517
4 changed files with 224 additions and 133 deletions
@@ -130,35 +130,23 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
}
});
// Wire the reverse-path inbound operator-write router: a client write to a writable equipment-tag
// Wire the reverse-path inbound operator-write gateway: a client write to a writable equipment-tag
// node that passes the node manager's WriteOperate gate routes the write to the owning driver child
// (RouteNodeWrite → NodeWriteResult) via the local DriverHostActor. This dispatch is FIRE-AND-FORGET
// (just like the alarm router): the SDK's CustomNodeManager2.Write holds the node-manager Lock while
// invoking OnWriteValue, so a blocking Ask here would freeze ALL address-space operations (reads,
// subscription notifications, the publish path) for up to the Ask timeout. We kick off the Ask and
// log failures from a continuation; the write reaches the device asynchronously and the value
// self-corrects on the next driver poll (standard OPC UA optimistic-write semantics). The
// DriverHostActor ref is resolved LAZILY per write — this hosted service's StartAsync runs before
// the Akka DriverHostActor registers, so a one-shot resolve here would always miss and leave every
// write unavailable. By write time (long after startup) the registry has it; a node that genuinely
// has no driver-host (admin-only, no writable driver nodes materialised) logs + drops the write.
_server.SetNodeWriteRouter((nodeId, value) =>
{
if (!_actorRegistry.TryGet<DriverHostActorKey>(out var driverHost))
{
_logger.LogWarning("Inbound write to {NodeId} dropped: no DriverHostActor registered", nodeId);
return;
}
driverHost.Ask<DriverHostActor.NodeWriteResult>(
new DriverHostActor.RouteNodeWrite(nodeId, value), TimeSpan.FromSeconds(10))
.ContinueWith(t =>
{
if (!t.IsCompletedSuccessfully)
_logger.LogWarning("Operator write to {NodeId} failed or timed out", nodeId);
else if (!t.Result.Success)
_logger.LogWarning("Operator write to {NodeId} rejected: {Reason}", nodeId, t.Result.Reason);
}, TaskScheduler.Default);
});
// (RouteNodeWrite → NodeWriteResult) via the local DriverHostActor. The node manager calls the
// gateway's WriteAsync FIRE-AND-FORGET: the SDK's CustomNodeManager2.Write holds the node-manager
// Lock while invoking OnWriteValue, so a blocking Ask here would freeze ALL address-space operations
// (reads, subscription notifications, the publish path) for up to the Ask timeout. The gateway kicks
// off the Ask and resolves a NodeWriteOutcome; the node manager applies the client value optimistically
// and self-corrects (reverts to the pre-write value) when the device write comes back FAILED — but only
// while the node still holds the optimistic value, so a fresh driver poll is not clobbered. The
// DriverHostActor ref is resolved LAZILY per write (inside the gateway) — this hosted service's
// StartAsync runs before the Akka DriverHostActor registers, so a one-shot resolve here would always
// miss and leave every write unavailable. By write time (long after startup) the registry has it; a
// node that genuinely has no driver-host (admin-only, no writable driver nodes materialised) logs +
// resolves the write to "writes unavailable".
_server.SetNodeWriteGateway(new ActorNodeWriteGateway(
resolveDriverHost: () => _actorRegistry.TryGet<DriverHostActorKey>(out var driverHost) ? driverHost : null,
logger: _loggerFactory.CreateLogger<ActorNodeWriteGateway>()));
// ServiceLevel publisher needs IServerInternal — only available after Start.
if (_server.CurrentInstance is { } serverInternal)
@@ -181,8 +169,8 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
// half-disposed NodeManager.
_deferredSink.SetSink(null);
_deferredServiceLevel.SetInner(null);
// Drop the inbound-write router too so a late client write doesn't Ask a stopping DriverHostActor.
_server?.SetNodeWriteRouter(null);
// Restore the Null write gateway so a late client write doesn't Ask a stopping DriverHostActor.
_server?.SetNodeWriteGateway(null);
return Task.CompletedTask;
}