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:
Joseph Doherty
2026-04-29 15:33:27 -04:00
parent a617086da1
commit ce004c80ab
8 changed files with 856 additions and 4 deletions
@@ -0,0 +1,130 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Contracts.Proto;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
/// <summary>
/// Long-running consumer of <see cref="IGalaxySubscriber.StreamEventsAsync"/>. Translates
/// each <see cref="MxEvent"/> with family <see cref="MxEventFamily.OnDataChange"/> into
/// <see cref="DataChangeEventArgs"/> and dispatches one event per registered driver
/// subscription that includes the changed item handle (fan-out via
/// <see cref="SubscriptionRegistry.ResolveSubscribers"/>).
/// </summary>
/// <remarks>
/// One pump per connected <see cref="GalaxyMxSession"/>. Reconnect lives in PR 4.5's
/// supervisor; on transport failure here we log + propagate so the supervisor can
/// decide whether to restart.
/// </remarks>
internal sealed class EventPump : IAsyncDisposable
{
private readonly IGalaxySubscriber _subscriber;
private readonly SubscriptionRegistry _registry;
private readonly ILogger _logger;
private readonly Func<long, ISubscriptionHandle> _handleFactory;
private readonly CancellationTokenSource _cts = new();
private Task? _loop;
private bool _disposed;
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public EventPump(
IGalaxySubscriber subscriber,
SubscriptionRegistry registry,
ILogger? logger = null,
Func<long, ISubscriptionHandle>? handleFactory = null)
{
_subscriber = subscriber ?? throw new ArgumentNullException(nameof(subscriber));
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_logger = logger ?? NullLogger.Instance;
_handleFactory = handleFactory ?? (id => new GalaxySubscriptionHandle(id));
}
/// <summary>
/// Start consuming the event stream on a background task. Idempotent — second
/// calls are no-ops while the loop is running.
/// </summary>
public void Start()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_loop is not null) return;
_loop = Task.Run(() => RunAsync(_cts.Token));
}
private async Task RunAsync(CancellationToken ct)
{
try
{
await foreach (var ev in _subscriber.StreamEventsAsync(ct).WithCancellation(ct).ConfigureAwait(false))
{
if (ct.IsCancellationRequested) break;
Dispatch(ev);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Clean shutdown — no log.
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Galaxy EventPump loop ended with an exception — reconnect supervisor (PR 4.5) handles restart.");
}
}
private void Dispatch(MxEvent ev)
{
// Only OnDataChange events fan out to driver subscriptions today. OnWriteComplete
// / OperationComplete / OnBufferedDataChange are filtered out — write callers get
// their reply via the InvokeAsync round-trip, not via the event stream.
if (ev.Family != MxEventFamily.OnDataChange) return;
var subscribers = _registry.ResolveSubscribers(ev.ItemHandle);
if (subscribers.Count == 0) return; // stale event after unsubscribe — drop quietly
var snapshot = ToSnapshot(ev);
foreach (var (subscriptionId, fullReference) in subscribers)
{
var handle = _handleFactory(subscriptionId);
try
{
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, fullReference, snapshot));
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Galaxy OnDataChange handler threw for {FullRef} subscription {SubscriptionId} — continuing fan-out.",
fullReference, subscriptionId);
}
}
}
private DataValueSnapshot ToSnapshot(MxEvent ev)
{
var value = MxValueDecoder.Decode(ev.Value);
var statusCode = ev.Statuses.Count > 0
? StatusCodeMap.FromMxStatus(ev.Statuses[0], _logger)
: StatusCodeMap.FromQualityByte((byte)(ev.Quality & 0xFF), _logger);
DateTime? sourceTimestamp = ev.SourceTimestamp is { } ts ? ts.ToDateTime() : null;
return new DataValueSnapshot(
Value: value,
StatusCode: statusCode,
SourceTimestampUtc: sourceTimestamp,
ServerTimestampUtc: DateTime.UtcNow);
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
_cts.Cancel();
if (_loop is not null)
{
try { await _loop.ConfigureAwait(false); } catch { /* shutdown */ }
}
_cts.Dispose();
}
}