using Polly; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Observability; 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. Implements /// so the Polly-free Runtime dispatch layer can consume it /// through the abstraction (bound per-driver by ). /// /// /// 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 : IDriverCapabilityInvoker { private readonly DriverResiliencePipelineBuilder _builder; private readonly string _driverInstanceId; private readonly string _driverType; private readonly Func _optionsAccessor; private readonly DriverResilienceStatusTracker? _statusTracker; private readonly long _optionsGeneration; private DriverResilienceOptions? _noRetryWriteOptions; /// /// Construct an invoker for one driver instance. /// /// Shared, process-singleton pipeline builder. /// The DriverInstance.Id column value. /// /// Snapshot accessor for the current resilience options. Invoked per call so Admin-edit + /// pipeline-invalidate can take effect without restarting the invoker. /// /// Driver type name for structured-log enrichment (e.g. "Modbus"). /// Optional resilience-status tracker. When wired, every capability call records start/complete so the AdminUI resilience panel can surface as the in-flight-call-depth proxy, and each successful call resets so the counter reflects only an active retry storm. /// /// Options epoch for this invoker (01/S-7). Threaded into every pipeline-cache lookup so a lingering /// old child's late re-cache can never poison a newer invoker's pipeline. Defaults to 0 so existing /// callers/tests are unaffected. /// public CapabilityInvoker( DriverResiliencePipelineBuilder builder, string driverInstanceId, Func optionsAccessor, string driverType = "Unknown", DriverResilienceStatusTracker? statusTracker = null, long optionsGeneration = 0) { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(optionsAccessor); _builder = builder; _driverInstanceId = driverInstanceId; _driverType = driverType; _optionsAccessor = optionsAccessor; _statusTracker = statusTracker; _optionsGeneration = optionsGeneration; } /// public async ValueTask ExecuteAsync( DriverCapability capability, string hostName, Func> callSite, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(callSite); var pipeline = ResolvePipeline(capability, hostName); _statusTracker?.RecordCallStart(_driverInstanceId, hostName); try { using (LogContextEnricher.Push(_driverInstanceId, _driverType, capability, LogContextEnricher.NewCorrelationId())) { var result = await pipeline.ExecuteAsync(callSite, cancellationToken).ConfigureAwait(false); // Success resets ConsecutiveFailures so the counter is a true "consecutive" gauge // (nonzero only during an active retry storm), not stuck at the last blip's count. _statusTracker?.RecordSuccess(_driverInstanceId, hostName, DateTime.UtcNow); return result; } } finally { _statusTracker?.RecordCallComplete(_driverInstanceId, hostName); } } /// public async ValueTask ExecuteAsync( DriverCapability capability, string hostName, Func callSite, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(callSite); var pipeline = ResolvePipeline(capability, hostName); _statusTracker?.RecordCallStart(_driverInstanceId, hostName); try { using (LogContextEnricher.Push(_driverInstanceId, _driverType, capability, LogContextEnricher.NewCorrelationId())) { await pipeline.ExecuteAsync(callSite, cancellationToken).ConfigureAwait(false); _statusTracker?.RecordSuccess(_driverInstanceId, hostName, DateTime.UtcNow); } } finally { _statusTracker?.RecordCallComplete(_driverInstanceId, hostName); } } /// public async ValueTask ExecuteWriteAsync( string hostName, bool isIdempotent, Func> callSite, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(callSite); if (!isIdempotent) { // Build the no-retry options snapshot ONCE per invoker lifetime (01/P-4). The options are // immutable for the invoker's lifetime — a ResilienceConfig change respawns the driver child, // which constructs a fresh invoker (#13 respawn-on-change) — so lazily caching the snapshot is // safe and removes the per-write allocation the previous once-per-call snapshot incurred. The // null-check-assign tolerates a benign first-call race (both threads build an equivalent record). var noRetryOptions = _noRetryWriteOptions ??= BuildNoRetryWriteOptions(); var pipeline = _builder.GetOrCreate(_driverInstanceId, $"{hostName}::non-idempotent", DriverCapability.Write, noRetryOptions, _optionsGeneration); // Record in-flight accounting on the CALLER's host (the "::non-idempotent" suffix is a // pipeline-cache key, not an operator-facing host) so Admin /hosts sees write depth too. _statusTracker?.RecordCallStart(_driverInstanceId, hostName); try { using (LogContextEnricher.Push(_driverInstanceId, _driverType, DriverCapability.Write, LogContextEnricher.NewCorrelationId())) { var result = await pipeline.ExecuteAsync(callSite, cancellationToken).ConfigureAwait(false); _statusTracker?.RecordSuccess(_driverInstanceId, hostName, DateTime.UtcNow); return result; } } finally { _statusTracker?.RecordCallComplete(_driverInstanceId, hostName); } } return await ExecuteAsync(DriverCapability.Write, hostName, callSite, cancellationToken).ConfigureAwait(false); } private DriverResilienceOptions BuildNoRetryWriteOptions() { var snapshot = _optionsAccessor(); return snapshot with { CapabilityPolicies = new Dictionary { [DriverCapability.Write] = snapshot.Resolve(DriverCapability.Write) with { RetryCount = 0 }, }, }; } private ResiliencePipeline ResolvePipeline(DriverCapability capability, string hostName) => _builder.GetOrCreate(_driverInstanceId, hostName, capability, _optionsAccessor(), _optionsGeneration); }