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:
Joseph Doherty
2026-04-08 15:56:23 -04:00
parent 8423915ba1
commit 9dccf8e72f
220 changed files with 25 additions and 132 deletions

View File

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