354b0e7613
Re-review at 7286d320. Core-013 (duplicate <summary> on HostBoundHandle), Core-014
(clarify EquipmentNodeWalker test-only hardcoded attrs). Both Low, doc-only. Prior
authz/Galaxy churn verified correct.
168 lines
8.1 KiB
C#
168 lines
8.1 KiB
C#
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
|
|
|
/// <summary>
|
|
/// Wraps the three mutating surfaces of <see cref="IAlarmSource"/>
|
|
/// (<see cref="IAlarmSource.SubscribeAlarmsAsync"/>, <see cref="IAlarmSource.UnsubscribeAlarmsAsync"/>,
|
|
/// <see cref="IAlarmSource.AcknowledgeAsync"/>) through <see cref="CapabilityInvoker"/> so the
|
|
/// Phase 6.1 resilience pipeline runs — retry semantics match
|
|
/// <see cref="DriverCapability.AlarmSubscribe"/> (retries by default) and
|
|
/// <see cref="DriverCapability.AlarmAcknowledge"/> (does NOT retry per decision #143).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>Multi-host dispatch: when the driver implements <see cref="IPerCallHostResolver"/>,
|
|
/// each source-node-id is resolved individually + grouped by host so a dead PLC inside a
|
|
/// multi-device driver doesn't poison the sibling hosts' breakers. Drivers with a single
|
|
/// host fall back to <see cref="IDriver.DriverInstanceId"/> as the single-host key.</para>
|
|
///
|
|
/// <para>Why this lives here + not on <see cref="CapabilityInvoker"/>: alarm surfaces have a
|
|
/// handle-returning shape (SubscribeAlarmsAsync returns <see cref="IAlarmSubscriptionHandle"/>)
|
|
/// + a per-call fan-out (AcknowledgeAsync gets a batch of
|
|
/// <see cref="AlarmAcknowledgeRequest"/>s that may span multiple hosts). Keeping the fan-out
|
|
/// logic here keeps the invoker's execute-overloads narrow.</para>
|
|
/// </remarks>
|
|
public sealed class AlarmSurfaceInvoker
|
|
{
|
|
private readonly CapabilityInvoker _invoker;
|
|
private readonly IAlarmSource _alarmSource;
|
|
private readonly IPerCallHostResolver? _hostResolver;
|
|
private readonly string _defaultHost;
|
|
|
|
/// <summary>Initializes a new instance of the AlarmSurfaceInvoker class.</summary>
|
|
/// <param name="invoker">The capability invoker for resilience pipeline.</param>
|
|
/// <param name="alarmSource">The alarm source to invoke.</param>
|
|
/// <param name="defaultHost">The default host name for single-host scenarios.</param>
|
|
/// <param name="hostResolver">Optional per-call host resolver for multi-host dispatch.</param>
|
|
public AlarmSurfaceInvoker(
|
|
CapabilityInvoker invoker,
|
|
IAlarmSource alarmSource,
|
|
string defaultHost,
|
|
IPerCallHostResolver? hostResolver = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(invoker);
|
|
ArgumentNullException.ThrowIfNull(alarmSource);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(defaultHost);
|
|
|
|
_invoker = invoker;
|
|
_alarmSource = alarmSource;
|
|
_defaultHost = defaultHost;
|
|
_hostResolver = hostResolver;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Subscribe to alarm events for a set of source node ids, fanning out by resolved host
|
|
/// so per-host breakers / bulkheads apply. Returns one handle per host — callers that
|
|
/// don't care about per-host separation may concatenate them. Each returned handle wraps
|
|
/// the driver's opaque handle together with its resolved host so <see cref="UnsubscribeAsync"/>
|
|
/// routes through the same host's pipeline that the subscription was created on.
|
|
/// </summary>
|
|
/// <param name="sourceNodeIds">The source node IDs to subscribe to.</param>
|
|
/// <param name="cancellationToken">The cancellation token.</param>
|
|
public async Task<IReadOnlyList<IAlarmSubscriptionHandle>> SubscribeAsync(
|
|
IReadOnlyList<string> sourceNodeIds,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(sourceNodeIds);
|
|
if (sourceNodeIds.Count == 0) return [];
|
|
|
|
var byHost = GroupByHost(sourceNodeIds);
|
|
var handles = new List<IAlarmSubscriptionHandle>(byHost.Count);
|
|
foreach (var (host, ids) in byHost)
|
|
{
|
|
var inner = await _invoker.ExecuteAsync(
|
|
DriverCapability.AlarmSubscribe,
|
|
host,
|
|
async ct => await _alarmSource.SubscribeAlarmsAsync(ids, ct).ConfigureAwait(false),
|
|
cancellationToken).ConfigureAwait(false);
|
|
handles.Add(new HostBoundHandle(inner, host));
|
|
}
|
|
return handles;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cancel an alarm subscription. Routes through the same host's resilience pipeline
|
|
/// that the subscription was created on (carried in the <see cref="HostBoundHandle"/>
|
|
/// wrapper returned by <see cref="SubscribeAsync"/>). Falls back to the default host for
|
|
/// handles not created by this invoker so the method remains safe to call on any
|
|
/// <see cref="IAlarmSubscriptionHandle"/> implementation.
|
|
/// </summary>
|
|
/// <param name="handle">The subscription handle to unsubscribe.</param>
|
|
/// <param name="cancellationToken">The cancellation token.</param>
|
|
public ValueTask UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(handle);
|
|
var (innerHandle, host) = handle is HostBoundHandle bound
|
|
? (bound.Inner, bound.Host)
|
|
: (handle, _defaultHost);
|
|
|
|
return _invoker.ExecuteAsync(
|
|
DriverCapability.AlarmSubscribe,
|
|
host,
|
|
async ct => await _alarmSource.UnsubscribeAlarmsAsync(innerHandle, ct).ConfigureAwait(false),
|
|
cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Acknowledge alarms. Fans out by resolved host; each host's batch runs through the
|
|
/// AlarmAcknowledge pipeline (no-retry per decision #143 — an alarm-ack is not idempotent
|
|
/// at the plant-floor acknowledgement level even if the OPC UA spec permits re-issue).
|
|
/// </summary>
|
|
/// <param name="acknowledgements">The alarm acknowledgement requests.</param>
|
|
/// <param name="cancellationToken">The cancellation token.</param>
|
|
public async Task AcknowledgeAsync(
|
|
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(acknowledgements);
|
|
if (acknowledgements.Count == 0) return;
|
|
|
|
var byHost = _hostResolver is null
|
|
? new Dictionary<string, List<AlarmAcknowledgeRequest>> { [_defaultHost] = acknowledgements.ToList() }
|
|
: acknowledgements
|
|
.GroupBy(a => _hostResolver.ResolveHost(a.SourceNodeId))
|
|
.ToDictionary(g => g.Key, g => g.ToList());
|
|
|
|
foreach (var (host, batch) in byHost)
|
|
{
|
|
var batchSnapshot = batch; // capture for the lambda
|
|
await _invoker.ExecuteAsync(
|
|
DriverCapability.AlarmAcknowledge,
|
|
host,
|
|
async ct => await _alarmSource.AcknowledgeAsync(batchSnapshot, ct).ConfigureAwait(false),
|
|
cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private Dictionary<string, List<string>> GroupByHost(IReadOnlyList<string> sourceNodeIds)
|
|
{
|
|
if (_hostResolver is null)
|
|
return new Dictionary<string, List<string>> { [_defaultHost] = sourceNodeIds.ToList() };
|
|
|
|
var result = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
|
foreach (var id in sourceNodeIds)
|
|
{
|
|
var host = _hostResolver.ResolveHost(id);
|
|
if (!result.TryGetValue(host, out var list))
|
|
result[host] = list = new List<string>();
|
|
list.Add(id);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wraps an <see cref="IAlarmSubscriptionHandle"/> returned by the driver with the
|
|
/// resolved host name used when the subscription was created. <see cref="UnsubscribeAsync"/>
|
|
/// unwraps this to route the unsubscribe through the same host's resilience pipeline.
|
|
/// </summary>
|
|
private sealed class HostBoundHandle(IAlarmSubscriptionHandle inner, string host) : IAlarmSubscriptionHandle
|
|
{
|
|
/// <summary>Gets the inner subscription handle.</summary>
|
|
public IAlarmSubscriptionHandle Inner { get; } = inner;
|
|
/// <summary>Gets the resolved host name.</summary>
|
|
public string Host { get; } = host;
|
|
/// <summary>Gets the diagnostic ID from the inner handle.</summary>
|
|
public string DiagnosticId => Inner.DiagnosticId;
|
|
}
|
|
}
|