fix(driver-galaxy): resolve Low code-review findings (Driver.Galaxy-005,010,012,013)
- Driver.Galaxy-005: rewrite the EventPump BoundedChannelOptions comment to honestly describe the Wait+TryWrite pattern. - Driver.Galaxy-010: ResolveApiKey now warns when a literal API key is used in production wiring; added an explicit dev: prefix for known cleartext-in-dev cases and rewrote the GalaxyGatewayOptions doc. - Driver.Galaxy-012: O(1) reverse-lookup for SubscriptionRegistry dispatch via per-entry FullRefByItemHandle map; immutable hash-set for the cross-binding reverse map; SubscribeAsync / ReadViaSubscribeOnce use BuildResultIndex for per-reference correlation. - Driver.Galaxy-013: ReinitializeAsync now validates the incoming JSON against the running options; ReplayOnSessionLost honoured by the Replay path; class summary rewritten to describe the shipped surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,9 +85,14 @@ internal sealed class EventPump : IAsyncDisposable
|
||||
}
|
||||
_channel = Channel.CreateBounded<MxEvent>(new BoundedChannelOptions(channelCapacity)
|
||||
{
|
||||
// Newest-dropped policy: when full, the producer's TryWrite returns false
|
||||
// and we account for the drop. We do this manually rather than relying on
|
||||
// BoundedChannelFullMode.DropWrite so we can count drops without polling.
|
||||
// Newest-dropped semantics: we use FullMode.Wait but never call the
|
||||
// awaitable WriteAsync — only the synchronous TryWrite below in
|
||||
// RunAsync. With Wait + TryWrite, a full channel makes TryWrite return
|
||||
// false immediately, which we account for via the EventsDropped counter.
|
||||
// We deliberately do NOT use BoundedChannelFullMode.DropWrite — that
|
||||
// would silently discard the new event inside Channel<T> without
|
||||
// surfacing the drop on a counter (Driver.Galaxy-005: keep the comment
|
||||
// and the FullMode value consistent).
|
||||
FullMode = BoundedChannelFullMode.Wait,
|
||||
SingleReader = true,
|
||||
SingleWriter = true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
@@ -18,7 +19,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
internal sealed class SubscriptionRegistry
|
||||
{
|
||||
private readonly ConcurrentDictionary<long, SubscriptionEntry> _bySubscriptionId = new();
|
||||
private readonly ConcurrentDictionary<int, ConcurrentBag<long>> _subscribersByItemHandle = new();
|
||||
// Driver.Galaxy-012: use ImmutableHashSet<long> 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<int, ImmutableHashSet<long>> _subscribersByItemHandle = new();
|
||||
private long _nextSubscriptionId;
|
||||
|
||||
public int TrackedSubscriptionCount => _bySubscriptionId.Count;
|
||||
@@ -42,7 +47,7 @@ internal sealed class SubscriptionRegistry
|
||||
_subscribersByItemHandle.AddOrUpdate(
|
||||
binding.ItemHandle,
|
||||
_ => [subscriptionId],
|
||||
(_, bag) => { bag.Add(subscriptionId); return bag; });
|
||||
(_, set) => set.Add(subscriptionId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,12 +62,10 @@ internal sealed class SubscriptionRegistry
|
||||
foreach (var binding in entry.Bindings)
|
||||
{
|
||||
if (binding.ItemHandle <= 0) continue;
|
||||
if (!_subscribersByItemHandle.TryGetValue(binding.ItemHandle, out var bag)) continue;
|
||||
|
||||
// Filter the bag to drop this subscription id. ConcurrentBag has no Remove —
|
||||
// rebuild it from the remaining entries. The contention here is bounded by
|
||||
// the number of tags in the dropped subscription.
|
||||
var remaining = new ConcurrentBag<long>(bag.Where(id => id != subscriptionId));
|
||||
// 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 _);
|
||||
else _subscribersByItemHandle[binding.ItemHandle] = remaining;
|
||||
}
|
||||
@@ -74,18 +77,23 @@ internal sealed class SubscriptionRegistry
|
||||
/// Look up the (subscription id, full reference) pairs that should receive an
|
||||
/// OnDataChange for the given gw item handle. Returns empty when nobody subscribes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Driver.Galaxy-012: O(1) per subscriber via the per-entry
|
||||
/// <c>FullRefByItemHandle</c> index, rather than a <c>FirstOrDefault</c> linear
|
||||
/// scan of the binding list. At 50k tags / 1Hz this turns each dispatch from a
|
||||
/// 50k-element scan into a single dictionary lookup.
|
||||
/// </remarks>
|
||||
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 binding list.
|
||||
// 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;
|
||||
var binding = entry.Bindings.FirstOrDefault(b => b.ItemHandle == itemHandle);
|
||||
if (binding is { FullReference: { } fullRef })
|
||||
if (entry.FullRefByItemHandle.TryGetValue(itemHandle, out var fullRef))
|
||||
result.Add((subId, fullRef));
|
||||
}
|
||||
return result;
|
||||
@@ -113,14 +121,14 @@ internal sealed class SubscriptionRegistry
|
||||
{
|
||||
if (!_bySubscriptionId.TryGetValue(subscriptionId, out var oldEntry)) return;
|
||||
|
||||
// Drop this subscription from every reverse-map bag it currently appears in. The
|
||||
// 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 bag)) continue;
|
||||
|
||||
var remaining = new ConcurrentBag<long>(bag.Where(id => id != subscriptionId));
|
||||
if (!_subscribersByItemHandle.TryGetValue(binding.ItemHandle, out var set)) continue;
|
||||
var remaining = set.Remove(subscriptionId);
|
||||
if (remaining.IsEmpty) _subscribersByItemHandle.TryRemove(binding.ItemHandle, out _);
|
||||
else _subscribersByItemHandle[binding.ItemHandle] = remaining;
|
||||
}
|
||||
@@ -132,11 +140,38 @@ internal sealed class SubscriptionRegistry
|
||||
_subscribersByItemHandle.AddOrUpdate(
|
||||
binding.ItemHandle,
|
||||
_ => [subscriptionId],
|
||||
(_, bag) => { bag.Add(subscriptionId); return bag; });
|
||||
(_, set) => set.Add(subscriptionId));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record SubscriptionEntry(long SubscriptionId, IReadOnlyList<TagBinding> Bindings);
|
||||
/// <summary>
|
||||
/// Per-subscription bookkeeping. <see cref="FullRefByItemHandle"/> is an index
|
||||
/// over <see cref="Bindings"/> keyed by item handle so <c>ResolveSubscribers</c>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private sealed class SubscriptionEntry
|
||||
{
|
||||
public long SubscriptionId { get; }
|
||||
public IReadOnlyList<TagBinding> Bindings { get; }
|
||||
public IReadOnlyDictionary<int, string> FullRefByItemHandle { get; }
|
||||
|
||||
public SubscriptionEntry(long subscriptionId, IReadOnlyList<TagBinding> bindings)
|
||||
{
|
||||
SubscriptionId = subscriptionId;
|
||||
Bindings = bindings;
|
||||
var index = new Dictionary<int, string>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user