using System.Collections.Concurrent;
using System.Collections.Immutable;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
///
/// Bookkeeping for live subscriptions. Maps each driver-issued SubscriptionId to the
/// set of (full-reference, gw item-handle) pairs the gateway returned, and maintains the
/// reverse map (item-handle → set of driver subscriptions) so the
/// can fan out a single OnDataChange event to every driver
/// subscription that includes the changed tag.
///
///
/// A tag may legitimately appear in multiple driver subscriptions (separate clients or
/// OPC UA monitored items observing the same Galaxy attribute). Using a single shared
/// gw subscription per session and fanning out on the driver side keeps the gateway's
/// work bounded; the reverse map is the fan-out index.
///
internal sealed class SubscriptionRegistry
{
private readonly ConcurrentDictionary _bySubscriptionId = new();
// Driver.Galaxy-012: use ImmutableHashSet for the reverse map so removal is
// O(log n) instead of "rebuild the entire ConcurrentBag from a LINQ filter on every
// unsubscribe"; reads are lock-free because the immutable snapshot is published
// atomically via ConcurrentDictionary AddOrUpdate.
private readonly ConcurrentDictionary> _subscribersByItemHandle = new();
// Forward index for the Galaxy writer: fullRef (case-insensitive) → live item handle.
// Maintained in lock-step with _subscribersByItemHandle; entries are cleaned up when
// the last subscriber for a handle is removed, and TryResolveItemHandle guards against
// stale entries by cross-checking _subscribersByItemHandle at read time.
private readonly ConcurrentDictionary _itemHandleByFullRef =
new(StringComparer.OrdinalIgnoreCase);
private long _nextSubscriptionId;
/// Gets the number of tracked subscriptions.
public int TrackedSubscriptionCount => _bySubscriptionId.Count;
/// Gets the number of tracked item handles.
public int TrackedItemHandleCount => _subscribersByItemHandle.Count;
/// Allocate a fresh subscription id. Monotonic; unique per registry lifetime.
public long NextSubscriptionId() => Interlocked.Increment(ref _nextSubscriptionId);
///
/// Register a subscription and the per-tag item handles the gateway returned for it.
/// Failed tags (item handle = 0 or negative) are stored anyway so unsubscribe can
/// emit per-tag UnsubscribeBulk for the ones that did succeed.
///
/// The subscription identifier.
/// The tag bindings for the subscription.
public void Register(long subscriptionId, IReadOnlyList bindings)
{
var entry = new SubscriptionEntry(subscriptionId, bindings);
_bySubscriptionId[subscriptionId] = entry;
foreach (var binding in bindings)
{
if (binding.ItemHandle <= 0) continue; // failed gw subscribe — no events expected
_subscribersByItemHandle.AddOrUpdate(
binding.ItemHandle,
_ => [subscriptionId],
(_, set) => set.Add(subscriptionId));
_itemHandleByFullRef[binding.FullReference] = binding.ItemHandle;
}
}
///
/// Remove a subscription. Returns the bindings the caller should pass to
/// UnsubscribeBulkAsync; null when the id was never registered.
///
/// The subscription identifier.
/// The bindings for the subscription, or null if not found.
public IReadOnlyList? Remove(long subscriptionId)
{
if (!_bySubscriptionId.TryRemove(subscriptionId, out var entry)) return null;
foreach (var binding in entry.Bindings)
{
if (binding.ItemHandle <= 0) continue;
// Driver.Galaxy-012: ImmutableHashSet.Remove is O(log n) and the result is
// published atomically — no need to rebuild from a LINQ filter.
if (!_subscribersByItemHandle.TryGetValue(binding.ItemHandle, out var set)) continue;
var remaining = set.Remove(subscriptionId);
if (remaining.IsEmpty)
{
_subscribersByItemHandle.TryRemove(binding.ItemHandle, out _);
// Clean up the forward index only when this handle is still the current
// mapping — a concurrent re-add for the same ref must not be clobbered.
if (_itemHandleByFullRef.TryGetValue(binding.FullReference, out var fwd) && fwd == binding.ItemHandle)
_itemHandleByFullRef.TryRemove(binding.FullReference, out _);
}
else _subscribersByItemHandle[binding.ItemHandle] = remaining;
}
return entry.Bindings;
}
///
/// Look up the (subscription id, full reference) pairs that should receive an
/// OnDataChange for the given gw item handle. Returns empty when nobody subscribes.
///
///
/// Driver.Galaxy-012: O(1) per subscriber via the per-entry
/// FullRefByItemHandle index, rather than a FirstOrDefault linear
/// scan of the binding list. At 50k tags / 1Hz this turns each dispatch from a
/// 50k-element scan into a single dictionary lookup.
///
/// The gateway item handle.
/// A list of subscription and reference pairs for the item handle.
public IReadOnlyList<(long SubscriptionId, string FullReference)> ResolveSubscribers(int itemHandle)
{
if (!_subscribersByItemHandle.TryGetValue(itemHandle, out var bag)) return [];
// Each subscription may include the tag once. Walk every active subscription that
// claims this handle and pull the full ref from its index in O(1).
var result = new List<(long, string)>();
foreach (var subId in bag.Distinct())
{
if (!_bySubscriptionId.TryGetValue(subId, out var entry)) continue;
if (entry.FullRefByItemHandle.TryGetValue(itemHandle, out var fullRef))
result.Add((subId, fullRef));
}
return result;
}
///
/// Resolve the live MXAccess item handle a current subscription holds for ,
/// or null when no live subscription covers it. The Galaxy writer borrows this handle to skip a
/// redundant AddItem round-trip on the first write to an already-subscribed tag. Guarded by the
/// authoritative live-handle set (_subscribersByItemHandle) so a stale forward-map entry
/// can never hand out a dead handle.
///
/// The dotted tag full reference (e.g. TestMachine_002.TestFloat).
/// The live item handle, or null when none is currently subscribed.
public int? TryResolveItemHandle(string fullRef)
{
if (fullRef is null) return null;
if (_itemHandleByFullRef.TryGetValue(fullRef, out var handle)
&& _subscribersByItemHandle.ContainsKey(handle))
return handle;
return null;
}
/// Snapshot every active binding for diagnostic output.
public IReadOnlyList SnapshotAllBindings() =>
[.. _bySubscriptionId.Values.SelectMany(entry => entry.Bindings)];
///
/// Snapshot every active subscription with its bindings, grouped by subscription id.
/// Used by the reconnect replay path so it can re-issue SubscribeBulk per subscription
/// and then each one with the post-reconnect item handles.
///
public IReadOnlyList<(long SubscriptionId, IReadOnlyList Bindings)> SnapshotEntries() =>
[.. _bySubscriptionId.Values.Select(entry => (entry.SubscriptionId, entry.Bindings))];
///
/// Replace an existing subscription's bindings with the item handles a post-reconnect
/// SubscribeBulk returned, rebuilding the reverse fan-out map so events on the new
/// handles dispatch and the now-dead pre-reconnect handles are dropped. No-op when the
/// subscription id is unknown (it was unsubscribed during the reconnect window).
///
/// The subscription identifier.
/// The new tag bindings after reconnection.
public void Rebind(long subscriptionId, IReadOnlyList newBindings)
{
if (!_bySubscriptionId.TryGetValue(subscriptionId, out var oldEntry)) return;
// Drop this subscription from every reverse-map set it currently appears in. The
// pre-reconnect item handles are stale once the gw re-issues fresh ones.
// Driver.Galaxy-012: ImmutableHashSet.Remove is O(log n) — no LINQ rebuild.
foreach (var binding in oldEntry.Bindings)
{
if (binding.ItemHandle <= 0) continue;
if (!_subscribersByItemHandle.TryGetValue(binding.ItemHandle, out var set)) continue;
var remaining = set.Remove(subscriptionId);
if (remaining.IsEmpty)
{
_subscribersByItemHandle.TryRemove(binding.ItemHandle, out _);
// Clean up the forward index only when this handle is still the current
// mapping — the add loop below will overwrite it with the fresh handle.
if (_itemHandleByFullRef.TryGetValue(binding.FullReference, out var fwd) && fwd == binding.ItemHandle)
_itemHandleByFullRef.TryRemove(binding.FullReference, out _);
}
else _subscribersByItemHandle[binding.ItemHandle] = remaining;
}
_bySubscriptionId[subscriptionId] = new SubscriptionEntry(subscriptionId, newBindings);
foreach (var binding in newBindings)
{
if (binding.ItemHandle <= 0) continue; // failed gw subscribe — no events expected
_subscribersByItemHandle.AddOrUpdate(
binding.ItemHandle,
_ => [subscriptionId],
(_, set) => set.Add(subscriptionId));
_itemHandleByFullRef[binding.FullReference] = binding.ItemHandle;
}
}
///
/// Per-subscription bookkeeping. is an index
/// over keyed by item handle so ResolveSubscribers
/// is O(1) per subscriber instead of a linear scan of every binding
/// (Driver.Galaxy-012). Failed bindings (item handle ≤ 0) are excluded from the
/// index because the EventPump only dispatches for positive handles.
///
/// Per-subscription bookkeeping entry.
private sealed class SubscriptionEntry
{
/// Gets the subscription identifier.
public long SubscriptionId { get; }
/// Gets the tag bindings for the subscription.
public IReadOnlyList Bindings { get; }
/// Gets the index of full references by item handle.
public IReadOnlyDictionary FullRefByItemHandle { get; }
/// Initializes a new subscription entry.
/// The subscription identifier.
/// The tag bindings for the subscription.
public SubscriptionEntry(long subscriptionId, IReadOnlyList bindings)
{
SubscriptionId = subscriptionId;
Bindings = bindings;
var index = new Dictionary(bindings.Count);
foreach (var binding in bindings)
{
if (binding.ItemHandle <= 0) continue; // failed gw subscribe — no events expected
// Last-write-wins on duplicates; the driver doesn't double-register a handle
// within a single subscription, but be defensive.
index[binding.ItemHandle] = binding.FullReference;
}
FullRefByItemHandle = index;
}
}
}
///
/// One (full reference, gw item handle) pair returned by SubscribeBulk. Item handle is
/// zero or negative when the gateway rejected this individual tag (bad name, duplicate);
/// the registry keeps the binding so the caller can surface a per-tag failure status.
///
internal sealed record TagBinding(string FullReference, int ItemHandle);