feat(galaxy): SubscriptionRegistry.TryResolveItemHandle forward lookup
Add _itemHandleByFullRef (OrdinalIgnoreCase ConcurrentDictionary) maintained in lock-step with _subscribersByItemHandle across Register/Remove/Rebind. TryResolveItemHandle cross-checks the authoritative reverse map so a stale forward entry can never hand out a dead handle. Also wires the scaffolded _addItemCallCount increment in EnsureItemHandleAsync (field was declared but never assigned, causing a TreatWarningsAsErrors build failure on the branch). 8 new xUnit + Shouldly facts covering register/case-insensitive/remove/rebind/ failed-handle/liveness-guard paths.
This commit is contained in:
@@ -24,6 +24,12 @@ internal sealed class SubscriptionRegistry
|
||||
// unsubscribe"; reads are lock-free because the immutable snapshot is published
|
||||
// atomically via ConcurrentDictionary AddOrUpdate.
|
||||
private readonly ConcurrentDictionary<int, ImmutableHashSet<long>> _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<string, int> _itemHandleByFullRef =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private long _nextSubscriptionId;
|
||||
|
||||
/// <summary>Gets the number of tracked subscriptions.</summary>
|
||||
@@ -52,6 +58,7 @@ internal sealed class SubscriptionRegistry
|
||||
binding.ItemHandle,
|
||||
_ => [subscriptionId],
|
||||
(_, set) => set.Add(subscriptionId));
|
||||
_itemHandleByFullRef[binding.FullReference] = binding.ItemHandle;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +79,14 @@ internal sealed class SubscriptionRegistry
|
||||
// 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 _);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -107,6 +121,24 @@ internal sealed class SubscriptionRegistry
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the live MXAccess item handle a current subscription holds for <paramref name="fullRef"/>,
|
||||
/// 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 (<c>_subscribersByItemHandle</c>) so a stale forward-map entry
|
||||
/// can never hand out a dead handle.
|
||||
/// </summary>
|
||||
/// <param name="fullRef">The dotted tag full reference (e.g. <c>TestMachine_002.TestFloat</c>).</param>
|
||||
/// <returns>The live item handle, or null when none is currently subscribed.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Snapshot every active binding for diagnostic output.</summary>
|
||||
public IReadOnlyList<TagBinding> SnapshotAllBindings() =>
|
||||
[.. _bySubscriptionId.Values.SelectMany(entry => entry.Bindings)];
|
||||
@@ -139,7 +171,14 @@ internal sealed class SubscriptionRegistry
|
||||
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 _);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -151,6 +190,7 @@ internal sealed class SubscriptionRegistry
|
||||
binding.ItemHandle,
|
||||
_ => [subscriptionId],
|
||||
(_, set) => set.Add(subscriptionId));
|
||||
_itemHandleByFullRef[binding.FullReference] = binding.ItemHandle;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user