Resolve all high-severity architecture review findings

This commit is contained in:
Joseph Doherty
2026-09-09 16:13:58 -04:00
parent 8bfe65512e
commit 146a13238f
192 changed files with 3160 additions and 753 deletions
@@ -12,12 +12,12 @@ using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
/// <summary>
/// Connection actor using Akka.NET Become/Stash pattern for lifecycle state machine.
/// Connection actor using Akka.NET Become for its lifecycle state machine.
///
/// States:
/// - Connecting: stash subscribe/write requests; attempts connection
/// - Connected: unstash and process all requests
/// - Reconnecting: push bad quality for all subscribed tags, stash new requests,
/// - Connecting: retain latest subscription requests, reject writes; attempts connection
/// - Connected: process pending subscriptions and live requests
/// - Reconnecting: push bad quality, retain latest subscriptions and reject writes,
/// fixed-interval reconnect
///
/// Auto-reconnect with bad quality on disconnect.
@@ -27,7 +27,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
/// Health reporting (connection status + tag resolution counts).
/// Subscription lifecycle (register on create, cleanup on stop).
/// </summary>
public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
public class DataConnectionActor : UntypedActor, IWithTimers
{
public enum ActiveEndpoint { Primary, Backup }
@@ -45,8 +45,6 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
private string _protocolType;
private readonly ISiteEventLogger? _siteEventLogger;
/// <summary>Stash for holding messages while the connection is not in the Connected state.</summary>
public IStash Stash { get; set; } = null!;
/// <summary>Timer scheduler for reconnect and tag-resolution retry timers.</summary>
public ITimerScheduler Timers { get; set; } = null!;
@@ -413,13 +411,23 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// declaring the same dead adapter Connected anyway.
NotePendingDisconnect(disconnected);
break;
case SubscribeTagsRequest:
case WriteTagRequest:
case WriteTagBatchRequest:
case UnsubscribeTagsRequest:
case SubscribeAlarmsRequest:
case UnsubscribeAlarmsRequest:
Stash.Stash();
case SubscribeTagsRequest pending:
QueueSubscribe(pending);
break;
case SubscribeAlarmsRequest pendingAlarm:
QueueSubscribeAlarms(pendingAlarm);
break;
case WriteTagRequest write:
RejectDisconnectedWrite(write);
break;
case WriteTagBatchRequest batch:
RejectDisconnectedWrite(batch);
break;
case UnsubscribeTagsRequest unsubscribe:
HandleUnsubscribe(unsubscribe);
break;
case UnsubscribeAlarmsRequest unsubscribeAlarm:
HandleUnsubscribeAlarms(unsubscribeAlarm);
break;
case ReconfigureConnectionCommand rc:
// NEVER stashed. A reconfigure that arrived while the initial connect was still
@@ -469,6 +477,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// ConnectionNotConnected.
HandleReadTagValues(read);
break;
case Terminated terminated:
HandleSubscriberTerminated(terminated.ActorRef);
break;
case GetHealthReport:
ReplyWithHealthReport();
break;
@@ -491,7 +502,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// Register row 54 — the only transition that raises the connected gauge.
ScadaBridgeTelemetry.RecordDataConnectionState(_connectionName, ConnectionHealth.Connected);
Become(Connected);
Stash.UnstashAll();
foreach (var pending in _pendingTagSubscriptions.Values)
Self.Tell(pending.Request, pending.Owner);
_pendingTagSubscriptions.Clear();
foreach (var (key, request) in _pendingAlarmSubscriptions)
Self.Tell(request, key.Owner);
_pendingAlarmSubscriptions.Clear();
}
private void Connected(object message)
@@ -563,6 +579,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
case ReadTagValuesCommand read:
HandleReadTagValues(read);
break;
case Terminated terminated:
HandleSubscriberTerminated(terminated.ActorRef);
break;
case GetHealthReport:
ReplyWithHealthReport();
break;
@@ -789,11 +808,17 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// adapter that will never raise Disconnected a second time.
NotePendingDisconnect(disconnected);
break;
case SubscribeTagsRequest:
case WriteTagRequest:
case WriteTagBatchRequest:
case SubscribeAlarmsRequest:
Stash.Stash();
case SubscribeTagsRequest pending:
QueueSubscribe(pending);
break;
case SubscribeAlarmsRequest pendingAlarm:
QueueSubscribeAlarms(pendingAlarm);
break;
case WriteTagRequest write:
RejectDisconnectedWrite(write);
break;
case WriteTagBatchRequest batch:
RejectDisconnectedWrite(batch);
break;
case UnsubscribeTagsRequest req:
// Allow unsubscribe even during reconnect (for cleanup on instance stop)
@@ -850,6 +875,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// to a ConnectionNotConnected failure.
HandleReadTagValues(read);
break;
case Terminated terminated:
HandleSubscriberTerminated(terminated.ActorRef);
break;
case GetHealthReport:
ReplyWithHealthReport();
break;
@@ -1110,6 +1138,52 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
BecomeReconnecting();
}
// One latest request per owner/source while disconnected; retries cannot grow a stash.
private readonly Dictionary<string, (SubscribeTagsRequest Request, IActorRef Owner)> _pendingTagSubscriptions = new();
private readonly Dictionary<(IActorRef Owner, string Source), SubscribeAlarmsRequest> _pendingAlarmSubscriptions = new();
private void QueueSubscribe(SubscribeTagsRequest request)
{
Context.Watch(Sender);
_pendingTagSubscriptions.TryGetValue(request.InstanceUniqueName, out var previous);
_pendingTagSubscriptions[request.InstanceUniqueName] = (request, Sender);
if (previous.Owner is not null && !previous.Owner.Equals(Sender))
UnwatchIfUnused(previous.Owner);
}
private void QueueSubscribeAlarms(SubscribeAlarmsRequest request)
{
Context.Watch(Sender);
_pendingAlarmSubscriptions[(Sender, request.SourceReference)] = request;
}
private void RejectDisconnectedWrite(WriteTagRequest request) => Sender.Tell(new WriteTagResponse(
request.CorrelationId, false, "Connection is not connected; device write was not queued.", DateTimeOffset.UtcNow));
private void RejectDisconnectedWrite(WriteTagBatchRequest request) => Sender.Tell(new WriteTagBatchResponse(
request.CorrelationId, false, "Connection is not connected; device write was not queued.", DateTimeOffset.UtcNow));
private void UnwatchIfUnused(IActorRef owner)
{
if (!_subscribers.Values.Contains(owner)
&& !_alarmSourceSubscribers.Values.Any(subs => subs.Contains(owner))
&& !_pendingTagSubscriptions.Values.Any(p => p.Owner.Equals(owner))
&& !_pendingAlarmSubscriptions.Keys.Any(k => k.Owner.Equals(owner)))
Context.Unwatch(owner);
}
private void HandleSubscriberTerminated(IActorRef owner)
{
foreach (var instance in _pendingTagSubscriptions.Where(kv => kv.Value.Owner.Equals(owner)).Select(kv => kv.Key).ToList())
_pendingTagSubscriptions.Remove(instance);
foreach (var key in _pendingAlarmSubscriptions.Keys.Where(k => k.Owner.Equals(owner)).ToList())
_pendingAlarmSubscriptions.Remove(key);
foreach (var instance in _subscribers.Where(kv => kv.Value.Equals(owner)).Select(kv => kv.Key).ToList())
RemoveTagSubscriber(instance, owner);
foreach (var source in _alarmSourceSubscribers.Where(kv => kv.Value.Contains(owner)).Select(kv => kv.Key).ToList())
RemoveAlarmSubscriber(source, owner);
}
// ── Subscription Management ──
private void HandleSubscribe(SubscribeTagsRequest request)
@@ -1117,10 +1191,21 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
_log.Debug("[{0}] Subscribing {1} tags for instance {2}",
_connectionName, request.TagPaths.Count, request.InstanceUniqueName);
Context.Watch(Sender);
if (_subscribers.TryGetValue(request.InstanceUniqueName, out var previousOwner) && !previousOwner.Equals(Sender))
RemoveTagSubscriber(request.InstanceUniqueName, previousOwner, request.TagPaths.ToHashSet());
_subscribers[request.InstanceUniqueName] = Sender;
if (previousOwner is not null && !previousOwner.Equals(Sender))
UnwatchIfUnused(previousOwner);
if (!_subscriptionsByInstance.ContainsKey(request.InstanceUniqueName))
_subscriptionsByInstance[request.InstanceUniqueName] = new HashSet<string>();
if (!_subscriptionsByInstance.TryGetValue(request.InstanceUniqueName, out var requestedTags))
_subscriptionsByInstance[request.InstanceUniqueName] = requestedTags = new HashSet<string>();
// Record demand before I/O, so a replacement owner can adopt a shared in-flight result.
foreach (var tag in request.TagPaths)
{
requestedTags.Add(tag);
IndexTag(tag, request.InstanceUniqueName);
}
var self = Self;
var sender = Sender;
@@ -1148,7 +1233,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
var preResolvedResults = new List<SubscribeTagResult>();
foreach (var tagPath in request.TagPaths)
{
if (_subscriptionIds.ContainsKey(tagPath) || _subscribesInFlight.Contains(tagPath))
if (_subscriptionIds.ContainsKey(tagPath) || _subscribesInFlight.Contains(tagPath) || _resolutionInFlight.Contains(tagPath))
{
preResolvedResults.Add(new SubscribeTagResult(
tagPath, AlreadySubscribed: true, Success: true, null, null));
@@ -1540,10 +1625,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// wholesale once the replacement adapter connects, and a marker the new generation
// has since placed for the same tag must not be removed by this dead message.
// Reply failure so the requester re-sends. Nothing has registered this request's
// tags in _subscriptionsByInstance (registration happens below, on the applied
// path), so ReSubscribeAll cannot pick them up — the InstanceActor's per-connection
// retry timer, armed until a Success arrives, is what re-establishes them.
// Demand was registered before I/O, so ReSubscribeAll can recover it on the
// replacement adapter. Reply failure as well so the owner's correlated retry
// state never treats this stale completion as an established subscription.
msg.ReplyTo.Tell(new SubscribeTagsResponse(
msg.Request.CorrelationId, instanceName, false,
"adapter replaced while the subscribe was in flight — will retry",
@@ -1551,53 +1635,17 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
return false;
}
if (!_subscriptionsByInstance.TryGetValue(instanceName, out var instanceTags))
if (!_subscribers.TryGetValue(instanceName, out var currentOwner) || !currentOwner.Equals(msg.ReplyTo)
|| !_subscriptionsByInstance.TryGetValue(instanceName, out var instanceTags))
{
// The instance was unsubscribed while the
// subscribe I/O was in flight. Re-creating the per-instance entry and
// applying the handle mutations here would permanently leak state
// — _subscriptionsByInstance[instanceName] resurrected with no
// subscriber to receive callbacks, _instancesByTag re-indexed against
// an instance no future HandleUnsubscribe will ever visit, and the
// health counts derived from both reporting tags nobody subscribes to
// for the rest of the adapter lifetime (also re-issued by ReSubscribeAll
// on every reconnect). Instead: drop all state mutations for this stale
// message and release the adapter-level monitored items we just
// created so the device doesn't keep streaming change notifications
// for a tag nobody is subscribed to.
_log.Warning(
"[{0}] SubscribeCompleted arrived for instance {1} but the instance " +
"was unsubscribed while the subscribe was in flight; releasing " +
"{2} adapter handle(s) and discarding state mutations.",
_connectionName, instanceName, msg.Results.Count(r => r.Success && !r.AlreadySubscribed));
var orphanedIds = new List<string>();
foreach (var result in msg.Results)
{
// Clear in-flight markers we placed in HandleSubscribe.
if (!result.AlreadySubscribed)
_subscribesInFlight.Remove(result.TagPath);
// Fire-and-forget release of any subscription id this request
// genuinely created. AlreadySubscribed=true means another caller
// owns the adapter handle and unsubscribing it would break them.
if (result is { Success: true, AlreadySubscribed: false, SubscriptionId: not null })
{
orphanedIds.Add(result.SubscriptionId);
}
}
// Against the adapter that created them (identical to _adapter here — the
// generation guard above already proved the completion is current — but stating
// the ownership rule at every release site is what keeps it true).
_ = UnsubscribeIdsAsync(msg.Adapter, orphanedIds);
// The original sender is already gone (unsubscribed). Telling a dead
// ref produces a dead letter, which is the harmless and observable
// outcome — but skipping the reply altogether keeps dead-letter noise
// out of the log when this race fires in the normal disable/redeploy
// path. The unsubscribe message did NOT request a response of its own.
return false;
// The initiating owner may be gone, but another instance/replacement can
// still need these tags. Reconcile against CURRENT per-tag demand instead
// of unconditionally releasing handles owned by shared in-flight work.
foreach (var row in msg.Results.Where(r => !r.AlreadySubscribed))
_subscribesInFlight.Remove(row.TagPath);
HandleBatchSubscribeCompleted(new BatchSubscribeCompleted(
msg.Results.Where(r => !r.AlreadySubscribed).ToList(), msg.AdapterGeneration, BatchSubscribeSource.Resubscribe));
return msg.Results.Any(r => r.ConnectionLevelFailure && _instancesByTag.ContainsKey(r.TagPath));
}
// If any tag failed because the adapter is not
@@ -1647,7 +1695,8 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// monitored item forever — _subscriptionIds holds a single id per tag, so
// no later unsubscribe can ever reference this one. Release it, exactly as
// HandleBatchSubscribeCompleted does for its duplicate rows.
if (result is { Success: true, SubscriptionId: not null })
if (result is { Success: true, SubscriptionId: not null }
&& _subscriptionIds[result.TagPath] != result.SubscriptionId)
redundantIds.Add(result.SubscriptionId);
continue;
}
@@ -1798,10 +1847,16 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
private void HandleUnsubscribe(UnsubscribeTagsRequest request)
{
_log.Debug("[{0}] Unsubscribing all tags for instance {1}",
_connectionName, request.InstanceUniqueName);
if (_pendingTagSubscriptions.TryGetValue(request.InstanceUniqueName, out var pending) && pending.Owner.Equals(Sender))
_pendingTagSubscriptions.Remove(request.InstanceUniqueName);
RemoveTagSubscriber(request.InstanceUniqueName, Sender);
UnwatchIfUnused(Sender);
}
if (!_subscriptionsByInstance.TryGetValue(request.InstanceUniqueName, out var tags))
private void RemoveTagSubscriber(string instanceName, IActorRef owner, HashSet<string>? preserveTags = null)
{
if (!_subscribers.TryGetValue(instanceName, out var currentOwner) || !currentOwner.Equals(owner)
|| !_subscriptionsByInstance.TryGetValue(instanceName, out var tags))
return;
// Released adapter handles are collected and released in ONE round trip below
@@ -1809,7 +1864,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
var idsToRelease = new List<string>();
// Cleanup on Instance Actor stop
foreach (var tagPath in tags)
foreach (var tagPath in tags.Where(t => preserveTags == null || !preserveTags.Contains(t)).ToList())
{
// Drop this instance from the per-tag counted set. UnindexTag removes the tag
// key entirely when its last subscriber leaves, so "the key is gone" IS the
@@ -1818,7 +1873,8 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// keys this loop retires, whatever state the tag was in (resolved, unresolved,
// or mid-reconnect with both maps cleared — the case the retired scalar
// counters silently failed to decrement at all).
UnindexTag(tagPath, request.InstanceUniqueName);
tags.Remove(tagPath);
UnindexTag(tagPath, instanceName);
if (_instancesByTag.ContainsKey(tagPath))
continue;
@@ -1851,8 +1907,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
_resolutionInFlight.Remove(tagPath);
}
_subscriptionsByInstance.Remove(request.InstanceUniqueName);
_subscribers.Remove(request.InstanceUniqueName);
if (preserveTags is null)
{
_subscriptionsByInstance.Remove(instanceName);
_subscribers.Remove(instanceName);
UnwatchIfUnused(owner);
}
// Fire-and-forget release of every handle this unsubscribe orphaned.
_ = UnsubscribeIdsAsync(_adapter, idsToRelease);
@@ -2274,7 +2334,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// Only dispatch retries for tags that do not already
// have an attempt in flight. A slow subscribe overlapping the next tick
// would otherwise produce duplicate concurrent subscribes for the same tag.
var toResolve = _unresolvedTags.Where(t => !_resolutionInFlight.Contains(t)).ToList();
var toResolve = _unresolvedTags.Where(t => !_resolutionInFlight.Contains(t) && !_subscribesInFlight.Contains(t)).ToList();
if (toResolve.Count == 0)
{
@@ -2506,7 +2566,8 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// Another path already stored a handle for this tag while this one was
// in flight — release the redundant handle instead of leaking it
// (mirrors the duplicate-alarm-feed guard).
idsToRelease.Add(row.SubscriptionId);
if (_subscriptionIds[row.TagPath] != row.SubscriptionId)
idsToRelease.Add(row.SubscriptionId);
}
else
{
@@ -2860,6 +2921,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
IndexAlarmSource(request.SourceReference);
}
subs.Add(subscriber);
Context.Watch(subscriber);
// S10: the adapter feed carries a single shared condition filter per source.
// A second subscriber with a different filter silently overwrites the first
// (last-writer-wins). Surface it so a co-subscriber mismatch is diagnosable —
@@ -3008,19 +3070,20 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
var transition = msg.Transition;
var notified = new HashSet<IActorRef>();
// A SnapshotComplete is a connection-wide framing sentinel, not a real
// condition: the mapper emits it with an empty SourceReference /
// SourceObjectReference. It must reach EVERY alarm subscriber so each
// NativeAlarmActor can atomically swap in the snapshot it just buffered.
// The per-source prefix match below would drop it ("".StartsWith("<src>.")
// is false), which would strand statically-active conditions that are only
// delivered in the snapshot (no later live transition) — they would buffer
// forever and never surface. Broadcast the sentinel to all subscribers,
// bypassing the source match and the condition-type filter (the sentinel
// carries no condition; the buffered entries were already filtered).
if (transition.Kind == AlarmTransitionKind.SnapshotComplete)
// OPC UA refresh boundaries belong to one monitored source, whereas Mx
// uses one connection-wide alarm stream. Boundaries bypass condition filters
// but must retain their scope: broadcasting a second source's end marker
// would clear the first source's already-committed snapshot.
if (transition.Kind is AlarmTransitionKind.SnapshotStart or AlarmTransitionKind.SnapshotComplete)
{
foreach (var subs in _alarmSourceSubscribers.Values)
// Explicit scope distinguishes the empty OPC UA Server binding from the
// connection-wide Mx stream. Legacy empty-scope sentinels stay connection-wide.
var scope = transition.SnapshotSourceReference
?? (string.IsNullOrEmpty(transition.SourceObjectReference) ? null : transition.SourceObjectReference);
var targets = scope is null
? _alarmSourceSubscribers.Values
: _alarmSourceSubscribers.Where(kv => kv.Key == scope).Select(kv => kv.Value);
foreach (var subs in targets)
foreach (var sub in subs)
if (notified.Add(sub))
sub.Tell(new NativeAlarmTransitionUpdate(_connectionName, transition));
@@ -3137,24 +3200,30 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
private void HandleUnsubscribeAlarms(UnsubscribeAlarmsRequest request)
{
if (!_alarmSourceSubscribers.TryGetValue(request.SourceReference, out var subs))
return;
_pendingAlarmSubscriptions.Remove((Sender, request.SourceReference));
RemoveAlarmSubscriber(request.SourceReference, Sender);
UnwatchIfUnused(Sender);
}
subs.Remove(Sender);
private void RemoveAlarmSubscriber(string sourceReference, IActorRef owner)
{
if (!_alarmSourceSubscribers.TryGetValue(sourceReference, out var subs) || !subs.Remove(owner))
return;
UnwatchIfUnused(owner);
if (subs.Count > 0)
return;
// No subscribers remain for this source — tear down the adapter feed.
_alarmSourceSubscribers.Remove(request.SourceReference);
UnindexAlarmSource(request.SourceReference);
_alarmSourceFilter.Remove(request.SourceReference);
_alarmSourceFilterPredicate.Remove(request.SourceReference);
_alarmSourceSubscribers.Remove(sourceReference);
UnindexAlarmSource(sourceReference);
_alarmSourceFilter.Remove(sourceReference);
_alarmSourceFilterPredicate.Remove(sourceReference);
// Clear the in-flight marker so that if an adapter
// subscribe is still in flight for this source, the late AlarmSubscribeCompleted
// is recognized as orphaned (its guard checks _alarmSourceSubscribers, now empty)
// and the just-created feed is released rather than stored and leaked.
_alarmSubscribesInFlight.Remove(request.SourceReference);
if (_alarmSubscriptionIds.Remove(request.SourceReference, out var subId) &&
_alarmSubscribesInFlight.Remove(sourceReference);
if (_alarmSubscriptionIds.Remove(sourceReference, out var subId) &&
_adapter is IAlarmSubscribableConnection alarmable)
{
_ = alarmable.UnsubscribeAlarmsAsync(subId);
@@ -263,7 +263,7 @@ public class MxGatewayDataConnection
public async Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default)
{
if (_subs.TryRemove(subscriptionId, out var s))
_tagToSub.TryRemove(s.TagPath, out _);
((ICollection<KeyValuePair<string, string>>)_tagToSub).Remove(new(s.TagPath, subscriptionId));
await _client!.UnsubscribeAsync(subscriptionId, cancellationToken);
}
@@ -297,7 +297,7 @@ public class MxGatewayDataConnection
foreach (var id in subscriptionIds)
{
if (_subs.TryRemove(id, out var s))
_tagToSub.TryRemove(s.TagPath, out _);
((ICollection<KeyValuePair<string, string>>)_tagToSub).Remove(new(s.TagPath, id));
}
await _client!.UnsubscribeBulkAsync(subscriptionIds, cancellationToken);
@@ -216,7 +216,7 @@ public sealed class RealMxGatewayClient : IMxGatewayClient
// UnsubscribeBulk unadvises AND removes the item, releasing every advice kind.
_supervisoryAdvised.TryRemove(handle, out _);
if (_handleToTag.TryRemove(handle, out var tag))
_tagToHandle.TryRemove(tag, out _);
((ICollection<KeyValuePair<string, int>>)_tagToHandle).Remove(new(tag, handle));
}
}
@@ -233,7 +233,7 @@ public sealed class RealMxGatewayClient : IMxGatewayClient
// so forget any supervisory-advise state for this handle.
_supervisoryAdvised.TryRemove(handle, out _);
if (_handleToTag.TryRemove(handle, out var tag))
_tagToHandle.TryRemove(tag, out _);
((ICollection<KeyValuePair<string, int>>)_tagToHandle).Remove(new(tag, handle));
}
/// <inheritdoc />
@@ -68,6 +68,7 @@ public class RealOpcUaClient : IOpcUaClient
private readonly ConcurrentDictionary<string, MonitoredItem> _alarmItems = new();
// Per-handle "currently inside a ConditionRefresh replay" flag → Snapshot kind.
private readonly ConcurrentDictionary<string, bool> _alarmInRefresh = new();
private readonly ConcurrentDictionary<string, Guid> _alarmSnapshotIds = new();
// Per-handle last (active, acked) by source reference, to derive transition kind.
private readonly ConcurrentDictionary<string, Dictionary<string, (bool Active, bool Acked)>> _alarmLastState = new();
// Int flag toggled with Interlocked.Exchange so the
@@ -543,6 +544,10 @@ public class RealOpcUaClient : IOpcUaClient
}
_monitoredItems.Clear();
_callbacks.Clear();
_alarmItems.Clear();
_alarmInRefresh.Clear();
_alarmSnapshotIds.Clear();
_alarmLastState.Clear();
}
/// <inheritdoc />
@@ -925,6 +930,7 @@ public class RealOpcUaClient : IOpcUaClient
}, cancellationToken);
}
_alarmInRefresh.TryRemove(subscriptionHandle, out _);
_alarmSnapshotIds.TryRemove(subscriptionHandle, out _);
_alarmLastState.TryRemove(subscriptionHandle, out _);
}
@@ -1160,12 +1166,16 @@ public class RealOpcUaClient : IOpcUaClient
if (eventType == ObjectTypeIds.RefreshStartEventType)
{
_alarmInRefresh[handle] = true;
var startEpoch = Guid.NewGuid();
_alarmSnapshotIds[handle] = startEpoch;
onTransition(SnapshotBoundary(AlarmTransitionKind.SnapshotStart, subscriptionSourceReference, startEpoch));
return;
}
if (eventType == ObjectTypeIds.RefreshEndEventType)
{
_alarmInRefresh[handle] = false;
onTransition(SnapshotComplete());
if (_alarmSnapshotIds.TryRemove(handle, out var endEpoch))
onTransition(SnapshotBoundary(AlarmTransitionKind.SnapshotComplete, subscriptionSourceReference, endEpoch));
return;
}
@@ -1253,13 +1263,16 @@ public class RealOpcUaClient : IOpcUaClient
LimitValue: limitValue,
// MES alarm-status API §6.4: true source ack instant (index 18) when supplied,
// else the observed event time; null unless the condition is active AND acked.
AckTime: OpcUaAlarmMapper.DeriveAckTime(active, acked, sourceAckTime, time)));
AckTime: OpcUaAlarmMapper.DeriveAckTime(active, acked, sourceAckTime, time),
SnapshotSourceReference: inRefresh ? subscriptionSourceReference ?? string.Empty : null,
SnapshotId: inRefresh && _alarmSnapshotIds.TryGetValue(handle, out var epoch) ? epoch : null));
}
private static NativeAlarmTransition SnapshotComplete() => new(
"", "", "", AlarmTransitionKind.SnapshotComplete,
private static NativeAlarmTransition SnapshotBoundary(AlarmTransitionKind kind, string? source, Guid epoch) => new(
"", source ?? string.Empty, "", kind,
new Commons.Types.Alarms.AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0),
"", "", "", "", "", null, DateTimeOffset.UtcNow, "", "");
"", "", "", "", "", null, DateTimeOffset.UtcNow, "", "",
SnapshotSourceReference: source ?? string.Empty, SnapshotId: epoch);
/// <summary>
/// Nodes per OPC UA Read/Write service call. Stays under the