|
|
|
@@ -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);
|
|
|
|
|