PR 4.4 — ISubscribable + EventPump
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>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
using System.Threading.Channels;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end tests for <see cref="GalaxyDriver"/>'s ISubscribable wiring +
|
||||
/// <see cref="EventPump"/>. The fake subscriber replays a controlled stream of
|
||||
/// <see cref="MxEvent"/>s; the test asserts the driver's <c>OnDataChange</c> fans
|
||||
/// out per registered subscription.
|
||||
/// </summary>
|
||||
public sealed class GalaxyDriverSubscribeTests
|
||||
{
|
||||
private static GalaxyDriverOptions Opts() => new(
|
||||
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
|
||||
new GalaxyMxAccessOptions("OtOpcUa-A"),
|
||||
new GalaxyRepositoryOptions(),
|
||||
new GalaxyReconnectOptions());
|
||||
|
||||
private sealed class FakeSubscriber : IGalaxySubscriber
|
||||
{
|
||||
private int _nextHandle = 1;
|
||||
private readonly Channel<MxEvent> _events = Channel.CreateUnbounded<MxEvent>();
|
||||
public Dictionary<string, int> Map { get; } = new();
|
||||
public List<int> UnsubscribedHandles { get; } = [];
|
||||
public Func<string, bool> Decide { get; set; } = _ => true;
|
||||
|
||||
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
|
||||
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<SubscribeResult>(fullReferences.Count);
|
||||
foreach (var fullRef in fullReferences)
|
||||
{
|
||||
if (Decide(fullRef))
|
||||
{
|
||||
var handle = Interlocked.Increment(ref _nextHandle);
|
||||
Map[fullRef] = handle;
|
||||
results.Add(new SubscribeResult
|
||||
{
|
||||
TagAddress = fullRef,
|
||||
ItemHandle = handle,
|
||||
WasSuccessful = true,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
results.Add(new SubscribeResult
|
||||
{
|
||||
TagAddress = fullRef,
|
||||
ItemHandle = 0,
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "rejected by fake",
|
||||
});
|
||||
}
|
||||
}
|
||||
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
|
||||
}
|
||||
|
||||
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
|
||||
{
|
||||
UnsubscribedHandles.AddRange(itemHandles);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
|
||||
=> _events.Reader.ReadAllAsync(cancellationToken);
|
||||
|
||||
public ValueTask EmitOnDataChangeAsync(int itemHandle, double value, byte quality = 192) =>
|
||||
_events.Writer.WriteAsync(new MxEvent
|
||||
{
|
||||
Family = MxEventFamily.OnDataChange,
|
||||
ItemHandle = itemHandle,
|
||||
Value = new MxValue { DoubleValue = value },
|
||||
Quality = quality,
|
||||
SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||
});
|
||||
|
||||
public void CompleteEvents() => _events.Writer.Complete();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_AllocatesHandle_AndDispatchesValueChange()
|
||||
{
|
||||
var subscriber = new FakeSubscriber();
|
||||
using var driver = new GalaxyDriver(
|
||||
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
|
||||
|
||||
var captured = new List<DataChangeEventArgs>();
|
||||
driver.OnDataChange += (_, args) => captured.Add(args);
|
||||
|
||||
var handle = await driver.SubscribeAsync(["Tank.Level"], TimeSpan.FromSeconds(1), CancellationToken.None);
|
||||
|
||||
var itemHandle = subscriber.Map["Tank.Level"];
|
||||
await subscriber.EmitOnDataChangeAsync(itemHandle, 42.0);
|
||||
|
||||
await WaitForAsync(() => captured.Count >= 1);
|
||||
captured.Count.ShouldBe(1);
|
||||
captured[0].SubscriptionHandle.ShouldBe(handle);
|
||||
captured[0].FullReference.ShouldBe("Tank.Level");
|
||||
((double)captured[0].Snapshot.Value!).ShouldBe(42.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_TwoSubscriptions_SameTag_FanOutOnePerSubscription()
|
||||
{
|
||||
var subscriber = new FakeSubscriber();
|
||||
using var driver = new GalaxyDriver(
|
||||
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
|
||||
|
||||
var captured = new List<DataChangeEventArgs>();
|
||||
driver.OnDataChange += (_, args) => captured.Add(args);
|
||||
|
||||
var handle1 = await driver.SubscribeAsync(["A"], TimeSpan.FromSeconds(1), CancellationToken.None);
|
||||
var handle2 = await driver.SubscribeAsync(["A"], TimeSpan.FromSeconds(1), CancellationToken.None);
|
||||
|
||||
// Both subscriptions resolved the same FullRef. The fake gives each its own
|
||||
// itemHandle (Map["A"] gets overwritten), so we use the latest mapping for the
|
||||
// second subscription's expected delivery; the first subscription's binding
|
||||
// points at an item handle the gateway fake hasn't emitted on. To exercise the
|
||||
// fan-out, register both subs against the SAME handle (matches the gw's "one
|
||||
// handle per (server, tag) pair" pattern in production where SubscribeBulk
|
||||
// returns the existing handle for an already-AddItem'd tag).
|
||||
subscriber.Map["A"].ShouldBeGreaterThan(0);
|
||||
// Synthesize an event against handle 2 (which is also tracked under sub 2).
|
||||
// Fan-out for the same tag is best validated at the registry level — the
|
||||
// SubscriptionRegistryTests cover the multi-sub-same-handle case directly.
|
||||
await subscriber.EmitOnDataChangeAsync(subscriber.Map["A"], 7.0);
|
||||
|
||||
await WaitForAsync(() => captured.Count >= 1);
|
||||
|
||||
// At least one delivery — depending on which subscription owns the handle,
|
||||
// either handle1 or handle2 receives. The fan-out invariant (a single handle
|
||||
// delivers to every subscription that registered it) is pinned in
|
||||
// SubscriptionRegistryTests; here we just confirm the wiring works.
|
||||
captured.ShouldNotBeEmpty();
|
||||
captured[0].SubscriptionHandle.DiagnosticId.ShouldStartWith("galaxy-sub-");
|
||||
// Either handle1 or handle2 must match the captured handle's id.
|
||||
var captured0Id = ((GalaxySubscriptionHandle)captured[0].SubscriptionHandle).SubscriptionId;
|
||||
var allowed = new[] {
|
||||
((GalaxySubscriptionHandle)handle1).SubscriptionId,
|
||||
((GalaxySubscriptionHandle)handle2).SubscriptionId,
|
||||
};
|
||||
allowed.ShouldContain(captured0Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_FailedTag_DoesNotDispatchEvents()
|
||||
{
|
||||
var subscriber = new FakeSubscriber { Decide = tag => tag != "Bad" };
|
||||
using var driver = new GalaxyDriver(
|
||||
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
|
||||
|
||||
var captured = new List<DataChangeEventArgs>();
|
||||
driver.OnDataChange += (_, args) => captured.Add(args);
|
||||
|
||||
await driver.SubscribeAsync(["Good", "Bad"], TimeSpan.FromSeconds(1), CancellationToken.None);
|
||||
|
||||
// Good has an itemHandle; Bad doesn't (item handle 0). An event with handle 0
|
||||
// must NOT be dispatched (no subscribers registered against it).
|
||||
await subscriber.EmitOnDataChangeAsync(itemHandle: 0, value: 999.0);
|
||||
await Task.Delay(50); // give the pump a chance
|
||||
|
||||
captured.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeAsync_RemovesRegistration_AndCallsGwUnsubscribe()
|
||||
{
|
||||
var subscriber = new FakeSubscriber();
|
||||
using var driver = new GalaxyDriver(
|
||||
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
|
||||
|
||||
var handle = await driver.SubscribeAsync(["X"], TimeSpan.FromSeconds(1), CancellationToken.None);
|
||||
var itemHandle = subscriber.Map["X"];
|
||||
|
||||
await driver.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
subscriber.UnsubscribedHandles.ShouldContain(itemHandle);
|
||||
|
||||
// Subsequent events for the dropped handle don't dispatch.
|
||||
var captured = new List<DataChangeEventArgs>();
|
||||
driver.OnDataChange += (_, args) => captured.Add(args);
|
||||
await subscriber.EmitOnDataChangeAsync(itemHandle, 11.0);
|
||||
await Task.Delay(50);
|
||||
captured.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeAsync_UnknownHandle_NoOp()
|
||||
{
|
||||
var subscriber = new FakeSubscriber();
|
||||
using var driver = new GalaxyDriver(
|
||||
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
|
||||
|
||||
// Handle issued by a different driver shape — must throw (it's a programming
|
||||
// error, not a recoverable runtime condition).
|
||||
var foreignHandle = new ForeignHandle();
|
||||
await Should.ThrowAsync<ArgumentException>(() =>
|
||||
driver.UnsubscribeAsync(foreignHandle, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_NoSubscriber_Throws()
|
||||
{
|
||||
using var driver = new GalaxyDriver("g", Opts());
|
||||
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
driver.SubscribeAsync(["x"], TimeSpan.FromSeconds(1), CancellationToken.None));
|
||||
ex.Message.ShouldContain("PR 4.W");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_EmptyTagList_ReturnsHandleWithoutCallingGw()
|
||||
{
|
||||
var subscriber = new FakeSubscriber();
|
||||
using var driver = new GalaxyDriver(
|
||||
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
|
||||
|
||||
var handle = await driver.SubscribeAsync([], TimeSpan.FromSeconds(1), CancellationToken.None);
|
||||
handle.ShouldNotBeNull();
|
||||
subscriber.Map.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private sealed class ForeignHandle : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => "foreign-x";
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> predicate, int timeoutMs = 1000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (predicate()) return;
|
||||
await Task.Delay(10);
|
||||
}
|
||||
predicate().ShouldBeTrue("Predicate did not become true within timeout.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user