fix(archreview #10): wire CapabilityInvoker into prod dispatch via a Polly-free seam
Remediates RESILIENCE-DISPATCH-GAP (surfaced by the 07/C-1 analyzer): the Phase 6.1 CapabilityInvoker resilience pipeline (retry / breaker / bulkhead / telemetry) was constructed only in tests — the production dispatch layer called driver-capability methods directly, bypassing it entirely. The filed plan (thread CapabilityInvoker directly into DriverInstanceActor) is INFEASIBLE: Runtime is deliberately Polly-free (references Core.Abstractions, not Core — the same boundary IDriverFactory documents). So this introduces a seam, mirroring IDriverFactory exactly: - Core.Abstractions: IDriverCapabilityInvoker (+ NullDriverCapabilityInvoker pass-through) and IDriverCapabilityInvokerFactory (+ null factory). - Core: CapabilityInvoker now implements the interface; new DriverCapabilityInvokerFactory builds a per-instance invoker over the process-singleton pipeline builder + status tracker + tier resolver. - Runtime: DriverInstanceActor takes an IDriverCapabilityInvoker (default = pass-through) and routes all 6 dispatch sites (write, alarm-ack, subscribe, unsubscribe, alarm-subscribe, discover) through it; per-host key resolved via IPerCallHostResolver for single-ref calls, driver-instance key for bulk calls. DriverHostActor builds + injects the real invoker per spawned driver. - Host: DriverFactoryBootstrap registers the tracker, pipeline builder, and concrete factory (needs DriverFactoryRegistry.GetTier). Runtime SCE resolves the factory from DI like IDriverFactory; pass-through on nodes without it bound. - Analyzer: IDriverCapabilityInvoker.ExecuteAsync/ExecuteWriteAsync are now recognized wrapper homes (interface-typed invoker calls must suppress OTOPCUA0001 too). All 6 RESILIENCE-DISPATCH-GAP pragmas removed — the analyzer (an error in Runtime) is now the standing regression guard. Also adds retry / breaker-open / breaker-close LOGGING to the pipeline builder — the operator-facing observability surface (the Admin /hosts reader, Phase 6.1 Stream E.2/E.3, was never built, so the pipeline otherwise runs silently). Scope notes: - Tier-DEFAULT policy only: DriverInstanceSpec (the deploy artifact) does not carry the per-instance ResilienceConfig JSON, so overrides aren't applied yet (tracked follow-up: plumb ResilienceConfig through the composer/artifact). - GenericDriverNodeManager's call is NOT wired: that class is test-only scaffolding (zero production references — the Server has its own address-space path); its pragma is re-annotated accordingly, not left as a "tracked gap". Verification (unit + analyzer; live behavioral gate still pending — see FOLLOWUP-10): - Negative control: unwrapping any site fails the Runtime build with OTOPCUA0001. - New Runtime guard (recording invoker): write routes via ExecuteWriteAsync with the IPerCallHostResolver host; subscribe via ExecuteAsync — proves runtime routing. - New analyzer test: the interface is a valid wrapper home. - New pipeline-builder test: retry events are logged. - Full solution builds clean (0 errors); Runtime.Tests 357, Core.Tests 238, Analyzers.Tests 32 all green; pass-through default keeps existing dispatch tests byte-for-byte unchanged.
This commit is contained in:
@@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||
|
||||
@@ -49,6 +50,25 @@ public static class DriverFactoryBootstrap
|
||||
services.AddSingleton<IDriverFactory>(sp =>
|
||||
new DriverFactoryRegistryAdapter(sp.GetRequiredService<DriverFactoryRegistry>()));
|
||||
|
||||
// Phase 6.1 resilience infrastructure — the process-singleton tracker + Polly pipeline builder,
|
||||
// and the per-driver-instance invoker factory the DriverHostActor uses to wrap every capability
|
||||
// call. Registered here (driver nodes) because the factory needs DriverFactoryRegistry.GetTier;
|
||||
// admin-only nodes skip this and the runtime falls back to the pass-through factory.
|
||||
services.AddSingleton<DriverResilienceStatusTracker>();
|
||||
services.AddSingleton<DriverResiliencePipelineBuilder>(sp =>
|
||||
new DriverResiliencePipelineBuilder(
|
||||
timeProvider: null, // TimeProvider.System
|
||||
statusTracker: sp.GetRequiredService<DriverResilienceStatusTracker>(),
|
||||
logger: sp.GetService<ILoggerFactory>()?.CreateLogger("ZB.MOM.WW.OtOpcUa.Core.Resilience.DriverResiliencePipelineBuilder")));
|
||||
services.AddSingleton<IDriverCapabilityInvokerFactory>(sp =>
|
||||
{
|
||||
var registry = sp.GetRequiredService<DriverFactoryRegistry>();
|
||||
return new DriverCapabilityInvokerFactory(
|
||||
sp.GetRequiredService<DriverResiliencePipelineBuilder>(),
|
||||
sp.GetRequiredService<DriverResilienceStatusTracker>(),
|
||||
registry.GetTier);
|
||||
});
|
||||
|
||||
// Driver nodes also carry the probe set so a fused admin,driver node has it; the admin-only
|
||||
// case is covered by Program.cs calling AddOtOpcUaDriverProbes() in the hasAdmin block.
|
||||
services.AddOtOpcUaDriverProbes();
|
||||
|
||||
@@ -61,6 +61,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
private readonly CommonsNodeId _localNode;
|
||||
private readonly IActorRef? _coordinatorOverride;
|
||||
private readonly IDriverFactory _driverFactory;
|
||||
|
||||
/// <summary>Builds the per-driver-instance <see cref="IDriverCapabilityInvoker"/> attached to each
|
||||
/// spawned <see cref="DriverInstanceActor"/>. Resolved from DI like <see cref="_driverFactory"/>;
|
||||
/// defaults to <see cref="NullDriverCapabilityInvokerFactory"/> (pass-through) when the fused Host
|
||||
/// hasn't bound the real resilience factory.</summary>
|
||||
private readonly IDriverCapabilityInvokerFactory _invokerFactory;
|
||||
private readonly IReadOnlySet<string> _localRoles;
|
||||
private readonly IActorRef? _dependencyMux;
|
||||
private readonly IActorRef? _opcUaPublishActor;
|
||||
@@ -281,6 +287,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// ScriptedAlarm host instead of spawning a real <see cref="ScriptedAlarmHostActor"/> child, so
|
||||
/// tests can intercept the <see cref="ScriptedAlarmHostActor.ApplyScriptedAlarms"/> message. Null
|
||||
/// in production (the real host is spawned).</param>
|
||||
/// <param name="invokerFactory">Optional Phase 6.1 resilience-invoker factory used to attach an
|
||||
/// <see cref="IDriverCapabilityInvoker"/> to each spawned driver; defaults to
|
||||
/// <see cref="NullDriverCapabilityInvokerFactory"/> (pass-through) when not supplied.</param>
|
||||
/// <returns>The Akka.NET <see cref="Akka.Actor.Props"/> used to spawn this actor.</returns>
|
||||
public static Props Props(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
@@ -296,11 +305,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
IActorRef? virtualTagHostOverride = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
ScriptRootLogger? scriptRootLogger = null,
|
||||
IActorRef? scriptedAlarmHostOverride = null) =>
|
||||
IActorRef? scriptedAlarmHostOverride = null,
|
||||
IDriverCapabilityInvokerFactory? invokerFactory = null) =>
|
||||
Akka.Actor.Props.Create(() => new DriverHostActor(
|
||||
dbFactory, localNode, coordinator, driverFactory, localRoles, dependencyMux, opcUaPublishActor,
|
||||
healthPublisher, virtualTagEvaluator, historyWriter, virtualTagHostOverride,
|
||||
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride));
|
||||
loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory));
|
||||
|
||||
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
|
||||
/// <param name="dbFactory">Database context factory for configuration database access.</param>
|
||||
@@ -323,6 +333,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
/// host; when null the host is left unspawned.</param>
|
||||
/// <param name="scriptedAlarmHostOverride">Test seam: when supplied, used as the ScriptedAlarm host
|
||||
/// instead of spawning a real <see cref="ScriptedAlarmHostActor"/> child.</param>
|
||||
/// <param name="invokerFactory">Phase 6.1 resilience-invoker factory used to attach an
|
||||
/// <see cref="IDriverCapabilityInvoker"/> to each spawned driver; defaults to
|
||||
/// <see cref="NullDriverCapabilityInvokerFactory"/> (pass-through) when null.</param>
|
||||
public DriverHostActor(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
CommonsNodeId localNode,
|
||||
@@ -337,12 +350,14 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
IActorRef? virtualTagHostOverride = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
ScriptRootLogger? scriptRootLogger = null,
|
||||
IActorRef? scriptedAlarmHostOverride = null)
|
||||
IActorRef? scriptedAlarmHostOverride = null,
|
||||
IDriverCapabilityInvokerFactory? invokerFactory = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_localNode = localNode;
|
||||
_coordinatorOverride = coordinator;
|
||||
_driverFactory = driverFactory ?? NullDriverFactory.Instance;
|
||||
_invokerFactory = invokerFactory ?? NullDriverCapabilityInvokerFactory.Instance;
|
||||
_localRoles = localRoles ?? new HashSet<string>(StringComparer.Ordinal);
|
||||
_dependencyMux = dependencyMux;
|
||||
_opcUaPublishActor = opcUaPublishActor;
|
||||
@@ -1609,11 +1624,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
else
|
||||
{
|
||||
// Attach the per-instance Phase 6.1 resilience invoker so this driver's capability calls
|
||||
// route through the retry/breaker/bulkhead/telemetry pipeline. The pass-through factory
|
||||
// yields a no-op invoker on nodes without the real resilience factory bound.
|
||||
var invoker = _invokerFactory.Create(spec.DriverInstanceId, spec.DriverType);
|
||||
child = Context.ActorOf(
|
||||
DriverInstanceActor.Props(
|
||||
driver!,
|
||||
healthPublisher: _healthPublisher,
|
||||
clusterId: clusterId),
|
||||
clusterId: clusterId,
|
||||
invoker: invoker),
|
||||
actorName);
|
||||
child.Tell(new DriverInstanceActor.InitializeRequested(spec.DriverConfig));
|
||||
}
|
||||
|
||||
@@ -137,6 +137,15 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
public static readonly TimeSpan HealthPollInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly IDriver _driver;
|
||||
|
||||
/// <summary>Phase 6.1 resilience seam: every guarded driver-capability call this actor makes is
|
||||
/// routed through this invoker (retry / breaker / bulkhead / tracker telemetry). Defaults to
|
||||
/// <see cref="NullDriverCapabilityInvoker"/> (a genuine pass-through) when none is supplied — so
|
||||
/// unit tests + the F7 skeleton path behave exactly as a raw driver call. The fused Host builds a
|
||||
/// real per-instance invoker via <see cref="IDriverCapabilityInvokerFactory"/> and injects it at
|
||||
/// spawn. Consumed through the <see cref="IDriverCapabilityInvoker"/> abstraction because Runtime
|
||||
/// is deliberately Polly-free (it does not reference <c>ZB.MOM.WW.OtOpcUa.Core</c>).</summary>
|
||||
private readonly IDriverCapabilityInvoker _invoker;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly string _clusterId;
|
||||
private readonly IDriverHealthPublisher _healthPublisher;
|
||||
@@ -212,6 +221,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// <param name="rediscoverInterval">Optional interval between post-connect re-discovery passes; defaults to 2 seconds.</param>
|
||||
/// <param name="rediscoverMaxAttempts">Optional cap on re-discovery passes; defaults to 15.</param>
|
||||
/// <param name="rediscoverDiscoverTimeout">Optional per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param>
|
||||
/// <param name="invoker">Optional Phase 6.1 resilience invoker wrapping this driver's capability
|
||||
/// calls; defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when not supplied.</param>
|
||||
/// <returns>A <see cref="Akka.Actor.Props"/> instance configured to create the wrapped <see cref="DriverInstanceActor"/>.</returns>
|
||||
public static Props Props(
|
||||
IDriver driver,
|
||||
@@ -221,7 +232,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
string? clusterId = null,
|
||||
TimeSpan? rediscoverInterval = null,
|
||||
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
|
||||
TimeSpan? rediscoverDiscoverTimeout = null) =>
|
||||
TimeSpan? rediscoverDiscoverTimeout = null,
|
||||
IDriverCapabilityInvoker? invoker = null) =>
|
||||
Akka.Actor.Props.Create(() => new DriverInstanceActor(
|
||||
driver,
|
||||
reconnectInterval ?? DefaultReconnectInterval,
|
||||
@@ -230,7 +242,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
clusterId ?? string.Empty,
|
||||
rediscoverInterval,
|
||||
rediscoverMaxAttempts,
|
||||
rediscoverDiscoverTimeout));
|
||||
rediscoverDiscoverTimeout,
|
||||
invoker));
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and
|
||||
@@ -262,6 +275,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// <param name="rediscoverInterval">Interval between post-connect re-discovery passes; defaults to 2 seconds.</param>
|
||||
/// <param name="rediscoverMaxAttempts">Cap on the number of re-discovery passes; defaults to 15.</param>
|
||||
/// <param name="rediscoverDiscoverTimeout">Per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param>
|
||||
/// <param name="invoker">Phase 6.1 resilience invoker wrapping this driver's capability calls;
|
||||
/// defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when null.</param>
|
||||
public DriverInstanceActor(
|
||||
IDriver driver,
|
||||
TimeSpan reconnectInterval,
|
||||
@@ -270,9 +285,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
string? clusterId = null,
|
||||
TimeSpan? rediscoverInterval = null,
|
||||
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
|
||||
TimeSpan? rediscoverDiscoverTimeout = null)
|
||||
TimeSpan? rediscoverDiscoverTimeout = null,
|
||||
IDriverCapabilityInvoker? invoker = null)
|
||||
{
|
||||
_driver = driver;
|
||||
_invoker = invoker ?? NullDriverCapabilityInvoker.Instance;
|
||||
_driverInstanceId = driver.DriverInstanceId;
|
||||
_clusterId = clusterId ?? string.Empty;
|
||||
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
|
||||
@@ -572,9 +589,16 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
try
|
||||
{
|
||||
#pragma warning disable OTOPCUA0001 // RESILIENCE-DISPATCH-GAP (07/C-1): dispatch bypasses CapabilityInvoker — Phase 6.1 pipeline built but never wired into DriverInstanceActor; tracked, NOT intentional.
|
||||
var results = await writable.WriteAsync(request, cts.Token);
|
||||
#pragma warning restore OTOPCUA0001
|
||||
// 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.
|
||||
var results = await _invoker.ExecuteWriteAsync(
|
||||
ResolveHost(msg.TagId),
|
||||
isIdempotent: true,
|
||||
async ct => await writable.WriteAsync(request, ct),
|
||||
cts.Token);
|
||||
if (results is { Count: 1 } && IsGoodStatus(results[0].StatusCode))
|
||||
{
|
||||
replyTo.Tell(new WriteAttributeResult(true, null));
|
||||
@@ -622,9 +646,12 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
try
|
||||
{
|
||||
#pragma warning disable OTOPCUA0001 // RESILIENCE-DISPATCH-GAP (07/C-1): dispatch bypasses CapabilityInvoker — Phase 6.1 pipeline built but never wired into DriverInstanceActor; tracked, NOT intentional.
|
||||
await src.AcknowledgeAsync(request, cts.Token);
|
||||
#pragma warning restore OTOPCUA0001
|
||||
// Ack is a write-shaped operation — the AlarmAcknowledge tier policy never retries.
|
||||
await _invoker.ExecuteAsync(
|
||||
DriverCapability.AlarmAcknowledge,
|
||||
ResolveHost(msg.ConditionId),
|
||||
async ct => await src.AcknowledgeAsync(request, ct),
|
||||
cts.Token);
|
||||
_log.Info("DriverInstance {Id}: acknowledged native condition {Cond} by {User}",
|
||||
_driverInstanceId, msg.ConditionId, msg.OperatorUser);
|
||||
}
|
||||
@@ -661,10 +688,13 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
subscribable.OnDataChange += _dataChangeHandler;
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
#pragma warning disable OTOPCUA0001 // RESILIENCE-DISPATCH-GAP (07/C-1): dispatch bypasses CapabilityInvoker — Phase 6.1 pipeline built but never wired into DriverInstanceActor; tracked, NOT intentional.
|
||||
_subscriptionHandle = await subscribable
|
||||
.SubscribeAsync(msg.FullReferences, msg.PublishingInterval, cts.Token);
|
||||
#pragma warning restore OTOPCUA0001
|
||||
// A bulk subscribe spans potentially many hosts, so key the pipeline on the driver
|
||||
// instance (single-host fallback) rather than any one reference's host.
|
||||
_subscriptionHandle = await _invoker.ExecuteAsync(
|
||||
DriverCapability.Subscribe,
|
||||
_driverInstanceId,
|
||||
async ct => await subscribable.SubscribeAsync(msg.FullReferences, msg.PublishingInterval, ct),
|
||||
cts.Token);
|
||||
|
||||
replyTo.Tell(new SubscriptionEstablished(_subscriptionHandle.DiagnosticId, msg.FullReferences.Count));
|
||||
_log.Info("DriverInstance {Id}: subscribed to {Count} refs ({Diag})",
|
||||
@@ -688,9 +718,12 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
#pragma warning disable OTOPCUA0001 // RESILIENCE-DISPATCH-GAP (07/C-1): dispatch bypasses CapabilityInvoker — Phase 6.1 pipeline built but never wired into DriverInstanceActor; tracked, NOT intentional.
|
||||
await subscribable.UnsubscribeAsync(_subscriptionHandle, cts.Token);
|
||||
#pragma warning restore OTOPCUA0001
|
||||
var handle = _subscriptionHandle; // non-null per the guard above; capture before the async lambda
|
||||
await _invoker.ExecuteAsync(
|
||||
DriverCapability.Subscribe,
|
||||
_driverInstanceId,
|
||||
async ct => await subscribable.UnsubscribeAsync(handle, ct),
|
||||
cts.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -767,9 +800,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
try
|
||||
{
|
||||
#pragma warning disable OTOPCUA0001 // RESILIENCE-DISPATCH-GAP (07/C-1): dispatch bypasses CapabilityInvoker — Phase 6.1 pipeline built but never wired into DriverInstanceActor; tracked, NOT intentional.
|
||||
_alarmSubscriptionHandle = await src.SubscribeAlarmsAsync(refs, cts.Token);
|
||||
#pragma warning restore OTOPCUA0001
|
||||
_alarmSubscriptionHandle = await _invoker.ExecuteAsync(
|
||||
DriverCapability.AlarmSubscribe,
|
||||
_driverInstanceId,
|
||||
async ct => await src.SubscribeAlarmsAsync(refs, ct),
|
||||
cts.Token);
|
||||
_log.Info("DriverInstance {Id}: native-alarm subscription established for {Count} alarm ref(s) ({Diag})",
|
||||
_driverInstanceId, refs.Count, _alarmSubscriptionHandle.DiagnosticId);
|
||||
}
|
||||
@@ -824,13 +859,17 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// Bound the browse — ReceiveAsync suspends the mailbox for the whole handler, so an unbounded
|
||||
// DiscoverAsync would block DisconnectObserved / ForceReconnect / writes / health-poll behind it.
|
||||
using var cts = new CancellationTokenSource(_rediscoverDiscoverTimeout);
|
||||
// NO ConfigureAwait(false): a genuinely-async DiscoverAsync (Galaxy / OpcUaClient / TwinCAT) must
|
||||
// resume on the actor task scheduler so the Context.Parent.Tell + Timers calls below run with a
|
||||
// live ActorContext. ConfigureAwait(false) would resume off-context and throw
|
||||
// NotSupportedException("no active ActorContext") — see the same warning on HandleSubscribeAsync.
|
||||
#pragma warning disable OTOPCUA0001 // RESILIENCE-DISPATCH-GAP (07/C-1): dispatch bypasses CapabilityInvoker — Phase 6.1 pipeline built but never wired into DriverInstanceActor; tracked, NOT intentional.
|
||||
await discovery.DiscoverAsync(builder, cts.Token);
|
||||
#pragma warning restore OTOPCUA0001
|
||||
// NO ConfigureAwait(false) on this outer await: a genuinely-async DiscoverAsync (Galaxy /
|
||||
// OpcUaClient / TwinCAT) must resume on the actor task scheduler so the Context.Parent.Tell +
|
||||
// Timers calls below run with a live ActorContext. ConfigureAwait(false) would resume
|
||||
// off-context and throw NotSupportedException("no active ActorContext"). The invoker's own
|
||||
// internal ConfigureAwait(false) does NOT propagate to this caller's await — the actor
|
||||
// continuation still resumes on the captured actor scheduler. (Discover retries per tier.)
|
||||
await _invoker.ExecuteAsync(
|
||||
DriverCapability.Discover,
|
||||
_driverInstanceId,
|
||||
async ct => await discovery.DiscoverAsync(builder, ct),
|
||||
cts.Token);
|
||||
nodes = builder.Nodes.ToArray(); // immutable snapshot — never hand the builder's live list across actors
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -910,6 +949,14 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
|
||||
private static bool IsGoodStatus(uint statusCode) => (statusCode >> 30) == 0;
|
||||
|
||||
/// <summary>Resolve the resilience-pipeline host key for a single driver-side reference. Multi-host
|
||||
/// drivers (e.g. Modbus across N PLCs) implement <see cref="IPerCallHostResolver"/> so one dead host
|
||||
/// trips only its own breaker; single-host drivers fall back to the driver-instance id. Used for the
|
||||
/// single-reference capability calls (write, alarm ack); bulk/whole-driver calls key on the instance
|
||||
/// id directly.</summary>
|
||||
private string ResolveHost(string reference) =>
|
||||
_driver is IPerCallHostResolver resolver ? resolver.ResolveHost(reference) : _driverInstanceId;
|
||||
|
||||
/// <summary>Records a transition into a Faulted / error state for the 5-minute sliding counter.</summary>
|
||||
private void RecordFault()
|
||||
{
|
||||
|
||||
@@ -213,6 +213,11 @@ public static class ServiceCollectionExtensions
|
||||
// Fallback to Null* if AddOtOpcUaRuntime wasn't called (e.g., test harnesses).
|
||||
var historianSink = resolver.GetService<IAlarmHistorianSink>() ?? NullAlarmHistorianSink.Instance;
|
||||
var driverFactory = resolver.GetService<IDriverFactory>() ?? NullDriverFactory.Instance;
|
||||
// Phase 6.1 resilience-invoker factory — bound by the fused Host (which references Core +
|
||||
// Polly); pass-through on nodes/harnesses that didn't register it. Resolved through the
|
||||
// Core.Abstractions seam so this Polly-free assembly stays that way.
|
||||
var invokerFactory = resolver.GetService<IDriverCapabilityInvokerFactory>()
|
||||
?? NullDriverCapabilityInvokerFactory.Instance;
|
||||
var addressSpaceSink = resolver.GetService<IOpcUaAddressSpaceSink>() ?? NullOpcUaAddressSpaceSink.Instance;
|
||||
var serviceLevel = resolver.GetService<IServiceLevelPublisher>() ?? NullServiceLevelPublisher.Instance;
|
||||
var loggerFactory = resolver.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance;
|
||||
@@ -333,7 +338,8 @@ public static class ServiceCollectionExtensions
|
||||
virtualTagEvaluator: virtualTagEvaluator,
|
||||
historyWriter: historyWriter,
|
||||
loggerFactory: loggerFactory,
|
||||
scriptRootLogger: scriptRootLogger),
|
||||
scriptRootLogger: scriptRootLogger,
|
||||
invokerFactory: invokerFactory),
|
||||
DriverHostActorName);
|
||||
registry.Register<DriverHostActorKey>(driverHost);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user