diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverCapabilityInvoker.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverCapabilityInvoker.cs new file mode 100644 index 00000000..348113a5 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverCapabilityInvoker.cs @@ -0,0 +1,99 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Abstraction over the Phase 6.1 resilience pipeline that wraps a single driver instance's +/// capability calls (retry / circuit-breaker / bulkhead / tracker telemetry). The Runtime +/// dispatch layer (DriverInstanceActor) consumes this interface instead of the concrete +/// CapabilityInvoker so the actor assembly does NOT pull in +/// ZB.MOM.WW.OtOpcUa.Core (which drags in Polly + driver hosting) — the same boundary +/// rationale as . The fused Host binds a real invoker (built by +/// ) per spawned driver; when none is supplied the +/// dispatch layer uses , a genuine pass-through. +/// +/// +/// The method shapes mirror CapabilityInvoker exactly so the concrete invoker satisfies +/// this interface without adapters. Callers pass the resolved hostName per call (a +/// multi-host driver resolves it from the tag reference via ; +/// a single-host driver passes its ) so per-host breaker +/// isolation + bulkhead-depth accounting key on the right target. +/// +public interface IDriverCapabilityInvoker +{ + /// Execute a value-returning capability call through the per-capability pipeline. + /// Return type of the underlying driver call. + /// The driver capability being executed. + /// The resolved host name for pipeline keying + status tracking. + /// The async function to execute (the raw driver-capability call). + /// Cancellation token for the operation. + /// The result of the underlying driver call. + ValueTask ExecuteAsync( + DriverCapability capability, + string hostName, + Func> callSite, + CancellationToken cancellationToken); + + /// Execute a void-returning capability call through the per-capability pipeline. + /// The driver capability being executed. + /// The resolved host name for pipeline keying + status tracking. + /// The async function to execute (the raw driver-capability call). + /// Cancellation token for the operation. + /// A task that represents the asynchronous operation. + ValueTask ExecuteAsync( + DriverCapability capability, + string hostName, + Func callSite, + CancellationToken cancellationToken); + + /// + /// Execute a call honoring idempotence semantics — when + /// is false, retries are disabled regardless of the + /// configured policy; when true, the call runs through the Write pipeline which may + /// retry if the tier configuration permits. + /// + /// Return type of the underlying driver write call. + /// The resolved host name for pipeline keying + status tracking. + /// Whether the write operation is safe to retry. + /// The async function to execute (the raw driver write call). + /// Cancellation token for the operation. + /// The result of the underlying driver write call. + ValueTask ExecuteWriteAsync( + string hostName, + bool isIdempotent, + Func> callSite, + CancellationToken cancellationToken); +} + +/// +/// Pass-through that invokes the call site directly with +/// NO pipeline — no timeout, retry, breaker, or telemetry. Bound when the dispatch layer is +/// constructed without a real invoker (unit tests, the F7 skeleton path, admin-only nodes that +/// never run drivers). Keeps behavior byte-for-byte identical to a raw driver call so existing +/// dispatch-layer tests are unaffected by the resilience wiring. +/// +public sealed class NullDriverCapabilityInvoker : IDriverCapabilityInvoker +{ + /// The shared singleton instance. + public static readonly NullDriverCapabilityInvoker Instance = new(); + private NullDriverCapabilityInvoker() { } + + /// + public ValueTask ExecuteAsync( + DriverCapability capability, + string hostName, + Func> callSite, + CancellationToken cancellationToken) => callSite(cancellationToken); + + /// + public ValueTask ExecuteAsync( + DriverCapability capability, + string hostName, + Func callSite, + CancellationToken cancellationToken) => callSite(cancellationToken); + + /// + public ValueTask ExecuteWriteAsync( + string hostName, + bool isIdempotent, + Func> callSite, + CancellationToken cancellationToken) => callSite(cancellationToken); +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverCapabilityInvokerFactory.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverCapabilityInvokerFactory.cs new file mode 100644 index 00000000..2160f74b --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverCapabilityInvokerFactory.cs @@ -0,0 +1,39 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Builds a per-driver-instance . The Runtime +/// DriverHostActor consumes this factory (resolved from DI, like ) +/// to attach a resilience invoker to each spawned DriverInstanceActor WITHOUT the actor +/// assembly referencing ZB.MOM.WW.OtOpcUa.Core (which drags in Polly). The fused Host +/// binds a concrete factory closing over the process-singleton pipeline builder + status tracker + +/// driver-tier resolver; when unbound, yields the +/// pass-through invoker so dispatch behaves exactly as before. +/// +public interface IDriverCapabilityInvokerFactory +{ + /// + /// Create the resilience invoker for one driver instance. The returned invoker is keyed on + /// and applies the tier policy resolved from + /// . + /// + /// The driver instance identifier (pipeline + tracker key). + /// The driver type name, used to resolve the resilience tier defaults. + /// A per-instance . + IDriverCapabilityInvoker Create(string driverInstanceId, string driverType); +} + +/// +/// Returns for every driver instance. Bound +/// when the fused Host hasn't registered a real resilience factory (Mac dev path, smoke tests, +/// admin-only nodes). Dispatch then routes calls straight through with no pipeline. +/// +public sealed class NullDriverCapabilityInvokerFactory : IDriverCapabilityInvokerFactory +{ + /// The shared singleton instance. + public static readonly NullDriverCapabilityInvokerFactory Instance = new(); + private NullDriverCapabilityInvokerFactory() { } + + /// + public IDriverCapabilityInvoker Create(string driverInstanceId, string driverType) => + NullDriverCapabilityInvoker.Instance; +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs index c6118261..e7a9f19d 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs @@ -68,7 +68,7 @@ public class GenericDriverNodeManager(IDriver driver) : IDisposable throw new NotSupportedException($"Driver '{Driver.DriverInstanceId}' does not implement ITagDiscovery."); var capturing = new CapturingBuilder(builder, _alarmSinks); -#pragma warning disable OTOPCUA0001 // RESILIENCE-DISPATCH-GAP (07/C-1): dispatch bypasses CapabilityInvoker — Phase 6.1 pipeline built but never wired into GenericDriverNodeManager; tracked, NOT intentional. +#pragma warning disable OTOPCUA0001 // NOT a production dispatch path: this Core scaffolding node-manager is exercised only by GenericDriverNodeManagerTests — the live server materialises the address space via the Server project's own OpcUaAddressSpaceBuilder path. No CapabilityInvoker by design (verified 07/#10: zero production references). If this class is ever wired into live dispatch, route this call through IDriverCapabilityInvoker. await discovery.DiscoverAsync(capturing, ct); #pragma warning restore OTOPCUA0001 diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs index e4e42922..6c55d4b1 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs @@ -7,16 +7,21 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Resilience; /// /// Executes driver-capability calls through a shared Polly pipeline. One invoker per /// (DriverInstance, IDriver) pair; the underlying -/// is process-singleton so all invokers share its cache. +/// is process-singleton so all invokers share its cache. Implements +/// so the Polly-free Runtime dispatch layer can consume it +/// through the abstraction (bound per-driver by ). /// /// -/// The server's dispatch -/// layer routes every capability call (IReadable.ReadAsync, IWritable.WriteAsync, -/// ITagDiscovery.DiscoverAsync, ISubscribable.SubscribeAsync/UnsubscribeAsync, -/// IHostConnectivityProbe probe loop, IAlarmSource.SubscribeAlarmsAsync/AcknowledgeAsync, -/// and all four IHistoryProvider reads) through this invoker. +/// The Runtime dispatch layer (DriverInstanceActor) routes its capability calls +/// (IWritable.WriteAsync, ITagDiscovery.DiscoverAsync, +/// ISubscribable.SubscribeAsync/UnsubscribeAsync, +/// IAlarmSource.SubscribeAlarmsAsync/AcknowledgeAsync) through this invoker via the +/// seam. The UnwrappedCapabilityCallAnalyzer +/// (OTOPCUA0001) enforces that every guarded call is wrapped in an +/// / lambda (the interface's methods are registered wrapper +/// homes too, so an interface-typed invoker call suppresses the diagnostic). /// -public sealed class CapabilityInvoker +public sealed class CapabilityInvoker : IDriverCapabilityInvoker { private readonly DriverResiliencePipelineBuilder _builder; private readonly string _driverInstanceId; diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverCapabilityInvokerFactory.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverCapabilityInvokerFactory.cs new file mode 100644 index 00000000..6c7f0039 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverCapabilityInvokerFactory.cs @@ -0,0 +1,58 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Core.Resilience; + +/// +/// Concrete that builds a per-driver-instance +/// over the process-singleton pipeline builder + status tracker. +/// The fused Host registers this (it references Core + Polly); the Runtime dispatch layer +/// resolves the abstraction from DI and never sees this type. +/// +/// +/// Resilience policy is currently the tier default for the driver type — resolved +/// via the injected tier resolver (backed by DriverFactoryRegistry.GetTier). The +/// per-instance DriverInstance.ResilienceConfig JSON override is NOT yet applied because +/// the deployment artifact (DriverInstanceSpec) does not carry it; plumbing that column +/// through the composer/artifact is a tracked follow-up. Until then every instance of a given +/// driver type gets that type's tier defaults. +/// +/// Options are snapshotted once per (tier is fixed for a driver type), +/// so the invoker's per-call options accessor is allocation-free on the hot path. +/// +public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFactory +{ + private readonly DriverResiliencePipelineBuilder _builder; + private readonly DriverResilienceStatusTracker _statusTracker; + private readonly Func _tierResolver; + + /// Construct the factory over the shared resilience infrastructure. + /// Process-singleton Polly pipeline builder (shared pipeline cache). + /// Process-singleton resilience status tracker feeding Admin /hosts. + /// Resolves a driver type name to its (e.g. DriverFactoryRegistry.GetTier). + public DriverCapabilityInvokerFactory( + DriverResiliencePipelineBuilder builder, + DriverResilienceStatusTracker statusTracker, + Func tierResolver) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(statusTracker); + ArgumentNullException.ThrowIfNull(tierResolver); + _builder = builder; + _statusTracker = statusTracker; + _tierResolver = tierResolver; + } + + /// + public IDriverCapabilityInvoker Create(string driverInstanceId, string driverType) + { + var tier = _tierResolver(driverType); + // No per-instance ResilienceConfig in the artifact yet — tier defaults only (see remarks). + var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, resilienceConfigJson: null, out _); + return new CapabilityInvoker( + _builder, + driverInstanceId, + optionsAccessor: () => options, + driverType: driverType, + statusTracker: _statusTracker); + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs index 03f36ef3..b1190f86 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; using Polly; using Polly.CircuitBreaker; using Polly.Retry; @@ -25,6 +26,7 @@ public sealed class DriverResiliencePipelineBuilder private readonly ConcurrentDictionary _pipelines = new(); private readonly TimeProvider _timeProvider; private readonly DriverResilienceStatusTracker? _statusTracker; + private readonly ILogger? _logger; /// Construct with the ambient clock (use in prod). /// Clock source for pipeline timeouts + breaker sampling. Defaults to system. @@ -33,12 +35,19 @@ public sealed class DriverResiliencePipelineBuilder /// LastBreakerOpenUtc, breaker-close resets failures. Feeds Admin /hosts + /// the Polly bulkhead-depth column. Absent tracker means no telemetry (unit tests + /// deployments that don't care about resilience observability). + /// When non-null, retry / circuit-breaker-open / breaker-close events are + /// logged (instance + host + capability + attempt/exception). This is the operator-facing + /// observability surface for the resilience pipeline until the Admin /hosts reader + /// (Phase 6.1 Stream E.2/E.3) ships — without it the pipeline runs silently. Absent logger = + /// no resilience logging (unit tests). public DriverResiliencePipelineBuilder( TimeProvider? timeProvider = null, - DriverResilienceStatusTracker? statusTracker = null) + DriverResilienceStatusTracker? statusTracker = null, + ILogger? logger = null) { _timeProvider = timeProvider ?? TimeProvider.System; _statusTracker = statusTracker; + _logger = logger; } /// @@ -66,8 +75,8 @@ public sealed class DriverResiliencePipelineBuilder var key = new PipelineKey(driverInstanceId, hostName, capability); return _pipelines.GetOrAdd(key, static (k, state) => Build( - k.DriverInstanceId, k.HostName, state.capability, state.options, state.timeProvider, state.tracker), - (capability, options, timeProvider: _timeProvider, tracker: _statusTracker)); + k.DriverInstanceId, k.HostName, state.capability, state.options, state.timeProvider, state.tracker, state.logger), + (capability, options, timeProvider: _timeProvider, tracker: _statusTracker, logger: _logger)); } /// Drop cached pipelines for one driver instance (e.g. on ResilienceConfig change). Test + Admin-reload use. @@ -93,7 +102,8 @@ public sealed class DriverResiliencePipelineBuilder DriverCapability capability, DriverResilienceOptions options, TimeProvider timeProvider, - DriverResilienceStatusTracker? tracker) + DriverResilienceStatusTracker? tracker, + ILogger? logger) { var policy = options.Resolve(capability); var builder = new ResiliencePipelineBuilder { TimeProvider = timeProvider }; @@ -114,11 +124,14 @@ public sealed class DriverResiliencePipelineBuilder MaxDelay = TimeSpan.FromSeconds(5), ShouldHandle = new PredicateBuilder().Handle(ex => ex is not OperationCanceledException), }; - if (tracker is not null) + if (tracker is not null || logger is not null) { retryOptions.OnRetry = args => { - tracker.RecordFailure(driverInstanceId, hostName, timeProvider.GetUtcNow().UtcDateTime); + tracker?.RecordFailure(driverInstanceId, hostName, timeProvider.GetUtcNow().UtcDateTime); + logger?.LogWarning( + "Driver resilience retry: instance={DriverInstanceId} host={HostName} capability={Capability} attempt={Attempt}: {Reason}", + driverInstanceId, hostName, capability, args.AttemptNumber, args.Outcome.Exception?.Message ?? "unknown"); return default; }; } @@ -135,18 +148,24 @@ public sealed class DriverResiliencePipelineBuilder BreakDuration = TimeSpan.FromSeconds(15), ShouldHandle = new PredicateBuilder().Handle(ex => ex is not OperationCanceledException), }; - if (tracker is not null) + if (tracker is not null || logger is not null) { breakerOptions.OnOpened = args => { - tracker.RecordBreakerOpen(driverInstanceId, hostName, timeProvider.GetUtcNow().UtcDateTime); + tracker?.RecordBreakerOpen(driverInstanceId, hostName, timeProvider.GetUtcNow().UtcDateTime); + logger?.LogWarning( + "Driver resilience circuit-breaker OPENED: instance={DriverInstanceId} host={HostName} capability={Capability} breakDuration={BreakDuration}: {Reason}", + driverInstanceId, hostName, capability, args.BreakDuration, args.Outcome.Exception?.Message ?? "unknown"); return default; }; breakerOptions.OnClosed = args => { // Closing the breaker means the target recovered — reset the consecutive- // failure counter so Admin UI stops flashing red for this host. - tracker.RecordSuccess(driverInstanceId, hostName, timeProvider.GetUtcNow().UtcDateTime); + tracker?.RecordSuccess(driverInstanceId, hostName, timeProvider.GetUtcNow().UtcDateTime); + logger?.LogInformation( + "Driver resilience circuit-breaker closed (recovered): instance={DriverInstanceId} host={HostName} capability={Capability}", + driverInstanceId, hostName, capability); return default; }; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs index 0619af3e..b2123bc7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs @@ -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(sp => new DriverFactoryRegistryAdapter(sp.GetRequiredService())); + // 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(); + services.AddSingleton(sp => + new DriverResiliencePipelineBuilder( + timeProvider: null, // TimeProvider.System + statusTracker: sp.GetRequiredService(), + logger: sp.GetService()?.CreateLogger("ZB.MOM.WW.OtOpcUa.Core.Resilience.DriverResiliencePipelineBuilder"))); + services.AddSingleton(sp => + { + var registry = sp.GetRequiredService(); + return new DriverCapabilityInvokerFactory( + sp.GetRequiredService(), + sp.GetRequiredService(), + 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(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 1df6bb83..2f258d98 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -61,6 +61,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers private readonly CommonsNodeId _localNode; private readonly IActorRef? _coordinatorOverride; private readonly IDriverFactory _driverFactory; + + /// Builds the per-driver-instance attached to each + /// spawned . Resolved from DI like ; + /// defaults to (pass-through) when the fused Host + /// hasn't bound the real resilience factory. + private readonly IDriverCapabilityInvokerFactory _invokerFactory; private readonly IReadOnlySet _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 child, so /// tests can intercept the message. Null /// in production (the real host is spawned). + /// Optional Phase 6.1 resilience-invoker factory used to attach an + /// to each spawned driver; defaults to + /// (pass-through) when not supplied. /// The Akka.NET used to spawn this actor. public static Props Props( IDbContextFactory 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)); /// Initializes a new DriverHostActor with the specified dependencies. /// Database context factory for configuration database access. @@ -323,6 +333,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers /// host; when null the host is left unspawned. /// Test seam: when supplied, used as the ScriptedAlarm host /// instead of spawning a real child. + /// Phase 6.1 resilience-invoker factory used to attach an + /// to each spawned driver; defaults to + /// (pass-through) when null. public DriverHostActor( IDbContextFactory 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(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)); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs index ef7fc99c..49de5c74 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs @@ -137,6 +137,15 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers public static readonly TimeSpan HealthPollInterval = TimeSpan.FromSeconds(30); private readonly IDriver _driver; + + /// 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 + /// (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 and injects it at + /// spawn. Consumed through the abstraction because Runtime + /// is deliberately Polly-free (it does not reference ZB.MOM.WW.OtOpcUa.Core). + 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 /// Optional interval between post-connect re-discovery passes; defaults to 2 seconds. /// Optional cap on re-discovery passes; defaults to 15. /// Optional per-pass timeout for ; defaults to 30 seconds. + /// Optional Phase 6.1 resilience invoker wrapping this driver's capability + /// calls; defaults to (pass-through) when not supplied. /// A instance configured to create the wrapped . 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)); /// /// 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 /// Interval between post-connect re-discovery passes; defaults to 2 seconds. /// Cap on the number of re-discovery passes; defaults to 15. /// Per-pass timeout for ; defaults to 30 seconds. + /// Phase 6.1 resilience invoker wrapping this driver's capability calls; + /// defaults to (pass-through) when null. 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; + /// Resolve the resilience-pipeline host key for a single driver-side reference. Multi-host + /// drivers (e.g. Modbus across N PLCs) implement 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. + private string ResolveHost(string reference) => + _driver is IPerCallHostResolver resolver ? resolver.ResolveHost(reference) : _driverInstanceId; + /// Records a transition into a Faulted / error state for the 5-minute sliding counter. private void RecordFault() { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs index 6872052e..662f3df9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs @@ -213,6 +213,11 @@ public static class ServiceCollectionExtensions // Fallback to Null* if AddOtOpcUaRuntime wasn't called (e.g., test harnesses). var historianSink = resolver.GetService() ?? NullAlarmHistorianSink.Instance; var driverFactory = resolver.GetService() ?? 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() + ?? NullDriverCapabilityInvokerFactory.Instance; var addressSpaceSink = resolver.GetService() ?? NullOpcUaAddressSpaceSink.Instance; var serviceLevel = resolver.GetService() ?? NullServiceLevelPublisher.Instance; var loggerFactory = resolver.GetService() ?? 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(driverHost); diff --git a/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs b/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs index 82620cb4..644e0f86 100644 --- a/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs +++ b/src/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers/UnwrappedCapabilityCallAnalyzer.cs @@ -91,6 +91,12 @@ public sealed class UnwrappedCapabilityCallAnalyzer : DiagnosticAnalyzer [ ("ZB.MOM.WW.OtOpcUa.Core.Resilience.CapabilityInvoker", "ExecuteAsync"), ("ZB.MOM.WW.OtOpcUa.Core.Resilience.CapabilityInvoker", "ExecuteWriteAsync"), + // The Polly-free Runtime dispatch layer wraps calls through the IDriverCapabilityInvoker + // seam (it cannot reference the concrete CapabilityInvoker in Core). An interface-typed + // invoker call's containing type is the interface, so it must be a wrapper home too — else + // correctly-wrapped dispatch-layer calls would still trip OTOPCUA0001. + ("ZB.MOM.WW.OtOpcUa.Core.Abstractions.IDriverCapabilityInvoker", "ExecuteAsync"), + ("ZB.MOM.WW.OtOpcUa.Core.Abstractions.IDriverCapabilityInvoker", "ExecuteWriteAsync"), ]; private static readonly DiagnosticDescriptor Rule = new( diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResiliencePipelineBuilderTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResiliencePipelineBuilderTests.cs index 2a78f3b8..32b6ea73 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResiliencePipelineBuilderTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Resilience/DriverResiliencePipelineBuilderTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using Polly.CircuitBreaker; using Polly.Timeout; using Shouldly; @@ -296,4 +297,48 @@ public sealed class DriverResiliencePipelineBuilderTests tracker.TryGet("drv-trk", "dead")!.ConsecutiveFailures.ShouldBeGreaterThan(0); tracker.TryGet("drv-trk", "live").ShouldBeNull(); } + + /// + /// Retry events are logged when a logger is supplied — this is the operator-facing observability + /// surface the arch-review #10 live-verify relies on (the Admin /hosts reader isn't built). + /// Without it the resilience pipeline runs silently and a live retry/breaker transition is invisible. + /// + [Fact] + public async Task Retry_Is_Logged_When_Logger_Supplied() + { + var logger = new CapturingLogger(); + var builder = new DriverResiliencePipelineBuilder(logger: logger); + var pipeline = builder.GetOrCreate("drv-log", "host-9", DriverCapability.Read, TierAOptions); + var attempts = 0; + + await pipeline.ExecuteAsync(async _ => + { + attempts++; + if (attempts < 3) throw new InvalidOperationException("transient"); + await Task.Yield(); + }); + + attempts.ShouldBe(3); + logger.Entries.ShouldContain(e => + e.Level == LogLevel.Warning && + e.Message.Contains("resilience retry") && + e.Message.Contains("host-9")); + } + + /// Minimal in-memory capturing (level, formatted-message) pairs so the + /// resilience logging contract can be asserted without a real logging provider. + private sealed class CapturingLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = new(); + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) => Entries.Add((logLevel, formatter(state, exception))); + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + public void Dispose() { } + } + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorResilienceWiringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorResilienceWiringTests.cs new file mode 100644 index 00000000..323f3431 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorResilienceWiringTests.cs @@ -0,0 +1,138 @@ +using System.Collections.Concurrent; +using Akka.Actor; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// Runtime guard for the Phase 6.1 resilience wiring (arch-review #10 / RESILIENCE-DISPATCH-GAP): +/// proves the routes each driver-capability call through the +/// injected at RUNTIME, with the correct +/// and resolved host key — not just that the calls compile wrapped +/// (the OTOPCUA0001 analyzer is the compile-time guard; this is the behavioural one). +/// A recording invoker stands in for the real CapabilityInvoker so the test stays in the +/// Polly-free Runtime layer. +/// +public sealed class DriverInstanceActorResilienceWiringTests : RuntimeActorTestBase +{ + /// + /// A bulk subscribe issued on connect (via ResubscribeDesired) routes through the invoker + /// with keyed on the driver-instance id (single-host + /// fallback for a set that may span hosts). The driver's SubscribeAsync only runs from + /// inside the invoker's call-site, so SubscribeCount >= 1 already proves the route. + /// + [Fact] + public async Task Subscribe_routes_through_the_capability_invoker() + { + var invoker = new RecordingInvoker(); + var driver = new SubscribableStubDriver(); + var actor = Sys.ActorOf(DriverInstanceActor.Props(driver, invoker: invoker)); + + actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions( + new[] { "tag-a", "tag-b" }, TimeSpan.FromMilliseconds(100))); + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + AwaitCondition(() => driver.SubscribeCount >= 1, TimeSpan.FromSeconds(3)); + + // Barrier: round-trip a Connected-only Ask so the async subscribe handler has fully recorded. + await actor.Ask( + new DriverInstanceActor.Subscribe(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)), + TimeSpan.FromSeconds(3)); + + invoker.Calls.ShouldContain(c => c.Capability == DriverCapability.Subscribe && c.Host == driver.DriverInstanceId); + } + + /// + /// A write routes through the invoker's ExecuteWriteAsync path (recorded as + /// ) with the host resolved via + /// — proving per-host breaker keying is wired, not the + /// single-host fallback. A write that arrives before Connected fast-fails WITHOUT + /// touching the invoker, so the loop drives to Connected and the recorded call is the real one. + /// + [Fact] + public async Task Write_routes_through_the_invoker_with_resolved_host() + { + var invoker = new RecordingInvoker(); + var driver = new HostResolvingWritableDriver(); // ResolveHost("tag-1") => "plc-7" + var actor = Sys.ActorOf(DriverInstanceActor.Props(driver, invoker: invoker)); + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + + // Retry until the write succeeds (i.e. the actor reached Connected). Connecting-state writes + // fast-fail on a synchronous path that never reaches the invoker, so they don't pollute Calls. + DriverInstanceActor.WriteAttributeResult? reply = null; + for (var i = 0; i < 60 && reply is not { Success: true }; i++) + { + reply = await actor.Ask( + new DriverInstanceActor.WriteAttribute("tag-1", 42), TimeSpan.FromSeconds(1)); + if (reply is not { Success: true }) await Task.Delay(25); + } + + reply.ShouldNotBeNull(); + reply!.Success.ShouldBeTrue(); + driver.Writes.Count.ShouldBeGreaterThanOrEqualTo(1); // the invoker's call-site actually ran + invoker.Calls.ShouldContain(c => c.Capability == DriverCapability.Write && c.Host == "plc-7"); + } + + /// Records every capability + resolved-host key routed through it, then delegates to the + /// real call-site so the driver still runs. Stands in for CapabilityInvoker without pulling + /// Polly / Core into the Runtime test. + private sealed class RecordingInvoker : IDriverCapabilityInvoker + { + public ConcurrentQueue<(DriverCapability Capability, string Host)> Calls { get; } = new(); + + public ValueTask ExecuteAsync( + DriverCapability capability, string hostName, + Func> callSite, CancellationToken cancellationToken) + { + Calls.Enqueue((capability, hostName)); + return callSite(cancellationToken); + } + + public ValueTask ExecuteAsync( + DriverCapability capability, string hostName, + Func callSite, CancellationToken cancellationToken) + { + Calls.Enqueue((capability, hostName)); + return callSite(cancellationToken); + } + + public ValueTask ExecuteWriteAsync( + string hostName, bool isIdempotent, + Func> callSite, CancellationToken cancellationToken) + { + Calls.Enqueue((DriverCapability.Write, hostName)); + return callSite(cancellationToken); + } + } + + /// A writable driver that also resolves a per-tag host, so the write path exercises + /// 's ResolveHost (via ). + private sealed class HostResolvingWritableDriver : IDriver, IWritable, IPerCallHostResolver + { + public List Writes { get; } = new(); + + public string DriverInstanceId => "host-resolving-driver"; + public string DriverType => "Stub"; + + public string ResolveHost(string fullReference) => "plc-7"; + + public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; + public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; + public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); + public long GetMemoryFootprint() => 0; + public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task> WriteAsync( + IReadOnlyList writes, CancellationToken cancellationToken) + { + Writes.AddRange(writes); + IReadOnlyList results = writes.Select(_ => new WriteResult(0u)).ToList(); + return Task.FromResult(results); + } + } +} diff --git a/tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/UnwrappedCapabilityCallAnalyzerTests.cs b/tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/UnwrappedCapabilityCallAnalyzerTests.cs index 0fcaab12..c13a25bb 100644 --- a/tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/UnwrappedCapabilityCallAnalyzerTests.cs +++ b/tests/Tooling/ZB.MOM.WW.OtOpcUa.Analyzers.Tests/UnwrappedCapabilityCallAnalyzerTests.cs @@ -61,6 +61,11 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions { => throw new NotSupportedException(); } public enum DriverCapability { Read, Write, Discover, AlarmSubscribe, AlarmAcknowledge } + public interface IDriverCapabilityInvoker { + ValueTask ExecuteAsync(DriverCapability c, string host, Func> call, CancellationToken ct); + ValueTask ExecuteAsync(DriverCapability c, string host, Func call, CancellationToken ct); + ValueTask ExecuteWriteAsync(string host, bool isIdempotent, Func> call, CancellationToken ct); + } } namespace ZB.MOM.WW.OtOpcUa.Core.Resilience { using System; @@ -130,6 +135,36 @@ namespace ZB.MOM.WW.OtOpcUa.Server { diags.ShouldBeEmpty(); } + /// The Polly-free Runtime dispatch layer wraps calls through the + /// IDriverCapabilityInvoker seam (it cannot reference the concrete CapabilityInvoker). + /// An interface-typed invoker call must be recognised as a wrapper home too — arch-review #10. + [Fact] + public async Task Wrapped_InsideDriverCapabilityInvokerInterfaceLambda_PassesCleanly() + { + const string userSrc = """ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Server { + public sealed class GoodSeamCaller { + public async Task Sub(ISubscribable driver, IDriverCapabilityInvoker invoker) { + var _ = await invoker.ExecuteAsync(DriverCapability.Read, "h1", + async ct => await driver.SubscribeAsync(new List(), System.TimeSpan.Zero, ct), CancellationToken.None); + } + public async Task Wr(IReadable driver, IDriverCapabilityInvoker invoker) { + // ExecuteWriteAsync interface overload is a wrapper home too (value-returning call wrapped). + var _ = await invoker.ExecuteWriteAsync("h1", true, + async ct => await driver.ReadAsync(new List(), ct), CancellationToken.None); + } + } +} +"""; + var diags = await Compile(userSrc); + diags.ShouldBeEmpty(); + } + /// Verifies that a direct write without wrapper trips the diagnostic. [Fact] public async Task DirectWrite_WithoutWrapper_TripsDiagnostic()