Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs
T

176 lines
8.5 KiB
C#

using Polly;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Observability;
namespace ZB.MOM.WW.OtOpcUa.Core.Resilience;
/// <summary>
/// Executes driver-capability calls through a shared Polly pipeline. One invoker per
/// <c>(DriverInstance, IDriver)</c> pair; the underlying <see cref="DriverResiliencePipelineBuilder"/>
/// is process-singleton so all invokers share its cache. Implements
/// <see cref="IDriverCapabilityInvoker"/> so the Polly-free Runtime dispatch layer can consume it
/// through the abstraction (bound per-driver by <see cref="IDriverCapabilityInvokerFactory"/>).
/// </summary>
/// <remarks>
/// The Runtime dispatch layer (<c>DriverInstanceActor</c>) routes its capability calls
/// (<c>IWritable.WriteAsync</c>, <c>ITagDiscovery.DiscoverAsync</c>,
/// <c>ISubscribable.SubscribeAsync/UnsubscribeAsync</c>,
/// <c>IAlarmSource.SubscribeAlarmsAsync/AcknowledgeAsync</c>) through this invoker via the
/// <see cref="IDriverCapabilityInvoker"/> seam. The <c>UnwrappedCapabilityCallAnalyzer</c>
/// (OTOPCUA0001) enforces that every guarded call is wrapped in an <see cref="ExecuteAsync{TResult}"/>
/// / <see cref="ExecuteWriteAsync{TResult}"/> lambda (the interface's methods are registered wrapper
/// homes too, so an interface-typed invoker call suppresses the diagnostic).
/// </remarks>
public sealed class CapabilityInvoker : IDriverCapabilityInvoker
{
private readonly DriverResiliencePipelineBuilder _builder;
private readonly string _driverInstanceId;
private readonly string _driverType;
private readonly Func<DriverResilienceOptions> _optionsAccessor;
private readonly DriverResilienceStatusTracker? _statusTracker;
private readonly long _optionsGeneration;
private DriverResilienceOptions? _noRetryWriteOptions;
/// <summary>
/// Construct an invoker for one driver instance.
/// </summary>
/// <param name="builder">Shared, process-singleton pipeline builder.</param>
/// <param name="driverInstanceId">The <c>DriverInstance.Id</c> column value.</param>
/// <param name="optionsAccessor">
/// Snapshot accessor for the current resilience options. Invoked per call so Admin-edit +
/// pipeline-invalidate can take effect without restarting the invoker.
/// </param>
/// <param name="driverType">Driver type name for structured-log enrichment (e.g. <c>"Modbus"</c>).</param>
/// <param name="statusTracker">Optional resilience-status tracker. When wired, every capability call records start/complete so the AdminUI resilience panel can surface <see cref="ResilienceStatusSnapshot.CurrentInFlight"/> as the in-flight-call-depth proxy, and each successful call resets <see cref="ResilienceStatusSnapshot.ConsecutiveFailures"/> so the counter reflects only an active retry storm.</param>
/// <param name="optionsGeneration">
/// 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.
/// </param>
public CapabilityInvoker(
DriverResiliencePipelineBuilder builder,
string driverInstanceId,
Func<DriverResilienceOptions> 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;
}
/// <inheritdoc />
public async ValueTask<TResult> ExecuteAsync<TResult>(
DriverCapability capability,
string hostName,
Func<CancellationToken, ValueTask<TResult>> 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);
}
}
/// <inheritdoc />
public async ValueTask ExecuteAsync(
DriverCapability capability,
string hostName,
Func<CancellationToken, ValueTask> 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);
}
}
/// <inheritdoc />
public async ValueTask<TResult> ExecuteWriteAsync<TResult>(
string hostName,
bool isIdempotent,
Func<CancellationToken, ValueTask<TResult>> 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, CapabilityPolicy>
{
[DriverCapability.Write] = snapshot.Resolve(DriverCapability.Write) with { RetryCount = 0 },
},
};
}
private ResiliencePipeline ResolvePipeline(DriverCapability capability, string hostName) =>
_builder.GetOrCreate(_driverInstanceId, hostName, capability, _optionsAccessor(), _optionsGeneration);
}