Subscription path online. GalaxyDriver implements ISubscribable; subscribes batches via gw SubscribeBulkAsync, runs a single shared EventPump consumer of StreamEventsAsync, fans out OnDataChange events to every driver subscription that observes the changed gw item handle. Files: - Runtime/GalaxySubscriptionHandle.cs — record implementing ISubscriptionHandle. - Runtime/SubscriptionRegistry.cs — bookkeeping with forward (subscriptionId → bindings) and reverse (itemHandle → list of subscriptionIds) maps. The reverse map is the fan-out index so a single OnDataChange dispatches to every subscription that observes the changed handle. - Runtime/IGalaxySubscriber.cs — driver-side seam: SubscribeBulk + UnsubscribeBulk + StreamEventsAsync. Production wraps GalaxyMxSession; tests substitute a fake driving synthetic MxEvents. - Runtime/GatewayGalaxySubscriber.cs — production. Forwards to MxGatewaySession; bufferedUpdateIntervalMs is captured for now and becomes a SetBufferedUpdateInterval call once gw issue #102 / gw-9 lands (PR 6.3 picks this up). - Runtime/EventPump.cs — long-running background consumer of StreamEventsAsync. Decodes MxValue + maps quality byte/MxStatusProxy via StatusCodeMap. Fan-out per subscriber resolves through the registry; bad handler exceptions are caught + logged, never break the dispatch loop. Filters out non-OnDataChange families (write-complete and operation- complete come back via InvokeAsync's reply path, not the event stream). GalaxyDriver: - Adds ISubscribable. SubscribeAsync allocates a subscription id, SubscribeBulks, builds the binding list (failed gw entries get ItemHandle=0 + a per-tag warn log), registers, and returns the handle. EventPump is started lazily on first subscribe; one pump per driver shared across all subscriptions. - UnsubscribeAsync removes from the registry first (so stale events are filtered immediately) then calls UnsubscribeBulk best-effort. Foreign handles throw ArgumentException. - ReadAsync NotSupportedException message updated: PR 4.4 no longer the pointer (deferred to a small follow-up that wraps the pump as a one-shot reader). - Dispose tears down the pump first, then the repository client, then clears state. - Internal ctor extended with optional subscriber parameter. Tests (15 new, 109 Galaxy total): - SubscriptionRegistryTests: monotonic id allocation, single+multi subscription fan-out, failed-handle exclusion, removal isolation, count invariants. - GalaxyDriverSubscribeTests: handle allocation + value-change dispatch, multi-subscription fan-out, failed-tag silence, unsubscribe drops gw handle and stops dispatch, foreign handle throws, no-subscriber throws, empty-tag-list returns handle without calling gw. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
138 lines
5.0 KiB
C#
138 lines
5.0 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="SubscriptionRegistry"/> — the bookkeeping the EventPump
|
|
/// uses to fan one OnDataChange event out to every driver subscription that
|
|
/// observes the changed item handle.
|
|
/// </summary>
|
|
public sealed class SubscriptionRegistryTests
|
|
{
|
|
[Fact]
|
|
public void NextSubscriptionId_IsMonotonic()
|
|
{
|
|
var registry = new SubscriptionRegistryAccess();
|
|
registry.NextSubscriptionId().ShouldBe(1);
|
|
registry.NextSubscriptionId().ShouldBe(2);
|
|
registry.NextSubscriptionId().ShouldBe(3);
|
|
}
|
|
|
|
[Fact]
|
|
public void Register_OneSubscription_OneTag_ResolvesSingleSubscriber()
|
|
{
|
|
var registry = new SubscriptionRegistryAccess();
|
|
registry.Register(42, [new TagBindingAccess("Tank.Level", 100)]);
|
|
|
|
var subs = registry.ResolveSubscribers(100);
|
|
subs.Count.ShouldBe(1);
|
|
subs[0].SubscriptionId.ShouldBe(42);
|
|
subs[0].FullReference.ShouldBe("Tank.Level");
|
|
}
|
|
|
|
[Fact]
|
|
public void Register_TwoSubscriptions_SameTag_FanOutToBoth()
|
|
{
|
|
var registry = new SubscriptionRegistryAccess();
|
|
registry.Register(1, [new TagBindingAccess("Tank.Level", 100)]);
|
|
registry.Register(2, [new TagBindingAccess("Tank.Level", 100)]);
|
|
|
|
var subs = registry.ResolveSubscribers(100);
|
|
subs.Count.ShouldBe(2);
|
|
subs.Select(s => s.SubscriptionId).OrderBy(x => x).ShouldBe(new[] { 1L, 2L });
|
|
}
|
|
|
|
[Fact]
|
|
public void Register_FailedItemHandle_NotIndexedForFanOut()
|
|
{
|
|
var registry = new SubscriptionRegistryAccess();
|
|
registry.Register(1, [
|
|
new TagBindingAccess("Good", 100),
|
|
new TagBindingAccess("Bad", 0), // gw rejected this tag
|
|
]);
|
|
|
|
registry.ResolveSubscribers(100).Count.ShouldBe(1);
|
|
registry.ResolveSubscribers(0).ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Remove_DropsAllBindings_AndReturnsThemForUnsubscribe()
|
|
{
|
|
var registry = new SubscriptionRegistryAccess();
|
|
registry.Register(1, [
|
|
new TagBindingAccess("A", 100),
|
|
new TagBindingAccess("B", 200),
|
|
]);
|
|
|
|
var removed = registry.Remove(1);
|
|
|
|
removed.ShouldNotBeNull();
|
|
removed!.Count.ShouldBe(2);
|
|
registry.ResolveSubscribers(100).ShouldBeEmpty();
|
|
registry.ResolveSubscribers(200).ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Remove_OneOfTwoSubscriptions_LeavesOtherIntact()
|
|
{
|
|
var registry = new SubscriptionRegistryAccess();
|
|
registry.Register(1, [new TagBindingAccess("A", 100)]);
|
|
registry.Register(2, [new TagBindingAccess("A", 100)]);
|
|
|
|
registry.Remove(1);
|
|
|
|
var subs = registry.ResolveSubscribers(100);
|
|
subs.Count.ShouldBe(1);
|
|
subs[0].SubscriptionId.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public void Remove_UnknownSubscription_IsNullSentinel()
|
|
{
|
|
var registry = new SubscriptionRegistryAccess();
|
|
registry.Remove(999).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void TrackedCounts_ReflectAdditionsAndRemovals()
|
|
{
|
|
var registry = new SubscriptionRegistryAccess();
|
|
registry.TrackedSubscriptionCount.ShouldBe(0);
|
|
|
|
registry.Register(1, [new TagBindingAccess("A", 100)]);
|
|
registry.Register(2, [new TagBindingAccess("A", 100), new TagBindingAccess("B", 200)]);
|
|
registry.TrackedSubscriptionCount.ShouldBe(2);
|
|
registry.TrackedItemHandleCount.ShouldBe(2);
|
|
|
|
registry.Remove(1);
|
|
registry.TrackedSubscriptionCount.ShouldBe(1);
|
|
registry.TrackedItemHandleCount.ShouldBe(2); // sub 2 still observes both handles
|
|
|
|
registry.Remove(2);
|
|
registry.TrackedSubscriptionCount.ShouldBe(0);
|
|
registry.TrackedItemHandleCount.ShouldBe(0);
|
|
}
|
|
|
|
// Internal types are accessed via friend assembly (InternalsVisibleTo); these
|
|
// wrapper aliases keep the test code readable.
|
|
private sealed class SubscriptionRegistryAccess
|
|
{
|
|
private readonly SubscriptionRegistry _inner = new();
|
|
public int TrackedSubscriptionCount => _inner.TrackedSubscriptionCount;
|
|
public int TrackedItemHandleCount => _inner.TrackedItemHandleCount;
|
|
public long NextSubscriptionId() => _inner.NextSubscriptionId();
|
|
public void Register(long id, IReadOnlyList<TagBindingAccess> bindings)
|
|
=> _inner.Register(id, [.. bindings.Select(b => new TagBinding(b.FullReference, b.ItemHandle))]);
|
|
public IReadOnlyList<TagBindingAccess>? Remove(long id)
|
|
{
|
|
var removed = _inner.Remove(id);
|
|
return removed is null ? null : [.. removed.Select(b => new TagBindingAccess(b.FullReference, b.ItemHandle))];
|
|
}
|
|
public IReadOnlyList<(long SubscriptionId, string FullReference)> ResolveSubscribers(int handle)
|
|
=> _inner.ResolveSubscribers(handle);
|
|
}
|
|
private sealed record TagBindingAccess(string FullReference, int ItemHandle);
|
|
}
|