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,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user