deprecate(lmxproxy): move all LmxProxy code, tests, and docs to deprecated/
LmxProxy is no longer needed. Moved the entire lmxproxy/ workspace, DCL adapter files, and related docs to deprecated/. Removed LmxProxy registration from DataConnectionFactory, project reference from DCL, protocol option from UI, and cleaned up all requirement docs.
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Subscriptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages per-client subscription channels with shared MxAccess subscriptions.
|
||||
/// Ref-counted tag subscriptions: first client creates, last client disposes.
|
||||
/// </summary>
|
||||
public sealed class SubscriptionManager : IDisposable
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<SubscriptionManager>();
|
||||
|
||||
private readonly IScadaClient _scadaClient;
|
||||
private readonly int _channelCapacity;
|
||||
private readonly BoundedChannelFullMode _channelFullMode;
|
||||
|
||||
// Subscription ID -> ClientSubscription
|
||||
private readonly ConcurrentDictionary<string, ClientSubscription> _clientSubscriptions
|
||||
= new ConcurrentDictionary<string, ClientSubscription>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Tag address -> TagSubscription (shared, ref-counted)
|
||||
private readonly ConcurrentDictionary<string, TagSubscription> _tagSubscriptions
|
||||
= new ConcurrentDictionary<string, TagSubscription>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Session ID -> set of subscription IDs owned by that session
|
||||
private readonly ConcurrentDictionary<string, HashSet<string>> _sessionSubscriptions
|
||||
= new ConcurrentDictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly ReaderWriterLockSlim _rwLock = new ReaderWriterLockSlim();
|
||||
|
||||
public SubscriptionManager(IScadaClient scadaClient, int channelCapacity = 1000,
|
||||
BoundedChannelFullMode channelFullMode = BoundedChannelFullMode.DropOldest)
|
||||
{
|
||||
_scadaClient = scadaClient;
|
||||
_channelCapacity = channelCapacity;
|
||||
_channelFullMode = channelFullMode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a subscription for a session. Returns a ChannelReader and unique
|
||||
/// subscription ID. Multiple subscriptions per session are supported.
|
||||
/// Awaits COM subscription creation so the initial OnDataChange callback
|
||||
/// is not missed.
|
||||
/// </summary>
|
||||
public async Task<(ChannelReader<(string address, Vtq vtq)> Reader, string SubscriptionId)> SubscribeAsync(
|
||||
string sessionId, IEnumerable<string> addresses, CancellationToken ct)
|
||||
{
|
||||
var subscriptionId = Guid.NewGuid().ToString("N");
|
||||
var channel = Channel.CreateBounded<(string address, Vtq vtq)>(
|
||||
new BoundedChannelOptions(_channelCapacity)
|
||||
{
|
||||
FullMode = _channelFullMode,
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
var addressSet = new HashSet<string>(addresses, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var clientSub = new ClientSubscription(subscriptionId, sessionId, channel, addressSet);
|
||||
_clientSubscriptions[subscriptionId] = clientSub;
|
||||
|
||||
// Track which session owns this subscription
|
||||
_sessionSubscriptions.AddOrUpdate(
|
||||
sessionId,
|
||||
_ => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { subscriptionId },
|
||||
(_, set) => { lock (set) { set.Add(subscriptionId); } return set; });
|
||||
|
||||
var newTags = new List<string>();
|
||||
|
||||
_rwLock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
foreach (var address in addressSet)
|
||||
{
|
||||
if (_tagSubscriptions.TryGetValue(address, out var tagSub))
|
||||
{
|
||||
tagSub.ClientIds.Add(subscriptionId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_tagSubscriptions[address] = new TagSubscription(address,
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { subscriptionId });
|
||||
newTags.Add(address);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_rwLock.ExitWriteLock();
|
||||
}
|
||||
|
||||
// Create MxAccess COM subscriptions — awaited so the initial
|
||||
// OnDataChange (first value delivery after AdviseSupervisory)
|
||||
// is not lost. The channel and routing are already set up above,
|
||||
// so any callback that fires during this call will be delivered.
|
||||
if (newTags.Count > 0)
|
||||
{
|
||||
await CreateMxAccessSubscriptionsAsync(newTags);
|
||||
}
|
||||
|
||||
// Register cancellation cleanup for this subscription only
|
||||
ct.Register(() => UnsubscribeSubscription(subscriptionId));
|
||||
|
||||
Log.Information("Session {SessionId} subscription {SubscriptionId} subscribed to {Count} tags ({NewCount} new MxAccess subscriptions)",
|
||||
sessionId, subscriptionId, addressSet.Count, newTags.Count);
|
||||
return (channel.Reader, subscriptionId);
|
||||
}
|
||||
|
||||
private async Task CreateMxAccessSubscriptionsAsync(List<string> addresses)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _scadaClient.SubscribeAsync(
|
||||
addresses,
|
||||
(address, vtq) => OnTagValueChanged(address, vtq));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to create MxAccess subscriptions for {Count} tags", addresses.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called from MxAccessClient's OnDataChange handler.
|
||||
/// Fans out the update to all subscribed clients.
|
||||
/// </summary>
|
||||
public void OnTagValueChanged(string address, Vtq vtq)
|
||||
{
|
||||
_rwLock.EnterReadLock();
|
||||
HashSet<string>? clientIds = null;
|
||||
try
|
||||
{
|
||||
if (_tagSubscriptions.TryGetValue(address, out var tagSub))
|
||||
{
|
||||
clientIds = new HashSet<string>(tagSub.ClientIds);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_rwLock.ExitReadLock();
|
||||
}
|
||||
|
||||
if (clientIds == null || clientIds.Count == 0) return;
|
||||
|
||||
foreach (var clientId in clientIds)
|
||||
{
|
||||
if (_clientSubscriptions.TryGetValue(clientId, out var clientSub))
|
||||
{
|
||||
if (!clientSub.Channel.Writer.TryWrite((address, vtq)))
|
||||
{
|
||||
clientSub.IncrementDropped();
|
||||
Log.Debug("Dropped message for client {ClientId} on tag {Address} (channel full)",
|
||||
clientId, address);
|
||||
}
|
||||
else
|
||||
{
|
||||
clientSub.IncrementDelivered();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a single subscription and cleans up its tag refs.
|
||||
/// Called when an individual Subscribe stream ends.
|
||||
/// </summary>
|
||||
public void UnsubscribeSubscription(string subscriptionId)
|
||||
{
|
||||
if (!_clientSubscriptions.TryRemove(subscriptionId, out var clientSub))
|
||||
return;
|
||||
|
||||
// Remove from session tracking
|
||||
if (_sessionSubscriptions.TryGetValue(clientSub.SessionId, out var subIds))
|
||||
{
|
||||
lock (subIds)
|
||||
{
|
||||
subIds.Remove(subscriptionId);
|
||||
if (subIds.Count == 0)
|
||||
{
|
||||
_sessionSubscriptions.TryRemove(clientSub.SessionId, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tagsToDispose = new List<string>();
|
||||
|
||||
_rwLock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
foreach (var address in clientSub.Addresses)
|
||||
{
|
||||
if (_tagSubscriptions.TryGetValue(address, out var tagSub))
|
||||
{
|
||||
tagSub.ClientIds.Remove(subscriptionId);
|
||||
|
||||
if (tagSub.ClientIds.Count == 0)
|
||||
{
|
||||
_tagSubscriptions.TryRemove(address, out _);
|
||||
tagsToDispose.Add(address);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_rwLock.ExitWriteLock();
|
||||
}
|
||||
|
||||
if (tagsToDispose.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
_scadaClient.UnsubscribeByAddressAsync(tagsToDispose).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error unsubscribing {Count} tags from MxAccess", tagsToDispose.Count);
|
||||
}
|
||||
}
|
||||
|
||||
clientSub.Channel.Writer.TryComplete();
|
||||
|
||||
Log.Information("Subscription {SubscriptionId} removed ({Delivered} delivered, {Dropped} dropped)",
|
||||
subscriptionId, clientSub.DeliveredCount, clientSub.DroppedCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes ALL subscriptions for a session.
|
||||
/// Called on explicit Disconnect or session scavenging.
|
||||
/// </summary>
|
||||
public void UnsubscribeSession(string sessionId)
|
||||
{
|
||||
if (!_sessionSubscriptions.TryRemove(sessionId, out var subscriptionIds))
|
||||
return;
|
||||
|
||||
List<string> ids;
|
||||
lock (subscriptionIds)
|
||||
{
|
||||
ids = subscriptionIds.ToList();
|
||||
}
|
||||
|
||||
foreach (var subId in ids)
|
||||
{
|
||||
UnsubscribeSubscription(subId);
|
||||
}
|
||||
|
||||
Log.Information("All subscriptions for session {SessionId} removed ({Count} subscriptions)",
|
||||
sessionId, ids.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a bad-quality notification to all subscribed clients for all their tags.
|
||||
/// Called when MxAccess disconnects.
|
||||
/// </summary>
|
||||
public void NotifyDisconnection()
|
||||
{
|
||||
var badVtq = Vtq.New(null, Quality.Bad_NotConnected);
|
||||
|
||||
foreach (var kvp in _clientSubscriptions)
|
||||
{
|
||||
foreach (var address in kvp.Value.Addresses)
|
||||
{
|
||||
kvp.Value.Channel.Writer.TryWrite((address, badVtq));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs reconnection for observability. Data flow resumes automatically
|
||||
/// via MxAccessClient.RecreateStoredSubscriptionsAsync callbacks.
|
||||
/// </summary>
|
||||
public void NotifyReconnection()
|
||||
{
|
||||
Log.Information("MxAccess reconnected -- subscriptions recreated, " +
|
||||
"data flow will resume via OnDataChange callbacks " +
|
||||
"({ClientCount} clients, {TagCount} tags)",
|
||||
_clientSubscriptions.Count, _tagSubscriptions.Count);
|
||||
}
|
||||
|
||||
/// <summary>Returns subscription statistics.</summary>
|
||||
public SubscriptionStats GetStats()
|
||||
{
|
||||
long totalDelivered = 0;
|
||||
long totalDropped = 0;
|
||||
foreach (var kvp in _clientSubscriptions)
|
||||
{
|
||||
totalDelivered += kvp.Value.DeliveredCount;
|
||||
totalDropped += kvp.Value.DroppedCount;
|
||||
}
|
||||
|
||||
return new SubscriptionStats(
|
||||
_sessionSubscriptions.Count,
|
||||
_tagSubscriptions.Count,
|
||||
_clientSubscriptions.Values.Sum(c => c.Addresses.Count),
|
||||
totalDelivered,
|
||||
totalDropped);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var kvp in _clientSubscriptions)
|
||||
{
|
||||
kvp.Value.Channel.Writer.TryComplete();
|
||||
}
|
||||
_clientSubscriptions.Clear();
|
||||
_sessionSubscriptions.Clear();
|
||||
_tagSubscriptions.Clear();
|
||||
_rwLock.Dispose();
|
||||
}
|
||||
|
||||
// ── Nested types ─────────────────────────────────────────
|
||||
|
||||
private class ClientSubscription
|
||||
{
|
||||
public ClientSubscription(string subscriptionId, string sessionId,
|
||||
Channel<(string address, Vtq vtq)> channel,
|
||||
HashSet<string> addresses)
|
||||
{
|
||||
SubscriptionId = subscriptionId;
|
||||
SessionId = sessionId;
|
||||
Channel = channel;
|
||||
Addresses = addresses;
|
||||
}
|
||||
|
||||
public string SubscriptionId { get; }
|
||||
public string SessionId { get; }
|
||||
public Channel<(string address, Vtq vtq)> Channel { get; }
|
||||
public HashSet<string> Addresses { get; }
|
||||
|
||||
// Use backing fields for Interlocked
|
||||
private long _delivered;
|
||||
private long _dropped;
|
||||
|
||||
public long DeliveredCount => Interlocked.Read(ref _delivered);
|
||||
public long DroppedCount => Interlocked.Read(ref _dropped);
|
||||
|
||||
public void IncrementDelivered() => Interlocked.Increment(ref _delivered);
|
||||
public void IncrementDropped() => Interlocked.Increment(ref _dropped);
|
||||
}
|
||||
|
||||
private class TagSubscription
|
||||
{
|
||||
public TagSubscription(string address, HashSet<string> clientIds)
|
||||
{
|
||||
Address = address;
|
||||
ClientIds = clientIds;
|
||||
}
|
||||
|
||||
public string Address { get; }
|
||||
public HashSet<string> ClientIds { get; }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user