176 lines
8.5 KiB
C#
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);
|
|
}
|